diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..345b99c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint-and-test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Sync deps (project + dev group) + run: uv sync --python ${{ matrix.python-version }} + + - name: Ruff + run: uv run --python ${{ matrix.python-version }} ruff check . + + - name: Pytest + run: uv run --python ${{ matrix.python-version }} pytest -q + + build: + runs-on: ubuntu-latest + needs: lint-and-test + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: Build wheel + sdist + run: | + uv sync + uv run python -m build + uv run twine check --strict dist/* + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..591c777 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,89 @@ +name: release + +on: + push: + tags: ["v*.*.*", "v*.*.*a*", "v*.*.*b*", "v*.*.*rc*"] + workflow_dispatch: + inputs: + target: + description: "Where to publish" + required: true + default: "testpypi" + type: choice + options: + - testpypi + - pypi + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: Build wheel + sdist + run: | + uv sync + uv run python -m build + uv run twine check --strict dist/* + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish-testpypi: + # TestPyPI publish: + # * any pre-release tag (vX.Y.ZaN / bN / rcN), or + # * manual dispatch with target=testpypi. + # Stable tags skip TestPyPI and go straight to PyPI. + runs-on: ubuntu-latest + needs: build + if: | + (startsWith(github.ref, 'refs/tags/v') && + (contains(github.ref_name, 'a') || contains(github.ref_name, 'b') || contains(github.ref_name, 'rc'))) + || (github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi') + environment: + name: testpypi + url: https://test.pypi.org/p/spiderwire + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + publish-pypi: + # Real PyPI publish: + # * stable tag (vX.Y.Z, no a/b/rc), or + # * manual dispatch with target=pypi. + runs-on: ubuntu-latest + needs: build + if: | + (startsWith(github.ref, 'refs/tags/v') && + !contains(github.ref_name, 'a') && !contains(github.ref_name, 'b') && !contains(github.ref_name, 'rc')) + || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi') + environment: + name: pypi + url: https://pypi.org/p/spiderwire + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbc6e3a --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Byte-compiled / cache +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +build/ +dist/ +*.egg-info/ +*.egg +wheels/ + +# Virtual envs +.venv/ +venv/ +env/ + +# Tooling caches +.ruff_cache/ +.pytest_cache/ +.mypy_cache/ +.tox/ +.coverage +htmlcov/ + +# uv +uv.lock.local + +# Local secrets / env +.env +.env.local + +# IDE / OS +.idea/ +.vscode/ +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..818d66a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-05-09 + +Initial release. + +### Added + +- `spiderwire` library: Modbus RTU protocol (`protocol`), RS-485 transport + (`transport`), per-device register map and decoders (`registers`), and + tiered bus master with setpoint heartbeat (`bus`). +- `gss-ctrl` CLI: `scan`, `poll`, `read`, `write`, `fan`, `blower`, + `light` commands. Acts as a stand-in master for the OEM SpiderFarmer + GSS hub on a USB-RS485 adapter. +- Reference docs: `docs/protocol-analysis.md`, `docs/device-map.md`, + `docs/hw-notes.md`. + +[Unreleased]: https://github.com/1am/spiderwire/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/1am/spiderwire/releases/tag/v0.1.0 diff --git a/LICENSE b/LICENSE index a37ab6b..986f357 100644 --- a/LICENSE +++ b/LICENSE @@ -1,641 +1,21 @@ -GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - . - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. +MIT License + +Copyright (c) 2026 1AM + +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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f6d48c0 --- /dev/null +++ b/Makefile @@ -0,0 +1,83 @@ +# SpiderWire — dev shortcuts for the gss-ctrl CLI. + +# Load .env if present and export every var to recipe shells. +# Recipe lines run in their own shell, so plain `make` vars don't reach +# tools like twine that look at the environment — `export` bridges that. +ifneq (,$(wildcard .env)) +include .env +export +endif + +PORT ?= /dev/ttyUSB0 +ADDR ?= 0x0A +QTY ?= 28 +SPEED ?= 15 +PCT ?= 50 +INTERVAL ?= 1.0 +HEARTBEAT ?= 3.5 + +PYTHON ?= uv run python + +.PHONY: install test lint build check publish-test publish scan poll read fan blower light help + +help: + @echo "Dev:" + @echo " make install Install deps + dev extras (uv sync --extra dev)" + @echo " make test Run pytest suite" + @echo " make lint Run ruff" + @echo " make build Build wheel + sdist into dist/" + @echo " make check twine check --strict dist/*" + @echo " make publish-test Upload dist/ to TestPyPI (creds via .env)" + @echo " make publish Upload dist/ to PyPI (creds via .env)" + @echo "" + @echo " .env (gitignored) is auto-loaded. Expected keys for publish:" + @echo " TWINE_USERNAME=__token__" + @echo " TWINE_PASSWORD=pypi-... # PyPI or TestPyPI API token" + @echo "" + @echo "Bus operations:" + @echo " make scan PORT=/dev/ttyUSB0 Scan bus for devices" + @echo " make poll PORT=/dev/ttyUSB0 Master mode (poll + heartbeat)" + @echo " make read PORT=... ADDR=0x0A QTY=28" + @echo " make fan PORT=... ADDR=0x04 SPEED=15" + @echo " make light PORT=... PCT=50" + @echo " make blower PORT=... PCT=40" + +install: + uv sync + +test: + uv run pytest -q + +lint: + uv run ruff check . + +build: + rm -rf dist/ + uv run python -m build + +check: + uv run twine check --strict dist/* + +publish-test: + uv run twine upload --repository testpypi dist/* + +publish: + uv run twine upload dist/* + +scan: + $(PYTHON) -m gss_ctrl_pc -v $(PORT) scan + +poll: + $(PYTHON) -m gss_ctrl_pc -v $(PORT) poll --interval $(INTERVAL) --heartbeat $(HEARTBEAT) + +read: + $(PYTHON) -m gss_ctrl_pc -v $(PORT) read $(ADDR) $(QTY) + +fan: + $(PYTHON) -m gss_ctrl_pc -v $(PORT) fan $(ADDR) $(SPEED) + +light: + $(PYTHON) -m gss_ctrl_pc -v $(PORT) light $(PCT) + +blower: + $(PYTHON) -m gss_ctrl_pc -v $(PORT) blower $(PCT) diff --git a/README.md b/README.md index 3cb18b1..d5d48d9 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,69 @@ # SpiderWire +[![PyPI](https://img.shields.io/pypi/v/spiderwire.svg)](https://pypi.org/project/spiderwire/) +[![Python](https://img.shields.io/pypi/pyversions/spiderwire.svg)](https://pypi.org/project/spiderwire/) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![CI](https://github.com/1am/spiderwire/actions/workflows/ci.yml/badge.svg)](https://github.com/1am/spiderwire/actions/workflows/ci.yml) + Open Modbus RTU library and `gss-ctrl` CLI for the **SpiderFarmer GSS** peripheral bus (inline fans, CO₂ sensor, sensor hub, light driver). The OEM GSS hub is a Modbus RTU master over a proprietary RJ12 wire layout; -SpiderWire replaces that hub so you can drive the bus from any host — +SpiderWire replaces that hub so you can drive the bus from any host - locally, no cloud account. +> **Unofficial and experimental.** This is an independent project with no +> affiliation, endorsement, or relationship with SpiderFarmer. It works +> on my hardware, but the GSS ecosystem ships in many hardware and +> firmware revisions - yours may behave differently or not work at all. +> Expect rough edges and verify behavior on your own bus before relying +> on it. + Two surfaces, one library: -| Surface | Role | What ships with it | -|---|---|---| -| `spiderwire` Python package | Transport, register map, tiered bus master | `bus.py`, `protocol.py`, `registers.py`, `transport.py` | -| `gss-ctrl` CLI | Test / ops / manual control over USB-RS485 | `gss-ctrl scan / poll / read / write / fan / blower / light` | +| Surface | Role | What ships with it | +| --------------------------- | ------------------------------------------ | ------------------------------------------------------------ | +| `spiderwire` Python package | Transport, register map, tiered bus master | `bus.py`, `protocol.py`, `registers.py`, `transport.py` | +| `gss-ctrl` CLI | Test / ops / manual control over USB-RS485 | `gss-ctrl scan / poll / read / write / fan / blower / light` | + +A companion Home Assistant integration is under [spiderfarmer-ha](https://github.com/1am/spiderfarmer-ha). + +## Hardware + +See [./docs/hw-notes.md](./docs/hw-notes.md) for more details on how to connect to the bus. + +## Usage + +You can use it independently as a controller and a reader from the bus + +[![asciicast](https://asciinema.org/a/1030214.svg)](https://asciinema.org/a/1030214) -Companion repo: **[`spiderfarmer-ha`](https://github.com/1am/spiderfarmer-ha)** — a -Home Assistant integration that pulls SpiderWire in as a git submodule -and exposes the bus as HA entities. +It is also possible to just sniff the bus with GSS connected to see what is happening but as expecteed there will be quite a few CRC error with +2 devices polling on the same bus. -- [PCB preview](https://www.youtube.com/watch?v=0Yn37gflFO0) +[![asciicast](https://asciinema.org/a/pjMOnAvHK90CbKit.svg)](https://asciinema.org/a/pjMOnAvHK90CbKit) ## Install +From PyPI (recommended): + +```bash +pip install spiderwire +``` + +From source (for development): + ```bash git clone https://github.com/1am/spiderwire.git cd spiderwire -make install # uv sync — Python 3.13 + pyserial +uv sync # installs runtime + dev tools (pytest, ruff, build, twine) +# or, with pip: +pip install -e . +pip install pytest ruff build twine ``` +Requires Python 3.10+ and a USB-RS485 adapter (`pyserial` is the only +runtime dependency). + ## CLI quickstart ```bash @@ -49,28 +86,29 @@ Full CLI: `gss-ctrl --help`. Commands: `scan`, `poll`, `read`, `write`, ## Library use ```python -from spiderwire.transport import RS485Transport -from spiderwire.bus import BusMaster +from spiderwire import BusMaster, RS485Transport -with RS485Transport("/dev/ttyUSB0", baud=115200) as tx: +with RS485Transport("/dev/ttyUSB0", baudrate=115200) as tx: bus = BusMaster(tx) - for snapshot in bus.tick_forever(interval=1.0, heartbeat=3.5): - print(snapshot) + bus.poll_loop(interval=1.0, callback=print) ``` -See `bus.py` for the tiered scheduler, `registers.py` for the per-device -register map, and `transport.py` for the RS-485 framer. +See `src/spiderwire/bus.py` for the tiered scheduler, +`src/spiderwire/registers.py` for the per-device register map, and +`src/spiderwire/transport.py` for the RS-485 framer. ## Layout ``` spiderwire/ -├── spiderwire/ Python package (lib) -│ ├── bus.py tiered master + heartbeat scheduler -│ ├── protocol.py Modbus RTU framing + CRC -│ ├── registers.py per-device register map + decoders -│ └── transport.py RS-485 framer over pyserial -├── gss_ctrl_pc/ gss-ctrl CLI (stand-in for the OEM master) +├── src/ +│ ├── spiderwire/ Python package (lib) +│ │ ├── bus.py tiered master + heartbeat scheduler +│ │ ├── protocol.py Modbus RTU framing + CRC +│ │ ├── registers.py per-device register map + decoders +│ │ └── transport.py RS-485 framer over pyserial +│ └── gss_ctrl_pc/ gss-ctrl CLI (stand-in for the OEM master) +├── tests/ pytest suite (no hardware required) ├── docs/ protocol + device map reference ├── pyproject.toml builds the `spiderwire` wheel + `gss-ctrl` script └── Makefile dev shortcuts @@ -78,29 +116,21 @@ spiderwire/ ## Docs -- [`docs/device-map.md`](docs/device-map.md) — per-device register map +- [`docs/device-map.md`](docs/device-map.md) - per-device register map for every address observed on the bus. -- [`docs/protocol-analysis.md`](docs/protocol-analysis.md) — protocol +- [`docs/protocol-analysis.md`](docs/protocol-analysis.md) - protocol reference: physical layer, function codes, tiered polling, heartbeat. -- [`docs/hw-notes.md`](docs/hw-notes.md) — OEM hub board notes and RJ12 +- [`docs/hw-notes.md`](docs/hw-notes.md) - OEM hub board notes and RJ12 pinout. ## License -Copyright (C) 2026 1AM - -[GNU General Public License v3.0 or later](LICENSE) — free for any -use, including commercial, with one strong condition: any product or -distribution that includes this code (modified or unmodified) must -itself be released under GPLv3, with full source code available to its -users. - -That means a vendor — including SpiderFarmer or any successor — cannot -ship this code inside a closed-source product. They have two options: +Copyright (c) 2026 [1AM](https://1am.pl) -1. Open-source their integration / firmware under GPLv3, with - attribution preserved, **or** -2. Obtain a separate commercial license from me. +Released under the [MIT License](LICENSE) - free to use, modify, and +distribute, including in commercial and closed-source products. The +only requirement is that the copyright notice and license text are +preserved in copies or substantial portions of the software. ## Disclaimer @@ -116,8 +146,8 @@ verifying the correctness of your wiring, your device configuration, and the commands you send. In no event shall the author or contributors be liable for any direct, -indirect, incidental, special, exemplary, or consequential damages — +indirect, incidental, special, exemplary, or consequential damages - including but not limited to damage to equipment, crops, property, or -persons — arising from the use of, or inability to use, this software. +persons - arising from the use of, or inability to use, this software. Use at your own risk. diff --git a/docs/assets/rj12.jpg b/docs/assets/rj12.jpg new file mode 100644 index 0000000..982307a Binary files /dev/null and b/docs/assets/rj12.jpg differ diff --git a/docs/assets/usb-rs485-power.jpg b/docs/assets/usb-rs485-power.jpg new file mode 100644 index 0000000..f07041b Binary files /dev/null and b/docs/assets/usb-rs485-power.jpg differ diff --git a/docs/device-map.md b/docs/device-map.md new file mode 100644 index 0000000..15badea --- /dev/null +++ b/docs/device-map.md @@ -0,0 +1,212 @@ +# Device Map — Current Bus State + +Snapshot of what actually lives on the RS-485 bus as captured by the +`gss-ctrl` CLI (`gss-ctrl scan` / `read`, or `make scan` / +`make read` from the repo root). Example port: +`/dev/cu.usbserial-130` (macOS). +See [`protocol-analysis.md`](./protocol-analysis.md) for the full +protocol write-up and historical observations; this file is a concise, +per-device reference grouped by role. + +**Code:** parsing, tier lists, and `DEFAULT_QTY` live in +[`registers.py`](../src/spiderwire/registers.py); +orchestration in +[`bus.py`](../src/spiderwire/bus.py). +The Home Assistant custom component +[`custom_components/spiderfarmer/`](https://github.com/1am/spiderfarmer-ha/tree/main/custom_components/spiderfarmer/) +drives the same `BusMaster` + `RS485Transport` stack (see below). + +## Summary + +| Addr | Role | Device | Header type (reg 6) | Model (reg 4) | FW (reg 2–3) | HW (reg 7) | Reliable register qty | +| ---- | --------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------- | ------------- | --------------------- | ---------- | ----------------------------------------------------------- | +| 0x03 | **Sensor** | CO₂ sensor | `0x0308` | `0x0330` | `"05.12"` (ASCII) | `0x0202` | 13 | +| 0x0A | **Sensor** + actuator | Sensor hub (air T/RH, soil T, PPFD); `reg 19` is the *reported* light value, not a setpoint | `0x0400` | `0x0330` | `"05.12"` (ASCII) | `0x0202` | 28 | +| 0x04 | **Actuator** | **Light dimmer (primary)** — accepts FC06 → reg 10 in **percent (0-100)**, never echoes writes | `0x0301` | `0x0426` | `0xF784C96D` (binary) | `0x0105` | 24 — silent to FC03 in current rig, but acts on FC06 writes | +| 0x06 | **Actuator** | **Blower / ventilation** (OEM UI calls it *"Light 2"*) — FC06 → **reg 14** in percent (0-100), *does* echo | `0x0204` | `0x043D` | `0xF784C90F` (binary) | `0x0102` | 16 | + +`DeviceHeader.type_name` in `registers.py` (`LIGHT=0x02`, `FAN=0x03`, +`SENSOR_HUB=0x04`) maps **only the high byte** of register 6. That is +why the CLI prints `fan` for the CO₂ sensor at `0x03` (header type high +byte is `0x03`). The subtype in the low byte is what actually +distinguishes CO₂ (`0x08`) from the duct fan (`0x01`). + +## Tier placement (OEM orchestration) + +`BusMaster.tick()` (see `bus.py`) interleaves the same four schedules the +OEM GSS hub uses (see `protocol-analysis.md` §"Bus Timing"). On each call, +actuators run **when their ~2.5 s deadline has elapsed** (before the fast +pair), the fast sensor pair **always** runs, then heartbeat / silent scan +fire on their own timers: + +| Tier | Cadence | Addresses / action | +| ----------------------- | ------------------------------ | ------------------------------------------------------------------------ | +| B — actuators | ~2.5 s | `0x04`, `0x06` (FC03 read when due) | +| A — fast sensors | every tick (~1.0 s outer loop) | `0x03`, `0x0A` | +| D — heartbeat broadcast | ~3.5 s | FC 0x10 → 0x00, 26 regs @ 1001 | +| C — silent-slot scan | ~7.0 s | `0x10, 0x02, 0x01, 0x0C, 0x05, 0x07, 0x0B, 0x0D, 0x0E, 0x08, 0x0F, 0x13` | + +Moving a device between tiers is editing `FAST_ADDRS`, +`ACTUATOR_ADDRS`, or `SCAN_ADDRS` in `spiderwire/registers.py` (or +mutating `bus.fast_addrs` / `actuator_addrs` / `scan_addrs` at runtime). + +### Home Assistant (`spiderfarmer`) + +The integration's coordinator calls `BusMaster.tick()` once per HA +update (`DEFAULT_SCAN_INTERVAL` = 1 s in `custom_components/spiderfarmer/const.py`), so the Python +master tracks the OEM cadence internally. + +| HA platform | What appears | Bus mapping | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Sensor** | Air temp, humidity, VPD, soil temp, PPFD on the hub; CO₂ ppm on `0x03` | Typed `SensorHubData` / `CO2SensorData` from coordinator data; unique ids `sf_0x0a_` / `sf_0x03_co2` (hex lower case). | +| **Light** | One **Light 1** entity, attached to the **dimmer device (`0x04`)** so the user sees it under "Light Driver" rather than under the sensor hub | State from hub regs **18** (on/off) and **19** (reported %). `turn_on` writes hub reg 18 → 1, dimmer `0x04` reg **16** → 1 and reg **10** → % — **all FC06 blind**, neither the hub nor the dimmer echoes writes on this firmware. `turn_off` writes hub reg 18 → 0 and dimmer reg 10 → 0. Unique id stays `sf_0x0a_light1` (legacy, hub-scoped) so existing installs don't lose their entity. | +| **Fan** | **Blower** for each `BlowerData` (today: `0x06`) | HA fan entity with `translation_key` blower; unique id `sf_0x06_blower`. Writes FC06 → reg **14** (0–100 %), waits for echo. | +| **Number** | **Light brightness** (under "Light Driver") and **Blower speed** (under "Blower") — direct 0–100 % sliders visible on the dashboard / tile cards, complementing the on/off light + fan entities | Brightness writes the same dimmer reg 10 (with reg 16 = 1 latch) as the light entity but does **not** touch the hub gate. Blower speed writes blower reg 14, identical to the fan entity's `set_percentage`. Unique ids `sf__brightness` / `sf__blower_percent`. | + +All four platforms use a **dynamic discovery** loop (`setup_dynamic_entities` in `entity.py`): entities are added both at first refresh **and** on subsequent coordinator updates as new addresses appear. This is what makes the CO₂ sensor at `0x03` show up reliably even when it misses the very first poll burst — without it, anything not on the bus during HA's startup tick would stay invisible until a full reload. + +Device names in the HA UI are derived from the *parsed* device class +(`SensorHubData` → "Sensor Hub", `CO2SensorData` → "CO₂ Sensor", +`FanControllerData` → "Light Driver", `BlowerData` → "Blower" — see +`_ROLE_NAMES` in `entity.py`), **not** from `DeviceHeader.type_name`. +The OEM SKUs on this rig all mis-identify themselves in register 6 +(0x03 CO₂ reports `fan`, 0x04 dimmer reports `fan`, 0x06 blower reports +`light`); using `type_name` for the device label is what produced the +"Blower device with a CO₂ sensor inside" misnaming on first install. + +There is no separate switch platform: light enable is folded into the +light entity. Legacy `BusMaster` helpers (`set_fan_speed` / +`set_fan_enable` on reg 10 / 16) remain for a true fan-class actuator if +one is wired at `0x04` on another rig. + +## Common Header (all devices, regs 0–9) + +| Reg | Meaning | Notes | +| --- | ------------------------------- | ------------------------------------------------------------------------------------ | +| 0 | Self-reported Modbus address | matches polling addr | +| 1 | `0xAA` magic byte << 8 \| addr | e.g. `0xAA0A` for `0x0A` | +| 2–3 | Firmware version | ASCII `"HH.LL"` on SF-made units, binary `0xF784xxxx` on OEM‑branded LED/fan drivers | +| 4 | Model / product code | | +| 5 | Serial number fragment | | +| 6 | Device type (hi) : subtype (lo) | | +| 7 | Hardware version | | +| 8–9 | Reserved | always `0x0000` | + +--- + +## Sensors + +### `0x03` — CO₂ sensor *(13 registers)* + +Example FC03 read (`qty` ≥ 13; values illustrative): + +| Reg | Hex | Decimal | Interpretation | +| ------ | -------- | ------- | ---------------------------------- | +| 0 | `0x0003` | 3 | self-addr | +| 1 | `0xAA03` | 43523 | magic + addr | +| 2 | `0x3035` | — | FW hi = `"05"` | +| 3 | `0x3132` | — | FW lo = `"12"` | +| 4 | `0x0330` | 816 | model | +| 5 | `0x7518` | 29976 | serial frag | +| 6 | `0x0308` | 776 | type / subtype (sensor-class, CO₂) | +| 7 | `0x0202` | 514 | hw | +| 8–9 | `0x0000` | 0 | reserved | +| **10** | `0x01B5` | **437** | **CO₂ concentration [ppm]** | +| 11–12 | `0x0000` | 0 | reserved (outside the 13-reg spec) | + +The scan uses `DEFAULT_QTY[0x03]=13`, the response parses to `CO2SensorData`, and `co2_ppm = reg[10]`. Observed range across recent runs: **430–466 ppm**, consistent with an occupied indoor room. The value is confirmed real sensor data (not stale, not from another device). + +### `0x0A` — Sensor hub (air T / RH / soil T / PPFD) + Light 1 enable *(28 registers)* + +Full 28-register reads work reliably since `RS485Transport` switched +to deterministic, fixed-size frame reads (`spiderwire.transport`); +the old silence-timing heuristic truncated long frames. The +authoritative snapshot below comes from +`docs/capture-20260418-1135.sal` (Saleae, Session 9), where the OEM +GSS hub pulls the full 61-byte response with CRC OK. + +| Reg | Hex | Decimal | Interpretation | +| ------ | ------------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0–9 | — | — | header (self-addr `0x000A`, magic `0xAA0A`, FW `"05.12"`, model `0x0330`, type `0x0400`, hw `0x0202`) | +| **10** | `0x00F5` | **245** | **Air temperature × 10 → 24.5 °C** | +| **11** | `0x01B0` | **432** | **Air humidity × 10 → 43.2 %RH** | +| **12** | `0xFC18` | −1000 (signed) | **Soil temperature × 10** — `-1000` means probe not connected (`soil_temp_c = None`) | +| **13** | `0x0146` | **326** | **PPFD in µmol/m²/s** (matches in-app display 325–326) | +| 14 | `0x0163` | 355 | co-tracking light channel — peak / IR / DLI TBD | +| 15 | `0x23F8` | 9208 | constant — calibration / device config | +| 16–17 | `0x0000` | 0 | reserved | +| **18** | `0x0001` | 1 | **Light 1 enable flag** — written by master via **blind** FC 0x06 (the hub never echoes the write — `gss-ctrl light` and HA both timed out waiting for an echo before we switched to blind writes; reads of reg 18 still confirm the new value on the next poll) | +| **19** | `0x0064` | 100 | **Light 1 *reported* value** (read-only status echo of the dimmer at `0x04`; writing it does not change brightness — confirmed absent from all FC06 traffic in `capture-20260418-1152.sal`) | +| 20–21 | `0x0023`, `0x000A` | 35, 10 | zone / group (reg 21 matches device addr = 10) | +| 22–27 | `0x0000` | 0 | reserved / status flags | + +`BusMaster.set_light_enable(addr=0x0A, enable)` writes reg 18. VPD is +computed client-side from reg 10 + reg 11 via the Tetens SVP formula in +`SensorHubData.vpd_kpa`. PPFD from reg 13 is exposed as a sensor entity +with translation key `ppfd` on the hub device in Home Assistant. + +--- + +## Actuators + +### `0x04` — Light dimmer *(24 registers)* + +Header type `0x0301` reads as "fan subtype" via `registers.py`, but the actual device wired at `0x04` in the current rig is the **main grow-light dimmer** — confirmed in `docs/capture-20260418-1152.sal`: every time the user moved the OEM app's brightness slider (0 → 53 → 30 → 53 → 0 %), the hub emitted FC06 writes to `0x04 reg 10` carrying the percent value directly. Whether this is a re-flashed fan MCU or the OEM assigning the fan product code to their light driver is unresolved; treat `reg 10` here as a 0-100 % brightness setpoint. + +| Reg | Interpretation | Observed | +| ------ | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 0–9 | header | type `0x0301`, model `0x0426`, FW `0xF784C96D`, hw `0x0105` | +| **10** | **Brightness 0-100 %** (direct percent) | written by master via FC 0x06; OEM writes it blind (see below) | +| 11–15 | reserved | `0` | +| **16** | **Enable flag** — must be `1` for reg 10 writes to take effect | Throughout `capture-20260418-1152.sal` the FC03 responses show `reg[16] = 1`; the OEM sets it during an earlier session and the value sticks. If you start a fresh master without setting this bit, reg 10 writes land on the dimmer but produce no visible change. `gss-ctrl light` (and the HA light entity) writes `reg 16 ← 1` blind alongside the brightness. | +| 17–23 | reserved | `0` | + +**No Modbus echo on FC06 writes.** The device acts on the command but never sends the 8-byte echo back — `gss-ctrl light` and the HA integration use `write_register(..., wait_for_response=False)` for this address so they don't stall 300 ms per write. FC03 reads also time out most of the time in the current rig. + +The legacy fan helpers — `BusMaster.set_fan_speed(addr, 0..25)` / `set_fan_enable(addr, bool)` — still target `reg 10` / `reg 16`. If you genuinely have a duct fan wired here (different rig), speed is 0-25 and speeds below ~11 stall the motor; see `protocol-analysis.md` §"Fan Speed Control". + +### `0x06` — Blower / ventilation *(16 registers)* + +The OEM app labels this "Light 2" but the 0x06 SKU is physically wired +to the blower / ventilation fan. Confirmed in +`docs/capture-20260418-1452.sal`: the user drove the OEM slider +**0 → 74 → 60 → 25 → OFF** and every change produced an FC06 write to +`0x06 reg 14` carrying the percent directly; reg 10 stays `0` the entire +capture. The device's own 16-register broadcast echoes the live +setpoint back in reg 14 and latches reg 12 to 1 whenever the blower is +running. + +| Reg | Interpretation | Notes | +| ------ | -------------------- | ----------------------------------------------------------- | +| 0–9 | header | type `0x0204`, model `0x043D`, FW `0xF784C90F`, hw `0x0102` | +| 10 | unused | `0x0000` throughout every capture | +| 11 | paired flag | `0x0001` once the controller has linked, latches | +| **12** | **running flag** | `1` whenever the blower is actively driven, `0` when off | +| 13 | reserved | `0x0000` | +| **14** | **Blower % (0-100)** | master writes via FC 0x06; slave echoes | +| 15 | reserved | `0x0000` | + +`BusMaster.set_blower(addr, percent)` issues `write_register` on reg 14 +(`BLOWER_SETPOINT_REG` in `registers.py`) and waits for the standard FC06 +response echo. The OEM UI enforces a 25 % floor above 0 (it refuses to +slide lower without going to OFF), but the device itself accepts any +0–100 value — `set_blower` and the HA blower entity allow lower values +if desired. + +--- + +## Addresses polled but silent + +The OEM master polls `0x01, 0x02, 0x05, 0x07, 0x08, 0x0B–0x10, 0x13` every poll cycle. None of these respond in the current rig. The per-address register-count table in `registers.py` (`DEFAULT_QTY`) encodes the OEM's expected device class at each slot; nothing is physically installed there today. + +> During `gss-ctrl poll`, the top-of-screen "N online" counter sometimes shows phantom devices at those addresses with data identical to `0x03`'s. Those are garbled frames that happened to pass CRC — not real devices. Only the clean `scan` output above should be trusted for device discovery. + +## Broadcast heartbeat (master → 0x00) + +FC `0x10` @ register 1001, 26 words. `BusMaster.setpoints` holds the +live payload; `broadcast_setpoints()` ships it, and `tick()` fires one +every `heartbeat_interval` seconds (default 3.5 s, matching the OEM +hub). Peripherals enter a master-missing fail-safe if the heartbeat +stops. See `protocol-analysis.md` §"Broadcast Writes" for the +per-register layout; default non-zero values are `reg[1009]=7` and +`reg[1011]=1112` (both observed constant in every capture). diff --git a/docs/hw-notes.md b/docs/hw-notes.md new file mode 100644 index 0000000..699aef4 --- /dev/null +++ b/docs/hw-notes.md @@ -0,0 +1,49 @@ +# Hardware Notes + +Raw observations of the OEM SpiderFarmer GSS hub, kept separate from +the protocol reference. + +## Required hardware + +- USB to RS-485 converter — I used the [Waveshare USB-to-RS485-B](https://www.waveshare.com/usb-to-rs485-b.htm). +- 12V DC power adapter - I used 1A and it seemed enough, a more powerful one likely won't hurt + +## Wiring + +The cable is a 6-conductor RJ12 lead carrying both the RS-485 differential pair +and the +12 V supply for the peripheral. The diagram below shows where each +wire lands on the controller side. + +![./assets/usb-rs485-power.jpg](./assets/usb-rs485-power.jpg) + +### RJ12 pinout (peripheral-side) + +| Pin colour | Signal | +| ---------- | -------------- | +| green | GND | +| blue | +12 V | +| white | +12 V | +| black | RS-485 (B) | +| yellow | RS-485 (A) | +| red | (unused / TBD) | + +![./assets/rj12.jpg](./assets/rj12.jpg) + +## GSS OEM hub board + +- MCU: **ESP32-S3-WROOM-1** +- Ethernet PHY: likely [Davicom DM9051](https://www.davicom.com.tw/production-item.php?lang_id=en) + (SPI MAC+PHY; S3 has no internal EMAC, and the pin-compatible DM9051 + is what fits the 25 MHz crystal next to U6) +- RS-485 transceiver: TBD (on-board, DE-driven) +- LCD: parallel TFT (driver chip not yet identified — candidates + include ILI9341, ST7789V, ST7796, ILI9488) + +See [`firmware/sf-gss.yaml`](../firmware/sf-gss.yaml) for a draft +ESPHome firmware that can flash this hardware as a drop-in replacement +for the OEM firmware — bring-up still needs the LCD pin mapping and +driver chip verified. + +## Other interesting resources + +- [PCB preview video](https://www.youtube.com/watch?v=0Yn37gflFO0) — author was kind enough to record it so you don't have to open your GSS controller. diff --git a/docs/protocol-analysis.md b/docs/protocol-analysis.md new file mode 100644 index 0000000..d7a3acf --- /dev/null +++ b/docs/protocol-analysis.md @@ -0,0 +1,141 @@ +# SpiderFarmer GSS Modbus Protocol Analysis + +Reverse-engineered protocol reference for the SpiderFarmer GSS bus. +Captured over several hours of Saleae logic traces on a running rig; +the live register layout ships in +[`spiderwire/registers.py`](../src/spiderwire/registers.py) +and [`docs/device-map.md`](./device-map.md). + +## Physical Layer + +| Parameter | Value | +|-----------|-------| +| Interface | RS-485 (differential pair) | +| Baud rate | **115200** | +| Data bits | 8 | +| Parity | None | +| Stop bits | 1 | +| Byte order | Big-endian (register data) | +| CRC | Modbus CRC-16 (polynomial 0xA001, init 0xFFFF) | + +### RJ12 pinout (see `docs/hw-notes.md` for wire colours) + +| RJ12 pin | Signal | +|----------|--------| +| RS-485 A | black | +| RS-485 B | yellow | +| +12 V | white / blue | +| GND | green | + +## Protocol + +Standard **Modbus RTU**. Only three function codes observed: + +| FC | Name | Direction | Usage | +|----|------|-----------|-------| +| 0x03 | Read Holding Registers | master → slave → master | Poll sensors / identity | +| 0x06 | Write Single Register | master → slave | Set individual setpoints | +| 0x10 | Write Multiple Registers | master → broadcast (0x00) | Heartbeat / setpoint broadcast | + +## Bus Architecture + +One bus master (the GSS controller MCU, no Modbus address) polls +peripherals on a shared RS-485 bus. Discovered devices and the +OEM-expected register count per address slot are captured in +[`registers.DEFAULT_QTY`](../src/spiderwire/registers.py) +and summarised in [`device-map.md`](./device-map.md). + +## Register Map + +See [`device-map.md`](./device-map.md) for the per-device tables +(header, sensor hub, CO₂ sensor, light dimmer, blower). Highlights: + +- Every device exposes the same 10-register header (addr, magic byte + `0xAA`, firmware version, model, serial fragment, type/subtype, + hardware revision). +- Sensor hub (`0x0A`) holds air T/RH/soil T/PPFD and the Light 1 + enable flag (reg 18). FC06 writes to reg 18 are accepted but + **never echoed** on this firmware — fire blind, then re-read to + confirm. +- Light dimmer (`0x04`) takes 0-100 % brightness on reg 10 via FC06 + and **never echoes** — writes must be fired blind. +- Blower (`0x06`, OEM UI "Light 2") takes 0-100 % on reg 14 via FC06 + and **does echo**. + +## Bus Timing & Polling Pattern + +The OEM hub does **not** poll every slot uniformly. Four independent +schedules interleave, all reimplemented in +[`BusMaster.tick()`](../src/spiderwire/bus.py): + +| Tier | Addresses / Action | Period | +|------|---------------------|--------| +| A — Fast sensors | `0x03` (CO₂), `0x0A` (hub) | **~1.0 s** | +| B — Actuators | `0x04` (dimmer), `0x06` (blower) | **~2.5 s** | +| C — Silent-slot scan | 12 unpopulated addresses | **~7.0 s** | +| D — Broadcast heartbeat | FC `0x10` → `0x00`, 26 regs @ 1001 | **~3.5 s** | + +Tier B is prepended to Tier A on cycles where its deadline elapses; +Tier D fires between fast pairs, never mid-request; Tier C is a single +~1.7 s burst of 12 × 140 ms timeouts every ~7 s. + +### Example wire sequence (one full tick) + +``` +0x04 → 0x06 → 0x03 → 0x0A (Tier B due, then Tier A) +0x03 → 0x0A (Tier A only, subsequent ticks) +0x00 FC10 broadcast (Tier D, every ~3.5 s) +0x10 → 0x02 → 0x01 → … (Tier C, every ~7 s) +``` + +## Broadcast Heartbeat (Tier D) + +FC `0x10` to addr `0x00`, writing 26 registers starting at reg 1001. +Observed payload is constant across captures: + +| Register | Value | Interpretation | +|----------|-------|---------------| +| 1001-1008 | 0 | Reserved | +| 1009 | 7 | Mode/state flag | +| 1010 | 0 | Reserved | +| 1011 | 1088-1112 | Setpoint (CO₂? still unresolved) | +| 1012-1026 | 0 | Reserved | + +Peripherals drop into a master-missing fail-safe if the heartbeat +stops, so the broadcast has to keep running even when nothing changes +(`BusMaster` fires it every `heartbeat_interval`, default 3.5 s). + +## Cold-Boot Behaviour (no master on the bus) + +When the master is powered but silent, peripherals emit unsolicited +FC `0x03`-**response**-formatted beacons on their assigned Modbus +address. Byte-for-byte identical to what they'd return to a real read +at reg 0, at a fixed cadence: + +- Sensor hub (`0x0A`) every ~1–1.5 s +- Blower (`0x06`) every ~2–3 s (last CRC byte often gets a framing + error and is stripped) + +Once the master starts transmitting (either polls or the Tier-D +broadcast), beacons stop and peripherals switch to pure request / +response mode. There is **no pairing / enrol / init** function code: +"pairing" in the OEM app is just the master starting to write +device-specific enable bits (hub reg 18 = 1, dimmer reg 16 = 1, …) via +ordinary FC 0x06 writes. + +Practical consequence: a clean master can simply power on and start +polling — no discovery handshake needed. + +## Fan Speed Control (24-register actuator on 0x04) + +The OEM master writes to `0x04` reg 10 via FC 0x06, ramping the fan +(or light, depending on wiring) from 11 → 25 in steps every ~5 s, +then resetting to 0 and restarting from 11. Below ~11 stalls the +motor on a true duct fan; on this rig the same SKU is wired as the +main grow-light dimmer and the value is a direct 0-100 % brightness +(see `device-map.md` §0x04 for the wiring discussion). + +Reg 16 is an enable latch that must be `1` for reg 10 writes to take +effect. The OEM leaves it set between sessions; `gss-ctrl light` and +the HA integration re-arm it at startup (blind — the dimmer never +echoes). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2a2f236 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,84 @@ +[project] +name = "spiderwire" +version = "0.1.0" +description = "SpiderWire — SpiderFarmer GSS Modbus protocol library + gss-ctrl CLI" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +authors = [{ name = "1AM" }] +keywords = [ + "modbus", + "modbus-rtu", + "rs485", + "spiderfarmer", + "gss", + "home-assistant", + "grow", + "horticulture", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Home Automation", + "Topic :: System :: Hardware :: Hardware Drivers", + "Topic :: Terminals :: Serial", + "Typing :: Typed", +] +dependencies = ["pyserial>=3.5"] + +[project.scripts] +gss-ctrl = "gss_ctrl_pc.__main__:main" + +[project.urls] +Homepage = "https://github.com/1am/spiderwire" +Source = "https://github.com/1am/spiderwire" +Issues = "https://github.com/1am/spiderwire/issues" +Changelog = "https://github.com/1am/spiderwire/blob/main/CHANGELOG.md" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/spiderwire", "src/gss_ctrl_pc"] + +[tool.hatch.build.targets.sdist] +include = [ + "src/", + "docs/", + "tests/", + "README.md", + "LICENSE", + "CHANGELOG.md", + "pyproject.toml", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = ["-ra", "--strict-markers"] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B"] +ignore = ["E501"] + +[dependency-groups] +dev = [ + "pytest>=9.0.3", + "ruff>=0.5", + "build>=1.0", + "twine>=5.0", +] diff --git a/src/gss_ctrl_pc/__init__.py b/src/gss_ctrl_pc/__init__.py new file mode 100644 index 0000000..f0117b4 --- /dev/null +++ b/src/gss_ctrl_pc/__init__.py @@ -0,0 +1 @@ +"""SpiderFarmer GSS peripheral controller over USB-RS485.""" diff --git a/src/gss_ctrl_pc/__main__.py b/src/gss_ctrl_pc/__main__.py new file mode 100644 index 0000000..6d63851 --- /dev/null +++ b/src/gss_ctrl_pc/__main__.py @@ -0,0 +1,481 @@ +"""CLI entry point for ``gss-ctrl``. + +Stand-in for the OEM SpiderFarmer GSS hub on the RS-485 bus: tiered +polling plus setpoint heartbeat broadcasts so peripherals stay out of +their master-missing fail-safe state. Shares ``spiderwire`` with the +Home Assistant integration — same bus logic, different surface. + +Usage +----- + gss-ctrl /dev/ttyUSB0 scan + gss-ctrl /dev/ttyUSB0 poll [--fast 0x03,0x0A] [--interval 1.0] + [--actuator-interval 2.5] [--scan-interval 7.0] + [--heartbeat 3.5] + gss-ctrl /dev/ttyUSB0 read [,addr...] [qty] + gss-ctrl /dev/ttyUSB0 write + gss-ctrl /dev/ttyUSB0 fan # 0-25 + gss-ctrl /dev/ttyUSB0 light # 0-100 + gss-ctrl /dev/ttyUSB0 blower [] # 0-100, addr default 0x06 +""" + +from __future__ import annotations + +import argparse +import logging +import sys +import time + +from spiderwire.bus import ( + DEFAULT_ACTUATOR_INTERVAL, + DEFAULT_FAST_INTERVAL, + DEFAULT_HEARTBEAT_INTERVAL, + DEFAULT_SCAN_INTERVAL, + BusMaster, +) +from spiderwire.protocol import ModbusTimeoutError +from spiderwire.registers import ( + BlowerData, + CO2SensorData, + FanControllerData, + SensorHubData, +) +from spiderwire.transport import RS485Transport + +log = logging.getLogger(__name__) + +# Match BusMaster.scan's default inter_poll: USB-RS485 adapters need a +# beat between slave transactions or later reads often time out. +_READ_ADDR_GAP_S = 0.05 + +# Default addresses for the light command. The "Light 1" fixture lives +# on the sensor hub (0x0A, reg 18 enable), brightness on the dimmer at +# 0x04 reg 10 (blind FC06 — the dimmer never echoes). +DEFAULT_HUB_ADDR = 0x0A +DEFAULT_DIMMER_ADDR = 0x04 +HUB_LIGHT_ENABLE_REG = 18 +DIMMER_ENABLE_REG = 16 +DIMMER_BRIGHTNESS_REG = 10 + + +# The OEM ships the same 24-reg SKU as either a fan or a light dimmer, +# and the same 16-reg SKU as either a light or a blower. The device's +# self-reported `type_major` byte therefore lies for 0x04 and 0x06 on +# this rig (see docs/device-map.md). Label by data class — that's +# already the wiring-aware role we resolved when parsing. +_ROLE_BY_TYPE = { + FanControllerData: "light", + BlowerData: "blower", + # 0x03 self-IDs as type=FAN but is the CO₂ sensor — see + # docs/protocol-analysis.md "Tier A — Fast sensors". + CO2SensorData: "co2_sensor", +} + + +def _role_label(data) -> str: + return _ROLE_BY_TYPE.get(type(data), data.header.type_name) + + +def _freshness_line(addr: int, bus: BusMaster | None) -> str | None: + """Diagnostic line: how recently we polled the device, plus tally of + ok / timeout / crc replies. Lets the user spot a slave that's silently + falling behind (rising `to=`) vs. one with bus-integrity issues + (rising `crc=`).""" + if bus is None: + return None + last = bus.last_seen.get(addr) + stats = bus.poll_stats.get(addr) or {} + if last is None and not stats: + return None + age = f"{time.monotonic() - last:.1f}s ago" if last is not None else "never" + return ( + f" polled {age} " + f"(ok={stats.get('ok', 0)} to={stats.get('timeout', 0)} " + f"crc={stats.get('crc', 0)})" + ) + + +def _format_device(addr: int, data, bus: BusMaster | None = None) -> str: + lines = [ + f" [{addr:#04x}] {_role_label(data)} " + f"model={data.header.model_code:#06x} " + f"fw={data.header.fw_version} hw={data.header.hw_version:#06x}" + ] + + if isinstance(data, SensorHubData): + soil = f"{data.soil_temp_c:.1f}°C" if data.soil_temp_c is not None else "--" + lines.append( + f" temp={data.air_temp_c:.1f}°C rh={data.air_humidity_pct:.1f}% " + f"vpd={data.vpd_kpa:.2f}kPa soil={soil}" + ) + lines.append( + f" light={'ON' if data.light_enabled else 'OFF'} " + f"val={data.light_value} zone={data.zone}" + ) + elif isinstance(data, CO2SensorData): + lines.append(f" co2={data.co2_ppm} ppm") + elif isinstance(data, FanControllerData): + lines.append( + f" brightness={data.value}/100 " + f"enabled={'ON' if data.enabled else 'OFF'}" + ) + elif isinstance(data, BlowerData): + lines.append( + f" setpoint={data.percent}% " + f"{'RUNNING' if data.running else 'idle'}" + ) + + fresh = _freshness_line(addr, bus) + if fresh is not None: + lines.append(fresh) + + return "\n".join(lines) + + +def _print_devices(devices: dict, bus: BusMaster | None = None) -> None: + if not devices: + print(" (no devices responding)") + return + for addr in sorted(devices): + print(_format_device(addr, devices[addr], bus)) + + +def _parse_addr_range(spec: str) -> list[int]: + """Parse ``START-END`` (inclusive, hex or dec) into a list of addresses.""" + if "-" not in spec: + raise ValueError(f"Range must be START-END, got {spec!r}") + start_s, end_s = spec.split("-", 1) + start, end = int(start_s, 0), int(end_s, 0) + if not 0 <= start <= end <= 0xFF: + raise ValueError(f"Range {spec!r} outside 0x00-0xFF or inverted") + return list(range(start, end + 1)) + + +def cmd_scan(bus: BusMaster, args: argparse.Namespace) -> None: + addrs = _parse_addr_range(args.range) if args.range else None + if addrs is not None: + # Skip broadcast addr 0x00 — reads to it are meaningless and some + # adapters treat it specially. + addrs = [a for a in addrs if a != 0] + print( + f"Scanning bus (range {args.range}, {len(addrs)} addrs, " + f"heartbeat {args.heartbeat}s)..." + ) + else: + print(f"Scanning bus (heartbeat {args.heartbeat}s)...") + devices = bus.scan( + addrs=addrs, + inter_poll=args.scan_gap, + heartbeat_interval=args.heartbeat, + ) + print(f"\n{len(devices)} device(s) found:\n") + _print_devices(devices, bus) + + +def cmd_poll(bus: BusMaster, args: argparse.Namespace) -> None: + if args.fast: + bus.fast_addrs = [int(a, 0) for a in args.fast.split(",")] + bus.actuator_interval = args.actuator_interval + bus.scan_interval = args.scan_interval + bus.heartbeat_interval = args.heartbeat + + hb = "off" if args.heartbeat <= 0 else f"{args.heartbeat}s" + print( + f"Master mode (tiered, matches OEM GSS hub):\n" + f" fast {bus.fast_addrs} every {args.interval}s\n" + f" actuator {bus.actuator_addrs} every {args.actuator_interval}s\n" + f" scan {len(bus.scan_addrs)} silent slots every {args.scan_interval}s\n" + f" heartbeat broadcast {hb}\n" + f"(Ctrl+C to stop)\n" + ) + + def on_cycle(devices): + sys.stdout.write("\033[2J\033[H") + print(f"--- GSS Bus ({len(devices)} online) ---\n") + _print_devices(devices, bus) + sys.stdout.flush() + + bus.poll_loop(interval=args.interval, callback=on_cycle) + + +def cmd_read(bus: BusMaster, args: argparse.Namespace) -> None: + addrs = [int(a.strip(), 0) for a in args.device_addr.split(",") if a.strip()] + qty = int(args.qty) if args.qty else 16 + for j, addr in enumerate(addrs): + if j: + time.sleep(_READ_ADDR_GAP_S) + print() + try: + resp = bus.transport.read_holding_registers(addr, start_reg=0, qty=qty) + except ModbusTimeoutError: + print(f"Device {addr:#04x}: no response (timeout)") + continue + print(f"Device {addr:#04x} ({qty} registers):") + for i, v in enumerate(resp.registers): + print(f" reg[{i:2d}] = {v:5d} (0x{v:04X})") + + +def cmd_write(bus: BusMaster, args: argparse.Namespace) -> None: + addr = int(args.device_addr, 0) + reg = int(args.reg, 0) + value = int(args.value, 0) + resp = bus.transport.write_register(addr, reg, value) + print(f"OK: dev={resp.addr:#04x} reg={resp.reg} value={resp.value}") + + +def cmd_fan(bus: BusMaster, args: argparse.Namespace) -> None: + addr = int(args.device_addr, 0) + speed = int(args.speed) + bus.set_fan_speed(addr, speed) + print(f"Fan {addr:#04x} speed → {speed}") + + +_WAKE_ATTEMPTS = 4 +_WAKE_GAP_S = 0.1 + +# Settle window between back-to-back blind FC06s to the dimmer at 0x04. +# `POST_BLIND_WRITE_QUIET` (5 ms) covers tail TX + adapter quiet, but the +# dimmer itself needs longer to latch reg 16 before reg 10 takes effect — +# without this the second write silently no-ops and the light stays at 0 +# after a previous turn-off (observed manually). +_DIMMER_LATCH_SETTLE_S = 0.05 + + +def _broadcast_wake(bus: BusMaster, count: int = 3) -> None: + """Broadcast a few heartbeats to silence cold-boot beacons. + + For all-blind-write commands like `cmd_light` we can't probe the + target's FC06 echo, so we rely on a short broadcast burst to switch + slaves out of beacon mode (see `docs/protocol-analysis.md` + "Cold-Boot Behaviour"). 3 broadcasts spaced by `_WAKE_GAP_S` + reliably win against the hub's ~1.5 s beacon cadence. + """ + for _ in range(count): + try: + bus.broadcast_setpoints() + except Exception: + log.exception("Wake broadcast failed") + time.sleep(_WAKE_GAP_S) + + +def _wake_bus(bus: BusMaster, target_addr: int) -> None: + """Pull `target_addr` out of cold-boot beacon mode. + + With no master on the bus, slaves emit unsolicited FC03-format + beacons every ~1–3 s instead of listening (see + `docs/protocol-analysis.md` "Cold-Boot Behaviour"). A single + broadcast usually silences them, but our frame race-loses against + any in-flight beacon, so we loop: broadcast → poll target → bail + out as soon as the target answers cleanly. + """ + for attempt in range(1, _WAKE_ATTEMPTS + 1): + try: + bus.broadcast_setpoints() + except Exception: + log.exception("Wake broadcast failed (attempt %d)", attempt) + if bus.poll_device(target_addr) is not None: + return + time.sleep(_WAKE_GAP_S) + log.warning( + "Slave 0x%02X never answered after %d wake attempts; " + "proceeding with the write anyway", + target_addr, _WAKE_ATTEMPTS, + ) + + +def _retry_on_timeout(action, label: str, attempts: int = 3) -> None: + """Run `action()`, retrying on `ModbusTimeoutError`. Re-raises the + last timeout if all attempts fail.""" + last: ModbusTimeoutError | None = None + for attempt in range(1, attempts + 1): + try: + action() + return + except ModbusTimeoutError as e: + last = e + log.warning("%s timed out (attempt %d/%d)", label, attempt, attempts) + time.sleep(_WAKE_GAP_S) + assert last is not None + raise last + + +def cmd_blower(bus: BusMaster, args: argparse.Namespace) -> None: + """Set blower / ventilation % (FC06 → reg 14 on 0x06). + + The OEM app labels this "Light 2" but the hardware is the blower. + """ + addr = int(args.device_addr, 0) + pct = int(args.percent) + _wake_bus(bus, addr) + _retry_on_timeout(lambda: bus.set_blower(addr, pct), f"blower {addr:#04x}") + print(f"Blower {addr:#04x} → {pct}%") + + +def cmd_light(bus: BusMaster, args: argparse.Namespace) -> None: + """Set Light 1 brightness 0-100 % (hub enable + dimmer brightness). + + Both the hub at ``0x0A`` and the dimmer at ``0x04`` accept FC06 but + **neither echoes** on this firmware, so all writes are fired blind + and the next poll surfaces the new state. To keep the writes from + landing on a slave that's still in cold-boot beacon mode, we + broadcast a few heartbeats first. + + Sequence on turn-on: dimmer enable → settle → dimmer brightness → + hub gate. Hub is opened *last* so the dimmer is already at the + right setpoint when light gating goes on (otherwise the light + flashes the previous brightness for a frame). + """ + hub_addr = int(args.hub, 0) + dimmer_addr = int(args.dimmer, 0) + pct = int(args.percent) + if not 0 <= pct <= 100: + raise SystemExit(f"percent must be 0-100 (got {pct})") + + _broadcast_wake(bus) + + if pct == 0: + bus.transport.write_register( + hub_addr, HUB_LIGHT_ENABLE_REG, 0, wait_for_response=False + ) + bus.transport.write_register( + dimmer_addr, DIMMER_BRIGHTNESS_REG, 0, wait_for_response=False + ) + print(f"Light off (hub {hub_addr:#04x} reg 18 ← 0, dimmer {dimmer_addr:#04x} reg 10 ← 0)") + return + + bus.transport.write_register( + dimmer_addr, DIMMER_ENABLE_REG, 1, wait_for_response=False + ) + time.sleep(_DIMMER_LATCH_SETTLE_S) + bus.transport.write_register( + dimmer_addr, DIMMER_BRIGHTNESS_REG, pct, wait_for_response=False + ) + bus.transport.write_register( + hub_addr, HUB_LIGHT_ENABLE_REG, 1, wait_for_response=False + ) + print(f"Light → {pct}% (hub {hub_addr:#04x} enable=1, dimmer {dimmer_addr:#04x} reg 10={pct})") + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="gss-ctrl", + description="SpiderFarmer GSS peripheral controller", + ) + p.add_argument("port", help="Serial port (e.g. /dev/ttyUSB0)") + p.add_argument("-b", "--baud", type=int, default=115200) + p.add_argument( + "-t", "--timeout", type=float, default=0.3, + help="Per-request serial timeout in seconds (default 0.3)", + ) + p.add_argument("-v", "--verbose", action="store_true") + + sub = p.add_subparsers(dest="command", required=True) + + scan_p = sub.add_parser("scan", help="Scan bus for devices") + scan_p.add_argument( + "--scan-gap", type=float, default=0.05, + help="Seconds between address polls (default 0.05)", + ) + scan_p.add_argument( + "--range", + help="Address range START-END (inclusive, hex ok), e.g. 0x00-0xff. " + "Default: OEM-polled addresses only.", + ) + scan_p.add_argument( + "--heartbeat", type=float, default=2.0, + help="Seconds between setpoint broadcasts during scan; 0 disables " + "(default 2.0 — matches the OEM hub)", + ) + + poll_p = sub.add_parser( + "poll", help="Act as the GSS master: tiered polling + heartbeat broadcast", + ) + poll_p.add_argument( + "--fast", + help="Comma-separated hex addresses polled every cycle " + "(default: 0x03,0x0A — the OEM's primary sensors)", + ) + poll_p.add_argument( + "--interval", type=float, default=DEFAULT_FAST_INTERVAL, + help=f"Seconds between fast-tier cycles (default {DEFAULT_FAST_INTERVAL})", + ) + poll_p.add_argument( + "--actuator-interval", type=float, default=DEFAULT_ACTUATOR_INTERVAL, + help=f"Seconds between actuator-tier polls (default {DEFAULT_ACTUATOR_INTERVAL})", + ) + poll_p.add_argument( + "--scan-interval", type=float, default=DEFAULT_SCAN_INTERVAL, + help=f"Seconds between silent-slot discovery scans (default {DEFAULT_SCAN_INTERVAL})", + ) + poll_p.add_argument( + "--heartbeat", type=float, default=DEFAULT_HEARTBEAT_INTERVAL, + help=f"Seconds between setpoint broadcasts; 0 to disable " + f"(default {DEFAULT_HEARTBEAT_INTERVAL})", + ) + + read_p = sub.add_parser("read", help="Read registers from one or more devices") + read_p.add_argument( + "device_addr", + help="Device address(es), comma-separated hex (e.g. 0x0A or 0x03,0x0A)", + ) + read_p.add_argument("qty", nargs="?", help="Number of registers (default 16)") + + write_p = sub.add_parser("write", help="Write a single register (FC06)") + write_p.add_argument("device_addr") + write_p.add_argument("reg", help="Register number") + write_p.add_argument("value", help="Value to write") + + fan_p = sub.add_parser("fan", help="Set fan speed (0-25)") + fan_p.add_argument("device_addr") + fan_p.add_argument("speed", help="Speed value 0-25") + + blower_p = sub.add_parser( + "blower", + help="Set ventilation blower %% (FC06 → reg 14; default addr 0x06)", + ) + blower_p.add_argument("device_addr", nargs="?", default="0x06") + blower_p.add_argument("percent", help="Percent value 0-100") + + light_p = sub.add_parser( + "light", + help="Set Light 1 brightness 0-100 %% (hub enable + dimmer brightness)", + ) + light_p.add_argument("percent", help="Brightness 0-100 (0 = off)") + light_p.add_argument( + "--hub", default=hex(DEFAULT_HUB_ADDR), + help=f"Sensor hub address (default {hex(DEFAULT_HUB_ADDR)})", + ) + light_p.add_argument( + "--dimmer", default=hex(DEFAULT_DIMMER_ADDR), + help=f"Dimmer address (default {hex(DEFAULT_DIMMER_ADDR)})", + ) + + return p + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(name)-20s %(levelname)-5s %(message)s", + datefmt="%H:%M:%S", + ) + + handlers = { + "scan": cmd_scan, + "poll": cmd_poll, + "read": cmd_read, + "write": cmd_write, + "fan": cmd_fan, + "blower": cmd_blower, + "light": cmd_light, + } + + with RS485Transport(args.port, baudrate=args.baud, timeout=args.timeout) as transport: + bus = BusMaster(transport=transport) + handlers[args.command](bus, args) + + +if __name__ == "__main__": + main() diff --git a/src/spiderwire/__init__.py b/src/spiderwire/__init__.py new file mode 100644 index 0000000..2d84dca --- /dev/null +++ b/src/spiderwire/__init__.py @@ -0,0 +1,60 @@ +"""SpiderFarmer GSS Modbus protocol library. + +Shared between the ``gss-ctrl`` CLI and the SpiderFarmer Home Assistant +integration. See ``docs/protocol-analysis.md`` and ``docs/device-map.md`` +for the bus reference. +""" + +from importlib.metadata import PackageNotFoundError, version + +from .bus import ( + DEFAULT_ACTUATOR_INTERVAL, + DEFAULT_FAST_INTERVAL, + DEFAULT_HEARTBEAT_INTERVAL, + DEFAULT_SCAN_INTERVAL, + BusMaster, +) +from .protocol import ( + CRCError, + ExceptionResponse, + ModbusError, + ModbusTimeoutError, +) +from .registers import ( + BlowerData, + CO2SensorData, + DeviceData, + DeviceHeader, + DeviceType, + FanControllerData, + SensorHubData, + parse_device_data, +) +from .transport import RS485Transport + +try: + __version__ = version("spiderwire") +except PackageNotFoundError: + __version__ = "0.0.0+unknown" + +__all__ = [ + "BlowerData", + "BusMaster", + "CO2SensorData", + "CRCError", + "DEFAULT_ACTUATOR_INTERVAL", + "DEFAULT_FAST_INTERVAL", + "DEFAULT_HEARTBEAT_INTERVAL", + "DEFAULT_SCAN_INTERVAL", + "DeviceData", + "DeviceHeader", + "DeviceType", + "ExceptionResponse", + "FanControllerData", + "ModbusError", + "ModbusTimeoutError", + "RS485Transport", + "SensorHubData", + "__version__", + "parse_device_data", +] diff --git a/src/spiderwire/bus.py b/src/spiderwire/bus.py new file mode 100644 index 0000000..78ab212 --- /dev/null +++ b/src/spiderwire/bus.py @@ -0,0 +1,288 @@ +"""Bus master — device discovery, polling, and control. + +Emulates the OEM GSS controller's tiered polling pattern (see +`docs/protocol-analysis.md`): + + Tier A (fast) 0x03, 0x0A every ~1.0 s + Tier B (actuator) 0x04, 0x06 every ~2.5 s + Tier C (scan) 12 silent slots every ~7.0 s + Tier D (heartbeat) FC 0x10 broadcast → 0x00 every ~3.5 s + +One "tick" walks Tier A and advances the slow tiers when their deadline +elapses. Call `tick()` on a ~1 Hz cadence (or let `poll_loop()` do it). +""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass, field + +from .protocol import CRCError, ModbusError, ModbusTimeoutError +from .registers import ( + ACTUATOR_ADDRS, + ALL_ADDRS, + BLOWER_SETPOINT_REG, + BROADCAST_REG_COUNT, + BROADCAST_START_REG, + DEFAULT_QTY, + FAST_ADDRS, + SCAN_ADDRS, + DeviceData, + parse_device_data, +) +from .transport import RS485Transport + +log = logging.getLogger(__name__) + +# Defaults chosen to match the OEM hub's observed cadence. +DEFAULT_FAST_INTERVAL = 1.0 +DEFAULT_ACTUATOR_INTERVAL = 2.5 +DEFAULT_SCAN_INTERVAL = 7.0 +DEFAULT_HEARTBEAT_INTERVAL = 3.5 + + +@dataclass +class BusMaster: + transport: RS485Transport + devices: dict[int, DeviceData] = field(default_factory=dict) + # Setpoints broadcast in the master heartbeat (reg 1001, 26 regs). + # Mutate at runtime to change what peripherals receive. + # reg 1009 = 7 and reg 1011 = 1112 are the OEM's observed defaults. + setpoints: list[int] = field( + default_factory=lambda: _default_setpoints() + ) + + # Tiered orchestration config — mirrors the OEM GSS hub. + fast_addrs: list[int] = field(default_factory=lambda: list(FAST_ADDRS)) + actuator_addrs: list[int] = field(default_factory=lambda: list(ACTUATOR_ADDRS)) + scan_addrs: list[int] = field(default_factory=lambda: list(SCAN_ADDRS)) + actuator_interval: float = DEFAULT_ACTUATOR_INTERVAL + scan_interval: float = DEFAULT_SCAN_INTERVAL + heartbeat_interval: float = DEFAULT_HEARTBEAT_INTERVAL + # Settle time between consecutive polls within a tier. Without this + # gap the *second* device in each pair (0x06 in actuator, 0x0A in + # fast) reliably times out — observed `to=` >> `ok=` for both, while + # `0x03` and `0x04` (the *first* polls) never timed out. `scan()` + # already uses 50 ms; tick() needs the same. Cheap insurance — at + # 4 polls/cycle this adds <0.2 s of overhead per tick. + inter_poll_gap: float = 0.05 + # Drop a device from `self.devices` only after this many consecutive + # failed polls. The OEM bus is bursty: 0x0A in particular goes silent + # for 30+ s at a time but then resumes. Keep stale data visible so + # callers (CLI, HA) can show "last seen Ns ago" instead of flapping. + offline_after_failures: int = 30 + + _fail_count: dict[int, int] = field(default_factory=dict) + # Monotonic timestamp of last successful poll per addr — drives the + # "polled Ns ago" / staleness display. + last_seen: dict[int, float] = field(default_factory=dict) + # Per-addr poll statistics — lets callers distinguish "slave is silent" + # (timeout) from "slave replied but we mangled it" (crc). The + # difference points at different bus problems. + poll_stats: dict[int, dict[str, int]] = field(default_factory=dict) + _next_actuator: float = 0.0 + _next_scan: float = 0.0 + _next_heartbeat: float = 0.0 + + def scan( + self, + addrs: list[int] | None = None, + inter_poll: float = 0.05, + heartbeat_interval: float = 0.0, + ) -> dict[int, DeviceData]: + """Poll every address once (discovery walk). + + Unlike `tick()`, this always hits every slot — use it for the + `gss-ctrl scan` command or a one-shot inventory. `inter_poll` + gives the RS-485 adapter a beat between transactions so slow + peripherals respond cleanly. `heartbeat_interval > 0` interleaves + broadcasts so peripherals don't drop into master-missing + fail-safe during a long (full-range) scan. + """ + targets = addrs or ALL_ADDRS + next_heartbeat = float("inf") + if heartbeat_interval > 0: + try: + self.broadcast_setpoints() + except Exception: + log.exception("Pre-scan heartbeat failed") + next_heartbeat = time.monotonic() + heartbeat_interval + + for i, addr in enumerate(targets): + if time.monotonic() >= next_heartbeat: + try: + self.broadcast_setpoints() + except Exception: + log.exception("In-scan heartbeat failed") + next_heartbeat = time.monotonic() + heartbeat_interval + self.poll_device(addr) + if inter_poll > 0 and i < len(targets) - 1: + time.sleep(inter_poll) + return self.devices + + def poll_device(self, addr: int) -> DeviceData | None: + qty = DEFAULT_QTY.get(addr, 16) + stats = self.poll_stats.setdefault( + addr, {"ok": 0, "timeout": 0, "crc": 0, "framing": 0, "other": 0} + ) + try: + resp = self.transport.read_holding_registers(addr, start_reg=0, qty=qty) + data = parse_device_data(resp.registers) + self.devices[addr] = data + self.last_seen[addr] = time.monotonic() + self._fail_count[addr] = 0 + stats["ok"] += 1 + log.info("Dev 0x%02X responded (%d regs)", addr, qty) + return data + except ModbusTimeoutError: + stats["timeout"] += 1 + self._fail_count[addr] = self._fail_count.get(addr, 0) + 1 + if addr in self.devices and self._fail_count[addr] > self.offline_after_failures: + log.warning("Dev 0x%02X went offline", addr) + del self.devices[addr] + log.debug("Dev 0x%02X timeout (silent)", addr) + return None + except CRCError as e: + # Slave did transmit bytes but we couldn't validate them — this + # is the fingerprint of bus-integrity / turnaround issues, not + # a missing slave. Keep loud so it shows up in stats. + stats["crc"] += 1 + log.warning("Dev 0x%02X CRC fail: %s", addr, e) + return None + except ModbusError as e: + # Frame parsed cleanly (CRC valid) but isn't the response we + # expected — typically because another master (the OEM GSS hub) + # is also driving the bus and we latched onto its traffic. + # Expected on a 2-master bus; log gracefully without a trace. + stats["framing"] += 1 + log.warning("Dev 0x%02X framing: %s", addr, e) + return None + except Exception: + stats["other"] += 1 + log.exception("Error polling dev 0x%02X", addr) + return None + + # ----- Control helpers ----- + + def set_fan_speed(self, addr: int, speed: int) -> None: + """Write fan speed (0-25) to the fan controller.""" + if not 0 <= speed <= 25: + raise ValueError(f"Fan speed must be 0-25, got {speed}") + self.transport.write_register(addr, reg=10, value=speed) + log.info("Dev 0x%02X fan speed → %d", addr, speed) + + def set_fan_enable(self, addr: int, enable: bool) -> None: + self.transport.write_register(addr, reg=16, value=int(enable)) + log.info("Dev 0x%02X fan enable → %s", addr, enable) + + def set_light_enable(self, addr: int, enable: bool) -> None: + """Write light enable flag (reg 18 on sensor hub).""" + self.transport.write_register(addr, reg=18, value=int(enable)) + log.info("Dev 0x%02X light enable → %s", addr, enable) + + def set_blower(self, addr: int, percent: int) -> None: + """Set blower / ventilation % via FC06 → reg 14. + + Device 0x06 (the "Light 2" in the OEM UI) echoes this write, so + we wait for the standard Modbus response. 0 turns the blower off; + the OEM UI enforces a 25 % floor above 0 but the device itself + accepts any 0-100 value. + """ + if not 0 <= percent <= 100: + raise ValueError(f"Blower percent must be 0-100, got {percent}") + self.transport.write_register(addr, reg=BLOWER_SETPOINT_REG, value=percent) + log.info("Dev 0x%02X blower → %d %%", addr, percent) + + def broadcast_setpoints(self, values: list[int] | None = None) -> None: + """Send FC 0x10 broadcast to addr 0x00 (all devices). + + The OEM master broadcasts 26 registers starting at 1001. If + `values` is None, the current `self.setpoints` is sent. + """ + payload = self.setpoints if values is None else values + if len(payload) != BROADCAST_REG_COUNT: + raise ValueError( + f"Expected {BROADCAST_REG_COUNT} values, got {len(payload)}" + ) + self.transport.write_registers(0x00, BROADCAST_START_REG, payload) + log.debug("Broadcast %d regs @ %d", BROADCAST_REG_COUNT, BROADCAST_START_REG) + + # ----- Tiered orchestration ----- + + def tick(self) -> dict[int, DeviceData]: + """Run one orchestration cycle matching the OEM GSS hub. + + * Tier B (actuators) runs first when its deadline hits — the OEM + hub slots it in right before the fast pair. + * Tier A (fast sensors) always runs. + * Tier D (heartbeat) fires between fast pairs, never mid-request. + * Tier C (silent-slot scan) runs last; ~1.7 s burst of timeouts + every ~7 s keeps the bus mostly free for sensors. + + Call at ~1 Hz (see `poll_loop` for the outer scheduler). Returns + the current `self.devices` snapshot. + """ + now = time.monotonic() + + if now >= self._next_actuator: + self._poll_with_gap(self.actuator_addrs) + self._next_actuator = now + self.actuator_interval + + self._poll_with_gap(self.fast_addrs) + + if time.monotonic() >= self._next_heartbeat: + try: + self.broadcast_setpoints() + except Exception: + log.exception("Heartbeat broadcast failed") + self._next_heartbeat = time.monotonic() + self.heartbeat_interval + + if time.monotonic() >= self._next_scan: + self._poll_with_gap(self.scan_addrs) + self._next_scan = time.monotonic() + self.scan_interval + + return self.devices + + def _poll_with_gap(self, addrs: list[int]) -> None: + """Poll `addrs` in order with `self.inter_poll_gap` between them. + See `inter_poll_gap` field for why the gap matters.""" + for i, addr in enumerate(addrs): + if i and self.inter_poll_gap > 0: + time.sleep(self.inter_poll_gap) + self.poll_device(addr) + + def poll_loop( + self, + interval: float = DEFAULT_FAST_INTERVAL, + callback=None, + ) -> None: + """Run `tick()` in a loop at `interval` seconds (default 1 s). + + Adjust tier cadence via `self.actuator_interval`, + `self.scan_interval`, `self.heartbeat_interval`. Calls + `callback(devices)` after each tick. Runs until KeyboardInterrupt. + """ + try: + while True: + cycle_start = time.monotonic() + self.tick() + if callback: + callback(self.devices) + sleep_for = interval - (time.monotonic() - cycle_start) + if sleep_for > 0: + time.sleep(sleep_for) + except KeyboardInterrupt: + log.info("Poll loop stopped") + + +def _default_setpoints() -> list[int]: + """Empty setpoint block with the OEM's observed constants pre-filled. + + reg 1009 = 7 (mode/state flag), reg 1011 = 1112 (setpoint). Both are + constant across all captures; peripherals appear to expect them. + """ + values = [0] * BROADCAST_REG_COUNT + values[1009 - BROADCAST_START_REG] = 7 + values[1011 - BROADCAST_START_REG] = 1112 + return values diff --git a/src/spiderwire/protocol.py b/src/spiderwire/protocol.py new file mode 100644 index 0000000..0aae614 --- /dev/null +++ b/src/spiderwire/protocol.py @@ -0,0 +1,149 @@ +"""Modbus RTU frame encoding/decoding and CRC-16 for SpiderFarmer bus.""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass +from enum import IntEnum + + +class FunctionCode(IntEnum): + READ_HOLDING_REGISTERS = 0x03 + WRITE_SINGLE_REGISTER = 0x06 + WRITE_MULTIPLE_REGISTERS = 0x10 + + # Exception flag (ORed with the original FC in error responses) + EXCEPTION_FLAG = 0x80 + + +class ModbusError(Exception): + pass + + +class CRCError(ModbusError): + pass + + +class ModbusTimeoutError(ModbusError): + pass + + +class ExceptionResponse(ModbusError): + def __init__(self, fc: int, code: int): + self.fc = fc + self.code = code + super().__init__(f"Modbus exception FC=0x{fc:02X} code={code}") + + +# --------------------------------------------------------------------------- +# CRC-16/Modbus (polynomial 0xA001, init 0xFFFF) +# --------------------------------------------------------------------------- + +def crc16(data: bytes) -> int: + crc = 0xFFFF + for b in data: + crc ^= b + for _ in range(8): + if crc & 1: + crc = (crc >> 1) ^ 0xA001 + else: + crc >>= 1 + return crc + + +def append_crc(frame: bytes) -> bytes: + c = crc16(frame) + return frame + struct.pack(" bytes: + """Validate CRC and return payload (frame minus trailing 2-byte CRC).""" + if len(frame) < 4: + raise CRCError(f"Frame too short ({len(frame)} bytes)") + payload, crc_rx = frame[:-2], struct.unpack(" bytes: + """FC 0x03 — Read Holding Registers request.""" + frame = struct.pack(">BBHH", addr, FunctionCode.READ_HOLDING_REGISTERS, start_reg, qty) + return append_crc(frame) + + +def build_write_single(addr: int, reg: int, value: int) -> bytes: + """FC 0x06 — Write Single Register request.""" + frame = struct.pack(">BBHH", addr, FunctionCode.WRITE_SINGLE_REGISTER, reg, value) + return append_crc(frame) + + +def build_write_multiple(addr: int, start_reg: int, values: list[int]) -> bytes: + """FC 0x10 — Write Multiple Registers request.""" + qty = len(values) + byte_count = qty * 2 + frame = struct.pack( + f">BBHHB{qty}H", + addr, FunctionCode.WRITE_MULTIPLE_REGISTERS, + start_reg, qty, byte_count, + *values, + ) + return append_crc(frame) + + +# --------------------------------------------------------------------------- +# Response parsers +# --------------------------------------------------------------------------- + +@dataclass +class ReadResponse: + addr: int + registers: list[int] + + +@dataclass +class WriteResponse: + addr: int + reg: int + value: int + + +@dataclass +class WriteMultipleResponse: + addr: int + start_reg: int + qty: int + + +def parse_response(raw: bytes) -> ReadResponse | WriteResponse | WriteMultipleResponse: + """Parse a validated Modbus RTU response frame (with CRC).""" + payload = check_crc(raw) + addr, fc = payload[0], payload[1] + + if fc & FunctionCode.EXCEPTION_FLAG: + raise ExceptionResponse(fc & 0x7F, payload[2]) + + if fc == FunctionCode.READ_HOLDING_REGISTERS: + byte_count = payload[2] + if len(payload) < 3 + byte_count: + raise ModbusError( + f"FC03 payload too short: byte_count={byte_count} but only " + f"{len(payload) - 3} data bytes present" + ) + n = byte_count // 2 + regs = list(struct.unpack(f">{n}H", payload[3:3 + byte_count])) + return ReadResponse(addr=addr, registers=regs) + + if fc == FunctionCode.WRITE_SINGLE_REGISTER: + reg, value = struct.unpack(">HH", payload[2:6]) + return WriteResponse(addr=addr, reg=reg, value=value) + + if fc == FunctionCode.WRITE_MULTIPLE_REGISTERS: + start_reg, qty = struct.unpack(">HH", payload[2:6]) + return WriteMultipleResponse(addr=addr, start_reg=start_reg, qty=qty) + + raise ModbusError(f"Unknown function code 0x{fc:02X}") diff --git a/src/spiderwire/py.typed b/src/spiderwire/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/spiderwire/registers.py b/src/spiderwire/registers.py new file mode 100644 index 0000000..b2b525f --- /dev/null +++ b/src/spiderwire/registers.py @@ -0,0 +1,288 @@ +"""Register map definitions for SpiderFarmer peripherals. + +Register numbers and expected quantities match the protocol analysis. +All register data is big-endian unsigned 16-bit unless noted otherwise. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from enum import IntEnum + + +class DeviceType(IntEnum): + """Known device type codes (register 6, high byte).""" + LIGHT = 0x02 + FAN = 0x03 + SENSOR_HUB = 0x04 + + +# Addresses the OEM master polls (from protocol analysis) +ALL_ADDRS = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x13, +] + +# Observed OEM orchestration tiers (see docs/protocol-analysis.md §"Bus Timing"). +# The GSS hub does not poll uniformly — it runs four interleaved schedules: +# +# Tier A FAST_ADDRS every ~1 s primary sensors +# Tier B ACTUATOR_ADDRS every ~2.5 s light dimmer + blower +# Tier C SCAN_ADDRS every ~7 s silent-slot discovery +# Tier D FC10 broadcast every ~3.5 s setpoint heartbeat +# +# Address lists are exactly the order the OEM hub uses. +FAST_ADDRS: list[int] = [0x03, 0x0A] +ACTUATOR_ADDRS: list[int] = [0x04, 0x06] +SCAN_ADDRS: list[int] = [ + 0x10, 0x02, 0x01, 0x0C, 0x05, 0x07, + 0x0B, 0x0D, 0x0E, 0x08, 0x0F, 0x13, +] + +# Per-address register count the OEM hub reads on every poll, encoding +# the OEM's expected device class at each slot. +DEFAULT_QTY: dict[int, int] = { + 0x01: 16, 0x02: 22, 0x03: 13, 0x04: 24, 0x05: 30, + 0x06: 16, 0x07: 30, 0x08: 21, 0x0A: 28, 0x0B: 30, + 0x0C: 21, 0x0D: 22, 0x0E: 21, 0x0F: 16, 0x10: 28, + 0x13: 23, +} + + +# --------------------------------------------------------------------------- +# Broadcast write (FC 0x10, addr 0x00) +# --------------------------------------------------------------------------- + +BROADCAST_START_REG = 1001 +BROADCAST_REG_COUNT = 26 + + +# --------------------------------------------------------------------------- +# Common header (regs 0-9, all devices) +# --------------------------------------------------------------------------- + +@dataclass +class DeviceHeader: + address: int + magic_byte: int # should be 0xAA + fw_version: str + model_code: int + serial_frag: int + device_type: int # high byte = type, low byte = subtype + hw_version: int + + @classmethod + def from_registers(cls, regs: list[int]) -> DeviceHeader: + addr = regs[0] + magic = (regs[1] >> 8) & 0xFF + r2, r3 = regs[2], regs[3] + try: + fw_hi = r2.to_bytes(2, "big").decode("ascii") + fw_lo = r3.to_bytes(2, "big").decode("ascii") + fw = f"{fw_hi}.{fw_lo}" + except (UnicodeDecodeError, ValueError): + fw = f"0x{r2:04X}{r3:04X}" + return cls( + address=addr, + magic_byte=magic, + fw_version=fw, + model_code=regs[4], + serial_frag=regs[5], + device_type=regs[6], + hw_version=regs[7], + ) + + @property + def type_major(self) -> int: + return (self.device_type >> 8) & 0xFF + + @property + def type_minor(self) -> int: + return self.device_type & 0xFF + + @property + def type_name(self) -> str: + try: + return DeviceType(self.type_major).name.lower() + except ValueError: + return f"unknown(0x{self.type_major:02X})" + + +# --------------------------------------------------------------------------- +# Sensor Hub (addr 0x0A, 28 regs) +# --------------------------------------------------------------------------- + +@dataclass +class SensorHubData: + header: DeviceHeader + air_temp_raw: int # reg 10, ×10 °C + air_humidity_raw: int # reg 11, ×10 % + soil_temp_raw: int # reg 12, ×10 °C (signed) + ppfd: int # reg 13, µmol/m²/s (confirmed against app) + ppfd_secondary: int # reg 14, co-tracking channel (peak/IR/DLI, TBD) + calibration: int # reg 15 + light_enabled: bool # reg 18 + light_value: int # reg 19 + zone: int # reg 21 + + @property + def air_temp_c(self) -> float: + return self.air_temp_raw / 10.0 + + @property + def air_humidity_pct(self) -> float: + return self.air_humidity_raw / 10.0 + + @property + def soil_temp_c(self) -> float | None: + # -1000 means not connected + v = _as_signed(self.soil_temp_raw) + return None if v == -1000 else v / 10.0 + + @property + def vpd_kpa(self) -> float: + """Vapour Pressure Deficit from temp and humidity.""" + t = self.air_temp_c + rh = self.air_humidity_pct + svp = 0.6108 * math.exp(17.27 * t / (t + 237.3)) + return svp * (1 - rh / 100.0) + + @classmethod + def from_registers(cls, regs: list[int]) -> SensorHubData: + header = DeviceHeader.from_registers(regs) + return cls( + header=header, + air_temp_raw=regs[10], + air_humidity_raw=regs[11], + soil_temp_raw=regs[12], + ppfd=regs[13], + ppfd_secondary=regs[14], + calibration=regs[15], + light_enabled=bool(regs[18]), + light_value=regs[19], + zone=regs[21], + ) + +# --------------------------------------------------------------------------- +# CO2 Sensor (addr 0x03, 13 regs) +# --------------------------------------------------------------------------- + +@dataclass +class CO2SensorData: + header: DeviceHeader + co2_ppm: int # reg 10 + + @classmethod + def from_registers(cls, regs: list[int]) -> CO2SensorData: + return cls( + header=DeviceHeader.from_registers(regs), + co2_ppm=regs[10], + ) + + +# --------------------------------------------------------------------------- +# Fan Controller (addr 0x04, 24 regs) +# --------------------------------------------------------------------------- + +@dataclass +class FanControllerData: + """24-register PWM actuator. + + The OEM firmware self-identifies as a fan (`type_major = 0x03`) but + the same SKU is wired as the **Light 1 dimmer** on this rig + (`reg 10` runs 0-100 %, confirmed in `docs/device-map.md` and + `docs/capture-20260418-1152.sal`). Callers that want fan semantics + read `.speed`; callers that want light semantics read + `.brightness_pct`. Both return the same underlying value — the + wiring decides how to interpret it. + """ + header: DeviceHeader + speed: int # reg 10 — raw 0-100 (PWM) or 0-25 (fan) + enabled: bool # reg 16 + + @property + def value(self) -> int: + """Neutral name for `reg 10` — no fan/light assumption.""" + return self.speed + + @property + def brightness_pct(self) -> int: + """Reg 10 interpreted as 0-100 % brightness (light-dimmer wiring).""" + return self.speed + + @classmethod + def from_registers(cls, regs: list[int]) -> FanControllerData: + return cls( + header=DeviceHeader.from_registers(regs), + speed=regs[10], + enabled=bool(regs[16]), + ) + +# --------------------------------------------------------------------------- +# Blower / ventilation (addr 0x06, 16 regs) +# --------------------------------------------------------------------------- +# +# The OEM app labels this "Light 2" but the 0x06 SKU actually drives the +# **blower / ventilation** — confirmed in `docs/capture-20260418-1452.sal`: +# the user drove the OEM slider 0 → 74 → 60 → 25 → OFF and every move +# produced FC06 writes to `0x06 reg 14` carrying the % directly, while the +# device's own broadcasts echoed the same value back in reg 14 (and set +# reg 12 = 1 whenever the blower was running). + +BLOWER_SETPOINT_REG = 14 +BLOWER_RUNNING_REG = 12 + + +@dataclass +class BlowerData: + header: DeviceHeader + data_regs: list[int] # regs 10-15 + + @property + def percent(self) -> int: + """Reg 14 = 0-100 % setpoint.""" + return self.data_regs[BLOWER_SETPOINT_REG - 10] + + @property + def running(self) -> bool: + """Reg 12 goes 1 whenever the blower is active.""" + return self.data_regs[BLOWER_RUNNING_REG - 10] != 0 + + @classmethod + def from_registers(cls, regs: list[int]) -> BlowerData: + return cls( + header=DeviceHeader.from_registers(regs), + data_regs=regs[10:16], + ) + +# --------------------------------------------------------------------------- +# Union type and auto-detect +# --------------------------------------------------------------------------- + +DeviceData = SensorHubData | CO2SensorData | FanControllerData | BlowerData | DeviceHeader + + +def parse_device_data(regs: list[int]) -> DeviceData: + """Auto-detect device type from register count and header, return typed data.""" + n = len(regs) + if n >= 28: + return SensorHubData.from_registers(regs) + if n >= 24: + return FanControllerData.from_registers(regs) + if n >= 16: + return BlowerData.from_registers(regs) + if n >= 13: + return CO2SensorData.from_registers(regs) + if n >= 10: + return DeviceHeader.from_registers(regs) + raise ValueError(f"Too few registers ({n}) to parse device header") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _as_signed(val: int) -> int: + """Interpret a 16-bit unsigned value as signed.""" + return val - 0x10000 if val >= 0x8000 else val diff --git a/src/spiderwire/transport.py b/src/spiderwire/transport.py new file mode 100644 index 0000000..eba2f99 --- /dev/null +++ b/src/spiderwire/transport.py @@ -0,0 +1,234 @@ +"""Thread-safe RS-485 serial transport for Modbus RTU. + +Frame reads are deterministic: we know every response size up front +from the function code, so we read exactly that many bytes from the +port. This is the fix for the USB-RS485 adapter's 28-register CRC +failures — the old `wait-for-silence-then-break` heuristic truncated +long frames when the OS delivered bytes in bursts slower than the +inter-frame gap. `docs/capture-20260418-1135.sal` confirms the wire +carries a clean 61-byte response for qty=28 (CRC OK). + +Half-duplex TX echo (cheap USB-RS485 dongles without automatic DE) +is supported via `echo=True`. Default is off, matching the DE-driven +adapters currently in use. +""" + +from __future__ import annotations + +import logging +import threading +import time +from collections.abc import Callable + +import serial + +from .protocol import ( + FunctionCode, + ModbusError, + ModbusTimeoutError, + ReadResponse, + WriteMultipleResponse, + WriteResponse, + build_read_holding, + build_write_multiple, + build_write_single, + parse_response, +) + +EXCEPTION_FLAG = FunctionCode.EXCEPTION_FLAG + +log = logging.getLogger(__name__) + +# Inter-frame silence: Modbus RTU spec says 3.5 char times. +# At 115200 baud, one char = 11 bits → 95.5 µs, so 3.5 chars ≈ 334 µs. +# We use a slightly longer gap for safety. +INTER_FRAME_GAP = 0.001 # 1 ms + +# After a `wait_for_response=False` write, give the slave long enough +# to finish any (potential) echo TX *and* internal processing before +# we send the next frame. An 8-byte FC06 echo at 115200 baud is ~0.76 ms; +# cheap dimmers also take a couple of ms to latch the new setpoint. 5 ms +# is well under the OEM's observed 3–6 s pacing between FC06 writes to +# 0x04, so this adds no user-visible latency to `pulse` / HA. +POST_BLIND_WRITE_QUIET = 0.005 # 5 ms + + +class RS485Transport: + """Low-level Modbus RTU master over a USB-RS485 serial adapter. + + All public methods are thread-safe (guarded by an internal lock). + Supports use as a context manager. + """ + + def __init__( + self, + port: str, + baudrate: int = 115200, + timeout: float = 0.3, + echo: bool = False, + ): + self._lock = threading.Lock() + self._timeout = timeout + self._echo = echo + self._ser = serial.Serial( + port=port, + baudrate=baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=timeout, + ) + log.info("Opened %s @ %d baud (echo=%s)", port, baudrate, echo) + + @property + def is_open(self) -> bool: + return self._ser.is_open + + def close(self) -> None: + with self._lock: + self._ser.close() + log.info("Closed serial port") + + def __enter__(self) -> RS485Transport: + return self + + def __exit__(self, *exc) -> None: + self.close() + + # ----- raw I/O ----- + + def _recv_exact(self, n: int) -> bytes: + """Read exactly n bytes within the port timeout or raise. + + pyserial's `read(n)` blocks up to the port's configured + timeout (set once at construction) and returns up to n bytes, + so a single call is enough — no per-byte bookkeeping. + """ + data = self._ser.read(n) + if len(data) < n: + raise ModbusTimeoutError(f"Got {len(data)}/{n} bytes before timeout") + return bytes(data) + + def _send(self, tx: bytes) -> None: + """Write a request and hold the required 3.5-char silence gap. + + Without the post-TX sleep the slave can miss frame-end + detection and never reply (Modbus RTU spec). Empirically, + dropping the gap caused every unicast read/write to time out. + + We short-circuit if the port has been closed under us — that + happens during HA reload, when `close()` lands between two + `_poll_with_gap` iterations. Returning a clean + `ModbusTimeoutError` (caught one level up by `poll_device`) + keeps the tail of that in-flight tick from spamming + SerialException tracebacks for every remaining scan-tier address. + """ + if not self._ser.is_open: + raise ModbusTimeoutError("Serial port is closed") + log.debug("TX [%d]: %s", len(tx), tx.hex(" ")) + self._ser.reset_input_buffer() + self._ser.write(tx) + self._ser.flush() + time.sleep(INTER_FRAME_GAP) + + def _transact( + self, tx: bytes, total_len_fn: Callable[[bytes], int] + ) -> bytes: + """Send `tx` and return the slave's raw response (addr..crc). + + `total_len_fn(head)` returns the full expected response size + once `head` contains the first 3 bytes of the frame (enough to + branch on the exception bit in FC). + """ + self._send(tx) + if self._echo: + self._recv_exact(len(tx)) + head = self._recv_exact(3) + total = total_len_fn(head) + raw = head + (self._recv_exact(total - 3) if total > 3 else b"") + log.debug("RX [%d]: %s", len(raw), raw.hex(" ")) + return raw + + # ----- Modbus operations ----- + + def read_holding_registers( + self, addr: int, start_reg: int = 0, qty: int = 1 + ) -> ReadResponse: + """FC 0x03: normal reply = 5 + 2*qty bytes; exception = 5 bytes.""" + req = build_read_holding(addr, start_reg, qty) + with self._lock: + raw = self._transact( + req, lambda h: 5 if (h[1] & EXCEPTION_FLAG) else 5 + qty * 2 + ) + resp = parse_response(raw) + if not isinstance(resp, ReadResponse): + raise ModbusError(f"Expected ReadResponse, got {type(resp).__name__}") + return resp + + def write_register( + self, + addr: int, + reg: int, + value: int, + wait_for_response: bool = True, + ) -> WriteResponse | None: + """FC 0x06: reply is the 8-byte echo of the request. + + Some GSS actuators (notably whatever's physically at `0x04` on + the current rig — a light dimmer) act on the write but never + send the Modbus echo back, confirmed in + `docs/capture-20260418-1152.sal`. Set `wait_for_response=False` + for those targets so the caller doesn't stall on a 300 ms + timeout per write — that path returns ``None`` since there's + nothing to echo back. + """ + req = build_write_single(addr, reg, value) + with self._lock: + if not wait_for_response: + # Go through _send so we honour the 3.5-char inter-frame + # silence Modbus RTU requires. Without it, the next TX on + # the bus lands inside the previous frame's silence + # window and every listening slave (including unrelated + # echo-capable ones like 0x06) discards it — that was + # the ~50% timeout we saw under `pulse`. + self._send(req) + # Give the slave a short tail window to finish + # processing (and transmitting an echo, if it does) + # before we let the next TX hit the bus. 0x04 is spec'd + # silent but this is cheap insurance. + time.sleep(POST_BLIND_WRITE_QUIET) + # Drain whatever's sitting in the RX buffer (any echo + # the slave sent) so it can't poison the next read. + if self._ser.in_waiting: + self._ser.reset_input_buffer() + return None + raw = self._transact(req, lambda h: 5 if (h[1] & EXCEPTION_FLAG) else 8) + resp = parse_response(raw) + if not isinstance(resp, WriteResponse): + raise ModbusError(f"Expected WriteResponse, got {type(resp).__name__}") + return resp + + def write_registers( + self, addr: int, start_reg: int, values: list[int] + ) -> WriteMultipleResponse: + """FC 0x10: 8-byte reply, or no reply for broadcast (addr 0).""" + req = build_write_multiple(addr, start_reg, values) + with self._lock: + if addr == 0: + self._send(req) + if self._echo: + try: + self._recv_exact(len(req)) + except ModbusTimeoutError: + pass + time.sleep(INTER_FRAME_GAP * 4) + return WriteMultipleResponse( + addr=0, start_reg=start_reg, qty=len(values) + ) + raw = self._transact(req, lambda h: 5 if (h[1] & EXCEPTION_FLAG) else 8) + resp = parse_response(raw) + if not isinstance(resp, WriteMultipleResponse): + raise ModbusError( + f"Expected WriteMultipleResponse, got {type(resp).__name__}" + ) + return resp diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_bus.py b/tests/test_bus.py new file mode 100644 index 0000000..d950c1a --- /dev/null +++ b/tests/test_bus.py @@ -0,0 +1,197 @@ +"""Tests for BusMaster — validation, scheduling, and broadcast.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from spiderwire.bus import BusMaster +from spiderwire.protocol import ModbusTimeoutError, ReadResponse +from spiderwire.registers import ( + ACTUATOR_ADDRS, + BROADCAST_REG_COUNT, + BROADCAST_START_REG, + FAST_ADDRS, + SCAN_ADDRS, + SensorHubData, +) + + +def _hub_regs(addr: int = 0x0A) -> list[int]: + """28-register sensor-hub frame (mirrors test_registers helper).""" + regs = [ + addr, + 0xAA00, + int.from_bytes(b"01", "big"), + int.from_bytes(b"23", "big"), + 0x1234, + 0x5678, + 0x0400, + 0x0001, + 0, + 0, + ] + regs += [0] * 18 + regs[10] = 245 # 24.5 °C + regs[11] = 612 # 61.2 % RH + return regs + + +def _make_transport() -> MagicMock: + """Mock transport that mimics the RS485Transport surface used by BusMaster.""" + return MagicMock() + + +class TestPollDevice: + def test_records_response_and_stats(self): + tx = _make_transport() + tx.read_holding_registers.return_value = ReadResponse( + addr=0x0A, registers=_hub_regs() + ) + bus = BusMaster(transport=tx) + result = bus.poll_device(0x0A) + assert isinstance(result, SensorHubData) + assert 0x0A in bus.devices + assert bus.poll_stats[0x0A]["ok"] == 1 + assert bus.last_seen[0x0A] > 0 + + def test_timeout_increments_fail_count(self): + tx = _make_transport() + tx.read_holding_registers.side_effect = ModbusTimeoutError("silent") + bus = BusMaster(transport=tx) + for _ in range(3): + assert bus.poll_device(0x0A) is None + assert bus.poll_stats[0x0A]["timeout"] == 3 + # `_fail_count` is internal but its behaviour drives the offline check. + assert bus._fail_count[0x0A] == 3 + + def test_offline_after_failures_drops_device(self): + tx = _make_transport() + tx.read_holding_registers.return_value = ReadResponse( + addr=0x0A, registers=_hub_regs() + ) + bus = BusMaster(transport=tx, offline_after_failures=2) + bus.poll_device(0x0A) + assert 0x0A in bus.devices + + tx.read_holding_registers.side_effect = ModbusTimeoutError("silent") + bus.poll_device(0x0A) + bus.poll_device(0x0A) + # Threshold is "more than" — exactly 2 failures still keeps it. + assert 0x0A in bus.devices + bus.poll_device(0x0A) + assert 0x0A not in bus.devices + + +class TestSetters: + def test_set_fan_speed_validates_range(self): + bus = BusMaster(transport=_make_transport()) + with pytest.raises(ValueError): + bus.set_fan_speed(0x04, 26) + with pytest.raises(ValueError): + bus.set_fan_speed(0x04, -1) + bus.set_fan_speed(0x04, 0) + bus.set_fan_speed(0x04, 25) + + def test_set_fan_speed_writes_reg_10(self): + tx = _make_transport() + bus = BusMaster(transport=tx) + bus.set_fan_speed(0x04, 12) + tx.write_register.assert_called_once_with(0x04, reg=10, value=12) + + def test_set_blower_validates_range(self): + bus = BusMaster(transport=_make_transport()) + with pytest.raises(ValueError): + bus.set_blower(0x06, 101) + with pytest.raises(ValueError): + bus.set_blower(0x06, -1) + bus.set_blower(0x06, 0) + bus.set_blower(0x06, 100) + + def test_set_blower_writes_reg_14(self): + tx = _make_transport() + bus = BusMaster(transport=tx) + bus.set_blower(0x06, 40) + tx.write_register.assert_called_once_with(0x06, reg=14, value=40) + + +class TestBroadcastSetpoints: + def test_default_setpoints_have_oem_constants(self): + bus = BusMaster(transport=_make_transport()) + assert len(bus.setpoints) == BROADCAST_REG_COUNT + assert bus.setpoints[1009 - BROADCAST_START_REG] == 7 + assert bus.setpoints[1011 - BROADCAST_START_REG] == 1112 + + def test_broadcast_calls_transport_with_payload(self): + tx = _make_transport() + bus = BusMaster(transport=tx) + bus.broadcast_setpoints() + tx.write_registers.assert_called_once() + addr, start, payload = tx.write_registers.call_args.args + assert addr == 0x00 + assert start == BROADCAST_START_REG + assert payload == bus.setpoints + + def test_broadcast_explicit_values_override_setpoints(self): + tx = _make_transport() + bus = BusMaster(transport=tx) + custom = [42] * BROADCAST_REG_COUNT + bus.broadcast_setpoints(custom) + _, _, payload = tx.write_registers.call_args.args + assert payload == custom + + def test_broadcast_rejects_wrong_length(self): + bus = BusMaster(transport=_make_transport()) + with pytest.raises(ValueError): + bus.broadcast_setpoints([0, 0]) + + +class TestTickScheduling: + def test_first_tick_visits_actuator_fast_and_heartbeat(self): + tx = _make_transport() + tx.read_holding_registers.return_value = ReadResponse( + addr=0x0A, registers=_hub_regs() + ) + bus = BusMaster(transport=tx, inter_poll_gap=0) + bus.tick() + + polled = [c.args[0] for c in tx.read_holding_registers.call_args_list] + # Tier B (actuators) runs before Tier A (fast) on the first tick + # because all deadlines start at 0. + assert polled[: len(ACTUATOR_ADDRS)] == ACTUATOR_ADDRS + assert polled[len(ACTUATOR_ADDRS):][: len(FAST_ADDRS)] == FAST_ADDRS + # Tier C (scan) also fires on the first tick (deadline 0). + scan_polled = polled[len(ACTUATOR_ADDRS) + len(FAST_ADDRS):] + assert scan_polled == SCAN_ADDRS + # Heartbeat broadcast happened at least once. + tx.write_registers.assert_called() + + def test_second_tick_skips_actuator_when_interval_not_elapsed(self): + tx = _make_transport() + tx.read_holding_registers.return_value = ReadResponse( + addr=0x0A, registers=_hub_regs() + ) + bus = BusMaster( + transport=tx, + inter_poll_gap=0, + actuator_interval=10.0, + scan_interval=10.0, + heartbeat_interval=10.0, + ) + bus.tick() + tx.read_holding_registers.reset_mock() + bus.tick() + + polled = [c.args[0] for c in tx.read_holding_registers.call_args_list] + # Only the fast tier should run on the second tick. + assert polled == FAST_ADDRS + + def test_tick_returns_devices_snapshot(self): + tx = _make_transport() + tx.read_holding_registers.return_value = ReadResponse( + addr=0x0A, registers=_hub_regs() + ) + bus = BusMaster(transport=tx, inter_poll_gap=0) + result = bus.tick() + assert result is bus.devices diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 0000000..4fc1773 --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,117 @@ +"""Pure-Python tests for the Modbus RTU protocol layer.""" + +from __future__ import annotations + +import pytest + +from spiderwire.protocol import ( + CRCError, + ExceptionResponse, + ModbusError, + ReadResponse, + WriteMultipleResponse, + WriteResponse, + append_crc, + build_read_holding, + build_write_multiple, + build_write_single, + check_crc, + crc16, + parse_response, +) + + +class TestCRC16: + def test_known_vector_empty(self): + # CRC-16/Modbus of empty input is the init value 0xFFFF. + assert crc16(b"") == 0xFFFF + + def test_known_vector_read_request(self): + # `01 03 00 00 00 01` → CRC 0x840A (low byte first on the wire). + assert crc16(bytes.fromhex("0103000000 01".replace(" ", ""))) == 0x0A84 + + def test_round_trip(self): + frame = bytes.fromhex("0A030001") + out = append_crc(frame) + assert check_crc(out) == frame + + def test_check_crc_too_short(self): + with pytest.raises(CRCError): + check_crc(b"\x01\x02\x03") + + def test_check_crc_mismatch(self): + good = append_crc(b"\x01\x03\x00") + tampered = good[:-1] + bytes([good[-1] ^ 0xFF]) + with pytest.raises(CRCError): + check_crc(tampered) + + +class TestRequestBuilders: + def test_read_holding(self): + # FC03 read 28 regs starting at 0 from slave 0x0A. + # Expected layload bytes (without CRC): 0A 03 00 00 00 1C + out = build_read_holding(addr=0x0A, start_reg=0, qty=28) + assert out[:6] == bytes.fromhex("0A030000001C") + assert len(out) == 8 # 6 payload + 2 CRC + # CRC must validate. + assert check_crc(out) == out[:-2] + + def test_write_single(self): + # FC06 write reg 10 = 50 on slave 0x04. + out = build_write_single(addr=0x04, reg=10, value=50) + assert out[:6] == bytes.fromhex("0406000A0032") + assert len(out) == 8 + assert check_crc(out) == out[:-2] + + def test_write_multiple(self): + # FC10 broadcast: addr 0x00, start 1001 (0x03E9), 2 regs. + values = [0x1111, 0x2222] + out = build_write_multiple(addr=0x00, start_reg=1001, values=values) + # addr fc start_hi start_lo qty_hi qty_lo bc data... + assert out[:7] == bytes.fromhex("001003E90002 04".replace(" ", "")) + assert out[7:11] == bytes.fromhex("11112222") + assert check_crc(out) == out[:-2] + + +class TestParseResponse: + def test_read_response(self): + # Slave 0x0A replies with 2 regs: [0x000A, 0xAA00]. + body = bytes.fromhex("0A0304000AAA00") + raw = append_crc(body) + resp = parse_response(raw) + assert isinstance(resp, ReadResponse) + assert resp.addr == 0x0A + assert resp.registers == [0x000A, 0xAA00] + + def test_write_single_response(self): + body = bytes.fromhex("0406000A0032") + raw = append_crc(body) + resp = parse_response(raw) + assert isinstance(resp, WriteResponse) + assert resp.addr == 0x04 + assert resp.reg == 10 + assert resp.value == 50 + + def test_write_multiple_response(self): + # Echo of the broadcast header: addr 00, fc 10, start 1001, qty 2. + body = bytes.fromhex("001003E90002") + raw = append_crc(body) + resp = parse_response(raw) + assert isinstance(resp, WriteMultipleResponse) + assert resp.start_reg == 1001 + assert resp.qty == 2 + + def test_exception_response(self): + # FC03 with the exception bit set, code 0x02 (Illegal Data Address). + body = bytes.fromhex("0A8302") + raw = append_crc(body) + with pytest.raises(ExceptionResponse) as ei: + parse_response(raw) + assert ei.value.fc == 0x03 + assert ei.value.code == 0x02 + + def test_unknown_function_code(self): + body = bytes.fromhex("0A7700") # FC 0x77 — not implemented. + raw = append_crc(body) + with pytest.raises(ModbusError): + parse_response(raw) diff --git a/tests/test_registers.py b/tests/test_registers.py new file mode 100644 index 0000000..941531e --- /dev/null +++ b/tests/test_registers.py @@ -0,0 +1,165 @@ +"""Tests for register parsing and dataclass decoders.""" + +from __future__ import annotations + +import math + +import pytest + +from spiderwire.registers import ( + BlowerData, + CO2SensorData, + DeviceHeader, + DeviceType, + FanControllerData, + SensorHubData, + _as_signed, + parse_device_data, +) + + +def _header(addr: int = 0x0A) -> list[int]: + """Build a 10-register device header. + + reg 0 = addr + reg 1 = magic 0xAA in high byte + reg 2,3 = ASCII "01" "23" → fw "01.23" + reg 4 = model code + reg 5 = serial fragment + reg 6 = device type (high byte = major) + reg 7 = hw version + reg 8,9 = padding + """ + return [ + addr, + 0xAA00, + int.from_bytes(b"01", "big"), + int.from_bytes(b"23", "big"), + 0x1234, + 0x5678, + (DeviceType.SENSOR_HUB << 8) | 0x01, + 0x0001, + 0, + 0, + ] + + +class TestDeviceHeader: + def test_basic_fields(self): + h = DeviceHeader.from_registers(_header(0x0A)) + assert h.address == 0x0A + assert h.magic_byte == 0xAA + assert h.fw_version == "01.23" + assert h.model_code == 0x1234 + assert h.serial_frag == 0x5678 + assert h.type_major == int(DeviceType.SENSOR_HUB) + assert h.type_name == "sensor_hub" + + def test_unknown_type_name(self): + regs = _header() + regs[6] = 0xEE00 # major byte 0xEE — not in DeviceType. + h = DeviceHeader.from_registers(regs) + assert h.type_name == "unknown(0xEE)" + + def test_non_ascii_fw_falls_back_to_hex(self): + regs = _header() + regs[2] = 0xFF00 # not ASCII + regs[3] = 0x00FF + h = DeviceHeader.from_registers(regs) + assert h.fw_version == "0xFF0000FF" + + +class TestSensorHubData: + def _hub_regs(self) -> list[int]: + regs = _header(0x0A) + [0] * 18 # need 28 total + # Override hub-specific slots. + regs[10] = 245 # 24.5 °C + regs[11] = 612 # 61.2 % RH + regs[12] = 218 # 21.8 °C soil + regs[13] = 850 # PPFD + regs[14] = 12 # secondary + regs[15] = 0 + regs[18] = 1 # light enabled + regs[19] = 75 # light value + regs[21] = 2 # zone + return regs + + def test_decode(self): + d = SensorHubData.from_registers(self._hub_regs()) + assert d.air_temp_c == pytest.approx(24.5) + assert d.air_humidity_pct == pytest.approx(61.2) + assert d.soil_temp_c == pytest.approx(21.8) + assert d.ppfd == 850 + assert d.light_enabled is True + assert d.light_value == 75 + assert d.zone == 2 + + def test_soil_disconnected_returns_none(self): + regs = self._hub_regs() + # -1000 sentinel encoded as 16-bit two's complement. + regs[12] = (-1000) & 0xFFFF + d = SensorHubData.from_registers(regs) + assert d.soil_temp_c is None + + def test_vpd_kpa_matches_tetens(self): + d = SensorHubData.from_registers(self._hub_regs()) + # Reference VPD at 24.5 °C / 61.2 % RH using Tetens. + t, rh = 24.5, 61.2 + svp = 0.6108 * math.exp(17.27 * t / (t + 237.3)) + expected = svp * (1 - rh / 100.0) + assert d.vpd_kpa == pytest.approx(expected, rel=1e-6) + + +class TestParseDeviceDataDispatch: + def test_sensor_hub_28_regs(self): + regs = _header(0x0A) + [0] * 18 + assert isinstance(parse_device_data(regs), SensorHubData) + + def test_fan_controller_24_regs(self): + regs = _header(0x04) + [0] * 14 + regs[10] = 50 # speed/brightness + regs[16] = 1 # enabled + d = parse_device_data(regs) + assert isinstance(d, FanControllerData) + assert d.speed == 50 + assert d.value == 50 + assert d.brightness_pct == 50 + assert d.enabled is True + + def test_blower_16_regs(self): + regs = _header(0x06) + [0] * 6 + regs[14] = 40 # setpoint % + regs[12] = 1 # running + d = parse_device_data(regs) + assert isinstance(d, BlowerData) + assert d.percent == 40 + assert d.running is True + + def test_co2_13_regs(self): + regs = _header(0x03) + [0] * 3 + regs[10] = 850 + d = parse_device_data(regs) + assert isinstance(d, CO2SensorData) + assert d.co2_ppm == 850 + + def test_header_only_10_regs(self): + d = parse_device_data(_header(0x0A)) + assert isinstance(d, DeviceHeader) + + def test_too_few_regs_raises(self): + with pytest.raises(ValueError): + parse_device_data([0] * 5) + + +class TestSigned: + def test_zero(self): + assert _as_signed(0) == 0 + + def test_positive(self): + assert _as_signed(0x7FFF) == 32767 + + def test_negative_one(self): + assert _as_signed(0xFFFF) == -1 + + def test_min(self): + assert _as_signed(0x8000) == -32768 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c732035 --- /dev/null +++ b/uv.lock @@ -0,0 +1,752 @@ +version = 1 +requires-python = ">=3.10" + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181 }, +] + +[[package]] +name = "build" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018 }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707 }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811 }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402 }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217 }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079 }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475 }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829 }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211 }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036 }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476 }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374 }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597 }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574 }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971 }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972 }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078 }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182 }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329 }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230 }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890 }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930 }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109 }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684 }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785 }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055 }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502 }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295 }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145 }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884 }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343 }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174 }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805 }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705 }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419 }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901 }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742 }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061 }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239 }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173 }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841 }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304 }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455 }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036 }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739 }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277 }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819 }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281 }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843 }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328 }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061 }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031 }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239 }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589 }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733 }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652 }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229 }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552 }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806 }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316 }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274 }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468 }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460 }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330 }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828 }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627 }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008 }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303 }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282 }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595 }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986 }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711 }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036 }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998 }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056 }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537 }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176 }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723 }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085 }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819 }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915 }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234 }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042 }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706 }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727 }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882 }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860 }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564 }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276 }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238 }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189 }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352 }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024 }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869 }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541 }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634 }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384 }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133 }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257 }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851 }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393 }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251 }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609 }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014 }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979 }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238 }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110 }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824 }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103 }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194 }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827 }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168 }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018 }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433 }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620 }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283 }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573 }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677 }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808 }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941 }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579 }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326 }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672 }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574 }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776 }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121 }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042 }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526 }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116 }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030 }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640 }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657 }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362 }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580 }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283 }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266 }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228 }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097 }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582 }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479 }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481 }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713 }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165 }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947 }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059 }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575 }, + { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126 }, + { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270 }, + { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797 }, + { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800 }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 }, +] + +[[package]] +name = "id" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", size = 14689 }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629 }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789 }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777 }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871 }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481 }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010 }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160 }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "more-itertools" +version = "11.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939 }, +] + +[[package]] +name = "nh3" +version = "0.3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/5f/1d19bdc7d27238e37f3672cdc02cb77c56a4a86d140cd4f4f23c90df6e16/nh3-0.3.5.tar.gz", hash = "sha256:45855e14ff056064fec77133bfcf7cd691838168e5e17bbef075394954dc9dc8", size = 20743 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/b0/8587ac42a9627ab88e7e221601f1dfccbf4db80b2a29222ea63266dc9abc/nh3-0.3.5-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:23a312224875f72cd16bde417f49071451877e29ef646a60e50fcb69407cc18a", size = 1420126 }, + { url = "https://files.pythonhosted.org/packages/c0/1b/1dbc4d0c43f12e8c1784ede17eaee6f061d4fbe5505757c65c49b2ceab95/nh3-0.3.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:387abd011e81959d5a35151a11350a0795c6edeb53ebfa02d2e882dc01299263", size = 793943 }, + { url = "https://files.pythonhosted.org/packages/47/9f/d6758d7a14ee964bf439cc35ae4fa24a763a93399c8ef6f22bd11d532d29/nh3-0.3.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:48f45e3e914be93a596431aa143dedf1582557bf41a58153c296048d6e3798c9", size = 841150 }, + { url = "https://files.pythonhosted.org/packages/b6/36/d5d1ae8374612c98f390e1ea7c610fa6c9716259a03bbf4d15b269f40073/nh3-0.3.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0a09f51806fd51b4fedbf9ea2b61fef388f19aef0d62fe51199d41648be14588", size = 1008415 }, + { url = "https://files.pythonhosted.org/packages/ba/8f/d13a9c3fd2d9c131a2a281737380e9379eb0f8c33fea24c2b923aaafbb15/nh3-0.3.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c357f1d042c67f135a5e6babb2b0e3b9d9224ff4a3543240f597767b01384ffd", size = 1092706 }, + { url = "https://files.pythonhosted.org/packages/bb/57/2f3add7f8680fcc896afa6a675cb2bab09982853ee8af40bad621f6b61c4/nh3-0.3.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:38748140bf76383ab7ce2dce0ad4cb663855d8fbc9098f7f3483673d09616a17", size = 1048346 }, + { url = "https://files.pythonhosted.org/packages/c1/c3/2f9e4ffa82863074d1361bfe949bc46393d91b3411579dfbbd090b24cac5/nh3-0.3.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:84bdeb082544fbcb77a12c034dd77d7da0556fdc0727b787eb6214b958c15e29", size = 1029038 }, + { url = "https://files.pythonhosted.org/packages/e8/10/2804deb3f3315184c9cae41702e293c87524b5a21f766b07d7fe3ffbcfbb/nh3-0.3.5-cp314-cp314t-win32.whl", hash = "sha256:c3aae321f67ae66cff2a627115f106a377d4475d10b0e13d97959a13486b9a88", size = 603263 }, + { url = "https://files.pythonhosted.org/packages/eb/a2/f6685248b49f7548fc9a8c335ab3a52f68610b72e8a61576447151e4e2e6/nh3-0.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c88605d8d468f7fc1b31e06129bc91d6c96f6c621776c9b504a0da9beac9df5f", size = 616866 }, + { url = "https://files.pythonhosted.org/packages/ca/b6/d8c9018635d4acfefde6b68470daa510eed715a350cbaa2f928ba0609f81/nh3-0.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:72c5bdedec27fa33de6a5326346ea8aa3fe54f6ac294d54c4b204fb66a9f1e79", size = 602566 }, + { url = "https://files.pythonhosted.org/packages/85/30/d162e99746a2fb1d98bb0ef23af3e201b156cf09f7de867c7390c8fe1c06/nh3-0.3.5-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3bb854485c9b33e5bb143ff3e49e577073bc6bc320f0ff8fc316dd89c0d3c101", size = 1442393 }, + { url = "https://files.pythonhosted.org/packages/25/8c/072120d506978ab053e1732d0efa7c86cb478fee0ee098fda0ac0d31cb34/nh3-0.3.5-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50d401ab2d8e86d59e2126e3ab2a2f45840c405842b626d9a51624b3a33b6878", size = 837722 }, + { url = "https://files.pythonhosted.org/packages/52/86/d4e06e28c5ad1c4b065f89737d02631bd49f1660b6ebcf17a87ffcd201da/nh3-0.3.5-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:acfd354e61accbe4c74f8017c6e397a776916dfe47c48643cf7fd84ade826f93", size = 822872 }, + { url = "https://files.pythonhosted.org/packages/0a/62/50659255213f241ec5797ae7427464c969397373e83b3659372b341ae869/nh3-0.3.5-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:52d877980d7ca01dc3baf3936bf844828bc6f332962227a684ed79c18cce14c3", size = 1100031 }, + { url = "https://files.pythonhosted.org/packages/00/7a/a12ae77593b2fcf3be25df7bc1c01967d0de448bdb4b6c7ec80fe4f5a74f/nh3-0.3.5-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:207c01801d3e9bb8ec08f08689346bdd30ce15b8bf60013a925d08b5388962a4", size = 1057669 }, + { url = "https://files.pythonhosted.org/packages/2d/71/5647dc04c0233192a3956fc91708822b21403a06508cacf78083c68e7bf0/nh3-0.3.5-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea232933394d1d58bf7c4bb348dc4660eae6604e1ae81cd2ba6d9ed80d390f3b", size = 914795 }, + { url = "https://files.pythonhosted.org/packages/1b/0e/bf298920729f216adcb002acf7ea01b90842603d2e4e2ce9b900d9ee8fab/nh3-0.3.5-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe3a787dc76b50de6bee54ef242f26c41dfe47654428e3e94f0fae5bb6dd2cc1", size = 806976 }, + { url = "https://files.pythonhosted.org/packages/85/01/26761e1dc2b848e65a62c19e5d39ad446283287cd4afddc89f364ab86bc9/nh3-0.3.5-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:488928988caad25ba14b1eb5bc74e25e21f3b5e40341d956f3ce4a8bc19460dc", size = 834904 }, + { url = "https://files.pythonhosted.org/packages/33/53/0766113e679540ac1edc1b82b1295aecd321eeb75d6fead70109a838b6ee/nh3-0.3.5-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c069570b06aa848457713ad7af4a9905691291548c4466a9ad78ee95808382b", size = 857159 }, + { url = "https://files.pythonhosted.org/packages/58/36/734d353dfaf292fed574b8b3092f0ef79dc6404f3879f7faaa61a4701fad/nh3-0.3.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:eeedc90ed8c42c327e8e10e621ccfa314fc6cce35d5929f4297ff1cdb89667c4", size = 1018600 }, + { url = "https://files.pythonhosted.org/packages/6b/aa/d9c59c1b49669fcb7bababa55df82385f029ad5c2651f583c3a1141cfdd1/nh3-0.3.5-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:de8e8621853b6470fe928c684ee0d3f39ea8086cebafe4c416486488dea7b68d", size = 1103530 }, + { url = "https://files.pythonhosted.org/packages/90/b0/cdd210bfb8d9d43fb02fc3c868336b9955934d8e15e66eb1d15a147b8af0/nh3-0.3.5-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:6ea58cc44d274c643b83547ca9654a0b1a817609b160601356f76a2b744c49ad", size = 1061754 }, + { url = "https://files.pythonhosted.org/packages/ce/cb/7a39e72e668c8445bdd95e494b3e21cfdddc68329be8ea3522c8befb46c4/nh3-0.3.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e49c9b564e6bcb03ecd2f057213df9a0de15a95812ac9db9600b590db23d3ae9", size = 1040938 }, + { url = "https://files.pythonhosted.org/packages/af/4c/fc2f9ed208a3801a319f59b5fea03cdc20cf3bd8af14be930d3a8de01224/nh3-0.3.5-cp38-abi3-win32.whl", hash = "sha256:559e4c73b689e9a7aa97ac9760b1bc488038d7c1a575aa4ab5a0e19ee9630c0f", size = 611445 }, + { url = "https://files.pythonhosted.org/packages/db/1a/e4c9b5e2ae13e6092c9ec16d8ca30646cb01fcdea245f36c5b08fd21fbd5/nh3-0.3.5-cp38-abi3-win_amd64.whl", hash = "sha256:45e6a65dc88a300a2e3502cb9c8e6d1d6b831d6fba7470643333609c6aab1f30", size = 626502 }, + { url = "https://files.pythonhosted.org/packages/80/7c/19cd0671d1ba2762fb388fc149697d20d0568ccfeef833b11280a619e526/nh3-0.3.5-cp38-abi3-win_arm64.whl", hash = "sha256:8f85285700a18e9f3fc5bff41fe573fa84f81542ef13b48a89f9fecca0474d3b", size = 611069 }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216 }, +] + +[[package]] +name = "pyserial" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585 }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, +] + +[[package]] +name = "readme-renderer" +version = "44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "nh3" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310 }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947 }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 }, +] + +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326 }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713 }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267 }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182 }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012 }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479 }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040 }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377 }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784 }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088 }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770 }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355 }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758 }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498 }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765 }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277 }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758 }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821 }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554 }, +] + +[[package]] +name = "spiderwire" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pyserial" }, +] + +[package.dev-dependencies] +dev = [ + { name = "build" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "twine" }, +] + +[package.metadata] +requires-dist = [{ name = "pyserial", specifier = ">=3.5" }] + +[package.metadata.requires-dev] +dev = [ + { name = "build", specifier = ">=1.0" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "ruff", specifier = ">=0.5" }, + { name = "twine", specifier = ">=5.0" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704 }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454 }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561 }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824 }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227 }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859 }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204 }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084 }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285 }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924 }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018 }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948 }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341 }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159 }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290 }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141 }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847 }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088 }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866 }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887 }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704 }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628 }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180 }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674 }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976 }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755 }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265 }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726 }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859 }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713 }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084 }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973 }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223 }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973 }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082 }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490 }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263 }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736 }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717 }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461 }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855 }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144 }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683 }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196 }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393 }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, +] + +[[package]] +name = "twine" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "id" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727 }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 }, +] + +[[package]] +name = "zipp" +version = "3.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378 }, +]