pax_global_header00006660000000000000000000000064150274160140014512gustar00rootroot0000000000000052 comment=095542dd0f5fc68e7ebc7063755500c725760d33 tr4nt0r-pynecil-9a12339/000077500000000000000000000000001502741601400147455ustar00rootroot00000000000000tr4nt0r-pynecil-9a12339/.cruft.json000066400000000000000000000017761502741601400170540ustar00rootroot00000000000000{ "template": "https://github.com/frankie567/cookiecutter-hipster-pypackage", "commit": "8d08540791a735c0668f08b8025719728c7677f9", "checkout": null, "context": { "cookiecutter": { "full_name": "Manfred Dennerlein Rodelo", "email": "manfred@dennerlein.name", "github_username": "tr4nt0r", "project_name": "Pynecil", "dist_name": "pynecil", "package_name": "pynecil", "project_short_description": "Python library to communicate with Pinecil V2 soldering irons via Bluetooth", "repository_name": "tr4nt0r/pynecil", "repository_url": "https://github.com/tr4nt0r/pynecil", "docs_url": "https://tr4nt0r.github.io/pynecil/", "open_source_license": "MIT license", "python_version": "3.12", "asyncio": "Y", "docs_icon": "material/library", "docs_color_primary": "purple", "docs_color_accent": "light blue", "_template": "https://github.com/frankie567/cookiecutter-hipster-pypackage" } }, "directory": null } tr4nt0r-pynecil-9a12339/.editorconfig000066400000000000000000000002561502741601400174250ustar00rootroot00000000000000# http://editorconfig.org root = true [*] indent_style = space indent_size = 4 trim_trailing_whitespace = true insert_final_newline = true charset = utf-8 end_of_line = lf tr4nt0r-pynecil-9a12339/.github/000077500000000000000000000000001502741601400163055ustar00rootroot00000000000000tr4nt0r-pynecil-9a12339/.github/FUNDING.yml000066400000000000000000000000531502741601400201200ustar00rootroot00000000000000github: [tr4nt0r] buy_me_a_coffee: tr4nt0r tr4nt0r-pynecil-9a12339/.github/ISSUE_TEMPLATE/000077500000000000000000000000001502741601400204705ustar00rootroot00000000000000tr4nt0r-pynecil-9a12339/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000015021502741601400231600ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser [e.g. stock browser, safari] - Version [e.g. 22] **Additional context** Add any other context about the problem here. tr4nt0r-pynecil-9a12339/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000011231502741601400242120ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. tr4nt0r-pynecil-9a12339/.github/dependabot.yml000066400000000000000000000005431502741601400211370ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "pip" directory: "/" schedule: interval: "weekly" labels: - ":recycle: dependencies" - ":snake: python" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" labels: - ":recycle: dependencies" - ":clapper: github_actions" tr4nt0r-pynecil-9a12339/.github/labels.yml000066400000000000000000000053111502741601400202720ustar00rootroot00000000000000--- # Labels names are important as they are used by Release Drafter to decide # regarding where to record them in changelog or if to skip them. # # The repository labels will be automatically configured using this file and # the GitHub Action https://github.com/marketplace/actions/github-labeler. - name: ':boom: breaking change' from_name: breaking description: Breaking Changes color: bfd4f2 - name: ':ghost: bug' from_name: bug description: Something isn't working color: d73a4a - name: ':building_construction: build' from_name: build description: Build System and Dependencies color: bfdadc - name: ':construction_worker_woman: ci' from_name: ci description: Continuous Integration color: 4a97d6 - name: ':recycle: dependencies' from_name: dependencies description: Pull requests that update a dependency file color: 0366d6 - name: ':book: documentation' from_name: documentation description: Improvements or additions to documentation color: 0075ca - name: ':roll_eyes: duplicate' from_name: duplicate description: This issue or pull request already exists color: cfd3d7 - name: ':rocket: feature' from_name: enhancement description: New feature or request color: a2eeef - name: ':clapper: github_actions' from_name: github_actions description: Pull requests that update Github_actions code color: '000000' - name: ':hatching_chick: good first issue' from_name: good first issue description: Good for newcomers color: 7057ff - name: ':pray: help wanted' from_name: help wanted description: Extra attention is needed color: '008672' - name: ':no_entry_sign: invalid' from_name: invalid description: This doesn't seem right color: e4e669 - name: ':racing_car: performance' from_name: performance description: Performance color: '016175' - name: ':snake: python' from_name: python description: Pull requests that update Python code color: 2b67c6 - name: ':question: question' from_name: question description: Further information is requested color: d876e3 - name: ':sparkles: code quality' from_name: code quality description: Code quality improvements color: ef67c4 - name: ':file_cabinet: deprecation' from_name: deprecation description: Removals and Deprecations color: 9ae7ea - name: ':nail_care: style' from_name: style description: Style color: c120e5 - name: ':test_tube: testing' from_name: testing description: Pull request that adds tests color: b1fc6f - name: ':woman_shrugging: wontfix' from_name: wontfix description: This will not be worked on color: ffffff - name: ':arrow_up: bump' description: Bump the version color: 3C5D34 - name: ':sparkles: enhancement' color: CBF8DA - name: 'skip-changelog' color: D3D3D3 tr4nt0r-pynecil-9a12339/.github/release-drafter.yml000066400000000000000000000037701502741601400221040ustar00rootroot00000000000000name-template: 'v$RESOLVED_VERSION' tag-template: 'v$RESOLVED_VERSION' categories: - title: '💥 Breaking changes' labels: - ':boom: breaking change' - title: '🚀 New Features' labels: - ':rocket: feature' - title: '👻 Bug Fixes' labels: - ':ghost: bug' - title: '🗄️ Deprecations' labels: - ':file_cabinet: deprecation' - title: '📃 Documentation' labels: - ':book: documentation' - title: '🧰 Maintenance' labels: - ':building_construction: build' - ':construction_worker_woman: ci' - ':clapper: github_actions' collapse-after: 5 - title: '🔬 Other updates' labels: - ':nail_care: style' - ':test_tube: testing' - ':racing_car: performance' - ':sparkles: code quality' - ':sparkles: enhancement' - title: '🧩 Dependency Updates' labels: - ':recycle: dependencies' collapse-after: 5 exclude-labels: - ':arrow_up: bump' - 'skip-changelog' autolabeler: - label: ':rocket: feature' title: - '/adds/i' - '/add method/i' - label: ':ghost: bug' title: - '/fix/i' - label: ':sparkles: code quality' title: - '/Refactor/i' - label: ':test_tube: testing' files: - 'test_*' - 'conftest.py' - label: ':book: documentation' title: - '/docs:/i' files: - '*.md' - 'mkdocs.yml' - label: ':construction_worker_woman: ci' files: - '.github/*' - label: ':recycle: dependencies' title: - '/bump/i' - label: ':file_cabinet: deprecation' title: - '/Deprecate/i' change-template: '- $TITLE @$AUTHOR (#$NUMBER)' change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. exclude-contributors: - 'dependabot' version-resolver: major: labels: - ':boom: breaking change' minor: labels: - ':rocket: feature' default: patch template: | ## What's Changed $CHANGES Contributors: $CONTRIBUTORS tr4nt0r-pynecil-9a12339/.github/workflows/000077500000000000000000000000001502741601400203425ustar00rootroot00000000000000tr4nt0r-pynecil-9a12339/.github/workflows/build.yml000066400000000000000000000047201502741601400221670ustar00rootroot00000000000000name: Build on: [push, pull_request] jobs: test: runs-on: ubuntu-latest strategy: matrix: python_version: ['3.12'] steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python_version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install hatch hatch env create - name: Lint and typecheck run: | hatch run lint-check - name: Test run: | hatch run test-cov-xml - uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true verbose: true release: runs-on: ubuntu-latest environment: release needs: test if: startsWith(github.ref, 'refs/tags/') permissions: contents: write id-token: write steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.13' - name: Install dependencies shell: bash run: | python -m pip install --upgrade pip pip install hatch - name: mint API token id: mint-token run: | # retrieve the ambient OIDC token resp=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=pypi") oidc_token=$(jq -r '.value' <<< "${resp}") # exchange the OIDC token for an API token resp=$(curl -X POST https://pypi.org/_/oidc/mint-token -d "{\"token\": \"${oidc_token}\"}") api_token=$(jq -r '.token' <<< "${resp}") # mask the newly minted API token, so that we don't accidentally leak it echo "::add-mask::${api_token}" # see the next step in the workflow for an example of using this step output echo "api-token=${api_token}" >> "${GITHUB_OUTPUT}" - name: Build and publish on PyPI env: HATCH_INDEX_USER: __token__ HATCH_INDEX_AUTH: ${{ steps.mint-token.outputs.api-token }} run: | hatch build hatch publish - name: Create release uses: ncipollo/release-action@v1 with: draft: true body: ${{ github.event.head_commit.message }} allowUpdates: true omitBodyDuringUpdate: true updateOnlyUnreleased: true artifacts: dist/*.whl,dist/*.tar.gz token: ${{ secrets.GITHUB_TOKEN }} tr4nt0r-pynecil-9a12339/.github/workflows/documentation.yml000066400000000000000000000021111502741601400237310ustar00rootroot00000000000000name: Build documentation on: push: branches: - main # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read pages: write id-token: write # Allow one concurrent deployment concurrency: group: "pages" cancel-in-progress: true # Default to bash defaults: run: shell: bash jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.13' - name: Install dependencies run: | python -m pip install --upgrade pip pip install hatch hatch env create - name: Build run: hatch run docs-build - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: ./site deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 tr4nt0r-pynecil-9a12339/.github/workflows/draft.yml000066400000000000000000000011221502741601400221610ustar00rootroot00000000000000name: Release Drafter on: push: branches: - main # pull_request event is required only for autolabeler pull_request: types: [opened, reopened, synchronize] pull_request_target: types: [opened, reopened, synchronize] permissions: contents: read jobs: update-draft: runs-on: ubuntu-latest permissions: contents: write pull-requests: write steps: # Drafts your next Release notes as Pull Requests are merged into "main" - uses: release-drafter/release-drafter@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} tr4nt0r-pynecil-9a12339/.github/workflows/labeler.yml000066400000000000000000000006011502741601400224700ustar00rootroot00000000000000name: Labeler on: workflow_dispatch permissions: actions: read contents: read security-events: write pull-requests: write jobs: labeler: runs-on: ubuntu-latest steps: - name: Check out the repository uses: actions/checkout@v4 - name: Run Labeler uses: crazy-max/ghaction-github-labeler@v5.3.0 with: skip-delete: true tr4nt0r-pynecil-9a12339/.gitignore000066400000000000000000000023031502741601400167330ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ junit/ junit.xml test.db # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # OS files .DS_Store tr4nt0r-pynecil-9a12339/.vscode/000077500000000000000000000000001502741601400163065ustar00rootroot00000000000000tr4nt0r-pynecil-9a12339/.vscode/settings.json000066400000000000000000000016761502741601400210530ustar00rootroot00000000000000{ "python.analysis.typeCheckingMode": "basic", "python.analysis.autoImportCompletions": true, "python.terminal.activateEnvironment": true, "python.terminal.activateEnvInCurrentTerminal": true, "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "editor.rulers": [88], "python.defaultInterpreterPath": "${workspaceFolder}/.hatch/pynecil/bin/python", "python.testing.pytestPath": "${workspaceFolder}/.hatch/pynecil/bin/pytest", "python.testing.cwd": "${workspaceFolder}", "python.testing.pytestArgs": ["--no-cov"], "[python]": { "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll": "explicit", "source.organizeImports": "explicit" }, "editor.defaultFormatter": "charliermarsh.ruff" }, "autoDocstring.docstringFormat": "numpy", "editor.defaultFormatter": "charliermarsh.ruff", "ruff.lint.preview": true } tr4nt0r-pynecil-9a12339/CONTRIBUTING.md000066400000000000000000000062251502741601400172030ustar00rootroot00000000000000# Contributing Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. You can contribute in many ways: ## Types of Contributions ### Report Bugs Report bugs at . If you are reporting a bug, please include: - Your operating system name and version. - Any details about your local setup that might be helpful in troubleshooting. - Detailed steps to reproduce the bug. ### Fix Bugs Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it. ### Implement Features Look through the GitHub issues for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it. ### Write Documentation Pynecil could always use more documentation, whether as part of the official Pynecil docs, in docstrings, or even on the web in blog posts, articles, and such. ### Submit Feedback The best way to send feedback is to file an issue at . If you are proposing a feature: - Explain in detail how it would work. - Keep the scope as narrow as possible, to make it easier to implement. - Remember that this is a volunteer-driven project, and that contributions are welcome :) ## Development Ready to contribute? Here's how to set up `Pynecil` for local development. ### Setup Git [Fork](https://github.com/tr4nt0r/pynecil/fork) the `Pynecil` repo on GitHub. Clone your fork locally: ```bash $ git clone git@github.com:yourusername/pynecil.git ``` Create a branch for local development: ``` bash $ git checkout -b name-of-your-bugfix-or-feature ``` Now you can make your changes locally. ### Setup environment We use [Hatch](https://hatch.pypa.io/latest/install/) to manage the development environment and production build. Ensure it's installed on your system. ### Run unit tests You can run all the tests with: ```bash hatch run test ``` ### Format the code Execute the following command to apply linting and check typing: ```bash hatch run lint ``` ### Publish a new version You can bump the version, create a commit and associated tag with one command: ```bash hatch version patch ``` ```bash hatch version minor ``` ```bash hatch version major ``` Your default Git text editor will open so you can add information about the release. When you push the tag on GitHub, the workflow will automatically publish it on PyPi and a GitHub release will be created as draft. ## Pull Request Guidelines Before you submit a pull request, check that it meets these guidelines: 1. The pull request should include tests. 2. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the feature to the list in README.md. 3. The pull request should work for Python 3.11 and 3.12. Check and make sure that the tests pass for all supported Python versions. ## Serve the documentation You can serve the Mkdocs documentation with: ```bash hatch run docs-serve ``` tr4nt0r-pynecil-9a12339/LICENSE000066400000000000000000000020741502741601400157550ustar00rootroot00000000000000MIT License Copyright (c) 2024, Manfred Dennerlein Rodelo Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. tr4nt0r-pynecil-9a12339/README.md000066400000000000000000000037361502741601400162350ustar00rootroot00000000000000# Pynecil Python library to communicate with Pinecil V2 soldering irons via Bluetooth [![build](https://github.com/tr4nt0r/pynecil/workflows/Build/badge.svg)](https://github.com/tr4nt0r/pynecil/actions) [![codecov](https://codecov.io/gh/tr4nt0r/pynecil/graph/badge.svg?token=RM3MC4LP07)](https://codecov.io/gh/tr4nt0r/pynecil) [![PyPI version](https://badge.fury.io/py/pynecil.svg)](https://badge.fury.io/py/pynecil) [!["Buy Me A Coffee"](https://img.shields.io/badge/-buy_me_a%C2%A0coffee-gray?logo=buy-me-a-coffee)](https://www.buymeacoffee.com/tr4nt0r) [![GitHub Sponsor](https://img.shields.io/badge/GitHub-Sponsor-blue?logo=github)](https://github.com/sponsors/tr4nt0r) --- ## 📖 Documentation - **Full Documentation**: [https://tr4nt0r.github.io/pynecil](https://tr4nt0r.github.io/pynecil) - **Source Code**: [ttps://github.com/tr4nt0r/pynecil](ttps://github.com/tr4nt0r/pynecil) --- ## 📦 Installation You can install Pynecil via pip: ```sh pip install pynecil ``` ## 🚀 Usage ### Basic Example ```python import asyncio from pynecil import CharSetting, discover, Pynecil async def main(): device = await discover() client = Pynecil(device) device_info = await client.get_device_info() live_data = await client.get_live_data() await client.write(CharSetting.SETPOINT_TEMP, 350) asyncio.run(main()) ``` For more advanced usage, refer to the [documentation](https://tr4nt0r.github.io/pynecil). --- ## 🛠 Contributing Contributions are welcome! To contribute: 1. Fork the repository. 2. Create a new branch. 3. Make your changes and commit them. 4. Submit a pull request. Make sure to follow the [contributing guidelines](CONTRIBUTING.md). --- ## 📜 License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. --- ## ❤️ Support If you find this project useful, consider [buying me a coffee ☕](https://www.buymeacoffee.com/tr4nt0r) or [sponsoring me on GitHub](https://github.com/sponsors/tr4nt0r)! tr4nt0r-pynecil-9a12339/docs/000077500000000000000000000000001502741601400156755ustar00rootroot00000000000000tr4nt0r-pynecil-9a12339/docs/contributing.md000066400000000000000000000000311502741601400207200ustar00rootroot00000000000000--8<-- "CONTRIBUTING.md" tr4nt0r-pynecil-9a12339/docs/index.md000066400000000000000000000000231502741601400173210ustar00rootroot00000000000000--8<-- "README.md" tr4nt0r-pynecil-9a12339/docs/reference/000077500000000000000000000000001502741601400176335ustar00rootroot00000000000000tr4nt0r-pynecil-9a12339/docs/reference/pynecil.md000066400000000000000000000001361502741601400216200ustar00rootroot00000000000000# Reference ::: pynecil options: show_root_heading: false show_source: false tr4nt0r-pynecil-9a12339/mkdocs.yml000066400000000000000000000033311502741601400167500ustar00rootroot00000000000000site_name: Pynecil site_description: Python library to communicate with Pinecil V2 soldering irons via Bluetooth repo_url: https://github.com/tr4nt0r/pynecil repo_name: tr4nt0r/pynecil theme: name: material icon: logo: material/library palette: # Palette toggle for automatic mode - media: "(prefers-color-scheme)" toggle: icon: material/brightness-auto name: Switch to light mode # Palette toggle for light mode - media: "(prefers-color-scheme: light)" scheme: default primary: purple accent: light blue toggle: icon: material/brightness-7 name: Switch to dark mode # Palette toggle for dark mode - media: "(prefers-color-scheme: dark)" scheme: slate primary: purple accent: light blue toggle: icon: material/brightness-4 name: Switch to light mode markdown_extensions: - toc: permalink: true - pymdownx.highlight: anchor_linenums: true - pymdownx.tasklist: custom_checkbox: true - pymdownx.inlinehilite - pymdownx.snippets - pymdownx.superfences plugins: - search - mkdocstrings: handlers: python: import: - https://docs.python.org/3.11/objects.inv options: docstring_style: numpy merge_init_into_class: true show_signature: false members_order: source watch: - docs - pynecil nav: - About: index.md - Contributing: contributing.md - Reference: - pynecil: reference/pynecil.md tr4nt0r-pynecil-9a12339/pynecil/000077500000000000000000000000001502741601400164105ustar00rootroot00000000000000tr4nt0r-pynecil-9a12339/pynecil/__init__.py000066400000000000000000000022031502741601400205160ustar00rootroot00000000000000"""Pynecil - Python library to communicate with Pinecil V2 soldering irons via Bluetooth.""" __version__ = "4.1.1" from .client import Pynecil, discover from .exceptions import CommunicationError, UpdateException from .types import ( AnimationSpeed, AutostartMode, BatteryType, CharBulk, CharLive, CharSetting, DeviceInfoResponse, LanguageCode, LiveDataResponse, LockingMode, LogoDuration, OperatingMode, PowerSource, ScreenOrientationMode, ScrollSpeed, SettingsDataResponse, TempUnit, TipType, USBPDMode, ) from .update import IronOSUpdate, LatestRelease __all__ = [ "AnimationSpeed", "AutostartMode", "BatteryType", "CharBulk", "CharLive", "CharSetting", "CommunicationError", "DeviceInfoResponse", "discover", "IronOSUpdate", "LanguageCode", "LatestRelease", "LiveDataResponse", "LockingMode", "LogoDuration", "OperatingMode", "PowerSource", "Pynecil", "ScreenOrientationMode", "ScrollSpeed", "SettingsDataResponse", "TempUnit", "TipType", "UpdateException", "USBPDMode", ] tr4nt0r-pynecil-9a12339/pynecil/client.py000066400000000000000000000615641502741601400202540ustar00rootroot00000000000000"""Pynecil - Python library to communicate with Pinecil V2 soldering irons via Bluetooth.""" from __future__ import annotations import asyncio import hashlib import logging import struct from math import floor from typing import TYPE_CHECKING, Any, cast from bleak import BleakClient, BleakScanner from bleak.backends.device import BLEDevice from bleak.exc import BleakError from . import const from .exceptions import CommunicationError from .types import ( AnimationSpeed, AutostartMode, BatteryType, Characteristic, CharBulk, CharLive, CharSetting, DeviceInfoResponse, LanguageCode, LiveDataResponse, LockingMode, LogoDuration, OperatingMode, PowerSource, ScreenOrientationMode, ScrollSpeed, SettingsDataResponse, TempUnit, TipType, USBPDMode, ) if TYPE_CHECKING: from collections.abc import Callable _LOGGER = logging.getLogger(__package__) async def discover(timeout: float = 10) -> BLEDevice | None: """Discover Pinecil device. Parameters ---------- timeout : float, optional Timeout to wait for detection before giving up, by default 10 Returns ------- BLEDevice | None The BLEDevice of a Pinecil or None if not detected before the timeout. """ return await BleakScanner().find_device_by_filter( filterfunc=lambda _, advertisement: bool( str(const.SVC_UUID_BULK) in advertisement.service_uuids ), timeout=timeout, ) class Pynecil: """Client for Pinecil V2 Devices. Attributes ---------- client_disconnected : bool Flag indicating whether the client has experienced an unexpected disconnect. Defaults to False. """ client_disconnected: bool = False def __init__( self, address_or_ble_device: BLEDevice | str, disconnected_callback: Callable[[BleakClient], None] | None = None, ) -> None: """Initialize a Pynecil client. Parameters ---------- address_or_ble_device : BLEDevice | str Bluetooth address of the Pinecil V2 device to connect to or BLEDevice object, received from a BleakScanner, representing it. disconnected_callback : Callable[[BleakClient], None] | None, optional Callback passed to BleakClient that will be scheduled in the event loop when the client is disconnected. The callable must take one argument, which will be the client object. Defaults to None. Notes ----- If `address_or_ble_device` is a BLEDevice object, `device_info` will be initialized with its `name` and `address`. Otherwise, `device_info` will only have an `address` initialized. Callbacks for disconnection events can be specified via `disconnected_callback`. If not provided, a default callback will set `client_disconnected` to True upon disconnection. """ if isinstance(address_or_ble_device, BLEDevice): self.device_info = DeviceInfoResponse( name=address_or_ble_device.name, address=address_or_ble_device.address ) else: self.device_info = DeviceInfoResponse(address=address_or_ble_device) def _disconnected_callback(client: BleakClient) -> None: _LOGGER.debug("Disconnected from %s", client.address) self.client_disconnected = True self.device_info.is_synced = False self._client = BleakClient( address_or_ble_device, disconnected_callback=disconnected_callback or _disconnected_callback, ) @property def is_connected(self) -> bool: """Check if the client is connected. Returns ------- bool `True` if the client is connected, `False` otherwise. """ return self._client.is_connected async def connect(self) -> None: """Establish or re-establish a connection to the device. This method ensures a connection to the device is established, handling both initial connections and reconnections if an unexpected disconnection occurred. If the device is not connected and there was a previous unexpected disconnect, it closes the stale connection before attempting to reconnect. Notes ----- - If `self._client.is_connected` is `True`, the method returns without performing any connection operations. - If `self.client_disconnected` is `True`, indicating a previous unexpected disconnection, the method first closes the stale connection by calling `disconnect()` before attempting to establish a new connection. Raises ------ BleakError If an error occurs during the connection attempt. """ if not self._client.is_connected: if self.client_disconnected: # close stale connection await self.disconnect() await self._client.connect() async def disconnect(self) -> None: """Disconnect from the Pinecil device.""" await self._client.disconnect() self.client_disconnected = False async def get_device_info(self) -> DeviceInfoResponse: """Retrieve device information from the Pinecil V2 device. This method fetches details such as `name`, `address`, `build`, `device_sn`, and `device_id` of the connected Pinecil V2 device. Returns ------- DeviceInfoResponse An object containing the retrieved device information. Raises ------ CommunicationError If an error occurred while connecting and retrieving data from device. Notes ----- If `self.device_info.is_synced` is `True`, it returns the cached `device_info` without performing a new data fetch. """ if self.device_info.is_synced: return self.device_info self.device_info.build = await self.read(CharBulk.BUILD) self.device_info.device_sn = await self.read(CharBulk.DEVICE_SN) self.device_info.device_id = await self.read(CharBulk.DEVICE_ID) self.device_info.is_synced = True return self.device_info async def get_live_data(self) -> LiveDataResponse: """Fetch live sensor data from the device. Returns ------- LiveDataResponse An object containing 14 sensor values retrieved from the bulk live data characteristic. Raises ------ CommunicationError If an error occurred while connecting and retrieving data from device. """ return await self.read(CharBulk.LIVE_DATA) async def get_settings( self, settings: list[CharSetting | int] | None = None ) -> SettingsDataResponse: """Fetch settings data from the device. Parameters ---------- settings : list[CharSetting | int] | None, optional List of settings identified by a `CharSetting` item or their ID that should be retrieved from the device. Retrieves all settings if ommited. Returns ------- SettingsDataResponse A dictionary containing lowercase names and normalized values of the settings retrieved from the device. Raises ------ CommunicationError If an error occurred while connecting and retrieving data from device. Notes ----- This method asynchronously reads data from the device for each specified setting, filtering based on the provided `settings` list. """ if settings is None: settings = [] tasks = [ (characteristic.name.lower(), self.read(characteristic)) for characteristic in CHAR_MAP if isinstance(characteristic, CharSetting) and ( not settings or characteristic in settings or characteristic.value in settings ) ] results = await asyncio.gather(*(task[1] for task in tasks)) return cast( SettingsDataResponse, {key: value for (key, _), value in zip(tasks, results)}, ) async def read(self, characteristic: Characteristic) -> Any: """Read specified characteristic and decode the result. Parameters ---------- characteristic : Characteristic The characteristic to retrieve from the device. Returns ------- Any The value read from the characteristic and parsed with the corresponding decoder. Raises ------ CommunicationError If an error occurred while connecting and retrieving data from device. """ uuid, decode, *_ = CHAR_MAP.get(characteristic, (None, None)) if not (decode and uuid): return None try: await self.connect() result = await self._client.read_gatt_char(uuid) _LOGGER.debug( "Read characteristic %s, result: %s", str(uuid), decode(result) ) except (BleakError, TimeoutError) as e: _LOGGER.debug("Failed to read characteristic %s: %s", str(uuid), e) raise CommunicationError from e return decode(result) async def write(self, setting: CharSetting, value: Any) -> None: """Write to the specified characteristic. Parameters ---------- setting : CharSetting The characteristic to write to. value : Any The value to write to the characteristic. Raises ------ ValueError If no conversion or validation functions are found for the specified `setting`. CommunicationError If an error occurs while connecting to or writing data to the device. """ uuid, _, convert, validate = CHAR_MAP.get(setting, (None, None, None, None)) if not (convert and validate): raise ValueError( f"No conversion or validation functions found for {setting}" ) data = validate(convert(value)) try: await self.connect() await self._client.write_gatt_char(uuid, encode_int(data)) _LOGGER.debug("Wrote characteristic %s with value: %s", str(uuid), value) except (BleakError, TimeoutError) as e: _LOGGER.debug("Failed to write characteristic %s: %s", str(uuid), e) raise CommunicationError from e def decode_int(value: bytearray) -> int: """Decode byte-encoded integer value to an integer. Parameters ---------- value : bytearray Byte-encoded integer value to decode. Returns ------- int Decoded integer value. Notes ----- The byte order is little-endian, and the integer is treated as unsigned. """ return int.from_bytes(value, byteorder="little", signed=False) def decode_str(value: bytearray) -> str: """Decode byte-encoded string to a UTF-8 string. Parameters ---------- value : bytearray Byte-encoded string to decode. Returns ------- str Decoded UTF-8 string. Raises ------ UnicodeDecodeError If the byte sequence cannot be decoded as UTF-8. """ return value.decode("utf-8") def decode_live_data(value: bytearray) -> LiveDataResponse: """Parse bytearray from bulk live data into LiveDataResponse. Parameters ---------- value : bytearray Byte value from bulk live data characteristic. Returns ------- LiveDataResponse Object containing 14 decoded values: - live_temp: int - setpoint_temp: int - dc_voltage: float (normalized) - handle_temp: float (normalized) - pwm_level: int - power_src: PowerSource - tip_resistance: float (normalized) - uptime: float (normalized) - movement_time: float (normalized) - max_tip_temp_ability: int - tip_voltage: int - hall_sensor: int - operating_mode: OperatingMode - estimated_power: float (normalized) """ data = struct.unpack("14I", value) return LiveDataResponse( data[0], data[1], data[2] / 10, data[3] / 10, int(data[4] / 255 * 100), PowerSource(data[5]), data[6] / 10, data[7] / 10, data[8] / 10, data[9], data[10], data[11], OperatingMode(data[12]), data[13] / 10, ) def clip(a: int, a_min: int, a_max: int) -> int: """Clip a value between max and min. Validate if the value is between the defined limits and set it to the max/min value if it exceeds one of them. Parameters ---------- a : int The value to clip a_min : int The lower bound for the value. a_max : int The upper bound for the value. Returns ------- int The clipped value: - If `a` is less than `a_min`, return `a_min`. - If `a` is greater than `a_max`, return `a_max`. - Otherwise, return `a` itself. """ a = min(a, a_max) return max(a, a_min) def encode_int(value: int) -> bytes: """Encode integer as byte. Parameters ---------- value : int An integer value to encode. Returns ------- bytes Byte representation of the integer value in unsigned little-endian format, occupying 2 bytes. """ return value.to_bytes(2, byteorder="little", signed=False) def encode_lang_code(language_code: str | LanguageCode) -> int: """Encode language code to its hashed integer representation. ironOS stores languages as hashed integer representations of the language code. This method uses the same hash algorithm used in ironOS. Parameters ---------- language_code : str | LanguageCode The language code as a string or as a member of LanguageCode enum. Returns ------- int The hashed integer value of language_code. Notes ----- If language_code is a member of LanguageCode enum, its integer value is returned directly. Otherwise, language_code is hashed using SHA-1, and the resulting hash is converted to an integer and returned. """ if isinstance(language_code, LanguageCode): return int(language_code.value) return int(hashlib.sha1(language_code.encode("utf-8")).hexdigest(), 16) % 0xFFFF def decode_lang_code(raw: bytearray) -> LanguageCode | int | None: """Decode hashed value to language code. Parameters ---------- raw : bytearray A byte-encoded integer value representing the hashed language code. Returns ------- LanguageCode | int | None The LanguageCode enum member corresponding to the hashed integer value, or the integer value itself if it couldn't be matched to a known LanguageCode. Returns None if decoding fails. """ try: return LanguageCode(decode_int(raw)) except ValueError: return decode_int(raw) # Map uuid, decoding, encoding and input sanitizing methods for each characteristic CHAR_MAP: dict[Characteristic, tuple] = { CharBulk.LIVE_DATA: (const.CHAR_UUID_BULK_LIVE_DATA, decode_live_data), CharBulk.BUILD: (const.CHAR_UUID_BULK_BUILD, decode_str), CharBulk.DEVICE_SN: ( const.CHAR_UUID_BULK_DEVICE_SN, lambda x: f"{decode_int(x):016x}", ), CharBulk.DEVICE_ID: ( const.CHAR_UUID_BULK_DEVICE_ID, lambda x: f"{decode_int(x):x}", ), CharLive.LIVE_TEMP: (const.CHAR_UUID_LIVE_LIVE_TEMP, decode_int), CharLive.SETPOINT_TEMP: (const.CHAR_UUID_LIVE_SETPOINT_TEMP, decode_int), CharLive.DC_VOLTAGE: ( const.CHAR_UUID_LIVE_DC_VOLTAGE, lambda x: decode_int(x) / 10, ), CharLive.HANDLE_TEMP: ( const.CHAR_UUID_LIVE_HANDLE_TEMP, lambda x: decode_int(x) / 10, ), CharLive.PWM_LEVEL: ( const.CHAR_UUID_LIVE_PWM_LEVEL, lambda x: int(decode_int(x) / 255 * 100), # convert to percent ), CharLive.POWER_SRC: ( const.CHAR_UUID_LIVE_POWER_SRC, lambda x: PowerSource(decode_int(x)), ), CharLive.TIP_RESISTANCE: ( const.CHAR_UUID_LIVE_TIP_RESISTANCE, lambda x: decode_int(x) / 10, ), CharLive.UPTIME: ( const.CHAR_UUID_LIVE_UPTIME, lambda x: decode_int(x) / 10, ), CharLive.MOVEMENT_TIME: ( const.CHAR_UUID_LIVE_MOVEMENT_TIME, lambda x: decode_int(x) / 10, ), CharLive.MAX_TIP_TEMP_ABILITY: ( const.CHAR_UUID_LIVE_MAX_TIP_TEMP_ABILITY, decode_int, ), CharLive.TIP_VOLTAGE: ( const.CHAR_UUID_LIVE_TIP_VOLTAGE, decode_int, ), CharLive.HALL_SENSOR: (const.CHAR_UUID_LIVE_HALL_SENSOR, decode_int), CharLive.OPERATING_MODE: ( const.CHAR_UUID_LIVE_OPERATING_MODE, lambda x: OperatingMode(decode_int(x)), ), CharLive.ESTIMATED_POWER: ( const.CHAR_UUID_LIVE_ESTIMATED_POWER, lambda x: decode_int(x) / 10, ), CharSetting.SETPOINT_TEMP: ( const.CHAR_UUID_SETTINGS_SETPOINT_TEMP, decode_int, int, lambda x: clip(x, 10, 850), ), CharSetting.SLEEP_TEMP: ( const.CHAR_UUID_SETTINGS_SLEEP_TEMP, decode_int, int, lambda x: clip(x, 10, 850), ), CharSetting.SLEEP_TIMEOUT: ( const.CHAR_UUID_SETTINGS_SLEEP_TIMEOUT, decode_int, int, lambda x: clip(x, 0, 15), ), CharSetting.MIN_DC_VOLTAGE_CELLS: ( const.CHAR_UUID_SETTINGS_MIN_DC_VOLTAGE_CELLS, lambda x: BatteryType(decode_int(x)), lambda x: x.value if isinstance(x, BatteryType) else int(x), lambda x: clip(x, 0, 4), ), CharSetting.MIN_VOLTAGE_PER_CELL: ( const.CHAR_UUID_SETTINGS_MIN_VOLTAGE_PER_CELL, lambda x: decode_int(x) / 10, lambda x: int(x * 10), lambda x: clip(x, 24, 38), ), CharSetting.QC_IDEAL_VOLTAGE: ( const.CHAR_UUID_SETTINGS_QC_IDEAL_VOLTAGE, lambda x: decode_int(x) / 10, lambda x: int(x * 10), lambda x: clip(x, 90, 220), ), CharSetting.ORIENTATION_MODE: ( const.CHAR_UUID_SETTINGS_ORIENTATION_MODE, lambda x: ScreenOrientationMode(decode_int(x)), lambda x: x.value if isinstance(x, ScreenOrientationMode) else int(x), lambda x: clip(x, 0, 2), ), CharSetting.ACCEL_SENSITIVITY: ( const.CHAR_UUID_SETTINGS_ACCEL_SENSITIVITY, decode_int, int, lambda x: clip(x, 0, 9), ), CharSetting.ANIMATION_LOOP: ( const.CHAR_UUID_SETTINGS_ANIMATION_LOOP, lambda x: bool(decode_int(x)), bool, int, ), CharSetting.ANIMATION_SPEED: ( const.CHAR_UUID_SETTINGS_ANIMATION_SPEED, lambda x: AnimationSpeed(decode_int(x)), lambda x: x.value if isinstance(x, AnimationSpeed) else int(x), lambda x: clip(x, 0, 3), ), CharSetting.AUTOSTART_MODE: ( const.CHAR_UUID_SETTINGS_AUTOSTART_MODE, lambda x: AutostartMode(decode_int(x)), lambda x: x.value if isinstance(x, AutostartMode) else int(x), lambda x: clip(x, 0, 3), ), CharSetting.SHUTDOWN_TIME: ( const.CHAR_UUID_SETTINGS_SHUTDOWN_TIME, decode_int, int, lambda x: clip(x, 0, 60), ), CharSetting.COOLING_TEMP_BLINK: ( const.CHAR_UUID_SETTINGS_COOLING_TEMP_BLINK, lambda x: bool(decode_int(x)), bool, int, ), CharSetting.IDLE_SCREEN_DETAILS: ( const.CHAR_UUID_SETTINGS_IDLE_SCREEN_DETAILS, lambda x: bool(decode_int(x)), bool, int, ), CharSetting.SOLDER_SCREEN_DETAILS: ( const.CHAR_UUID_SETTINGS_SOLDER_SCREEN_DETAILS, lambda x: bool(decode_int(x)), bool, int, ), CharSetting.TEMP_UNIT: ( const.CHAR_UUID_SETTINGS_TEMP_UNIT, lambda x: TempUnit(decode_int(x)), lambda x: x.value if isinstance(x, TempUnit) else int(x), lambda x: clip(x, 0, 1), ), CharSetting.DESC_SCROLL_SPEED: ( const.CHAR_UUID_SETTINGS_DESC_SCROLL_SPEED, lambda x: ScrollSpeed(decode_int(x)), lambda x: x.value if isinstance(x, ScrollSpeed) else int(x), lambda x: clip(x, 0, 1), ), CharSetting.LOCKING_MODE: ( const.CHAR_UUID_SETTINGS_LOCKING_MODE, lambda x: LockingMode(decode_int(x)), lambda x: x.value if isinstance(x, LockingMode) else int(x), lambda x: clip(x, 0, 2), ), CharSetting.KEEP_AWAKE_PULSE_POWER: ( const.CHAR_UUID_SETTINGS_KEEP_AWAKE_PULSE_POWER, lambda x: decode_int(x) / 10, lambda x: int(x * 10), lambda x: clip(x, 0, 99), ), CharSetting.KEEP_AWAKE_PULSE_DELAY: ( const.CHAR_UUID_SETTINGS_KEEP_AWAKE_PULSE_DELAY, decode_int, int, lambda x: clip(x, 0, 9), ), CharSetting.KEEP_AWAKE_PULSE_DURATION: ( const.CHAR_UUID_SETTINGS_KEEP_AWAKE_PULSE_DURATION, decode_int, int, lambda x: clip(x, 0, 9), ), CharSetting.VOLTAGE_DIV: ( const.CHAR_UUID_SETTINGS_VOLTAGE_DIV, decode_int, int, lambda x: clip(x, 360, 900), ), CharSetting.BOOST_TEMP: ( const.CHAR_UUID_SETTINGS_BOOST_TEMP, decode_int, int, lambda x: clip(x, 250, 850) if x != 0 else 0, ), CharSetting.CALIBRATION_OFFSET: ( const.CHAR_UUID_SETTINGS_CALIBRATION_OFFSET, decode_int, int, lambda x: clip(x, 100, 2500), ), CharSetting.POWER_LIMIT: ( const.CHAR_UUID_SETTINGS_POWER_LIMIT, lambda x: decode_int(x), int, lambda x: clip(floor(x / 5) * 5, 0, 120), ), CharSetting.INVERT_BUTTONS: ( const.CHAR_UUID_SETTINGS_INVERT_BUTTONS, lambda x: bool(decode_int(x)), bool, int, ), CharSetting.TEMP_INCREMENT_LONG: ( const.CHAR_UUID_SETTINGS_TEMP_INCREMENT_LONG, decode_int, int, lambda x: clip(x, 5, 90), ), CharSetting.TEMP_INCREMENT_SHORT: ( const.CHAR_UUID_SETTINGS_TEMP_INCREMENT_SHORT, decode_int, int, lambda x: clip(x, 1, 50), ), CharSetting.HALL_SENSITIVITY: ( const.CHAR_UUID_SETTINGS_HALL_SENSITIVITY, decode_int, int, lambda x: clip(x, 0, 9), ), CharSetting.ACCEL_WARN_COUNTER: ( const.CHAR_UUID_SETTINGS_ACCEL_WARN_COUNTER, decode_int, int, lambda x: clip(x, 0, 9), ), CharSetting.PD_WARN_COUNTER: ( const.CHAR_UUID_SETTINGS_PD_WARN_COUNTER, decode_int, int, lambda x: clip(x, 0, 9), ), CharSetting.UI_LANGUAGE: ( const.CHAR_UUID_SETTINGS_UI_LANGUAGE, decode_lang_code, encode_lang_code, lambda x: clip(x, 0, 65535), ), CharSetting.PD_NEGOTIATION_TIMEOUT: ( const.CHAR_UUID_SETTINGS_PD_NEGOTIATION_TIMEOUT, lambda x,: decode_int(x) / 10, lambda x: int(x * 10), lambda x: clip(x, 0, 50), ), CharSetting.DISPLAY_INVERT: ( const.CHAR_UUID_SETTINGS_DISPLAY_INVERT, lambda x: bool(decode_int(x)), bool, int, ), CharSetting.DISPLAY_BRIGHTNESS: ( const.CHAR_UUID_SETTINGS_DISPLAY_BRIGHTNESS, lambda x: int((decode_int(x) + 24) / 25), # convert to values 1-5 lambda x: int(25 * x - 24), lambda x: clip(x, 1, 101), ), CharSetting.LOGO_DURATION: ( const.CHAR_UUID_SETTINGS_LOGO_DURATION, lambda x: LogoDuration(decode_int(x)), lambda x: x.value if isinstance(x, LogoDuration) else int(x), lambda x: clip(x, 0, 6), ), CharSetting.CALIBRATE_CJC: ( const.CHAR_UUID_SETTINGS_CALIBRATE_CJC, lambda x: bool(decode_int(x)), bool, int, ), CharSetting.BLE_ENABLED: ( const.CHAR_UUID_SETTINGS_BLE_ENABLED, lambda x: bool(decode_int(x)), bool, int, ), CharSetting.USB_PD_MODE: ( const.CHAR_UUID_SETTINGS_USB_PD_MODE, lambda x: USBPDMode(decode_int(x)), lambda x: x.value if isinstance(x, USBPDMode) else int(x), lambda x: clip(x, 0, 2), ), # added in 2.23 CharSetting.HALL_SLEEP_TIME: ( const.CHAR_UUID_SETTINGS_HALL_SLEEP_TIME, lambda x,: decode_int(x) * 5, lambda x: int(x / 5), lambda x: clip(x, 0, 12), ), # added in 2.23 CharSetting.TIP_TYPE: ( const.CHAR_UUID_SETTINGS_TIP_TYPE, lambda x: TipType(decode_int(x)), lambda x: x.value if isinstance(x, TipType) else int(x), lambda x: clip(x, 0, 3), ), CharSetting.SETTINGS_SAVE: ( const.CHAR_UUID_SETTINGS_SAVE, lambda x: bool(decode_int(x)), bool, int, ), CharSetting.SETTINGS_RESET: ( const.CHAR_UUID_SETTINGS_RESET, lambda x: bool(decode_int(x)), bool, int, ), } tr4nt0r-pynecil-9a12339/pynecil/const.py000066400000000000000000000122521502741601400201120ustar00rootroot00000000000000"""Constants for Pynecil.""" from uuid import UUID SVC_UUID_BULK = UUID("9eae1000-9d0d-48c5-aa55-33e27f9bc533") CHAR_UUID_BULK_LIVE_DATA = UUID("9eae1001-9d0d-48c5-aa55-33e27f9bc533") CHAR_UUID_BULK_ACCEL_NAME = UUID("9eae1002-9d0d-48c5-aa55-33e27f9bc533") CHAR_UUID_BULK_BUILD = UUID("9eae1003-9d0d-48c5-aa55-33e27f9bc533") CHAR_UUID_BULK_DEVICE_SN = UUID("9eae1004-9d0d-48c5-aa55-33e27f9bc533") CHAR_UUID_BULK_DEVICE_ID = UUID("9eae1005-9d0d-48c5-aa55-33e27f9bc533") SVC_UUID_LIVE = UUID("d85ef000-168e-4a71-aa55-33e27f9bc533") CHAR_UUID_LIVE_LIVE_TEMP = UUID("d85ef001-168e-4a71-aa55-33e27f9bc533") CHAR_UUID_LIVE_SETPOINT_TEMP = UUID("d85ef002-168e-4a71-aa55-33e27f9bc533") CHAR_UUID_LIVE_DC_VOLTAGE = UUID("d85ef003-168e-4a71-aa55-33e27f9bc533") CHAR_UUID_LIVE_HANDLE_TEMP = UUID("d85ef004-168e-4a71-aa55-33e27f9bc533") CHAR_UUID_LIVE_PWM_LEVEL = UUID("d85ef005-168e-4a71-aa55-33e27f9bc533") CHAR_UUID_LIVE_POWER_SRC = UUID("d85ef006-168e-4a71-aa55-33e27f9bc533") CHAR_UUID_LIVE_TIP_RESISTANCE = UUID("d85ef007-168e-4a71-aa55-33e27f9bc533") CHAR_UUID_LIVE_UPTIME = UUID("d85ef008-168e-4a71-aa55-33e27f9bc533") CHAR_UUID_LIVE_MOVEMENT_TIME = UUID("d85ef009-168e-4a71-aa55-33e27f9bc533") CHAR_UUID_LIVE_MAX_TIP_TEMP_ABILITY = UUID("d85ef00a-168e-4a71-aa55-33e27f9bc533") CHAR_UUID_LIVE_TIP_VOLTAGE = UUID("d85ef00b-168e-4a71-aa55-33e27f9bc533") CHAR_UUID_LIVE_HALL_SENSOR = UUID("d85ef00c-168e-4a71-aa55-33e27f9bc533") CHAR_UUID_LIVE_OPERATING_MODE = UUID("d85ef00d-168e-4a71-aa55-33e27f9bc533") CHAR_UUID_LIVE_ESTIMATED_POWER = UUID("d85ef00e-168e-4a71-aa55-33e27f9bc533") SVC_UUID_SETTINGS = UUID("f6d80000-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_SAVE = UUID("f6d7ffff-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_RESET = UUID("f6d7fffe-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_SETPOINT_TEMP = UUID("f6d70000-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_SLEEP_TEMP = UUID("f6d70001-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_SLEEP_TIMEOUT = UUID("f6d70002-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_MIN_DC_VOLTAGE_CELLS = UUID("f6d70003-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_MIN_VOLTAGE_PER_CELL = UUID("f6d70004-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_QC_IDEAL_VOLTAGE = UUID("f6d70005-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_ORIENTATION_MODE = UUID("f6d70006-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_ACCEL_SENSITIVITY = UUID("f6d70007-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_ANIMATION_LOOP = UUID("f6d70008-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_ANIMATION_SPEED = UUID("f6d70009-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_AUTOSTART_MODE = UUID("f6d7000a-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_SHUTDOWN_TIME = UUID("f6d7000b-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_COOLING_TEMP_BLINK = UUID("f6d7000c-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_IDLE_SCREEN_DETAILS = UUID("f6d7000d-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_SOLDER_SCREEN_DETAILS = UUID("f6d7000e-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_TEMP_UNIT = UUID("f6d7000f-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_DESC_SCROLL_SPEED = UUID("f6d70010-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_LOCKING_MODE = UUID("f6d70011-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_KEEP_AWAKE_PULSE_POWER = UUID("f6d70012-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_KEEP_AWAKE_PULSE_DELAY = UUID("f6d70013-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_KEEP_AWAKE_PULSE_DURATION = UUID( "f6d70014-5a10-4eba-aa55-33e27f9bc533" ) CHAR_UUID_SETTINGS_VOLTAGE_DIV = UUID("f6d70015-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_BOOST_TEMP = UUID("f6d70016-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_CALIBRATION_OFFSET = UUID("f6d70017-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_POWER_LIMIT = UUID("f6d70018-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_INVERT_BUTTONS = UUID("f6d70019-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_TEMP_INCREMENT_LONG = UUID("f6d7001a-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_TEMP_INCREMENT_SHORT = UUID("f6d7001b-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_HALL_SENSITIVITY = UUID("f6d7001c-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_ACCEL_WARN_COUNTER = UUID("f6d7001d-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_PD_WARN_COUNTER = UUID("f6d7001e-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_UI_LANGUAGE = UUID("f6d7001f-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_PD_NEGOTIATION_TIMEOUT = UUID("f6d70020-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_DISPLAY_INVERT = UUID("f6d70021-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_DISPLAY_BRIGHTNESS = UUID("f6d70022-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_LOGO_DURATION = UUID("f6d70023-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_CALIBRATE_CJC = UUID("f6d70024-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_BLE_ENABLED = UUID("f6d70025-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_USB_PD_MODE = UUID("f6d70026-5a10-4eba-aa55-33e27f9bc533") # added in IronOS 2.23 CHAR_UUID_SETTINGS_HALL_SLEEP_TIME = UUID("f6d70035-5a10-4eba-aa55-33e27f9bc533") CHAR_UUID_SETTINGS_TIP_TYPE = UUID("f6d70036-5a10-4eba-aa55-33e27f9bc533") GITHUB_LATEST_RELEASES_URL = "https://api.github.com/repos/Ralim/IronOS/releases/latest" tr4nt0r-pynecil-9a12339/pynecil/exceptions.py000066400000000000000000000016161502741601400211470ustar00rootroot00000000000000"""Exceptions for Pynecil.""" class CommunicationError(Exception): """Exception raised for communication failures.""" def __init__(self, message="Communication error occurred."): """Initialize the CommunicationError exception. Parameters ---------- message : str, optional Error message describing the communication failure, by default "Communication error occurred." """ self.message = message super().__init__(self.message) def __str__(self): """Return a string representation of the exception. Returns ------- str Formatted string containing the class name and the error message. """ return f"{self.__class__.__name__}: {self.message}" class UpdateException(Exception): """Exception raised for errors fetching latest release from github.""" tr4nt0r-pynecil-9a12339/pynecil/py.typed000066400000000000000000000000001502741601400200750ustar00rootroot00000000000000tr4nt0r-pynecil-9a12339/pynecil/types.py000066400000000000000000000270201502741601400201270ustar00rootroot00000000000000"""Types for Pynecil.""" from dataclasses import dataclass from enum import Enum from typing import TypedDict class Characteristic(Enum): """Base class for Characteristics.""" class CharLive(Characteristic, Enum): """Characteristics from live service.""" LIVE_TEMP = 0 SETPOINT_TEMP = 1 DC_VOLTAGE = 2 HANDLE_TEMP = 3 PWM_LEVEL = 4 POWER_SRC = 5 TIP_RESISTANCE = 6 UPTIME = 7 MOVEMENT_TIME = 8 MAX_TIP_TEMP_ABILITY = 9 TIP_VOLTAGE = 10 HALL_SENSOR = 11 OPERATING_MODE = 12 ESTIMATED_POWER = 13 class CharSetting(Characteristic, Enum): """Characteristics from settings service.""" SETPOINT_TEMP = 0 SLEEP_TEMP = 1 SLEEP_TIMEOUT = 2 MIN_DC_VOLTAGE_CELLS = 3 MIN_VOLTAGE_PER_CELL = 4 QC_IDEAL_VOLTAGE = 5 ORIENTATION_MODE = 6 ACCEL_SENSITIVITY = 7 ANIMATION_LOOP = 8 ANIMATION_SPEED = 9 AUTOSTART_MODE = 10 SHUTDOWN_TIME = 11 COOLING_TEMP_BLINK = 12 IDLE_SCREEN_DETAILS = 13 SOLDER_SCREEN_DETAILS = 14 TEMP_UNIT = 15 DESC_SCROLL_SPEED = 16 LOCKING_MODE = 17 KEEP_AWAKE_PULSE_POWER = 18 KEEP_AWAKE_PULSE_DELAY = 19 KEEP_AWAKE_PULSE_DURATION = 20 VOLTAGE_DIV = 21 BOOST_TEMP = 22 CALIBRATION_OFFSET = 23 POWER_LIMIT = 24 INVERT_BUTTONS = 25 TEMP_INCREMENT_LONG = 26 TEMP_INCREMENT_SHORT = 27 HALL_SENSITIVITY = 28 ACCEL_WARN_COUNTER = 29 PD_WARN_COUNTER = 30 UI_LANGUAGE = 31 PD_NEGOTIATION_TIMEOUT = 32 DISPLAY_INVERT = 33 DISPLAY_BRIGHTNESS = 34 LOGO_DURATION = 35 CALIBRATE_CJC = 36 BLE_ENABLED = 37 USB_PD_MODE = 38 HALL_SLEEP_TIME = 53 TIP_TYPE = 54 SETTINGS_RESET = 98 SETTINGS_SAVE = 99 class CharBulk(Characteristic, Enum): """Characteristics from bulk service.""" LIVE_DATA = 0 ACCEL_NAME = 1 BUILD = 2 DEVICE_SN = 3 DEVICE_ID = 4 class PowerSource(Enum): """Power source types.""" DC = 0 QC = 1 PD_VBUS = 2 PD = 3 class OperatingMode(Enum): """Operating modes.""" IDLE = 0 SOLDERING = 1 BOOST = 2 SLEEPING = 3 SETTINGS = 4 DEBUG = 5 SOLDERING_PROFILE = 6 TEMPERATURE_ADJUST = 7 USB_PD_DEBUG = 8 THERMAL_RUNAWAY = 9 STARTUP_LOGO = 10 CJC_CALIBRATION = 11 STARTUP_WARNINGS = 12 INITIALISATION_DONE = 13 HIBERNATING = 14 class LanguageCode(Enum): """Language Codes.""" BE = 60301 BG = 15395 CS = 36791 DA = 63942 DE = 5496 EL = 5003 EN = 41431 ES = 38713 ET = 18074 FI = 25411 FR = 38783 HR = 49773 HU = 19902 IT = 57867 JA_JP = 2385 LT = 5183 NB = 31043 NL = 22266 NL_BE = 55046 PL = 55968 PT = 56922 RO = 61480 RU = 26979 SK = 13916 SL = 21931 SR_CYRL = 41427 SR_LATN = 61017 SV = 65456 TR = 9120 UK = 29374 VI = 20758 YUE_HK = 17119 ZH_CN = 44731 ZH_TW = 34289 UZ = 60827 class BatteryType(Enum): """Battery type (cell count).""" NO_BATTERY = 0 # DC power supply BATTERY_3S = 1 BATTERY_4S = 2 BATTERY_5S = 3 BATTERY_6S = 4 class ScreenOrientationMode(Enum): """Screen orientation mode.""" RIGHT_HANDED = 0 LEFT_HANDED = 1 AUTO = 2 class AnimationSpeed(Enum): """Animation speed.""" OFF = 0 SLOW = 1 MEDIUM = 2 FAST = 3 class AutostartMode(Enum): """Autostart mode.""" DISABLED = 0 SOLDERING = 1 SLEEPING = 2 IDLE = 3 class TempUnit(Enum): """Temperature unit.""" CELSIUS = 0 FAHRENHEIT = 1 class ScrollSpeed(Enum): """Description scroll speed.""" SLOW = 0 FAST = 1 class LockingMode(Enum): """Locking mode.""" OFF = 0 BOOST_ONLY = 1 FULL_LOCKING = 2 class LogoDuration(Enum): """Logo duration.""" OFF = 0 SECONDS_1 = 1 SECONDS_2 = 2 SECONDS_3 = 3 SECONDS_4 = 4 SECONDS_5 = 5 LOOP = 6 class USBPDMode(Enum): """USB Power delivery mode.""" OFF = 0 ON = 1 SAFE = 2 class TipType(Enum): """Type of soldering tip.""" AUTO = 0 TS100_LONG = 1 PINE_SHORT = 2 PTS200 = 3 @dataclass class DeviceInfoResponse: """Response data with information about the Pinecil device. Attributes ---------- build: str | None ironOS build version of the device (e.g. 2.22) device_id: str | None Identifier of the device address: str | None Bluetooth address of the device device_sn: str | None Serial number of the device name: str | None Name of the device """ build: str | None = None device_id: str | None = None address: str | None = None device_sn: str | None = None name: str | None = None is_synced: bool = False @dataclass class LiveDataResponse: """Live data response class. Attributes ---------- live_temp: int | None Temperature of the tip (in °C) setpoint_temp: int | None Setpoint temperature (in °C) dc_voltage: float | None DC input voltage handle_temp: float | None Handle temperature (in °C) pwm_level: int | None Power level (0-100%) power_src: PowerSource | None Power source (e.g. USB-PD/QC or DC) tip_resistance: float | None Resistance of the tip (in Ω) uptime: float | None Uptime of the device (in seconds) movement_time: float | None Last movement time (in seconds) max_tip_temp_ability: int | None Maximum temperature supported by the tip (in °C) tip_voltage: int | None Raw tip voltage (in μV) hall_sensor: int | None Hall effect strength (if hall sensor is installed) operating_mode: OperatingMode | None Current operating mode of the device (e.g. soldering, idle...) estimated_power: float | None Estimated power usage (in Watt) """ live_temp: int | None = None setpoint_temp: int | None = None dc_voltage: float | None = None handle_temp: float | None = None pwm_level: int | None = None power_src: PowerSource | None = None tip_resistance: float | None = None uptime: float | None = None movement_time: float | None = None max_tip_temp_ability: int | None = None tip_voltage: int | None = None hall_sensor: int | None = None operating_mode: OperatingMode | None = None estimated_power: float | None = None class SettingsDataResponse(TypedDict, total=False): """Settings data response class. Attributes ---------- setpoint_temp: int | None Setpoint temperature (in °C, 10-450) sleep_temp: int | None Temperature to drop to in sleep mode (in °C, 10-450) sleep_timeout: int | None timeout till sleep mode (in minutes , 0-15) min_dc_voltage_cells: BatteryType | None Voltage to cut out at for under voltage when powered by DC jack (0=DC, 1=3S, 2=4S, 3=5S, 4=6S) min_voltage_per_cell: float | None Minimum allowed voltage per cell (in V, 2.4-3.8, step=0.1) qc_ideal_voltage: float | None QC3.0 maximum voltage (9.0-22.0V, step=0.1) orientation_mode: ScreenOrientationMode | None Screen orientation (right-handed, left-handed, Auto) accel_sensitivity: int | None Motion sensitivity (0-9) animation_loop: bool | None Animation loop switch animation_speed: AnimationSpeed | None Animation speed (off, slow, medium, fast) autostart_mode: AutostartMode | None Mode to enter on start-up (disabled, soldering, sleeping, idle) shutdown_time: int | None Time until unit shuts down if not moved (in seconds, 0-60) cooling_temp_blink: bool | None Blink temperature on the cool down screen until its <50C idle_screen_details: bool | None Show details on idle screen solder_screen_details: bool | None Show details on soldering screen temp_unit: TempUnit | None Temperature unit (0=Celsius, 1=Fahrenheit) desc_scroll_speed: ScrollSpeed | None Description scroll speed (0=slow, 1=fast) locking_mode: LockingMode | None Allow locking buttons (off, boost mode only, full locking) keep_awake_pulse_power: float | None Intensity of power of keep-awake-pulse in W (0-9.9) keep_awake_pulse_delay: int | None Delay before keep-awake-pulse is triggered (1-9 x 2.5s) keep_awake_pulse_duration: int | None Keep-awake-pulse duration (1-9 x 250ms) voltage_div: int | None Voltage divisor factor (360-900) boost_temp: int | None Boost mode set point temperature (in °C, 0-450) calibration_offset: int | None Calibration offset for the installed tip (in µV, 100-2500) power_limit: int | None Maximum power allowed to output (in W, 0-120W, step=5) invert_buttons: bool | None Change the plus and minus button assigment temp_increment_long: int | None Temperature-change-increment on long button press in degree (5-90) temp_increment_short: int | None Temperature-change-increment on short button press in degree (1-50) hall_sensitivity: int | None Hall effect sensor sensitivity (0-9) accel_warn_counter: int | None Warning counter when accelerometer could not be detected (0-9) pd_warn_counter: int | None Warning counter when PD interface could not be detected (0-9) ui_language: LanguageCode | None Hashed integer of language code pd_negotiation_timeout: float | None Power delivery negotiation timeout in seconds (0-5.0, step=0.1) display_invert: bool | None Invert colors of display display_brightness: int | None Display brightness (1-5) logo_duration: LogoDuration | None Boot logo duration (off, 1-5seconds, loop ∞) calibrate_cjc: bool | None Enable CJC calibration at next boot ble_enabled: bool | None Disable BLE usb_pd_mode: USBPDMode | None PPS & EPR USB Power delivery mode settings_save: bool | None Save settings to flash settings_reset: bool | None Reset settings to default values """ setpoint_temp: int | None sleep_temp: int | None sleep_timeout: int | None min_dc_voltage_cells: BatteryType | None min_voltage_per_cell: float | None qc_ideal_voltage: float | None orientation_mode: ScreenOrientationMode | None accel_sensitivity: int | None animation_loop: bool | None animation_speed: AnimationSpeed | None autostart_mode: AutostartMode | None shutdown_time: int | None cooling_temp_blink: bool | None idle_screen_details: bool | None solder_screen_details: bool | None temp_unit: TempUnit | None desc_scroll_speed: ScrollSpeed | None locking_mode: LockingMode | None keep_awake_pulse_power: float | None keep_awake_pulse_delay: int | None keep_awake_pulse_duration: int | None voltage_div: int | None boost_temp: int | None calibration_offset: int | None power_limit: int | None invert_buttons: bool | None temp_increment_long: int | None temp_increment_short: int | None hall_sensitivity: int | None accel_warn_counter: int | None pd_warn_counter: int | None ui_language: LanguageCode | None pd_negotiation_timeout: float | None display_invert: bool | None display_brightness: int | None logo_duration: LogoDuration | None calibrate_cjc: bool | None ble_enabled: bool | None usb_pd_mode: USBPDMode | None hall_sleep_time: int | None tip_type: TipType | None settings_save: bool | None settings_reset: bool | None tr4nt0r-pynecil-9a12339/pynecil/update.py000066400000000000000000000025321502741601400202460ustar00rootroot00000000000000"""Retrieve latest update for IronOS.""" from __future__ import annotations from dataclasses import dataclass from aiohttp import ClientError, ClientSession from .const import GITHUB_LATEST_RELEASES_URL from .exceptions import UpdateException @dataclass(kw_only=True) class LatestRelease: """Latest release data.""" tag_name: str name: str html_url: str body: str class IronOSUpdate: """Check for IronOS updates.""" def __init__( self, session: ClientSession, ) -> None: """Initialize IronOS release checker.""" self._session = session self.url = GITHUB_LATEST_RELEASES_URL async def latest_release(self) -> LatestRelease: """Fetch latest IronOS release.""" try: async with self._session.get(self.url) as response: data = await response.json() return LatestRelease( tag_name=data["tag_name"], name=data["name"], html_url=data["html_url"], body=data["body"], ) except ClientError: raise UpdateException("Failed to fetch latest IronOS release from Github") except KeyError: raise UpdateException( "Failed to parse latest IronOS release from Github response" ) tr4nt0r-pynecil-9a12339/pyproject.toml000066400000000000000000000034201502741601400176600ustar00rootroot00000000000000[tool.coverage.report] exclude_lines = [ "if TYPE_CHECKING:" ] [tool.ruff] target-version = "py312" [tool.ruff.lint] extend-select = ["I", "TRY", "UP", "D", "W"] extend-ignore = ["D213", "D202", "D203", "D213", "UP038", "TRY003"] [tool.pytest.ini_options] addopts = "--cov=pynecil/ --cov-report=term-missing" asyncio_mode = "auto" [tool.hatch] [tool.hatch.metadata] allow-direct-references = true [tool.hatch.version] source = "regex_commit" tag_sign = false commit_extra_args = ["-e"] path = "pynecil/__init__.py" [tool.hatch.envs.default] python = "3.12" installer = "uv" dependencies = [ "aiohttp==3.12.13", "bleak==0.22.3", "mypy==1.16.1", "ruff==0.12.1", "pytest==8.4.1", "pytest-cov==6.2.1", "mkdocs-material==9.6.14", "mkdocstrings[python]==0.29.1", "pytest-asyncio==1.0.0", ] [tool.hatch.envs.default.scripts] test = "pytest" test-cov-xml = "pytest --cov-report=xml" lint = [ "ruff format .", "ruff --fix .", "mypy pynecil/", ] lint-check = [ "ruff format --check .", "ruff check .", "mypy pynecil/", ] docs-serve = "mkdocs serve" docs-build = "mkdocs build" [build-system] requires = ["hatchling", "hatch-regex-commit"] build-backend = "hatchling.build" [project] name = "pynecil" authors = [ { name = "Manfred Dennerlein Rodelo", email = "manfred@dennerlein.name" } ] description = "Python library to communicate with Pinecil V2 soldering irons via Bluetooth" readme = "README.md" dynamic = ["version"] license = "MIT" classifiers = [ "Programming Language :: Python :: 3 :: Only", "Operating System :: OS Independent" ] requires-python = ">=3.11" dependencies = [ "bleak>=0.22.0", "aiohttp>=3.11.10" ] [project.urls] Documentation = "https://tr4nt0r.github.io/pynecil/" Source = "https://github.com/tr4nt0r/pynecil" tr4nt0r-pynecil-9a12339/renovate.json000066400000000000000000000006401502741601400174630ustar00rootroot00000000000000{ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended" ], "rebaseWhen": "behind-base-branch", "labels": [ ":recycle: dependencies" ], "commitMessageTopic": "{{depName}}", "commitMessageAction": "Bump", "packageRules": [ { "matchManagers": [ "pep621" ], "addLabels": [ ":snake: python" ] } ] } tr4nt0r-pynecil-9a12339/tests/000077500000000000000000000000001502741601400161075ustar00rootroot00000000000000tr4nt0r-pynecil-9a12339/tests/__init__.py000066400000000000000000000000451502741601400202170ustar00rootroot00000000000000"""Tests for the Pynecil library.""" tr4nt0r-pynecil-9a12339/tests/conftest.py000066400000000000000000000015751502741601400203160ustar00rootroot00000000000000"""Fixtures for Tests.""" from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest from bleak.backends.device import BLEDevice @pytest.fixture def mock_bleak_client() -> Generator[AsyncMock]: """Mock bleak client.""" with patch("pynecil.client.BleakClient", autospec=True) as mock_client: client = mock_client.return_value client.is_connected = True client.read_gatt_char.return_value = b"\x00\x00" yield client @pytest.fixture def mock_bleak_scanner() -> Generator[AsyncMock]: """Mock bleak scanner.""" with patch("pynecil.client.BleakScanner", autospec=True) as mock_client: client = mock_client.return_value client.find_device_by_filter.return_value = BLEDevice( address="AA:BB:CC:DD:EE:FF", name="Pinecil-ABCDEF", rssi=-50, details={} ) yield client tr4nt0r-pynecil-9a12339/tests/test_live.py000066400000000000000000000746371502741601400205000ustar00rootroot00000000000000"""Unit tests for the Pynecil client module.""" from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import UUID import pytest from bleak.exc import BleakError from pynecil import ( AnimationSpeed, AutostartMode, BatteryType, CharLive, CharSetting, CommunicationError, DeviceInfoResponse, LanguageCode, LockingMode, LogoDuration, OperatingMode, PowerSource, Pynecil, ScreenOrientationMode, ScrollSpeed, TempUnit, TipType, USBPDMode, discover, ) @pytest.mark.usefixtures("mock_bleak_scanner") async def test_discover_success(): """Test discover function success.""" result = await discover() assert result is not None assert result.name == "Pinecil-ABCDEF" assert result.address == "AA:BB:CC:DD:EE:FF" async def test_discover_timeout(mock_bleak_scanner: AsyncMock): """Test discover function timeout.""" mock_bleak_scanner.find_device_by_filter.return_value = None result = await discover(timeout=0.1) assert result is None @pytest.mark.usefixtures("mock_bleak_scanner") async def test_connect_success(mock_bleak_client: AsyncMock): """Test connection success.""" mock_bleak_client.is_connected = False device = await discover() client = Pynecil(device) # type: ignore await client.connect() mock_bleak_client.connect.assert_awaited_once() async def test_connect_already_connected(mock_bleak_client: AsyncMock): """Test connection when already connected.""" client = Pynecil("AA:BB:CC:DD:EE:FF") assert client.is_connected await client.connect() mock_bleak_client.connect.assert_not_awaited() async def test_connect_reconnect(mock_bleak_client: AsyncMock): """Test reconnect after disconnection.""" mock_bleak_client.is_connected = False client = Pynecil("AA:BB:CC:DD:EE:FF") client.client_disconnected = True await client.connect() mock_bleak_client.connect.assert_awaited_once() mock_bleak_client.disconnect.assert_awaited_once() async def test_disconnect(mock_bleak_client: AsyncMock): """Test disconnection.""" client = Pynecil("AA:BB:CC:DD:EE:FF") client.client_disconnected = True await client.disconnect() mock_bleak_client.disconnect.assert_awaited_once() assert not client.client_disconnected async def test_get_device_info_success(mock_bleak_client: AsyncMock): """Test get_device_info success.""" mock_bleak_client.read_gatt_char.side_effect = [ b"2.22", b"\xef\xcd\xab\x90\x78\x56\x34\x12", b"\x78\x56\x34\x12", ] client = Pynecil("AA:BB:CC:DD:EE:FF") info = await client.get_device_info() assert info.build == "2.22" assert info.device_sn == "1234567890abcdef" assert info.device_id == "12345678" assert info.address == "AA:BB:CC:DD:EE:FF" assert info.is_synced is True async def test_get_device_info_cached(mock_bleak_client: AsyncMock): """Test get_device_info when data is cached.""" mock_bleak_client.is_connected = True client = Pynecil("AA:BB:CC:DD:EE:FF") client.device_info = DeviceInfoResponse( build="2.22", device_sn="1234567890abcdef", device_id="12345678", address="AA:BB:CC:DD:EE:FF", is_synced=True, ) info = await client.get_device_info() assert info.build == "2.22" assert info.device_sn == "1234567890abcdef" assert info.device_id == "12345678" assert info.address == "AA:BB:CC:DD:EE:FF" mock_bleak_client.read_gatt_char.assert_not_awaited() async def test_get_device_info_bleak_error(mock_bleak_client: AsyncMock): """Test get_device_info with BleakError.""" mock_bleak_client.read_gatt_char.side_effect = BleakError client = Pynecil("AA:BB:CC:DD:EE:FF") with pytest.raises(CommunicationError): await client.get_device_info() async def test_get_device_info_timeout_error(mock_bleak_client: AsyncMock): """Test get_device_info with TimeoutError.""" mock_bleak_client.read_gatt_char.side_effect = TimeoutError client = Pynecil("AA:BB:CC:DD:EE:FF") with pytest.raises(CommunicationError): await client.get_device_info() async def test_get_live_data(mock_bleak_client: AsyncMock): """Test get_live_data function.""" mock_bleak_client.read_gatt_char.return_value = bytearray( b"\xf1\x00\x00\x00\xf0\x00\x00\x00\xc9\x00\x00\x00+\x01\x00\x00\n\x00\x00\x00\x03\x00\x00\x00>\x00\x00\x00E\x02\x00\x00\xc2\x00\x00\x00\xb8\x01\x00\x00^\x16\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x19\x00\x00\x00" ) client = Pynecil("AA:BB:CC:DD:EE:FF") data = await client.get_live_data() assert data.live_temp == 241 assert data.setpoint_temp == 240 assert data.dc_voltage == 20.1 assert data.handle_temp == 29.9 assert data.pwm_level == 3 assert data.power_src == PowerSource.PD assert data.tip_resistance == 6.2 assert data.uptime == 58.1 assert data.movement_time == 19.4 assert data.max_tip_temp_ability == 440 assert data.tip_voltage == 5726 assert data.hall_sensor == 0 assert data.operating_mode == OperatingMode.SOLDERING assert data.estimated_power == 2.5 mock_bleak_client.read_gatt_char.assert_awaited_once() async def test_get_live_data_communication_error(mock_bleak_client: AsyncMock): """Test get_live_data with communication error.""" mock_bleak_client.read_gatt_char.side_effect = BleakError client = Pynecil("AA:BB:CC:DD:EE:FF") with pytest.raises(CommunicationError): await client.get_live_data() async def test_get_settings_all(mock_bleak_client: AsyncMock): """Test get_settings function.""" mock_bleak_client.read_gatt_char.side_effect = [ b"@\x01", b"\x96\x00", b"\x05\x00", b"\x01\x00", b"!\x00", b"Z\x00", b"\x02\x00", b"\x07\x00", b"\x01\x00", b"\x02\x00", b"\x03\x00", b"\x0a\x00", b"\x01\x00", b"\x01\x00", b"\x01\x00", b"\x00\x00", b"\x01\x00", b"\x02\x00", b"\x05\x00", b"\x04\x00", b"\x01\x00", b"X\x02", b"\xa4\x01", b"\x84\x03", b"x\x00", b"\x01\x00", b"\x0a\x00", b"\x01\x00", b"\x07\x00", b"\x09\x00", b"\x09\x00", b"\xd7\xa1", b"\x14\x00", b"\x01\x00", b"e\x00", b"\x06\x00", b"\x01\x00", b"\x01\x00", b"\x02\x00", b"\x05\x00", b"\x01\x00", b"\x00\x00", b"\x00\x00", ] client = Pynecil("AA:BB:CC:DD:EE:FF") settings = await client.get_settings() assert settings.get("setpoint_temp") == 320 assert settings.get("sleep_temp") == 150 assert settings.get("sleep_timeout") == 5 assert settings.get("min_dc_voltage_cells") == BatteryType.BATTERY_3S assert settings.get("min_voltage_per_cell") == 3.3 assert settings.get("qc_ideal_voltage") == 9.0 assert settings.get("orientation_mode") == ScreenOrientationMode.AUTO assert settings.get("accel_sensitivity") == 7 assert settings.get("animation_loop") is True assert settings.get("animation_speed") == AnimationSpeed.MEDIUM assert settings.get("autostart_mode") == AutostartMode.IDLE assert settings.get("shutdown_time") == 10 assert settings.get("cooling_temp_blink") is True assert settings.get("idle_screen_details") is True assert settings.get("solder_screen_details") is True assert settings.get("temp_unit") == TempUnit.CELSIUS assert settings.get("desc_scroll_speed") == ScrollSpeed.FAST assert settings.get("locking_mode") == LockingMode.FULL_LOCKING assert settings.get("keep_awake_pulse_power") == 0.5 assert settings.get("keep_awake_pulse_delay") == 4 assert settings.get("keep_awake_pulse_duration") == 1 assert settings.get("voltage_div") == 600 assert settings.get("boost_temp") == 420 assert settings.get("calibration_offset") == 900 assert settings.get("power_limit") == 120 assert settings.get("invert_buttons") is True assert settings.get("temp_increment_long") == 10 assert settings.get("temp_increment_short") == 1 assert settings.get("hall_sensitivity") == 7 assert settings.get("accel_warn_counter") == 9 assert settings.get("pd_warn_counter") == 9 assert settings.get("ui_language") == LanguageCode.EN assert settings.get("pd_negotiation_timeout") == 2.0 assert settings.get("display_invert") is True assert settings.get("display_brightness") == 5 assert settings.get("logo_duration") == LogoDuration.LOOP assert settings.get("calibrate_cjc") is True assert settings.get("ble_enabled") is True assert settings.get("usb_pd_mode") == USBPDMode.SAFE assert settings.get("hall_sleep_time") == 25 assert settings.get("tip_type") == TipType.TS100_LONG assert settings.get("settings_save") is False assert settings.get("settings_reset") is False assert mock_bleak_client.read_gatt_char.call_count == 43 @pytest.mark.parametrize( ("setting", "raw_value", "result"), [ (CharSetting.SETPOINT_TEMP, b"@\x01", 320), (CharSetting.SLEEP_TEMP, b"\x96\x00", 150), (CharSetting.SLEEP_TIMEOUT, b"\x05\x00", 5), (CharSetting.TEMP_UNIT, b"\x01\x00", TempUnit.FAHRENHEIT), (CharSetting.MIN_DC_VOLTAGE_CELLS, b"\x01\x00", BatteryType.BATTERY_3S), (CharSetting.MIN_VOLTAGE_PER_CELL, b"!\x00", 3.3), (CharSetting.QC_IDEAL_VOLTAGE, b"Z\x00", 9.0), (CharSetting.ORIENTATION_MODE, b"\x02\x00", ScreenOrientationMode.AUTO), (CharSetting.ACCEL_SENSITIVITY, b"\x07\x00", 7), (CharSetting.ANIMATION_LOOP, b"\x01\x00", True), (CharSetting.ANIMATION_SPEED, b"\x02\x00", AnimationSpeed.MEDIUM), (CharSetting.AUTOSTART_MODE, b"\x03\x00", AutostartMode.IDLE), (CharSetting.SHUTDOWN_TIME, b"\x0a\x00", 10), (CharSetting.COOLING_TEMP_BLINK, b"\x01\x00", True), (CharSetting.IDLE_SCREEN_DETAILS, b"\x01\x00", True), (CharSetting.SOLDER_SCREEN_DETAILS, b"\x01\x00", True), (CharSetting.DESC_SCROLL_SPEED, b"\x01\x00", ScrollSpeed.FAST), (CharSetting.LOCKING_MODE, b"\x02\x00", LockingMode.FULL_LOCKING), (CharSetting.KEEP_AWAKE_PULSE_POWER, b"\x05\x00", 0.5), (CharSetting.KEEP_AWAKE_PULSE_DELAY, b"\x04\x00", 4), (CharSetting.KEEP_AWAKE_PULSE_DURATION, b"\x01\x00", 1), (CharSetting.VOLTAGE_DIV, b"X\x02", 600), (CharSetting.BOOST_TEMP, b"\xa4\x01", 420), (CharSetting.CALIBRATION_OFFSET, b"\x84\x03", 900), (CharSetting.POWER_LIMIT, b"x\x00", 120), (CharSetting.INVERT_BUTTONS, b"\x03\x00", True), (CharSetting.TEMP_INCREMENT_LONG, b"\x0a\x00", 10), (CharSetting.TEMP_INCREMENT_SHORT, b"\x01\x00", 1), (CharSetting.HALL_SENSITIVITY, b"\x07\x00", 7), (CharSetting.ACCEL_WARN_COUNTER, b"\x0a\x00", 10), (CharSetting.PD_WARN_COUNTER, b"\x0a\x00", 10), (CharSetting.UI_LANGUAGE, b"\xd7\xa1", LanguageCode.EN), (CharSetting.PD_NEGOTIATION_TIMEOUT, b"\x14\x00", 2.0), (CharSetting.DISPLAY_INVERT, b"\x01\x00", True), (CharSetting.DISPLAY_BRIGHTNESS, b"e\x00", 5), (CharSetting.LOGO_DURATION, b"\x06\x00", LogoDuration.LOOP), (CharSetting.CALIBRATE_CJC, b"\x01\x00", True), (CharSetting.BLE_ENABLED, b"\x01\x00", True), (CharSetting.USB_PD_MODE, b"\x02\x00", USBPDMode.SAFE), (CharSetting.HALL_SLEEP_TIME, b"\x05\x00", 25), (CharSetting.TIP_TYPE, b"\x01\x00", TipType.TS100_LONG), ], ) async def test_get_settings_specific( mock_bleak_client: AsyncMock, setting: CharSetting, raw_value: bytes, result: Any, ): """Test get_settings for specific settings.""" mock_bleak_client.read_gatt_char.return_value = raw_value client = Pynecil("AA:BB:CC:DD:EE:FF") settings = await client.get_settings([setting]) assert settings.get(setting.name.lower()) == result assert len(settings) == 1 mock_bleak_client.read_gatt_char.assert_awaited() assert mock_bleak_client.read_gatt_char.call_count == 1 async def test_get_settings_communication_error(mock_bleak_client: AsyncMock): """Test get_settings with communication error.""" mock_bleak_client.read_gatt_char.side_effect = BleakError client = Pynecil("AA:BB:CC:DD:EE:FF") with pytest.raises(CommunicationError): await client.get_settings() @pytest.mark.parametrize( ("setting", "characteristic", "raw_value", "value"), [ ( CharSetting.SETPOINT_TEMP, UUID("f6d70000-5a10-4eba-aa55-33e27f9bc533"), b"@\x01", 320, ), ( CharSetting.SLEEP_TEMP, UUID("f6d70001-5a10-4eba-aa55-33e27f9bc533"), b"\x96\x00", 150, ), ( CharSetting.SLEEP_TIMEOUT, UUID("f6d70002-5a10-4eba-aa55-33e27f9bc533"), b"\x05\x00", 5, ), ( CharSetting.MIN_DC_VOLTAGE_CELLS, UUID("f6d70003-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", BatteryType.BATTERY_3S, ), ( CharSetting.MIN_VOLTAGE_PER_CELL, UUID("f6d70004-5a10-4eba-aa55-33e27f9bc533"), b"!\x00", 3.3, ), ( CharSetting.QC_IDEAL_VOLTAGE, UUID("f6d70005-5a10-4eba-aa55-33e27f9bc533"), b"Z\x00", 9.0, ), ( CharSetting.ORIENTATION_MODE, UUID("f6d70006-5a10-4eba-aa55-33e27f9bc533"), b"\x02\x00", ScreenOrientationMode.AUTO, ), ( CharSetting.ACCEL_SENSITIVITY, UUID("f6d70007-5a10-4eba-aa55-33e27f9bc533"), b"\x07\x00", 7, ), ( CharSetting.ANIMATION_LOOP, UUID("f6d70008-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", True, ), ( CharSetting.ANIMATION_SPEED, UUID("f6d70009-5a10-4eba-aa55-33e27f9bc533"), b"\x02\x00", AnimationSpeed.MEDIUM, ), ( CharSetting.AUTOSTART_MODE, UUID("f6d7000a-5a10-4eba-aa55-33e27f9bc533"), b"\x03\x00", AutostartMode.IDLE, ), ( CharSetting.SHUTDOWN_TIME, UUID("f6d7000b-5a10-4eba-aa55-33e27f9bc533"), b"\x0a\x00", 10, ), ( CharSetting.COOLING_TEMP_BLINK, UUID("f6d7000c-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", True, ), ( CharSetting.IDLE_SCREEN_DETAILS, UUID("f6d7000d-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", True, ), ( CharSetting.SOLDER_SCREEN_DETAILS, UUID("f6d7000e-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", True, ), ( CharSetting.TEMP_UNIT, UUID("f6d7000f-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", TempUnit.FAHRENHEIT, ), ( CharSetting.DESC_SCROLL_SPEED, UUID("f6d70010-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", ScrollSpeed.FAST, ), ( CharSetting.LOCKING_MODE, UUID("f6d70011-5a10-4eba-aa55-33e27f9bc533"), b"\x02\x00", LockingMode.FULL_LOCKING, ), ( CharSetting.KEEP_AWAKE_PULSE_POWER, UUID("f6d70012-5a10-4eba-aa55-33e27f9bc533"), b"\x05\x00", 0.5, ), ( CharSetting.KEEP_AWAKE_PULSE_DELAY, UUID("f6d70013-5a10-4eba-aa55-33e27f9bc533"), b"\x04\x00", 4, ), ( CharSetting.KEEP_AWAKE_PULSE_DURATION, UUID("f6d70014-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", 1, ), ( CharSetting.VOLTAGE_DIV, UUID("f6d70015-5a10-4eba-aa55-33e27f9bc533"), b"X\x02", 600, ), ( CharSetting.BOOST_TEMP, UUID("f6d70016-5a10-4eba-aa55-33e27f9bc533"), b"\xa4\x01", 420, ), ( CharSetting.CALIBRATION_OFFSET, UUID("f6d70017-5a10-4eba-aa55-33e27f9bc533"), b"\x84\x03", 900, ), ( CharSetting.POWER_LIMIT, UUID("f6d70018-5a10-4eba-aa55-33e27f9bc533"), b"x\x00", 120, ), ( CharSetting.INVERT_BUTTONS, UUID("f6d70019-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", True, ), ( CharSetting.TEMP_INCREMENT_LONG, UUID("f6d7001a-5a10-4eba-aa55-33e27f9bc533"), b"\x0a\x00", 10, ), ( CharSetting.TEMP_INCREMENT_SHORT, UUID("f6d7001b-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", 1, ), ( CharSetting.HALL_SENSITIVITY, UUID("f6d7001c-5a10-4eba-aa55-33e27f9bc533"), b"\x07\x00", 7, ), ( CharSetting.ACCEL_WARN_COUNTER, UUID("f6d7001d-5a10-4eba-aa55-33e27f9bc533"), b"\x09\x00", 9, ), ( CharSetting.PD_WARN_COUNTER, UUID("f6d7001e-5a10-4eba-aa55-33e27f9bc533"), b"\x09\x00", 9, ), ( CharSetting.UI_LANGUAGE, UUID("f6d7001f-5a10-4eba-aa55-33e27f9bc533"), b"\xd7\xa1", LanguageCode.EN, ), ( CharSetting.PD_NEGOTIATION_TIMEOUT, UUID("f6d70020-5a10-4eba-aa55-33e27f9bc533"), b"\x14\x00", 2.0, ), ( CharSetting.DISPLAY_INVERT, UUID("f6d70021-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", True, ), ( CharSetting.DISPLAY_BRIGHTNESS, UUID("f6d70022-5a10-4eba-aa55-33e27f9bc533"), b"e\x00", 5, ), ( CharSetting.LOGO_DURATION, UUID("f6d70023-5a10-4eba-aa55-33e27f9bc533"), b"\x06\x00", LogoDuration.LOOP, ), ( CharSetting.CALIBRATE_CJC, UUID("f6d70024-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", True, ), ( CharSetting.BLE_ENABLED, UUID("f6d70025-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", True, ), ( CharSetting.USB_PD_MODE, UUID("f6d70026-5a10-4eba-aa55-33e27f9bc533"), b"\x02\x00", USBPDMode.SAFE, ), ( CharSetting.HALL_SLEEP_TIME, UUID("f6d70035-5a10-4eba-aa55-33e27f9bc533"), b"\x05\x00", 25, ), ( CharSetting.TIP_TYPE, UUID("f6d70036-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", TipType.TS100_LONG, ), ( CharLive.LIVE_TEMP, UUID("d85ef001-168e-4a71-aa55-33e27f9bc533"), b"\xf1\x00", 241, ), ( CharLive.SETPOINT_TEMP, UUID("d85ef002-168e-4a71-aa55-33e27f9bc533"), b"\xf0\x00", 240, ), ( CharLive.DC_VOLTAGE, UUID("d85ef003-168e-4a71-aa55-33e27f9bc533"), b"\xc9\x00", 20.1, ), ( CharLive.HANDLE_TEMP, UUID("d85ef004-168e-4a71-aa55-33e27f9bc533"), b"+\x01", 29.9, ), ( CharLive.PWM_LEVEL, UUID("d85ef005-168e-4a71-aa55-33e27f9bc533"), b"\x08\x00", 3, ), ( CharLive.POWER_SRC, UUID("d85ef006-168e-4a71-aa55-33e27f9bc533"), b"\x03\x00", PowerSource.PD, ), ( CharLive.TIP_RESISTANCE, UUID("d85ef007-168e-4a71-aa55-33e27f9bc533"), b">\x00", 6.2, ), ( CharLive.UPTIME, UUID("d85ef008-168e-4a71-aa55-33e27f9bc533"), b"E\x02", 58.1, ), ( CharLive.MOVEMENT_TIME, UUID("d85ef009-168e-4a71-aa55-33e27f9bc533"), b"\xc2\x00", 19.4, ), ( CharLive.MAX_TIP_TEMP_ABILITY, UUID("d85ef00a-168e-4a71-aa55-33e27f9bc533"), b"\xb8\x01", 440, ), ( CharLive.TIP_VOLTAGE, UUID("d85ef00b-168e-4a71-aa55-33e27f9bc533"), b"^\x16", 5726, ), ( CharLive.HALL_SENSOR, UUID("d85ef00c-168e-4a71-aa55-33e27f9bc533"), b"\x00\x00", 0, ), ( CharLive.OPERATING_MODE, UUID("d85ef00d-168e-4a71-aa55-33e27f9bc533"), b"\x01\x00", OperatingMode.SOLDERING, ), ( CharLive.ESTIMATED_POWER, UUID("d85ef00e-168e-4a71-aa55-33e27f9bc533"), b"\x19\x00", 2.5, ), ], ) async def test_read_success( mock_bleak_client: AsyncMock, setting: CharSetting | CharLive, characteristic: UUID, raw_value: bytes, value: Any, ): """Test read function success.""" mock_bleak_client.read_gatt_char.return_value = raw_value client = Pynecil("AA:BB:CC:DD:EE:FF") result = await client.read(setting) assert result == value mock_bleak_client.read_gatt_char.assert_awaited_once_with(characteristic) async def test_read_invalid_characteristic(mock_bleak_client: AsyncMock): """Test read function with invalid characteristic.""" client = Pynecil("AA:BB:CC:DD:EE:FF") result = await client.read(MagicMock()) # type: ignore assert result is None mock_bleak_client.read_gatt_char.assert_not_awaited() async def test_read_communication_error(mock_bleak_client: AsyncMock): """Test read function with communication error.""" mock_bleak_client.read_gatt_char.side_effect = BleakError client = Pynecil("AA:BB:CC:DD:EE:FF") with pytest.raises(CommunicationError): await client.get_settings() @pytest.mark.parametrize( ("setting", "characteristic", "raw_value", "value"), [ ( CharSetting.SETPOINT_TEMP, UUID("f6d70000-5a10-4eba-aa55-33e27f9bc533"), b"@\x01", 320, ), ( CharSetting.SLEEP_TEMP, UUID("f6d70001-5a10-4eba-aa55-33e27f9bc533"), b"\x96\x00", 150, ), ( CharSetting.SLEEP_TIMEOUT, UUID("f6d70002-5a10-4eba-aa55-33e27f9bc533"), b"\x05\x00", 5, ), ( CharSetting.MIN_DC_VOLTAGE_CELLS, UUID("f6d70003-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", BatteryType.BATTERY_3S, ), ( CharSetting.MIN_VOLTAGE_PER_CELL, UUID("f6d70004-5a10-4eba-aa55-33e27f9bc533"), b"!\x00", 3.3, ), ( CharSetting.QC_IDEAL_VOLTAGE, UUID("f6d70005-5a10-4eba-aa55-33e27f9bc533"), b"Z\x00", 9.0, ), ( CharSetting.ORIENTATION_MODE, UUID("f6d70006-5a10-4eba-aa55-33e27f9bc533"), b"\x02\x00", ScreenOrientationMode.AUTO, ), ( CharSetting.ACCEL_SENSITIVITY, UUID("f6d70007-5a10-4eba-aa55-33e27f9bc533"), b"\x07\x00", 7, ), ( CharSetting.ANIMATION_LOOP, UUID("f6d70008-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", True, ), ( CharSetting.ANIMATION_SPEED, UUID("f6d70009-5a10-4eba-aa55-33e27f9bc533"), b"\x02\x00", AnimationSpeed.MEDIUM, ), ( CharSetting.AUTOSTART_MODE, UUID("f6d7000a-5a10-4eba-aa55-33e27f9bc533"), b"\x03\x00", AutostartMode.IDLE, ), ( CharSetting.SHUTDOWN_TIME, UUID("f6d7000b-5a10-4eba-aa55-33e27f9bc533"), b"\x0a\x00", 10, ), ( CharSetting.COOLING_TEMP_BLINK, UUID("f6d7000c-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", True, ), ( CharSetting.IDLE_SCREEN_DETAILS, UUID("f6d7000d-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", True, ), ( CharSetting.SOLDER_SCREEN_DETAILS, UUID("f6d7000e-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", True, ), ( CharSetting.TEMP_UNIT, UUID("f6d7000f-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", TempUnit.FAHRENHEIT, ), ( CharSetting.DESC_SCROLL_SPEED, UUID("f6d70010-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", ScrollSpeed.FAST, ), ( CharSetting.LOCKING_MODE, UUID("f6d70011-5a10-4eba-aa55-33e27f9bc533"), b"\x02\x00", LockingMode.FULL_LOCKING, ), ( CharSetting.KEEP_AWAKE_PULSE_POWER, UUID("f6d70012-5a10-4eba-aa55-33e27f9bc533"), b"\x05\x00", 0.5, ), ( CharSetting.KEEP_AWAKE_PULSE_DELAY, UUID("f6d70013-5a10-4eba-aa55-33e27f9bc533"), b"\x04\x00", 4, ), ( CharSetting.KEEP_AWAKE_PULSE_DURATION, UUID("f6d70014-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", 1, ), ( CharSetting.VOLTAGE_DIV, UUID("f6d70015-5a10-4eba-aa55-33e27f9bc533"), b"X\x02", 600, ), ( CharSetting.BOOST_TEMP, UUID("f6d70016-5a10-4eba-aa55-33e27f9bc533"), b"\xa4\x01", 420, ), ( CharSetting.CALIBRATION_OFFSET, UUID("f6d70017-5a10-4eba-aa55-33e27f9bc533"), b"\x84\x03", 900, ), ( CharSetting.POWER_LIMIT, UUID("f6d70018-5a10-4eba-aa55-33e27f9bc533"), b"x\x00", 120, ), ( CharSetting.INVERT_BUTTONS, UUID("f6d70019-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", True, ), ( CharSetting.TEMP_INCREMENT_LONG, UUID("f6d7001a-5a10-4eba-aa55-33e27f9bc533"), b"\x0a\x00", 10, ), ( CharSetting.TEMP_INCREMENT_SHORT, UUID("f6d7001b-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", 1, ), ( CharSetting.HALL_SENSITIVITY, UUID("f6d7001c-5a10-4eba-aa55-33e27f9bc533"), b"\x07\x00", 7, ), ( CharSetting.ACCEL_WARN_COUNTER, UUID("f6d7001d-5a10-4eba-aa55-33e27f9bc533"), b"\x09\x00", 9, ), ( CharSetting.PD_WARN_COUNTER, UUID("f6d7001e-5a10-4eba-aa55-33e27f9bc533"), b"\x09\x00", 9, ), ( CharSetting.UI_LANGUAGE, UUID("f6d7001f-5a10-4eba-aa55-33e27f9bc533"), b"\xd7\xa1", LanguageCode.EN, ), ( CharSetting.UI_LANGUAGE, UUID("f6d7001f-5a10-4eba-aa55-33e27f9bc533"), b"\xd7\xa1", "EN", ), ( CharSetting.PD_NEGOTIATION_TIMEOUT, UUID("f6d70020-5a10-4eba-aa55-33e27f9bc533"), b"\x14\x00", 2.0, ), ( CharSetting.DISPLAY_INVERT, UUID("f6d70021-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", True, ), ( CharSetting.DISPLAY_BRIGHTNESS, UUID("f6d70022-5a10-4eba-aa55-33e27f9bc533"), b"e\x00", 5, ), ( CharSetting.LOGO_DURATION, UUID("f6d70023-5a10-4eba-aa55-33e27f9bc533"), b"\x06\x00", LogoDuration.LOOP, ), ( CharSetting.CALIBRATE_CJC, UUID("f6d70024-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", True, ), ( CharSetting.BLE_ENABLED, UUID("f6d70025-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", True, ), ( CharSetting.USB_PD_MODE, UUID("f6d70026-5a10-4eba-aa55-33e27f9bc533"), b"\x02\x00", USBPDMode.SAFE, ), ( CharSetting.HALL_SLEEP_TIME, UUID("f6d70035-5a10-4eba-aa55-33e27f9bc533"), b"\x05\x00", 25, ), ( CharSetting.TIP_TYPE, UUID("f6d70036-5a10-4eba-aa55-33e27f9bc533"), b"\x01\x00", TipType.TS100_LONG, ), ], ) async def test_write_success( mock_bleak_client: AsyncMock, setting: CharSetting, characteristic: UUID, value: Any, raw_value: bytes, ): """Test write function.""" client = Pynecil("AA:BB:CC:DD:EE:FF") await client.write(setting, value) mock_bleak_client.write_gatt_char.assert_called_once_with(characteristic, raw_value) async def test_write_clip_values(mock_bleak_client: AsyncMock): """Test write function with invalid value.""" client = Pynecil("AA:BB:CC:DD:EE:FF") await client.write(CharSetting.SETPOINT_TEMP, 900) mock_bleak_client.write_gatt_char.assert_called_once_with( UUID("f6d70000-5a10-4eba-aa55-33e27f9bc533"), b"R\x03" ) mock_bleak_client.reset_mock() await client.write(CharSetting.SETPOINT_TEMP, 0) mock_bleak_client.write_gatt_char.assert_called_once_with( UUID("f6d70000-5a10-4eba-aa55-33e27f9bc533"), b"\n\x00" ) async def test_write_communication_error(mock_bleak_client: AsyncMock): """Test write function with communication error.""" mock_bleak_client.write_gatt_char.side_effect = BleakError client = Pynecil("AA:BB:CC:DD:EE:FF") with pytest.raises(CommunicationError): await client.write(CharSetting.SETPOINT_TEMP, 300)