diff --git a/.github/workflows/copr_glob.txt b/.github/workflows/copr_glob.txt new file mode 100644 index 00000000..e69de29b diff --git a/.github/workflows/package-publish.yml b/.github/workflows/package-publish.yml new file mode 100644 index 00000000..f5a5b39d --- /dev/null +++ b/.github/workflows/package-publish.yml @@ -0,0 +1,87 @@ +name: Publish to PyPi, AUR, COPR + +on: + workflow_dispatch: + release: + types: [published] + +permissions: + contents: read + +jobs: + deploy_pypi: + runs-on: ubuntu-latest + environment: prod + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build babel + - name: Generate mo files + run: | + pybabel compile -D hhd -d ./i18n + pybabel compile -D adjustor -d ./i18n + /bin/cp -rf ./i18n/* ./src/hhd/i18n + - name: Build package + run: python -m build -s + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} + - name: Sleep for 15 minutes + run: sleep 900s + shell: bash + + deploy_aur: + runs-on: ubuntu-latest + environment: prod + needs: deploy_pypi + + steps: + - uses: actions/checkout@v3 + - name: Create PKGBUILD dir + run: mkdir -p ./pkg/ + - name: Build PKGBUILD + run: sed "s/pkgver=VERSION/pkgver=$(cat pyproject.toml | grep -E 'version = "[0-9\.]+"' -o | grep -E "[0-9\.]+" -o)/" PKGBUILD > ./pkg/PKGBUILD + - name: Publish AUR package + uses: KSXGitHub/github-actions-deploy-aur@v2.7.0 + with: + pkgname: hhd + pkgbuild: ./pkg/PKGBUILD + commit_username: ${{ secrets.AUR_USERNAME }} + commit_email: ${{ secrets.AUR_EMAIL }} + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_message: update to '${{ github.event.release.name }}' + allow_empty_commits: false + ssh_keyscan_types: rsa,ecdsa,ed25519 + updpkgsums: true + + deploy_copr: + runs-on: ubuntu-latest + environment: prod + needs: deploy_pypi + + steps: + - uses: actions/checkout@v3 + - name: Create spec file dir + run: mkdir -p ./pkg/ + - name: Build spec file + run: sed "s/REPLACE_VERSION/$(cat pyproject.toml | grep -E 'version = "[0-9\.]+"' -o | grep -E "[0-9\.]+" -o)/" hhd.spec > ./pkg/hhd.spec + - name: Publish to COPR repo + uses: s0/git-publish-subdir-action@develop + env: + REPO: git@github.com:hhd-dev/hhd-copr.git + BRANCH: main + FOLDER: pkg + SSH_PRIVATE_KEY: ${{ secrets.COPR_SSH_PRIVATE_KEY }} + MESSAGE: update to '${{ github.event.release.name }}' + SKIP_EMPTY_COMMITS: true + # Do not clear any files + CLEAR_GLOBS_FILE: .github/workflows/copr_glob.txt diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml new file mode 100644 index 00000000..19b2c2a1 --- /dev/null +++ b/.github/workflows/publish-aur.yml @@ -0,0 +1,31 @@ +name: Publish to AUR + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + deploy_aur: + runs-on: ubuntu-latest + environment: prod + + steps: + - uses: actions/checkout@v3 + - name: Create PKGBUILD dir + run: mkdir -p ./pkg/ + - name: Build PKGBUILD + run: sed "s/pkgver=VERSION/pkgver=$(cat pyproject.toml | grep -E 'version = "[0-9\.]+"' -o | grep -E "[0-9\.]+" -o)/" PKGBUILD > ./pkg/PKGBUILD + - name: Publish AUR package + uses: KSXGitHub/github-actions-deploy-aur@v2.7.0 + with: + pkgname: hhd + pkgbuild: ./pkg/PKGBUILD + commit_username: ${{ secrets.AUR_USERNAME }} + commit_email: ${{ secrets.AUR_EMAIL }} + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_message: manual update + allow_empty_commits: false + ssh_keyscan_types: rsa,ecdsa,ed25519 + updpkgsums: true diff --git a/.github/workflows/publish-copr.yml b/.github/workflows/publish-copr.yml new file mode 100644 index 00000000..73c84e9f --- /dev/null +++ b/.github/workflows/publish-copr.yml @@ -0,0 +1,27 @@ +name: Publish to COPR + +on: + workflow_dispatch: + +jobs: + deploy_copr: + runs-on: ubuntu-latest + environment: prod + + steps: + - uses: actions/checkout@v3 + - name: Create spec file dir + run: mkdir -p ./pkg/ + - name: Build spec file + run: sed "s/REPLACE_VERSION/$(cat pyproject.toml | grep -E 'version = "[0-9\.]+"' -o | grep -E "[0-9\.]+" -o)/" hhd.spec > ./pkg/hhd.spec + - name: Publish to COPR repo + uses: s0/git-publish-subdir-action@develop + env: + REPO: git@github.com:hhd-dev/hhd-copr.git + BRANCH: main + FOLDER: pkg + SSH_PRIVATE_KEY: ${{ secrets.COPR_SSH_PRIVATE_KEY }} + MESSAGE: update to '${{ github.event.release.name }}' + SKIP_EMPTY_COMMITS: true + # Do not clear any files + CLEAR_GLOBS_FILE: .github/workflows/copr_glob.txt diff --git a/.github/workflows/publish-pipy.yml b/.github/workflows/publish-pipy.yml new file mode 100644 index 00000000..e9a29ebc --- /dev/null +++ b/.github/workflows/publish-pipy.yml @@ -0,0 +1,35 @@ +name: Publish to PiPy + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + deploy_pypi: + runs-on: ubuntu-latest + environment: prod + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build babel + - name: Generate mo files + run: | + pybabel compile -D hhd -d ./i18n + pybabel compile -D adjustor -d ./i18n + /bin/cp -rf ./i18n/* ./src/hhd/i18n + - name: Build package + run: python -m build -s + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore index 99b04e0b..cbe3c45e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ venv *.egg-info dist .vscode -notebooks \ No newline at end of file +notebooks +*.mo \ No newline at end of file diff --git a/LICENSE b/LICENSE index 330dc700..f288702d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,23 +1,674 @@ -MIT License - -Copyright (c) 2023 Antheas Kapenekakis -Copyright (c) 2020 Filipe LaĆ­ns (initial ./src/hhd/controller/lib/uhid.py) -Copyright (c) 2019 Austin Morton (initial ./src/hhd/controller/lib/hid.py) - -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. + 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. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + 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 +. diff --git a/MANIFEST.in b/MANIFEST.in index cccc1f4b..0bfcf107 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,8 @@ recursive-include src/hhd *.yml recursive-include src/hhd *.yaml -graft usr/ \ No newline at end of file +recursive-include src/hhd *.mo +recursive-include src/hhd *.hut + +graft usr/ +graft src/hhd/http/static +include src/hhd/http/index.html \ No newline at end of file diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 00000000..dab49f79 --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,31 @@ +# Maintainer: Antheas Kapenekakis +pkgname=hhd +pkgver=VERSION +pkgrel=1 +pkgdesc='Handheld Daemon. A tool for managing the quirks of handheld devices.' +arch=('x86_64') +url='https://github.com/hhd-dev/hhd' +license=('GPL-3.0-or-later' 'MIT') +depends=('python' 'python-setuptools' 'python-evdev' 'python-rich' 'python-yaml' 'python-xlib' 'libusb' 'python-pyserial' 'lsof') +optdepends=('hhd-user: allows running hhd as a user service.') +makedepends=('python-'{'build','installer','setuptools','wheel'}) +source=("https://pypi.python.org/packages/source/h/hhd/hhd-${pkgver}.tar.gz") +sha512sums=('SKIP') + +build() { + cd "hhd-$pkgver" + python -m build --wheel --no-isolation +} + +package() { + cd "hhd-$pkgver" + python -m installer --destdir="$pkgdir" dist/*.whl + + # Install minimally necessary rules for running as a system service + mkdir -p ${pkgdir}/usr/lib/udev/rules.d/ + install -m644 usr/lib/udev/rules.d/83-hhd.rules ${pkgdir}/usr/lib/udev/rules.d/83-hhd.rules + mkdir -p ${pkgdir}/usr/lib/udev/hwdb.d/ + install -m644 usr/lib/udev/hwdb.d/83-hhd.hwdb ${pkgdir}/usr/lib/udev/hwdb.d/83-hhd.hwdb + mkdir -p ${pkgdir}/usr/lib/systemd/system/ + install -m644 usr/lib/systemd/system/hhd@.service ${pkgdir}/usr/lib/systemd/system/hhd@.service +} diff --git a/art/dark_mono.svg b/art/dark_mono.svg new file mode 100644 index 00000000..9e679e47 --- /dev/null +++ b/art/dark_mono.svg @@ -0,0 +1,48 @@ + + + + diff --git a/art/favicon.svg b/art/favicon.svg new file mode 100644 index 00000000..27db9789 --- /dev/null +++ b/art/favicon.svg @@ -0,0 +1,39 @@ + + + + diff --git a/art/light_mono.svg b/art/light_mono.svg new file mode 100644 index 00000000..18179c75 --- /dev/null +++ b/art/light_mono.svg @@ -0,0 +1,51 @@ + + + + diff --git a/art/logo.svg b/art/logo.svg new file mode 100644 index 00000000..f5a83876 --- /dev/null +++ b/art/logo.svg @@ -0,0 +1,770 @@ + + + + diff --git a/art/logo_dark.png b/art/logo_dark.png new file mode 100644 index 00000000..10b29f99 Binary files /dev/null and b/art/logo_dark.png differ diff --git a/art/logo_dark.svg b/art/logo_dark.svg new file mode 100644 index 00000000..12320b5d --- /dev/null +++ b/art/logo_dark.svg @@ -0,0 +1,45 @@ + + + + diff --git a/art/logo_light.png b/art/logo_light.png new file mode 100644 index 00000000..87ad3ad2 Binary files /dev/null and b/art/logo_light.png differ diff --git a/art/logo_light.svg b/art/logo_light.svg new file mode 100644 index 00000000..68ed4ef1 --- /dev/null +++ b/art/logo_light.svg @@ -0,0 +1,43 @@ + + + + diff --git a/art/poster.png b/art/poster.png new file mode 100644 index 00000000..8022254e Binary files /dev/null and b/art/poster.png differ diff --git a/art/poster.svg b/art/poster.svg new file mode 100644 index 00000000..c144f43f --- /dev/null +++ b/art/poster.svg @@ -0,0 +1,160 @@ + + + + diff --git a/art/poster_oem.png b/art/poster_oem.png new file mode 100644 index 00000000..7c1ca8df Binary files /dev/null and b/art/poster_oem.png differ diff --git a/art/poster_oem.svg b/art/poster_oem.svg new file mode 100644 index 00000000..47beafe6 --- /dev/null +++ b/art/poster_oem.svg @@ -0,0 +1,207 @@ + + + + diff --git a/docs/http.md b/docs/http.md new file mode 100644 index 00000000..3988c019 --- /dev/null +++ b/docs/http.md @@ -0,0 +1,344 @@ +# HTTP API Docs (v1) +HHD now has a simple and fully featured HTTP endpoint, which allows configuring +all available settings. +All endpoints below should be prefixed with `/api/v1/`. + +## Authentication +By default, the endpoint is restricted to localhost, and is only available through +the use of a token. +This token is automatically generated in `~/.config/hhd/token` and can be changed +afterwards by the user as well. + +The authentication is achieved through HTTP basic auth with a bearer token. +This means that all requests to `/api/v1` require the header `Authorization` +with content `Bearer `. + +To retrieve the user, you can either ask the user for it (they can retrieve it +with `hhd token`), or read it from `~/.config/hhd/token` with either superuser +or that user's permissions. + +## Settings endpoint (`./api/v1/settings`) +The API is on purpose very simple. +The settings endpoint `settings` returns the currently available settings as a +JSON. +All the available settings types can be found in `src/hhd/plugins.py`. +HHD ensures the json will have all the listed values in plugins.py, so you +may not check if they exist. + +Each setting has a title which is meant to be shown in the UI and an optional +hint meant to be shown under a hover hint or `?` button. + +Each setting may include a set of tags, that work like classes. +For example, a keyboard mapping setting may have the tags +`[razer_lycosa_123, razer_kbd, keyboard, advanced]`, which would allow the UI +to customize the presentation based on the specific device make, manufacturer, +or if neither are supported, show a generic keyboard remapper. +Tags are ordered by specificity, so `razer_lycosa_123` overrides `razer_kbd`. +The tag `advanced` can be used as a hint to hide the setting in simplified UIs (TBD). + +Essentially, under the type `Settings` are all the available settings, which +are self explanatory. +- `event`: Meant to simulate a one off event, like a reset. Set to true and hhd will remove it once it's applied (unused). +- `bool`: Checkbox setting +- `multiple`: Radial/dropdown setting. Options is a dictionary of values to UI friendly titles. +- `discrete`: Allows a number of fixed integer or floating values + (options is listed in increasing order). You may handle the same as multiple. +- `float`: Floating point setting, with optional min, max values +- `int`: Integer setting, same as above +- `color`: Broken and unused right now + +Each setting can be set to a single value coherent for its type (except color, tbd). + +Settings are grouped within containers with a type `container`, which has +an ordered dictionary of children. +The key of the dictionary is the id that will be used for the option. +Containers can be nested within containers, and the id of each container is +appended to the option name. + +HHD features settings sections, which are the outermost layer. +This allows you to only focus at the settings necessary for each UI component +(TDP, controllers, hhd settings). + +Here is an example that you will receive in json form from `/settings` (in yaml): +```yaml +version: +hhd: + http: + type: container + tags: [hhd-http] + title: REST API Configuration (BETA) + hint: >- + Settings for configuring the http endpoint of HHD. + + children: + enable: + type: bool + title: Enable REST API. + hint: >- + Enables the rest API of Handheld Daemon + default: False + port: + type: int + title: REST API Port + hint: >- + Which port should the REST API be on? + min: 1024 + max: 49151 + default: 5335 +``` + +The example above will result in the following default state: +```yaml +hhd: + http: + enable: False + port: 5335 +``` + +Settings can be viewed both as nested dictionaries and as a single dictionary. +The following states are identical according to HHD. +```yaml +hhd.http: + enable: False + port: 5335 +``` +```yaml +hhd.http.enable: False +hhd.http.port: 5335 +``` +```yaml +hhd: + http: + enable: False +hhd.http.port: 5335 +``` + +You also receive a version hex, which contains whether the settings have changed +and would prompt you to redraw your UI. +Currently, HHD settings do not change after service start, but future plugins +that rely on autodetection may start when e.g., a controller is connected. +This will make the settings change. + +HHD always performs validation for the currently loaded settings, so using a stale +state will not create problems. + +The final setting type is `mode`, which is a special type of container. +It is meant to be displayed as an accordion, with a specific sub-container +shown at a time. + +```yaml +controllers.legion_go: + type: container + tags: [lgc] + title: Legion Controllers Configuration + # ... + + children: + xinput: + type: mode + # ... + + default: ds5e + modes: + disabled: + type: container + # ... + + children: + shortcuts: + # ... + ds5e: + type: container + # ... + + children: + led_support: + # ... +``` + +The above will create the following default state: +```yaml +controllers.legion_go.xinput.mode: ds5e +controllers.legion_go.xinput.disabled.shortcuts: disabled +controllers.legion_go.xinput.ds5e.led_support: True +``` + +Example call (token disabled): +```bash +curl -i http://localhost:5335/api/v1/settings +HTTP/1.0 200 OK +Server: BaseHTTP/0.6 Python/3.11.6 +Date: ... +Access-Control-Allow-Origin: * +Access-Control-Allow-Credentials: true +WWW-Authenticate: Bearer + +{"controllers": {"legion_go": ... +``` + +## State endpoint (`./api/v1/state`) +The state endpoint with `GET` returns the current app state in JSON form. +Currently, only the nested dictionary form is returned, e.g.: +```yaml +hhd: + http: + enable: False + port: 5335 +``` +However, a future option will allow returning a single dictionary: +```yaml +hhd.http.enable: False +hhd.http.port: 5335 +``` + +You can also `POST` to the same endpoint with a mixed state presentation, which +may include some options inlined and some as nested dictionaries. +You only need to send changed options and HHD will merge them to the current +state internally. + +The `POST` endpoint will lock, apply the settings under `HHD`, and will return +the updated state. + +> Warning: the post endpoint may lock for up to 5+ seconds. +> Use a separate fetch thread/promise! +> Typically, it will be much less than 1 second. + +Example call (token disabled): +```bash +curl -i http://localhost:5335/api/v1/state +HTTP/1.0 200 OK +Server: BaseHTTP/0.6 Python/3.11.6 +Date: ... +Access-Control-Allow-Origin: * +Access-Control-Allow-Credentials: true +WWW-Authenticate: Bearer + +{"controllers": {"legion_go": {"xinput": {"mode": "ds5e", "disabled": {"shortcuts": true}, "ds5e": {"led_support": true}}, "gyro": true, "accel": true, "gyro_fix": 100, "swap_legion": "disabled", "share_to_qam": true, "touchpad_mode": "crop_end", "debug": false, "shortcuts": true}}, "hhd": {"http": {"enable": true, "port": 5335, "localhost": true, "token": false}}, "version": "af6eb199"}% +``` + +## Profile endpoint +HHD contains a profile system for changing multiple settings at a time. +This can be done per game, when switching windows, etc. + +The `profile` endpoint has 4 sub-endpoints: `list`, `apply`, `get`, `set`, `del`. + +Only characters and spaces are supported for the profile name. +HHD will silently strip other characters from the name. + +### `profile/list` Endpoint +The `list` `GET` endpoint returns a list of the available profiles. + +```bash +curl -i http://localhost:5335/api/v1/profile/list +HTTP/1.0 200 OK +Server: BaseHTTP/0.6 Python/3.11.6 +Date: ... +Access-Control-Allow-Origin: * +Access-Control-Allow-Credentials: true +WWW-Authenticate: Bearer + +["test"]% +``` + +### `profile/apply` Endpoint +The `apply` `GET` endpoint applies the selected profiles in the specified order +and returns the new HHD state. +The applied profiles are supplied as query arguments. +You may apply multiple profiles at a time, by nesting them as query parameters. +```bash +curl -i http://localhost:5335/api/v1/profile/apply\?profile\=\&profile\=test2 +HTTP/1.0 200 OK +Server: BaseHTTP/0.6 Python/3.11.6 +Date: ... +Access-Control-Allow-Origin: * +Access-Control-Allow-Credentials: true +WWW-Authenticate: Bearer + +{"controllers": {"legion_go": {"xinput": {"mode": "ds5e", "disabled": {"shortcuts": true}, "ds5e": {"led_support": true}}, "gyro": true, "accel": true, "gyro_fix": 100, "swap_legion": "disabled", "share_to_qam": true, "touchpad_mode": "crop_end", "debug": false, "shortcuts": true}}, "hhd": {"http": {"enable": true, "port": 5335, "localhost": true, "token": false}}, "version": "af6eb199"}% +``` + +### `profile/set` Endpoint +The set endpoint allows you to update the contents of a profile. +The response contains the updated profile. +The `set` endpoint replaces the whole profile and validates it, unlike the state +endpoint which merges it to the current state. +```bash +curl -i -X POST -d '{"controllers.legion_go.shortcuts": false}' http://localhost:5335/api/v1/profile/set\?profile\=test +HTTP/1.0 200 OK +Server: BaseHTTP/0.6 Python/3.11.6 +Date: ... +Access-Control-Allow-Origin: * +Access-Control-Allow-Credentials: true +WWW-Authenticate: Bearer + +{"controllers": {"legion_go": {"shortcuts": false}}, "version": "af6eb199"}% +``` + +### `profile/get` Endpoint +The `get` `GET` endpoint allows you to retrieve the contents of a profile. +```bash +curl -i http://localhost:5335/api/v1/profile/get\?profile\=test +HTTP/1.0 200 OK +Server: BaseHTTP/0.6 Python/3.11.6 +Date: ... +Access-Control-Allow-Origin: * +Access-Control-Allow-Credentials: true +WWW-Authenticate: Bearer + +{"controllers": {"legion_go": {"shortcuts": false}}, "version": "af6eb199"}% +``` + +### `profile/del` Endpoint +The `del` `GET` endpoint deletes the provided profile. + +```bash +# Profile exists +curl -i http://localhost:5335/api/v1/profile/del\?profile\=test +HTTP/1.0 200 OK +Server: BaseHTTP/0.6 Python/3.11.6 +Date: ... +Access-Control-Allow-Origin: * +Access-Control-Allow-Credentials: true +WWW-Authenticate: Bearer + +# Profile does not exist +curl -i http://localhost:5335/api/v1/profile/del\?profile\=test +HTTP/1.0 400 Bad Request +Server: BaseHTTP/0.6 Python/3.11.6 +Date: ... +Access-Control-Allow-Origin: * +Access-Control-Allow-Credentials: true +WWW-Authenticate: Bearer +Content-type: text / plain + +Handheld Daemon Error: +Profile 'test' not found.% +``` + +## Handling Errors +The `v1` API will always return a `JSON` object with status code 200 if called properly. + +When called improperly, it will return the following status codes: + - 401: Unauthorized: your token is invalid. + - 404: The endpoint you tried to access does not exist. + - 400: You supplied invalid parameters. + +The content of the response will be a human readable explanation in text form. +You may choose to display that to the user, through a modal or portal. + +## Version endpoint +You can query the version of the HHD V1 API to determine which features are available +and whether the user should update either your app or HHD. +The version is 1 now and this endpoint requires authentication. +It might not require authentication in the future. +```bash +curl -i http://localhost:5335/api/v1/version +HTTP/1.0 200 OK +Server: BaseHTTP/0.6 Python/3.11.6 +Date: ... +Access-Control-Allow-Origin: * +Access-Control-Allow-Credentials: true +WWW-Authenticate: Bearer + +{"version": 1}% +``` \ No newline at end of file diff --git a/docs/overlay.gif b/docs/overlay.gif new file mode 100644 index 00000000..171304bd Binary files /dev/null and b/docs/overlay.gif differ diff --git a/faq.md b/faq.md new file mode 100644 index 00000000..6f8aba79 --- /dev/null +++ b/faq.md @@ -0,0 +1,123 @@ +## Frequently Asked Questions (Old) + +### What does the current version of Handheld Daemon do? +The current version of HHD maps the x-input mode of the Legion Go controllers to +a DualSense 5 Edge controller, which allows using all of the controller +functions. In addition, it adds support for the Steam powerbutton action, so you +get a wink when going to sleep mode. + +When the controllers are not in x-input mode, HHD adds a shortcuts device so +that combos such as Steam and QAM keep working. + +### Steam reports a Legion Controller and a Shortcuts controller instead of a PS5 +The Legion controllers have multiple modes (namely x-input, d-input, dual d-input, +and FPS). +HHD only remaps the x-input mode of the controllers. +You can cycle through the modes with Legion L + RB. + +X-input and d-input refer to the protocol the controllers operate in. +Both are legacy protocols introduced in the mid-2000s and are included for hardware +support reasons. + +X-input is a USB controller protocol introduced with the xbox 360 controller and +is widely supported. +Direct input is a competing protocol that works based on USB HID. +Both work the same. +The only difference between them is that d-input has discrete triggers for some +reason, and some games read the button order wrong. + +X-input requires a special udev rule to work, see below. + +### Other Legion Go gamepad modes +Handheld Daemon remaps the x-input mode of the Legion Go controllers into a PS5 controller. +All other modes function as normal. +In addition, Handheld Daemon adds a shortcuts device that allows remapping the back buttons +and all Legion L, R + button combinations into shortcuts that will work accross +all modes. + +### I can not see any Legion Controllers controllers before or after installing +Your kernel needs to know to use the `xpad` driver for the Legion Go's +controllers. + +This is expected to be included in a future Linux kernel, so it is not included +by default by HHD. + +In the mean time, [apply the patch](https://github.com/torvalds/linux/compare/master...appsforartists:linux:legion-go-controllers.patch), or add a `udev` +rule: + + +You will see the following in the HHD logs (`sudo systemctl status hhd@$(whoami)`) +if you are missing the `xpad` rule. + +``` + ERROR Device with the following not found: + Vendor ID: ['17ef'] + Product ID: ['6182'] + Name: ['Generic X-Box pad'] +``` + +### Yuzu does not work with the PS5 controller +See above. +Use yuzu controller settings to select the DualSense controller and disable +Steam Input. + +### Freezing Gyro +The gyro used for the PS5 controller is found in the display. +It may freeze occasionally. This is due to the accelerometer driver being +designed to be polled at 5hz, not 100hz. +If that is the case, you need to reboot. + +The gyro may exhibit stutters when being polled by `iio-sensor-proxy` to determine +screen orientation. +However, a udev rule that is installed by default disables this. + +If you do not need gyro support, you should disable it for a .2% cpu utilisation +reduction. + +### No screen autorotation after install +HHD includes a udev rule that disables screen autorotation, because it interferes +with gyro support. +This is only done specifically to the accelerometer of the Legion Go. +If you do not need gyro, you can do the local install and modify +`83-hhd.rules` to remove that rule. +The gyro will freeze and will be unusable after that. + +### Touchpad Behavior is different after install/is not part of dualsense +By default, in the Legion Go the handheld daemon uses a virtual touchpad +with proper left/right clicks, which work in gamescope. +If you use your device outside gamescope and find this problematic, switch +`Touchpad Emulation` to `Disabled`. +If you want to use your touchpad for steam input, set the option to `Controller` +and use the `Right Touchpad` under steam. + +### HandyGCCS +HHD replicates all functionality of HandyGCCS for the Legion Go, so it is not +required. In addition, it will break HHD by hiding the controller. +You should uninstall it with `sudo pacman -R handygccs-git`. + +You will see the following in the HHD logs (`sudo systemctl status hhd@$(whoami)`) +if HandyGCCS is enabled. +``` + ERROR Device with the following not found: + Vendor ID: ['17ef'] + Product ID: ['6182'] + Name: ['Generic X-Box pad'] +``` + +### Buttons are mapped incorrectly +Buttons mapped in Legion Space will carry over to Linux. +This includes both back buttons and legion swap. +You can reset each controller by holding Legion R + RT + RB, Legion L + LT + LB. +However, we do not know how to reset the Legion Space legion button swap at +this point, so you need to use Legion Space for that. + +Another set of obscure issues occur depending on how apps hook to the Dualsense controller. +Certain versions of gamescope and certain games do not support the edge controller, +so switch to `Dualsense` or `Xbox` emulation modes if you are having issues. + +If Steam itself is broken and can not see the controllers properly (e.g., you +can not see led/gyro settings or the Edge controller mapping is wrong), you +should update your distribution and if that does not fix it consider re-installing. +There are certain gamescope/distro issues that cause this and we are unsure of +the cause at this moment. +ChimeraOS 44 and certain versions of Nobara 38 and 39 have this issue. diff --git a/hhd.spec b/hhd.spec new file mode 100644 index 00000000..9986cc78 --- /dev/null +++ b/hhd.spec @@ -0,0 +1,53 @@ +Name: hhd +Version: REPLACE_VERSION +Release: 1%{?dist} +Summary: Handheld Daemon, a tool for configuring handheld devices. + +License: GPL-3.0-or-later AND MIT +URL: https://github.com/hhd-dev/hhd +Source: https://pypi.python.org/packages/source/h/%{name}/%{name}-%{version}.tar.gz + +BuildArch: noarch +BuildRequires: systemd-rpm-macros +BuildRequires: python3-devel +BuildRequires: python3-build +BuildRequires: python3-installer +BuildRequires: python3-setuptools +BuildRequires: python3-wheel + +Requires: python3 +Requires: python3-evdev +Requires: python3-rich +Requires: python3-yaml +Requires: python3-setuptools +Requires: python3-xlib +Requires: python3-pyserial +Requires: libusb1 +Requires: hidapi + +%description +Handheld Daemon is a project that aims to provide utilities for managing handheld devices. With features ranging from TDP controls, to controller remappings, and gamescope session management. This will be done through a plugin system and an HTTP(/d-bus?) daemon, which will expose the settings of the plugins in a UI agnostic way. + +%prep +%autosetup -n %{name}-%{version} + +%build +%{python3} -m build --wheel --no-isolation + +%install +%{python3} -m installer --destdir="%{buildroot}" dist/*.whl +mkdir -p %{buildroot}%{_udevrulesdir} +install -m644 usr/lib/udev/rules.d/83-%{name}.rules %{buildroot}%{_udevrulesdir}/83-%{name}.rules +mkdir -p %{buildroot}%{_sysconfdir}/udev/hwdb.d +install -m644 usr/lib/udev/hwdb.d/83-%{name}.hwdb %{buildroot}%{_sysconfdir}/udev/hwdb.d/83-%{name}.hwdb +mkdir -p %{buildroot}%{_unitdir} +install -m644 usr/lib/systemd/system/%{name}@.service %{buildroot}%{_unitdir}/%{name}@.service + +%files +%doc readme.md +%license LICENSE +%{_bindir}/%{name}* +%{python3_sitelib}/%{name}* +%{_udevrulesdir}/83-%{name}.rules +%{_sysconfdir}/udev/hwdb.d/83-%{name}.hwdb +%{_unitdir}/%{name}@.service diff --git a/hhd_cmd.sh b/hhd_cmd.sh new file mode 100755 index 00000000..b929e8e2 --- /dev/null +++ b/hhd_cmd.sh @@ -0,0 +1,12 @@ +#!/usr/bin/bash +# Runs a new handheld daemon version until reboot +sudo systemctl stop hhd@$(whoami) +sudo systemctl stop hhd_local@$(whoami) +sudo pkill hhd + +rm -rf ~/.local/share/hhd-tmp +mkdir -p ~/.local/share/hhd-tmp +python -m venv --system-site-packages ~/.local/share/hhd-tmp/venv +~/.local/share/hhd-tmp/venv/bin/pip install git+https://github.com/hhd-dev/adjustor git+https://github.com/hhd-dev/hhd + +sudo ~/.local/share/hhd-tmp/venv/bin/hhd --user $(whoami) \ No newline at end of file diff --git a/i18n/adjustor.pot b/i18n/adjustor.pot new file mode 100644 index 00000000..79b4b743 --- /dev/null +++ b/i18n/adjustor.pot @@ -0,0 +1,889 @@ +# Translations template for PROJECT. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2025. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2025-02-28 12:37+0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +msgid "Disable Decky TDP plugins using the button below to continue." +msgstr "" + +#. Setting: TDP Controls +#. Field: title +msgid "TDP Controls" +msgstr "" + +#. Setting: Enable TDP Controls +#. Field: title +msgid "Enable TDP Controls" +msgstr "" + +#. Setting: Enable TDP Controls +#. Field: hint +msgid "" +"Enables TDP management by Handheld Daemon. While enabled, Handheld Daemon" +" will set and maintain the TDP limits set on start-up and during other " +"device changes (ac/dc)." +msgstr "" + +#. Setting: Error +#. Field: title +msgid "Error" +msgstr "" + +#. Setting: Error +#. Option: nowrite +msgid "Can not write to ACPI Call file. ACPI Call is required for TDP." +msgstr "" + +#. Setting: TDP Capabilities +#. Field: title +msgid "TDP Capabilities" +msgstr "" + +#. Setting: Disable Decky TDP Plugins +#. Field: title +msgid "Disable Decky TDP Plugins" +msgstr "" + +#. Setting: Disable Decky TDP Plugins +#. Field: hint +msgid "" +"Disables Decky TDP plugins (Powercontrol, SimpleDeckyTDP) by moving them " +"from ~/homebrew/plugins to ~/homebrew/plugins/hhd-disabled. Then, " +"restarts Decky. This might cause Steam to restart. Move them back and " +"reboot to re-enable." +msgstr "" + +#. Setting: Enable TDP Controls +#. Field: hint +msgid "" +"Enables TDP management by the Handheld Daemon. While enabled, Handheld " +"Daemon will set and maintain the TDP limits set on start-up and during " +"other device changes (ac/dc).\n" +"If the device crashes, TDP setting will be disabled on next startup." +msgstr "" + +#. Setting: Add TDP to /sys for Steam (Requires Restart) +#. Field: title +msgid "Add TDP to /sys for Steam (Requires Restart)" +msgstr "" + +#. Setting: Add TDP to /sys for Steam (Requires Restart) +#. Field: hint +msgid "" +"Uses a FUSE mount to add TDP attributes to /sys/class/drm. This fixes the" +" TDP slider in Steam." +msgstr "" + +#. Setting: Enforce Device TDP Limits +#. Field: title +msgid "Enforce Device TDP Limits" +msgstr "" + +#. Setting: Enforce Device TDP Limits +#. Field: hint +msgid "" +"When this option is on, the settings will adhere to the limits set out by" +" the device manufacturer, subject to their availability.\n" +"With it off, the TDP settings ranges will expand to what is logically " +"possible for the current device (regardless of manufacturer " +"specifications).\n" +"All settings outside specifications will be set to system specifications " +"after rebooting." +msgstr "" + +#. Setting: Processor Settings +#. Field: title +msgid "Processor Settings" +msgstr "" + +#. Setting: CPU Settings +#. Field: title +msgid "CPU Settings" +msgstr "" + +#. Setting: Auto +#. Field: title +msgid "Auto" +msgstr "" + +#. Setting: Auto +#. Field: hint +msgid "" +"Handheld Daemon will manage the energy management settings. This includes" +" CPU governor, CPU boost, GPU frequency, and CPU power preferences. At " +"low TDPs, the CPU will be tuned down and at other TDPs, it will use " +"balanced settings." +msgstr "" + +#. Setting: Manual +#. Field: title +msgid "Manual" +msgstr "" + +#. Setting: Manual +#. Field: hint +msgid "Allows you to set the energy management settings manually." +msgstr "" + +#. Setting: CPU Power (EPP) +#. Field: title +msgid "CPU Power (EPP)" +msgstr "" + +#. Setting: CPU Power (EPP) +#. Field: hint +msgid "" +"Sets the energy performance preference for the CPU. Keep on balanced for " +"good performance on all TDPs. Options map to `power`, `balance_power`, " +"`balance_performance`. Performance is not recommended and is not " +"included." +msgstr "" + +#. Setting: CPU Power (EPP) +#. Option: power +msgid "Low" +msgstr "" + +#. Setting: CPU Power (EPP) +#. Option: balance_power +#. Setting: Power Profile +#. Option: balanced +#. Setting: Balanced +#. Field: title +#. Setting: Platform Profile +#. Setting: Energy Policy +msgid "Balanced" +msgstr "" + +#. Setting: CPU Power (EPP) +#. Option: balance_performance +msgid "High" +msgstr "" + +#. Setting: CPU Minimum Frequency +#. Field: title +msgid "CPU Minimum Frequency" +msgstr "" + +#. Setting: CPU Minimum Frequency +#. Field: hint +msgid "" +"Sets the minimum frequency for the CPU. Using 400MHz will save battery in" +" light games. However, the delay of increasing the frequency may cause " +"minor stutters, especially in VRR displays." +msgstr "" + +#. Setting: CPU Minimum Frequency +#. Option: min +msgid "400MHz" +msgstr "" + +#. Setting: CPU Minimum Frequency +#. Option: nonlinear +msgid "1GHz" +msgstr "" + +#. Setting: CPU Boost +#. Field: title +msgid "CPU Boost" +msgstr "" + +#. Setting: CPU Boost +#. Field: hint +msgid "" +"Enables or disables the CPU boost frequencies. Disabling lowers total " +"consumption by 2W with minimal performance impact." +msgstr "" + +#. Setting: CPU Boost +#. Option: disabled +#. Setting: Custom Scheduler +#. Setting: Disabled +#. Field: title +#. Setting: Extreme Standby Mode +msgid "Disabled" +msgstr "" + +#. Setting: CPU Boost +#. Option: enabled +#. Setting: Extreme Standby Mode +msgid "Enabled" +msgstr "" + +#. Setting: Custom Scheduler +#. Field: title +msgid "Custom Scheduler" +msgstr "" + +#. Setting: Custom Scheduler +#. Field: hint +msgid "" +"Allows attaching a scheduler to the kernel sched_ext. Schedulers need to " +"be installed and kernel needs to support sched_ext." +msgstr "" + +#. Setting: Custom Scheduler +#. Option: scx_lavd +msgid "LAVD" +msgstr "" + +#. Setting: Custom Scheduler +#. Option: scx_bpfland +msgid "bpfland" +msgstr "" + +#. Setting: Custom Scheduler +#. Option: scx_rusty +msgid "rusty" +msgstr "" + +#. Setting: GPU Frequency +#. Field: title +msgid "GPU Frequency" +msgstr "" + +#. Setting: GPU Frequency +#. Field: hint +msgid "" +"Pins the GPU to a certain frequency. Helps in certain games that are CPU " +"or GPU heavy by shifting power to or from the GPU. Has a minor effect." +msgstr "" + +#. Setting: Auto +#. Field: hint +msgid "Lets the GPU manage its own frequency." +msgstr "" + +#. Setting: Max Limit +#. Field: title +msgid "Max Limit" +msgstr "" + +#. Setting: Max Limit +#. Field: hint +msgid "Limits the maximum frequency of the GPU." +msgstr "" + +#. Setting: Maximum Frequency +#. Field: title +msgid "Maximum Frequency" +msgstr "" + +#. Setting: Range +#. Field: title +msgid "Range" +msgstr "" + +#. Setting: Range +#. Field: hint +msgid "Sets the GPU frequency to a range." +msgstr "" + +#. Setting: Minimum Frequency +#. Field: title +msgid "Minimum Frequency" +msgstr "" + +#. Setting: Fixed +#. Field: title +msgid "Fixed" +msgstr "" + +#. Setting: Fixed +#. Field: hint +msgid "Pins the GPU to a certain frequency (not recommended)." +msgstr "" + +#. Setting: Frequency +#. Field: title +msgid "Frequency" +msgstr "" + +#. Setting: Conflict Detected +#. Field: title +msgid "Conflict Detected" +msgstr "" + +#. Setting: Enable Processor Settings +#. Field: title +msgid "Enable Processor Settings" +msgstr "" + +#. Setting: Enable energy management +#. Field: title +msgid "Enable energy management" +msgstr "" + +#. Setting: Enable energy management +#. Field: hint +msgid "" +"Handheld daemon will manage the power preferences for the system, " +"including Governor, Boost, GPU frequency, and EPP. In addition, Handheld " +"daemon will launch a PPD service to replace PPD's role in the system. " +msgstr "" + +#. Setting: Enable PPD Emulation (KDE/Gnome Power) +#. Field: title +msgid "Enable PPD Emulation (KDE/Gnome Power)" +msgstr "" + +#. Setting: Enable PPD Emulation (KDE/Gnome Power) +#. Field: hint +msgid "Enable PPD service to manage the power preferences for the system." +msgstr "" + +msgid "Steam is controlling TDP" +msgstr "" + +#. Setting: Asus TDP +#. Field: title +msgid "Asus TDP" +msgstr "" + +#. Setting: Asus TDP +#. Field: hint +msgid "Uses the interface of Armory Crate to set the TDP of the device." +msgstr "" + +#. Setting: TDP Mode +#. Field: title +msgid "TDP Mode" +msgstr "" + +#. Setting: Silent +#. Field: title +msgid "Silent" +msgstr "" + +#. Setting: Performance +#. Field: title +#. Setting: Power Profile +#. Option: performance +#. Setting: Platform Profile +#. Setting: Energy Policy +msgid "Performance" +msgstr "" + +#. Setting: Turbo +#. Field: title +msgid "Turbo" +msgstr "" + +#. Setting: Custom +#. Field: title +msgid "Custom" +msgstr "" + +#. Setting: TDP +#. Field: title +msgid "TDP" +msgstr "" + +#. Setting: TDP +#. Field: hint +msgid "" +"Average TDP Target. TDP Boost is recommended for desktop use and does not" +" affect gaming." +msgstr "" + +#. Setting: TDP Boost +#. Field: title +msgid "TDP Boost" +msgstr "" + +#. Setting: TDP Boost +#. Field: hint +msgid "" +"Allows the device to temporarily boost by setting appropriate slow and " +"fast TDPs." +msgstr "" + +#. Setting: +#. Field: title +msgid " " +msgstr "" + +#. Setting: Change TDP with View+Y +#. Field: title +msgid "Change TDP with View+Y" +msgstr "" + +#. Setting: Change TDP with View+Y +#. Field: hint +msgid "" +"Allows you to cycle through TDP modes with the View+Y key combination. " +"Recommended to use with ROG Swap, as the View button will be muted to " +"games." +msgstr "" + +#. Setting: Custom Fan Curve +#. Field: title +msgid "Custom Fan Curve" +msgstr "" + +#. Setting: Custom Fan Curve +#. Field: hint +msgid "Allows you to set a custom fan curve." +msgstr "" + +#. Setting: Disabled +#. Field: hint +msgid "Lets the device manage the fan curve on its own." +msgstr "" + +#. Setting: 30C +#. Field: title +msgid "30C" +msgstr "" + +#. Setting: 30C +#. Field: hint +#. Setting: 40C +#. Setting: 50C +#. Setting: 60C +#. Setting: 70C +#. Setting: 80C +#. Setting: 90C +#. Setting: 100C +#. Setting: 10C +#. Setting: 20C +msgid "Sets the speed at the named temperature." +msgstr "" + +#. Setting: 40C +#. Field: title +msgid "40C" +msgstr "" + +#. Setting: 50C +#. Field: title +msgid "50C" +msgstr "" + +#. Setting: 60C +#. Field: title +msgid "60C" +msgstr "" + +#. Setting: 70C +#. Field: title +msgid "70C" +msgstr "" + +#. Setting: 80C +#. Field: title +msgid "80C" +msgstr "" + +#. Setting: 90C +#. Field: title +msgid "90C" +msgstr "" + +#. Setting: 100C +#. Field: title +msgid "100C" +msgstr "" + +#. Setting: Restore Default +#. Field: title +msgid "Restore Default" +msgstr "" + +#. Setting: Restore Default +#. Field: hint +msgid "Restore a default sane fan curve." +msgstr "" + +#. Setting: Fan Curve Limitation +#. Field: title +msgid "Fan Curve Limitation" +msgstr "" + +#. Setting: Battery Settings +#. Field: title +msgid "Battery Settings" +msgstr "" + +#. Setting: Charge Limit (%) +#. Field: title +msgid "Charge Limit (%)" +msgstr "" + +#. Setting: Charge Limit (%) +#. Field: hint +msgid "Applies a charge limit to the battery, 75% and up." +msgstr "" + +#. Setting: Charge Limit (%) +#. Option: p70 +msgid "70%" +msgstr "" + +#. Setting: Charge Limit (%) +#. Option: p80 +msgid "80%" +msgstr "" + +#. Setting: Charge Limit (%) +#. Option: p85 +msgid "85%" +msgstr "" + +#. Setting: Charge Limit (%) +#. Option: p90 +msgid "90%" +msgstr "" + +#. Setting: Charge Limit (%) +#. Option: p95 +msgid "95%" +msgstr "" + +#. Setting: Charge Limit (%) +#. Option: disabled +#. Setting: Charge Bypass +msgid "Unset" +msgstr "" + +#. Setting: Extreme Standby Mode +#. Field: title +msgid "Extreme Standby Mode" +msgstr "" + +#. Setting: Extreme Standby Mode +#. Field: hint +#, python-format +msgid "" +"Lowers the power consumption of the device from 4% to 1% overnight. " +"Active only on battery. Turns off the power light and the controller " +"requires longer to wake up." +msgstr "" + +#. Setting: Charge Bypass +#. Field: title +msgid "Charge Bypass" +msgstr "" + +#. Setting: Charge Bypass +#. Option: awake +msgid "While On" +msgstr "" + +#. Setting: Charge Bypass +#. Option: always +msgid "Always" +msgstr "" + +#. Setting: Power +#. Field: title +#. Setting: Energy Policy +#. Option: power +msgid "Power" +msgstr "" + +#. Setting: Power Profile +#. Field: title +msgid "Power Profile" +msgstr "" + +#. Setting: Power Profile +#. Field: hint +msgid "" +"Allows setting the power profile of the system using Power Profiles " +"Daemon." +msgstr "" + +#. Setting: Power Profile +#. Option: power-saver +msgid "Powersave" +msgstr "" + +#. Setting: Steamdeck Overclock (Requires Reboot) +#. Field: title +msgid "Steamdeck Overclock (Requires Reboot)" +msgstr "" + +#. Setting: Steamdeck Overclock (Requires Reboot) +#. Field: hint +msgid "" +"Allows setting the Steam TDP slider from 1-20W instead of 4-15W. " +"Unchecked, it is still setting TDP to 15W." +msgstr "" + +msgid "Power Light" +msgstr "" + +msgid "Legion Button + Y changes TDP Mode" +msgstr "" + +#. Setting: Lenovo TDP +#. Field: title +msgid "Lenovo TDP" +msgstr "" + +#. Setting: Lenovo TDP +#. Field: hint +msgid "Uses the interface of Legion Space to set the TDP of the device." +msgstr "" + +#. Setting: Quiet +#. Field: title +#. Setting: Platform Profile +#. Option: quiet +msgid "Quiet" +msgstr "" + +#. Setting: TDP +#. Field: hint +msgid "" +"Maximum average TDP. Boost goes a bit higher and is recommended for " +"desktop use." +msgstr "" + +#. Setting: TDP Boost +#. Field: hint +msgid "Allows the device to boost by setting appropriate slow and fast TDPs." +msgstr "" + +#. Setting: Set Fan to Full Speed +#. Field: title +msgid "Set Fan to Full Speed" +msgstr "" + +#. Setting: Disabled +#. Field: hint +msgid "" +"Lets Legion GO manage the curve on its own. Setting this option will " +"cause a mode change to reset the fan curve." +msgstr "" + +#. Setting: 10C +#. Field: title +msgid "10C" +msgstr "" + +#. Setting: 20C +#. Field: title +msgid "20C" +msgstr "" + +#. Setting: Enforce Windows Minimums +#. Field: title +msgid "Enforce Windows Minimums" +msgstr "" + +#. Setting: Enforce Windows Minimums +#. Field: hint +msgid "Enforce the minimum fan curve from Legion Space." +msgstr "" + +#. Setting: Restore Default +#. Field: hint +msgid "Reset to the original fan curve for custom mode." +msgstr "" + +#. Setting: Show TDP changes with RGB +#. Field: title +msgid "Show TDP changes with RGB" +msgstr "" + +#. Setting: Charge Limit (80%) +#. Field: title +msgid "Charge Limit (80%)" +msgstr "" + +#. Setting: Charge Limit (80%) +#. Field: hint +msgid "Limits device charging to 80%." +msgstr "" + +#. Setting: Power Light (Awake) +#. Field: title +msgid "Power Light (Awake)" +msgstr "" + +#. Setting: Power Light (Sleep) +#. Field: title +msgid "Power Light (Sleep)" +msgstr "" + +#. Setting: TDP Settings +#. Field: title +msgid "TDP Settings" +msgstr "" + +#. Setting: TDP +#. Field: hint +msgid "Controls all Ryzen SMU settings through preset curves." +msgstr "" + +#. Setting: Custom Fan Curve +#. Field: hint +msgid "" +"Allows you to set a custom fan curve and to choose the temperature probe " +"(Edge or Junction). Junction is the peak temperature of the chip: " +"responds faster and prevents throttling. Edge is the temperature of the " +"chip: responds slower and prevents overheating." +msgstr "" + +#. Setting: Manual (Edge, Smooth) +#. Field: title +msgid "Manual (Edge, Smooth)" +msgstr "" + +#. Setting: Reset to Default +#. Field: title +msgid "Reset to Default" +msgstr "" + +#. Setting: Manual (Tctl, Fast) +#. Field: title +msgid "Manual (Tctl, Fast)" +msgstr "" + +#. Setting: Advanced Configurator +#. Field: title +msgid "Advanced Configurator" +msgstr "" + +#. Setting: Apply Settings +#. Field: title +msgid "Apply Settings" +msgstr "" + +#. Setting: TDP Status +#. Field: title +msgid "TDP Status" +msgstr "" + +#. Setting: Platform Profile +#. Field: title +msgid "Platform Profile" +msgstr "" + +#. Setting: Platform Profile +#. Option: disabled +msgid "Not Set" +msgstr "" + +#. Setting: Platform Profile +#. Option: low-power +msgid "Low Power" +msgstr "" + +#. Setting: Platform Profile +#. Option: cool +msgid "Cool" +msgstr "" + +#. Setting: Platform Profile +#. Option: balanced-performance +msgid "Balanced Performance" +msgstr "" + +#. Setting: Energy Policy +#. Field: title +msgid "Energy Policy" +msgstr "" + +#. Setting: Standard Parameters +#. Field: title +msgid "Standard Parameters" +msgstr "" + +#. Setting: Standard Parameters +#. Field: hint +msgid "" +"Standard TDP parameters for Ryzen processors. All need to be set to " +"properly control the TDP of the device.\n" +"Ryzen processors have 2 modes: STTv2 and STAPM (legacy). AMD suggests to" +" manufacturers to use STTv2, which makes the Legion Go the only device " +"to offer the STAPM alternative through a BIOS setting.\n" +"In STTv2, the device will keep boosting until the \"skin\" of the device " +"(hottest user accessible spot) reaches a manufacturer set temperature. " +"Then, the device will use the Skin Temp TDP limit. In STAPM, the device " +"averages the TDP values from the 1-3 previous minutes and keeps that " +"value under the STAPM TDP limit. Either mode ignores the other mode's " +"limit (STAPM limit does nothing on STT and Skin Temp Limit does nothing " +"on STAPM), so both should be set.\n" +"The Fast and Slow limits control boosting behavior. The Fast TDP limit is" +" the actual max TDP value of the device. Then,the Slow TDP limit averages" +" the last 10-20s of TDP values and keeps the value below it." +msgstr "" + +#. Setting: Fast TDP Limit +#. Field: title +msgid "Fast TDP Limit" +msgstr "" + +#. Setting: Slow TDP Limit +#. Field: title +msgid "Slow TDP Limit" +msgstr "" + +#. Setting: Skin Temp TDP Limit +#. Field: title +msgid "Skin Temp TDP Limit" +msgstr "" + +#. Setting: STAPM TDP Limit +#. Field: title +msgid "STAPM TDP Limit" +msgstr "" + +#. Setting: Advanced Parameters +#. Field: title +msgid "Advanced Parameters" +msgstr "" + +#. Setting: Advanced Parameters +#. Field: hint +msgid "" +"The Advanced Parameters below control boosting behavior and need to be " +"adjusted per device depending on its cooling system. They mostly affect " +"boosting behavior, which is important for desktop use.\n" +"The exception is the Temp Target (TCTL), which controls the max " +"temperature of the CPU die. On most devices, it can safely be raised up " +"to 100C. However, if a temperature spike makes the chip reach 105C, it " +"will enter a thermal protection mode, which is 5W, for a couple of " +"minutes.\n" +"The integration times for Slow TDP and STAPM influence how many previous " +"TDP values the CPU will average to calculate its current Slow and STAPM " +"TDP values." +msgstr "" + +#. Setting: Temp Target (TCTL) +#. Field: title +msgid "Temp Target (TCTL)" +msgstr "" + +#. Setting: Slow Limit Integration Time +#. Field: title +msgid "Slow Limit Integration Time" +msgstr "" + +#. Setting: STAPM Limit Integration Time +#. Field: title +msgid "STAPM Limit Integration Time" +msgstr "" + +#. Setting: Enable Advanced Parameters +#. Field: title +msgid "Enable Advanced Parameters" +msgstr "" + diff --git a/i18n/babel.cfg b/i18n/babel.cfg new file mode 100644 index 00000000..e57448cc --- /dev/null +++ b/i18n/babel.cfg @@ -0,0 +1,5 @@ +[hhd_yaml: **.yml] + +[hhd_yaml: **.yaml] + +[python: **.py] \ No newline at end of file diff --git a/i18n/hhd.pot b/i18n/hhd.pot new file mode 100644 index 00000000..3a506aad --- /dev/null +++ b/i18n/hhd.pot @@ -0,0 +1,2000 @@ +# Translations template for PROJECT. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2025. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2025-02-28 12:21+0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#. Section Name for: tdp +msgid "TDP" +msgstr "" + +#. Section Name for: rgb +msgid "RGB" +msgstr "" + +#. Section Name for: controllers +#. Setting: Controller +#. Field: title +msgid "Controller" +msgstr "" + +#. Section Name for: wincontrols +#. Setting: WinControls +#. Field: title +msgid "WinControls" +msgstr "" + +#. Section Name for: gamemode +msgid "General" +msgstr "" + +#. Section Name for: updates +msgid "Updates" +msgstr "" + +#. Section Name for: debug +msgid "Bugreport" +msgstr "" + +#. Section Name for: shortcuts +msgid "Shortcuts" +msgstr "" + +#. Section Name for: hhd +msgid "Settings" +msgstr "" + +#. Setting: Core Settings +#. Field: title +msgid "Core Settings" +msgstr "" + +#. Setting: Language +#. Field: title +msgid "Language" +msgstr "" + +#. Setting: Language +#. Option: system +#. Setting: System +#. Field: title +msgid "System" +msgstr "" + +#. Setting: Language +#. Option: C +msgid "English" +msgstr "" + +#. Setting: Language +#. Option: zh_CN +msgid "Simplified Chinese" +msgstr "" + +#. Setting: Language +#. Option: zh_TW +msgid "Traditional Chinese" +msgstr "" + +#. Setting: Language +#. Option: pt +msgid "Portugese" +msgstr "" + +#. Setting: Theme +#. Field: title +msgid "Theme" +msgstr "" + +#. Setting: Theme +#. Field: hint +msgid "" +"Allows changing the theme in the UI. Default is either Diavolo or your " +"distribution's theme." +msgstr "" + +#. Setting: Theme +#. Option: default +#. Setting: Default +#. Field: title +msgid "Default" +msgstr "" + +#. Setting: Theme +#. Option: diavolo +msgid "Diavolo" +msgstr "" + +#. Setting: Theme +#. Option: ocean +msgid "Atlantis" +msgstr "" + +#. Setting: Theme +#. Option: vapor +msgid "Vapor" +msgstr "" + +#. Setting: Theme +#. Option: blood_orange +msgid "Blood Orange" +msgstr "" + +#. Setting: Reset Settings +#. Field: title +msgid "Reset Settings" +msgstr "" + +#. Setting: Reset Settings +#. Field: hint +msgid "Resets all Handheld Daemon settings to their default values." +msgstr "" + +#. Setting: It is no longer possible to update Decky from here. If you see +#. this, update the Decky plugin manually. +#. Field: title +msgid "" +"It is no longer possible to update Decky from here. If you see this, " +"update the Decky plugin manually." +msgstr "" + +#. Setting: Handheld Daemon Version +#. Field: title +msgid "Handheld Daemon Version" +msgstr "" + +#. Setting: Handheld Daemon Version +#. Field: hint +#. Setting: Handheld Daemon UI Version +#. Setting: Adjustor (TDP) Version +msgid "Displays the Handheld Daemon version." +msgstr "" + +#. Setting: Handheld Daemon UI Version +#. Field: title +msgid "Handheld Daemon UI Version" +msgstr "" + +#. Setting: Adjustor (TDP) Version +#. Field: title +msgid "Adjustor (TDP) Version" +msgstr "" + +#. Setting: Update (Stable) +#. Field: title +msgid "Update (Stable)" +msgstr "" + +#. Setting: Update (Stable) +#. Field: hint +msgid "Updates to the latest version from PyPi (local install only)." +msgstr "" + +#. Setting: Update (Unstable) +#. Field: title +msgid "Update (Unstable)" +msgstr "" + +#. Setting: Update (Unstable) +#. Field: hint +msgid "Updates to the master branch from git (local install only)." +msgstr "" + +#. Setting: Update Error +#. Field: title +msgid "Update Error" +msgstr "" + +#. Setting: API Configuration +#. Field: title +msgid "API Configuration" +msgstr "" + +#. Setting: API Configuration +#. Field: hint +msgid "Settings for configuring the http endpoint of HHD." +msgstr "" + +#. Setting: Enable the API +#. Field: title +msgid "Enable the API" +msgstr "" + +#. Setting: Enable the API +#. Field: hint +msgid "Enables the API of Handheld Daemon (required for decky and ui)." +msgstr "" + +#. Setting: API Port +#. Field: title +msgid "API Port" +msgstr "" + +#. Setting: API Port +#. Field: hint +msgid "Which port should the API be on?" +msgstr "" + +#. Setting: Limit Access to localhost +#. Field: title +msgid "Limit Access to localhost" +msgstr "" + +#. Setting: Limit Access to localhost +#. Field: hint +msgid "Sets the API target to '127.0.0.1' instead '0.0.0.0'." +msgstr "" + +#. Setting: Use Security token +#. Field: title +msgid "Use Security token" +msgstr "" + +#. Setting: Use Security token +#. Field: hint +msgid "" +"Generates a security token in `~/.config/hhd/token` that is required for " +"authentication." +msgstr "" + +#. Setting: Handheld +#. Field: title +msgid "Handheld" +msgstr "" + +#. Setting: Handheld +#. Field: hint +#. Setting: Orange Pi Neo +#. Setting: OneXPlayer Controller +msgid "Allows for configuring your handheld's controller to a unified output." +msgstr "" + +#. Setting: Controller Emulation +#. Field: title +msgid "Controller Emulation" +msgstr "" + +#. Setting: Controller Emulation +#. Field: hint +msgid "Emulate different controller types to fuse your device's features." +msgstr "" + +#. Setting: Swap Guide and Menu/View +#. Field: title +msgid "Swap Guide and Menu/View" +msgstr "" + +#. Setting: Swap Guide and Menu/View +#. Field: hint +msgid "Swaps the Guide and QAM buttons with start and select." +msgstr "" + +#. Setting: Motion Support +#. Field: title +msgid "Motion Support" +msgstr "" + +#. Setting: Motion Support +#. Field: hint +msgid "Enable gyroscope/accelerometer (IMU) support (.3% background CPU use)" +msgstr "" + +#. Setting: Motion Hz +#. Field: title +msgid "Motion Hz" +msgstr "" + +#. Setting: Motion Hz +#. Field: hint +msgid "Sets the sampling frequency for the IMU." +msgstr "" + +#. Setting: Nintendo Mode (A-B Swap) +#. Field: title +msgid "Nintendo Mode (A-B Swap)" +msgstr "" + +#. Setting: Nintendo Mode (A-B Swap) +#. Field: hint +msgid "Swaps A with B and X with Y." +msgstr "" + +#. Setting: Hold View to Reboot +#. Field: title +msgid "Hold View to Reboot" +msgstr "" + +#. Setting: GPD Controller +#. Field: title +msgid "GPD Controller" +msgstr "" + +#. Setting: GPD Controller +#. Field: hint +msgid "Allows for configuring the gpd win controllers to a unified output." +msgstr "" + +#. Setting: Controller Emulation +#. Field: hint +msgid "Emulate different controller types to fuse gpd features." +msgstr "" + +#. Setting: Menu/L4/R4 Mapping +#. Field: title +msgid "Menu/L4/R4 Mapping" +msgstr "" + +#. Setting: Menu/L4/R4 Mapping +#. Field: hint +msgid "" +"Maps L4/R4 to Steam Input (requires L4/R4 as HHD in Wincontrols tab). If " +"disabled, they are keyboard buttons. Menu/L4/R4 can be combos: Menu is " +"single-press QAM, double HHD, hold Xbox. L4/R4 are single-press QAM, hold" +" HHD (legacy). " +msgstr "" + +#. Setting: Start/Select do SteamOS Combos +#. Option: disabled +#. Setting: Menu/L4/R4 Mapping +#. Setting: Disabled +#. Field: title +#. Setting: Extra buttons as +#. Setting: Short Action +#. Setting: Hold Action +#. Setting: Xbox or View + B (Press) +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "Disabled" +msgstr "" + +#. Setting: Menu/L4/R4 Mapping +#. Option: generic +msgid "Paddles, no Combo" +msgstr "" + +#. Setting: Menu/L4/R4 Mapping +#. Option: menu +msgid "Menu is Combo" +msgstr "" + +#. Setting: Menu/L4/R4 Mapping +#. Option: l4 +msgid "L4 is Combo" +msgstr "" + +#. Setting: Menu/L4/R4 Mapping +#. Option: r4 +msgid "R4 is Combo" +msgstr "" + +#. Setting: Start/Select do SteamOS Combos +#. Field: title +msgid "Start/Select do SteamOS Combos" +msgstr "" + +#. Setting: Start/Select do SteamOS Combos +#. Field: hint +msgid "" +"When holding Select or Start, if another button is pressed, they become " +"the Xbox button, which allows doing SteamOS combos (Select+RT is " +"screenshot)." +msgstr "" + +#. Setting: Start/Select do SteamOS Combos +#. Option: select +msgid "Select Only" +msgstr "" + +#. Setting: Start/Select do SteamOS Combos +#. Option: start_select +msgid "Start+Select" +msgstr "" + +#. Setting: WinControls +#. Field: hint +msgid "Specialized settings for GPD devices." +msgstr "" + +#. Setting: RGB Mode +#. Field: title +msgid "RGB Mode" +msgstr "" + +#. Setting: Off +#. Field: title +#. Setting: Vibration Strength +#. Option: off +msgid "Off" +msgstr "" + +#. Setting: Off +#. Field: hint +msgid "Turns the LEDs off." +msgstr "" + +#. Setting: Solid +#. Field: title +msgid "Solid" +msgstr "" + +#. Setting: Solid +#. Field: hint +msgid "Maintains the LEDs at a solid color." +msgstr "" + +#. Setting: Hue +#. Field: title +msgid "Hue" +msgstr "" + +#. Setting: Pulse +#. Field: title +msgid "Pulse" +msgstr "" + +#. Setting: Pulse +#. Field: hint +msgid "Slowly pulses the LEDs as a prespecified color." +msgstr "" + +#. Setting: Rainbow +#. Field: title +msgid "Rainbow" +msgstr "" + +#. Setting: Rainbow +#. Field: hint +msgid "Cycles through the different colors." +msgstr "" + +#. Setting: Mouse Mode Mapping +#. Field: title +msgid "Mouse Mode Mapping" +msgstr "" + +#. Setting: Mouse Mode Mapping +#. Option: unchanged +#. Setting: Mouse Mode Triggers +#. Setting: L4/R4 Mapping +#. Setting: Do not change +#. Field: title +msgid "Do not change" +msgstr "" + +#. Setting: Mouse Mode Mapping +#. Option: mouse +msgid "GPD Mouse Mode" +msgstr "" + +#. Setting: Mouse Mode Mapping +#. Option: wasd +msgid "For Games" +msgstr "" + +#. Setting: Mouse Mode Triggers +#. Field: title +msgid "Mouse Mode Triggers" +msgstr "" + +#. Setting: Mouse Mode Triggers +#. Option: gpd +msgid "GPD (RT is Fast Mouse)" +msgstr "" + +#. Setting: Mouse Mode Triggers +#. Option: steamos +msgid "SteamOS (LT/RT are R/L Clicks)" +msgstr "" + +#. Setting: L4/R4 Mapping +#. Field: title +msgid "L4/R4 Mapping" +msgstr "" + +#. Setting: L4/R4 Mapping +#. Option: hhd +msgid "For HHD (F20/F21)" +msgstr "" + +#. Setting: L4/R4 Mapping +#. Option: default +msgid "Default (Pause/PrntScr)" +msgstr "" + +#. Setting: Deadzones +#. Field: title +msgid "Deadzones" +msgstr "" + +#. Setting: Do not change +#. Field: hint +msgid "Do not change the deadzones." +msgstr "" + +#. Setting: Set Deadzones +#. Field: title +msgid "Set Deadzones" +msgstr "" + +#. Setting: Set Deadzones +#. Field: hint +msgid "Use custom deadzones." +msgstr "" + +#. Setting: Left Stick Center +#. Field: title +msgid "Left Stick Center" +msgstr "" + +#. Setting: Left Stick Boundary +#. Field: title +msgid "Left Stick Boundary" +msgstr "" + +#. Setting: Right Stick Center +#. Field: title +msgid "Right Stick Center" +msgstr "" + +#. Setting: Right Stick Boundary +#. Field: title +msgid "Right Stick Boundary" +msgstr "" + +#. Setting: Vibration Strength +#. Field: title +msgid "Vibration Strength" +msgstr "" + +#. Setting: Vibration Strength +#. Option: medium +#. Setting: Brightness +#. Setting: Speed +msgid "Medium" +msgstr "" + +#. Setting: Vibration Strength +#. Option: high +#. Setting: Brightness +#. Setting: Speed +msgid "High" +msgstr "" + +#. Setting: Firmware +#. Field: title +msgid "Firmware" +msgstr "" + +#. Setting: Error +#. Field: title +msgid "Error" +msgstr "" + +#. Setting: Apply Settings +#. Field: title +msgid "Apply Settings" +msgstr "" + +#. Setting: Legion Controller +#. Field: title +msgid "Legion Controller" +msgstr "" + +#. Setting: Legion Controller +#. Field: hint +msgid "Configure the Legion Controller emulation modes." +msgstr "" + +#. Setting: Emulation Mode (X-Input) +#. Field: title +msgid "Emulation Mode (X-Input)" +msgstr "" + +#. Setting: Emulation Mode (X-Input) +#. Field: hint +msgid "Emulate different controller types when in X-Input mode." +msgstr "" + +#. Setting: Motion Support +#. Field: hint +msgid "Enable gyroscope/accelerometer (IMU) support" +msgstr "" + +#. Setting: Swap Legion with Menu/View +#. Field: title +msgid "Swap Legion with Menu/View" +msgstr "" + +#. Setting: Enable Shortcuts Controller +#. Field: title +msgid "Enable Shortcuts Controller" +msgstr "" + +#. Setting: Enable Shortcuts Controller +#. Field: hint +msgid "When in dinput mode, enable a controller for shortcuts." +msgstr "" + +#. Setting: Reset Controller +#. Field: title +msgid "Reset Controller" +msgstr "" + +#. Setting: Reset Controller +#. Field: hint +msgid "Resets the controller to stock settings." +msgstr "" + +#. Setting: Legion Controllers +#. Field: title +msgid "Legion Controllers" +msgstr "" + +#. Setting: Legion Controllers +#. Field: hint +msgid "" +"Allows for configuring the Legion controllers using the built in firmware" +" commands and enabling emulation modes for various controller types." +msgstr "" + +#. Setting: Emulation Mode (X-Input) +#. Field: hint +msgid "" +"Emulate different controller types when the Legion Controllers are in " +"X-Input mode." +msgstr "" + +#. Setting: Controller Motions Device +#. Field: title +msgid "Controller Motions Device" +msgstr "" + +#. Setting: Left Controller +#. Field: title +msgid "Left Controller" +msgstr "" + +#. Setting: Right Controller +#. Field: title +msgid "Right Controller" +msgstr "" + +#. Setting: Both Controllers +#. Field: title +msgid "Both Controllers" +msgstr "" + +#. Setting: Both Controllers +#. Field: hint +msgid "" +"The main controller uses the right controller's motion sensor, and a " +"secondary controller is created for the left controller's motion sensor." +msgstr "" + +#. Setting: M2 As Xbox Share/Dualsense Mic Mute +#. Field: title +msgid "M2 As Xbox Share/Dualsense Mic Mute" +msgstr "" + +#. Setting: M2 As Xbox Share/Dualsense Mic Mute +#. Field: hint +msgid "" +"Maps the M2 to the mute button on Dualsense and the share button on the " +"Xbox Elite controller." +msgstr "" + +#. Setting: Enable Shortcuts Controller +#. Field: hint +msgid "" +"When in other modes (dinput, dual dinput, and fps), enable a shortcuts " +"controller to restore Guide, QAM, and shortcut functionality." +msgstr "" + +#. Setting: Factory Reset Controllers +#. Field: title +msgid "Factory Reset Controllers" +msgstr "" + +#. Setting: Factory Reset Controllers +#. Field: hint +msgid "Resets the controllers to factory settings." +msgstr "" + +#. Setting: Orange Pi Neo +#. Field: title +msgid "Orange Pi Neo" +msgstr "" + +#. Setting: OneXPlayer Controller +#. Field: title +msgid "OneXPlayer Controller" +msgstr "" + +#. Setting: Keyboard and Turbo buttons are: +#. Field: title +msgid "Keyboard and Turbo buttons are:" +msgstr "" + +#. Setting: Keyboard and Turbo buttons are: +#. Option: oem +msgid "Keyboard, Combo" +msgstr "" + +#. Setting: Keyboard and Turbo buttons are: +#. Option: separate +msgid "Steam Menu, HHD" +msgstr "" + +#. Setting: Keyboard and Turbo buttons are: +#. Option: combo_hhd +msgid "Combo, HHD" +msgstr "" + +#. Setting: Keyboard and Turbo buttons are: +#. Option: combo +msgid "Combo, Combo" +msgstr "" + +#. Setting: Swap View/Menu and Xbox/Kbd +#. Field: title +msgid "Swap View/Menu and Xbox/Kbd" +msgstr "" + +#. Setting: Holding Turbo Reboots +#. Field: title +msgid "Holding Turbo Reboots" +msgstr "" + +#. Setting: Reverse Volume Buttons +#. Field: title +msgid "Reverse Volume Buttons" +msgstr "" + +#. Setting: Reverse Volume Buttons +#. Field: hint +msgid "Reverse the volume buttons of the X1 style devices to match other tablets." +msgstr "" + +#. Setting: Ally Controller +#. Field: title +msgid "Ally Controller" +msgstr "" + +#. Setting: Ally Controller +#. Field: hint +msgid "Allows for configuring the ROG Ally controllers to a unified output." +msgstr "" + +#. Setting: Controller Emulation +#. Field: hint +msgid "Emulate different controller types to fuse ROG features." +msgstr "" + +#. Setting: Swap ROG and Menu/View +#. Field: title +msgid "Swap ROG and Menu/View" +msgstr "" + +#. Setting: Swap ROG and Menu/View +#. Field: hint +msgid "Swaps the Armory Crate and Command center buttons with start and select." +msgstr "" + +#. Setting: RGB During Boot +#. Field: title +msgid "RGB During Boot" +msgstr "" + +#. Setting: RGB During Charging Asleep +#. Field: title +msgid "RGB During Charging Asleep" +msgstr "" + +#. Setting: Motion Axis +#. Field: title +msgid "Motion Axis" +msgstr "" + +#. Setting: Default +#. Field: hint +msgid "The default axis loaded for this device." +msgstr "" + +#. Setting: Override +#. Field: title +msgid "Override" +msgstr "" + +#. Setting: Override +#. Field: hint +msgid "" +"Remap and invert the axis of your device. If the axis of your device are " +"wrong, please submit a picture or a text version of the following." +msgstr "" + +#. Setting: Manufacturer +#. Field: title +msgid "Manufacturer" +msgstr "" + +#. Setting: Product +#. Field: title +msgid "Product" +msgstr "" + +#. Setting: Axis X +#. Field: title +msgid "Axis X" +msgstr "" + +#. Setting: Axis X +#. Option: x +#. Setting: Axis Y +#. Setting: Axis Z +msgid "X" +msgstr "" + +#. Setting: Axis X +#. Option: y +#. Setting: Axis Y +#. Setting: Axis Z +msgid "Y" +msgstr "" + +#. Setting: Axis X +#. Option: z +#. Setting: Axis Y +#. Setting: Axis Z +msgid "Z" +msgstr "" + +#. Setting: Invert X +#. Field: title +msgid "Invert X" +msgstr "" + +#. Setting: Axis Y +#. Field: title +msgid "Axis Y" +msgstr "" + +#. Setting: Invert Y +#. Field: title +msgid "Invert Y" +msgstr "" + +#. Setting: Axis Z +#. Field: title +msgid "Axis Z" +msgstr "" + +#. Setting: Invert Z +#. Field: title +msgid "Invert Z" +msgstr "" + +#. Setting: Deadzones & Vibration +#. Field: title +msgid "Deadzones & Vibration" +msgstr "" + +#. Setting: Deadzones & Vibration +#. Field: hint +msgid "Configure joystick and trigger deadzones, vibration intensity." +msgstr "" + +#. Setting: Default +#. Field: hint +msgid "Uses reasonable values based on hardware." +msgstr "" + +#. Setting: Manual +#. Field: title +msgid "Manual" +msgstr "" + +#. Setting: Manual +#. Field: hint +msgid "Allows for manual configuration of deadzones and vibration intensity." +msgstr "" + +#. Setting: Vibration Intensity +#. Field: title +msgid "Vibration Intensity" +msgstr "" + +#. Setting: Vibration Intensity +#. Field: hint +msgid "" +"Intensity of the vibration. The higher the value, the stronger the " +"vibration." +msgstr "" + +#. Setting: Left Stick Minimum +#. Field: title +msgid "Left Stick Minimum" +msgstr "" + +#. Setting: Left Stick Minimum +#. Field: hint +#. Setting: Right Stick Minimum +#. Setting: Left Trigger Minimum +#. Setting: Right Trigger Minimum +msgid "" +"Deadzone for the joystick. The higher the value, the more the joystick " +"needs to be moved before registering." +msgstr "" + +#. Setting: Left Stick Maximum +#. Field: title +msgid "Left Stick Maximum" +msgstr "" + +#. Setting: Left Stick Maximum +#. Field: hint +#. Setting: Right Stick Maximum +#. Setting: Left Trigger Maximum +#. Setting: Right Trigger Maximum +msgid "" +"Maximum value for joystick. The higher the value, the more the joystick " +"needs to be moved before reaching maximum." +msgstr "" + +#. Setting: Right Stick Minimum +#. Field: title +msgid "Right Stick Minimum" +msgstr "" + +#. Setting: Right Stick Maximum +#. Field: title +msgid "Right Stick Maximum" +msgstr "" + +#. Setting: Left Trigger Minimum +#. Field: title +msgid "Left Trigger Minimum" +msgstr "" + +#. Setting: Left Trigger Maximum +#. Field: title +msgid "Left Trigger Maximum" +msgstr "" + +#. Setting: Right Trigger Minimum +#. Field: title +msgid "Right Trigger Minimum" +msgstr "" + +#. Setting: Right Trigger Maximum +#. Field: title +msgid "Right Trigger Maximum" +msgstr "" + +#. Setting: Reset to Default +#. Field: title +msgid "Reset to Default" +msgstr "" + +#. Setting: Reset to Default +#. Field: hint +msgid "Reset all values to default." +msgstr "" + +#. Setting: Hidden +#. Field: title +msgid "Hidden" +msgstr "" + +#. Setting: Hidden +#. Field: hint +msgid "" +"Disables the controller. Handheld Daemon overlay will still work in " +"gamemode." +msgstr "" + +#. Setting: Extra buttons as Keyboard/Overlay +#. Field: title +msgid "Extra buttons as Keyboard/Overlay" +msgstr "" + +#. Setting: Extra buttons as Keyboard/Overlay +#. Field: hint +msgid "" +"Makes the left paddle bring up a keyboard and the right paddle bring up " +"the overlay." +msgstr "" + +#. Setting: Xbox +#. Field: title +msgid "Xbox" +msgstr "" + +#. Setting: Extra buttons as +#. Field: title +msgid "Extra buttons as" +msgstr "" + +#. Setting: Extra buttons as +#. Field: hint +msgid "" +"Changes the behavior of the extra buttons. Left button is Keyboard, right" +" button is Overlay. Or they can be set for Steam Input." +msgstr "" + +#. Setting: Extra buttons as +#. Option: steam_input +msgid "Steam Input (Elite)" +msgstr "" + +#. Setting: Extra buttons as +#. Option: noob +msgid "Keyboard/Overlay" +msgstr "" + +#. Setting: Nintendo QAM Fix +#. Field: title +msgid "Nintendo QAM Fix" +msgstr "" + +#. Setting: Xbox Elite +#. Field: title +msgid "Xbox Elite" +msgstr "" + +#. Setting: Steam Controller +#. Field: title +msgid "Steam Controller" +msgstr "" + +#. Setting: Steam Controller +#. Field: hint +msgid "Allows for gyro, paddles, and has a proper QAM button." +msgstr "" + +#. Setting: Invert Roll Axis +#. Field: title +msgid "Invert Roll Axis" +msgstr "" + +#. Setting: Invert Roll Axis +#. Field: hint +msgid "" +"Inverts the roll (Z) axis compared to a real Horipad controller. Useful " +"for Steam Input, since you want it to be inverted to look left to right, " +"but an issue in emulators." +msgstr "" + +#. Setting: Dualsense +#. Field: title +msgid "Dualsense" +msgstr "" + +#. Setting: Extra buttons as +#. Field: hint +msgid "" +"Changes the behavior of the extra buttons. Left button is Keyboard, right" +" button is Overlay. Or they can be left/right touchpad clicks. For the " +"legion go, top buttons are shortcuts, bottom are touchpad clicks." +msgstr "" + +#. Setting: Extra buttons as +#. Option: steam_input +msgid "Steam Input (Edge)" +msgstr "" + +#. Setting: Extra buttons as +#. Option: touchpad +msgid "Touchpad Clicks" +msgstr "" + +#. Setting: Extra buttons as +#. Option: both +msgid "Shortcuts + Touchpad Clicks" +msgstr "" + +#. Setting: LED Support +#. Field: title +msgid "LED Support" +msgstr "" + +#. Setting: LED Support +#. Field: hint +msgid "" +"Passes through the LEDs to the controller, which allows games to control " +"them." +msgstr "" + +#. Setting: Gyro Output Sync +#. Field: title +msgid "Gyro Output Sync" +msgstr "" + +#. Setting: Gyro Output Sync +#. Field: hint +msgid "" +"Steam relies on the IMU timestamp for the touchpad as Mouse and `Gyro to " +"Mouse [BETA]`. If the same timestamp is sent in 2 reports, this causes a " +"division by 0 and instability. This option makes it so reports are sent " +"only when there is a new IMU timestamp, effectively limiting the " +"responsiveness of the controller to that of the IMU. This only makes a " +"difference for the Legion Go (125hz), as all the other handhelds are " +"using 400hz by default." +msgstr "" + +#. Setting: Invert Roll Axis +#. Field: hint +msgid "" +"Inverts the roll (Z) axis compared to a real Dualsense controller. Useful" +" for Steam Input, since you want it to be inverted to look left to right," +" but an issue in emulators." +msgstr "" + +#. Setting: Bluetooth Mode +#. Field: title +msgid "Bluetooth Mode" +msgstr "" + +#. Setting: Bluetooth Mode +#. Field: hint +msgid "" +"Emulates the controller in bluetooth mode instead of USB mode. This is " +"the default as it causes less issues with how apps interact with the " +"controller. However, using USB mode can improve LED support (?) in some " +"games. Test and report back!" +msgstr "" + +#. Setting: Paused +#. Field: title +msgid "Paused" +msgstr "" + +#. Setting: Touchpad Emulation +#. Field: title +msgid "Touchpad Emulation" +msgstr "" + +#. Setting: Touchpad Emulation +#. Field: hint +msgid "" +"Use an emulated touchpad. Part of the controller if it is supported " +"(e.g., Dualsense) or a virtual one if not." +msgstr "" + +#. Setting: Disabled +#. Field: hint +msgid "" +"Does not modify the touchpad. Short + holding presses will not work " +"within gamescope." +msgstr "" + +#. Setting: Virtual +#. Field: title +msgid "Virtual" +msgstr "" + +#. Setting: Virtual +#. Field: hint +msgid "" +"Adds an emulated touchpad. This touchpad is meant for use in gamescope " +"and has left, right click support by default. However, it causes issues " +"in desktop mode, and it doesnt allow dragging files. Therefore, it will " +"autodisable in desktop." +msgstr "" + +#. Setting: Disable on Desktop +#. Field: title +msgid "Disable on Desktop" +msgstr "" + +#. Setting: Disable on Desktop +#. Field: hint +msgid "" +"Touchpad emulation will automatically be disabled when not in gamemode. " +"Specifically, steam will be periodically be checked to be running in " +"gamepad mode and if not, touchpad emulation will be disabled." +msgstr "" + +#. Setting: Short Action +#. Field: title +msgid "Short Action" +msgstr "" + +#. Setting: Short Action +#. Field: hint +msgid "Maps short touches (less than 0.2s) to a virtual touchpad button." +msgstr "" + +#. Setting: Short Action +#. Option: left_click +#. Setting: Hold Action +msgid "Left Click" +msgstr "" + +#. Setting: Short Action +#. Option: right_click +#. Setting: Hold Action +msgid "Right Click" +msgstr "" + +#. Setting: Hold Action +#. Field: title +msgid "Hold Action" +msgstr "" + +#. Setting: Hold Action +#. Field: hint +msgid "Maps long touches (more than 2s) to a virtual touchpad button." +msgstr "" + +#. Setting: Controller +#. Field: hint +msgid "" +"Uses the touchpad of the emulated controller (if it exists). Otherwise, " +"the touchpad remains unmapped (will still show up in the system). Meant " +"to be used as steam input, so short press is unassigned by default and " +"long press simulates trackpad click." +msgstr "" + +#. Setting: Location +#. Field: title +msgid "Location" +msgstr "" + +#. Setting: Location +#. Field: hint +msgid "" +"Controls the placement of the real touchpad to the virtual one, using " +"what steam expects. In Steam, the \"Left\" touchpad maps to the left " +"half, the \"Right\" touchpad maps to the right half, and \"Center\" maps " +"to the whole touchpad. Therefore, the virtual touchpad is cropped to the " +"left side for left, the right side for right, and expanded in the center " +"for center. This means when set to center, half of the left touchpad is " +"left and half of the right is right. \"Stretch\" stretches the touchpad " +"to the whole dualsense surface." +msgstr "" + +#. Setting: Location +#. Option: right +#. Setting: Direction +msgid "Right" +msgstr "" + +#. Setting: Location +#. Option: center +msgid "Center" +msgstr "" + +#. Setting: Location +#. Option: left +#. Setting: Direction +msgid "Left" +msgstr "" + +#. Setting: Location +#. Option: stretch +msgid "Stretch" +msgstr "" + +#. Setting: Short Action +#. Field: hint +msgid "" +"Maps short touches (less than 0.2s) to a touchpad action. Dualsense uses " +"a physical press for left and a double tap for right click (support for " +"double tap varies; enable \"Tap to Click\" in your desktop's touchpad " +"settings)." +msgstr "" + +#. Setting: Hold Action +#. Field: hint +msgid "" +"Maps long touches (more than 2s) to a touchpad action. Dualsense uses a " +"physical press for left and a double tap for right click (support for " +"double tap varies; enable \"Tap to Click\" in your desktop's touchpad " +"settings)." +msgstr "" + +msgid "Downloading:" +msgstr "" + +msgid "Importing:" +msgstr "" + +msgid "Deploying:" +msgstr "" + +msgid "Loading" +msgstr "" + +msgid "No update available" +msgstr "" + +msgid "Rebasing to " +msgstr "" + +msgid "Updating to latest " +msgstr "" + +msgid "Updating... " +msgstr "" + +msgid "Checking for updates..." +msgstr "" + +msgid "Undoing Update..." +msgstr "" + +msgid "Undoing Revert..." +msgstr "" + +msgid "Reverting to Previous version..." +msgstr "" + +msgid "Loading Versions..." +msgstr "" + +msgid "Removing Customizations..." +msgstr "" + +msgid "Failed to load previous versions" +msgstr "" + +#. Setting: System Image +#. Field: title +msgid "System Image" +msgstr "" + +#. Setting: System Image +#. Field: hint +msgid "Manage the currently installed image with bootc." +msgstr "" + +#. Setting: Image +#. Field: title +msgid "Image" +msgstr "" + +#. Setting: Next +#. Field: title +msgid "Next" +msgstr "" + +#. Setting: Current +#. Field: title +msgid "Current" +msgstr "" + +#. Setting: Previous +#. Field: title +msgid "Previous" +msgstr "" + +#. Setting: Update +#. Field: title +msgid "Update" +msgstr "" + +#. Setting: Update Stage +#. Field: title +msgid "Update Stage" +msgstr "" + +#. Setting: Apply Update +#. Field: title +msgid "Apply Update" +msgstr "" + +#. Setting: Revert to Previous +#. Field: title +msgid "Revert to Previous" +msgstr "" + +#. Setting: Revert to Previous +#. Field: hint +msgid "Rollback to the previous image." +msgstr "" + +#. Setting: Change Version (Rebase) +#. Field: title +msgid "Change Version (Rebase)" +msgstr "" + +#. Setting: Remove Pin and Update +#. Field: title +msgid "Remove Pin and Update" +msgstr "" + +#. Setting: Change Branch (Rebase) +#. Field: title +msgid "Change Branch (Rebase)" +msgstr "" + +#. Setting: Check for Updates +#. Field: title +msgid "Check for Updates" +msgstr "" + +#. Setting: Reboot +#. Field: title +msgid "Reboot" +msgstr "" + +#. Setting: Reboot +#. Field: hint +msgid "Reboot to apply the update. Are you sure?" +msgstr "" + +#. Setting: Undo Update +#. Field: title +msgid "Undo Update" +msgstr "" + +#. Setting: Undo Revert +#. Field: title +msgid "Undo Revert" +msgstr "" + +#. Setting: Choose Version (Rebase) +#. Field: title +msgid "Choose Version (Rebase)" +msgstr "" + +#. Setting: Run rpm-ostree reset +#. Field: title +msgid "Run rpm-ostree reset" +msgstr "" + +#. Setting: Run rpm-ostree reset +#. Field: hint +msgid "" +"Disable the custom initramfs and remove layers. Your personal data will " +"not be affected." +msgstr "" + +#. Setting: Cancel +#. Field: title +msgid "Cancel" +msgstr "" + +#. Setting: Branch +#. Field: title +msgid "Branch" +msgstr "" + +#. Setting: Version Pin +#. Field: title +msgid "Version Pin" +msgstr "" + +#. Setting: Apply +#. Field: title +msgid "Apply" +msgstr "" + +msgid "Uploading log to fpaste..." +msgstr "" + +msgid "Shutting down..." +msgstr "" + +msgid "Failed to download Handheld Daemon Beta." +msgstr "" + +msgid "Downloading Beta and Restarting..." +msgstr "" + +#. Setting: Bug Report +#. Field: title +msgid "Bug Report" +msgstr "" + +#. Setting: Bug Report Link +#. Field: title +msgid "Bug Report Link" +msgstr "" + +#. Setting: Upload Error +#. Field: title +msgid "Upload Error" +msgstr "" + +#. Setting: Submit Report +#. Field: title +msgid "Submit Report" +msgstr "" + +#. Setting: Submit Report +#. Field: hint +msgid "Upload a bug report to paste.centos.org" +msgstr "" + +#. Setting: Logs from +#. Field: title +msgid "Logs from" +msgstr "" + +#. Setting: Logs from +#. Option: current +msgid "Current Boot" +msgstr "" + +#. Setting: Logs from +#. Option: previous +msgid "Previous Boot (-1)" +msgstr "" + +#. Setting: Logs from +#. Option: m2 +msgid "Boot -2" +msgstr "" + +#. Setting: Logs from +#. Option: m3 +msgid "Boot -3" +msgstr "" + +#. Setting: Development Tools +#. Field: title +msgid "Development Tools" +msgstr "" + +#. Setting: Use HHD Beta Until Restart +#. Field: title +msgid "Use HHD Beta Until Restart" +msgstr "" + +#. Setting: Use HHD Beta Until Restart +#. Field: hint +msgid "Switch to the HHD beta channel until you restart." +msgstr "" + +#. Setting: Go Back to Stable +#. Field: title +msgid "Go Back to Stable" +msgstr "" + +#. Setting: System +#. Field: hint +msgid "" +"Basic display settings. Brightness (and framerate TBD). This pane is " +"meant to replace " +msgstr "" + +#. Setting: Brightness +#. Field: title +msgid "Brightness" +msgstr "" + +#. Setting: Brightness +#. Field: hint +msgid "" +"Sets the brightness level of a display. Only one display is supported and" +" it is the one that was read." +msgstr "" + +#. Setting: Display +#. Field: title +msgid "Display" +msgstr "" + +#. Setting: Disable Touchscreen (Until Restart) +#. Field: title +msgid "Disable Touchscreen (Until Restart)" +msgstr "" + +#. Setting: Disable Touch Gestures (Until Restart) +#. Field: title +msgid "Disable Touch Gestures (Until Restart)" +msgstr "" + +#. Setting: Gamescope +#. Field: title +msgid "Gamescope" +msgstr "" + +#. Setting: Run Steam at 60/72 Hz +#. Field: title +msgid "Run Steam at 60/72 Hz" +msgstr "" + +#. Setting: Poweroff screen before sleep +#. Field: title +msgid "Poweroff screen before sleep" +msgstr "" + +#. Setting: All Controllers +#. Field: title +msgid "All Controllers" +msgstr "" + +#. Setting: Xbox or View + B (Press) +#. Field: title +msgid "Xbox or View + B (Press)" +msgstr "" + +#. Setting: Xbox or View + B (Press) +#. Option: keyboard +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "Steam Keyboard" +msgstr "" + +#. Setting: Xbox or View + B (Press) +#. Option: steam_qam +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "Steam Side Menu" +msgstr "" + +#. Setting: Xbox or View + B (Press) +#. Option: steam_expanded +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "Steam Overlay" +msgstr "" + +#. Setting: Xbox or View + B (Press) +#. Option: hhd_qam +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "HHD Side Menu" +msgstr "" + +#. Setting: Xbox or View + B (Press) +#. Option: hhd_expanded +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "HHD Overlay" +msgstr "" + +#. Setting: Xbox or View + Y +#. Field: title +msgid "Xbox or View + Y" +msgstr "" + +#. Setting: Xbox or View + Y +#. Option: disconnect +msgid "Disconnect Controller" +msgstr "" + +#. Setting: Touchscreen +#. Field: title +msgid "Touchscreen" +msgstr "" + +#. Setting: ↑ Swipe Up +#. Field: title +msgid "↑ Swipe Up" +msgstr "" + +#. Setting: ← Swipe Right Side (Top) +#. Field: title +msgid "← Swipe Right Side (Top)" +msgstr "" + +#. Setting: ← Swipe Right Side (Bottom) +#. Field: title +msgid "← Swipe Right Side (Bottom)" +msgstr "" + +#. Setting: → Swipe Left Side (Top) +#. Field: title +msgid "→ Swipe Left Side (Top)" +msgstr "" + +#. Setting: → Swipe Left Side (Bottom) +#. Field: title +msgid "→ Swipe Left Side (Bottom)" +msgstr "" + +#. Setting: ↓ Swipe Down +#. Field: title +msgid "↓ Swipe Down" +msgstr "" + +#. Setting: Orientation Correction +#. Field: title +msgid "Orientation Correction" +msgstr "" + +#. Setting: Auto +#. Field: title +msgid "Auto" +msgstr "" + +#. Setting: Device +#. Field: title +msgid "Device" +msgstr "" + +#. Setting: Portrait +#. Field: title +msgid "Portrait" +msgstr "" + +#. Setting: Flip Left-Right +#. Field: title +msgid "Flip Left-Right" +msgstr "" + +#. Setting: Flip Top-Bottom +#. Field: title +msgid "Flip Top-Bottom" +msgstr "" + +#. Setting: Keyboard (Gaming Only) +#. Field: title +msgid "Keyboard (Gaming Only)" +msgstr "" + +#. Setting: Start (Meta) Press +#. Field: title +msgid "Start (Meta) Press" +msgstr "" + +#. Setting: Start (Meta) Hold +#. Field: title +msgid "Start (Meta) Hold" +msgstr "" + +#. Setting: Ctrl + 3 +#. Field: title +msgid "Ctrl + 3" +msgstr "" + +#. Setting: Ctrl + 4 +#. Field: title +msgid "Ctrl + 4" +msgstr "" + +msgid "Failed to hibernate (missing swap file)." +msgstr "" + +msgid "Failed to create temporary swap." +msgstr "" + +msgid "Failed to hibernate to temporary swap." +msgstr "" + +#. Setting: Power +#. Field: title +msgid "Power" +msgstr "" + +#. Setting: Reboot into Windows +#. Field: title +msgid "Reboot into Windows" +msgstr "" + +#. Setting: Reboot into Windows +#. Field: hint +msgid "Make sure you saved your game progress." +msgstr "" + +#. Setting: Hibernate +#. Field: title +msgid "Hibernate" +msgstr "" + +#. Setting: Hibernate +#. Field: hint +msgid "Saves your progress and powers off the device." +msgstr "" + +#. Setting: Hibernate when device asks and at 5%. +#. Field: title +msgid "Hibernate when device asks and at 5%." +msgstr "" + +#. Setting: Hibernate when device asks and at 5%. +#. Field: hint +msgid "" +"Certain devices wake up to force Windows to hibernate. If Linux does not\n" +"they cause issues. Detect and hibernate on thermal events and on 5%\n" +"battery.\n" +msgstr "" + +#. Setting: Steam Powerbutton Handler +#. Field: title +msgid "Steam Powerbutton Handler" +msgstr "" + +#. Setting: Steam Powerbutton Handler +#. Field: hint +msgid "" +"Enables the Steam Powerbutton handler (responsible for the wink and " +"powerbutton menu)." +msgstr "" + +#. Setting: Saturation +#. Field: title +msgid "Saturation" +msgstr "" + +#. Setting: Brightness +#. Option: low +#. Setting: Speed +msgid "Low" +msgstr "" + +#. Setting: Stick Style +#. Field: title +msgid "Stick Style" +msgstr "" + +#. Setting: Stick Style +#. Option: monster_woke +msgid "Monster Woke" +msgstr "" + +#. Setting: Stick Style +#. Option: flowing +msgid "Flowing Light" +msgstr "" + +#. Setting: Stick Style +#. Option: sunset +msgid "Sunset Afterglow" +msgstr "" + +#. Setting: Stick Style +#. Option: neon +msgid "Colorful Neon" +msgstr "" + +#. Setting: Stick Style +#. Option: dreamy +msgid "Dreamy" +msgstr "" + +#. Setting: Stick Style +#. Option: cyberpunk +msgid "Cyberpunk" +msgstr "" + +#. Setting: Stick Style +#. Option: colorful +msgid "Colorful" +msgstr "" + +#. Setting: Stick Style +#. Option: aurora +msgid "Aurora" +msgstr "" + +#. Setting: Stick Style +#. Option: sun +msgid "Warm Sun" +msgstr "" + +#. Setting: Stick Style +#. Option: classic +msgid "OXP Classic" +msgstr "" + +#. Setting: Secondary +#. Field: title +msgid "Secondary" +msgstr "" + +#. Setting: Enable Secondary +#. Field: title +msgid "Enable Secondary" +msgstr "" + +#. Setting: Speed +#. Field: title +msgid "Speed" +msgstr "" + +#. Setting: Direction +#. Field: title +msgid "Direction" +msgstr "" + +#. Setting: Spiral +#. Field: title +msgid "Spiral" +msgstr "" + +#. Setting: Spiral +#. Field: hint +msgid "Creates an RGB spiral around the stick." +msgstr "" + +#. Setting: Duality +#. Field: title +msgid "Duality" +msgstr "" + +#. Setting: Duality +#. Field: hint +msgid "Alternates between two colors." +msgstr "" + +#. Setting: OneXPlayer +#. Field: title +msgid "OneXPlayer" +msgstr "" + +#. Setting: RGB Settings +#. Field: title +msgid "RGB Settings" +msgstr "" + +#. Setting: Controller RGB +#. Field: title +msgid "Controller RGB" +msgstr "" + +#. Setting: Enable RGB support. +#. Field: title +msgid "Enable RGB support." +msgstr "" + diff --git a/i18n/pt/LC_MESSAGES/adjustor.po b/i18n/pt/LC_MESSAGES/adjustor.po new file mode 100644 index 00000000..9ab20b11 --- /dev/null +++ b/i18n/pt/LC_MESSAGES/adjustor.po @@ -0,0 +1,574 @@ +# +# Victor Borges , 2024. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2024-04-08 19:36+0200\n" +"PO-Revision-Date: 2024-04-09 15:26-0300\n" +"Last-Translator: Victor Borges \n" +"Language: pt\n" +"Language-Team: pt \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.14.0\n" +"X-Generator: Gtranslator 45.3\n" + +msgid "Due to a suspected crash, auto-start was disabled." +msgstr "Devido a um possĆ­vel erro, a auto-inicialização foi desabilitada." + +msgid "TDP Controls can not be enabled while other TDP plugins are installed." +msgstr "" +"Controles de TDP nĆ£o podem ser habilitados enquanto outros plugins de TDP " +"estĆ£o instalados." + +#. Setting: TDP Controls +#. Field: title +msgid "TDP Controls" +msgstr "Controles de TDP" + +#. Setting: Enable TDP Controls +#. Field: title +msgid "Enable TDP Controls" +msgstr "Habilitar Controles de TDP" + +#. Setting: Enable TDP Controls +#. Field: hint +#. Setting: Enable TDP Controls +msgid "" +"Enables TDP management by the Handheld Daemon. While enabled, Handheld " +"Daemon will set and maintain the TDP limits set on start-up and during other " +"device changes (ac/dc).\n" +"If the device crashes, TDP setting will be disabled on next startup." +msgstr "" +"Habilitado o gerenciamento de TDP pelo Handheld Daemon. Enquanto habilitado, " +"Handheld Daemon vai atribuir e manter os limites de TDP definidos na " +"inicialização e durante outras mudanƧas no dispositivo (plugar carregador).\n" +"Se o dispositivo crashar, as configuraƧƵes de TDP serĆ£o desabilitadas na " +"próxima inicialização." + +#. Setting: Error +#. Field: title +msgid "Error" +msgstr "Erro" + +#. Setting: Error +#. Option: nowrite +msgid "Can not write to ACPI Call file. ACPI Call is required for TDP." +msgstr "" +"NĆ£o foi possĆ­vel escrever no arquivo de chamada ACPI. Chamada ACPI Ć© " +"necessĆ”rio para TDP." + +#. Setting: Enable TDP Controls +#. Field: title +msgid "Enable TDP Controls" +msgstr "Habilitar Controles de TDP" + +#. Setting: Enforce Device TDP Limits +#. Field: title +msgid "Enforce Device TDP Limits" +msgstr "ForƧar Limites de TDP do Dispositivo" + +#. Setting: Enforce Device TDP Limits +#. Field: hint +msgid "" +"When this option is on, the settings will adhere to the limits set out by " +"the device manufacturer, subject to their availability.\n" +"With it off, the TDP settings ranges will expand to what is logically " +"possible for the current device (regardless of manufacturer " +"specifications).\n" +"All settings outside specifications will be set to system specifications " +"after rebooting." +msgstr "" +"Quando esta opção estĆ” habilitada, as configuraƧƵes aqui vĆ£o aderir aos " +"limites impostos pelo fabricante do dispositivo, sujeito Ć  sua " +"disponibilidade.\n" +"Com isso desativado, os limites das configuraƧƵes de TDP vĆ£o expandir para o " +"que Ć© logicamente possĆ­vel para o dispositivo atual (independentemente das " +"especificaƧƵes do fabricante).\n" +"Todas as configuraƧƵes fora das especificaƧƵes serĆ£o definidas para as " +"especificaƧƵes do fabricante depois de reiniciar." + +#. Setting: Asus TDP +#. Field: title +msgid "Asus TDP" +msgstr "Asus TDP" + +#. Setting: Asus TDP +#. Field: hint +msgid "Uses the interface of Armory Crate to set the TDP of the device." +msgstr "Usa a interface do Armoury Crate para definir o TDP do dispositivo." + +#. Setting: TDP +#. Field: title +msgid "TDP" +msgstr "TDP" + +#. Setting: TDP +#. Field: hint +msgid "" +"Average TDP Target.\n" +"Sets the values STAMP and Skin Power Limit to it without boost. With boost, " +"it sets the fast value to 53/30*tdp and the slow value to 43/30*tdp. Boost " +"is recommended for desktop use." +msgstr "" +"MĆ©dia de TDP desejado.\n" +"Define os valores STAPM para ele sem boost. Com boost, define o valor rĆ”pido " +"para 53/30*TDP e o valor lento para 43/30*TDP. Boost Ć© recomendado para uso " +"como desktop." + +#. Setting: TDP Boost +#. Field: title +msgid "TDP Boost" +msgstr "TDP Boost" + +#. Setting: TDP Boost +#. Field: hint +msgid "Allows the device to boost by setting appropriate slow and fast TDPs." +msgstr "" +"Permite o dispositivo usar boost clocks definindo valores apropriados para " +"os TDPs lento e rĆ”pido." + +#. Setting: Custom Fan Curve +#. Field: title +msgid "Custom Fan Curve" +msgstr "Curva Personalizada da Ventoinha" + +#. Setting: Custom Fan Curve +#. Field: hint +msgid "Allows you to set a custom fan curve." +msgstr "" +"Permite definir uma curva personalizada para a ventoinha do dispositivo." + +#. Setting: Disabled +#. Field: title +#. Setting: Charge Limit (%) +#. Option: disabled +msgid "Disabled" +msgstr "Desabilitado" + +#. Setting: Disabled +#. Field: hint +msgid "Lets the device manage the fan curve on its own." +msgstr "Deixa o dispositivo gerenciar a curva da ventoinha por si próprio." + +#. Setting: Manual +#. Field: title +msgid "Manual" +msgstr "Manual" + +#. Setting: 30C +#. Field: title +msgid "30C" +msgstr "30 °C" + +#. Setting: 30C +#. Field: hint +#. Setting: 40C +#. Setting: 50C +#. Setting: 60C +#. Setting: 70C +#. Setting: 80C +#. Setting: 90C +#. Setting: 100C +#. Setting: 10C +#. Setting: 20C +msgid "Sets the speed at the named temperature." +msgstr "Define a velocidade nessa temperatura." + +#. Setting: 40C +#. Field: title +msgid "40C" +msgstr "40 °C" + +#. Setting: 50C +#. Field: title +msgid "50C" +msgstr "50 °C" + +#. Setting: 60C +#. Field: title +msgid "60C" +msgstr "60 °C" + +#. Setting: 70C +#. Field: title +msgid "70C" +msgstr "70 °C" + +#. Setting: 80C +#. Field: title +msgid "80C" +msgstr "80 °C" + +#. Setting: 90C +#. Field: title +msgid "90C" +msgstr "90 °C" + +#. Setting: 100C +#. Field: title +msgid "100C" +msgstr "100 °C" + +#. Setting: Restore Default +#. Field: title +msgid "Restore Default" +msgstr "Restaurar PadrĆ£o" + +#. Setting: Restore Default +#. Field: hint +msgid "Restore a default sane fan curve." +msgstr "Restaura para a curva padrĆ£o." + +#. Setting: Fan Curve Limitation +#. Field: title +msgid "Fan Curve Limitation" +msgstr "Limitação da Curva da Ventoinha" + +#. Setting: Charge Limit (%) +#. Field: title +msgid "Charge Limit (%)" +msgstr "Limite de Carregamento (%)" + +#. Setting: Charge Limit (%) +#. Field: hint +msgid "Applies a charge limit to the battery, 75% and up." +msgstr "Aplica um limite de carregamento para a bateria, 75% e acima." + +#. Setting: Charge Limit (%) +#. Option: p65 +msgid "65%" +msgstr "65%" + +#. Setting: Charge Limit (%) +#. Option: p70 +msgid "70%" +msgstr "70%" + +#. Setting: Charge Limit (%) +#. Option: p80 +msgid "80%" +msgstr "80%" + +#. Setting: Charge Limit (%) +#. Option: p85 +msgid "85%" +msgstr "85%" + +#. Setting: Charge Limit (%) +#. Option: p90 +msgid "90%" +msgstr "90%" + +#. Setting: Charge Limit (%) +#. Option: p95 +msgid "95%" +msgstr "95%" + +#. Setting: Sleep Bug +#. Field: title +msgid "Sleep Bug" +msgstr "Bug da SuspensĆ£o" + +#. Setting: Lenovo TDP +#. Field: title +msgid "Lenovo TDP" +msgstr "Lenovo TDP" + +#. Setting: Lenovo TDP +#. Field: hint +msgid "Uses the interface of Legion Space to set the TDP of the device." +msgstr "Usa a interface do Legion Space para definir o TDP do dispositivo." + +#. Setting: TDP Mode +#. Field: title +msgid "TDP Mode" +msgstr "Modo TDP" + +#. Setting: Quiet (8W) +#. Field: title +msgid "Quiet (8W)" +msgstr "Quieto (8W)" + +#. Setting: Balanced (15W) +#. Field: title +msgid "Balanced (15W)" +msgstr "Balanceado (15W)" + +#. Setting: Performance (20W) +#. Field: title +msgid "Performance (20W)" +msgstr "Performance (20W)" + +#. Setting: Custom (up to 25-30W) +#. Field: title +msgid "Custom (up to 25-30W)" +msgstr "Personalizado (atĆ© 25-30W)" + +#. Setting: TDP +#. Field: hint +msgid "" +"Average TDP Target.\n" +"Sets the values STAMP and Skin Power Limit to it. If boost is enabled, " +"interpolates values for slow and fast TDPs based on those used by Lenovo. " +"If it is not, it sets the Slow limit equal to TDP and the Fast limit to +2W. " +"Boost is recommended for desktop use." +msgstr "" +"MĆ©dia de TDP desejado.\n" +"Define os valores STAPM para ele. Se o boost estiver habilitado, interpola " +"os valores para TDP lento e rĆ”pido baseado nos valores usados pela Lenovo. " +"Se nĆ£o estiver, define o limite lento igual ao TDP, e o limite rĆ”pido igual " +"ao TDP+2W. Boost Ć© recomendado para uso como desktop." + +#. Setting: Set Fan to Full Speed +#. Field: title +msgid "Set Fan to Full Speed" +msgstr "Ventoinha na Velocidade MĆ”xima" + +#. Setting: Custom Fan Curve +#. Field: hint +msgid "" +"Allows you to set a custom fan curve. This fan curve is only officially " +"supported on custom mode, but you can nevertheless use it in other power " +"modes. This fan curve needs to be reapplied and is reapplied every time you " +"switch TDP modes." +msgstr "" +"Permite definir uma curva personalizada para a ventoinha. Essa curva só Ć© " +"oficialmente suportada no modo personalizado, mas vocĆŖ pode usĆ”-la em outros " +"modos. Essa curva precisa ser reaplicada e Ć© reaplicada toda vez que vocĆŖ " +"altera entre os modos TDP." + +#. Setting: Disabled +#. Field: hint +msgid "" +"Lets Legion GO manage the curve on its own. Setting this option will cause a " +"mode change to reset the fan curve." +msgstr "" +"Deixa o Legion Go gerenciar a curva por si próprio. Habilitar essa opção " +"farĆ” com que a curva da ventoinha seja reiniciada ao mudar o modo TDP." + +#. Setting: 10C +#. Field: title +msgid "10C" +msgstr "10 °C" + +#. Setting: 20C +#. Field: title +msgid "20C" +msgstr "20 °C" + +#. Setting: Enforce Windows Minimums +#. Field: title +msgid "Enforce Windows Minimums" +msgstr "ForƧar MĆ­nimos do Windows" + +#. Setting: Enforce Windows Minimums +#. Field: hint +msgid "Enforce the minimum fan curve from Legion Space." +msgstr "ForƧa a curva mĆ­nima da ventoinha do Legion Space." + +#. Setting: Restore Default +#. Field: hint +msgid "Reset to the original fan curve provided by Lenovo in BIOS V28." +msgstr "Restaura para a curva original providenciada pela Lenovo na BIOS v28." + +#. Setting: Enable Charge Limit (80%) +#. Field: title +msgid "Enable Charge Limit (80%)" +msgstr "Habilita o Limite de Carregamento (80%)" + +#. Setting: Enable Charge Limit (80%) +#. Field: hint +msgid "" +"Limits device charging to 80%. Lenovo EC method. Available since BIOSv29." +msgstr "" +"Limita o carregamento do dispositivo para 80%. MĆ©todo Lenovo EC. DisponĆ­vel " +"desde a BIOS v29." + +#. Setting: Enable Power Light +#. Field: title +msgid "Enable Power Light" +msgstr "Habilitar o LED de Energia" + +#. Setting: TDP Settings +#. Field: title +msgid "TDP Settings" +msgstr "ConfiguraƧƵes TDP" + +#. Setting: TDP +#. Field: hint +msgid "Controls all Ryzen SMU settings through preset curves." +msgstr "Controla todas as configuraƧƵes do Ryzen SMU por curva prĆ©-definidas." + +#. Setting: Advanced Configurator +#. Field: title +msgid "Advanced Configurator" +msgstr "Configurador AvanƧado" + +#. Setting: Apply Settings +#. Field: title +msgid "Apply Settings" +msgstr "Aplicar ConfiguraƧƵes" + +#. Setting: TDP Status +#. Field: title +msgid "TDP Status" +msgstr "Status TDP" + +#. Setting: Platform Profile +#. Field: title +msgid "Platform Profile" +msgstr "Perfil da Plataforma" + +#. Setting: Platform Profile +#. Option: disabled +msgid "Not Set" +msgstr "NĆ£o Definido" + +#. Setting: Platform Profile +#. Option: low-power +msgid "Low Power" +msgstr "Baixo Consumo" + +#. Setting: Platform Profile +#. Option: cool +msgid "Cool" +msgstr "Frio" + +#. Setting: Platform Profile +#. Option: quiet +msgid "Quiet" +msgstr "Quieto" + +#. Setting: Platform Profile +#. Option: balanced +msgid "Balanced" +msgstr "Balanceado" + +#. Setting: Platform Profile +#. Option: balanced-performance +msgid "Balanced Performance" +msgstr "Balanceado Performance" + +#. Setting: Platform Profile +#. Option: performance +msgid "Performance" +msgstr "Performance" + +#. Setting: Standard Parameters +#. Field: title +msgid "Standard Parameters" +msgstr "ParĆ¢metros PadrĆ£o" + +#. Setting: Standard Parameters +#. Field: hint +msgid "" +"Standard TDP parameters for Ryzen processors. All need to be set to properly " +"control the TDP of the device.\n" +"Ryzen processors have 2 modes: STTv2 and STAPM (legacy). AMD suggests to " +"manufacturers to use STTv2, which makes the Legion Go the only device to " +"offer the STAPM alternative through a BIOS setting.\n" +"In STTv2, the device will keep boosting until the \"skin\" of the device " +"(hottest user accessible spot) reaches a manufacturer set temperature. Then, " +"the device will use the Skin Temp TDP limit. In STAPM, the device averages " +"the TDP values from the 1-3 previous minutes and keeps that value under the " +"STAPM TDP limit. Either mode ignores the other mode's limit (STAPM limit " +"does nothing on STT and Skin Temp Limit does nothing on STAPM), so both " +"should be set.\n" +"The Fast and Slow limits control boosting behavior. The Fast TDP limit is " +"the actual max TDP value of the device. Then,the Slow TDP limit averages the " +"last 10-20s of TDP values and keeps the value below it." +msgstr "" +"ParĆ¢metros PadrĆ£o de TDP para processadores Ryzen. Todos devem ser definidos " +"para controlar o TDP do dispositivo de forma correta.\n" +"Processadores Ryzen possuem 2 modos: STTv2 e STAPM (legado). AMD sugere que " +"os fabricantes usem STTv2, o que faz o Legion Go ser o Ćŗnico dispositivo que " +"oferece o STAPM como alternativa na BIOS.\n" +"Em STTv2, o dispositivo continuarĆ” em boost atĆ© que a \"pele\" do " +"dispositivo (a parte mais quente que o usuĆ”rio pode acessar) chegue em uma " +"temperatura definida pelo fabricante. EntĆ£o, o dispositivo usarĆ” o limite de " +"TDP de temperatura da pele. Em STAPM, o dispositivo faz uma mĆ©dia dos " +"valores de TDP dos Ćŗltimos 1 a 3 minutos e mantĆ©m esse valor abaixo do " +"limite de TDP STAPM. Qualquer modo ignora os limites do outro modo (limite " +"do STAPM nĆ£o faz nada em STT e o limite de temperatura da pele nĆ£o faz nada " +"em STAPM), entĆ£o ambos os valores devem ser definidos.\n" +"Os limites rĆ”pido e lento controlam o comportamento do boost. O limite de " +"TDP rĆ”pido Ć© o verdadeiro valor mĆ”ximo de TDP do dispositivo. EntĆ£o, o " +"limite de TDP lento faz uma mĆ©dia dos Ćŗltimos 10 a 20 segundos de valores de " +"TDP e mantĆ©m o valor abaixo dele." + +#. Setting: Fast TDP Limit +#. Field: title +msgid "Fast TDP Limit" +msgstr "Limite de TDP RĆ”pido" + +#. Setting: Slow TDP Limit +#. Field: title +msgid "Slow TDP Limit" +msgstr "Limite de TDP Lento" + +#. Setting: Skin Temp TDP Limit +#. Field: title +msgid "Skin Temp TDP Limit" +msgstr "Limite de TDP da Temperatura da Pele" + +#. Setting: STAPM TDP Limit +#. Field: title +msgid "STAPM TDP Limit" +msgstr "Limite de TDP STAPM" + +#. Setting: Advanced Parameters +#. Field: title +msgid "Advanced Parameters" +msgstr "ParĆ¢metros AvanƧados" + +#. Setting: Advanced Parameters +#. Field: hint +msgid "" +"The Advanced Parameters below control boosting behavior and need to be " +"adjusted per device depending on its cooling system. They mostly affect " +"boosting behavior, which is important for desktop use.\n" +"The exception is the Temp Target (TCTL), which controls the max temperature " +"of the CPU die. On most devices, it can safely be raised up to 100C. " +"However, if a temperature spike makes the chip reach 105C, it will enter a " +"thermal protection mode, which is 5W, for a couple of minutes.\n" +"The integration times for Slow TDP and STAPM influence how many previous TDP " +"values the CPU will average to calculate its current Slow and STAPM TDP " +"values." +msgstr "" +"Os ParĆ¢metros AvanƧados abaixo controlam o comportamento de boost e precisam " +"ser ajustados por dispositivo dependendo dos limites de refrigeração do " +"sistema. Eles afetam principalmente o comportamento de boost, que Ć© " +"importante para uso como desktop.\n" +"A exceção Ć© o Limite de Temperatura (TCTL), que controla a temperatura " +"mĆ”xima da CPU. Na maioria dos dispositivos, ele pode seguramente ser " +"aumentado atĆ© 100 °C. PorĆ©m, se um pico de temperatura fizer o chip chegar a " +"105 °C, ele entrarĆ” em um modo de proteção tĆ©rmico, que Ć© 5W, por alguns " +"minutos.\n" +"Os tempos de integração para o TDP lento e STAPM influenciam quantos valores " +"prĆ©viso de TDP que a CPU farĆ” a mĆ©dia para calcular o valor atual do TDP " +"lento e STAPM." + +#. Setting: Temp Target (TCTL) +#. Field: title +msgid "Temp Target (TCTL)" +msgstr "Limite de Temperatura (TCTL)" + +#. Setting: Slow Limit Integration Time +#. Field: title +msgid "Slow Limit Integration Time" +msgstr "Tempo de Integração do Limite Lento" + +#. Setting: STAPM Limit Integration Time +#. Field: title +msgid "STAPM Limit Integration Time" +msgstr "Tempo de Integração do Limite STAPM" + +#. Setting: Enable Advanced Parameters +#. Field: title +msgid "Enable Advanced Parameters" +msgstr "Habilitar ParĆ¢metros AvanƧados" diff --git a/i18n/pt/LC_MESSAGES/hhd.po b/i18n/pt/LC_MESSAGES/hhd.po new file mode 100644 index 00000000..aef8437b --- /dev/null +++ b/i18n/pt/LC_MESSAGES/hhd.po @@ -0,0 +1,1122 @@ +# Portuguese (Brazil) translations for PROJECT. +# Copyright (C) 2024 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2024. +# +# SPDX-FileCopyrightText: 2024 Victor Borges +# Victor Borges , 2024. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2024-04-08 19:36+0200\n" +"PO-Revision-Date: 2024-04-09 14:25-0300\n" +"Last-Translator: Victor Borges \n" +"Language: pt_BR\n" +"Language-Team: Brazilian Portuguese <>\n" +"Plural-Forms: nplurals=2; plural=(n > 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.14.0\n" +"X-Generator: Gtranslator 45.3\n" + +#. Section Name for: system +#. Setting: Language +#. Option: system +#. Setting: System +#. Field: title +msgid "System" +msgstr "Sistema" + +#. Section Name for: tdp +msgid "TDP" +msgstr "TDP" + +#. Section Name for: controller +#. Setting: Controller +#. Field: title +msgid "Controller" +msgstr "Controle" + +#. Section Name for: hhd +msgid "Settings" +msgstr "ConfiguraƧƵes" + +#. Section Name for: bazzite +msgid "Bazzite" +msgstr "Bazzite" + +#. Section Name for: utilities +msgid "Utilities" +msgstr "Utilidades" + +#. Setting: Core Settings +#. Field: title +msgid "Core Settings" +msgstr "ConfiguraƧƵes Principais" + +#. Setting: Language +#. Field: title +msgid "Language" +msgstr "Idioma" + +#. Setting: Language +#. Option: C +msgid "English" +msgstr "" + +#. Setting: Language +#. Option: zh_CN +msgid "Simplified Chinese" +msgstr "简体中文" + +#. Setting: Language +#. Option: zh_TW +msgid "Traditional Chinese" +msgstr "繁體中文" + +#. Setting: Language +#. Option: pt +msgid "Portugese" +msgstr "PortuguĆŖs" + +#. Setting: Theme +#. Field: title +msgid "Theme" +msgstr "Tema" + +#. Setting: Theme +#. Field: hint +msgid "" +"Allows changing the theme in the UI. Default is either Diavolo or your " +"distribution's theme." +msgstr "" +"Permite alterar o tema da interface. O padrĆ£o Ć© Diavolo ou o tema da sua " +"distribuição." + +#. Setting: Theme +#. Option: default +#. Setting: Default +#. Field: title +msgid "Default" +msgstr "PadrĆ£o" + +#. Setting: Theme +#. Option: diavolo +msgid "Diavolo" +msgstr "Diavolo" + +#. Setting: Theme +#. Option: ocean +msgid "Atlantis" +msgstr "Atlantis" + +#. Setting: It is no longer possible to update Decky from here. If you see +#. this, update the Decky plugin manually. +#. Field: title +msgid "" +"It is no longer possible to update Decky from here. If you see this, update " +"the Decky plugin manually." +msgstr "" +"NĆ£o Ć© mais possĆ­vel atualizar o Decky por aqui. Se vocĆŖ consegue ver essa " +"mensagem, atualize o plugin Decky manualmente." + +#. Setting: Handheld Daemon Version +#. Field: title +msgid "Handheld Daemon Version" +msgstr "VersĆ£o do Handheld Daemon" + +#. Setting: Handheld Daemon Version +#. Field: hint +#. Setting: Handheld Daemon UI Version +#. Setting: Adjustor (TDP) Version +msgid "Displays the Handheld Daemon version." +msgstr "Mostra a versĆ£o do Handheld Daemon." + +#. Setting: Handheld Daemon UI Version +#. Field: title +msgid "Handheld Daemon UI Version" +msgstr "VersĆ£o da UI do Handheld Daemon" + +#. Setting: Adjustor (TDP) Version +#. Field: title +msgid "Adjustor (TDP) Version" +msgstr "VersĆ£o do Adjustor (TDP)" + +#. Setting: Update (Stable) +#. Field: title +msgid "Update (Stable)" +msgstr "Atualizar (EstĆ”vel)" + +#. Setting: Update (Stable) +#. Field: hint +msgid "Updates to the latest version from PyPi (local install only)." +msgstr "Atualiza para a Ćŗltima versĆ£o pelo PyPi (somente instalação local)." + +#. Setting: Update (Unstable) +#. Field: title +msgid "Update (Unstable)" +msgstr "Atualizar (InstĆ”vel)" + +#. Setting: Update (Unstable) +#. Field: hint +msgid "Updates to the master branch from git (local install only)." +msgstr "Atualiza para a branch master do git (somente instalação local)." + +#. Setting: Update Error +#. Field: title +msgid "Update Error" +msgstr "Erro de Atualização" + +#. Setting: API Configuration +#. Field: title +msgid "API Configuration" +msgstr "Configuração da API" + +#. Setting: API Configuration +#. Field: hint +msgid "Settings for configuring the http endpoint of HHD." +msgstr "ConfiguraƧƵes do endpoint HTTP do HHD." + +#. Setting: Enable the API +#. Field: title +msgid "Enable the API" +msgstr "Habilitar a API" + +#. Setting: Enable the API +#. Field: hint +msgid "Enables the API of Handheld Daemon (required for decky and ui)." +msgstr "Habilita a API do Handheld Daemon (obrigatório para o Decky e a UI)." + +#. Setting: API Port +#. Field: title +msgid "API Port" +msgstr "Porta da API" + +#. Setting: API Port +#. Field: hint +msgid "Which port should the API be on?" +msgstr "Em qual porta a API deve ficar?" + +#. Setting: Limit Access to localhost +#. Field: title +msgid "Limit Access to localhost" +msgstr "Limitar Accesso ao localhost" + +#. Setting: Limit Access to localhost +#. Field: hint +msgid "Sets the API target to '127.0.0.1' instead '0.0.0.0'." +msgstr "Define o destino da API para '127.0.0.1' ao invĆ©s de '0.0.0.0'." + +#. Setting: Use Security token +#. Field: title +msgid "Use Security token" +msgstr "Usar token de seguranƧa" + +#. Setting: Use Security token +#. Field: hint +msgid "" +"Generates a security token in `~/.config/hhd/token` that is required for " +"authentication." +msgstr "" +"Gera um token de seguranƧa em `~/.config/hhd/token` que Ć© necessĆ”rio para a " +"autenticação." + +#. Setting: Handheld +#. Field: title +msgid "Handheld" +msgstr "Dispositivo PortĆ”til" + +#. Setting: Handheld +#. Field: hint +#. Setting: Orange Pi Neo +msgid "Allows for configuring your handheld's controller to a unified output." +msgstr "" +"Permite configurar o controle do seu dispositivo portĆ”til para um controle " +"unificado." + +#. Setting: Controller Emulation +#. Field: title +msgid "Controller Emulation" +msgstr "Emulação de Controles" + +#. Setting: Controller Emulation +#. Field: hint +msgid "Emulate different controller types to fuse your device's features." +msgstr "" +"Emula diferentes tipos de controle para unificar as funcionalidades do seu " +"dispositivo." + +#. Setting: Motion Support +#. Field: title +msgid "Motion Support" +msgstr "Suporte a Movimento" + +#. Setting: Motion Support +#. Field: hint +msgid "Enable gyroscope/accelerometer (IMU) support (.3% background CPU use)" +msgstr "" +"Habilita suporte ao giroscópio/acelerĆ“metro (IMU, aumenta em 0.3% o uso da " +"CPU)" + +#. Setting: Motion Hz +#. Field: title +msgid "Motion Hz" +msgstr "FrequĆŖncia do Movimento" + +#. Setting: Motion Hz +#. Field: hint +msgid "" +"Sets the sampling frequency for the IMU. Check `/sys/bus/iio/devices/iio:" +"device0/in_anglvel_sampling_frequency_available`." +msgstr "" +"Define a taxa de amostragem para o IMU. Veja `/sys/bus/iio/devices/iio:" +"device0/in_anglvel_sampling_frequency_available`." + +#. Setting: Nintendo Mode (A-B Swap) +#. Field: title +msgid "Nintendo Mode (A-B Swap)" +msgstr "Modo Nintendo (Troca A-B)" + +#. Setting: Nintendo Mode (A-B Swap) +#. Field: hint +msgid "Swaps A with B and X with Y." +msgstr "Troca A por B e X Y." + +#. Setting: Map share buttom to QAM. +#. Field: title +msgid "Map share buttom to QAM." +msgstr "Mapeia o botĆ£o de compartilhar para o QAM." + +#. Setting: Debug +#. Field: title +msgid "Debug" +msgstr "Debug" + +#. Setting: Debug +#. Field: hint +msgid "" +"Output controller events to the console (high CPU use) and raises exceptions " +"(HHD will crash on errors)." +msgstr "" +"Envia eventos do controle para o console (uso alto de CPU) e estoura " +"exceƧƵes (o HHD irĆ” travar em caso de erros)." + +#. Setting: GPD Controller +#. Field: title +msgid "GPD Controller" +msgstr "Controle GPD" + +#. Setting: GPD Controller +#. Field: hint +msgid "Allows for configuring the gpd win controllers to a unified output." +msgstr "Permite configurar os controles do GPD Win para um controle unificado." + +#. Setting: Controller Emulation +#. Field: hint +msgid "Emulate different controller types to fuse gpd features." +msgstr "" +"Emula diferentes tipos de controle para unificar as funcionalidades dos " +"controles do GPD." + +#. Setting: Map L4/R4 to QAM. +#. Field: title +msgid "Map L4/R4 to QAM." +msgstr "Mapeira L4/R4 para o QAM." + +#. Setting: Map L4/R4 to QAM. +#. Option: disabled +#. Setting: Disabled +#. Field: title +#. Setting: Swap Legion with Start/Select +#. Setting: Short Action +#. Setting: Hold Action +msgid "Disabled" +msgstr "Desabilitado" + +#. Setting: Map L4/R4 to QAM. +#. Option: l4 +msgid "L4" +msgstr "L4" + +#. Setting: Map L4/R4 to QAM. +#. Option: r4 +msgid "R4" +msgstr "R4" + +#. Setting: Legion Controllers +#. Field: title +msgid "Legion Controllers" +msgstr "Controles do Legion" + +#. Setting: Legion Controllers +#. Field: hint +msgid "" +"Allows for configuring the Legion controllers using the built in firmware " +"commands and enabling emulation modes for various controller types." +msgstr "" +"Permite configurar os controles do Legion usando os comandos embutidos no " +"firmware e habilitando modos de emulação para vĆ”rios tipos de controle." + +#. Setting: Emulation Mode (X-Input) +#. Field: title +msgid "Emulation Mode (X-Input)" +msgstr "Modo de Emulação (X-Input)" + +#. Setting: Emulation Mode (X-Input) +#. Field: hint +msgid "" +"Emulate different controller types when the Legion Controllers are in X-" +"Input mode." +msgstr "" +"Emula diferentes tipos de controle quando os controles do Legion estĆ£o em " +"modo X-Input." + +#. Setting: Controller Motions Device +#. Field: title +msgid "Controller Motions Device" +msgstr "Dispositivo de Controle de Movimento" + +#. Setting: Left Controller +#. Field: title +msgid "Left Controller" +msgstr "Controle Esquerdo" + +#. Setting: Right Controller +#. Field: title +msgid "Right Controller" +msgstr "Controle Direito" + +#. Setting: Display [SEE HINT] +#. Field: title +msgid "Display [SEE HINT]" +msgstr "Tela [LEIA A MENSAGEM]" + +#. Setting: Display [SEE HINT] +#. Field: hint +msgid "" +"Using the gyro of the display unit is deprecated. The controller units " +"(after calibration) are better in every way. As part of the deprecation, the " +"rule that disabled the use of the accelerometer for display autorotation was " +"removed. If you try to use the display gyroscope without this rule you will " +"get freezing." +msgstr "" +"Usar o giroscópio do corpo do dispositivo estĆ” obsoleto. Os controles " +"(depois da calibração) sĆ£o melhores em todos os sentidos. Como parte da " +"obsolescĆŖncia, a regra que desabilitava o uso do acelerĆ“metro para a auto-" +"rotação da tela foi removida. Se vocĆŖ tentar usar o giroscópio da tela sem " +"essa regra, ocorrerĆ” congelamento." + +#. Setting: Gyroscope +#. Field: title +msgid "Gyroscope" +msgstr "Giroscópio" + +#. Setting: Gyroscope +#. Field: hint +msgid "Enables gyroscope support (.3% background CPU use)" +msgstr "Habilita suporte ao giroscópio (aumenta em 0.3% o uso da CPU)" + +#. Setting: Accelerometer +#. Field: title +msgid "Accelerometer" +msgstr "AcelerĆ“metro" + +#. Setting: Accelerometer +#. Field: hint +msgid "Enables accelerometer support (CURRENTLY BROKEN)." +msgstr "Habilita suporte ao acelerĆ“metro (NƃO FUNCIONA NO MOMENTO)" + +#. Setting: Gyro Hz +#. Field: title +msgid "Gyro Hz" +msgstr "FrequĆŖncia do Giroscópio" + +#. Setting: Gyro Hz +#. Field: hint +msgid "" +"Adds polling to the legion go gyroscope, to fix the low polling rate " +"(required for gyroscope support). Set to 0 to disable. Due to hardware " +"limitations, there is a marginal difference above 100hz." +msgstr "" +"Adiciona polling ao giroscópio do Legion Go, para consertar a baixa taxa de " +"polling (necessĆ”ria para suporte ao giroscópio). Defina como 0 para " +"desabilitar. Devido a limitaƧƵes de hardware, hĆ” uma diferenƧa mĆ­nima acima " +"de 100 Hz." + +#. Setting: Gyro Scale +#. Field: title +msgid "Gyro Scale" +msgstr "Escala do Giroscópio" + +#. Setting: Gyro Scale +#. Field: hint +msgid "" +"Applies a scaling factor to the legion go gyroscope (since it is " +"misconfigured by the driver). Try different values to see what works best. " +"Low values cause a deadzone and high values will clip when moving the Go " +"abruptly." +msgstr "" +"Aplica um fator de escala ao giroscópio do Legion Go (jĆ” que ele Ć© " +"configurado errado pelo driver). Experimente valores diferentes para ver o " +"que funciona melhor. Valores baixos fazem aparecer uma zona morta, e valores " +"altos serĆ£o cortados quando mover o Go abruptamente." + +#. Setting: Dual Controller Motion Output (evdev) +#. Field: title +msgid "Dual Controller Motion Output (evdev)" +msgstr "Dois Controles com Movimento (evdev)" + +#. Setting: Dual Controller Motion Output (evdev) +#. Field: hint +msgid "" +"Adds two Motions evdev devices, one for each controller that can be used at " +"the same time." +msgstr "" +"Adiciona dois dispositivos evdev com movimento, um para cada controle, que " +"podem ser usados ao mesmo tempo." + +#. Setting: Swap Legion with Start/Select +#. Field: title +msgid "Swap Legion with Start/Select" +msgstr "Trocar botĆ£o Legion com Start/Select" + +#. Setting: Swap Legion with Start/Select +#. Field: hint +msgid "Swaps the legion buttons with start select." +msgstr "Troca os dois botƵes Legion com os botƵes Start e Select." + +#. Setting: Swap Legion with Start/Select +#. Option: l_is_start +msgid "Left is Start" +msgstr "Esquerda Ć© Start" + +#. Setting: Swap Legion with Start/Select +#. Option: l_is_select +msgid "Left is Select" +msgstr "Esquerda Ć© Select" + +#. Setting: M2 As Mute +#. Field: title +msgid "M2 As Mute" +msgstr "M2 para Mutar" + +#. Setting: M2 As Mute +#. Field: hint +msgid "" +"Maps the M2 to the mute button on Dualsense and the HAPPY_TRIGGER_20 on Xbox." +msgstr "" +"Mapeia o botĆ£o M2 para o botĆ£o de mutar no Dualsense o para o " +"HAPPY_TRIGGER_20 no Xbox." + +#. Setting: Hold Select to Reboot +#. Field: title +msgid "Hold Select to Reboot" +msgstr "Segurar Select para Reiniciar" + +#. Setting: Legion R to QAM +#. Field: title +msgid "Legion R to QAM" +msgstr "Legion R para o QAM" + +#. Setting: Fix touchpad hold [BROKEN] +#. Field: title +msgid "Fix touchpad hold [BROKEN]" +msgstr "Consertar ação de segurar o touchpad [QUEBRADO]" + +#. Setting: Enable Shortcuts Controller +#. Field: title +msgid "Enable Shortcuts Controller" +msgstr "Habilita Controle de Atalhos" + +#. Setting: Enable Shortcuts Controller +#. Field: hint +msgid "" +"When in other modes (dinput, dual dinput, and fps), enable a shortcuts " +"controller to restore Guide, QAM, and shortcut functionality." +msgstr "" +"Quando estiver em outros modos (DirectInput, dual DirectInput e FPS), " +"habilita um controle de atalhos para restaurar o Guia, QAM e outros atalhos." + +#. Setting: Factory Reset Controllers +#. Field: title +msgid "Factory Reset Controllers" +msgstr "Restauração de FĆ”brica dos Controles" + +#. Setting: Factory Reset Controllers +#. Field: hint +msgid "Resets the controllers to factory settings." +msgstr "Restaura os controles para as configuraƧƵes de fĆ”brica." + +#. Setting: Orange Pi Neo +#. Field: title +msgid "Orange Pi Neo" +msgstr "Orange Pi Neo" + +#. Setting: Ally Controller +#. Field: title +msgid "Ally Controller" +msgstr "Controle do Ally" + +#. Setting: Ally Controller +#. Field: hint +msgid "Allows for configuring the ROG Ally controllers to a unified output." +msgstr "" +"Permite configurar os controles do ROG Ally para um controle unificado." + +#. Setting: Controller Emulation +#. Field: hint +msgid "Emulate different controller types to fuse ROG features." +msgstr "" +"Emula diferentes tipos de controle para unificar as funcionalidades do ROG." + +#. Setting: Motion Hz +#. Field: hint +msgid "" +"Sets the sampling frequency for the IMU. 1600 requires an IMU patch. Check `/" +"sys/bus/iio/devices/iio:device0/in_anglvel_sampling_frequency_available`." +msgstr "" +"Define a taxa de amostragem para o IMU. Ɖ necessĆ”rio um patch para definir " +"como 1600. Veja `/sys/bus/iio/devices/iio:device0/" +"in_anglvel_sampling_frequency_available`." + +#. Setting: Led Brightness +#. Field: title +msgid "Led Brightness" +msgstr "Brilho do LED" + +#. Setting: Led Brightness +#. Field: hint +msgid "" +"When LEDs are configured, set their brightness. High does not work below 30% " +"brightness." +msgstr "" +"Quando os LEDs estĆ£o configurados, define o brilho deles. Alto nĆ£o funciona " +"abaixo de 30% de brilho." + +#. Setting: Led Brightness +#. Option: False +msgid "Off" +msgstr "Desligado" + +#. Setting: Led Brightness +#. Option: low +msgid "Low" +msgstr "Baixo" + +#. Setting: Led Brightness +#. Option: medium +msgid "Medium" +msgstr "MĆ©dio" + +#. Setting: Led Brightness +#. Option: high +msgid "High" +msgstr "Alto" + +#. Setting: Map Armory to QAM. +#. Field: title +msgid "Map Armory to QAM." +msgstr "Mapeia Armory para o QAM." + +#. Setting: Swap ROG and Start/Select +#. Field: title +msgid "Swap ROG and Start/Select" +msgstr "Trocar botĆ£o ROG com Start/Select" + +#. Setting: Swap ROG and Start/Select +#. Field: hint +msgid "" +"Swaps the Armory Crate and Command center buttons with start and select." +msgstr "" +"Troca os botƵes Armoury Crate e Command Center com os botƵes Start e Select." + +#. Setting: Motion Axis +#. Field: title +msgid "Motion Axis" +msgstr "Eixo de Movimento" + +#. Setting: Default +#. Field: hint +msgid "The default axis loaded for this device." +msgstr "O eixo padrĆ£o carregado este dispositivo." + +#. Setting: Override +#. Field: title +msgid "Override" +msgstr "Sobrescrever" + +#. Setting: Override +#. Field: hint +msgid "" +"Remap and invert the axis of your device. If the axis of your device are " +"wrong, please submit a picture or a text version of the following." +msgstr "" +"Remapeia e inverte os eixos do dispositivo. Se os eixos do dispositivo " +"estiverem errados, por favor nos envie uma imagem ou uma versĆ£o em texto das " +"informaƧƵes abaixo." + +#. Setting: Manufacturer +#. Field: title +msgid "Manufacturer" +msgstr "Manufacturer" + +#. Setting: Product +#. Field: title +msgid "Product" +msgstr "Product" + +#. Setting: Axis X +#. Field: title +msgid "Axis X" +msgstr "Axis X" + +#. Setting: Axis X +#. Option: x +#. Setting: Axis Y +#. Setting: Axis Z +msgid "X" +msgstr "X" + +#. Setting: Axis X +#. Option: y +#. Setting: Axis Y +#. Setting: Axis Z +msgid "Y" +msgstr "Y" + +#. Setting: Axis X +#. Option: z +#. Setting: Axis Y +#. Setting: Axis Z +msgid "Z" +msgstr "Z" + +#. Setting: Invert X +#. Field: title +msgid "Invert X" +msgstr "Invert X" + +#. Setting: Axis Y +#. Field: title +msgid "Axis Y" +msgstr "Axis Y" + +#. Setting: Invert Y +#. Field: title +msgid "Invert Y" +msgstr "Invert Y" + +#. Setting: Axis Z +#. Field: title +msgid "Axis Z" +msgstr "Axis Z" + +#. Setting: Invert Z +#. Field: title +msgid "Invert Z" +msgstr "Invert Z" + +#. Setting: Disabled +#. Field: hint +msgid "Does not modify the default controller." +msgstr "NĆ£o modifica o controle padrĆ£o." + +#. Setting: Xbox +#. Field: title +msgid "Xbox" +msgstr "Xbox" + +#. Setting: Xbox +#. Field: hint +msgid "" +"Creates a virtual `Handheld Daemon Controller` that can be used normally in " +"apps. Back buttons are supported but steam will not detect them. If " +"Gyroscope or Accelerometer are enabled, a Motion device will be created as " +"well (experimental; works in Dolphin)." +msgstr "" +"Cria um controle virtual `Handheld Daemon Controller` que pode ser usado " +"normalmente em aplicativos. Os botƵes traseiros sĆ£o suportados mas a Steam " +"nĆ£o os detecta. Se o Giroscópio ou o AcelerĆ“metro estiverem habilitados, um " +"dispotivo de Movimento serĆ” criado tambĆ©m (experimental; funciona no " +"Dolphin)." + +#. Setting: Dualsense +#. Field: title +msgid "Dualsense" +msgstr "Dualsense" + +#. Setting: Dualsense +#. Field: hint +msgid "" +"Emulates the Dualsense Sony controller from the Playstation 5. Since this " +"controller does not have paddles, the paddles are mapped to left and right " +"touchpad clicks." +msgstr "" +"Emula o controle Dualsense da Sony para o Playstation 5. Como esse controle " +"nĆ£o tem botƵes traseiros, eles sĆ£o mapeados para clique esquerdo e direito " +"no touchpad." + +#. Setting: LED Support +#. Field: title +msgid "LED Support" +msgstr "Suporte a LED" + +#. Setting: LED Support +#. Field: hint +msgid "" +"Passes through the LEDs to the controller, which allows games to control " +"them." +msgstr "" +"Passa as informaƧƵes dos LEDs para o controle, o que deixa que jogos os " +"controlem." + +#. Setting: Paddles to Clicks +#. Field: title +msgid "Paddles to Clicks" +msgstr "BotƵes Traseiros para Cliques" + +#. Setting: Paddles to Clicks +#. Field: hint +msgid "" +"Maps the paddles of the device to left and right touchpad clicks making them " +"usable in Steam. If more than 2 paddles (e.g., Legion Go) uses the top ones. " +"If extra buttons (e.g., Ayaneo, GPD), uses them instead." +msgstr "" +"Mapeia os botƵes traseiros do dispositivo para clique esquerdo e direito do " +"touchpad, os tornando Ćŗteis na Steam. Se existirem mais que 2 botƵes " +"traseiros (por exemplo, no Legion Go) sĆ£o usados os de cima. Se existirem " +"botƵes extras (por exemplo, Ayaneo, GPD), eles sĆ£o usados." + +#. Setting: Gyro Output Sync +#. Field: title +msgid "Gyro Output Sync" +msgstr "Sincronizar Giroscópios" + +#. Setting: Gyro Output Sync +#. Field: hint +msgid "" +"Steam relies on the IMU timestamp for the touchpad as Mouse and `Gyro to " +"Mouse [BETA]`. If the same timestamp is sent in 2 reports, this causes a " +"division by 0 and instability. This option makes it so reports are sent only " +"when there is a new IMU timestamp, effectively limiting the responsiveness " +"of the controller to that of the IMU. This only makes a difference for the " +"Legion Go (125hz), as all the other handhelds are using 400hz by default." +msgstr "" +"A Steam depende do timestamp do IMU para o Touchpad como Mouse e para o " +"`Giroscópio como mouse (beta)`. Se o mesmo timestamp Ć© enviado em 2 reports, " +"isso causa uma divisĆ£o por 0 e instabilidade. Essa opção faz com que os " +"reports sejam enviados apenas quando hĆ” um novo timestamp no IMU, " +"efetivamente limitando a responsividade do controle para aquela do IMU. Isso " +"só faz diferenƧa para o Legion Go (125 Hz), jĆ” que todos os outros portĆ”teis " +"estĆ£o usando 400 Hz por padrĆ£o." + +#. Setting: Invert Roll Axis +#. Field: title +msgid "Invert Roll Axis" +msgstr "Inverter Eixo de Rolagem" + +#. Setting: Invert Roll Axis +#. Field: hint +msgid "" +"Inverts the roll (Z) axis compared to a real Dualsense controller. Useful " +"for Steam Input, since you want it to be inverted to look left to right, but " +"an issue in emulators." +msgstr "" +"Inverte o eixo (Z) de rolagem comparando com um controle Dualsense real. " +"Útil para a Entrada Steam, jĆ” que vocĆŖ quer que seja invertido para olhar " +"para a esquerda ou direita, mas Ć© um problema nos emuladores." + +#. Setting: Bluetooth Mode +#. Field: title +msgid "Bluetooth Mode" +msgstr "Modo Bluetooth" + +#. Setting: Bluetooth Mode +#. Field: hint +msgid "" +"Emulates the controller in bluetooth mode instead of USB mode. This is the " +"default as it causes less issues with how apps interact with the controller. " +"However, using USB mode can improve LED support (?) in some games. Test and " +"report back!" +msgstr "" +"Emula o controle em modo Bluetooth ao invĆ©s do modo USB. Esse Ć© o padrĆ£o jĆ” " +"que causa menos problemas em como os aplicativos interagem com o controle. " +"PorĆ©m, usar o modo USB pode melhorar o suporte para LED (?) em alguns jogos. " +"Teste e nos avise!" + +#. Setting: Dualsense Edge +#. Field: title +msgid "Dualsense Edge" +msgstr "Dualsense Edge" + +#. Setting: Dualsense Edge +#. Field: hint +msgid "" +"Emulates the expensive Dualsense Sony controller which enables paddle " +"support. The edge controller is a bit obscure, so some games might not " +"support it correctly." +msgstr "" +"Emula o controle Dualsense caro da Sony que habilita suporte a botƵes " +"traseiros. O controle Edge Ć© um pouco obscuro, entĆ£o alguns jogos podem nĆ£o " +"suportar ele corretamente." + +#. Setting: Touchpad Emulation +#. Field: title +msgid "Touchpad Emulation" +msgstr "Emulação do Touchpad" + +#. Setting: Touchpad Emulation +#. Field: hint +msgid "" +"Use an emulated touchpad. Part of the controller if it is supported (e.g., " +"Dualsense) or a virtual one if not." +msgstr "" +"Usa um touchpad emulado. Parte do controle se ele for suportado (como no " +"Dualsense) ou um virtual se nĆ£o for." + +#. Setting: Disabled +#. Field: hint +msgid "" +"Does not modify the touchpad. Short + holding presses will not work within " +"gamescope." +msgstr "" +"NĆ£o modifica o touchpad. Cliques curtos ou longos nĆ£o vĆ£o funcionar no " +"gamescope." + +#. Setting: Virtual +#. Field: title +msgid "Virtual" +msgstr "Virtual" + +#. Setting: Virtual +#. Field: hint +msgid "" +"Adds an emulated touchpad. This touchpad is meant for use in gamescope and " +"has left, right click support by default. However, it causes issues in " +"desktop mode, and it doesnt allow dragging files. Therefore, it will " +"autodisable in desktop." +msgstr "" +"Adiciona um touchpad emulado. Esse touchpad Ć© indicado para uso no modo de " +"jogo e possui suporte a clique esquerdo e direito por padrĆ£o. PorĆ©m, causa " +"problemas no modo Ć”rea de trabalho e nĆ£o deixa arrastar arquivos. Por isso, " +"Ć© desabilitado automaticamente no modo Ć”rea de trabalho." + +#. Setting: Disable on Desktop +#. Field: title +msgid "Disable on Desktop" +msgstr "Desabilitar no Modo Ɓrea de Trabalho" + +#. Setting: Disable on Desktop +#. Field: hint +msgid "" +"Touchpad emulation will automatically be disabled when not in gamemode. " +"Specifically, steam will be periodically be checked to be running in gamepad " +"mode and if not, touchpad emulation will be disabled." +msgstr "" +"Emulação do touchpad serĆ” automaticamente desabilitada quando nĆ£o estiver em " +"modo de jogo. Especificamente, serĆ” checado periodicamente se a Steam estĆ” " +"rodando em modo de jogo e, se nĆ£o estiver, a emulação do touchpad serĆ” " +"desabilitada." + +#. Setting: Short Action +#. Field: title +msgid "Short Action" +msgstr "Toque RĆ”pido" + +#. Setting: Short Action +#. Field: hint +msgid "Maps short touches (less than 0.2s) to a virtual touchpad button." +msgstr "" +"Mapeia toques rĆ”pidos (menos de 0,2 segundos) para um botĆ£o no touchpad " +"virtual." + +#. Setting: Short Action +#. Option: left_click +#. Setting: Hold Action +msgid "Left Click" +msgstr "Clique Esquerdo" + +#. Setting: Short Action +#. Option: right_click +#. Setting: Hold Action +msgid "Right Click" +msgstr "Clique Direito" + +#. Setting: Hold Action +#. Field: title +msgid "Hold Action" +msgstr "Toque Longo" + +#. Setting: Hold Action +#. Field: hint +msgid "Maps long touches (more than 2s) to a virtual touchpad button." +msgstr "" +"Mapeia toques longos (mais que 2 segundos) para um botĆ£o no touchpad virtual." + +#. Setting: Controller +#. Field: hint +msgid "" +"Uses the touchpad of the emulated controller (if it exists). Otherwise, the " +"touchpad remains unmapped (will still show up in the system). Meant to be " +"used as steam input, so short press is unassigned by default and long press " +"simulates trackpad click." +msgstr "" +"Usa o touchpad do controle emulado (se ele existir). SenĆ£o, o touchpad nĆ£o " +"fica mapeado (ainda aparecerĆ” no sistema). Indicado para ser usado com a " +"Entrada Steam, assim toques curtos nĆ£o sĆ£o mapeados por padrĆ£o e toques " +"longos simulam um clique no trackpad." + +#. Setting: Location +#. Field: title +msgid "Location" +msgstr "Posição" + +#. Setting: Location +#. Field: hint +msgid "" +"Controls the placement of the real touchpad to the virtual one, using what " +"steam expects. In Steam, the \"Left\" touchpad maps to the left half, the " +"\"Right\" touchpad maps to the right half, and \"Center\" maps to the whole " +"touchpad. Therefore, the virtual touchpad is cropped to the left side for " +"left, the right side for right, and expanded in the center for center. This " +"means when set to center, half of the left touchpad is left and half of the " +"right is right. \"Stretch\" stretches the touchpad to the whole dualsense " +"surface." +msgstr "" +"Controla a posição do touchpad real para a do virtual, usando o que a Steam " +"espera. Na Steam, \"Esquerda\" Ć© mapeado para a metade esquerda, \"Direita\" " +"Ć© mapeado para a metade direita, e \"Centro\" mapeia para o touchpad " +"inteiro. EntĆ£o, o touchpad virtual Ć© cortado para o lado esquerdo para a " +"\"Esquerda\", cortado para o lado direito para a \"Direita\", e expandido no " +"centro para o \"Centro\". Isso significa que quando marcado como \"Centro\", " +"metade do touchpad esquerdo Ć© esquerda, e metade do touchpad direito Ć© " +"direita. \"Esticar\" estica o touchpad para a superfĆ­cie inteira do " +"Dualsense." + +#. Setting: Location +#. Option: right +msgid "Right" +msgstr "Direita" + +#. Setting: Location +#. Option: center +msgid "Center" +msgstr "Centro" + +#. Setting: Location +#. Option: left +msgid "Left" +msgstr "Esquerda" + +#. Setting: Location +#. Option: stretch +msgid "Stretch" +msgstr "Esticar" + +#. Setting: Short Action +#. Field: hint +msgid "" +"Maps short touches (less than 0.2s) to a touchpad action. Dualsense uses a " +"physical press for left and a double tap for right click (support for double " +"tap varies; enable \"Tap to Click\" in your desktop's touchpad settings)." +msgstr "" +"Mapeia toques curtos (menos de 0,2 segundos) como uma ação do touchpad. " +"Dualsense usa um pressionamento fĆ­sico para clique esquerdo e um toque duplo " +"para clique direito (suporte para toque duplo varia; habilite o \"Toque para " +"clicar\" nas configuraƧƵes do touchpad no seu desktop)." + +#. Setting: Hold Action +#. Field: hint +msgid "" +"Maps long touches (more than 2s) to a touchpad action. Dualsense uses a " +"physical press for left and a double tap for right click (support for double " +"tap varies; enable \"Tap to Click\" in your desktop's touchpad settings)." +msgstr "" +"Mapeia toques longos (mais de 2 segundos) como uma ação do touchpad. " +"Dualsense usa um pressionamento fĆ­sico para clique esquerdo e um toque duplo " +"para clique direito (suporte para toque duplo varia; habilite o \"Toque para " +"clicar\" nas configuraƧƵes do touchpad no seu desktop)." + +#. Setting: System +#. Field: hint +msgid "" +"Basic display settings. Brightness (and framerate TBD). This pane is meant " +"to replace " +msgstr "" +"ConfiguraƧƵes bĆ”sicas da tela. Brilho (e taxa de atualização, talvez). Esse " +"painel deve substituir" + +#. Setting: Brightness +#. Field: title +msgid "Brightness" +msgstr "Brilho" + +#. Setting: Brightness +#. Field: hint +msgid "" +"Sets the brightness level of a display. Only one display is supported and it " +"is the one that was read." +msgstr "" +"Define o nĆ­vel de brilho da tela. Somente uma tela Ć© suportada e Ć© essa que " +"Ć© usada." + +#. Setting: Overlay with QAM Double/Triple press (requires restart) +#. Field: title +msgid "Overlay with QAM Double/Triple press (requires restart)" +msgstr "Sobrepor com toques duplos/triplos do QAM (necessĆ”rio reiniciar)" + +#. Setting: Steam Powerbutton Handler +#. Field: title +msgid "Steam Powerbutton Handler" +msgstr "Gerenciador do botĆ£o de energia da Steam" + +#. Setting: Steam Powerbutton Handler +#. Field: hint +msgid "" +"Enables the Steam Powerbutton handler (responsible for the wink and " +"powerbutton menu)." +msgstr "" +"Habilita o gerenciador do botĆ£o de energia da Steam (responsĆ”vel pela " +"\"piscada\" e o menu do botĆ£o de energia)." + +#~ msgid "Display" +#~ msgstr "Tela" + +#~ msgid "Gyro to Mouse Fix" +#~ msgstr "Consertar opção Giroscópio como mouse" + +#~ msgid "Correction Type" +#~ msgstr "Tipo de Correção" + +#~ msgid "" +#~ "The legion touchpad is square, whereas the DS5 one is rectangular. " +#~ "Therefore, it needs to be corrected. \"Contain\" maintain the whole DS5 " +#~ "touchpad and part of the Legion one is unused. \"Crop\" uses the full " +#~ "legion touchpad, and limits the area of the DS5. \"Stretch\" uses both " +#~ "fully (distorted). \"Crop End\" enables use in steam input as the right " +#~ "touchpad." +#~ msgstr "" +#~ "O touchpad do Legion Go Ć© quadrado, enquanto que o do Dualsense Ć© " +#~ "retangular. Assim, ele precisa ser corrigido. \"Conter\" mantĆ©m o " +#~ "touchpad inteiro do Dualsense e parte do touchpad do Legion Go nĆ£o Ć© " +#~ "usado. \"Cortar\" usa todo o touchpad do Legion Go e limita a Ć”rea do " +#~ "Dualsense. \"Esticar\" usa ambos inteiramente (distorcido). \"Cortar " +#~ "Fim\" habilita usar na Entrada Steam como o touchpad direito." + +#~ msgid "Crop Start" +#~ msgstr "Cortar ComeƧo" + +#~ msgid "Crop End" +#~ msgstr "Cortar Fim" + +#~ msgid "Contain Start" +#~ msgstr "Conter ComeƧo" + +#~ msgid "Contain End" +#~ msgstr "Conter Fim" + +#~ msgid "Contain Center" +#~ msgstr "Conter Centro" diff --git a/i18n/zh_CN/LC_MESSAGES/adjustor.po b/i18n/zh_CN/LC_MESSAGES/adjustor.po new file mode 100644 index 00000000..1bf680b0 --- /dev/null +++ b/i18n/zh_CN/LC_MESSAGES/adjustor.po @@ -0,0 +1,1003 @@ +# Chinese translations for PROJECT. +# Copyright (C) 2024 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2024. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2025-02-28 12:37+0800\n" +"PO-Revision-Date: 2024-04-08 19:00+0200\n" +"Last-Translator: Alex \n" +"Language: zh\n" +"Language-Team: zh \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +msgid "Disable Decky TDP plugins using the button below to continue." +msgstr "ä½æē”Øäø‹é¢ēš„ęŒ‰é’®ē¦ē”Ø Decky TDP ę’ä»¶ä»„ē»§ē»­ć€‚" + +#. Setting: TDP Controls +#. Field: title +msgid "TDP Controls" +msgstr "TDP ęŽ§åˆ¶" + +#. Setting: Enable TDP Controls +#. Field: title +msgid "Enable TDP Controls" +msgstr "启用 TDP ęŽ§åˆ¶" + +#. Setting: Enable TDP Controls +#. Field: hint +msgid "" +"Enables TDP management by Handheld Daemon. While enabled, Handheld Daemon" +" will set and maintain the TDP limits set on start-up and during other " +"device changes (ac/dc)." +msgstr "" +"启用 Handheld Daemon ēš„ TDP ē®”ē†ć€‚åÆē”ØåŽ, Handheld Daemon å°†åœØåÆåŠØę—¶å’Œå…¶ä»–č®¾å¤‡ę›“ę”¹ (ac/dc) " +"ę—¶č®¾ē½®å’Œē»“ęŠ¤ TDP é™åˆ¶ć€‚\n" +"å¦‚ęžœč®¾å¤‡å“©ęŗƒ, TDP č®¾ē½®å°†åœØäø‹ę¬”åÆåŠØę—¶ē¦ē”Ø" + +#. Setting: Error +#. Field: title +msgid "Error" +msgstr "错误" + +#. Setting: Error +#. Option: nowrite +msgid "Can not write to ACPI Call file. ACPI Call is required for TDP." +msgstr "无法写兄 ACPI Call ꖇ件, TDP ęŽ§åˆ¶éœ€č¦ ACPI Call ę”ÆęŒ" + +#. Setting: TDP Capabilities +#. Field: title +msgid "TDP Capabilities" +msgstr "TDP 功能" + +#. Setting: Disable Decky TDP Plugins +#. Field: title +msgid "Disable Decky TDP Plugins" +msgstr "禁用 Decky TDP ę’ä»¶" + +#. Setting: Disable Decky TDP Plugins +#. Field: hint +msgid "" +"Disables Decky TDP plugins (Powercontrol, SimpleDeckyTDP) by moving them " +"from ~/homebrew/plugins to ~/homebrew/plugins/hhd-disabled. Then, " +"restarts Decky. This might cause Steam to restart. Move them back and " +"reboot to re-enable." +msgstr "" +"é€ščæ‡å°†å…¶ä»Ž ~/homebrew/plugins 移动到 ~/homebrew/plugins/hhd-disabled ę„ē¦ē”Ø Decky " +"TDP ę’ä»¶ (Powercontrol, SimpleDeckyTDP)ć€‚ē„¶åŽ, é‡ę–°åÆåŠØ Deckyć€‚čæ™åÆčƒ½ä¼šåÆ¼č‡“ Steam " +"é‡ę–°åÆåŠØć€‚å°†å®ƒä»¬ē§»å›žå¹¶é‡ę–°åÆåŠØä»„é‡ę–°åÆē”Øć€‚" + +#. Setting: Enable TDP Controls +#. Field: hint +msgid "" +"Enables TDP management by the Handheld Daemon. While enabled, Handheld " +"Daemon will set and maintain the TDP limits set on start-up and during " +"other device changes (ac/dc).\n" +"If the device crashes, TDP setting will be disabled on next startup." +msgstr "" +"启用 Handheld Daemon ēš„ TDP ē®”ē†ć€‚åÆē”ØåŽ, Handheld Daemon å°†åœØåÆåŠØę—¶å’Œå…¶ä»–č®¾å¤‡ę›“ę”¹ (ac/dc) " +"ę—¶č®¾ē½®å’Œē»“ęŠ¤ TDP é™åˆ¶ć€‚\n" +"å¦‚ęžœč®¾å¤‡å“©ęŗƒ, TDP č®¾ē½®å°†åœØäø‹ę¬”åÆåŠØę—¶ē¦ē”Øć€‚" + +#. Setting: Add TDP to /sys for Steam (Requires Restart) +#. Field: title +msgid "Add TDP to /sys for Steam (Requires Restart)" +msgstr "添加 TDP 到 /sys 仄供 Steam 使用 (éœ€č¦é‡ę–°åÆåŠØ)" + +#. Setting: Add TDP to /sys for Steam (Requires Restart) +#. Field: hint +msgid "" +"Uses a FUSE mount to add TDP attributes to /sys/class/drm. This fixes the" +" TDP slider in Steam." +msgstr "使用 FUSE ęŒ‚č½½å°† TDP å±žę€§ę·»åŠ åˆ° /sys/class/drmć€‚čæ™åÆä»„äæ®å¤ Steam äø­ēš„ TDP ę»‘å—ć€‚" + +#. Setting: Enforce Device TDP Limits +#. Field: title +msgid "Enforce Device TDP Limits" +msgstr "å¼ŗåˆ¶ä½æē”Øč®¾å¤‡ TDP 限制" + +#. Setting: Enforce Device TDP Limits +#. Field: hint +msgid "" +"When this option is on, the settings will adhere to the limits set out by" +" the device manufacturer, subject to their availability.\n" +"With it off, the TDP settings ranges will expand to what is logically " +"possible for the current device (regardless of manufacturer " +"specifications).\n" +"All settings outside specifications will be set to system specifications " +"after rebooting." +msgstr "" +"当此选锹打开时, č®¾ē½®å°†éµå®ˆč®¾å¤‡åˆ¶é€ å•†č§„å®šēš„é™åˆ¶, ä½†č¦č§†å…¶åÆē”Øę€§č€Œå®šć€‚\n" +"关闭时, TDP č®¾ē½®čŒƒå›“å°†ę‰©å±•åˆ°å½“å‰č®¾å¤‡é€»č¾‘äøŠåÆčƒ½ēš„čŒƒå›“ (äøč€ƒč™‘åˆ¶é€ å•†č§„ę ¼)怂\n" +"é‡ę–°åÆåŠØåŽ, ę‰€ęœ‰č¶…å‡ŗč§„ę ¼ēš„č®¾ē½®å°†č¢«č®¾ē½®äøŗē³»ē»Ÿč§„ę ¼ć€‚" + +#. Setting: Processor Settings +#. Field: title +msgid "Processor Settings" +msgstr "处理器设置" + +#. Setting: CPU Settings +#. Field: title +msgid "CPU Settings" +msgstr "CPU 设置" + +#. Setting: Auto +#. Field: title +msgid "Auto" +msgstr "č‡ŖåŠØ" + +#. Setting: Auto +#. Field: hint +msgid "" +"Handheld Daemon will manage the energy management settings. This includes" +" CPU governor, CPU boost, GPU frequency, and CPU power preferences. At " +"low TDPs, the CPU will be tuned down and at other TDPs, it will use " +"balanced settings." +msgstr "" +"Handheld Daemon å°†ē®”ē†čƒ½ęŗē®”ē†č®¾ē½®ć€‚čæ™åŒ…ę‹¬ CPU č°ƒåŗ¦ēØ‹åŗć€CPU boost态GPU é¢‘ēŽ‡å’Œ CPU åŠŸēŽ‡åå„½ć€‚åœØä½Ž TDP " +"äø‹, CPU å°†č¢«č°ƒę•“, åœØå…¶ä»– TDP äø‹, å®ƒå°†ä½æē”Øå¹³č””č®¾ē½®ć€‚" + +#. Setting: Manual +#. Field: title +msgid "Manual" +msgstr "ę‰‹åŠØ" + +#. Setting: Manual +#. Field: hint +msgid "Allows you to set the energy management settings manually." +msgstr "å…č®øę‚Øę‰‹åŠØč®¾ē½®čƒ½ęŗē®”ē†č®¾ē½®ć€‚" + +#. Setting: CPU Power (EPP) +#. Field: title +msgid "CPU Power (EPP)" +msgstr "CPU电源(EPP)" + +#. Setting: CPU Power (EPP) +#. Field: hint +msgid "" +"Sets the energy performance preference for the CPU. Keep on balanced for " +"good performance on all TDPs. Options map to `power`, `balance_power`, " +"`balance_performance`. Performance is not recommended and is not " +"included." +msgstr "" +"设置 CPU ēš„čƒ½ęŗę€§čƒ½åå„½ć€‚åœØę‰€ęœ‰ TDP äø‹äæęŒå¹³č””ä»„čŽ·å¾—č‰Æå„½ēš„ę€§čƒ½ć€‚é€‰é”¹ę˜ å°„åˆ° `power`, `balance_power`, " +"`balance_performance`ć€‚äøęŽØčä½æē”Øę€§čƒ½é€‰é”¹, ä¹ŸäøåŒ…ę‹¬åœØå†…ć€‚" + +#. Setting: CPU Power (EPP) +#. Option: power +msgid "Low" +msgstr "低" + +#. Setting: CPU Power (EPP) +#. Option: balance_power +#. Setting: Power Profile +#. Option: balanced +#. Setting: Balanced +#. Field: title +#. Setting: Platform Profile +#. Setting: Energy Policy +msgid "Balanced" +msgstr "å‡č””" + +#. Setting: CPU Power (EPP) +#. Option: balance_performance +msgid "High" +msgstr "高" + +#. Setting: CPU Minimum Frequency +#. Field: title +msgid "CPU Minimum Frequency" +msgstr "CPU ęœ€å°é¢‘ēŽ‡" + +#. Setting: CPU Minimum Frequency +#. Field: hint +msgid "" +"Sets the minimum frequency for the CPU. Using 400MHz will save battery in" +" light games. However, the delay of increasing the frequency may cause " +"minor stutters, especially in VRR displays." +msgstr "设置 CPU ēš„ęœ€å°é¢‘ēŽ‡ć€‚åœØč½»é‡ēŗ§ęøøęˆäø­ä½æē”Ø 400MHz åÆä»„čŠ‚ēœē”µę± ć€‚ē„¶č€Œ, å¢žåŠ é¢‘ēŽ‡ēš„å»¶čæŸåÆčƒ½ä¼šåÆ¼č‡“č½»å¾®ēš„å”é”æ, å°¤å…¶ę˜ÆåœØ VRR ę˜¾ē¤ŗå™ØäøŠć€‚" + +#. Setting: CPU Minimum Frequency +#. Option: min +msgid "400MHz" +msgstr "" + +#. Setting: CPU Minimum Frequency +#. Option: nonlinear +msgid "1GHz" +msgstr "" + +#. Setting: CPU Boost +#. Field: title +msgid "CPU Boost" +msgstr "CPU 加速" + +#. Setting: CPU Boost +#. Field: hint +msgid "" +"Enables or disables the CPU boost frequencies. Disabling lowers total " +"consumption by 2W with minimal performance impact." +msgstr "åÆē”Øęˆ–ē¦ē”Ø CPU boost é¢‘ēŽ‡ć€‚ē¦ē”ØåÆä»„å°†ę€»ę¶ˆč€—é™ä½Ž 2W, åÆ¹ę€§čƒ½å½±å“å¾ˆå°ć€‚" + +#. Setting: CPU Boost +#. Option: disabled +#. Setting: Custom Scheduler +#. Setting: Disabled +#. Field: title +#. Setting: Extreme Standby Mode +msgid "Disabled" +msgstr "禁用" + +#. Setting: CPU Boost +#. Option: enabled +#. Setting: Extreme Standby Mode +msgid "Enabled" +msgstr "启用" + +#. Setting: Custom Scheduler +#. Field: title +msgid "Custom Scheduler" +msgstr "č‡Ŗå®šä¹‰č°ƒåŗ¦" + +#. Setting: Custom Scheduler +#. Field: hint +msgid "" +"Allows attaching a scheduler to the kernel sched_ext. Schedulers need to " +"be installed and kernel needs to support sched_ext." +msgstr "å…č®øå°†č‡Ŗå®šä¹‰č°ƒåŗ¦ēØ‹åŗé™„åŠ åˆ°å†…ę øēš„ sched_extć€‚éœ€č¦å…ˆå®‰č£…č°ƒåŗ¦ēØ‹åŗļ¼Œäø”å†…ę øåæ…é”»ę”ÆęŒ sched_ext" + +#. Setting: Custom Scheduler +#. Option: scx_lavd +msgid "LAVD" +msgstr "" + +#. Setting: Custom Scheduler +#. Option: scx_bpfland +msgid "bpfland" +msgstr "" + +#. Setting: Custom Scheduler +#. Option: scx_rusty +msgid "rusty" +msgstr "" + +#. Setting: GPU Frequency +#. Field: title +msgid "GPU Frequency" +msgstr "GPU é¢‘ēŽ‡" + +#. Setting: GPU Frequency +#. Field: hint +msgid "" +"Pins the GPU to a certain frequency. Helps in certain games that are CPU " +"or GPU heavy by shifting power to or from the GPU. Has a minor effect." +msgstr "将 GPU é”å®šåœØē‰¹å®šé¢‘ēŽ‡ć€‚åœØ CPU ꈖ GPU č“Ÿč½½č¾ƒé‡ēš„ęøøęˆäø­ļ¼Œé€ščæ‡č°ƒę•“ GPU ēš„åŠŸč€—åˆ†é…ę„ęä¾›åø®åŠ©ļ¼Œä½†ę•ˆęžœč¾ƒå°" + +#. Setting: Auto +#. Field: hint +msgid "Lets the GPU manage its own frequency." +msgstr "GPU č‡Ŗč”Œē®”ē†å…¶é¢‘ēŽ‡ć€‚" + +#. Setting: Max Limit +#. Field: title +msgid "Max Limit" +msgstr "ęœ€å¤§é™åˆ¶" + +#. Setting: Max Limit +#. Field: hint +msgid "Limits the maximum frequency of the GPU." +msgstr "限制 GPU ēš„ęœ€å¤§é¢‘ēŽ‡ć€‚" + +#. Setting: Maximum Frequency +#. Field: title +msgid "Maximum Frequency" +msgstr "ęœ€å¤§é¢‘ēŽ‡" + +#. Setting: Range +#. Field: title +msgid "Range" +msgstr "čŒƒå›“" + +#. Setting: Range +#. Field: hint +msgid "Sets the GPU frequency to a range." +msgstr "将 GPU é¢‘ēŽ‡č®¾ē½®äøŗäø€äøŖčŒƒå›“ć€‚" + +#. Setting: Minimum Frequency +#. Field: title +msgid "Minimum Frequency" +msgstr "ęœ€å°é¢‘ēŽ‡" + +#. Setting: Fixed +#. Field: title +msgid "Fixed" +msgstr "å›ŗå®š" + +#. Setting: Fixed +#. Field: hint +msgid "Pins the GPU to a certain frequency (not recommended)." +msgstr "将 GPU å›ŗå®šåœØęŸäøŖé¢‘ēŽ‡ (äøęŽØč)怂" + +#. Setting: Frequency +#. Field: title +msgid "Frequency" +msgstr "é¢‘ēŽ‡" + +#. Setting: Conflict Detected +#. Field: title +msgid "Conflict Detected" +msgstr "ę£€ęµ‹åˆ°å†²ēŖ" + +#. Setting: Enable Processor Settings +#. Field: title +msgid "Enable Processor Settings" +msgstr "åÆē”Øå¤„ē†å™ØęŽ§åˆ¶" + +#. Setting: Enable energy management +#. Field: title +msgid "Enable energy management" +msgstr "启用电源箔理" + +#. Setting: Enable energy management +#. Field: hint +msgid "" +"Handheld daemon will manage the power preferences for the system, " +"including Governor, Boost, GPU frequency, and EPP. In addition, Handheld " +"daemon will launch a PPD service to replace PPD's role in the system. " +msgstr "" +"Handheld daemon å°†ē®”ē†ē³»ē»Ÿēš„ē”µęŗåå„½, åŒ…ę‹¬č°ƒåŗ¦ēØ‹åŗć€Boost态GPU é¢‘ēŽ‡å’Œ EPP。此外, Handheld daemon " +"å°†åÆåŠØäø€äøŖ PPD ęœåŠ”ę„å–ä»£ē³»ē»Ÿäø­ PPD ēš„č§’č‰²ć€‚" + +#. Setting: Enable PPD Emulation (KDE/Gnome Power) +#. Field: title +msgid "Enable PPD Emulation (KDE/Gnome Power)" +msgstr "启用 PPD ęØ”ę‹Ÿ (KDE/Gnome 电源)" + +#. Setting: Enable PPD Emulation (KDE/Gnome Power) +#. Field: hint +msgid "Enable PPD service to manage the power preferences for the system." +msgstr "启用 PPD ęœåŠ”ę„ē®”ē†ē³»ē»Ÿēš„ē”µęŗåå„½ć€‚" + +msgid "Steam is controlling TDP" +msgstr "Steam ę­£åœØęŽ§åˆ¶ TDP" + +#. Setting: Asus TDP +#. Field: title +msgid "Asus TDP" +msgstr "åŽē”• TDP" + +#. Setting: Asus TDP +#. Field: hint +msgid "Uses the interface of Armory Crate to set the TDP of the device." +msgstr "使用 Armory Crate ēš„ęŽ„å£č®¾ē½®č®¾å¤‡ēš„ TDP怂" + +#. Setting: TDP Mode +#. Field: title +msgid "TDP Mode" +msgstr "TDP ęØ”å¼" + +#. Setting: Silent +#. Field: title +msgid "Silent" +msgstr "静音" + +#. Setting: Performance +#. Field: title +#. Setting: Power Profile +#. Option: performance +#. Setting: Platform Profile +#. Setting: Energy Policy +msgid "Performance" +msgstr "ę€§čƒ½" + +#. Setting: Turbo +#. Field: title +msgid "Turbo" +msgstr "" + +#. Setting: Custom +#. Field: title +msgid "Custom" +msgstr "č‡Ŗå®šä¹‰" + +#. Setting: TDP +#. Field: title +msgid "TDP" +msgstr "" + +#. Setting: TDP +#. Field: hint +msgid "" +"Average TDP Target. TDP Boost is recommended for desktop use and does not" +" affect gaming." +msgstr "平均 TDP 目标。TDP å¢žå¼ŗé€‚ē”ØäŗŽę”Œé¢ä½æē”Ø, äøå½±å“ęøøęˆć€‚" + +#. Setting: TDP Boost +#. Field: title +msgid "TDP Boost" +msgstr "TDP å¢žå¼ŗ" + +#. Setting: TDP Boost +#. Field: hint +msgid "" +"Allows the device to temporarily boost by setting appropriate slow and " +"fast TDPs." +msgstr "å…č®øč®¾å¤‡é€ščæ‡č®¾ē½®é€‚å½“ēš„ slow 和 fast TDP å€¼ę„ęå‡ę€§čƒ½ć€‚" + +#. Setting: +#. Field: title +msgid " " +msgstr " " + +#. Setting: Change TDP with View+Y +#. Field: title +msgid "Change TDP with View+Y" +msgstr "使用 View+Y 曓改 TDP" + +#. Setting: Change TDP with View+Y +#. Field: hint +msgid "" +"Allows you to cycle through TDP modes with the View+Y key combination. " +"Recommended to use with ROG Swap, as the View button will be muted to " +"games." +msgstr "允许您使用 View+Y ē»„åˆé”®åœØ TDP ęØ”å¼ä¹‹é—“å¾ŖēŽÆć€‚å»ŗč®®äøŽ ROG Swap 一起使用, å› äøŗ View ęŒ‰é’®å°†č¢«ęøøęˆé™é»˜ć€‚" + +#. Setting: Custom Fan Curve +#. Field: title +msgid "Custom Fan Curve" +msgstr "č‡Ŗå®šä¹‰é£Žę‰‡ę›²ēŗæ" + +#. Setting: Custom Fan Curve +#. Field: hint +msgid "Allows you to set a custom fan curve." +msgstr "č®¾ē½®č‡Ŗå®šä¹‰é£Žę‰‡ę›²ēŗæć€‚" + +#. Setting: Disabled +#. Field: hint +msgid "Lets the device manage the fan curve on its own." +msgstr "č®©č®¾å¤‡č‡Ŗč”Œē®”ē†é£Žę‰‡ę›²ēŗæć€‚" + +#. Setting: 30C +#. Field: title +msgid "30C" +msgstr "30°C" + +#. Setting: 30C +#. Field: hint +#. Setting: 40C +#. Setting: 50C +#. Setting: 60C +#. Setting: 70C +#. Setting: 80C +#. Setting: 90C +#. Setting: 100C +#. Setting: 10C +#. Setting: 20C +msgid "Sets the speed at the named temperature." +msgstr "č®¾ē½®åœØęŒ‡å®šęø©åŗ¦äø‹ēš„é£Žę‰‡é€Ÿåŗ¦ć€‚" + +#. Setting: 40C +#. Field: title +msgid "40C" +msgstr "40°C" + +#. Setting: 50C +#. Field: title +msgid "50C" +msgstr "50°C" + +#. Setting: 60C +#. Field: title +msgid "60C" +msgstr "60°C" + +#. Setting: 70C +#. Field: title +msgid "70C" +msgstr "70°C" + +#. Setting: 80C +#. Field: title +msgid "80C" +msgstr "80°C" + +#. Setting: 90C +#. Field: title +msgid "90C" +msgstr "90°C" + +#. Setting: 100C +#. Field: title +msgid "100C" +msgstr "100°C" + +#. Setting: Restore Default +#. Field: title +msgid "Restore Default" +msgstr "ę¢å¤é»˜č®¤" + +#. Setting: Restore Default +#. Field: hint +msgid "Restore a default sane fan curve." +msgstr "ę¢å¤é»˜č®¤ēš„é£Žę‰‡ę›²ēŗæ" + +#. Setting: Fan Curve Limitation +#. Field: title +msgid "Fan Curve Limitation" +msgstr "é£Žę‰‡ę›²ēŗæé™åˆ¶" + + +#. Setting: Battery Settings +#. Field: title +#, fuzzy +msgid "Battery Settings" +msgstr "电池设置" + +#. Setting: Charge Limit (%) +#. Field: title +msgid "Charge Limit (%)" +msgstr "å……ē”µé™åˆ¶ (%)" + +#. Setting: Charge Limit (%) +#. Field: hint +msgid "Applies a charge limit to the battery, 75% and up." +msgstr "åÆ¹ē”µę± åŗ”ē”Øå……ē”µé™åˆ¶, 75% åŠä»„äøŠć€‚" + +#. Setting: Charge Limit (%) +#. Option: p70 +msgid "70%" +msgstr "" + +#. Setting: Charge Limit (%) +#. Option: p80 +msgid "80%" +msgstr "" + +#. Setting: Charge Limit (%) +#. Option: p85 +msgid "85%" +msgstr "" + +#. Setting: Charge Limit (%) +#. Option: p90 +msgid "90%" +msgstr "" + +#. Setting: Charge Limit (%) +#. Option: p95 +msgid "95%" +msgstr "" + +#. Setting: Charge Limit (%) +#. Option: disabled +#. Setting: Charge Bypass +msgid "Unset" +msgstr "äøé™" + +#. Setting: Extreme Standby Mode +#. Field: title +msgid "Extreme Standby Mode" +msgstr "ęžé™å¾…ęœŗęØ”å¼" + +#. Setting: Charge Bypass +#. Field: title +msgid "Charge Bypass" +msgstr "充电旁路" + +#. Setting: Charge Bypass +#. Option: awake +msgid "While On" +msgstr "开启时" + +#. Setting: Charge Bypass +#. Option: always +msgid "Always" +msgstr "å§‹ē»ˆ" + +#. Setting: Extreme Standby Mode +#. Field: hint +#, python-format +# msgid "" +# "Lowers the power consumption of the device from 4% to 1% overnight. " +# "Active only on battery. Turns off the power light and the controller " +# "requires longer to wake up." +# msgstr "å°†č®¾å¤‡ēš„åŠŸč€—ä»Ž 4% 降低到 1%, ä»…åœØē”µę± ęØ”å¼äø‹ęœ‰ę•ˆć€‚å…³é—­ē”µęŗęŒ‡ē¤ŗēÆ, ęŽ§åˆ¶å™Øéœ€č¦ę›“é•æę—¶é—“ę‰čƒ½å”¤é†’" + +#. Setting: Power +#. Field: title +#. Setting: Energy Policy +#. Option: power +msgid "Power" +msgstr "电源" + +#. Setting: Power Profile +#. Field: title +msgid "Power Profile" +msgstr "ē”µęŗé…ē½®" + +#. Setting: Power Profile +#. Field: hint +msgid "" +"Allows setting the power profile of the system using Power Profiles " +"Daemon." +msgstr "使用 Power Profiles Daemon č®¾ē½®ē³»ē»Ÿēš„ē”µęŗé…ē½®ć€‚" + +#. Setting: Power Profile +#. Option: power-saver +msgid "Powersave" +msgstr "čŠ‚čƒ½" + +#. Setting: Steamdeck Overclock (Requires Reboot) +#. Field: title +msgid "Steamdeck Overclock (Requires Reboot)" +msgstr "Steamdeck 超频 (éœ€č¦é‡ę–°åÆåŠØ)" + +#. Setting: Steamdeck Overclock (Requires Reboot) +#. Field: hint +msgid "" +"Allows setting the Steam TDP slider from 1-20W instead of 4-15W. " +"Unchecked, it is still setting TDP to 15W." +msgstr "允许将 Steam TDP ę»‘å—č®¾ē½®äøŗ 1-20W, č€Œäøę˜Æ 4-15Wć€‚ęœŖé€‰äø­ę—¶, 仍将 TDP 设置为 15W怂" + +msgid "Power Light" +msgstr "ē”µęŗęŒ‡ē¤ŗēÆ" + +#, fuzzy +msgid "Legion Button + Y changes TDP Mode" +msgstr "Legion 键 + Y 曓改 TDP ęØ”å¼" + +#. Setting: Lenovo TDP +#. Field: title +msgid "Lenovo TDP" +msgstr "č”ęƒ³ TDP" + +#. Setting: Lenovo TDP +#. Field: hint +msgid "Uses the interface of Legion Space to set the TDP of the device." +msgstr "使用 Legion Space ēš„ęŽ„å£č®¾ē½®č®¾å¤‡ēš„ TDP怂" + +#. Setting: Quiet +#. Field: title +#. Setting: Platform Profile +#. Option: quiet +msgid "Quiet" +msgstr "安静" + +#. Setting: TDP +#. Field: hint +msgid "" +"Maximum average TDP. Boost goes a bit higher and is recommended for " +"desktop use." +msgstr "ęœ€å¤§å¹³å‡ TDP怂TDP å¢žå¼ŗę›“ęæ€čæ›ļ¼ŒęŽØčē”ØäŗŽę”Œé¢ä½æē”Øć€‚" + +#. Setting: TDP Boost +#. Field: hint +msgid "Allows the device to boost by setting appropriate slow and fast TDPs." +msgstr "å…č®øč®¾å¤‡é€ščæ‡č®¾ē½®é€‚å½“ēš„ slow 和 fast TDP å€¼ę„ęå‡ę€§čƒ½ć€‚" + +#. Setting: Set Fan to Full Speed +#. Field: title +msgid "Set Fan to Full Speed" +msgstr "å°†é£Žę‰‡č®¾ē½®äøŗęœ€å¤§é€Ÿåŗ¦" + +#. Setting: Disabled +#. Field: hint +msgid "" +"Lets Legion GO manage the curve on its own. Setting this option will " +"cause a mode change to reset the fan curve." +msgstr "让 Legion GO č‡Ŗč”Œē®”ē†é£Žę‰‡ę›²ēŗæć€‚č®¾ē½®ę­¤é€‰é”¹å°†åÆ¼č‡“ęØ”å¼ę›“ę”¹ä»„é‡ē½®é£Žę‰‡ę›²ēŗæć€‚" + +#. Setting: 10C +#. Field: title +msgid "10C" +msgstr "10°C" + +#. Setting: 20C +#. Field: title +msgid "20C" +msgstr "20°C" + +#. Setting: Enforce Windows Minimums +#. Field: title +msgid "Enforce Windows Minimums" +msgstr "强制Windowsęœ€ä½Žč¦ę±‚" + +#. Setting: Enforce Windows Minimums +#. Field: hint +msgid "Enforce the minimum fan curve from Legion Space." +msgstr "å¼ŗåˆ¶ę‰§č”Œę„č‡Ŗ Legion Space ēš„ęœ€ä½Žé£Žę‰‡ę›²ēŗæć€‚" + +#. Setting: Restore Default +#. Field: hint +#, fuzzy +msgid "Reset to the original fan curve for custom mode." +msgstr "é‡ē½®äøŗåŽŸå§‹é£Žę‰‡ę›²ēŗæć€‚" + +#. Setting: Show TDP changes with RGB +#. Field: title +msgid "Show TDP changes with RGB" +msgstr "使用RGB显示TDPēŠ¶ę€" + +#. Setting: Charge Limit (80%) +#. Field: title +msgid "Charge Limit (80%)" +msgstr "å……ē”µé™åˆ¶ (80%)" + +#. Setting: Charge Limit (80%) +#. Field: hint +msgid "Limits device charging to 80%." +msgstr "é™åˆ¶å……ē”µ80%" + +#. Setting: Power Light (Awake) +#. Field: title +msgid "Power Light (Awake)" +msgstr "ē”µęŗęŒ‡ē¤ŗēÆ(唤醒时)" + +#. Setting: Power Light (Sleep) +#. Field: title +msgid "Power Light (Sleep)" +msgstr "ē”µęŗęŒ‡ē¤ŗēÆ(ē”ēœ ę—¶)" + +#. Setting: TDP Settings +#. Field: title +msgid "TDP Settings" +msgstr "TDP 设置" + +#. Setting: TDP +#. Field: hint +msgid "Controls all Ryzen SMU settings through preset curves." +msgstr "é€ščæ‡é¢„č®¾ę›²ēŗæęŽ§åˆ¶ę‰€ęœ‰ Ryzen SMU 设置。" + +#. Setting: Custom Fan Curve +#. Field: hint +msgid "" +"Allows you to set a custom fan curve and to choose the temperature probe " +"(Edge or Junction). Junction is the peak temperature of the chip: " +"responds faster and prevents throttling. Edge is the temperature of the " +"chip: responds slower and prevents overheating." +msgstr "" +"å…č®øę‚Øč®¾ē½®č‡Ŗå®šä¹‰é£Žę‰‡ę›²ēŗæå¹¶é€‰ę‹©ęø©åŗ¦ęŽ¢å¤“ (EdgeꈖJunction)怂Junctionę˜ÆčŠÆē‰‡ēš„ēƒ­ē‚¹ęø©åŗ¦ļ¼š" +"å“åŗ”ę›“åæ«å¹¶é˜²ę­¢é™é¢‘ć€‚Edgeę˜ÆčŠÆē‰‡ēš„ęø©åŗ¦ļ¼šå“åŗ”č¾ƒę…¢å¹¶é˜²ę­¢čæ‡ēƒ­ć€‚" + +#. Setting: Manual (Edge, Smooth) +#. Field: title +msgid "Manual (Edge, Smooth)" +msgstr "ę‰‹åŠØ (Edge, 平滑)" + +#. Setting: Reset to Default +#. Field: title +msgid "Reset to Default" +msgstr "ę¢å¤é»˜č®¤" + +#. Setting: Manual (Tctl, Fast) +#. Field: title +msgid "Manual (Tctl, Fast)" +msgstr "ę‰‹åŠØ (Tctl, åæ«é€Ÿ)" + +#. Setting: Advanced Configurator +#. Field: title +msgid "Advanced Configurator" +msgstr "é«˜ēŗ§é…ē½®" + +#. Setting: Apply Settings +#. Field: title +msgid "Apply Settings" +msgstr "应用设置" + +#. Setting: TDP Status +#. Field: title +msgid "TDP Status" +msgstr "TDP ēŠ¶ę€" + +#. Setting: Platform Profile +#. Field: title +msgid "Platform Profile" +msgstr "å¹³å°é…ē½®" + +#. Setting: Platform Profile +#. Option: disabled +msgid "Not Set" +msgstr "未设置" + +#. Setting: Platform Profile +#. Option: low-power +msgid "Low Power" +msgstr "ä½ŽåŠŸč€—" + +#. Setting: Platform Profile +#. Option: cool +msgid "Cool" +msgstr "å‡‰ēˆ½" + +#. Setting: Platform Profile +#. Option: balanced-performance +msgid "Balanced Performance" +msgstr "å¹³č””ę€§čƒ½" + +#. Setting: Energy Policy +#. Field: title +msgid "Energy Policy" +msgstr "电源政策" + +#. Setting: Standard Parameters +#. Field: title +msgid "Standard Parameters" +msgstr "ę ‡å‡†å‚ę•°" + +#. Setting: Standard Parameters +#. Field: hint +msgid "" +"Standard TDP parameters for Ryzen processors. All need to be set to " +"properly control the TDP of the device.\n" +"Ryzen processors have 2 modes: STTv2 and STAPM (legacy). AMD suggests to" +" manufacturers to use STTv2, which makes the Legion Go the only device " +"to offer the STAPM alternative through a BIOS setting.\n" +"In STTv2, the device will keep boosting until the \"skin\" of the device " +"(hottest user accessible spot) reaches a manufacturer set temperature. " +"Then, the device will use the Skin Temp TDP limit. In STAPM, the device " +"averages the TDP values from the 1-3 previous minutes and keeps that " +"value under the STAPM TDP limit. Either mode ignores the other mode's " +"limit (STAPM limit does nothing on STT and Skin Temp Limit does nothing " +"on STAPM), so both should be set.\n" +"The Fast and Slow limits control boosting behavior. The Fast TDP limit is" +" the actual max TDP value of the device. Then,the Slow TDP limit averages" +" the last 10-20s of TDP values and keeps the value below it." +msgstr "" +"Ryzen å¤„ē†å™Øēš„ę ‡å‡† TDP å‚ę•°ć€‚ę‰€ęœ‰å‚ę•°éœ€č¦č®¾ē½®äøŗę­£ē”®ęŽ§åˆ¶č®¾å¤‡ēš„ TDP怂\n" +"Ryzen å¤„ē†å™Øęœ‰äø¤ē§ęØ”å¼ļ¼šSTTv2 和 STAPMļ¼ˆä¼ ē»ŸęØ”å¼ļ¼‰ć€‚AMD å»ŗč®®åˆ¶é€ å•†ä½æē”Ø STTv2ļ¼Œčæ™ä½æå¾— Legion Go ęˆäøŗå”Æäø€é€ščæ‡" +" BIOS č®¾ē½®ęä¾› STAPM é€‰é”¹ēš„č®¾å¤‡ć€‚\n" +"在 STTv2 ęØ”å¼äø‹ļ¼Œč®¾å¤‡å°†ęŒē»­ęå‡ę€§čƒ½ļ¼Œē›“åˆ°č®¾å¤‡ēš„ \"č”Øé¢\"(ē”Øęˆ·åÆęŽ„č§¦ēš„ęœ€ēƒ­ē‚¹) č¾¾åˆ°åˆ¶é€ å•†č®¾å®šēš„ęø©åŗ¦ć€‚ē„¶åŽļ¼Œč®¾å¤‡å°†ä½æē”Øč”Øé¢ęø©åŗ¦ TDP" +" é™åˆ¶ć€‚åœØ STAPM ęØ”å¼äø‹ļ¼Œč®¾å¤‡ä¼šå¹³å‡čæ‡åŽ» 1-3 åˆ†é’Ÿēš„ TDP å€¼ļ¼Œå¹¶äæęŒčÆ„å€¼ä½ŽäŗŽ STAPM TDP " +"é™åˆ¶ć€‚ä»»äø€ęØ”å¼éƒ½ä¼šåæ½ē•„å¦äø€ęØ”å¼ēš„é™åˆ¶ļ¼ˆSTAPM é™åˆ¶åœØ STT äøŠę— ę•ˆļ¼Œč”Øé¢ęø©åŗ¦é™åˆ¶åœØ STAPM äøŠę— ę•ˆļ¼‰ļ¼Œå› ę­¤äø¤č€…éƒ½åŗ”čÆ„č®¾ē½®ć€‚\n" +"åæ«é€Ÿå’Œę…¢é€Ÿé™åˆ¶ęŽ§åˆ¶ęå‡č”Œäøŗć€‚åæ«é€Ÿ TDP é™åˆ¶ę˜Æč®¾å¤‡ēš„å®žé™…ęœ€å¤§TDP å€¼ć€‚ē„¶åŽļ¼Œę…¢é€Ÿ TDP é™åˆ¶ä¼šå¹³å‡čæ‡åŽ» 10-20 ē§’ēš„ TDP " +"å€¼ļ¼Œå¹¶äæęŒčÆ„å€¼ä½ŽäŗŽę­¤é™åˆ¶ć€‚" + +#. Setting: Fast TDP Limit +#. Field: title +msgid "Fast TDP Limit" +msgstr "åæ«é€ŸTDP限制" + +#. Setting: Slow TDP Limit +#. Field: title +msgid "Slow TDP Limit" +msgstr "ę…¢é€ŸTDP限制" + +#. Setting: Skin Temp TDP Limit +#. Field: title +msgid "Skin Temp TDP Limit" +msgstr "č”Øé¢ēƒ­ē‚¹TDP限制" + +#. Setting: STAPM TDP Limit +#. Field: title +msgid "STAPM TDP Limit" +msgstr "STAPM TDP限制" + +#. Setting: Advanced Parameters +#. Field: title +msgid "Advanced Parameters" +msgstr "é«˜ēŗ§å‚ę•°" + +#. Setting: Advanced Parameters +#. Field: hint +msgid "" +"The Advanced Parameters below control boosting behavior and need to be " +"adjusted per device depending on its cooling system. They mostly affect " +"boosting behavior, which is important for desktop use.\n" +"The exception is the Temp Target (TCTL), which controls the max " +"temperature of the CPU die. On most devices, it can safely be raised up " +"to 100C. However, if a temperature spike makes the chip reach 105C, it " +"will enter a thermal protection mode, which is 5W, for a couple of " +"minutes.\n" +"The integration times for Slow TDP and STAPM influence how many previous " +"TDP values the CPU will average to calculate its current Slow and STAPM " +"TDP values." +msgstr "" +"äø‹é¢ēš„é«˜ēŗ§å‚ę•°ęŽ§åˆ¶ęå‡č”Œäøŗļ¼Œéœ€č¦ę ¹ę®ęÆäøŖč®¾å¤‡ēš„å†·å“ē³»ē»Ÿčæ›č”Œč°ƒę•“ć€‚å®ƒä»¬äø»č¦å½±å“ęå‡č”Œäøŗļ¼Œčæ™åÆ¹å°å¼ęœŗä½æē”Øå¾ˆé‡č¦ć€‚\n" +"ä¾‹å¤–ę˜Æęø©åŗ¦ē›®ę ‡ (TCTL)ļ¼Œå®ƒęŽ§åˆ¶ CPU å†…ę øēš„ęœ€é«˜ęø©åŗ¦ć€‚åœØå¤§å¤šę•°č®¾å¤‡äøŠļ¼Œå®ƒåÆä»„å®‰å…Øåœ°ęé«˜åˆ° 100°Cć€‚ä½†ę˜Æļ¼Œå¦‚ęžœęø©åŗ¦ēŖē„¶äøŠå‡åˆ° " +"105°Cļ¼Œå®ƒå°†čæ›å…„ēƒ­äæęŠ¤ęØ”å¼ļ¼ŒåŠŸč€—é™č‡³ 5Wļ¼ŒęŒē»­å‡ åˆ†é’Ÿć€‚\n" +"ę…¢é€Ÿ TDP 和 STAPM ēš„ē§Æåˆ†ę—¶é—“å½±å“ CPU å°†å¹³å‡å¤šå°‘äøŖå…ˆå‰ēš„ TDP å€¼ę„č®”ē®—å…¶å½“å‰ēš„ę…¢é€Ÿå’Œ STAPM TDP 值。" + +#. Setting: Temp Target (TCTL) +#. Field: title +msgid "Temp Target (TCTL)" +msgstr "温度目标 (TCTL)" + +#. Setting: Slow Limit Integration Time +#. Field: title +msgid "Slow Limit Integration Time" +msgstr "ę…¢é€Ÿé™åˆ¶ē§Æåˆ†ę—¶é—“" + +#. Setting: STAPM Limit Integration Time +#. Field: title +msgid "STAPM Limit Integration Time" +msgstr "STAPM é™åˆ¶ē§Æåˆ†ę—¶é—“" + +#. Setting: Enable Advanced Parameters +#. Field: title +msgid "Enable Advanced Parameters" +msgstr "åÆē”Øé«˜ēŗ§å‚ę•°" + +#~ msgid "Due to a suspected crash, auto-start was disabled." +#~ msgstr "" + +#~ msgid "TDP Controls can not be enabled while other TDP plugins are installed." +#~ msgstr "" + +#~ msgid "" +#~ "Average TDP Target.\n" +#~ "Sets the values STAMP and Skin " +#~ "Power Limit to it without boost. " +#~ "With boost, it sets the fast value" +#~ " to 53/30*tdp and the slow value " +#~ "to 43/30*tdp. Boost is recommended for" +#~ " desktop use." +#~ msgstr "" +#~ "平均 TDP 目标。\n" +#~ "设置 STAMP 和 Skin Power Limit ēš„å€¼," +#~ " äøä½æē”Ø boost。使用 boost ę—¶, 将 fast " +#~ "值设置为 53/30*tdp, 将 slow 值设置为 " +#~ "43/30*tdpć€‚å»ŗč®®åœØę”Œé¢ä½æē”Øę—¶åÆē”Ø boost怂" + +#~ msgid "65%" +#~ msgstr "" + +#~ msgid "Sleep Bug" +#~ msgstr "ē”ēœ é—®é¢˜" + +#~ msgid "Quiet (8W)" +#~ msgstr "安静 (8W)" + +#~ msgid "Balanced (15W)" +#~ msgstr "平蔔 (15W)" + +#~ msgid "Performance (20W)" +#~ msgstr "ę€§čƒ½ (20W)" + +#~ msgid "Custom (up to 25-30W)" +#~ msgstr "č‡Ŗå®šä¹‰ (ęœ€é«˜ 25-30W)" + +#~ msgid "Enable Charge Limit (80%)" +#~ msgstr "åÆē”Øå……ē”µé™åˆ¶ (80%)" + +#~ msgid "" +#~ "Limits device charging to 80%. Lenovo" +#~ " EC method. Available since BIOSv29." +#~ msgstr "å°†č®¾å¤‡å……ē”µé™åˆ¶åœØ 80%ć€‚č”ęƒ³ EC 方法。自 BIOSv29 čµ·åÆē”Øć€‚" + +#~ msgid "" +#~ "Average TDP Target.\n" +#~ "Sets the values STAMP and Skin " +#~ "Power Limit to it. If boost is " +#~ "enabled, interpolates values for slow " +#~ "and fast TDPs based on those used" +#~ " by Lenovo. If it is not, it" +#~ " sets the Slow limit equal to " +#~ "TDP and the Fast limit to +2W. " +#~ "Boost is recommended for desktop use." +#~ msgstr "" +#~ "平均 TDP 目标。\n" +#~ "设置 STAMP 和 Skin Power Limit " +#~ "ēš„å€¼ć€‚å¦‚ęžœåÆē”Øäŗ† boost, åˆ™ę ¹ę®č”ęƒ³ä½æē”Øēš„å€¼ę’å€¼č®”ē®—å‡ŗ slow 和 " +#~ "fast TDP ēš„å€¼ć€‚å¦‚ęžœę²”ęœ‰åÆē”Ø, 则将 Slow é™åˆ¶č®¾ē½®äøŗ " +#~ "TDP, 将 Fast é™åˆ¶č®¾ē½®äøŗ +2Wć€‚å»ŗč®®åœØę”Œé¢ä½æē”Øę—¶åÆē”Ø boost怂" + +#~ msgid "" +#~ "Allows you to set a custom fan " +#~ "curve. This fan curve is only " +#~ "officially supported on custom mode, but" +#~ " you can nevertheless use it in " +#~ "other power modes. This fan curve " +#~ "needs to be reapplied and is " +#~ "reapplied every time you switch TDP " +#~ "modes." +#~ msgstr "" +#~ "å…č®øę‚Øč®¾ē½®č‡Ŗå®šä¹‰é£Žę‰‡ę›²ēŗæć€‚ę­¤é£Žę‰‡ę›²ēŗæä»…åœØč‡Ŗå®šä¹‰ęØ”å¼äø‹å¾—åˆ°å®˜ę–¹ę”ÆęŒ, ä½†ę‚Øä»ē„¶åÆä»„åœØå…¶ä»–ē”µęŗęØ”å¼äø‹ä½æē”Øć€‚ę­¤é£Žę‰‡ę›²ēŗæéœ€č¦é‡ę–°åŗ”ē”Ø," +#~ " å¹¶äø”åœØåˆ‡ę¢ TDP ęØ”å¼ę—¶é‡ę–°åŗ”ē”Øć€‚" + diff --git a/i18n/zh_CN/LC_MESSAGES/hhd.po b/i18n/zh_CN/LC_MESSAGES/hhd.po new file mode 100644 index 00000000..854c169e --- /dev/null +++ b/i18n/zh_CN/LC_MESSAGES/hhd.po @@ -0,0 +1,2317 @@ +# Chinese (Simplified, China) translations for PROJECT. +# Copyright (C) 2024 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2024. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2025-02-28 12:21+0800\n" +"PO-Revision-Date: 2024-02-07 17:27+0100\n" +"Last-Translator: Alex \n" +"Language: zh_Hans_CN\n" +"Language-Team: zh_Hans_CN \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#. Section Name for: tdp +msgid "TDP" +msgstr "TDP" + +#. Section Name for: rgb +msgid "RGB" +msgstr "" + +#. Section Name for: controllers +#. Setting: Controller +#. Field: title +msgid "Controller" +msgstr "ęŽ§åˆ¶å™Ø" + +#. Section Name for: wincontrols +#. Setting: WinControls +#. Field: title +msgid "WinControls" +msgstr "" + +#. Section Name for: gamemode +msgid "General" +msgstr "é€šē”Ø" + +#. Section Name for: updates +msgid "Updates" +msgstr "ꛓꖰ" + +#. Section Name for: debug +msgid "Bugreport" +msgstr "é”™čÆÆęŠ„å‘Š" + +#. Section Name for: shortcuts +msgid "Shortcuts" +msgstr "åæ«ę·" + +#. Section Name for: hhd +msgid "Settings" +msgstr "设置" + +#. Setting: Core Settings +#. Field: title +msgid "Core Settings" +msgstr "核心设置" + +#. Setting: Language +#. Field: title +msgid "Language" +msgstr "语言" + +#. Setting: Language +#. Option: system +#. Setting: System +#. Field: title +msgid "System" +msgstr "系统" + +#. Setting: Language +#. Option: C +msgid "English" +msgstr "" + +#. Setting: Language +#. Option: zh_CN +msgid "Simplified Chinese" +msgstr "简体中文" + +#. Setting: Language +#. Option: zh_TW +msgid "Traditional Chinese" +msgstr "繁體中文" + +#. Setting: Language +#. Option: pt +msgid "Portugese" +msgstr "PortuguĆŖs" + +#. Setting: Theme +#. Field: title +msgid "Theme" +msgstr "主题" + +#. Setting: Theme +#. Field: hint +msgid "" +"Allows changing the theme in the UI. Default is either Diavolo or your " +"distribution's theme." +msgstr "在 HHD-UI äø­ę›“ę”¹äø»é¢˜ć€‚é»˜č®¤äøŗ Diavolo ęˆ–ę‚Øēš„å‘č”Œē‰ˆäø»é¢˜" + +#. Setting: Theme +#. Option: default +#. Setting: Default +#. Field: title +msgid "Default" +msgstr "默认" + +#. Setting: Theme +#. Option: diavolo +msgid "Diavolo" +msgstr "" + +#. Setting: Theme +#. Option: ocean +msgid "Atlantis" +msgstr "" + +#. Setting: Theme +#. Option: vapor +msgid "Vapor" +msgstr "" + +#. Setting: Theme +#. Option: blood_orange +msgid "Blood Orange" +msgstr "" + +#. Setting: Reset Settings +#. Field: title +msgid "Reset Settings" +msgstr "é‡ē½®č®¾ē½®" + +#. Setting: Reset Settings +#. Field: hint +msgid "Resets all Handheld Daemon settings to their default values." +msgstr "å°†ę‰€ęœ‰ Handheld Daemon č®¾ē½®é‡ē½®äøŗé»˜č®¤å€¼" + +#. Setting: It is no longer possible to update Decky from here. If you see +#. this, update the Decky plugin manually. +#. Field: title +msgid "" +"It is no longer possible to update Decky from here. If you see this, " +"update the Decky plugin manually." +msgstr "Decky ę’ä»¶äøå†ę”ÆęŒä»Žčæ™é‡Œę›“ę–°ć€‚å¦‚ęžœę‚Øēœ‹åˆ°ę­¤ę¶ˆęÆ, čÆ·ę‰‹åŠØę›“ę–° Decky ę’ä»¶" + +#. Setting: Handheld Daemon Version +#. Field: title +msgid "Handheld Daemon Version" +msgstr "Handheld Daemon ē‰ˆęœ¬" + +#. Setting: Handheld Daemon Version +#. Field: hint +#. Setting: Handheld Daemon UI Version +#. Setting: Adjustor (TDP) Version +msgid "Displays the Handheld Daemon version." +msgstr "显示 Handheld Daemon ē‰ˆęœ¬" + +#. Setting: Handheld Daemon UI Version +#. Field: title +msgid "Handheld Daemon UI Version" +msgstr "Handheld Daemon UI ē‰ˆęœ¬" + +#. Setting: Adjustor (TDP) Version +#. Field: title +msgid "Adjustor (TDP) Version" +msgstr "Adjustor (TDP) ē‰ˆęœ¬" + +#. Setting: Update (Stable) +#. Field: title +msgid "Update (Stable)" +msgstr "ꛓꖰ (ēØ³å®šē‰ˆ)" + +#. Setting: Update (Stable) +#. Field: hint +msgid "Updates to the latest version from PyPi (local install only)." +msgstr "从 PyPi ę›“ę–°åˆ°ęœ€ę–°ē‰ˆęœ¬ (ä»…ęœ¬åœ°å®‰č£…)" + +#. Setting: Update (Unstable) +#. Field: title +msgid "Update (Unstable)" +msgstr "ꛓꖰ (äøēØ³å®šē‰ˆ)" + +#. Setting: Update (Unstable) +#. Field: hint +msgid "Updates to the master branch from git (local install only)." +msgstr "ę›“ę–°åˆ° git ēš„ master åˆ†ę”Æ (ä»…ęœ¬åœ°å®‰č£…)" + +#. Setting: Update Error +#. Field: title +msgid "Update Error" +msgstr "曓新错误" + +#. Setting: API Configuration +#. Field: title +msgid "API Configuration" +msgstr "API é…ē½®" + +#. Setting: API Configuration +#. Field: hint +msgid "Settings for configuring the http endpoint of HHD." +msgstr "HHD ēš„ http ē«Æē‚¹ēš„č®¾ē½®" + +#. Setting: Enable the API +#. Field: title +msgid "Enable the API" +msgstr "启用 API" + +#. Setting: Enable the API +#. Field: hint +msgid "Enables the API of Handheld Daemon (required for decky and ui)." +msgstr "启用 Handheld Daemon ēš„ API (decky 和 hhd-ui åæ…éœ€) " + +#. Setting: API Port +#. Field: title +msgid "API Port" +msgstr "API ē«Æå£" + +#. Setting: API Port +#. Field: hint +msgid "Which port should the API be on?" +msgstr "API č¦ä½æē”Øå“ŖäøŖē«Æå£ļ¼Ÿ" + +#. Setting: Limit Access to localhost +#. Field: title +msgid "Limit Access to localhost" +msgstr "ä»…é™ęœ¬åœ°č®æé—®" + +#. Setting: Limit Access to localhost +#. Field: hint +msgid "Sets the API target to '127.0.0.1' instead '0.0.0.0'." +msgstr "API 监听目标设置为 '127.0.0.1' č€Œäøę˜Æ '0.0.0.0'" + +#. Setting: Use Security token +#. Field: title +msgid "Use Security token" +msgstr "ä½æē”Øå®‰å…Øä»¤ē‰Œ" + +#. Setting: Use Security token +#. Field: hint +msgid "" +"Generates a security token in `~/.config/hhd/token` that is required for " +"authentication." +msgstr "在 `~/.config/hhd/token` ē”Ÿęˆäø€äøŖå®‰å…Øä»¤ē‰Œ, ē”ØäŗŽč®¤čÆ" + +#. Setting: Handheld +#. Field: title +msgid "Handheld" +msgstr "ęŽŒęœŗ" + +#. Setting: Handheld +#. Field: hint +#. Setting: Orange Pi Neo +#. Setting: OneXPlayer Controller +msgid "Allows for configuring your handheld's controller to a unified output." +msgstr "åÆä»„é…ē½®ę‚Øēš„ęŽŒęœŗęŽ§åˆ¶å™Øäøŗē»Ÿäø€č®¾å¤‡č¾“å‡ŗ" + +#. Setting: Controller Emulation +#. Field: title +msgid "Controller Emulation" +msgstr "ęŽ§åˆ¶å™ØęØ”ę‹Ÿ" + +#. Setting: Controller Emulation +#. Field: hint +msgid "Emulate different controller types to fuse your device's features." +msgstr "ęØ”ę‹ŸäøåŒē±»åž‹ēš„ęŽ§åˆ¶å™Ø, ä»„ä¾æčžåˆč®¾å¤‡ē‰¹ę€§" + + + + +#. Setting: Swap Guide and Menu/View +#. Field: title +#, fuzzy +msgid "Swap Guide and Menu/View" +msgstr "äŗ¤ę¢ Guide äøŽ Menu/View ęŒ‰é’®" + +#. Setting: Swap Guide and Menu/View +#. Field: hint +#, fuzzy +msgid "Swaps the Guide and QAM buttons with start and select." +msgstr "äŗ¤ę¢ Guide/QAM ęŒ‰é’®äøŽ start/select ęŒ‰é’®" + +#. Setting: Motion Support +#. Field: title +msgid "Motion Support" +msgstr "ä½“ę„Ÿę”ÆęŒ" + +#. Setting: Motion Support +#. Field: hint +msgid "Enable gyroscope/accelerometer (IMU) support (.3% background CPU use)" +msgstr "åÆē”Øé™€čžŗä»Ŗ/åŠ é€Ÿåŗ¦č®” (IMU) ę”ÆęŒ (0.3% 后台 CPU 使用)" + +#. Setting: Motion Hz +#. Field: title +msgid "Motion Hz" +msgstr "ä½“ę„Ÿé‡‡ę ·ēŽ‡" + +#. Setting: Motion Hz +#. Field: hint +msgid "Sets the sampling frequency for the IMU." +msgstr "设置IMUēš„é‡‡ę ·é¢‘ēŽ‡" + +#. Setting: Nintendo Mode (A-B Swap) +#. Field: title +msgid "Nintendo Mode (A-B Swap)" +msgstr "ä»»å¤©å ‚ęØ”å¼ (A-B äŗ¤ę¢)" + +#. Setting: Nintendo Mode (A-B Swap) +#. Field: hint +msgid "Swaps A with B and X with Y." +msgstr "A äøŽ B äŗ¤ę¢, X äøŽ Y äŗ¤ę¢" + +#. Setting: Hold View to Reboot +#. Field: title +msgid "Hold View to Reboot" +msgstr "é•æęŒ‰ View é”®é‡åÆ" + +#. Setting: GPD Controller +#. Field: title +msgid "GPD Controller" +msgstr "GPD ęŽ§åˆ¶å™Ø" + +#. Setting: GPD Controller +#. Field: hint +msgid "Allows for configuring the gpd win controllers to a unified output." +msgstr "åÆä»„é…ē½®ę‚Øēš„ GPD ęŽ§åˆ¶å™Øäøŗē»Ÿäø€č®¾å¤‡č¾“å‡ŗ" + +#. Setting: Controller Emulation +#. Field: hint +msgid "Emulate different controller types to fuse gpd features." +msgstr "ęØ”ę‹ŸäøåŒē±»åž‹ēš„ęŽ§åˆ¶å™Ø, ä»„čžåˆ GPD č®¾å¤‡ēš„ē‰¹ę€§" + +#. Setting: Menu/L4/R4 Mapping +#. Field: title +msgid "Menu/L4/R4 Mapping" +msgstr "čœå•/L4/R4 ę˜ å°„" + +#. Setting: Menu/L4/R4 Mapping +#. Field: hint +msgid "" +"Maps L4/R4 to Steam Input (requires L4/R4 as HHD in Wincontrols tab). If " +"disabled, they are keyboard buttons. Menu/L4/R4 can be combos: Menu is " +"single-press QAM, double HHD, hold Xbox. L4/R4 are single-press QAM, hold" +" HHD (legacy). " +msgstr "" +"将 L4/R4 ę˜ å°„åˆ° Steam č¾“å…„ļ¼ˆéœ€č¦åœØ Wincontrols é€‰é”¹å”äø­å°† L4/R4 设置为 HHDļ¼‰ć€‚å¦‚ęžœē¦ē”Øļ¼Œå®ƒä»¬å°†ä½œäøŗé”®ē›˜ęŒ‰é’®ć€‚" +"čœå•/L4/R4 åÆä»„ä½œäøŗē»„åˆé”®ļ¼ščœå•å•å‡»å‘¼å‡ŗåæ«é€Ÿčœå•é”®(QAM)ļ¼ŒåŒå‡»å‘¼å‡ŗHHDļ¼Œé•æęŒ‰äøŗXbox怂" +"L4/R4 ę˜Æå•å‡»å‘¼å‡ŗåæ«é€Ÿčœå•é”®(QAM)ļ¼Œé•æęŒ‰å‘¼å‡ŗ HHD čœå•ļ¼ˆåŽŸå§‹ļ¼‰ć€‚" + +#. Setting: Start/Select do SteamOS Combos +#. Option: disabled +#. Setting: Menu/L4/R4 Mapping +#. Setting: Disabled +#. Field: title +#. Setting: Extra buttons as +#. Setting: Short Action +#. Setting: Hold Action +#. Setting: Xbox or View + B (Press) +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "Disabled" +msgstr "禁用" + +#. Setting: Menu/L4/R4 Mapping +#. Option: generic +msgid "Paddles, no Combo" +msgstr "čƒŒé”®, äøåšē»„åˆé”®" + +#. Setting: Menu/L4/R4 Mapping +#. Option: menu +msgid "Menu is Combo" +msgstr "čœå•é”®äøŗē»„åˆé”®" + +#. Setting: Menu/L4/R4 Mapping +#. Option: l4 +msgid "L4 is Combo" +msgstr "L4 äøŗē»„åˆé”®" + +#. Setting: Menu/L4/R4 Mapping +#. Option: r4 +msgid "R4 is Combo" +msgstr "R4 äøŗē»„åˆé”®" + +#. Setting: Start/Select do SteamOS Combos +#. Field: title +msgid "Start/Select do SteamOS Combos" +msgstr "Start/Select é”®ä½œäøŗ SteamOS ē»„åˆé”®" + +#. Setting: Start/Select do SteamOS Combos +#. Field: hint +msgid "" +"When holding Select or Start, if another button is pressed, they become " +"the Xbox button, which allows doing SteamOS combos (Select+RT is " +"screenshot)." +msgstr "" +"ęŒ‰ä½ Select ꈖ Start ę—¶ļ¼Œå¦‚ęžœęŒ‰äø‹å¦äø€äøŖęŒ‰é’®ļ¼Œå®ƒä»¬å°†å˜äøŗ Xbox ęŒ‰é’®ļ¼Œ" +"åÆä»„å®žēŽ°ę‰§č”Œ SteamOS ē»„åˆé”®ļ¼ˆSelect+RT ę˜Æå±å¹•ęˆŖå›¾ļ¼‰ć€‚" + +#. Setting: Start/Select do SteamOS Combos +#. Option: select +msgid "Select Only" +msgstr "仅 Select" + +#. Setting: Start/Select do SteamOS Combos +#. Option: start_select +msgid "Start+Select" +msgstr "" + +#. Setting: WinControls +#. Field: hint +msgid "Specialized settings for GPD devices." +msgstr "GPD č®¾å¤‡ēš„äø“ē”Øč®¾ē½®" + +#. Setting: RGB Mode +#. Field: title +msgid "RGB Mode" +msgstr "RGB ęØ”å¼" + +#. Setting: Off +#. Field: title +#. Setting: Vibration Strength +#. Option: off +msgid "Off" +msgstr "关闭" + +#. Setting: Off +#. Field: hint +msgid "Turns the LEDs off." +msgstr "关闭 LED 灯" + +#. Setting: Solid +#. Field: title +msgid "Solid" +msgstr "å›ŗå®š" + +#. Setting: Solid +#. Field: hint +msgid "Maintains the LEDs at a solid color." +msgstr "äæęŒ LED ēÆäøŗå›ŗå®šé¢œč‰²" + +#. Setting: Hue +#. Field: title +msgid "Hue" +msgstr "č‰²č°ƒ" + +#. Setting: Pulse +#. Field: title +msgid "Pulse" +msgstr "呼吸" + +#. Setting: Pulse +#. Field: hint +msgid "Slowly pulses the LEDs as a prespecified color." +msgstr "LED ęŒ‰ē…§é¢„č®¾é¢œč‰²ē¼“ę…¢å‘¼åø" + +#. Setting: Rainbow +#. Field: title +msgid "Rainbow" +msgstr "彩虹" + +#. Setting: Rainbow +#. Field: hint +msgid "Cycles through the different colors." +msgstr "å¾ŖēŽÆę˜¾ē¤ŗäøåŒé¢œč‰²" + +#. Setting: Mouse Mode Mapping +#. Field: title +msgid "Mouse Mode Mapping" +msgstr "é¼ ę ‡ęØ”å¼ę˜ å°„" + +#. Setting: Mouse Mode Mapping +#. Option: unchanged +#. Setting: Mouse Mode Triggers +#. Setting: L4/R4 Mapping +#. Setting: Do not change +#. Field: title +msgid "Do not change" +msgstr "äøę›“ę”¹" + +#. Setting: Mouse Mode Mapping +#. Option: mouse +msgid "GPD Mouse Mode" +msgstr "GPD é¼ ę ‡ęØ”å¼" + +#. Setting: Mouse Mode Mapping +#. Option: wasd +msgid "For Games" +msgstr "ē”ØäŗŽęøøęˆ" + +#. Setting: Mouse Mode Triggers +#. Field: title +msgid "Mouse Mode Triggers" +msgstr "é¼ ę ‡ęØ”å¼č§¦å‘é”®" + +#. Setting: Mouse Mode Triggers +#. Option: gpd +msgid "GPD (RT is Fast Mouse)" +msgstr "GPD (RT äøŗé¼ ę ‡ē§»åŠØåŠ é€Ÿ)" + +#. Setting: Mouse Mode Triggers +#. Option: steamos +msgid "SteamOS (LT/RT are R/L Clicks)" +msgstr "SteamOS (LT/RT äøŗ é¼ ę ‡å³é”®/左键)" + +#. Setting: L4/R4 Mapping +#. Field: title +msgid "L4/R4 Mapping" +msgstr "L4/R4 ę˜ å°„" + +#. Setting: L4/R4 Mapping +#. Option: hhd +msgid "For HHD (F20/F21)" +msgstr "ē”ØäŗŽ HHD (F20/F21)" + +#. Setting: L4/R4 Mapping +#. Option: default +msgid "Default (Pause/PrntScr)" +msgstr "默认 (Pause/PrntScr)" + +#. Setting: Deadzones +#. Field: title +msgid "Deadzones" +msgstr "死区" + +#. Setting: Do not change +#. Field: hint +msgid "Do not change the deadzones." +msgstr "äøę”¹å˜ę­»åŒŗ" + +#. Setting: Set Deadzones +#. Field: title +msgid "Set Deadzones" +msgstr "设置死区" + +#. Setting: Set Deadzones +#. Field: hint +msgid "Use custom deadzones." +msgstr "ä½æē”Øč‡Ŗå®šä¹‰ę­»åŒŗ" + +#. Setting: Left Stick Center +#. Field: title +msgid "Left Stick Center" +msgstr "å·¦ę‘‡ę†äø­åæƒå€¼" + +#. Setting: Left Stick Boundary +#. Field: title +msgid "Left Stick Boundary" +msgstr "å·¦ę‘‡ę†č¾¹ē•Œå€¼" + +#. Setting: Right Stick Center +#. Field: title +msgid "Right Stick Center" +msgstr "å³ę‘‡ę†äø­åæƒå€¼" + +#. Setting: Right Stick Boundary +#. Field: title +msgid "Right Stick Boundary" +msgstr "å³ę‘‡ę†č¾¹ē•Œå€¼" + +#. Setting: Vibration Strength +#. Field: title +msgid "Vibration Strength" +msgstr "éœ‡åŠØå¼ŗåŗ¦" + +#. Setting: Vibration Strength +#. Option: medium +#. Setting: Brightness +#. Setting: Speed +msgid "Medium" +msgstr "äø­" + +#. Setting: Vibration Strength +#. Option: high +#. Setting: Brightness +#. Setting: Speed +msgid "High" +msgstr "高" + +#. Setting: Firmware +#. Field: title +msgid "Firmware" +msgstr "固件" + +#. Setting: Error +#. Field: title +msgid "Error" +msgstr "错误" + +#. Setting: Apply Settings +#. Field: title +msgid "Apply Settings" +msgstr "应用设置" + +#. Setting: Legion Controller +#. Field: title +msgid "Legion Controller" +msgstr "Legion ęŽ§åˆ¶å™Ø" + +#. Setting: Legion Controller +#. Field: hint +msgid "Configure the Legion Controller emulation modes." +msgstr "é…ē½® Legion ęŽ§åˆ¶å™ØęØ”ę‹ŸęØ”å¼" + +#. Setting: Emulation Mode (X-Input) +#. Field: title +msgid "Emulation Mode (X-Input)" +msgstr "ęØ”ę‹ŸęØ”å¼ (X-Input)" + +#. Setting: Emulation Mode (X-Input) +#. Field: hint +msgid "Emulate different controller types when in X-Input mode." +msgstr "当 Legion ęŽ§åˆ¶å™Øå¤„äŗŽ X-Input ęØ”å¼ę—¶ęØ”ę‹ŸäøåŒē±»åž‹ēš„ęŽ§åˆ¶å™Ø" + +#. Setting: Motion Support +#. Field: hint +msgid "Enable gyroscope/accelerometer (IMU) support" +msgstr "åÆē”Øé™€čžŗä»Ŗ/åŠ é€Ÿåŗ¦č®” (IMU) ę”ÆęŒ" + +#. Setting: Swap Legion with Menu/View +#. Field: title +msgid "Swap Legion with Menu/View" +msgstr "äŗ¤ę¢ Legion Menu/View ęŒ‰é’®" + +#. Setting: Enable Shortcuts Controller +#. Field: title +msgid "Enable Shortcuts Controller" +msgstr "åÆē”Øåæ«ę·ęŽ§åˆ¶å™Ø" + +#. Setting: Enable Shortcuts Controller +#. Field: hint +msgid "When in dinput mode, enable a controller for shortcuts." +msgstr "å½“å¤„äŗŽ dinput ęØ”å¼ę—¶, åÆē”Øäø€äøŖęŽ§åˆ¶å™Øē”ØäŗŽåæ«ę·č®¾ē½®" + +#. Setting: Reset Controller +#. Field: title +msgid "Reset Controller" +msgstr "é‡ē½®ęŽ§åˆ¶å™Ø" + +#. Setting: Reset Controller +#. Field: hint +msgid "Resets the controller to stock settings." +msgstr "å°†ęŽ§åˆ¶å™Øé‡ē½®äøŗé»˜č®¤č®¾ē½®" + +#. Setting: Legion Controllers +#. Field: title +msgid "Legion Controllers" +msgstr "Legion ęŽ§åˆ¶å™Ø" + +#. Setting: Legion Controllers +#. Field: hint +msgid "" +"Allows for configuring the Legion controllers using the built in firmware" +" commands and enabling emulation modes for various controller types." +msgstr "é€ščæ‡å†…ē½®å›ŗä»¶å‘½ä»¤é…ē½® Legion ęŽ§åˆ¶å™Øļ¼ŒåÆä»„åÆē”ØäøåŒē±»åž‹ēš„ęŽ§åˆ¶å™ØęØ”ę‹ŸęØ”å¼" + +#. Setting: Emulation Mode (X-Input) +#. Field: hint +msgid "" +"Emulate different controller types when the Legion Controllers are in " +"X-Input mode." +msgstr "当 Legion ęŽ§åˆ¶å™Øå¤„äŗŽ X-Input ęØ”å¼ę—¶ęØ”ę‹ŸäøåŒē±»åž‹ēš„ęŽ§åˆ¶å™Ø" + +#. Setting: Controller Motions Device +#. Field: title +msgid "Controller Motions Device" +msgstr "ęŽ§åˆ¶å™Øä½“ę„Ÿč®¾å¤‡" + +#. Setting: Left Controller +#. Field: title +msgid "Left Controller" +msgstr "å·¦ęŽ§åˆ¶å™Ø" + +#. Setting: Right Controller +#. Field: title +msgid "Right Controller" +msgstr "å³ęŽ§åˆ¶å™Ø" + +#. Setting: Both Controllers +#. Field: title +msgid "Both Controllers" +msgstr "åŒęŽ§åˆ¶å™Ø" + +#. Setting: Both Controllers +#. Field: hint +msgid "" +"The main controller uses the right controller's motion sensor, and a " +"secondary controller is created for the left controller's motion sensor." +msgstr "äø»ęŽ§åˆ¶å™Øä¼šä½æē”Øå³ę‰‹ęŸ„ēš„åŠØä½œä¼ ę„Ÿå™Øļ¼ŒåŒę—¶äøŗå·¦ę‰‹ęŸ„ēš„åŠØä½œä¼ ę„Ÿå™Øåˆ›å»ŗäø€äøŖē‹¬ē«‹ēš„č¾…åŠ©ęŽ§åˆ¶å™Øć€‚" + +#. Setting: M2 As Xbox Share/Dualsense Mic Mute +#. Field: title +msgid "M2 As Xbox Share/Dualsense Mic Mute" +msgstr "M2 ę˜ å°„äøŗ Xbox Share/Dualsense éŗ¦å…‹é£Žé™éŸ³ęŒ‰é’®" + +#. Setting: M2 As Xbox Share/Dualsense Mic Mute +#. Field: hint +msgid "" +"Maps the M2 to the mute button on Dualsense and the share button on the " +"Xbox Elite controller." +msgstr "将 M2 ę˜ å°„äøŗ Dualsense ēš„é™éŸ³ęŒ‰é’®å’Œ Xbox Elite ęŽ§åˆ¶å™Øēš„å…±äŗ«ęŒ‰é’®" + +#. Setting: Enable Shortcuts Controller +#. Field: hint +msgid "" +"When in other modes (dinput, dual dinput, and fps), enable a shortcuts " +"controller to restore Guide, QAM, and shortcut functionality." +msgstr "åœØå…¶ä»–ęØ”å¼ (dinputć€åŒ dinput 和 fps)äø­, åÆē”Øåæ«ę·ęŽ§åˆ¶å™Øä»„ę¢å¤ Guide态QAM å’Œåæ«ę·åŠŸčƒ½" + +#. Setting: Factory Reset Controllers +#. Field: title +msgid "Factory Reset Controllers" +msgstr "ę¢å¤é»˜č®¤č®¾ē½®" + +#. Setting: Factory Reset Controllers +#. Field: hint +msgid "Resets the controllers to factory settings." +msgstr "å°†ęŽ§åˆ¶å™Øé‡ē½®äøŗé»˜č®¤č®¾ē½®" + +#. Setting: Orange Pi Neo +#. Field: title +msgid "Orange Pi Neo" +msgstr "香橙擾 Neo" + +#. Setting: OneXPlayer Controller +#. Field: title +msgid "OneXPlayer Controller" +msgstr "OneXPlayer ęŽ§åˆ¶å™Ø" + +#. Setting: Keyboard and Turbo buttons are: +#. Field: title +msgid "Keyboard and Turbo buttons are:" +msgstr "é”®ē›˜å’Œ Turbo ęŒ‰é’®ē”Øä½œ:" + +#. Setting: Keyboard and Turbo buttons are: +#. Option: oem +msgid "Keyboard, Combo" +msgstr "é”®ē›˜, ē»„åˆé”®" + +#. Setting: Keyboard and Turbo buttons are: +#. Option: separate +msgid "Steam Menu, HHD" +msgstr "Steam čœå•, HHD" + +#. Setting: Keyboard and Turbo buttons are: +#. Option: combo_hhd +msgid "Combo, HHD" +msgstr "ē»„åˆé”®, HHD" + +#. Setting: Keyboard and Turbo buttons are: +#. Option: combo +msgid "Combo, Combo" +msgstr "ē»„åˆé”®, ē»„åˆé”®" + +#. Setting: Swap View/Menu and Xbox/Kbd +#. Field: title +msgid "Swap View/Menu and Xbox/Kbd" +msgstr "äŗ¤ę¢ View/Menu 和 Xbox/Kbd" + +#. Setting: Holding Turbo Reboots +#. Field: title +msgid "Holding Turbo Reboots" +msgstr "ęŒ‰ä½ Turbo é”®é‡åÆ" + +#. Setting: Reverse Volume Buttons +#. Field: title +msgid "Reverse Volume Buttons" +msgstr "åč½¬éŸ³é‡ęŒ‰é’®" + +#. Setting: Reverse Volume Buttons +#. Field: hint +msgid "Reverse the volume buttons of the X1 style devices to match other tablets." +msgstr "åč½¬ X1 č®¾å¤‡ēš„éŸ³é‡ęŒ‰é’®, ä»„åŒ¹é…å…¶ä»–å¹³ęæč®¾å¤‡" + +#. Setting: Ally Controller +#. Field: title +msgid "Ally Controller" +msgstr "Ally ęŽ§åˆ¶å™Ø" + +#. Setting: Ally Controller +#. Field: hint +msgid "Allows for configuring the ROG Ally controllers to a unified output." +msgstr "åÆä»„é…ē½®ę‚Øēš„ ROG Ally ęŽ§åˆ¶å™Øäøŗē»Ÿäø€č®¾å¤‡č¾“å‡ŗ" + +#. Setting: Controller Emulation +#. Field: hint +msgid "Emulate different controller types to fuse ROG features." +msgstr "ęØ”ę‹ŸäøåŒē±»åž‹ēš„ęŽ§åˆ¶å™Ø, ä»„čžåˆ ROG č®¾å¤‡ēš„ē‰¹ę€§" + +#. Setting: Swap ROG and Menu/View +#. Field: title +msgid "Swap ROG and Menu/View" +msgstr "äŗ¤ę¢ ROG Menu/View ęŒ‰é’®" + +#. Setting: Swap ROG and Menu/View +#. Field: hint +msgid "Swaps the Armory Crate and Command center buttons with start and select." +msgstr "äŗ¤ę¢ Armory Crate/Command center äøŽ start/select ęŒ‰é’®" + +#. Setting: RGB During Boot +#. Field: title +msgid "RGB During Boot" +msgstr "åÆåŠØę—¶ēš„ RGB ēÆę•ˆ" + +#. Setting: RGB During Charging Asleep +#. Field: title +msgid "RGB During Charging Asleep" +msgstr "å……ē”µę—¶ä¼‘ēœ ēŠ¶ę€äø‹ēš„ RGB ēÆę•ˆ" + +#. Setting: Motion Axis +#. Field: title +msgid "Motion Axis" +msgstr "ä½“ę„Ÿč½“" + +#. Setting: Default +#. Field: hint +msgid "The default axis loaded for this device." +msgstr "ęœ¬č®¾å¤‡é»˜č®¤č½“č®¾ē½®" + +#. Setting: Override +#. Field: title +msgid "Override" +msgstr "覆盖" + +#. Setting: Override +#. Field: hint +msgid "" +"Remap and invert the axis of your device. If the axis of your device are " +"wrong, please submit a picture or a text version of the following." +msgstr "é‡ę–°ę˜ å°„å’Œåč½¬č®¾å¤‡ēš„č½“ć€‚å¦‚ęžœč®¾å¤‡č½“é”™čÆÆ, čÆ·ęäŗ¤ä»„äø‹é€‰é”¹ēš„å›¾ē‰‡ęˆ–ę–‡ęœ¬ē‰ˆęœ¬" + +#. Setting: Manufacturer +#. Field: title +msgid "Manufacturer" +msgstr "制造商" + +#. Setting: Product +#. Field: title +msgid "Product" +msgstr "产品" + +#. Setting: Axis X +#. Field: title +msgid "Axis X" +msgstr "X 轓" + +#. Setting: Axis X +#. Option: x +#. Setting: Axis Y +#. Setting: Axis Z +msgid "X" +msgstr "" + +#. Setting: Axis X +#. Option: y +#. Setting: Axis Y +#. Setting: Axis Z +msgid "Y" +msgstr "" + +#. Setting: Axis X +#. Option: z +#. Setting: Axis Y +#. Setting: Axis Z +msgid "Z" +msgstr "" + +#. Setting: Invert X +#. Field: title +msgid "Invert X" +msgstr "X č½“åč½¬" + +#. Setting: Axis Y +#. Field: title +msgid "Axis Y" +msgstr "Y 轓" + +#. Setting: Invert Y +#. Field: title +msgid "Invert Y" +msgstr "Y č½“åč½¬" + +#. Setting: Axis Z +#. Field: title +msgid "Axis Z" +msgstr "Z 轓" + +#. Setting: Invert Z +#. Field: title +msgid "Invert Z" +msgstr "Z č½“åč½¬" + +#. Setting: Deadzones & Vibration +#. Field: title +msgid "Deadzones & Vibration" +msgstr "ę­»åŒŗå’Œéœ‡åŠØ" + +#. Setting: Deadzones & Vibration +#. Field: hint +msgid "Configure joystick and trigger deadzones, vibration intensity." +msgstr "é…ē½®ę‘‡ę†å’Œę‰³ęœŗę­»åŒŗ, éœ‡åŠØå¼ŗåŗ¦" + +#. Setting: Default +#. Field: hint +msgid "Uses reasonable values based on hardware." +msgstr "ę ¹ę®ē”¬ä»¶č‡ŖåŠØé€‰ę‹©åˆé€‚ēš„å€¼" + +#. Setting: Manual +#. Field: title +msgid "Manual" +msgstr "ę‰‹åŠØ" + +#. Setting: Manual +#. Field: hint +msgid "Allows for manual configuration of deadzones and vibration intensity." +msgstr "å…č®øę‰‹åŠØé…ē½®ę­»åŒŗå’Œéœ‡åŠØå¼ŗåŗ¦" + +#. Setting: Vibration Intensity +#. Field: title +msgid "Vibration Intensity" +msgstr "éœ‡åŠØå¼ŗåŗ¦" + +#. Setting: Vibration Intensity +#. Field: hint +msgid "" +"Intensity of the vibration. The higher the value, the stronger the " +"vibration." +msgstr "éœ‡åŠØå¼ŗåŗ¦ć€‚å€¼č¶Šé«˜, éœ‡åŠØč¶Šå¼ŗ" + +#. Setting: Left Stick Minimum +#. Field: title +msgid "Left Stick Minimum" +msgstr "å·¦ę‘‡ę†ęœ€å°å€¼" + +#. Setting: Left Stick Minimum +#. Field: hint +#. Setting: Right Stick Minimum +#. Setting: Left Trigger Minimum +#. Setting: Right Trigger Minimum +msgid "" +"Deadzone for the joystick. The higher the value, the more the joystick " +"needs to be moved before registering." +msgstr "ę‘‡ę†ę­»åŒŗå€¼ć€‚ę•°å€¼č¶Šå¤§ļ¼Œéœ€č¦ę›“å¤§ēš„ę‘‡ę†åē§»ę‰ä¼šå¼€å§‹å“åŗ”" + +#. Setting: Left Stick Maximum +#. Field: title +msgid "Left Stick Maximum" +msgstr "å·¦ę‘‡ę†ęœ€å¤§å€¼" + +#. Setting: Left Stick Maximum +#. Field: hint +#. Setting: Right Stick Maximum +#. Setting: Left Trigger Maximum +#. Setting: Right Trigger Maximum +msgid "" +"Maximum value for joystick. The higher the value, the more the joystick " +"needs to be moved before reaching maximum." +msgstr "ę‘‡ę†ęœ€å¤§é˜ˆå€¼ć€‚ę•°å€¼č¶Šå¤§ļ¼Œéœ€č¦ę›“å¤§ēš„ę‘‡ę†åē§»ę‰ä¼šč¾¾åˆ°ęœ€å¤§č¾“å‡ŗ" + +#. Setting: Right Stick Minimum +#. Field: title +msgid "Right Stick Minimum" +msgstr "å³ę‘‡ę†ęœ€å°å€¼" + +#. Setting: Right Stick Maximum +#. Field: title +msgid "Right Stick Maximum" +msgstr "å³ę‘‡ę†ęœ€å¤§å€¼" + +#. Setting: Left Trigger Minimum +#. Field: title +msgid "Left Trigger Minimum" +msgstr "å·¦ę‰³ęœŗęœ€å°å€¼" + +#. Setting: Left Trigger Maximum +#. Field: title +msgid "Left Trigger Maximum" +msgstr "å·¦ę‰³ęœŗęœ€å¤§å€¼" + +#. Setting: Right Trigger Minimum +#. Field: title +msgid "Right Trigger Minimum" +msgstr "å³ę‰³ęœŗęœ€å°å€¼" + +#. Setting: Right Trigger Maximum +#. Field: title +msgid "Right Trigger Maximum" +msgstr "å³ę‰³ęœŗęœ€å¤§å€¼" + +#. Setting: Reset to Default +#. Field: title +msgid "Reset to Default" +msgstr "é‡ē½®äøŗé»˜č®¤" + +#. Setting: Reset to Default +#. Field: hint +msgid "Reset all values to default." +msgstr "å°†ę‰€ęœ‰å€¼é‡ē½®äøŗé»˜č®¤" + +#. Setting: Hidden +#. Field: title +msgid "Hidden" +msgstr "隐藏" + +#. Setting: Hidden +#. Field: hint +msgid "" +"Disables the controller. Handheld Daemon overlay will still work in " +"gamemode." +msgstr "ē¦ē”ØęŽ§åˆ¶å™Øć€‚Handheld Daemon å åŠ ä»ē„¶åÆä»„åœØęøøęˆęØ”å¼äø‹å·„ä½œ" + +#. Setting: Extra buttons as Keyboard/Overlay +#. Field: title +msgid "Extra buttons as Keyboard/Overlay" +msgstr "é¢å¤–ęŒ‰é’®ä½œäøŗé”®ē›˜/å åŠ čœå•åæ«ę·é”®" + +#. Setting: Extra buttons as Keyboard/Overlay +#. Field: hint +msgid "" +"Makes the left paddle bring up a keyboard and the right paddle bring up " +"the overlay." +msgstr "ä½æē”Øå·¦čƒŒé”®å‘¼å‡ŗé”®ē›˜ļ¼Œå³čƒŒé”®å‘¼å‡ŗčœå•å åŠ å±‚" + +#. Setting: Xbox +#. Field: title +msgid "Xbox" +msgstr "" + +#. Setting: Extra buttons as +#. Field: title +msgid "Extra buttons as" +msgstr "é¢å¤–ęŒ‰é’®ä½œäøŗ" + +#. Setting: Extra buttons as +#. Field: hint +msgid "" +"Changes the behavior of the extra buttons. Left button is Keyboard, right" +" button is Overlay. Or they can be set for Steam Input." +msgstr "č®¾ē½®é¢å¤–ęŒ‰é’®ēš„åŠŸčƒ½ć€‚åÆä»„å°†å·¦é”®č®¾äøŗå‘¼å‡ŗé”®ē›˜ļ¼Œå³é”®č®¾äøŗå‘¼å‡ŗčœå•å åŠ å±‚ć€‚ęˆ–č€…åÆä»„č®¾ē½®äøŗ Steam 输兄" + +#. Setting: Extra buttons as +#. Option: steam_input +msgid "Steam Input (Elite)" +msgstr "Steam 输兄 (Elite)" + +#. Setting: Extra buttons as +#. Option: noob +msgid "Keyboard/Overlay" +msgstr "é”®ē›˜/叠加层" + +#. Setting: Nintendo QAM Fix +#. Field: title +msgid "Nintendo QAM Fix" +msgstr "任天堂 QAM äæ®å¤" + +#. Setting: Xbox Elite +#. Field: title +msgid "Xbox Elite" +msgstr "Xbox ē²¾č‹±ę‰‹ęŸ„" + +#. Setting: Steam Controller +#. Field: title +msgid "Steam Controller" +msgstr "Steam ęŽ§åˆ¶å™Ø" + +#. Setting: Steam Controller +#. Field: hint +msgid "Allows for gyro, paddles, and has a proper QAM button." +msgstr "ę”ÆęŒé™€čžŗä»Ŗå’ŒčƒŒé”®åŠŸčƒ½ļ¼Œå¹¶ęä¾›äø“é—Øēš„åæ«é€Ÿč®æé—®čœå•(QAM)ęŒ‰é’®" + +#. Setting: Invert Roll Axis +#. Field: title +msgid "Invert Roll Axis" +msgstr "åč½¬ę»šåŠØč½“" + +#. Setting: Invert Roll Axis +#. Field: hint +msgid "" +"Inverts the roll (Z) axis compared to a real Horipad controller. Useful " +"for Steam Input, since you want it to be inverted to look left to right, " +"but an issue in emulators." +msgstr "" +"äøŽēœŸå®ž Dualsense ęŽ§åˆ¶å™Øē›øęÆ”, åč½¬ę»šåŠØ (Z) č½“ć€‚åÆ¹äŗŽ Steam č¾“å…„å¾ˆęœ‰ē”Ø, å› äøŗę‚ØåÆčƒ½åøŒęœ›å®ƒåč½¬ä»„ä¾æä»Žå·¦åˆ°å³ēœ‹, " +"ä½†åœØęØ”ę‹Ÿå™Øäø­ä¼šęœ‰é—®é¢˜" + +#. Setting: Dualsense +#. Field: title +msgid "Dualsense" +msgstr "" + +#. Setting: Extra buttons as +#. Field: hint +msgid "" +"Changes the behavior of the extra buttons. Left button is Keyboard, right" +" button is Overlay. Or they can be left/right touchpad clicks. For the " +"legion go, top buttons are shortcuts, bottom are touchpad clicks." +msgstr "" +"č®¾ē½®é¢å¤–ęŒ‰é’®ēš„åŠŸčƒ½ć€‚åÆä»„å°†å·¦é”®č®¾äøŗå‘¼å‡ŗé”®ē›˜ļ¼Œå³é”®č®¾äøŗå‘¼å‡ŗčœå•å åŠ å±‚ļ¼›" +"ęˆ–č€…å°†å®ƒä»¬č®¾ē½®äøŗč§¦ę‘øęæēš„å·¦å³ē‚¹å‡»ć€‚åœØ Legion Go äøŠļ¼Œé”¶éƒØęŒ‰é’®ē”Øä½œåæ«ę·é”®ļ¼Œåŗ•éƒØęŒ‰é’®ē”Øä½œč§¦ę‘øęæē‚¹å‡»" + +#. Setting: Extra buttons as +#. Option: steam_input +msgid "Steam Input (Edge)" +msgstr "Steam 输兄 (Edge)" + +#. Setting: Extra buttons as +#. Option: touchpad +msgid "Touchpad Clicks" +msgstr "č§¦ę‘øęæē‚¹å‡»" + +#. Setting: Extra buttons as +#. Option: both +msgid "Shortcuts + Touchpad Clicks" +msgstr "åæ«ę·é”® + č§¦ę‘øęæē‚¹å‡»" + +#. Setting: LED Support +#. Field: title +msgid "LED Support" +msgstr "LED ę”ÆęŒ" + +#. Setting: LED Support +#. Field: hint +msgid "" +"Passes through the LEDs to the controller, which allows games to control " +"them." +msgstr "å°†č®¾å¤‡ēš„ LED ēÆå…‰ē›“ęŽ„ę˜ å°„åˆ°ęŽ§åˆ¶å™Øļ¼Œč®©ęøøęˆå’Œ Steam åÆä»„ē›“ęŽ„ęŽ§åˆ¶ēÆå…‰ę•ˆęžœ" + +#. Setting: Gyro Output Sync +#. Field: title +msgid "Gyro Output Sync" +msgstr "é™€čžŗä»Ŗč¾“å‡ŗåŒę­„" + +#. Setting: Gyro Output Sync +#. Field: hint +msgid "" +"Steam relies on the IMU timestamp for the touchpad as Mouse and `Gyro to " +"Mouse [BETA]`. If the same timestamp is sent in 2 reports, this causes a " +"division by 0 and instability. This option makes it so reports are sent " +"only when there is a new IMU timestamp, effectively limiting the " +"responsiveness of the controller to that of the IMU. This only makes a " +"difference for the Legion Go (125hz), as all the other handhelds are " +"using 400hz by default." +msgstr "" +"Steam åœØå¤„ē†č§¦ę‘øęæé¼ ę ‡å’Œ`é™€čžŗä»Ŗåˆ°é¼ ę ‡ [BETA]`åŠŸčƒ½ę—¶ä¾čµ– IMU ę—¶é—“ęˆ³ć€‚" +"å¦‚ęžœåœØ 2 äøŖęŠ„å‘Šäø­å‘é€ē›øåŒēš„ę—¶é—“ęˆ³, čæ™ä¼šåÆ¼č‡“é™¤ä»„ 0 é€ ęˆäøēØ³å®šć€‚" +"ę­¤é€‰é”¹ä½æęŠ„å‘Šä»…åœØęœ‰ę–°ēš„ IMU ę—¶é—“ęˆ³ę—¶å‘é€, ęœ‰ę•ˆåœ°å°†ęŽ§åˆ¶å™Øēš„å“åŗ”é€Ÿåŗ¦é™åˆ¶äøŗ IMU ēš„é€Ÿåŗ¦ć€‚" +"čæ™åŖåÆ¹ Legion Go (125hz) ęœ‰å½±å“, å› äøŗę‰€ęœ‰å…¶ä»–ęŽŒęœŗé»˜č®¤ä½æē”Ø 400hz" + +#. Setting: Invert Roll Axis +#. Field: hint +msgid "" +"Inverts the roll (Z) axis compared to a real Dualsense controller. Useful" +" for Steam Input, since you want it to be inverted to look left to right," +" but an issue in emulators." +msgstr "" +"ē›øåÆ¹äŗŽēœŸå®žēš„ DualSense ęŽ§åˆ¶å™Øåč½¬ę»šåŠØč½“ļ¼ˆZč½“ļ¼‰ć€‚åœØ Steam č¾“å…„äø­čæ™å¾ˆęœ‰ē”Øļ¼Œ" +"å› äøŗåč½¬åŽę›“ē¬¦åˆå·¦å³č§†č§’ē§»åŠØēš„ä¹ ęƒÆļ¼Œä½†åœØęØ”ę‹Ÿå™Øäø­åÆčƒ½ä¼šé€ ęˆé—®é¢˜" + +#. Setting: Bluetooth Mode +#. Field: title +msgid "Bluetooth Mode" +msgstr "č“ē‰™ęØ”å¼" + +#. Setting: Bluetooth Mode +#. Field: hint +msgid "" +"Emulates the controller in bluetooth mode instead of USB mode. This is " +"the default as it causes less issues with how apps interact with the " +"controller. However, using USB mode can improve LED support (?) in some " +"games. Test and report back!" +msgstr "" +"å°†ęŽ§åˆ¶å™ØęØ”ę‹Ÿäøŗč“ē‰™ęØ”å¼č€Œéž USB ęØ”å¼ć€‚é»˜č®¤ä½æē”Øč“ē‰™ęØ”å¼ę˜Æå› äøŗčæ™ę ·åÆä»„å‡å°‘åŗ”ē”ØēØ‹åŗäøŽęŽ§åˆ¶å™Øäŗ¤äŗ’ę—¶ēš„é—®é¢˜ć€‚" +"äøčæ‡åœØęŸäŗ›ęøøęˆäø­ļ¼ŒUSB ęØ”å¼åÆčƒ½ä¼šåø¦ę„ę›“å„½ēš„ LED ēÆå…‰ę”ÆęŒć€‚ę¬¢čæŽęµ‹čÆ•å¹¶åé¦ˆļ¼" + +#. Setting: Paused +#. Field: title +msgid "Paused" +msgstr "ęš‚åœ" + +#. Setting: Touchpad Emulation +#. Field: title +msgid "Touchpad Emulation" +msgstr "č§¦ę‘øęæęØ”ę‹Ÿ" + +#. Setting: Touchpad Emulation +#. Field: hint +msgid "" +"Use an emulated touchpad. Part of the controller if it is supported " +"(e.g., Dualsense) or a virtual one if not." +msgstr "" +"åÆē”Øč§¦ę‘øęæęØ”ę‹ŸåŠŸčƒ½ć€‚åÆ¹äŗŽę”ÆęŒč§¦ę‘øęæēš„ē›®ę ‡ęŽ§åˆ¶å™Ø(如 DualSense), ä¼šē›“ęŽ„ē”ØäŗŽå…¶å†…ē½®č§¦ę‘øęæ; " +"åÆ¹äŗŽäøę”ÆęŒēš„ē›®ę ‡č®¾å¤‡, å°†ä¼šåˆ›å»ŗäø€äøŖč™šę‹Ÿč§¦ę‘øęæ" + +#. Setting: Disabled +#. Field: hint +msgid "" +"Does not modify the touchpad. Short + holding presses will not work " +"within gamescope." +msgstr "äæęŒč§¦ę‘øęæåŽŸå§‹č®¾ē½®ć€‚åœØ GameScope ēŽÆå¢ƒäø‹ēŸ­ęŒ‰å’Œé•æęŒ‰åŠŸčƒ½å°†ę— ę³•ä½æē”Ø" + +#. Setting: Virtual +#. Field: title +msgid "Virtual" +msgstr "č™šę‹Ÿč§¦ę‘øęæ" + +#. Setting: Virtual +#. Field: hint +msgid "" +"Adds an emulated touchpad. This touchpad is meant for use in gamescope " +"and has left, right click support by default. However, it causes issues " +"in desktop mode, and it doesnt allow dragging files. Therefore, it will " +"autodisable in desktop." +msgstr "" +"ę·»åŠ äø€äøŖč™šę‹Ÿč§¦ę‘øęæć€‚čæ™äøŖč§¦ę‘øęæäø»č¦ē”ØäŗŽ GameScope ēŽÆå¢ƒļ¼Œå¹¶äø”é»˜č®¤ę”ÆęŒå·¦å³é”®ē‚¹å‡»ć€‚ä½†åÆčƒ½ä¼šåœØę”Œé¢ęØ”å¼äø­å¼•čµ·é—®é¢˜ļ¼Œ" +"å¹¶äø”äøå…č®øę‹–åŠØę–‡ä»¶ć€‚å› ę­¤, å®ƒä¼šåœØę”Œé¢ęØ”å¼äø­č‡ŖåŠØē¦ē”Ø" + +#. Setting: Disable on Desktop +#. Field: title +msgid "Disable on Desktop" +msgstr "åœØę”Œé¢ęØ”å¼ē¦ē”Ø" + +#. Setting: Disable on Desktop +#. Field: hint +msgid "" +"Touchpad emulation will automatically be disabled when not in gamemode. " +"Specifically, steam will be periodically be checked to be running in " +"gamepad mode and if not, touchpad emulation will be disabled." +msgstr "äøå¤„äŗŽęøøęˆęØ”å¼ę—¶ä¼šč‡ŖåŠØåœē”Øč§¦ę‘øęæęØ”ę‹Ÿć€‚ē³»ē»Ÿä¼šå®šęœŸę£€ęŸ„ Steam ę˜Æå¦čæč”ŒåœØę‰‹ęŸ„ęØ”å¼ļ¼Œå¦‚ęžœäøę˜Æåˆ™ä¼šåœē”Øč§¦ę‘øęæęØ”ę‹Ÿ" + +#. Setting: Short Action +#. Field: title +msgid "Short Action" +msgstr "ēŸ­ęŒ‰åŠØä½œ" + +#. Setting: Short Action +#. Field: hint +msgid "Maps short touches (less than 0.2s) to a virtual touchpad button." +msgstr "å°†ēŸ­č§¦ę‘ø (å°äŗŽ 0.2s)ę˜ å°„äøŗč™šę‹Ÿč§¦ę‘øęæęŒ‰é’®" + +#. Setting: Short Action +#. Option: left_click +#. Setting: Hold Action +msgid "Left Click" +msgstr "点击左侧" + +#. Setting: Short Action +#. Option: right_click +#. Setting: Hold Action +msgid "Right Click" +msgstr "ē‚¹å‡»å³ä¾§" + +#. Setting: Hold Action +#. Field: title +msgid "Hold Action" +msgstr "é•æęŒ‰åŠØä½œ" + +#. Setting: Hold Action +#. Field: hint +msgid "Maps long touches (more than 2s) to a virtual touchpad button." +msgstr "将长触摸 (å¤§äŗŽ 2s)ę˜ å°„äøŗč™šę‹Ÿč§¦ę‘øęæęŒ‰é’®" + +#. Setting: Controller +#. Field: hint +msgid "" +"Uses the touchpad of the emulated controller (if it exists). Otherwise, " +"the touchpad remains unmapped (will still show up in the system). Meant " +"to be used as steam input, so short press is unassigned by default and " +"long press simulates trackpad click." +msgstr "" +"ä½æē”ØęØ”ę‹ŸęŽ§åˆ¶å™Øēš„č§¦ę‘øęæ (å¦‚ęžœå­˜åœØ)ć€‚å¦åˆ™, č§¦ę‘øęæäæęŒęœŖę˜ å°„ (ä»ä¼šę˜¾ē¤ŗåœØē³»ē»Ÿäø­)ć€‚ē”Øä½œ Steam 输兄, å› ę­¤é»˜č®¤ęƒ…å†µäø‹ēŸ­ęŒ‰ęœŖåˆ†é…, " +"é•æęŒ‰ęØ”ę‹Ÿč§¦ę‘øęæē‚¹å‡»" + +#. Setting: Location +#. Field: title +msgid "Location" +msgstr "ä½ē½®" + +#. Setting: Location +#. Field: hint +msgid "" +"Controls the placement of the real touchpad to the virtual one, using " +"what steam expects. In Steam, the \"Left\" touchpad maps to the left " +"half, the \"Right\" touchpad maps to the right half, and \"Center\" maps " +"to the whole touchpad. Therefore, the virtual touchpad is cropped to the " +"left side for left, the right side for right, and expanded in the center " +"for center. This means when set to center, half of the left touchpad is " +"left and half of the right is right. \"Stretch\" stretches the touchpad " +"to the whole dualsense surface." +msgstr "" +"é…ē½®ēœŸå®žč§¦ęŽ§ęæäøŽč™šę‹Ÿč§¦ęŽ§ęæēš„ä½ē½®å…³ē³»ļ¼Œä»„é€‚é… Steam č®¾ē½®ć€‚åœØ Steam 中,\"å·¦\" å°†č§¦ęŽ§ęæę˜ å°„åˆ°å·¦åŠč¾¹ļ¼Œ" +"\"右\" å°†č§¦ęŽ§ęæę˜ å°„åˆ°å³åŠč¾¹ļ¼Œč€Œ \"å±…äø­\" å°†ę˜ å°„åˆ°ę•“äøŖč§¦ęŽ§ęæć€‚" +"å› ę­¤ļ¼Œč™šę‹Ÿč§¦ę‘øęæč¢«č£å‰Ŗäøŗå·¦ä¾§ē”ØäŗŽå·¦č¾¹ļ¼Œå³ä¾§ē”ØäŗŽå³č¾¹ļ¼Œå¹¶åœØäø­åæƒę‰©å±•ć€‚čæ™ę„å‘³ē€å½“č®¾ē½®äøŗ\"å±…äø­\"ę—¶ļ¼Œå·¦č§¦ęŽ§ęæēš„äø€åŠę˜Æå·¦ä¾§ļ¼Œå³č§¦ęŽ§ęæēš„äø€åŠę˜Æå³ä¾§ć€‚\"拉伸\"" +" å°†č§¦ęŽ§ęæę‹‰ä¼øåˆ°ę•“äøŖDualsenseč™šę‹Ÿč§¦ęŽ§ęæć€‚" + +#. Setting: Location +#. Option: right +#. Setting: Direction +msgid "Right" +msgstr "右" + +#. Setting: Location +#. Option: center +msgid "Center" +msgstr "å±…äø­" + +#. Setting: Location +#. Option: left +#. Setting: Direction +msgid "Left" +msgstr "å·¦" + +#. Setting: Location +#. Option: stretch +msgid "Stretch" +msgstr "拉伸" + +#. Setting: Short Action +#. Field: hint +msgid "" +"Maps short touches (less than 0.2s) to a touchpad action. Dualsense uses " +"a physical press for left and a double tap for right click (support for " +"double tap varies; enable \"Tap to Click\" in your desktop's touchpad " +"settings)." +msgstr "" +"å°†ēŸ­č§¦ę‘ø (å°äŗŽ 0.2s)ę˜ å°„äøŗč§¦ę‘øęæåŠØä½œć€‚Dualsense ä½æē”Øē‰©ē†ęŒ‰é”®ä½œäøŗå·¦é”®, åŒå‡»ä½œäøŗå³é”® (åŒå‡»ę”ÆęŒå› č®¾å¤‡č€Œå¼‚; " +"čÆ·åœØę”Œé¢č§¦ę‘øęæč®¾ē½®äø­åÆē”Ø\"轻触点击\")" + +#. Setting: Hold Action +#. Field: hint +msgid "" +"Maps long touches (more than 2s) to a touchpad action. Dualsense uses a " +"physical press for left and a double tap for right click (support for " +"double tap varies; enable \"Tap to Click\" in your desktop's touchpad " +"settings)." +msgstr "" +"将长触摸 (å¤§äŗŽ 2s)ę˜ å°„äøŗč§¦ę‘øęæåŠØä½œć€‚Dualsense ä½æē”Øē‰©ē†ęŒ‰é”®ä½œäøŗå·¦é”®, åŒå‡»ä½œäøŗå³é”® (åŒå‡»ę”ÆęŒå› č®¾å¤‡č€Œå¼‚; " +"čÆ·åœØę”Œé¢č§¦ę‘øęæč®¾ē½®äø­åÆē”Ø\"轻触点击\")" + +msgid "Downloading:" +msgstr "下载中: " + +msgid "Importing:" +msgstr "导兄中:" + +msgid "Deploying:" +msgstr "部署中:" + +msgid "Loading" +msgstr "加载中" + +msgid "No update available" +msgstr "ę²”ęœ‰åÆē”Øēš„ę›“ę–°" + +msgid "Rebasing to " +msgstr "é‡ē½®åˆ° " + +msgid "Updating to latest " +msgstr "ę›“ę–°åˆ°ęœ€ę–°ē‰ˆęœ¬" + +msgid "Updating... " +msgstr "曓新中... " + +msgid "Checking for updates..." +msgstr "ę£€ęŸ„ę›“ę–°äø­..." + +msgid "Undoing Update..." +msgstr "撤销曓新中..." + +msgid "Undoing Revert..." +msgstr "ę’¤é”€čæ˜åŽŸäø­..." + +msgid "Reverting to Previous version..." +msgstr "čæ˜åŽŸåˆ°äøŠäø€äøŖē‰ˆęœ¬äø­..." + +msgid "Loading Versions..." +msgstr "åŠ č½½ē‰ˆęœ¬äø­..." + +msgid "Removing Customizations..." +msgstr "ē§»é™¤č‡Ŗå®šä¹‰č®¾ē½®äø­..." + +msgid "Failed to load previous versions" +msgstr "åŠ č½½äøŠäø€äøŖē‰ˆęœ¬å¤±č“„" + +#. Setting: System Image +#. Field: title +msgid "System Image" +msgstr "ē³»ē»Ÿé•œåƒ" + +#. Setting: System Image +#. Field: hint +msgid "Manage the currently installed image with bootc." +msgstr "使用 bootc ē®”ē†å½“å‰å®‰č£…ēš„é•œåƒ" + +#. Setting: Image +#. Field: title +msgid "Image" +msgstr "镜像" + +#. Setting: Next +#. Field: title +msgid "Next" +msgstr "下一个" + +#. Setting: Current +#. Field: title +msgid "Current" +msgstr "当前" + +#. Setting: Previous +#. Field: title +msgid "Previous" +msgstr "äøŠäø€äøŖ" + +#. Setting: Update +#. Field: title +msgid "Update" +msgstr "ꛓꖰ" + +#. Setting: Update Stage +#. Field: title +msgid "Update Stage" +msgstr "ę›“ę–°é˜¶ę®µ" + +#. Setting: Apply Update +#. Field: title +msgid "Apply Update" +msgstr "应用曓新" + +#. Setting: Revert to Previous +#. Field: title +msgid "Revert to Previous" +msgstr "čæ˜åŽŸåˆ°äøŠäø€äøŖ" + +#. Setting: Revert to Previous +#. Field: hint +msgid "Rollback to the previous image." +msgstr "å›žę»šåˆ°äøŠäø€äøŖé•œåƒ" + +#. Setting: Change Version (Rebase) +#. Field: title +msgid "Change Version (Rebase)" +msgstr "ę›“ę”¹ē‰ˆęœ¬ (é‡ē½®)" + +#. Setting: Remove Pin and Update +#. Field: title +msgid "Remove Pin and Update" +msgstr "ē§»é™¤ē‰ˆęœ¬é”å®šå¹¶ę›“ę–°" + +#. Setting: Change Branch (Rebase) +#. Field: title +msgid "Change Branch (Rebase)" +msgstr "ę›“ę”¹åˆ†ę”Æ (é‡ē½®)" + +#. Setting: Check for Updates +#. Field: title +msgid "Check for Updates" +msgstr "ę£€ęŸ„ę›“ę–°" + +#. Setting: Reboot +#. Field: title +msgid "Reboot" +msgstr "é‡åÆ" + +#. Setting: Reboot +#. Field: hint +msgid "Reboot to apply the update. Are you sure?" +msgstr "é‡åÆä»„åŗ”ē”Øę›“ę–°ć€‚ē”®å®šå—ļ¼Ÿ" + +#. Setting: Undo Update +#. Field: title +msgid "Undo Update" +msgstr "꒤销ꛓꖰ" + +#. Setting: Undo Revert +#. Field: title +msgid "Undo Revert" +msgstr "ę’¤é”€čæ˜åŽŸ" + +#. Setting: Choose Version (Rebase) +#. Field: title +msgid "Choose Version (Rebase)" +msgstr "é€‰ę‹©ē‰ˆęœ¬ (Rebase)" + +#. Setting: Run rpm-ostree reset +#. Field: title +msgid "Run rpm-ostree reset" +msgstr "运蔌 rpm-ostree é‡ē½®" + +#. Setting: Run rpm-ostree reset +#. Field: hint +msgid "" +"Disable the custom initramfs and remove layers. Your personal data will " +"not be affected." +msgstr "ē¦ē”Øč‡Ŗå®šä¹‰ initramfs å¹¶ē§»é™¤å±‚ć€‚ę‚Øēš„äøŖäŗŗę•°ę®äøä¼šå—åˆ°å½±å“" + +#. Setting: Cancel +#. Field: title +msgid "Cancel" +msgstr "å–ę¶ˆ" + +#. Setting: Branch +#. Field: title +msgid "Branch" +msgstr "åˆ†ę”Æ" + +#. Setting: Version Pin +#. Field: title +msgid "Version Pin" +msgstr "ē‰ˆęœ¬é”å®š" + +#. Setting: Apply +#. Field: title +msgid "Apply" +msgstr "应用" + +msgid "Uploading log to fpaste..." +msgstr "äøŠä¼ ę—„åæ—åˆ° fpaste..." + +msgid "Shutting down..." +msgstr "å…³ęœŗäø­..." + +msgid "Failed to download Handheld Daemon Beta." +msgstr "äø‹č½½ Handheld Daemon Beta 失蓄" + +msgid "Downloading Beta and Restarting..." +msgstr "äø‹č½½ Beta å¹¶é‡åÆäø­..." + +#. Setting: Bug Report +#. Field: title +msgid "Bug Report" +msgstr "Bug ęŠ„å‘Š" + +#. Setting: Bug Report Link +#. Field: title +msgid "Bug Report Link" +msgstr "Bug ęŠ„å‘Šé“¾ęŽ„" + +#. Setting: Upload Error +#. Field: title +msgid "Upload Error" +msgstr "曓新错误" + +#. Setting: Submit Report +#. Field: title +msgid "Submit Report" +msgstr "ęäŗ¤ęŠ„å‘Š" + +#. Setting: Submit Report +#. Field: hint +msgid "Upload a bug report to paste.centos.org" +msgstr "äøŠä¼ äø€äøŖ bug ęŠ„å‘Šåˆ° paste.centos.org" + +#. Setting: Logs from +#. Field: title +msgid "Logs from" +msgstr "ę—„åæ—ę„č‡Ŗ" + +#. Setting: Logs from +#. Option: current +msgid "Current Boot" +msgstr "å½“å‰åÆåŠØ" + +#. Setting: Logs from +#. Option: previous +msgid "Previous Boot (-1)" +msgstr "äøŠäø€äøŖåÆåŠØ (-1)" + +#. Setting: Logs from +#. Option: m2 +msgid "Boot -2" +msgstr "启动 -2" + +#. Setting: Logs from +#. Option: m3 +msgid "Boot -3" +msgstr "启动 -3" + +#. Setting: Development Tools +#. Field: title +msgid "Development Tools" +msgstr "开发巄具" + +#. Setting: Use HHD Beta Until Restart +#. Field: title +msgid "Use HHD Beta Until Restart" +msgstr "使用 HHD Beta ē›“åˆ°é‡åÆ" + +#. Setting: Use HHD Beta Until Restart +#. Field: hint +msgid "Switch to the HHD beta channel until you restart." +msgstr "åˆ‡ę¢åˆ° HHD beta é¢‘é“ē›“åˆ°é‡åÆ" + +#. Setting: Go Back to Stable +#. Field: title +msgid "Go Back to Stable" +msgstr "čæ”å›žēØ³å®šē‰ˆ" + +#. Setting: System +#. Field: hint +msgid "" +"Basic display settings. Brightness (and framerate TBD). This pane is " +"meant to replace " +msgstr "åŸŗęœ¬ę˜¾ē¤ŗč®¾ē½®ć€‚äŗ®åŗ¦ļ¼ˆå’Œåø§ēŽ‡å¾…å®šļ¼‰ć€‚ę­¤é¢ęæę—ØåœØę›æä»£" + +#. Setting: Brightness +#. Field: title +msgid "Brightness" +msgstr "亮度" + +#. Setting: Brightness +#. Field: hint +msgid "" +"Sets the brightness level of a display. Only one display is supported and" +" it is the one that was read." +msgstr "č®¾ē½®ę˜¾ē¤ŗå™Øēš„äŗ®åŗ¦ēŗ§åˆ«ć€‚ä»…ę”ÆęŒäø€äøŖę˜¾ē¤ŗå™Ø, å¹¶äø”ę˜ÆčÆ»å–ēš„ę˜¾ē¤ŗå™Ø" + +#. Setting: Display +#. Field: title +msgid "Display" +msgstr "显示" + +#. Setting: Disable Touchscreen (Until Restart) +#. Field: title +msgid "Disable Touchscreen (Until Restart)" +msgstr "ē¦ē”Øč§¦ę‘øå± (ē›“åˆ°é‡åÆ)" + +#. Setting: Disable Touch Gestures (Until Restart) +#. Field: title +msgid "Disable Touch Gestures (Until Restart)" +msgstr "ē¦ē”Øč§¦ę‘øę‰‹åŠæ (ē›“åˆ°é‡åÆ)" + +#. Setting: Gamescope +#. Field: title +msgid "Gamescope" +msgstr "ęøøęˆčŒƒå›“" + +#. Setting: Run Steam at 60/72 Hz +#. Field: title +msgid "Run Steam at 60/72 Hz" +msgstr "让 Steam 运蔌在 60/72 Hz åˆ·ę–°ēŽ‡" + +#. Setting: Poweroff screen before sleep +#. Field: title +msgid "Poweroff screen before sleep" +msgstr "åœØē”ēœ å‰å…³é—­å±å¹•" + +#. Setting: All Controllers +#. Field: title +msgid "All Controllers" +msgstr "ę‰€ęœ‰ęŽ§åˆ¶å™Ø" + +#. Setting: Xbox or View + B (Press) +#. Field: title +msgid "Xbox or View + B (Press)" +msgstr "Xbox ꈖ View + B (ęŒ‰äø‹)" + +#. Setting: Xbox or View + B (Press) +#. Option: keyboard +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "Steam Keyboard" +msgstr "Steam é”®ē›˜" + +#. Setting: Xbox or View + B (Press) +#. Option: steam_qam +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "Steam Side Menu" +msgstr "Steam ä¾§č¾¹čœå•" + +#. Setting: Xbox or View + B (Press) +#. Option: steam_expanded +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "Steam Overlay" +msgstr "Steam 叠加层" + +#. Setting: Xbox or View + B (Press) +#. Option: hhd_qam +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "HHD Side Menu" +msgstr "HHD ä¾§č¾¹čœå•" + +#. Setting: Xbox or View + B (Press) +#. Option: hhd_expanded +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "HHD Overlay" +msgstr "HHD 叠加层" + +#. Setting: Xbox or View + Y +#. Field: title +msgid "Xbox or View + Y" +msgstr "Xbox ꈖ View + Y" + +#. Setting: Xbox or View + Y +#. Option: disconnect +msgid "Disconnect Controller" +msgstr "ę–­å¼€ęŽ§åˆ¶å™ØčæžęŽ„" + +#. Setting: Touchscreen +#. Field: title +msgid "Touchscreen" +msgstr "č§¦ę‘øå±" + +#. Setting: ↑ Swipe Up +#. Field: title +msgid "↑ Swipe Up" +msgstr "↑ å‘äøŠę»‘åŠØ" + +#. Setting: ← Swipe Right Side (Top) +#. Field: title +msgid "← Swipe Right Side (Top)" +msgstr "← å³ä¾§č¾¹ę»‘åŠØ (锶部)" + +#. Setting: ← Swipe Right Side (Bottom) +#. Field: title +msgid "← Swipe Right Side (Bottom)" +msgstr "← å³ä¾§č¾¹ę»‘åŠØ (åŗ•éƒØ)" + +#. Setting: → Swipe Left Side (Top) +#. Field: title +msgid "→ Swipe Left Side (Top)" +msgstr "→ å·¦ä¾§č¾¹ę»‘åŠØ (锶部)" + +#. Setting: → Swipe Left Side (Bottom) +#. Field: title +msgid "→ Swipe Left Side (Bottom)" +msgstr "→ å·¦ä¾§č¾¹ę»‘åŠØ (åŗ•éƒØ)" + +#. Setting: ↓ Swipe Down +#. Field: title +msgid "↓ Swipe Down" +msgstr "↓ å‘äø‹ę»‘åŠØ" + +#. Setting: Orientation Correction +#. Field: title +msgid "Orientation Correction" +msgstr "方向栔正" + +#. Setting: Auto +#. Field: title +msgid "Auto" +msgstr "č‡ŖåŠØ" + +#. Setting: Device +#. Field: title +msgid "Device" +msgstr "设备" + +#. Setting: Portrait +#. Field: title +msgid "Portrait" +msgstr "纵向" + +#. Setting: Flip Left-Right +#. Field: title +msgid "Flip Left-Right" +msgstr "å·¦å³ēæ»č½¬" + +#. Setting: Flip Top-Bottom +#. Field: title +msgid "Flip Top-Bottom" +msgstr "äøŠäø‹ēæ»č½¬" + +#. Setting: Keyboard (Gaming Only) +#. Field: title +msgid "Keyboard (Gaming Only)" +msgstr "é”®ē›˜ (ä»…ęøøęˆäø­)" + +#. Setting: Start (Meta) Press +#. Field: title +msgid "Start (Meta) Press" +msgstr "Start (Meta) ęŒ‰äø‹" + +#. Setting: Start (Meta) Hold +#. Field: title +msgid "Start (Meta) Hold" +msgstr "Start (Meta) ęŒ‰ä½" + +#. Setting: Ctrl + 3 +#. Field: title +msgid "Ctrl + 3" +msgstr "" + +#. Setting: Ctrl + 4 +#. Field: title +msgid "Ctrl + 4" +msgstr "" + +msgid "Failed to hibernate (missing swap file)." +msgstr "ä¼‘ēœ å¤±č“„ (ē¼ŗå°‘äŗ¤ę¢ę–‡ä»¶)." + +msgid "Failed to create temporary swap." +msgstr "åˆ›å»ŗäø“ę—¶äŗ¤ę¢ę–‡ä»¶å¤±č“„." + +msgid "Failed to hibernate to temporary swap." +msgstr "ä¼‘ēœ åˆ°äø“ę—¶äŗ¤ę¢ę–‡ä»¶å¤±č“„." + +#. Setting: Power +#. Field: title +msgid "Power" +msgstr "电源" + +#. Setting: Reboot into Windows +#. Field: title +msgid "Reboot into Windows" +msgstr "é‡åÆčæ›å…„ Windows" + +#. Setting: Reboot into Windows +#. Field: hint +msgid "Make sure you saved your game progress." +msgstr "ē”®äæę‚Øå·²äæå­˜ęøøęˆčæ›åŗ¦" + +#. Setting: Hibernate +#. Field: title +msgid "Hibernate" +msgstr "ä¼‘ēœ " + +#. Setting: Hibernate +#. Field: hint +msgid "Saves your progress and powers off the device." +msgstr "äæå­˜ę‚Øēš„čæ›åŗ¦å¹¶å…³é—­č®¾å¤‡" + +#. Setting: Hibernate when device asks and at 5%. +#. Field: title +msgid "Hibernate when device asks and at 5%." +msgstr "å½“č®¾å¤‡čÆ·ę±‚ä¼‘ēœ äø”ē”µę± ē”µé‡ä½ŽäŗŽ 5% ę—¶č‡ŖåŠØä¼‘ēœ " + +#. Setting: Hibernate when device asks and at 5%. +#. Field: hint +msgid "" +"Certain devices wake up to force Windows to hibernate. If Linux does not\n" +"they cause issues. Detect and hibernate on thermal events and on 5%\n" +"battery.\n" +msgstr "" +"ęŸäŗ›č®¾å¤‡ä¼šå”¤é†’å¹¶å¼ŗåˆ¶ Windows čæ›å…„ä¼‘ēœ ēŠ¶ę€ć€‚å¦‚ęžœ Linux äøę‰§č”Œē›øåŒę“ä½œļ¼ŒåÆčƒ½ä¼šå¼•å‘é—®é¢˜ć€‚" +"ē³»ē»Ÿå°†åœØę£€ęµ‹åˆ°č®¾å¤‡å‘ēƒ­äø”ē”µę± ē”µé‡ä½ŽäŗŽ 5% ę—¶č‡ŖåŠØčæ›å…„ä¼‘ēœ ēŠ¶ę€ć€‚\n" + +#. Setting: Steam Powerbutton Handler +#. Field: title +msgid "Steam Powerbutton Handler" +msgstr "Steam ē”µęŗęŒ‰é’®å¤„ē†" + +#. Setting: Steam Powerbutton Handler +#. Field: hint +msgid "" +"Enables the Steam Powerbutton handler (responsible for the wink and " +"powerbutton menu)." +msgstr "启用 Steam ē”µęŗęŒ‰é’®å¤„ē† (č“Ÿč“£ē”ēœ åŠØē”»å’Œē”µęŗęŒ‰é’®čœå•)" + +#. Setting: Saturation +#. Field: title +msgid "Saturation" +msgstr "é„±å’Œåŗ¦" + +#. Setting: Brightness +#. Option: low +#. Setting: Speed +msgid "Low" +msgstr "低" + +#. Setting: Stick Style +#. Field: title +msgid "Stick Style" +msgstr "ę‘‡ę†ę ·å¼" + +#. Setting: Stick Style +#. Option: monster_woke +msgid "Monster Woke" +msgstr "" + +#. Setting: Stick Style +#. Option: flowing +msgid "Flowing Light" +msgstr "" + +#. Setting: Stick Style +#. Option: sunset +msgid "Sunset Afterglow" +msgstr "" + +#. Setting: Stick Style +#. Option: neon +msgid "Colorful Neon" +msgstr "" + +#. Setting: Stick Style +#. Option: dreamy +msgid "Dreamy" +msgstr "" + +#. Setting: Stick Style +#. Option: cyberpunk +msgid "Cyberpunk" +msgstr "" + +#. Setting: Stick Style +#. Option: colorful +msgid "Colorful" +msgstr "" + +#. Setting: Stick Style +#. Option: aurora +msgid "Aurora" +msgstr "" + +#. Setting: Stick Style +#. Option: sun +msgid "Warm Sun" +msgstr "" + +#. Setting: Stick Style +#. Option: classic +msgid "OXP Classic" +msgstr "" + +#. Setting: Secondary +#. Field: title +msgid "Secondary" +msgstr "ꬔ要" + +#. Setting: Enable Secondary +#. Field: title +msgid "Enable Secondary" +msgstr "启用欔要" + +#. Setting: Speed +#. Field: title +msgid "Speed" +msgstr "é€Ÿåŗ¦" + +#. Setting: Direction +#. Field: title +msgid "Direction" +msgstr "方向" + +#. Setting: Spiral +#. Field: title +msgid "Spiral" +msgstr "čžŗę—‹" + +#. Setting: Spiral +#. Field: hint +msgid "Creates an RGB spiral around the stick." +msgstr "åœØę‘‡ę†å‘Øå›“åˆ›å»ŗäø€äøŖ RGB čžŗę—‹" + +#. Setting: Duality +#. Field: title +msgid "Duality" +msgstr "åŒč‰²" + +#. Setting: Duality +#. Field: hint +msgid "Alternates between two colors." +msgstr "åœØäø¤ē§é¢œč‰²ä¹‹é—“äŗ¤ę›æ" + +#. Setting: OneXPlayer +#. Field: title +msgid "OneXPlayer" +msgstr "" + +#. Setting: RGB Settings +#. Field: title +msgid "RGB Settings" +msgstr "RGB 设置" + +#. Setting: Controller RGB +#. Field: title +msgid "Controller RGB" +msgstr "ęŽ§åˆ¶å™Ø RGB" + +#. Setting: Enable RGB support. +#. Field: title +msgid "Enable RGB support." +msgstr "启用 RGB ę”ÆęŒ" + +#~ msgid "About" +#~ msgstr "å…³äŗŽ" + +#~ msgid "Version Information" +#~ msgstr "ē‰ˆęœ¬äæ”ęÆ" + +#~ msgid "Handheld Daemon version manager (local install only)." +#~ msgstr "Handheld Daemon ē‰ˆęœ¬ē®”ē† (ä»…ęœ¬åœ°å®‰č£…)" + +#~ msgid "Decky Plugin Version" +#~ msgstr "Decky ę’ä»¶ē‰ˆęœ¬" + +#~ msgid "Displays the Handheld Daemon Decky plugin version." +#~ msgstr "显示 Handheld Daemon Decky ę’ä»¶ē‰ˆęœ¬" + +#~ msgid "WARNING: This button will be removed soon, update Decky now!" +#~ msgstr "č­¦å‘Šļ¼šę­¤ęŒ‰é’®å°†å¾ˆåæ«č¢«ē§»é™¤, čÆ·ē«‹å³ę›“ę–° Decky!" + +#~ msgid "Updates the Decky plugin to the latest release." +#~ msgstr "ꛓꖰ Decky ę’ä»¶åˆ°ęœ€ę–°ē‰ˆęœ¬" + +#~ msgid "Handheld Controller" +#~ msgstr "ęŽŒęœŗęŽ§åˆ¶å™Ø" + +#~ msgid "Gyro to Mouse Fix" +#~ msgstr "é™€čžŗä»Ŗč½¬é¼ ę ‡ äæ®å¤" + +#~ msgid "" +#~ "In the latest steam update, `Gyro " +#~ "to Mouse [BETA]` misbehaves if a " +#~ "report is sent without a new imu" +#~ " timestamp. This option makes it so" +#~ " reports are sent only when there " +#~ "is a new gyro timestamp, effectively " +#~ "limiting the responsiveness of the " +#~ "controller to that of the IMU " +#~ "(e.g., for Legion Go 100hz instead " +#~ "of 400hz). If the IMU is not " +#~ "working, this setting will break the " +#~ "controller." +#~ msgstr "" + +#~ msgid "Correction Type" +#~ msgstr "ę ”ę­£ē±»åž‹" + +#~ msgid "" +#~ "The legion touchpad is square, whereas" +#~ " the DS5 one is rectangular. " +#~ "Therefore, it needs to be corrected. " +#~ "\"Contain\" maintain the whole DS5 " +#~ "touchpad and part of the Legion " +#~ "one is unused. \"Crop\" uses the " +#~ "full legion touchpad, and limits the " +#~ "area of the DS5. \"Stretch\" uses " +#~ "both fully (distorted). \"Crop End\" " +#~ "enables use in steam input as the" +#~ " right touchpad." +#~ msgstr "" +#~ "Legion č§¦ę‘øęæę˜Æę­£ę–¹å½¢ēš„, 而 DS5 č§¦ę‘øęæę˜Æé•æę–¹å½¢ēš„ć€‚å› ę­¤, " +#~ "éœ€č¦čæ›č”Œę ”ę­£ć€‚\"Contain\" äæęŒę•“äøŖ DS5 č§¦ę‘øęæ, éƒØåˆ† " +#~ "Legion č§¦ę‘øęæęœŖä½æē”Øć€‚\"č£åˆ‡\" 使用敓个 Legion č§¦ę‘øęæ, " +#~ "并限制 DS5 ēš„åŒŗåŸŸć€‚\"拉伸\" å®Œę•“ä½æē”Ø (拉伸)怂\"Crop " +#~ "End\" 在 Steam č¾“å…„äø­åÆē”Øå³č§¦ę‘øęæ" + +#~ msgid "Crop Start" +#~ msgstr "左对齐裁剪" + +#~ msgid "Crop End" +#~ msgstr "å³åÆ¹é½č£å‰Ŗ" + +#~ msgid "Contain Start" +#~ msgstr "å·¦åÆ¹é½åŒ…å«" + +#~ msgid "Contain End" +#~ msgstr "å³åÆ¹é½åŒ…å«" + +#~ msgid "Contain Center" +#~ msgstr "å±…äø­åŒ…å«" + +#~ msgid "Map share buttom to QAM." +#~ msgstr "将 share é”®ę˜ å°„äøŗåæ«ę·čœå•é”®" + +#~ msgid "Debug" +#~ msgstr "DebugęØ”å¼" + +#~ msgid "" +#~ "Output controller events to the console" +#~ " (high CPU use) and raises exceptions" +#~ " (HHD will crash on errors)." +#~ msgstr "č¾“å‡ŗęŽ§åˆ¶å™Øäŗ‹ä»¶åˆ°ęŽ§åˆ¶å° (高 CPU 使用) å¹¶ęŠ›å‡ŗå¼‚åøø (HHD å°†åœØé”™čÆÆę—¶å“©ęŗƒ)" + +#~ msgid "Map L4/R4 to QAM." +#~ msgstr "将 L4/R4 ę˜ å°„äøŗåæ«ę·čœå•é”®" + +#~ msgid "L4" +#~ msgstr "L4" + +#~ msgid "R4" +#~ msgstr "R4" + +#~ msgid "Display [SEE HINT]" +#~ msgstr "显示 [ęŸ„ēœ‹ęē¤ŗ]" + +#~ msgid "" +#~ "Using the gyro of the display unit" +#~ " is deprecated. The controller units " +#~ "(after calibration) are better in every" +#~ " way. As part of the deprecation, " +#~ "the rule that disabled the use of" +#~ " the accelerometer for display autorotation" +#~ " was removed. If you try to use" +#~ " the display gyroscope without this " +#~ "rule you will get freezing." +#~ msgstr "" + +#~ msgid "Gyroscope" +#~ msgstr "é™€čžŗä»Ŗ" + +#~ msgid "Enables gyroscope support (.3% background CPU use)" +#~ msgstr "åÆē”Øé™€čžŗä»Ŗę”ÆęŒ (0.3% 后台 CPU 使用)" + +#~ msgid "Accelerometer" +#~ msgstr "åŠ é€Ÿåŗ¦ä¼ ę„Ÿå™Ø" + +#~ msgid "Enables accelerometer support (CURRENTLY BROKEN)." +#~ msgstr "åÆē”ØåŠ é€Ÿåŗ¦ä¼ ę„Ÿå™Øę”ÆęŒ (å½“å‰äøåÆē”Ø)" + +#~ msgid "Gyro Hz" +#~ msgstr "é™€čžŗä»Ŗé‡‡ę ·ēŽ‡" + +#~ msgid "" +#~ "Adds polling to the legion go " +#~ "gyroscope, to fix the low polling " +#~ "rate (required for gyroscope support). " +#~ "Set to 0 to disable. Due to " +#~ "hardware limitations, there is a " +#~ "marginal difference above 100hz." +#~ msgstr "" +#~ "对Legion Go é™€čžŗä»Ŗēš„é¢å¤–č½®čÆ¢, ē”ØäŗŽäæ®å¤ä½Žč½®čÆ¢ēŽ‡ (éœ€č¦é™€čžŗä»Ŗę”ÆęŒ)。设置为" +#~ " 0 ä»„ē¦ē”Øć€‚ē”±äŗŽē”¬ä»¶é™åˆ¶, 100hz ä»„äøŠēš„å·®å¼‚å¾®ä¹Žå…¶å¾®" + +#~ msgid "Gyro Scale" +#~ msgstr "é™€čžŗä»Ŗē¼©ę”¾" + +#~ msgid "" +#~ "Applies a scaling factor to the " +#~ "legion go gyroscope (since it is " +#~ "misconfigured by the driver). Try " +#~ "different values to see what works " +#~ "best. Low values cause a deadzone " +#~ "and high values will clip when " +#~ "moving the Go abruptly." +#~ msgstr "对 Legion Go é™€čžŗä»Ŗåŗ”ē”Øē¼©ę”¾ē³»ę•° (å› äøŗé©±åŠØēØ‹åŗé…ē½®é”™čÆÆ)ć€‚å°čÆ•äøåŒēš„å€¼ä»„ę‰¾åˆ°ęœ€ä½³ę•ˆęžœć€‚ä½Žå€¼ä¼šåÆ¼č‡“ę­»åŒŗ, é«˜å€¼ä¼šåœØåæ«é€Ÿē§»åŠØę—¶č£å‰Ŗ" + +#~ msgid "Dual Controller Motion Output (evdev)" +#~ msgstr "" + +#~ msgid "" +#~ "Adds two Motions evdev devices, one " +#~ "for each controller that can be " +#~ "used at the same time." +#~ msgstr "" + +#~ msgid "Swaps the legion buttons with start select." +#~ msgstr "äŗ¤ę¢ Legion Go ēš„ Start/Select ęŒ‰é’®" + +#~ msgid "Left is Start" +#~ msgstr "左侧为 Start" + +#~ msgid "Left is Select" +#~ msgstr "左侧为 Select" + +#~ msgid "M2 As Mute" +#~ msgstr "M2 静音键" + +#~ msgid "Legion R to QAM" +#~ msgstr "Legion R ę˜ å°„äøŗåæ«ę·čœå•é”®" + +#~ msgid "Led Brightness" +#~ msgstr "LED 亮度" + +#~ msgid "" +#~ "When LEDs are configured, set their " +#~ "brightness. High does not work below " +#~ "30% brightness." +#~ msgstr "设置 LED ēš„äŗ®åŗ¦ć€‚é«˜äŗ®åŗ¦åœØ 30% ä»„äø‹äøčµ·ä½œē”Ø" + +#~ msgid "Map Armory to QAM." +#~ msgstr "将 Armory é”®ę˜ å°„äøŗåæ«ę·čœå•å»ŗ" + +#~ msgid "Does not modify the default controller." +#~ msgstr "äøäæ®ę”¹é»˜č®¤ęŽ§åˆ¶å™Ø" + +#~ msgid "" +#~ "Creates a virtual `Handheld Daemon " +#~ "Controller` that can be used normally" +#~ " in apps. Back buttons are supported" +#~ " but steam will not detect them. " +#~ "If Gyroscope or Accelerometer are " +#~ "enabled, a Motion device will be " +#~ "created as well (experimental; works in" +#~ " Dolphin)." +#~ msgstr "" +#~ "åˆ›å»ŗäø€äøŖč™šę‹Ÿēš„ `Handheld Daemon Controller` 设备, " +#~ "åÆä»„åœØåŗ”ē”Øäø­ę­£åøøä½æē”Øć€‚ę”ÆęŒčƒŒé”®, 但 Steam äøä¼šę£€ęµ‹åˆ°å®ƒä»¬ć€‚å¦‚ęžœåÆē”Øäŗ†é™€čžŗä»Ŗęˆ–åŠ é€Ÿåŗ¦č®”, " +#~ "čæ˜å°†åˆ›å»ŗäø€äøŖ Motion 设备 (å®žéŖŒę€§ēš„, 在 Dolphin " +#~ "中巄作)" + +#~ msgid "" +#~ "Emulates the Dualsense Sony controller " +#~ "from the Playstation 5. Since this " +#~ "controller does not have paddles, the" +#~ " paddles are mapped to left and " +#~ "right touchpad clicks." +#~ msgstr "ęØ”ę‹Ÿē“¢å°¼ PlayStation 5 ēš„ Dualsense ęŽ§åˆ¶å™Øć€‚ē”±äŗŽčÆ„ęŽ§åˆ¶å™Øę²”ęœ‰čƒŒé”®, č®¾å¤‡ēš„čƒŒé”®č¢«ę˜ å°„äøŗå·¦å³č§¦ę‘øęæē‚¹å‡»" + +#~ msgid "Paddles to Clicks" +#~ msgstr "čƒŒé”®" + +#~ msgid "" +#~ "Maps the paddles of the device to" +#~ " left and right touchpad clicks " +#~ "making them usable in Steam. If " +#~ "more than 2 paddles (e.g., Legion " +#~ "Go) uses the top ones. If extra" +#~ " buttons (e.g., Ayaneo, GPD), uses " +#~ "them instead." +#~ msgstr "" +#~ "å°†č®¾å¤‡ēš„čƒŒé”®ę˜ å°„äøŗå·¦å³č§¦ę‘øęæē‚¹å‡», ä½æå…¶åœØ Steam äø­åÆē”Øć€‚å¦‚ęžœč®¾å¤‡ęœ‰å¤šäŗŽ 2 " +#~ "äøŖčƒŒé”® (例如 Legion Go) ä½æē”ØäøŠé¢ēš„ć€‚å¦‚ęžœęœ‰é¢å¤–ēš„ęŒ‰é’® (例如" +#~ " Ayaneo, GPD), åˆ™ä½æē”Øčæ™äŗ›ęŒ‰é’®" + +#~ msgid "Dualsense Edge" +#~ msgstr "" + +#~ msgid "" +#~ "Emulates the expensive Dualsense Sony " +#~ "controller which enables paddle support. " +#~ "The edge controller is a bit " +#~ "obscure, so some games might not " +#~ "support it correctly." +#~ msgstr "ęØ”ę‹Ÿē“¢å°¼ Dualsense ęŽ§åˆ¶å™Ø, åÆē”ØčƒŒé”®ę”ÆęŒć€‚Edge ęŽ§åˆ¶å™Øęœ‰ē‚¹å¤ę‚, å› ę­¤äø€äŗ›ęøøęˆåÆčƒ½ę— ę³•ę­£ē”®ę”ÆęŒå®ƒ" + +#~ msgid "Bazzite" +#~ msgstr "" + +#~ msgid "Utilities" +#~ msgstr "å·„å…·" + +#~ msgid "" +#~ "Sets the sampling frequency for the " +#~ "IMU. Check " +#~ "`/sys/bus/iio/devices/iio:device0/in_anglvel_sampling_frequency_available`." +#~ msgstr "" +#~ "设置 IMU ēš„é‡‡ę ·é¢‘ēŽ‡ć€‚ę£€ęŸ„ " +#~ "`/sys/bus/iio/devices/iio:device0/in_anglvel_sampling_frequency_available`" + +#~ msgid "Paddles" +#~ msgstr "čƒŒé”®" + +#~ msgid "Fix touchpad hold [BROKEN]" +#~ msgstr "äæ®å¤č§¦ę‘øęæé•æęŒ‰ [äøåÆē”Ø]" + +#~ msgid "" +#~ "Sets the sampling frequency for the " +#~ "IMU. 1600 requires an IMU patch. " +#~ "Check " +#~ "`/sys/bus/iio/devices/iio:device0/in_anglvel_sampling_frequency_available`." +#~ msgstr "" +#~ "设置 IMU ēš„é‡‡ę ·é¢‘ēŽ‡ć€‚1600 éœ€č¦äø€äøŖ IMU č”„äøć€‚ę£€ęŸ„ " +#~ "`/sys/bus/iio/devices/iio:device0/in_anglvel_sampling_frequency_available`" + +#~ msgid "Shortcut Support + Side Button double/triple press (Requires Restart)" +#~ msgstr "åæ«ę·é”®ę”ÆęŒ + ä¾§é¢ęŒ‰é’®åŒå‡»/三击 (éœ€č¦é‡åÆ)" + +#~ msgid "Chinese" +#~ msgstr "" + diff --git a/i18n/zh_TW/LC_MESSAGES/adjustor.po b/i18n/zh_TW/LC_MESSAGES/adjustor.po new file mode 100644 index 00000000..31d63817 --- /dev/null +++ b/i18n/zh_TW/LC_MESSAGES/adjustor.po @@ -0,0 +1,921 @@ +# Chinese (Traditional, Taiwan) translations for PROJECT. +# Copyright (C) 2024 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2024. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2025-02-06 18:26+0100\n" +"PO-Revision-Date: 2025-02-04 16:36+0800\n" +"Last-Translator: Alex \n" +"Language: zh_Hant_TW\n" +"Language-Team: zh_Hant_TW \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +msgid "Disable Decky TDP plugins using the button below to continue." +msgstr "ä½æē”Øäø‹é¢ēš„ęŒ‰éˆ•ē¦ē”Ø Decky TDP ę’ä»¶ä»„ē¹¼ēŗŒć€‚" + +#. Setting: TDP Controls +#. Field: title +msgid "TDP Controls" +msgstr "TDP ęŽ§åˆ¶" + +#. Setting: Enable TDP Controls +#. Field: title +msgid "Enable TDP Controls" +msgstr "å•Ÿē”Ø TDP ęŽ§åˆ¶" + +#. Setting: Enable TDP Controls +#. Field: hint +msgid "" +"Enables TDP management by Handheld Daemon. While enabled, Handheld Daemon" +" will set and maintain the TDP limits set on start-up and during other " +"device changes (ac/dc)." +msgstr "" +"å•Ÿē”Ø Handheld Daemon ēš„ TDP ē®”ē†ć€‚å•Ÿē”Øå¾Œļ¼ŒHandheld Daemon å°‡åœØå•Ÿå‹•ę™‚å’Œå…¶ä»–čØ­å‚™å……/ę”¾é›»ę™‚čØ­å®šå’Œē¶­č­· TDP " +"é™åˆ¶ć€‚\n" +"å¦‚ęžœčØ­å‚™å“©ę½°ļ¼ŒTDP čØ­å®šå°‡åœØäø‹ę¬”å•Ÿå‹•ę™‚ē¦ē”Øć€‚" + +#. Setting: Error +#. Field: title +msgid "Error" +msgstr "錯誤" + +#. Setting: Error +#. Option: nowrite +msgid "Can not write to ACPI Call file. ACPI Call is required for TDP." +msgstr "焔法寫兄 ACPI Call ęŖ”ę”ˆļ¼ŒTDP ęŽ§åˆ¶éœ€č¦ ACPI Call ę”Æę“ć€‚" + +#. Setting: TDP Capabilities +#. Field: title +msgid "TDP Capabilities" +msgstr "TDP 功能" + +#. Setting: Disable Decky TDP Plugins +#. Field: title +msgid "Disable Decky TDP Plugins" +msgstr "禁用 Decky TDP ę’ä»¶" + +#. Setting: Disable Decky TDP Plugins +#. Field: hint +msgid "" +"Disables Decky TDP plugins (Powercontrol, SimpleDeckyTDP) by moving them " +"from ~/homebrew/plugins to ~/homebrew/plugins/hhd-disabled. Then, " +"restarts Decky. This might cause Steam to restart. Move them back and " +"reboot to re-enable." +msgstr "" +"通過將其從 ~/homebrew/plugins ē§»å‹•åˆ° ~/homebrew/plugins/hhd-disabled 來禁用 Decky " +"TDP ę’ä»¶ļ¼ˆPowercontrol, SimpleDeckyTDPļ¼‰ć€‚ē„¶å¾Œļ¼Œé‡ę–°å•Ÿå‹• Deckyć€‚é€™åÆčƒ½ęœƒå°Žč‡“ Steam " +"é‡ę–°å•Ÿå‹•ć€‚å°‡å®ƒå€‘ē§»å›žäø¦é‡ę–°å•Ÿå‹•ä»„é‡ę–°å•Ÿē”Øć€‚" + +#. Setting: Enable TDP Controls +#. Field: hint +msgid "" +"Enables TDP management by the Handheld Daemon. While enabled, Handheld " +"Daemon will set and maintain the TDP limits set on start-up and during " +"other device changes (ac/dc).\n" +"If the device crashes, TDP setting will be disabled on next startup." +msgstr "" +"å•Ÿē”Ø Handheld Daemon ēš„ TDP ē®”ē†ć€‚å•Ÿē”Øå¾Œļ¼ŒHandheld Daemon å°‡åœØå•Ÿå‹•ę™‚å’Œå…¶ä»–čØ­å‚™å……/ę”¾é›»ę™‚čØ­å®šå’Œē¶­č­· TDP " +"é™åˆ¶ć€‚\n" +"å¦‚ęžœčØ­å‚™å“©ę½°ļ¼ŒTDP čØ­å®šå°‡åœØäø‹ę¬”å•Ÿå‹•ę™‚ē¦ē”Øć€‚" + +#. Setting: Add TDP to /sys for Steam (Requires Restart) +#. Field: title +msgid "Add TDP to /sys for Steam (Requires Restart)" +msgstr "ę–°å¢ž TDP 到 /sys 仄供 Steam 使用 (éœ€č¦é‡ę–°å•Ÿå‹•)" + +#. Setting: Add TDP to /sys for Steam (Requires Restart) +#. Field: hint +msgid "" +"Uses a FUSE mount to add TDP attributes to /sys/class/drm. This fixes the" +" TDP slider in Steam." +msgstr "使用 FUSE ęŽ›č¼‰å°‡ TDP å±¬ę€§ę·»åŠ åˆ° /sys/class/drmć€‚é€™åÆä»„äæ®å¾© Steam äø­ēš„ TDP ę»‘å”Šć€‚" + +#. Setting: Enforce Device TDP Limits +#. Field: title +msgid "Enforce Device TDP Limits" +msgstr "å¼·åˆ¶ä½æē”ØčØ­å‚™ TDP 限制" + +#. Setting: Enforce Device TDP Limits +#. Field: hint +msgid "" +"When this option is on, the settings will adhere to the limits set out by" +" the device manufacturer, subject to their availability.\n" +"With it off, the TDP settings ranges will expand to what is logically " +"possible for the current device (regardless of manufacturer " +"specifications).\n" +"All settings outside specifications will be set to system specifications " +"after rebooting." +msgstr "" +"ē•¶ę­¤éøé …é–‹å•Ÿę™‚ļ¼ŒčØ­å®šå°‡éµå¾ŖčØ­å‚™č£½é€ å•†č¦å®šēš„é™åˆ¶ļ¼Œä½†éœ€č¦–å…¶åÆē”Øę€§č€Œå®šć€‚\n" +"é—œé–‰ę™‚ļ¼ŒTDP čØ­å®šēÆ„åœå°‡ę““å±•č‡³ē•¶å‰čØ­å‚™é‚č¼ÆäøŠåÆčƒ½ēš„ēÆ„åœļ¼ˆäøč€ƒę…®č£½é€ å•†č¦ę ¼ļ¼‰ć€‚\n" +"é‡ę–°å•Ÿå‹•å¾Œļ¼Œę‰€ęœ‰č¶…å‡ŗč¦ę ¼ēš„čØ­å®šå°‡č¢«é‡ē½®ē‚ŗē³»ēµ±č¦ę ¼ć€‚" + +#. Setting: Processor Settings +#. Field: title +msgid "Processor Settings" +msgstr "č™•ē†å™ØčØ­å®š" + +#. Setting: CPU Settings +#. Field: title +msgid "CPU Settings" +msgstr "CPU 設定" + +#. Setting: Auto +#. Field: title +msgid "Auto" +msgstr "自動" + +#. Setting: Auto +#. Field: hint +msgid "" +"Handheld Daemon will manage the energy management settings. This includes" +" CPU governor, CPU boost, GPU frequency, and CPU power preferences. At " +"low TDPs, the CPU will be tuned down and at other TDPs, it will use " +"balanced settings." +msgstr "" +"Handheld Daemon å°‡ē®”ē†čƒ½ęŗčØ­å®šć€‚é€™åŒ…ę‹¬ CPU čŖæåŗ¦ēØ‹å¼ć€CPU boost态GPU é »ēŽ‡å’Œ CPU åŠŸēŽ‡åå„½ć€‚åœØä½Ž TDP äø‹," +" CPU 將被調敓, åœØå…¶ä»– TDP äø‹, å®ƒå°‡ä½æē”Øå¹³č””čØ­å®šć€‚" + +#. Setting: Manual +#. Field: title +msgid "Manual" +msgstr "手動" + +#. Setting: Manual +#. Field: hint +msgid "Allows you to set the energy management settings manually." +msgstr "å…čØ±ę‚Øę‰‹å‹•čØ­å®ščƒ½ęŗē®”ē†čØ­å®šć€‚" + +#. Setting: CPU Power (EPP) +#. Field: title +msgid "CPU Power (EPP)" +msgstr "CPU電源(EPP)" + +#. Setting: CPU Power (EPP) +#. Field: hint +msgid "" +"Sets the energy performance preference for the CPU. Keep on balanced for " +"good performance on all TDPs. Options map to `power`, `balance_power`, " +"`balance_performance`. Performance is not recommended and is not " +"included." +msgstr "" +"設定 CPU ēš„čƒ½ęŗę€§čƒ½åå„½ć€‚åœØę‰€ęœ‰ TDP äø‹äæęŒå¹³č””ä»„ē²å¾—č‰Æå„½ēš„ę€§čƒ½ć€‚éøé …ę˜ å°„åˆ° `power`, `balance_power`, " +"`balance_performance`ć€‚äøęŽØč–¦ä½æē”Øę€§čƒ½éøé …ļ¼Œä¹ŸäøåŒ…ę‹¬åœØå…§ć€‚" + +#. Setting: CPU Power (EPP) +#. Option: power +msgid "Low" +msgstr "低" + +#. Setting: CPU Power (EPP) +#. Option: balance_power +#. Setting: Power Profile +#. Option: balanced +#. Setting: Balanced +#. Field: title +#. Setting: Platform Profile +#. Setting: Energy Policy +msgid "Balanced" +msgstr "平蔔" + +#. Setting: CPU Power (EPP) +#. Option: balance_performance +msgid "High" +msgstr "高" + +#. Setting: CPU Minimum Frequency +#. Field: title +msgid "CPU Minimum Frequency" +msgstr "CPU ęœ€å°é »ēŽ‡" + +#. Setting: CPU Minimum Frequency +#. Field: hint +msgid "" +"Sets the minimum frequency for the CPU. Using 400MHz will save battery in" +" light games. However, the delay of increasing the frequency may cause " +"minor stutters, especially in VRR displays." +msgstr "" +"設定 CPU ēš„ęœ€å°é »ēŽ‡ć€‚åœØč¼•é‡ē“šéŠęˆ²äø­ä½æē”Ø 400MHz åÆä»„ēÆ€ēœé›»ę± ć€‚ē„¶č€Œļ¼Œå› é »ēŽ‡ēš„č€Œå¢žåŠ ēš„å»¶é²åÆčƒ½ęœƒå°Žč‡“č¼•å¾®ēš„å”é “ļ¼Œå°¤å…¶ę˜ÆåœØ VRR " +"é”Æē¤ŗå™ØäøŠć€‚" + +#. Setting: CPU Minimum Frequency +#. Option: min +msgid "400MHz" +msgstr "" + +#. Setting: CPU Minimum Frequency +#. Option: nonlinear +msgid "1GHz" +msgstr "" + +#. Setting: CPU Boost +#. Field: title +msgid "CPU Boost" +msgstr "CPU 加速" + +#. Setting: CPU Boost +#. Field: hint +msgid "" +"Enables or disables the CPU boost frequencies. Disabling lowers total " +"consumption by 2W with minimal performance impact." +msgstr "å•Ÿē”Øęˆ–ē¦ē”Ø CPU åŠ é€Ÿé »ēŽ‡ć€‚ē¦ē”ØåÆä»„å°‡ēø½ę¶ˆč€—é™ä½Ž 2Wļ¼Œå°ę€§čƒ½å½±éŸæå¾ˆå°ć€‚" + +#. Setting: CPU Boost +#. Option: disabled +#. Setting: Custom Scheduler +#. Setting: Disabled +#. Field: title +#. Setting: Extreme Standby Mode +msgid "Disabled" +msgstr "禁用" + +#. Setting: CPU Boost +#. Option: enabled +#. Setting: Extreme Standby Mode +msgid "Enabled" +msgstr "å•Ÿē”Ø" + +#. Setting: Custom Scheduler +#. Field: title +msgid "Custom Scheduler" +msgstr "自訂調度" + +#. Setting: Custom Scheduler +#. Field: hint +msgid "" +"Allows attaching a scheduler to the kernel sched_ext. Schedulers need to " +"be installed and kernel needs to support sched_ext." +msgstr "å…čØ±å°‡čŖæåŗ¦ēØ‹åŗé™„åŠ åˆ°å…§ę ø sched_extć€‚čŖæåŗ¦ēØ‹åŗéœ€č¦å®‰č£ļ¼Œå…§ę øéœ€č¦ę”ÆęŒ sched_ext怂" + +#. Setting: Custom Scheduler +#. Option: scx_lavd +msgid "LAVD" +msgstr "" + +#. Setting: Custom Scheduler +#. Option: scx_bpfland +msgid "bpfland" +msgstr "" + +#. Setting: Custom Scheduler +#. Option: scx_rusty +msgid "rusty" +msgstr "" + +#. Setting: GPU Frequency +#. Field: title +msgid "GPU Frequency" +msgstr "GPU é »ēŽ‡" + +#. Setting: GPU Frequency +#. Field: hint +msgid "" +"Pins the GPU to a certain frequency. Helps in certain games that are CPU " +"or GPU heavy by shifting power to or from the GPU. Has a minor effect." +msgstr "將 GPU å›ŗå®šåœØęŸå€‹é »ēŽ‡ć€‚é€šéŽå°‡é›»ęŗå¾ž GPU č½‰ē§»ęˆ–č½‰ē§»č‡³ GPU ä¾†å¹«åŠ©åœØęŸäŗ› CPU ꈖ GPU č² č¼‰č¼ƒé‡ēš„éŠęˆ²äø­ć€‚ęœ‰č¼•å¾®ēš„å½±éŸæć€‚" + +#. Setting: Auto +#. Field: hint +msgid "Lets the GPU manage its own frequency." +msgstr "GPU č‡Ŗč”Œē®”ē†å…¶é »ēŽ‡ć€‚" + +#. Setting: Max Limit +#. Field: title +msgid "Max Limit" +msgstr "ęœ€å¤§é™åˆ¶" + +#. Setting: Max Limit +#. Field: hint +msgid "Limits the maximum frequency of the GPU." +msgstr "限制 GPU ēš„ęœ€å¤§é »ēŽ‡ć€‚" + +#. Setting: Maximum Frequency +#. Field: title +msgid "Maximum Frequency" +msgstr "ęœ€å¤§é »ēŽ‡" + +#. Setting: Range +#. Field: title +msgid "Range" +msgstr "ēÆ„åœ" + +#. Setting: Range +#. Field: hint +msgid "Sets the GPU frequency to a range." +msgstr "將 GPU é »ēŽ‡čØ­å®šē‚ŗäø€å€‹ēÆ„åœć€‚" + +#. Setting: Minimum Frequency +#. Field: title +msgid "Minimum Frequency" +msgstr "ęœ€å°é »ēŽ‡" + +#. Setting: Fixed +#. Field: title +msgid "Fixed" +msgstr "å›ŗå®š" + +#. Setting: Fixed +#. Field: hint +msgid "Pins the GPU to a certain frequency (not recommended)." +msgstr "將 GPU å›ŗå®šåœØęŸå€‹é »ēŽ‡ (äøęŽØč–¦)怂" + +#. Setting: Frequency +#. Field: title +msgid "Frequency" +msgstr "é »ēŽ‡" + +#. Setting: Conflict Detected +#. Field: title +msgid "Conflict Detected" +msgstr "ęŖ¢ęø¬åˆ°č”ēŖ" + +#. Setting: Enable Processor Settings +#. Field: title +msgid "Enable Processor Settings" +msgstr "å•Ÿē”Øč™•ē†å™ØęŽ§åˆ¶" + +#. Setting: Enable energy management +#. Field: title +msgid "Enable energy management" +msgstr "å•Ÿē”Øé›»ęŗē®”ē†" + +#. Setting: Enable energy management +#. Field: hint +msgid "" +"Handheld daemon will manage the power preferences for the system, " +"including Governor, Boost, GPU frequency, and EPP. In addition, Handheld " +"daemon will launch a PPD service to replace PPD's role in the system. " +msgstr "" +"Handheld daemon å°‡ē®”ē†ē³»ēµ±ēš„é›»ęŗåå„½ļ¼ŒåŒ…ę‹¬čŖæåŗ¦ēØ‹åŗć€Boost态GPU é »ēŽ‡å’Œ EPPć€‚ę­¤å¤–ļ¼ŒHandheld daemon " +"å°‡å•Ÿå‹•äø€å€‹ PPD ęœå‹™ä»„å–ä»£ē³»ēµ±äø­åŽŸęœ‰ēš„ PPD 角色。" + +#. Setting: Enable PPD Emulation (KDE/Gnome Power) +#. Field: title +msgid "Enable PPD Emulation (KDE/Gnome Power)" +msgstr "å•Ÿē”Ø PPD ęØ”ę“¬ļ¼ˆKDE/Gnome 電源)" + +#. Setting: Enable PPD Emulation (KDE/Gnome Power) +#. Field: hint +msgid "Enable PPD service to manage the power preferences for the system." +msgstr "å•Ÿē”Ø PPD ęœå‹™ä»„ē®”ē†ē³»ēµ±ēš„é›»ęŗåå„½ć€‚" + +msgid "Steam is controlling TDP" +msgstr "Steam ę­£åœØęŽ§åˆ¶ TDP" + +#. Setting: Asus TDP +#. Field: title +msgid "Asus TDP" +msgstr "čÆē¢© TDP" + +#. Setting: Asus TDP +#. Field: hint +msgid "Uses the interface of Armory Crate to set the TDP of the device." +msgstr "使用 Armory Crate ä»‹é¢čØ­å‚™ēš„ TDP怂" + +#. Setting: TDP Mode +#. Field: title +msgid "TDP Mode" +msgstr "TDP ęØ”å¼" + +#. Setting: Silent +#. Field: title +msgid "Silent" +msgstr "靜音" + +#. Setting: Performance +#. Field: title +#. Setting: Power Profile +#. Option: performance +#. Setting: Platform Profile +#. Setting: Energy Policy +msgid "Performance" +msgstr "ę•ˆčƒ½" + +#. Setting: Turbo +#. Field: title +msgid "Turbo" +msgstr "ę„µé€Ÿ" + +#. Setting: Custom +#. Field: title +msgid "Custom" +msgstr "č‡Ŗå®šē¾©" + +#. Setting: TDP +#. Field: title +msgid "TDP" +msgstr "" + +#. Setting: TDP +#. Field: hint +msgid "" +"Average TDP Target. TDP Boost is recommended for desktop use and does not" +" affect gaming." +msgstr "平均 TDP 目標。TDP å¢žå¼·é©ē”Øę–¼ę”Œé¢ä½æē”Ø, äøå½±éŸæéŠęˆ²ć€‚" + +#. Setting: TDP Boost +#. Field: title +msgid "TDP Boost" +msgstr "TDP å¢žå¼ŗ" + +#. Setting: TDP Boost +#. Field: hint +msgid "" +"Allows the device to temporarily boost by setting appropriate slow and " +"fast TDPs." +msgstr "å…čØ±čØ­å‚™é€šéŽčØ­å®šé©ē•¶ēš„ slow 和 fast TDP å€¼ä¾†ęå‡ę€§čƒ½ć€‚" + +#. Setting: +#. Field: title +msgid " " +msgstr " " + +#. Setting: Change TDP with View+Y +#. Field: title +msgid "Change TDP with View+Y" +msgstr "使用 View + Y éµę›“ę”¹ TDP" + +#. Setting: Change TDP with View+Y +#. Field: hint +msgid "" +"Allows you to cycle through TDP modes with the View+Y key combination. " +"Recommended to use with ROG Swap, as the View button will be muted to " +"games." +msgstr "允許你使用 View + Y ēµ„åˆéµåœØ TDP ęØ”å¼ä¹‹é–“å¾Ŗē’°ć€‚å»ŗč­°čˆ‡ ROG Swap 一起使用, 因為 View éµå°‡å› éŠęˆ²č€Œē„”ę•ˆć€‚" + +#. Setting: Custom Fan Curve +#. Field: title +msgid "Custom Fan Curve" +msgstr "č‡Ŗå®šē¾©é¢Øę‰‡ę›²ē·š" + +#. Setting: Custom Fan Curve +#. Field: hint +msgid "Allows you to set a custom fan curve." +msgstr "čØ­å®šč‡Ŗå®šē¾©é¢Øę‰‡ę›²ē·šć€‚" + +#. Setting: Disabled +#. Field: hint +msgid "Lets the device manage the fan curve on its own." +msgstr "č®“čØ­å‚™č‡Ŗč”Œē®”ē†é¢Øę‰‡ę›²ē·šć€‚" + +#. Setting: 30C +#. Field: title +msgid "30C" +msgstr "30°C" + +#. Setting: 30C +#. Field: hint +#. Setting: 40C +#. Setting: 50C +#. Setting: 60C +#. Setting: 70C +#. Setting: 80C +#. Setting: 90C +#. Setting: 100C +#. Setting: 10C +#. Setting: 20C +msgid "Sets the speed at the named temperature." +msgstr "čØ­å®šåœØęŒ‡å®šęø©åŗ¦äø‹ēš„é¢Øę‰‡é€Ÿåŗ¦ć€‚" + +#. Setting: 40C +#. Field: title +msgid "40C" +msgstr "40°C" + +#. Setting: 50C +#. Field: title +msgid "50C" +msgstr "50°C" + +#. Setting: 60C +#. Field: title +msgid "60C" +msgstr "60°C" + +#. Setting: 70C +#. Field: title +msgid "70C" +msgstr "70°C" + +#. Setting: 80C +#. Field: title +msgid "80C" +msgstr "80°C" + +#. Setting: 90C +#. Field: title +msgid "90C" +msgstr "90°C" + +#. Setting: 100C +#. Field: title +msgid "100C" +msgstr "100°C" + +#. Setting: Restore Default +#. Field: title +msgid "Restore Default" +msgstr "恢復預設" + +#. Setting: Restore Default +#. Field: hint +msgid "Restore a default sane fan curve." +msgstr "ę¢å¾©é čØ­ēš„é¢Øę‰‡ę›²ē·š" + +#. Setting: Fan Curve Limitation +#. Field: title +msgid "Fan Curve Limitation" +msgstr "é¢Øę‰‡ę›²ē·šé™åˆ¶" + +#. Setting: Charge Limit (%) +#. Field: title +msgid "Charge Limit (%)" +msgstr "充電限制 (%)" + +#. Setting: Charge Limit (%) +#. Field: hint +msgid "Applies a charge limit to the battery, 75% and up." +msgstr "å°é›»ę± ä½æē”Øå……é›»é™åˆ¶, 75% åŠä»„äøŠć€‚" + +#. Setting: Charge Limit (%) +#. Option: p70 +msgid "70%" +msgstr "" + +#. Setting: Charge Limit (%) +#. Option: p80 +msgid "80%" +msgstr "" + +#. Setting: Charge Limit (%) +#. Option: p85 +msgid "85%" +msgstr "" + +#. Setting: Charge Limit (%) +#. Option: p90 +msgid "90%" +msgstr "" + +#. Setting: Charge Limit (%) +#. Option: p95 +msgid "95%" +msgstr "" + +#. Setting: Charge Limit (%) +#. Option: disabled +msgid "Unset" +msgstr "äøé™åˆ¶" + +#. Setting: Extreme Standby Mode +#. Field: title +msgid "Extreme Standby Mode" +msgstr "ę„µé™å¾…ę©ŸęØ”å¼" + +#. Setting: Extreme Standby Mode +#. Field: hint +#, python-format +# msgid "" +# "Lowers the power consumption of the device from 4% to 1% overnight. " +# "Active only on battery. Turns off the power light and the controller " +# "requires longer to wake up." +# msgstr "å°‡čØ­å‚™ēš„č€—é›»é‡å¾ž 4% é™ä½Žč‡³ 1%ļ¼Œåƒ…åœØé›»ę± ęØ”å¼äø‹ęœ‰ę•ˆć€‚é—œé–‰é›»ęŗęŒ‡ē¤ŗē‡ˆļ¼ŒęŽ§åˆ¶å™Øéœ€č¦ę›“é•·ę™‚é–“ę‰čƒ½å–šé†’" + +#. Setting: Power +#. Field: title +#. Setting: Energy Policy +#. Option: power +msgid "Power" +msgstr "電源" + +#. Setting: Power Profile +#. Field: title +msgid "Power Profile" +msgstr "é›»ęŗé…ē½®" + +#. Setting: Power Profile +#. Field: hint +msgid "" +"Allows setting the power profile of the system using Power Profiles " +"Daemon." +msgstr "使用 Power Profiles Daemon čØ­å®šē³»ēµ±ēš„é›»ęŗé…ē½®ć€‚" + +#. Setting: Power Profile +#. Option: power-saver +msgid "Powersave" +msgstr "ēœé›»" + +#. Setting: Steamdeck Overclock (Requires Reboot) +#. Field: title +msgid "Steamdeck Overclock (Requires Reboot)" +msgstr "Steamdeck č¶…é » (éœ€č¦é‡ę–°å•Ÿå‹•)" + +#. Setting: Steamdeck Overclock (Requires Reboot) +#. Field: hint +msgid "" +"Allows setting the Steam TDP slider from 1-20W instead of 4-15W. " +"Unchecked, it is still setting TDP to 15W." +msgstr "允許 Steam TDP čØ­å®šē‚ŗ 1-20W, č€Œäøę˜Æ 4-15Wć€‚ęœŖéøę“‡ę™‚, 仍將 TDP čØ­å®šē‚ŗ 15W怂" + +msgid "Power Light" +msgstr "é›»ęŗęŒ‡ē¤ŗē‡ˆ" + +msgid "Legion L + Y changes TDP Mode" +msgstr "Legion L + Y 曓改 TDP ęØ”å¼" + +#. Setting: Lenovo TDP +#. Field: title +msgid "Lenovo TDP" +msgstr "聯想 TDP" + +#. Setting: Lenovo TDP +#. Field: hint +msgid "Uses the interface of Legion Space to set the TDP of the device." +msgstr "使用 Legion Space ēš„čØ­å‚™ TDP怂" + +#. Setting: Quiet +#. Field: title +#. Setting: Platform Profile +#. Option: quiet +msgid "Quiet" +msgstr "靜音" + +#. Setting: TDP +#. Field: hint +msgid "" +"Average TDP Target.\n" +"Sets the values STAMP and Skin Power Limit to it. If boost is enabled, " +"interpolates values for slow and fast TDPs based on those used by " +"Lenovo. If it is not, it sets the Slow limit equal to TDP and the Fast " +"limit to +2W. Boost is recommended for desktop use." +msgstr "" +"平均 TDP 目標。\n" +"設定 STAMP 和 Skin Power Limit ēš„å€¼ć€‚å¦‚ęžœå•Ÿē”Øäŗ† TDPå¢žå¼·ļ¼Œå‰‡ę ¹ę“ščÆęƒ³ä½æē”Øēš„å€¼ę’å€¼čØˆē®—å‡ŗ slow 和 fast TDP" +" ēš„å€¼ć€‚å¦‚ęžœę²’ęœ‰å•Ÿē”Øļ¼Œå‰‡å°‡ Slow é™åˆ¶čØ­å®šē‚ŗ TDPļ¼Œå°‡ Fast é™åˆ¶čØ­å®šē‚ŗ +2Wć€‚å»ŗč­°åœØę”Œé¢ä½æē”Øę™‚å•Ÿē”Ø boost怂" + +#. Setting: TDP Boost +#. Field: hint +msgid "Allows the device to boost by setting appropriate slow and fast TDPs." +msgstr "å…čØ±č£ē½®é€éŽčØ­å®šé©ē•¶ēš„ slow 和 fast TDP å€¼ä¾†ęå‡ę•ˆčƒ½ć€‚" + +#. Setting: Set Fan to Full Speed +#. Field: title +msgid "Set Fan to Full Speed" +msgstr "å°‡é¢Øę‰‡čØ­å®šē‚ŗęœ€é«˜é€Ÿåŗ¦" + +#. Setting: Custom Fan Curve +#. Field: hint +msgid "" +"Allows you to set a custom fan curve. This fan curve is only officially " +"supported on custom mode, but you can nevertheless use it in other power " +"modes. This fan curve needs to be reapplied and is reapplied every time " +"you switch TDP modes." +msgstr "" +"å…čØ±ę‚Øč‡Ŗå®šē¾©é¢Øę‰‡ę›²ē·šć€‚ę­¤é¢Øę‰‡ę›²ē·šåƒ…åœØč‡Ŗå®šē¾©ęØ”å¼äø‹å¾—åˆ°å®˜ę–¹ę”Æę“ļ¼Œä½†ę‚Øä»ē„¶åÆä»„åœØå…¶ä»–é›»ęŗęØ”å¼äø‹ä½æē”Øć€‚ę­¤é¢Øę‰‡ę›²ē·šéœ€č¦é‡ę–°ę‡‰ē”Øļ¼Œäø¦äø”åœØåˆ‡ę› TDP " +"ęØ”å¼ę™‚é‡ę–°ę‡‰ē”Øć€‚" + +#. Setting: Disabled +#. Field: hint +msgid "" +"Lets Legion GO manage the curve on its own. Setting this option will " +"cause a mode change to reset the fan curve." +msgstr "讓 Legion GO č‡Ŗč”Œē®”ē†é¢Øę‰‡ę›²ē·šć€‚čØ­å®šę­¤éøé …å°‡å°Žč‡“ęØ”å¼ę›“ę”¹ä»„é‡ē½®é¢Øę‰‡ę›²ē·šć€‚" + +#. Setting: 10C +#. Field: title +msgid "10C" +msgstr "10°C" + +#. Setting: 20C +#. Field: title +msgid "20C" +msgstr "20°C" + +#. Setting: Enforce Windows Minimums +#. Field: title +msgid "Enforce Windows Minimums" +msgstr "執蔌Windowsēš„ęœ€ä½Žč¦ę±‚" + +#. Setting: Enforce Windows Minimums +#. Field: hint +msgid "Enforce the minimum fan curve from Legion Space." +msgstr "å¼·åˆ¶åŸ·č”Œä¾†č‡Ŗ Legion Space ēš„ęœ€ä½Žé¢Øę‰‡ę›²ē·šć€‚" + +#. Setting: Restore Default +#. Field: hint +msgid "Reset to the original fan curve provided by Lenovo in BIOS V28." +msgstr "é‡ē½®ē‚ŗ BIOS V28 äø­ē”±čÆęƒ³ęä¾›ēš„åŽŸå§‹é¢Øę‰‡ę›²ē·šć€‚" + +#. Setting: Show TDP changes with RGB +#. Field: title +msgid "Show TDP changes with RGB" +msgstr "使用 RGB 锯示 TDP 狀態" + +#. Setting: Charge Limit (80%) +#. Field: title +msgid "Charge Limit (80%)" +msgstr "充電限制 (80%)" + +#. Setting: Charge Limit (80%) +#. Field: hint +msgid "Limits device charging to 80%." +msgstr "é™åˆ¶čØ­å‚™å……é›»č‡³80%" + +#. Setting: Power Light (Awake) +#. Field: title +msgid "Power Light (Awake)" +msgstr "é›»ęŗęŒ‡ē¤ŗē‡ˆ(å–šé†’ę™‚)" + +#. Setting: Power Light (Sleep) +#. Field: title +msgid "Power Light (Sleep)" +msgstr "é›»ęŗęŒ‡ē¤ŗē‡ˆ(ē”ēœ ę™‚)" + +#. Setting: TDP Settings +#. Field: title +msgid "TDP Settings" +msgstr "TDP 設定" + +#. Setting: TDP +#. Field: hint +msgid "Controls all Ryzen SMU settings through preset curves." +msgstr "é€šéŽé čØ­ę›²ē·šęŽ§åˆ¶ę‰€ęœ‰ Ryzen SMU čØ­å®šć€‚" + +#. Setting: Custom Fan Curve +#. Field: hint +msgid "" +"Allows you to set a custom fan curve and to choose the temperature probe " +"(Edge or Junction). Junction is the peak temperature of the chip: " +"responds faster and prevents throttling. Edge is the temperature of the " +"chip: responds slower and prevents overheating." +msgstr "" +"å…čØ±ę‚ØčØ­ē½®č‡Ŗå®šē¾©é¢Øę‰‡ę›²ē·šäø¦éøę“‡ęŗ«åŗ¦é»žļ¼ˆEdgeꈖJunction)。" +"Junctionę˜Æę™¶ē‰‡ēš„ē†±é»žęŗ«åŗ¦ļ¼šåę‡‰ę›“åæ«äø¦é˜²ę­¢é™é »ć€‚" +"Edgeę˜Æę™¶ē‰‡ēš„ęŗ«åŗ¦ļ¼šåę‡‰č¼ƒę…¢äø¦é˜²ę­¢éŽē†±ć€‚" + +#. Setting: Manual (Edge, Smooth) +#. Field: title +msgid "Manual (Edge, Smooth)" +msgstr "手動 (Edge, 平滑)" + +#. Setting: Reset to Default +#. Field: title +msgid "Reset to Default" +msgstr "恢復預設" + +#. Setting: Manual (Tctl, Fast) +#. Field: title +msgid "Manual (Tctl, Fast)" +msgstr "手動 (Tctl, åæ«é€Ÿ)" + +#. Setting: Advanced Configurator +#. Field: title +msgid "Advanced Configurator" +msgstr "é€²éšŽčØ­å®š" + +#. Setting: Apply Settings +#. Field: title +msgid "Apply Settings" +msgstr "應用" + +#. Setting: TDP Status +#. Field: title +msgid "TDP Status" +msgstr "TDP 狀態" + +#. Setting: Platform Profile +#. Field: title +msgid "Platform Profile" +msgstr "å¹³å°é…ē½®" + +#. Setting: Platform Profile +#. Option: disabled +msgid "Not Set" +msgstr "未設定" + +#. Setting: Platform Profile +#. Option: low-power +msgid "Low Power" +msgstr "ä½ŽåŠŸč€—" + +#. Setting: Platform Profile +#. Option: cool +msgid "Cool" +msgstr "涼爽" + +#. Setting: Platform Profile +#. Option: balanced-performance +msgid "Balanced Performance" +msgstr "å¹³č””ę€§čƒ½" + +#. Setting: Energy Policy +#. Field: title +msgid "Energy Policy" +msgstr "電源策畄" + +#. Setting: Standard Parameters +#. Field: title +msgid "Standard Parameters" +msgstr "ęØ™ęŗ–åƒę•ø" + +#. Setting: Standard Parameters +#. Field: hint +msgid "" +"Standard TDP parameters for Ryzen processors. All need to be set to " +"properly control the TDP of the device.\n" +"Ryzen processors have 2 modes: STTv2 and STAPM (legacy). AMD suggests to" +" manufacturers to use STTv2, which makes the Legion Go the only device " +"to offer the STAPM alternative through a BIOS setting.\n" +"In STTv2, the device will keep boosting until the \"skin\" of the device " +"(hottest user accessible spot) reaches a manufacturer set temperature. " +"Then, the device will use the Skin Temp TDP limit. In STAPM, the device " +"averages the TDP values from the 1-3 previous minutes and keeps that " +"value under the STAPM TDP limit. Either mode ignores the other mode's " +"limit (STAPM limit does nothing on STT and Skin Temp Limit does nothing " +"on STAPM), so both should be set.\n" +"The Fast and Slow limits control boosting behavior. The Fast TDP limit is" +" the actual max TDP value of the device. Then,the Slow TDP limit averages" +" the last 10-20s of TDP values and keeps the value below it." +msgstr "" +"Ryzen č™•ē†å™Øēš„ęØ™ęŗ– TDP åƒę•øć€‚ę‰€ęœ‰åƒę•øéœ€č¦čØ­ē½®ē‚ŗę­£ē¢ŗęŽ§åˆ¶čØ­å‚™ēš„ TDP怂\n" +"Ryzen č™•ē†å™Øęœ‰å…©ēØ®ęØ”å¼ļ¼šSTTv2 和 STAPMļ¼ˆå‚³ēµ±ęØ”å¼ļ¼‰ć€‚AMD 建議製造商使用 STTv2ļ¼Œé€™ä½æå¾— Legion Go ęˆē‚ŗå”Æäø€é€šéŽ" +" BIOS čØ­å®šęä¾› STAPM éøé …ēš„čØ­å‚™ć€‚\n" +"在 STTv2 ęØ”å¼äø‹ļ¼ŒčØ­å‚™å°‡ęŒēŗŒęå‡ę€§čƒ½ļ¼Œē›“åˆ°čØ­å‚™ēš„ \"č”Øé¢\"(ē”Øęˆ¶åÆęŽ„č§øēš„ęœ€ē†±é»ž) é”åˆ°č£½é€ å•†čØ­å®šēš„ęŗ«åŗ¦ć€‚ē„¶å¾Œļ¼ŒčØ­å‚™å°‡ä½æē”Øč”Øé¢ęŗ«åŗ¦ TDP" +" é™åˆ¶ć€‚åœØ STAPM ęØ”å¼äø‹ļ¼ŒčØ­å‚™ęœƒå¹³å‡éŽåŽ» 1-3 åˆ†é˜ēš„ TDP å€¼ļ¼Œäø¦äæęŒč©²å€¼ä½Žę–¼ STAPM TDP " +"é™åˆ¶ć€‚ä»»äø€ęØ”å¼éƒ½ęœƒåæ½ē•„å¦äø€ęØ”å¼ēš„é™åˆ¶ļ¼ˆSTAPM é™åˆ¶åœØ STT äøŠē„”ę•ˆļ¼Œč”Øé¢ęŗ«åŗ¦é™åˆ¶åœØ STAPM äøŠē„”ę•ˆļ¼‰ļ¼Œå› ę­¤å…©č€…éƒ½ę‡‰č©²čØ­ē½®ć€‚\n" +"åæ«é€Ÿå’Œę…¢é€Ÿé™åˆ¶ęŽ§åˆ¶ęå‡č”Œē‚ŗć€‚åæ«é€Ÿ TDP é™åˆ¶ę˜ÆčØ­å‚™ēš„åÆ¦éš›ęœ€å¤§TDP å€¼ć€‚ē„¶å¾Œļ¼Œę…¢é€Ÿ TDP é™åˆ¶ęœƒå¹³å‡éŽåŽ» 10-20 ē§’ēš„ TDP " +"å€¼ļ¼Œäø¦äæęŒč©²å€¼ä½Žę–¼ę­¤é™åˆ¶ć€‚" + +#. Setting: Fast TDP Limit +#. Field: title +msgid "Fast TDP Limit" +msgstr "åæ«é€ŸTDP限制" + +#. Setting: Slow TDP Limit +#. Field: title +msgid "Slow TDP Limit" +msgstr "ę…¢é€ŸTDP限制" + +#. Setting: Skin Temp TDP Limit +#. Field: title +msgid "Skin Temp TDP Limit" +msgstr "č”Øé¢ęŗ«åŗ¦TDP限制" + +#. Setting: STAPM TDP Limit +#. Field: title +msgid "STAPM TDP Limit" +msgstr "STAPM TDP限制" + +#. Setting: Advanced Parameters +#. Field: title +msgid "Advanced Parameters" +msgstr "é€²éšŽåƒę•ø" + +#. Setting: Advanced Parameters +#. Field: hint +msgid "" +"The Advanced Parameters below control boosting behavior and need to be " +"adjusted per device depending on its cooling system. They mostly affect " +"boosting behavior, which is important for desktop use.\n" +"The exception is the Temp Target (TCTL), which controls the max " +"temperature of the CPU die. On most devices, it can safely be raised up " +"to 100C. However, if a temperature spike makes the chip reach 105C, it " +"will enter a thermal protection mode, which is 5W, for a couple of " +"minutes.\n" +"The integration times for Slow TDP and STAPM influence how many previous " +"TDP values the CPU will average to calculate its current Slow and STAPM " +"TDP values." +msgstr "" +"äø‹é¢ēš„é«˜ē“šåƒę•øęŽ§åˆ¶ęå‡č”Œē‚ŗļ¼Œéœ€č¦ę ¹ę“šęÆå€‹čØ­å‚™ēš„å†·å»ē³»ēµ±é€²č”ŒčŖæę•“ć€‚å®ƒå€‘äø»č¦å½±éŸæęå‡č”Œē‚ŗļ¼Œé€™å°ę”Œé¢ä½æē”Øå¾ˆé‡č¦ć€‚\n" +"ä¾‹å¤–ę˜Æęŗ«åŗ¦ē›®ęØ™ (TCTL)ļ¼Œå®ƒęŽ§åˆ¶ CPU å…§ę øēš„ęœ€é«˜ęŗ«åŗ¦ć€‚åœØå¤§å¤šę•øčØ­å‚™äøŠļ¼Œå®ƒåÆä»„å®‰å…Øåœ°ęé«˜åˆ° 100°Cć€‚ä½†ę˜Æļ¼Œå¦‚ęžœęŗ«åŗ¦ēŖē„¶äøŠå‡åˆ° " +"105°Cļ¼Œå®ƒå°‡é€²å…„ē†±äæč­·ęØ”å¼ļ¼ŒåŠŸč€—é™č‡³ 5Wļ¼ŒęŒēŗŒå¹¾åˆ†é˜ć€‚\n" +"ę…¢é€Ÿ TDP 和 STAPM ēš„ē©åˆ†ę™‚é–“å½±éŸæ CPU å°‡å¹³å‡å¤šå°‘å€‹å…ˆå‰ēš„ TDP å€¼ä¾†čØˆē®—å…¶ē•¶å‰ēš„ę…¢é€Ÿå’Œ STAPM TDP 值。" + +#. Setting: Temp Target (TCTL) +#. Field: title +msgid "Temp Target (TCTL)" +msgstr "溫度目標 (TCTL)" + +#. Setting: Slow Limit Integration Time +#. Field: title +msgid "Slow Limit Integration Time" +msgstr "ę…¢é€Ÿé™åˆ¶ē©åˆ†ę™‚é–“" + +#. Setting: STAPM Limit Integration Time +#. Field: title +msgid "STAPM Limit Integration Time" +msgstr "STAPM é™åˆ¶ē©åˆ†ę™‚é–“" + +#. Setting: Enable Advanced Parameters +#. Field: title +msgid "Enable Advanced Parameters" +msgstr "å•Ÿē”Øé€²éšŽåƒę•ø" + diff --git a/i18n/zh_TW/LC_MESSAGES/hhd.po b/i18n/zh_TW/LC_MESSAGES/hhd.po new file mode 100644 index 00000000..99de62f9 --- /dev/null +++ b/i18n/zh_TW/LC_MESSAGES/hhd.po @@ -0,0 +1,2059 @@ +# Chinese (Traditional, Taiwan) translations for PROJECT. +# Copyright (C) 2024 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2024. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2025-02-06 18:26+0100\n" +"PO-Revision-Date: 2025-02-04 16:36+0800\n" +"Last-Translator: Alex \n" +"Language: zh_Hant_TW\n" +"Language-Team: zh_Hant_TW \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#. Section Name for: tdp +msgid "TDP" +msgstr "" + +#. Section Name for: rgb +msgid "RGB" +msgstr "" + +#. Section Name for: controllers +#. Setting: Controller +#. Field: title +msgid "Controller" +msgstr "ęŽ§åˆ¶å™Ø" + +#. Section Name for: wincontrols +#. Setting: WinControls +#. Field: title +msgid "WinControls" +msgstr "WinęŽ§åˆ¶å™Ø" + +#. Section Name for: gamemode +msgid "General" +msgstr "äø€čˆ¬" + +#. Section Name for: updates +msgid "Updates" +msgstr "ꛓꖰ" + +#. Section Name for: debug +msgid "Bugreport" +msgstr "éŒÆčŖ¤å ±å‘Š" + +#. Section Name for: shortcuts +msgid "Shortcuts" +msgstr "åæ«ę·éµ" + +#. Section Name for: hhd +msgid "Settings" +msgstr "設定" + +#. Setting: Core Settings +#. Field: title +msgid "Core Settings" +msgstr "核心設定" + +#. Setting: Language +#. Field: title +msgid "Language" +msgstr "čŖžčØ€" + +#. Setting: Language +#. Option: system +#. Setting: System +#. Field: title +msgid "System" +msgstr "系統" + +#. Setting: Language +#. Option: C +msgid "English" +msgstr "" + +#. Setting: Language +#. Option: zh +msgid "Chinese" +msgstr "äø­ę–‡" + +#. Setting: Language +#. Option: pt +msgid "Portugese" +msgstr "PortuguĆŖs" + +#. Setting: Theme +#. Field: title +msgid "Theme" +msgstr "主题" + +#. Setting: Theme +#. Field: hint +msgid "" +"Allows changing the theme in the UI. Default is either Diavolo or your " +"distribution's theme." +msgstr "在 HHD-UI äø­ę›“ę”¹äø»é¢˜ć€‚é čØ­ Diavolo ęˆ–ę‚Øēš„ē™¼č”Œē‰ˆäø»é¢˜" + +#. Setting: Theme +#. Option: default +#. Setting: Default +#. Field: title +msgid "Default" +msgstr "預設" + +#. Setting: Theme +#. Option: diavolo +msgid "Diavolo" +msgstr "" + +#. Setting: Theme +#. Option: ocean +msgid "Atlantis" +msgstr "" + +#. Setting: Theme +#. Option: vapor +msgid "Vapor" +msgstr "" + +#. Setting: Theme +#. Option: blood_orange +msgid "Blood Orange" +msgstr "" + +#. Setting: Reset Settings +#. Field: title +msgid "Reset Settings" +msgstr "é‡ē½®čØ­å®š" + +#. Setting: Reset Settings +#. Field: hint +msgid "Resets all Handheld Daemon settings to their default values." +msgstr "å°‡ę‰€ęœ‰ Handheld Daemon é‡čØ­ē‚ŗé čØ­å€¼" + +#. Setting: It is no longer possible to update Decky from here. If you see +#. this, update the Decky plugin manually. +#. Field: title +msgid "" +"It is no longer possible to update Decky from here. If you see this, " +"update the Decky plugin manually." +msgstr "Decky ę’ä»¶äøå†ę”ÆęŒå¾žé€™č£”ę›“ę–°ć€‚å¦‚ęžœę‚Øēœ‹åˆ°ę­¤ę¶ˆęÆļ¼Œč«‹ę‰‹å‹•ę›“ę–° Decky ę’ä»¶ć€‚" + +#. Setting: Handheld Daemon Version +#. Field: title +msgid "Handheld Daemon Version" +msgstr "Handheld Daemon ē‰ˆęœ¬" + +#. Setting: Handheld Daemon Version +#. Field: hint +#. Setting: Handheld Daemon UI Version +#. Setting: Adjustor (TDP) Version +msgid "Displays the Handheld Daemon version." +msgstr "锯示 Handheld Daemon ē‰ˆęœ¬" + +#. Setting: Handheld Daemon UI Version +#. Field: title +msgid "Handheld Daemon UI Version" +msgstr "Handheld Daemon UI ē‰ˆęœ¬" + +#. Setting: Adjustor (TDP) Version +#. Field: title +msgid "Adjustor (TDP) Version" +msgstr "Adjustor (TDP) ē‰ˆęœ¬" + +#. Setting: Update (Stable) +#. Field: title +msgid "Update (Stable)" +msgstr "ꛓꖰ (ē©©å®šē‰ˆ)" + +#. Setting: Update (Stable) +#. Field: hint +msgid "Updates to the latest version from PyPi (local install only)." +msgstr "從 PyPi ę›“ę–°åˆ°ęœ€ę–°ē‰ˆęœ¬ (åƒ…ęœ¬åœ°å®‰č£…)" + +#. Setting: Update (Unstable) +#. Field: title +msgid "Update (Unstable)" +msgstr "ꛓꖰ (äøēØ³å®šē‰ˆ)" + +#. Setting: Update (Unstable) +#. Field: hint +msgid "Updates to the master branch from git (local install only)." +msgstr "ę›“ę–°åˆ° git ēš„ master åˆ†ę”Æ (åƒ…ęœ¬åœ°å®‰č£…)" + +#. Setting: Update Error +#. Field: title +msgid "Update Error" +msgstr "ę›“ę–°éŒÆčŖ¤" + +#. Setting: API Configuration +#. Field: title +msgid "API Configuration" +msgstr "API 設定" + +#. Setting: API Configuration +#. Field: hint +msgid "Settings for configuring the http endpoint of HHD." +msgstr "HHD ēš„ http 端設定" + +#. Setting: Enable the API +#. Field: title +msgid "Enable the API" +msgstr "å•Ÿē”Ø API" + +#. Setting: Enable the API +#. Field: hint +msgid "Enables the API of Handheld Daemon (required for decky and ui)." +msgstr "å•Ÿē”Ø Handheld Daemon ēš„ API (decky 和 hhd-ui åæ…éœ€) " + +#. Setting: API Port +#. Field: title +msgid "API Port" +msgstr "API ē«Æå£" + +#. Setting: API Port +#. Field: hint +msgid "Which port should the API be on?" +msgstr "API č¦ä½æē”Øå“Ŗå€‹ē«Æå£(port)?" + +#. Setting: Limit Access to localhost +#. Field: title +msgid "Limit Access to localhost" +msgstr "åƒ…é™ęœ¬åœ°čØŖå•" + +#. Setting: Limit Access to localhost +#. Field: hint +msgid "Sets the API target to '127.0.0.1' instead '0.0.0.0'." +msgstr "API ē›£č½ē›®ęØ™čØ­å®šē‚ŗ '127.0.0.1' č€Œäøę˜Æ '0.0.0.0'" + +#. Setting: Use Security token +#. Field: title +msgid "Use Security token" +msgstr "使用安全 Token" + +#. Setting: Use Security token +#. Field: hint +msgid "" +"Generates a security token in `~/.config/hhd/token` that is required for " +"authentication." +msgstr "在 `~/.config/hhd/token` ē”¢ē”Ÿäø€å€‹å®‰å…ØToken, 用於驗證" + +#. Setting: Handheld +#. Field: title +msgid "Handheld" +msgstr "ęŽŒę©Ÿ" + +#. Setting: Handheld +#. Field: hint +#. Setting: Orange Pi Neo +#. Setting: OneXPlayer Controller +msgid "Allows for configuring your handheld's controller to a unified output." +msgstr "åÆä»„čØ­å®šę‚Øēš„ęŽŒę©ŸęŽ§åˆ¶å™Øē‚ŗēµ±äø€čØ­å‚™č¼øå‡ŗ" + +#. Setting: Controller Emulation +#. Field: title +msgid "Controller Emulation" +msgstr "ęŽ§åˆ¶å™ØęØ”ę“¬" + +#. Setting: Controller Emulation +#. Field: hint +msgid "Emulate different controller types to fuse your device's features." +msgstr "ęØ”ę“¬äøåŒé”žåž‹ēš„ęŽ§åˆ¶å™Ø, ä»„ä¾æčžåˆčØ­å‚™ē‰¹ę€§" + +#. Setting: Motion Support +#. Field: title +msgid "Motion Support" +msgstr "é«”ę„Ÿę”Æę“" + +#. Setting: Motion Support +#. Field: hint +msgid "Enable gyroscope/accelerometer (IMU) support (.3% background CPU use)" +msgstr "å•Ÿē”Øé™€čžŗå„€/åŠ é€Ÿåŗ¦čØˆ (IMU) ę”Æę“ (0.3% čƒŒę™Æ CPU 使用)" + +#. Setting: Motion Hz +#. Field: title +msgid "Motion Hz" +msgstr "é«”ę„Ÿå–ęØ£ēŽ‡" + +#. Setting: Motion Hz +#. Field: hint +msgid "Sets the sampling frequency for the IMU." +msgstr "IMU ēš„å–ęØ£é »ēŽ‡" + +#. Setting: Nintendo Mode (A-B Swap) +#. Field: title +msgid "Nintendo Mode (A-B Swap)" +msgstr "ä»»å¤©å ‚ęØ”å¼ (A-B äŗ¤ę›)" + +#. Setting: Nintendo Mode (A-B Swap) +#. Field: hint +msgid "Swaps A with B and X with Y." +msgstr "A 與 B äŗ¤ę›ļ¼ŒX 與 Y äŗ¤ę›" + +#. Setting: Hold View to Reboot +#. Field: title +msgid "Hold View to Reboot" +msgstr "ęŒ‰ä½ View 鍵重啟" + +#. Setting: GPD Controller +#. Field: title +msgid "GPD Controller" +msgstr "GPD ęŽ§åˆ¶å™Ø" + +#. Setting: GPD Controller +#. Field: hint +msgid "Allows for configuring the gpd win controllers to a unified output." +msgstr "åÆä»„å°‡ę‚Øēš„ GPD ęŽ§åˆ¶å™Øé…ē½®ē‚ŗēµ±äø€čØ­å‚™č¼øå‡ŗ" + +#. Setting: Controller Emulation +#. Field: hint +msgid "Emulate different controller types to fuse gpd features." +msgstr "ęØ”ę“¬äøåŒé”žåž‹ēš„ęŽ§åˆ¶å™Øļ¼Œä»„čžåˆ GPD čØ­å‚™ēš„ē‰¹ę€§" + +#. Setting: Menu/L4/R4 Mapping +#. Field: title +msgid "Menu/L4/R4 Mapping" +msgstr "L4/R4 ę˜ å°„" + +#. Setting: Menu/L4/R4 Mapping +#. Field: hint +msgid "" +"Maps L4/R4 to Steam Input (requires L4/R4 as HHD in Wincontrols tab). If " +"disabled, they are keyboard buttons. Menu/L4/R4 can be combos: Menu is " +"single-press QAM, double HHD, hold Xbox. L4/R4 are single-press QAM, hold" +" HHD (legacy). " +msgstr "" +"將 L4/R4 ę˜ å°„åˆ° Steam č¼øå…„ (éœ€č¦åœØ Wincontrols éøé …å”äø­å°‡ L4/R4 設置為 HHD)怂" +"å¦‚ęžœē¦ē”Øļ¼Œå‰‡å®ƒå€‘å°‡ä½œē‚ŗéµē›¤ęŒ‰éˆ•ć€‚" +"選單/L4/R4 åÆä»„ä½œē‚ŗēµ„åˆéµļ¼šMenuå–®ę“Šå‘¼å«åæ«é€Ÿéøå–®(QAM)ć€é›™ę“Šå‘¼å«HHDéøå–®ć€ęŒ‰ä½å‰‡åšē‚ŗXboxéµć€‚" +"L4/R4 ę˜Æå–®ę“Šå‘¼å«åæ«é€Ÿéøå–®(QAM)ļ¼Œé•·ęŒ‰å‘¼å« HHD 選單(原始)怂" + +#. Setting: Menu/L4/R4 Mapping +#. Option: disabled +#. Setting: Start/Select do SteamOS Combos +#. Setting: Disabled +#. Field: title +#. Setting: Extra buttons as +#. Setting: Short Action +#. Setting: Hold Action +#. Setting: Xbox or View + B (Press) +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "Disabled" +msgstr "禁用" + +#. Setting: Menu/L4/R4 Mapping +#. Option: generic +msgid "Paddles, no Combo" +msgstr "čƒŒéµ, äøåšēµ„åˆéµ" + +#. Setting: Menu/L4/R4 Mapping +#. Option: menu +msgid "Menu is Combo" +msgstr "éøå–®éµē‚ŗēµ„åˆéµ" + +#. Setting: Menu/L4/R4 Mapping +#. Option: l4 +msgid "L4 is Combo" +msgstr "L4ē‚ŗēµ„åˆéµ" + +#. Setting: Menu/L4/R4 Mapping +#. Option: r4 +msgid "R4 is Combo" +msgstr "R4ē‚ŗēµ„åˆéµ" + +#. Setting: Start/Select do SteamOS Combos +#. Field: title +msgid "Start/Select do SteamOS Combos" +msgstr " Start/Select éµåšē‚ŗ SteamOS ēµ„åˆéµ" + +#. Setting: Start/Select do SteamOS Combos +#. Field: hint +msgid "" +"When holding Select or Start, if another button is pressed, they become " +"the Xbox button, which allows doing SteamOS combos (Select+RT is " +"screenshot)." +msgstr "" +"ęŒ‰ä½ Select ꈖ Start éµļ¼Œå¦‚ęžœęŒ‰äø‹å¦äø€å€‹ęŒ‰éˆ•ļ¼Œå‰‡å®ƒå€‘å°‡ęˆē‚ŗ Xbox ęŒ‰éˆ•ļ¼Œ" +"é€™ęØ£åÆä»„åÆ¦ē¾ SteamOS ēµ„åˆéµę•ˆęžœ (Select+RT ę˜ÆęˆŖåœ–)怂" + +#. Setting: Start/Select do SteamOS Combos +#. Option: select +msgid "Select Only" +msgstr "僅 Select" + +#. Setting: Start/Select do SteamOS Combos +#. Option: start_select +msgid "Start+Select" +msgstr "Start+Select 鍵" + +#. Setting: WinControls +#. Field: hint +msgid "Specialized settings for GPD devices." +msgstr "GPD čØ­å‚™ēš„ē‰¹ę®ŠčØ­å®š" + +#. Setting: RGB Mode +#. Field: title +msgid "RGB Mode" +msgstr "RGB ęØ”å¼" + +#. Setting: Off +#. Field: title +#. Setting: Vibration Strength +#. Option: off +msgid "Off" +msgstr "關閉" + +#. Setting: Off +#. Field: hint +msgid "Turns the LEDs off." +msgstr "關閉 LED ē‡ˆ" + +#. Setting: Solid +#. Field: title +msgid "Solid" +msgstr "å›ŗå®š" + +#. Setting: Solid +#. Field: hint +msgid "Maintains the LEDs at a solid color." +msgstr "äæęŒ LED ē‡ˆē‚ŗå›ŗå®šé”č‰²" + +#. Setting: Hue +#. Field: title +msgid "Hue" +msgstr "色調" + +#. Setting: Pulse +#. Field: title +msgid "Pulse" +msgstr "呼吸" + +#. Setting: Pulse +#. Field: hint +msgid "Slowly pulses the LEDs as a prespecified color." +msgstr "LED ęŒ‰ē…§é čØ­é”č‰²ē¼“ę…¢å‘¼åø" + +#. Setting: Rainbow +#. Field: title +msgid "Rainbow" +msgstr "彩虹" + +#. Setting: Rainbow +#. Field: hint +msgid "Cycles through the different colors." +msgstr "å¾Ŗē’°é”Æē¤ŗäøåŒé”č‰²" + +#. Setting: Mouse Mode Mapping +#. Field: title +msgid "Mouse Mode Mapping" +msgstr "é¼ ęØ™ęØ”å¼ę˜ å°„" + +#. Setting: Mouse Mode Mapping +#. Option: unchanged +#. Setting: Mouse Mode Triggers +#. Setting: L4/R4 Mapping +#. Setting: Do not change +#. Field: title +msgid "Do not change" +msgstr "äøę›“ę”¹" + +#. Setting: Mouse Mode Mapping +#. Option: mouse +msgid "GPD Mouse Mode" +msgstr "GPD é¼ ęØ™ęØ”å¼" + +#. Setting: Mouse Mode Mapping +#. Option: wasd +msgid "For Games" +msgstr "ę–¼éŠęˆ²" + +#. Setting: Mouse Mode Triggers +#. Field: title +msgid "Mouse Mode Triggers" +msgstr "é¼ ęØ™ęØ”å¼č§øē™¼" + +#. Setting: Mouse Mode Triggers +#. Option: gpd +msgid "GPD (RT is Fast Mouse)" +msgstr "GPD (RT ę˜Æåæ«é€Ÿé¼ ęØ™)" + +#. Setting: Mouse Mode Triggers +#. Option: steamos +msgid "SteamOS (LT/RT are R/L Clicks)" +msgstr "SteamOS (LT/RT 為 R/L 點꓊)" + +#. Setting: L4/R4 Mapping +#. Field: title +msgid "L4/R4 Mapping" +msgstr "L4/R4 ę˜ å°„" + +#. Setting: L4/R4 Mapping +#. Option: hhd +msgid "For HHD (F20/F21)" +msgstr "ę–¼ HHD (F20/F21)" + +#. Setting: L4/R4 Mapping +#. Option: default +msgid "Default (Pause/PrntScr)" +msgstr "預設 (Pause/PrntScr)" + +#. Setting: Deadzones +#. Field: title +msgid "Deadzones" +msgstr "ę­»å€" + +#. Setting: Do not change +#. Field: hint +msgid "Do not change the deadzones." +msgstr "äøę›“ę”¹ę­»å€" + +#. Setting: Set Deadzones +#. Field: title +msgid "Set Deadzones" +msgstr "čØ­å®šę­»å€" + +#. Setting: Set Deadzones +#. Field: hint +msgid "Use custom deadzones." +msgstr "ä½æē”Øč‡Ŗå®šē¾©ę­»å€" + +#. Setting: Left Stick Center +#. Field: title +msgid "Left Stick Center" +msgstr "å·¦ę–ę”æęœ€å°å€¼" + +#. Setting: Left Stick Boundary +#. Field: title +msgid "Left Stick Boundary" +msgstr "å·¦ę–ę”æęœ€å°å€¼" + +#. Setting: Right Stick Center +#. Field: title +msgid "Right Stick Center" +msgstr "å³ę–ę”æęœ€å°å€¼" + +#. Setting: Right Stick Boundary +#. Field: title +msgid "Right Stick Boundary" +msgstr "å³ę–ę”æęœ€å°å€¼" + +#. Setting: Vibration Strength +#. Field: title +msgid "Vibration Strength" +msgstr "éœ‡å‹•å¼·åŗ¦" + +#. Setting: Vibration Strength +#. Option: medium +#. Setting: Brightness +#. Setting: Speed +msgid "Medium" +msgstr "äø­" + +#. Setting: Vibration Strength +#. Option: high +#. Setting: Brightness +#. Setting: Speed +msgid "High" +msgstr "高" + +#. Setting: Firmware +#. Field: title +msgid "Firmware" +msgstr "韌體" + +#. Setting: Error +#. Field: title +msgid "Error" +msgstr "錯誤" + +#. Setting: Apply Settings +#. Field: title +msgid "Apply Settings" +msgstr "ę‡‰ē”ØčØ­å®š" + +#. Setting: Legion Controller +#. Field: title +msgid "Legion Controller" +msgstr "Legion ęŽ§åˆ¶å™Ø" + +#. Setting: Legion Controller +#. Field: hint +msgid "Configure the Legion Controller emulation modes." +msgstr "é…ē½® Legion ęŽ§åˆ¶å™ØęØ”ę“¬ęØ”å¼" + +#. Setting: Emulation Mode (X-Input) +#. Field: title +msgid "Emulation Mode (X-Input)" +msgstr "ęØ”ę“¬ęØ”å¼ (X-Input)" + +#. Setting: Emulation Mode (X-Input) +#. Field: hint +msgid "Emulate different controller types when in X-Input mode." +msgstr "ē•¶ Legion ęŽ§åˆ¶å™Øč™•ę–¼ X-Input ęØ”å¼ę™‚ļ¼ŒęØ”ę“¬äøåŒé”žåž‹ēš„ęŽ§åˆ¶å™Øć€‚" + +#. Setting: Motion Support +#. Field: hint +msgid "Enable gyroscope/accelerometer (IMU) support" +msgstr "å•Ÿē”Øé™€čžŗå„€/åŠ é€Ÿåŗ¦čØˆ (IMU) ę”Æę“" + +#. Setting: Swap Legion with Menu/View +#. Field: title +msgid "Swap Legion with Menu/View" +msgstr "äŗ¤ę› Legion čœå–®/č¦–åœ– ꌉ鈕" + +#. Setting: Enable Shortcuts Controller +#. Field: title +msgid "Enable Shortcuts Controller" +msgstr "å•Ÿē”Øåæ«ę·ęŽ§åˆ¶å™Ø" + +#. Setting: Enable Shortcuts Controller +#. Field: hint +msgid "When in dinput mode, enable a controller for shortcuts." +msgstr "在 dinput ęØ”å¼äø‹ļ¼Œå•Ÿē”Øäø€å€‹ęŽ§åˆ¶å™Øä¾†åŸ·č”Œåæ«ę·éµ" + +#. Setting: Reset Controller +#. Field: title +msgid "Reset Controller" +msgstr "é‡ē½®ęŽ§åˆ¶å™Ø" + +#. Setting: Reset Controller +#. Field: hint +msgid "Resets the controller to stock settings." +msgstr "å°‡ęŽ§åˆ¶å™Øé‡ē½®ē‚ŗé čØ­čØ­å®š" + +#. Setting: Legion Controllers +#. Field: title +msgid "Legion Controllers" +msgstr "Legion ęŽ§åˆ¶å™Ø" + +#. Setting: Legion Controllers +#. Field: hint +msgid "" +"Allows for configuring the Legion controllers using the built in firmware" +" commands and enabling emulation modes for various controller types." +msgstr "å…čØ±ä½æē”ØęŽ§åˆ¶å™ØéŸŒé«”å…§å»ŗå‘½ä»¤é…ē½® Legion ęŽ§åˆ¶å™Øļ¼Œäø¦å•Ÿē”Øå„ēØ®ęŽ§åˆ¶å™Øé”žåž‹ēš„ęØ”ę“¬ęØ”å¼ć€‚" + +#. Setting: Emulation Mode (X-Input) +#. Field: hint +msgid "" +"Emulate different controller types when the Legion Controllers are in " +"X-Input mode." +msgstr "ē•¶ Legion ęŽ§åˆ¶å™Øč™•ę–¼ X-Input ęØ”å¼ę™‚ļ¼ŒęØ”ę“¬äøåŒé”žåž‹ēš„ęŽ§åˆ¶å™Øć€‚" + +#. Setting: Controller Motions Device +#. Field: title +msgid "Controller Motions Device" +msgstr "ęŽ§åˆ¶å™Øé«”ę„ŸčØ­å‚™" + +#. Setting: Left Controller +#. Field: title +msgid "Left Controller" +msgstr "å·¦ęŽ§åˆ¶å™Ø" + +#. Setting: Right Controller +#. Field: title +msgid "Right Controller" +msgstr "å³ęŽ§åˆ¶å™Ø" + +#. Setting: Both Controllers +#. Field: title +msgid "Both Controllers" +msgstr "å·¦å³ęŽ§åˆ¶å™Ø" + +#. Setting: Both Controllers +#. Field: hint +msgid "" +"The main controller uses the right controller's motion sensor, and a " +"secondary controller is created for the left controller's motion sensor." +msgstr "äø»ęŽ§åˆ¶å™Øä½æē”Øå³å“ęŽ§åˆ¶å™Øēš„é‹å‹•ę„Ÿę‡‰å™Øļ¼Œ äø¦ē‚ŗå·¦å“ęŽ§åˆ¶å™Øēš„é‹å‹•ę„Ÿę‡‰å™Øå‰µå»ŗäŗ†äø€å€‹č¼”åŠ©ęŽ§åˆ¶å™Øć€‚" + +#. Setting: M2 As Xbox Share/Dualsense Mic Mute +#. Field: title +msgid "M2 As Xbox Share/Dualsense Mic Mute" +msgstr "M2 ę˜ å°„ē‚ŗ Xbox Share/Dualsense éŗ„å…‹é¢ØéœéŸ³ęŒ‰éˆ•" + +#. Setting: M2 As Xbox Share/Dualsense Mic Mute +#. Field: hint +msgid "" +"Maps the M2 to the mute button on Dualsense and the share button on the " +"Xbox Elite controller." +msgstr "将 M2 ę˜ å°„äøŗ Dualsense ēš„é™éŸ³ęŒ‰é’®å’Œ Xbox Elite ęŽ§åˆ¶å™Øēš„å…±äŗ«ęŒ‰é’®" + +#. Setting: Enable Shortcuts Controller +#. Field: hint +msgid "" +"When in other modes (dinput, dual dinput, and fps), enable a shortcuts " +"controller to restore Guide, QAM, and shortcut functionality." +msgstr "åœØå…¶ä»–ęØ”å¼ļ¼ˆdinput态雙 dinput 和 fpsļ¼‰äø­ļ¼Œå•Ÿē”Øåæ«ę·ęŽ§åˆ¶å™Øä»„ę¢å¾© Guide态QAM å’Œåæ«ę·åŠŸčƒ½ć€‚" + +#. Setting: Factory Reset Controllers +#. Field: title +msgid "Factory Reset Controllers" +msgstr "恢復預設設定" + +#. Setting: Factory Reset Controllers +#. Field: hint +msgid "Resets the controllers to factory settings." +msgstr "å°‡ęŽ§åˆ¶å™Øé‡ē½®ē‚ŗé čØ­čØ­å®š" + +#. Setting: Orange Pi Neo +#. Field: title +msgid "Orange Pi Neo" +msgstr "香橙擾 Neo" + +#. Setting: OneXPlayer Controller +#. Field: title +msgid "OneXPlayer Controller" +msgstr "OneXPlayer ęŽ§åˆ¶å™Ø" + +#. Setting: Keyboard and Turbo buttons are: +#. Field: title +msgid "Keyboard and Turbo buttons are:" +msgstr "éµē›¤å’Œ Turbo ęŒ‰éˆ•ē”Øä½œļ¼š" + +#. Setting: Keyboard and Turbo buttons are: +#. Option: oem +msgid "Keyboard, Combo" +msgstr "éµē›¤, ēµ„åˆéµ" + +#. Setting: Keyboard and Turbo buttons are: +#. Option: separate +msgid "Steam Menu, HHD" +msgstr "Steam 選單, HHD" + +#. Setting: Keyboard and Turbo buttons are: +#. Option: combo_hhd +msgid "Combo, HHD" +msgstr "ēµ„åˆéµ, HHD" + +#. Setting: Keyboard and Turbo buttons are: +#. Option: combo +msgid "Combo, Combo" +msgstr "ēµ„åˆéµ, ēµ„åˆéµ" + +#. Setting: Swap View/Menu and Xbox/Kbd +#. Field: title +msgid "Swap View/Menu and Xbox/Kbd" +msgstr "äŗ¤ę› View/Menu 和 Xbox/Kbd" + +#. Setting: Holding Turbo Reboots +#. Field: title +msgid "Holding Turbo Reboots" +msgstr "ęŒ‰ä½ Turbo éµé‡ę–°å•Ÿå‹•" + +#. Setting: Reverse Volume Buttons +#. Field: title +msgid "Reverse Volume Buttons" +msgstr "åč½‰éŸ³é‡ęŒ‰éˆ•" + +#. Setting: Reverse Volume Buttons +#. Field: hint +msgid "Reverse the volume buttons of the X1 style devices to match other tablets." +msgstr "åč½‰ X1 čØ­å‚™ēš„éŸ³é‡ęŒ‰éˆ•, ä»„åŒ¹é…å…¶ä»–å¹³ęæčØ­å‚™" + +#. Setting: Ally Controller +#. Field: title +msgid "Ally Controller" +msgstr "Ally ęŽ§åˆ¶å™Ø" + +#. Setting: Ally Controller +#. Field: hint +msgid "Allows for configuring the ROG Ally controllers to a unified output." +msgstr "åÆä»„čØ­å®šę‚Øēš„ ROG Ally ęŽ§åˆ¶å™Øē‚ŗēµ±äø€čØ­å‚™č¼øå‡ŗ" + +#. Setting: Controller Emulation +#. Field: hint +msgid "Emulate different controller types to fuse ROG features." +msgstr "ęØ”ę“¬äøåŒé”žåž‹ēš„ęŽ§åˆ¶å™Ø, ä»„čžåˆ ROG čØ­å‚™ēš„ē‰¹ę€§" + +#. Setting: Swap ROG and Menu/View +#. Field: title +msgid "Swap ROG and Menu/View" +msgstr "äŗ¤ę› ROG Menu/View ꌉ鈕" + +#. Setting: Swap ROG and Menu/View +#. Field: hint +msgid "Swaps the Armory Crate and Command center buttons with start and select." +msgstr "äŗ¤ę› Armory Crate/Command center 與 Menu/View ꌉ鈕" + +#. Setting: RGB During Boot +#. Field: title +msgid "RGB During Boot" +msgstr "RGB å•Ÿå‹•ę™‚é–‹å•Ÿ" + +#. Setting: RGB During Charging Asleep +#. Field: title +msgid "RGB During Charging Asleep" +msgstr "RGB å……é›»ę™‚ä¼‘ēœ " + +#. Setting: Motion Axis +#. Field: title +msgid "Motion Axis" +msgstr "é«”ę„Ÿč»ø" + +#. Setting: Default +#. Field: hint +msgid "The default axis loaded for this device." +msgstr "ęœ¬čØ­å‚™é čØ­č»øčØ­å®š" + +#. Setting: Override +#. Field: title +msgid "Override" +msgstr "覆蓋" + +#. Setting: Override +#. Field: hint +msgid "" +"Remap and invert the axis of your device. If the axis of your device are " +"wrong, please submit a picture or a text version of the following." +msgstr "é‡ę–°ę˜ å°„å’Œåč½‰čØ­å‚™ēš„č»øć€‚å¦‚ęžœčØ­å‚™č»øęœ‰čŖ¤ļ¼Œč«‹ęäŗ¤ä»„äø‹éøé …ēš„åœ–ē‰‡ęˆ–ę–‡ęœ¬ē‰ˆęœ¬ć€‚" + +#. Setting: Manufacturer +#. Field: title +msgid "Manufacturer" +msgstr "製造商" + +#. Setting: Product +#. Field: title +msgid "Product" +msgstr "產品" + +#. Setting: Axis X +#. Field: title +msgid "Axis X" +msgstr "X 軸" + +#. Setting: Axis X +#. Option: x +#. Setting: Axis Y +#. Setting: Axis Z +msgid "X" +msgstr "" + +#. Setting: Axis X +#. Option: y +#. Setting: Axis Y +#. Setting: Axis Z +msgid "Y" +msgstr "" + +#. Setting: Axis X +#. Option: z +#. Setting: Axis Y +#. Setting: Axis Z +msgid "Z" +msgstr "" + +#. Setting: Invert X +#. Field: title +msgid "Invert X" +msgstr "X č»øåč½‰" + +#. Setting: Axis Y +#. Field: title +msgid "Axis Y" +msgstr "Y 軸" + +#. Setting: Invert Y +#. Field: title +msgid "Invert Y" +msgstr "Y č»øåč½‰" + +#. Setting: Axis Z +#. Field: title +msgid "Axis Z" +msgstr "Z 軸" + +#. Setting: Invert Z +#. Field: title +msgid "Invert Z" +msgstr "Z č»øåč½‰" + +#. Setting: Deadzones & Vibration +#. Field: title +msgid "Deadzones & Vibration" +msgstr "ę­»å€å’Œéœ‡å‹•" + +#. Setting: Deadzones & Vibration +#. Field: hint +msgid "Configure joystick and trigger deadzones, vibration intensity." +msgstr "é…ē½®ę–ę”æå’Œę‰³ę©Ÿę­»å€, éœ‡å‹•å¼·åŗ¦" + +#. Setting: Default +#. Field: hint +msgid "Uses reasonable values based on hardware." +msgstr "ę ¹ę“šē”¬é«”ä½æē”Øåˆē†ēš„å€¼" + +#. Setting: Manual +#. Field: title +msgid "Manual" +msgstr "手動" + +#. Setting: Manual +#. Field: hint +msgid "Allows for manual configuration of deadzones and vibration intensity." +msgstr "å…čØ±ę‰‹å‹•čØ­å®šę­»å€å’Œéœ‡å‹•å¼ŗåŗ¦" + +#. Setting: Vibration Intensity +#. Field: title +msgid "Vibration Intensity" +msgstr "éœ‡å‹•å¼·åŗ¦" + +#. Setting: Vibration Intensity +#. Field: hint +msgid "" +"Intensity of the vibration. The higher the value, the stronger the " +"vibration." +msgstr "éœ‡å‹•å¼·åŗ¦ć€‚å€¼č¶Šé«˜ļ¼Œéœ‡å‹•č¶Šå¼·ć€‚" + +#. Setting: Left Stick Minimum +#. Field: title +msgid "Left Stick Minimum" +msgstr "å·¦ę–ę”æęœ€å°å€¼" + +#. Setting: Left Stick Minimum +#. Field: hint +#. Setting: Right Stick Minimum +#. Setting: Left Trigger Minimum +#. Setting: Right Trigger Minimum +msgid "" +"Deadzone for the joystick. The higher the value, the more the joystick " +"needs to be moved before registering." +msgstr "ę–ę”æę­»å€ć€‚å€¼č¶Šé«˜, ę–ę”æéœ€č¦ē§»å‹•ēš„č·é›¢č¶Šå¤§ę‰ęœƒč§øē™¼" + +#. Setting: Left Stick Maximum +#. Field: title +msgid "Left Stick Maximum" +msgstr "å·¦ę–ę”æęœ€å¤§å€¼" + +#. Setting: Left Stick Maximum +#. Field: hint +#. Setting: Right Stick Maximum +#. Setting: Left Trigger Maximum +#. Setting: Right Trigger Maximum +msgid "" +"Maximum value for joystick. The higher the value, the more the joystick " +"needs to be moved before reaching maximum." +msgstr "ę–ę”æęœ€å¤§å€¼ć€‚å€¼č¶Šé«˜, ę–ę”æéœ€č¦ē§»å‹•ēš„č·é›¢č¶Šå¤§ę‰čƒ½é”åˆ°ęœ€å¤§å€¼" + +#. Setting: Right Stick Minimum +#. Field: title +msgid "Right Stick Minimum" +msgstr "å³ę–ę”æęœ€å°å€¼" + +#. Setting: Right Stick Maximum +#. Field: title +msgid "Right Stick Maximum" +msgstr "å³ę–ę”æęœ€å¤§å€¼" + +#. Setting: Left Trigger Minimum +#. Field: title +msgid "Left Trigger Minimum" +msgstr "å·¦ę‰³ę©Ÿęœ€å°å€¼" + +#. Setting: Left Trigger Maximum +#. Field: title +msgid "Left Trigger Maximum" +msgstr "å·¦ę‰³ę©Ÿęœ€å¤§å€¼" + +#. Setting: Right Trigger Minimum +#. Field: title +msgid "Right Trigger Minimum" +msgstr "å³ę‰³ę©Ÿęœ€å°å€¼" + +#. Setting: Right Trigger Maximum +#. Field: title +msgid "Right Trigger Maximum" +msgstr "å³ę‰³ę©Ÿęœ€å¤§å€¼" + +#. Setting: Reset to Default +#. Field: title +msgid "Reset to Default" +msgstr "é‡ē½®ē‚ŗé čØ­å€¼" + +#. Setting: Reset to Default +#. Field: hint +msgid "Reset all values to default." +msgstr "å°‡ę‰€ęœ‰å€¼é‡ē½®ē‚ŗé čØ­å€¼" + +#. Setting: Hidden +#. Field: title +msgid "Hidden" +msgstr "éš±č—" + +#. Setting: Hidden +#. Field: hint +msgid "" +"Disables the controller. Handheld Daemon overlay will still work in " +"gamemode." +msgstr "ē¦ē”ØęŽ§åˆ¶å™Øć€‚Handheld Daemon č¦†č“‹éøå–®ä»ē„¶åÆä»„åœØéŠęˆ²ęØ”å¼äø‹é‹ä½œ" + +#. Setting: Extra buttons as Keyboard/Overlay +#. Field: title +msgid "Extra buttons as Keyboard/Overlay" +msgstr "é”å¤–ęŒ‰éˆ•ä½œē‚ŗéµē›¤/č¦†č“‹éøå–®åæ«ę·éµ" + +#. Setting: Extra buttons as Keyboard/Overlay +#. Field: hint +msgid "" +"Makes the left paddle bring up a keyboard and the right paddle bring up " +"the overlay." +msgstr "å·¦čƒŒéµå‘¼å«č™›ę“¬éµē›¤, å³čƒŒéµå‘¼å«č¦†č“‹éøå–®" + +#. Setting: Xbox +#. Field: title +msgid "Xbox" +msgstr "" + +#. Setting: Extra buttons as +#. Field: title +msgid "Extra buttons as" +msgstr "é”å¤–ęŒ‰éˆ•ä½œē‚ŗ" + +#. Setting: Extra buttons as +#. Field: hint +msgid "" +"Changes the behavior of the extra buttons. Left button is Keyboard, right" +" button is Overlay. Or they can be set for Steam Input." +msgstr "ę›“ę”¹é”å¤–ęŒ‰éˆ•ēš„č”Œē‚ŗć€‚å·¦å“ęŒ‰éˆ•ē‚ŗéµē›¤ļ¼Œå³å“ęŒ‰éˆ•ē‚ŗč¦†č“‹éøå–®ć€‚ęˆ–č€…åÆä»„čØ­å®šē‚ŗ Steam 輸兄。" + +#. Setting: Extra buttons as +#. Option: steam_input +msgid "Steam Input (Elite)" +msgstr "Steam č¼øå…„ (Elite)" + +#. Setting: Extra buttons as +#. Option: noob +msgid "Keyboard/Overlay" +msgstr "éµē›¤/覆蓋選單" + +#. Setting: Nintendo QAM Fix +#. Field: title +msgid "Nintendo QAM Fix" +msgstr "ä»»å¤©å ‚åæ«é€Ÿéøå–®(QAM)修復" + +#. Setting: Xbox Elite +#. Field: title +msgid "Xbox Elite" +msgstr "Xbox čč‹±ę‰‹ęŠŠ" + +#. Setting: Steam Controller +#. Field: title +msgid "Steam Controller" +msgstr "Steam ęŽ§åˆ¶å™Ø" + +#. Setting: Steam Controller +#. Field: hint +msgid "Allows for gyro, paddles, and has a proper QAM button." +msgstr "åÆē”Øé™€čžŗå„€ć€čƒŒéµ, äø¦äø”ęœ‰äø€å€‹åˆé©ēš„ åæ«é€Ÿéøå–®(QAM) ꌉ鈕" + +#. Setting: Invert Roll Axis +#. Field: title +msgid "Invert Roll Axis" +msgstr "åč½‰ę»¾å‹•č»ø" + +#. Setting: Invert Roll Axis +#. Field: hint +msgid "" +"Inverts the roll (Z) axis compared to a real Horipad controller. Useful " +"for Steam Input, since you want it to be inverted to look left to right, " +"but an issue in emulators." +msgstr "" +"čˆ‡ēœŸåÆ¦ēš„ Dualsense ęŽ§åˆ¶å™Øē›øęÆ”ļ¼Œåč½‰ę»¾å‹• (Z) č»øć€‚é€™å°ę–¼ Steam " +"č¼øå…„éžåøøęœ‰ē”Øļ¼Œå› ē‚ŗę‚ØåÆčƒ½åøŒęœ›å®ƒåč½‰ä»„ä¾æå¾žå·¦åˆ°å³ęŸ„ēœ‹ć€‚ä½†åœØęØ”ę“¬å™Øäø­ęœƒęœ‰å•é”Œ" + +#. Setting: Dualsense +#. Field: title +msgid "Dualsense" +msgstr "" + +#. Setting: Extra buttons as +#. Field: hint +msgid "" +"Changes the behavior of the extra buttons. Left button is Keyboard, right" +" button is Overlay. Or they can be left/right touchpad clicks. For the " +"legion go, top buttons are shortcuts, bottom are touchpad clicks." +msgstr "" +"ę›“ę”¹é”å¤–ęŒ‰éˆ•ēš„č”Œē‚ŗć€‚å·¦å“ęŒ‰éˆ•ē‚ŗéµē›¤ļ¼Œå³å“ęŒ‰éˆ•ē‚ŗč¦†č“‹éøå–®ć€‚ęˆ–č€…åÆä»„čØ­å®šē‚ŗå·¦/å³č§øęŽ§ęæęØ”ę“¬ć€‚å°ę–¼ Legion " +"Goļ¼Œé ‚éƒØęŒ‰éˆ•ę˜Æåæ«ę·éµļ¼Œåŗ•éƒØę˜Æč§øęŽ§ęæęØ”ę“¬ć€‚" + +#. Setting: Extra buttons as +#. Option: steam_input +msgid "Steam Input (Edge)" +msgstr "Steam č¼øå…„ (Edge)" + +#. Setting: Extra buttons as +#. Option: touchpad +msgid "Touchpad Clicks" +msgstr "č§øęŽ§ęæé»žę“Š" + +#. Setting: Extra buttons as +#. Option: both +msgid "Shortcuts + Touchpad Clicks" +msgstr "åæ«ę·éµ + č§øęŽ§ęæęØ”ę“¬" + +#. Setting: LED Support +#. Field: title +msgid "LED Support" +msgstr "LED ę”Æę“" + +#. Setting: LED Support +#. Field: hint +msgid "" +"Passes through the LEDs to the controller, which allows games to control " +"them." +msgstr "å°‡čØ­å‚™ēš„ LED ē‡ˆę˜ å°„ē‚ŗęŽ§åˆ¶å™Øēš„ LED ē‡ˆļ¼ŒåÆä»„ä½æ Steam ęˆ–éŠęˆ²čƒ½å¤ ęŽ§åˆ¶ LED ē‡ˆć€‚" + +#. Setting: Gyro Output Sync +#. Field: title +msgid "Gyro Output Sync" +msgstr "é™€čžŗå„€č¼øå‡ŗåŒę­„" + +#. Setting: Gyro Output Sync +#. Field: hint +msgid "" +"Steam relies on the IMU timestamp for the touchpad as Mouse and `Gyro to " +"Mouse [BETA]`. If the same timestamp is sent in 2 reports, this causes a " +"division by 0 and instability. This option makes it so reports are sent " +"only when there is a new IMU timestamp, effectively limiting the " +"responsiveness of the controller to that of the IMU. This only makes a " +"difference for the Legion Go (125hz), as all the other handhelds are " +"using 400hz by default." +msgstr "" +"Steam ä¾č³“ IMU ę™‚é–“ęˆ³ä¾†ä½œē‚ŗč§øęŽ§ęæę»‘é¼ å’Œ `é™€čžŗå„€åˆ°ę»‘é¼  [BETA]`ć€‚å¦‚ęžœåœØ 2 å€‹å ±å‘Šäø­ē™¼é€ē›øåŒēš„ę™‚é–“ęˆ³ļ¼Œé€™ęœƒå°Žč‡“é™¤ä»„ 0 " +"ä»„åŠäøē©©å®šć€‚ę­¤éøé …ä½æå ±å‘Šåƒ…åœØęœ‰ę–°ēš„ IMU ę™‚é–“ęˆ³ę™‚ē™¼é€ļ¼Œęœ‰ę•ˆåœ°å°‡ęŽ§åˆ¶å™Øēš„éŸæę‡‰é€Ÿåŗ¦é™åˆ¶ē‚ŗ IMU ēš„é€Ÿåŗ¦ć€‚é€™åŖå° Legion Go " +"(125hz) ęœ‰å½±éŸæļ¼Œå› ē‚ŗę‰€ęœ‰å…¶ä»–ęŽŒę©Ÿé čØ­ä½æē”Ø 400hz怂" + +#. Setting: Invert Roll Axis +#. Field: hint +msgid "" +"Inverts the roll (Z) axis compared to a real Dualsense controller. Useful" +" for Steam Input, since you want it to be inverted to look left to right," +" but an issue in emulators." +msgstr "" +"čˆ‡ēœŸåÆ¦ēš„ Dualsense ęŽ§åˆ¶å™Øē›øęÆ”ļ¼Œåč½‰ę»¾å‹• (Z) č»øć€‚å°ę–¼ Steam " +"č¼øå…„å¾ˆęœ‰ē”Øļ¼Œå› ē‚ŗę‚ØåÆčƒ½åøŒęœ›å®ƒåč½‰ä»„ä¾æå¾žå·¦åˆ°å³ēœ‹ļ¼Œä½†åœØęØ”ę“¬å™Øäø­ęœƒęœ‰å•é”Œć€‚" + +#. Setting: Bluetooth Mode +#. Field: title +msgid "Bluetooth Mode" +msgstr "č—ē‰™ęØ”å¼" + +#. Setting: Bluetooth Mode +#. Field: hint +msgid "" +"Emulates the controller in bluetooth mode instead of USB mode. This is " +"the default as it causes less issues with how apps interact with the " +"controller. However, using USB mode can improve LED support (?) in some " +"games. Test and report back!" +msgstr "" +"ęØ”ę“¬ęŽ§åˆ¶å™Øč—ē‰™ęØ”å¼č€Œäøę˜Æ USB ęØ”å¼ć€‚é€™ę˜Æé čØ­čØ­å®šļ¼Œå› ē‚ŗå®ƒęœƒęø›å°‘ę‡‰ē”Øčˆ‡ęŽ§åˆ¶å™Øäŗ¤äŗ’ēš„å•é”Œć€‚ä½†ę˜Æļ¼Œä½æē”Ø USB ęØ”å¼åÆä»„ę”¹å–„äø€äŗ›éŠęˆ²äø­ēš„ LED " +"ę”Æę“(?)ć€‚ę­”čæŽęø¬č©¦äø¦åé„‹ļ¼" + +#. Setting: Paused +#. Field: title +msgid "Paused" +msgstr "暫停" + +#. Setting: Touchpad Emulation +#. Field: title +msgid "Touchpad Emulation" +msgstr "č§øęŽ§ęæęØ”ę“¬" + +#. Setting: Touchpad Emulation +#. Field: hint +msgid "" +"Use an emulated touchpad. Part of the controller if it is supported " +"(e.g., Dualsense) or a virtual one if not." +msgstr "ęØ”ę“¬äø€å€‹č§øęŽ§ęæć€‚å¦‚ęžœē•¶å‰ęØ”ę“¬ęŽ§åˆ¶å™Øęœ¬čŗ«ę”ÆęŒč§øęŽ§ęæļ¼ˆä¾‹å¦‚ Dualsenseļ¼‰ļ¼Œå‰‡å°‡ę˜ å°„ęˆē›®ęØ™čØ­å‚™č‡Ŗčŗ«ēš„č§øęŽ§ęæļ¼Œå¦å‰‡ęœƒęØ”ę“¬ęˆäø€å€‹ēØē«‹ēš„č™›ę“¬č§øęŽ§ęæčØ­å‚™ć€‚" + +#. Setting: Disabled +#. Field: hint +msgid "" +"Does not modify the touchpad. Short + holding presses will not work " +"within gamescope." +msgstr "äøäæ®ę”¹č§øęŽ§ęæčØ­ē½®ć€‚åÆčƒ½ęœƒå°Žč‡“åœØ gamescope äø­ē„”ę³•å·„ä½œć€‚" + +#. Setting: Virtual +#. Field: title +msgid "Virtual" +msgstr "č™›ę“¬č§øęŽ§ęæ" + +#. Setting: Virtual +#. Field: hint +msgid "" +"Adds an emulated touchpad. This touchpad is meant for use in gamescope " +"and has left, right click support by default. However, it causes issues " +"in desktop mode, and it doesnt allow dragging files. Therefore, it will " +"autodisable in desktop." +msgstr "" +"ę·»åŠ äø€å€‹č™›ę“¬č§øęŽ§ęæć€‚é€™å€‹č§øęŽ§ęæę˜Æē‚ŗåœØ gamescope " +"äø­ä½æē”Øč€ŒčØ­čØˆēš„ļ¼Œé čØ­ę”Æę“å·¦éµć€å³éµć€‚ä½†ę˜Æļ¼Œå®ƒęœƒåœØę”Œé¢ęØ”å¼äø­å¼•čµ·å•é”Œļ¼Œäø¦äø”äøå…čØ±ę‹–å‹•ę–‡ä»¶ć€‚å› ę­¤ļ¼Œå®ƒęœƒåœØę”Œé¢ęØ”å¼äø­č‡Ŗå‹•ē¦ē”Øć€‚" + +#. Setting: Disable on Desktop +#. Field: title +msgid "Disable on Desktop" +msgstr "åœØę”Œé¢ęØ”å¼ē¦ē”Ø " + +#. Setting: Disable on Desktop +#. Field: hint +msgid "" +"Touchpad emulation will automatically be disabled when not in gamemode. " +"Specifically, steam will be periodically be checked to be running in " +"gamepad mode and if not, touchpad emulation will be disabled." +msgstr "ē•¶äøč™•ę–¼éŠęˆ²ęØ”å¼ę™‚ļ¼Œč§øęŽ§ęæęØ”ę“¬å°‡č‡Ŗå‹•ē¦ē”Øć€‚é€šéŽå®šęœŸęŖ¢ęŸ„ Steam ę˜Æå¦é‹č”ŒåœØéŠęˆ²ęØ”å¼äø­ä¾†åÆ¦ē¾ļ¼Œå¦‚ęžœäøę˜Æļ¼Œč§øęŽ§ęæęØ”ę“¬å°‡č¢«ē¦ē”Øć€‚" + +#. Setting: Short Action +#. Field: title +msgid "Short Action" +msgstr "ēŸ­ęŒ‰å‹•ä½œ" + +#. Setting: Short Action +#. Field: hint +msgid "Maps short touches (less than 0.2s) to a virtual touchpad button." +msgstr "å°‡ēŸ­č§øęŽ§ (å°ę–¼ 0.2s)ę˜ å°„ē‚ŗč™›ę“¬č§øęŽ§ęæęŒ‰éˆ•" + +#. Setting: Short Action +#. Option: left_click +#. Setting: Hold Action +msgid "Left Click" +msgstr "å·¦éµé»žę“Š" + +#. Setting: Short Action +#. Option: right_click +#. Setting: Hold Action +msgid "Right Click" +msgstr "å³éµé»žę“Š" + +#. Setting: Hold Action +#. Field: title +msgid "Hold Action" +msgstr "é•·ęŒ‰å‹•ä½œ" + +#. Setting: Hold Action +#. Field: hint +msgid "Maps long touches (more than 2s) to a virtual touchpad button." +msgstr "å°‡é•·č§øęŽ§ (大於 2s)ę˜ å°„ē‚ŗč™›ę“¬č§øęŽ§ęæęŒ‰éˆ•" + +#. Setting: Controller +#. Field: hint +msgid "" +"Uses the touchpad of the emulated controller (if it exists). Otherwise, " +"the touchpad remains unmapped (will still show up in the system). Meant " +"to be used as steam input, so short press is unassigned by default and " +"long press simulates trackpad click." +msgstr "" +"ä½æē”ØęØ”ę“¬ęŽ§åˆ¶å™Øēš„č§øęŽ§ęæļ¼ˆå¦‚ęžœå­˜åœØļ¼‰ć€‚å¦å‰‡ļ¼Œč§øęŽ§ęæäæęŒęœŖę˜ å°„ļ¼ˆä»ęœƒé”Æē¤ŗåœØē³»ēµ±äø­ļ¼‰ć€‚ē”Øä½œ Steam " +"č¼øå…„ļ¼Œå› ę­¤é čØ­ęƒ…ę³äø‹ēŸ­ęŒ‰ęœŖåˆ†é…ļ¼Œé•·ęŒ‰ęØ”ę“¬č§øęŽ§ęæé»žę“Šć€‚" + +#. Setting: Location +#. Field: title +msgid "Location" +msgstr "ä½ē½®" + +#. Setting: Location +#. Field: hint +msgid "" +"Controls the placement of the real touchpad to the virtual one, using " +"what steam expects. In Steam, the \"Left\" touchpad maps to the left " +"half, the \"Right\" touchpad maps to the right half, and \"Center\" maps " +"to the whole touchpad. Therefore, the virtual touchpad is cropped to the " +"left side for left, the right side for right, and expanded in the center " +"for center. This means when set to center, half of the left touchpad is " +"left and half of the right is right. \"Stretch\" stretches the touchpad " +"to the whole dualsense surface." +msgstr "" +"é…ē½®ēœŸåÆ¦č§øęŽ§ęæčˆ‡č™›ę“¬č§øęŽ§ęæēš„ä½ē½®é—œäæ‚ļ¼Œä»„é©é… Steam čØ­å®šć€‚åœØ Steam 中,\"å·¦\" å°‡č§øęŽ§ęæå°ę‡‰åˆ°å·¦åŠé‚Šļ¼Œ" +"\"右\" å°‡č§øęŽ§ęæå°ę‡‰åˆ°å³åŠé‚Šļ¼Œč€Œ \"置中\" å°‡å°ę‡‰åˆ°ę•“å€‹č§øęŽ§ęæć€‚" +"å› ę­¤ļ¼Œč™›ę“¬č§øęŽ§ęæč¢«č£åˆ‡ē‚ŗå·¦å“ē”Øę–¼å·¦é‚Šļ¼Œå³å“ē”Øę–¼å³é‚Šļ¼Œäø¦åœØäø­åæƒę““å±•ć€‚é€™ę„å‘³č‘—ē•¶čØ­ē½®ē‚ŗ\"置中\"ę™‚ļ¼Œå·¦č§øęŽ§ęæēš„äø€åŠę˜Æå·¦å“ļ¼Œå³č§øęŽ§ęæēš„äø€åŠę˜Æå³å“ć€‚\"å»¶ä¼ø\"" +" å°‡č§øęŽ§ęæä¼øå±•č‡³ę•“å€‹Dualsenseč™›ę“¬č§øęŽ§ęæć€‚" + +#. Setting: Location +#. Option: right +#. Setting: Direction +msgid "Right" +msgstr "右" + +#. Setting: Location +#. Option: center +msgid "Center" +msgstr "置中" + +#. Setting: Location +#. Option: left +#. Setting: Direction +msgid "Left" +msgstr "å·¦" + +#. Setting: Location +#. Option: stretch +msgid "Stretch" +msgstr "å»¶ä¼ø" + +#. Setting: Short Action +#. Field: hint +msgid "" +"Maps short touches (less than 0.2s) to a touchpad action. Dualsense uses " +"a physical press for left and a double tap for right click (support for " +"double tap varies; enable \"Tap to Click\" in your desktop's touchpad " +"settings)." +msgstr "" +"å°‡ēŸ­č§øęŽ§ļ¼ˆå°ę–¼ 0.2sļ¼‰ę˜ å°„ē‚ŗč§øęŽ§ęæå‹•ä½œć€‚Dualsense " +"ä½æē”Øē‰©ē†ęŒ‰éµä½œē‚ŗå·¦éµļ¼Œé›™ę“Šä½œē‚ŗå³éµļ¼ˆé›™ę“Šę”Æę“å› čØ­å‚™č€Œē•°ļ¼›č«‹åœØę”Œé¢č§øęŽ§ęæčØ­å®šäø­å•Ÿē”Ø\"č¼•č§øé»žę“Š\")。" + +#. Setting: Hold Action +#. Field: hint +msgid "" +"Maps long touches (more than 2s) to a touchpad action. Dualsense uses a " +"physical press for left and a double tap for right click (support for " +"double tap varies; enable \"Tap to Click\" in your desktop's touchpad " +"settings)." +msgstr "" +"å°‡é•·č§øęŽ§ļ¼ˆå¤§ę–¼ 2sļ¼‰ę˜ å°„ē‚ŗč§øęŽ§ęæå‹•ä½œć€‚Dualsense " +"ä½æē”Øē‰©ē†ęŒ‰éµä½œē‚ŗå·¦éµļ¼Œé›™ę“Šä½œē‚ŗå³éµļ¼ˆé›™ę“Šę”Æę“å› čØ­å‚™č€Œē•°ļ¼›č«‹åœØę”Œé¢č§øęŽ§ęæčØ­å®šäø­å•Ÿē”Ø\"č¼•č§øé»žę“Š\")。" + +msgid "Downloading:" +msgstr "下載中:" + +msgid "Importing:" +msgstr "å°Žå…„äø­:" + +msgid "Deploying:" +msgstr "部屬中:" + +msgid "Loading" +msgstr "č®€å–äø­" + +msgid "No update available" +msgstr "ē„”åÆē”Øę›“ę–°" + +msgid "Rebasing to " +msgstr "é‡ę–°å®šä½åˆ°" + +msgid "Updating to latest " +msgstr "ę›“ę–°åˆ°ęœ€ę–°ē‰ˆęœ¬" + +msgid "Updating... " +msgstr "曓新中..." + +msgid "Checking for updates..." +msgstr "ęŖ¢ęŸ„ę›“ę–°..." + +msgid "Undoing Update..." +msgstr "꒤銷ꛓꖰ..." + +msgid "Undoing Revert..." +msgstr "ę’¤éŠ·é‚„åŽŸ..." + +msgid "Reverting to Previous version..." +msgstr "é‚„åŽŸåˆ°äøŠäø€ē‰ˆęœ¬..." + +msgid "Loading Versions..." +msgstr "č¼‰å…„ē‰ˆęœ¬..." + +msgid "Removing Customizations..." +msgstr "ē§»é™¤č‡Ŗå®šē¾©..." + +msgid "Failed to load previous versions" +msgstr "č¼‰å…„äøŠäø€ē‰ˆęœ¬å¤±ę•—" + +#. Setting: System Image +#. Field: title +msgid "System Image" +msgstr "系統" + +#. Setting: System Image +#. Field: hint +msgid "Manage the currently installed image with bootc." +msgstr "使用 bootc ē®”ē†ē•¶å‰å®‰č£ēš„ę˜ åƒ" + +#. Setting: Image +#. Field: title +msgid "Image" +msgstr "ę˜ åƒ" + +#. Setting: Next +#. Field: title +msgid "Next" +msgstr "下一個" + +#. Setting: Current +#. Field: title +msgid "Current" +msgstr "ē›®å‰" + +#. Setting: Previous +#. Field: title +msgid "Previous" +msgstr "äøŠäø€å€‹" + +#. Setting: Update +#. Field: title +msgid "Update" +msgstr "ꛓꖰ" + +#. Setting: Update Stage +#. Field: title +msgid "Update Stage" +msgstr "ꛓꖰ階ꮵ" + +#. Setting: Apply Update +#. Field: title +msgid "Apply Update" +msgstr "應用曓新" + +#. Setting: Revert to Previous +#. Field: title +msgid "Revert to Previous" +msgstr "é‚„åŽŸåˆ°äøŠäø€å€‹" + +#. Setting: Revert to Previous +#. Field: hint +msgid "Rollback to the previous image." +msgstr "å›žę»¾åˆ°äøŠäø€å€‹ę˜ åƒ" + +#. Setting: Change Version (Rebase) +#. Field: title +msgid "Change Version (Rebase)" +msgstr "ę›“ę”¹ē‰ˆęœ¬ (é‡ę–°å®šä½)" + +#. Setting: Remove Pin and Update +#. Field: title +msgid "Remove Pin and Update" +msgstr "ē§»é™¤ē‰ˆęœ¬éŽ–å®šäø¦ę›“ę–°" + +#. Setting: Change Branch (Rebase) +#. Field: title +msgid "Change Branch (Rebase)" +msgstr "ę›“ę”¹åˆ†ę”Æ (é‡ę–°å®šä½)" + +#. Setting: Check for Updates +#. Field: title +msgid "Check for Updates" +msgstr "ęŖ¢ęŸ„ę›“ę–°" + +#. Setting: Reboot +#. Field: title +msgid "Reboot" +msgstr "é‡ę–°å•Ÿå‹•" + +#. Setting: Reboot +#. Field: hint +msgid "Reboot to apply the update. Are you sure?" +msgstr "é‡ę–°å•Ÿå‹•ä»„ę‡‰ē”Øę›“ę–°ć€‚ē¢ŗå®šå—Žļ¼Ÿ" + +#. Setting: Undo Update +#. Field: title +msgid "Undo Update" +msgstr "꒤銷ꛓꖰ" + +#. Setting: Undo Revert +#. Field: title +msgid "Undo Revert" +msgstr "ę’¤éŠ·é‚„åŽŸ" + +#. Setting: Choose Version (Rebase) +#. Field: title +msgid "Choose Version (Rebase)" +msgstr "éøę“‡ē‰ˆęœ¬ (Rebase)" + +#. Setting: Run rpm-ostree reset +#. Field: title +msgid "Run rpm-ostree reset" +msgstr "執蔌 rpm-ostree reset" + +#. Setting: Run rpm-ostree reset +#. Field: hint +msgid "" +"Disable the custom initramfs and remove layers. Your personal data will " +"not be affected." +msgstr "" +"ē¦ē”Øč‡Ŗå®šē¾© initramfs 並移除覆蓋層。" +"ę‚Øēš„å€‹äŗŗč³‡ę–™äøęœƒå—åˆ°å½±éŸæć€‚" +#. Setting: Cancel +#. Field: title +msgid "Cancel" +msgstr "å–ę¶ˆ" + +#. Setting: Branch +#. Field: title +msgid "Branch" +msgstr "åˆ†ę”Æ" + +#. Setting: Version Pin +#. Field: title +msgid "Version Pin" +msgstr "ē‰ˆęœ¬éŽ–å®š" + +#. Setting: Apply +#. Field: title +msgid "Apply" +msgstr "應用" + +msgid "Uploading log to fpaste..." +msgstr "äøŠå‚³ę—„čŖŒåˆ° fpaste..." + +msgid "Shutting down..." +msgstr "é—œę©Ÿäø­..." + +msgid "Failed to download Handheld Daemon Beta." +msgstr "下載 Handheld Daemon Beta 失敗。" + +msgid "Downloading Beta and Restarting..." +msgstr "下載 Beta äø¦é‡ę–°å•Ÿå‹•..." + +#. Setting: Bug Report +#. Field: title +msgid "Bug Report" +msgstr "Bug 報告" + +#. Setting: Bug Report Link +#. Field: title +msgid "Bug Report Link" +msgstr "Bug å ±å‘Šé€£ēµ" + +#. Setting: Upload Error +#. Field: title +msgid "Upload Error" +msgstr "ę›“ę–°éŒÆčŖ¤" + +#. Setting: Submit Report +#. Field: title +msgid "Submit Report" +msgstr "ęäŗ¤å ±å‘Š" + +#. Setting: Submit Report +#. Field: hint +msgid "Upload a bug report to paste.centos.org" +msgstr "å°‡éŒÆčŖ¤å ±å‘ŠäøŠå‚³åˆ° paste.centos.org" + +#. Setting: Logs from +#. Field: title +msgid "Logs from" +msgstr "ę—„čŖŒä¾†ęŗ" + +#. Setting: Logs from +#. Option: current +msgid "Current Boot" +msgstr "ē•¶å‰å•Ÿå‹•" + +#. Setting: Logs from +#. Option: previous +msgid "Previous Boot (-1)" +msgstr "äøŠäø€ę¬”å•Ÿå‹• (-1)" + +#. Setting: Logs from +#. Option: m2 +msgid "Boot -2" +msgstr "啟動 -2" + +#. Setting: Logs from +#. Option: m3 +msgid "Boot -3" +msgstr "啟動 -3" + +#. Setting: Development Tools +#. Field: title +msgid "Development Tools" +msgstr "開發巄具" + +#. Setting: Use HHD Beta Until Restart +#. Field: title +msgid "Use HHD Beta Until Restart" +msgstr "使用 HHD Beta ē›“åˆ°é‡ę–°å•Ÿå‹•" + +#. Setting: Use HHD Beta Until Restart +#. Field: hint +msgid "Switch to the HHD beta channel until you restart." +msgstr "åˆ‡ę›åˆ° HHD beta é »é“ē›“åˆ°é‡ę–°å•Ÿå‹•ć€‚" + +#. Setting: Go Back to Stable +#. Field: title +msgid "Go Back to Stable" +msgstr "čæ”å›žē©©å®šē‰ˆ" + +#. Setting: System +#. Field: hint +msgid "" +"Basic display settings. Brightness (and framerate TBD). This pane is " +"meant to replace " +msgstr "" +"åŸŗęœ¬é”Æē¤ŗčØ­å®šć€‚äŗ®åŗ¦ļ¼ˆå’Œå¹€ēŽ‡å¾…å®šļ¼‰ć€‚" +"ę­¤é¢ęæę—ØåœØę›æä»£" + +#. Setting: Brightness +#. Field: title +msgid "Brightness" +msgstr "亮度" + +#. Setting: Brightness +#. Field: hint +msgid "" +"Sets the brightness level of a display. Only one display is supported and" +" it is the one that was read." +msgstr "čØ­å®šé”Æē¤ŗå™Øēš„äŗ®åŗ¦ē“šåˆ„ć€‚åƒ…ę”ÆęŒäø€å€‹é”Æē¤ŗå™Øļ¼Œäø¦äø”ę˜Æč®€å–ēš„é”Æē¤ŗå™Øć€‚" + +#. Setting: Display +#. Field: title +msgid "Display" +msgstr "锯示" + +#. Setting: Disable Touchscreen (Until Restart) +#. Field: title +msgid "Disable Touchscreen (Until Restart)" +msgstr "ē¦ē”Øč§øęŽ§čž¢å¹• (ē›“åˆ°é‡ę–°å•Ÿå‹•)" + +#. Setting: Disable Touch Gestures (Until Restart) +#. Field: title +msgid "Disable Touch Gestures (Until Restart)" +msgstr "ē¦ē”Øč§øęŽ§ę‰‹ę˜Æ (ē›“åˆ°é‡ę–°å•Ÿå‹•)" + +#. Setting: Gamescope +#. Field: title +msgid "Gamescope" +msgstr "éŠęˆ²ēÆ„åœ" + +#. Setting: Run Steam at 60/72 Hz +#. Field: title +msgid "Run Steam at 60/72 Hz" +msgstr "Steam å°‡åœØ 60/72 Hz äø‹é‹č”Œ" + +#. Setting: Poweroff screen before sleep +#. Field: title +msgid "Poweroff screen before sleep" +msgstr "åœØē”ēœ å‰é—œé–‰čž¢å¹•" + +#. Setting: All Controllers +#. Field: title +msgid "All Controllers" +msgstr "ę‰€ęœ‰ęŽ§åˆ¶å™Ø" + +#. Setting: Xbox or View + B (Press) +#. Field: title +msgid "Xbox or View + B (Press)" +msgstr "Xbox ꈖ View + B (ęŒ‰äø‹)" + +#. Setting: Xbox or View + B (Press) +#. Option: keyboard +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "Steam Keyboard" +msgstr "Steam éµē›¤" + +#. Setting: Xbox or View + B (Press) +#. Option: steam_qam +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "Steam Side Menu" +msgstr "Steam å“é‚Šéøå–®" + +#. Setting: Xbox or View + B (Press) +#. Option: steam_expanded +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "Steam Overlay" +msgstr "Steam 覆蓋選單" + +#. Setting: Xbox or View + B (Press) +#. Option: hhd_qam +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "HHD Side Menu" +msgstr "HHD å“é‚Šéøå–®" + +#. Setting: Xbox or View + B (Press) +#. Option: hhd_expanded +#. Setting: Xbox or View + Y +#. Setting: ↑ Swipe Up +#. Setting: ← Swipe Right Side (Top) +#. Setting: ← Swipe Right Side (Bottom) +#. Setting: → Swipe Left Side (Top) +#. Setting: → Swipe Left Side (Bottom) +#. Setting: ↓ Swipe Down +#. Setting: Start (Meta) Press +#. Setting: Start (Meta) Hold +#. Setting: Ctrl + 3 +#. Setting: Ctrl + 4 +msgid "HHD Overlay" +msgstr "HHD 覆蓋選單" + +#. Setting: Xbox or View + Y +#. Field: title +msgid "Xbox or View + Y" +msgstr "Xbox ꈖ View + Y" + +#. Setting: Xbox or View + Y +#. Option: disconnect +msgid "Disconnect Controller" +msgstr "äø­ę–·ęŽ§åˆ¶å™Øé€£ęŽ„" + +#. Setting: Touchscreen +#. Field: title +msgid "Touchscreen" +msgstr "č§øęŽ§čž¢å¹•" + +#. Setting: ↑ Swipe Up +#. Field: title +msgid "↑ Swipe Up" +msgstr "↑ å‘äøŠę»‘å‹•" + +#. Setting: ← Swipe Right Side (Top) +#. Field: title +msgid "← Swipe Right Side (Top)" +msgstr "← å³é‚Šå“ę»‘ (é ‚éƒØ)" + +#. Setting: ← Swipe Right Side (Bottom) +#. Field: title +msgid "← Swipe Right Side (Bottom)" +msgstr "← å³é‚Šå“ę»‘ (åŗ•éƒØ)" + +#. Setting: → Swipe Left Side (Top) +#. Field: title +msgid "→ Swipe Left Side (Top)" +msgstr "→ å·¦é‚Šå“ę»‘ (é ‚éƒØ)" + +#. Setting: → Swipe Left Side (Bottom) +#. Field: title +msgid "→ Swipe Left Side (Bottom)" +msgstr "→ å·¦é‚Šå“ę»‘ (åŗ•éƒØ)" + +#. Setting: ↓ Swipe Down +#. Field: title +msgid "↓ Swipe Down" +msgstr "↓ 向下滑動" + +#. Setting: Orientation Correction +#. Field: title +msgid "Orientation Correction" +msgstr "方向栔正" + +#. Setting: Auto +#. Field: title +msgid "Auto" +msgstr "自動" + +#. Setting: Device +#. Field: title +msgid "Device" +msgstr "設備" + +#. Setting: Portrait +#. Field: title +msgid "Portrait" +msgstr "盓向" + +#. Setting: Flip Left-Right +#. Field: title +msgid "Flip Left-Right" +msgstr "å·¦å³ēæ»č½‰" + +#. Setting: Flip Top-Bottom +#. Field: title +msgid "Flip Top-Bottom" +msgstr "äøŠäø‹ēæ»č½‰" + +#. Setting: Keyboard (Gaming Only) +#. Field: title +msgid "Keyboard (Gaming Only)" +msgstr "éµē›¤ (åƒ…éŠęˆ²äø­)" + +#. Setting: Start (Meta) Press +#. Field: title +msgid "Start (Meta) Press" +msgstr "Start (Meta) ęŒ‰äø‹" + +#. Setting: Start (Meta) Hold +#. Field: title +msgid "Start (Meta) Hold" +msgstr "Start (Meta) ęŒ‰ä½" + +#. Setting: Ctrl + 3 +#. Field: title +msgid "Ctrl + 3" +msgstr "" + +#. Setting: Ctrl + 4 +#. Field: title +msgid "Ctrl + 4" +msgstr "" + +msgid "Failed to hibernate (missing swap file)." +msgstr "ē”ēœ å¤±ę•— (ē¼ŗå°‘äŗ¤ę›ę–‡ä»¶)怂" + +msgid "Failed to create temporary swap." +msgstr "å‰µå»ŗč‡Øę™‚äŗ¤ę›ę–‡ä»¶å¤±ę•—ć€‚" + +msgid "Failed to hibernate to temporary swap." +msgstr "ē”ēœ åˆ°č‡Øę™‚äŗ¤ę›ę–‡ä»¶å¤±ę•—ć€‚" + +#. Setting: Power +#. Field: title +msgid "Power" +msgstr "電源" + +#. Setting: Reboot into Windows +#. Field: title +msgid "Reboot into Windows" +msgstr "é‡ę–°å•Ÿå‹•åˆ° Windows" + +#. Setting: Reboot into Windows +#. Field: hint +msgid "Make sure you saved your game progress." +msgstr "č«‹ē¢ŗäæę‚Øå·²äæå­˜éŠęˆ²é€²åŗ¦ć€‚" + +#. Setting: Hibernate +#. Field: title +msgid "Hibernate" +msgstr "ē”ēœ " + +#. Setting: Hibernate +#. Field: hint +msgid "Saves your progress and powers off the device." +msgstr "äæå­˜ę‚Øēš„é€²åŗ¦äø¦é—œé–‰čØ­å‚™ć€‚" + +#. Setting: Hibernate when device asks and at 5%. +#. Field: title +msgid "Hibernate when device asks and at 5%." +msgstr "ē•¶čØ­å‚™č¦ę±‚ę™‚å’ŒåœØ 5% ę™‚é€²å…„ē”ēœ " + +#. Setting: Hibernate when device asks and at 5%. +#. Field: hint +msgid "" +"Certain devices wake up to force Windows to hibernate. If Linux does not\n" +"they cause issues. Detect and hibernate on thermal events and on 5%\n" +"battery.\n" +msgstr "" +"ęŸäŗ›čØ­å‚™ęœƒå–šé†’ä»„å¼·åˆ¶ Windows é€²å…„ē”ēœ ć€‚\n" +"å¦‚ęžœ Linux äøé€™ęØ£åšļ¼Œå®ƒå€‘ęœƒå¼•čµ·å•é”Œć€‚\n" +"åœØē†±äŗ‹ä»¶å’Œé›»ę± å‰©é¤˜ 5% ę™‚ęŖ¢ęø¬äø¦é€²å…„ē”ēœ ć€‚\n" + +#. Setting: Steam Powerbutton Handler +#. Field: title +msgid "Steam Powerbutton Handler" +msgstr "Steam é›»ęŗéµč§øē™¼" + +#. Setting: Steam Powerbutton Handler +#. Field: hint +msgid "" +"Enables the Steam Powerbutton handler (responsible for the wink and " +"powerbutton menu)." +msgstr "å•Ÿē”Ø Steam é›»ęŗéµč§øē™¼ (č² č²¬ē”ēœ å‹•ē•«å’Œé›»ęŗęŒ‰éˆ•ęø…å–®)" + +#. Setting: Saturation +#. Field: title +msgid "Saturation" +msgstr "é£½å’Œåŗ¦" + +#. Setting: Brightness +#. Option: low +#. Setting: Speed +msgid "Low" +msgstr "低" + +#. Setting: Stick Style +#. Field: title +msgid "Stick Style" +msgstr "ę–ę”æęØ£å¼" + +#. Setting: Stick Style +#. Option: monster_woke +msgid "Monster Woke" +msgstr "" + +#. Setting: Stick Style +#. Option: flowing +msgid "Flowing Light" +msgstr "" + +#. Setting: Stick Style +#. Option: sunset +msgid "Sunset Afterglow" +msgstr "" + +#. Setting: Stick Style +#. Option: neon +msgid "Colorful Neon" +msgstr "" + +#. Setting: Stick Style +#. Option: dreamy +msgid "Dreamy" +msgstr "" + +#. Setting: Stick Style +#. Option: cyberpunk +msgid "Cyberpunk" +msgstr "" + +#. Setting: Stick Style +#. Option: colorful +msgid "Colorful" +msgstr "" + +#. Setting: Stick Style +#. Option: aurora +msgid "Aurora" +msgstr "" + +#. Setting: Stick Style +#. Option: sun +msgid "Warm Sun" +msgstr "" + +#. Setting: Stick Style +#. Option: classic +msgid "OXP Classic" +msgstr "" + +#. Setting: Secondary +#. Field: title +msgid "Secondary" +msgstr "ꬔ要" + +#. Setting: Enable Secondary +#. Field: title +msgid "Enable Secondary" +msgstr "å•Ÿē”Øę¬”č¦" + +#. Setting: Speed +#. Field: title +msgid "Speed" +msgstr "é€Ÿåŗ¦" + +#. Setting: Direction +#. Field: title +msgid "Direction" +msgstr "方向" + +#. Setting: Spiral +#. Field: title +msgid "Spiral" +msgstr "čžŗę—‹" + +#. Setting: Spiral +#. Field: hint +msgid "Creates an RGB spiral around the stick." +msgstr "åœØę–ę”æå‘Øåœå»ŗē«‹äø€å€‹ RGB čžŗę—‹" + +#. Setting: Duality +#. Field: title +msgid "Duality" +msgstr "雙色" + +#. Setting: Duality +#. Field: hint +msgid "Alternates between two colors." +msgstr "åœØå…©ēØ®é”č‰²ä¹‹é–“äŗ¤ę›æ" + +#. Setting: OneXPlayer +#. Field: title +msgid "OneXPlayer" +msgstr "" + +#. Setting: RGB Settings +#. Field: title +msgid "RGB Settings" +msgstr "RGB 設定" + +#. Setting: Controller RGB +#. Field: title +msgid "Controller RGB" +msgstr "ęŽ§åˆ¶å™Ø RGB" + +#. Setting: Enable RGB support. +#. Field: title +msgid "Enable RGB support." +msgstr "å•Ÿē”Ø RGB ę”Æę“" + +#~ msgid "Bazzite" +#~ msgstr "" + +#~ msgid "Utilities" +#~ msgstr "å·„å…·" + +#~ msgid "Simplified Chinese" +#~ msgstr "简体中文" + +#~ msgid "Traditional Chinese" +#~ msgstr "繁體中文" + +#~ msgid "" +#~ "Sets the sampling frequency for the " +#~ "IMU. Check " +#~ "`/sys/bus/iio/devices/iio:device0/in_anglvel_sampling_frequency_available`." +#~ msgstr "" +#~ "設定 IMU ēš„ęŽ”ęØ£é »ēŽ‡ć€‚ęŖ¢ęŸ„ " +#~ "`/sys/bus/iio/devices/iio:device0/in_anglvel_sampling_frequency_available`" + +#~ msgid "Paddles" +#~ msgstr "čƒŒéµ" + +#~ msgid "Fix touchpad hold [BROKEN]" +#~ msgstr "äæ®å¾©č§øęŽ§ęæé•·ęŒ‰ [äøåÆē”Ø]" + +#~ msgid "" +#~ "Sets the sampling frequency for the " +#~ "IMU. 1600 requires an IMU patch. " +#~ "Check " +#~ "`/sys/bus/iio/devices/iio:device0/in_anglvel_sampling_frequency_available`." +#~ msgstr "" +#~ "設定 IMU ēš„å–ęØ£é »ēŽ‡ć€‚1600 éœ€č¦äø€å€‹ IMU č£œäøć€‚éµęŸ„ " +#~ "`/sys/bus/iio/devices/iio:device0/in_anglvel_sampling_frequency_available`" + +#~ msgid "Shortcut Support + Side Button double/triple press (Requires Restart)" +#~ msgstr "åæ«ę·éµę”Æę“ + å“é‚ŠęŒ‰éˆ•é›™ę“Š/äø‰ę“Šļ¼ˆéœ€č¦é‡å•Ÿļ¼‰" + diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..35a8e6cc --- /dev/null +++ b/install.sh @@ -0,0 +1,71 @@ +#!/usr/bin/bash +# Installs Handheld Daemon to ~/.local/share/hhd + +if [ "$EUID" = 0 ]; then + echo "You should run this script as your user, not root (sudo)." + exit +fi + +is_bazzite=$(cat /etc/os-release | sed -e 's/\(.*\)/\L\1/' | grep bazzite-deck) +if [ "${is_bazzite}" ]; then + echo "Handheld Daemon is preinstalled on bazzite-deck." + echo "If your device is not whitelisted, you can enable Handheld Daemon with the command:" + echo "sudo systemctl enable --now hhd@\$(whoami)" + exit +fi + +is_steamos=$(cat /etc/os-release | grep ID=steamos) +if [[ -n "${is_steamos}" && -z "${BYPASS_STEAMOS_CHECK}" ]]; then + echo "Installing Handheld Daemon on SteamOS is not canon." + echo + echo "Did you mean to install Bazzite? https://bazzite.gg" + exit +fi + +set -e + +# Install Handheld Daemon to ~/.local/share/hhd +mkdir -p ~/.local/share/hhd && cd ~/.local/share/hhd + +python3 -m venv --system-site-packages venv +source venv/bin/activate +pip3 install --upgrade hhd adjustor + +# Install udev rules and create a service file +sudo mkdir -p /etc/udev/rules.d/ +sudo mkdir -p /etc/udev/hwdb.d/ +sudo curl https://raw.githubusercontent.com/hhd-dev/hhd/master/usr/lib/udev/rules.d/83-hhd.rules -o /etc/udev/rules.d/83-hhd.rules +sudo curl https://raw.githubusercontent.com/hhd-dev/hhd/master/usr/lib/udev/hwdb.d/83-hhd.hwdb -o /etc/udev/hwdb.d/83-hhd.hwdb +sudo curl https://raw.githubusercontent.com/hhd-dev/hhd/master/usr/lib/systemd/system/hhd_local%40.service -o /etc/systemd/system/hhd_local@.service + +# Add hhd to user path +mkdir -p ~/.local/bin +ln -sf ~/.local/share/hhd/venv/bin/hhd ~/.local/bin/hhd +ln -sf ~/.local/share/hhd/venv/bin/hhd.contrib ~/.local/bin/hhd.contrib + +FINAL_URL='https://api.github.com/repos/hhd-dev/hhd-ui/releases/latest' +curl -L $(curl -s "${FINAL_URL}" | grep "browser_download_url" | cut -d '"' -f 4) -o $HOME/.local/bin/hhd-ui +chmod +x $HOME/.local/bin/hhd-ui + +if [ -f /sys/fs/selinux/enforce ]; then + # The presence of this file means SELinux is loaded in the kernel. + # A value of 0 means Permissive, 1 means Enforcing. + selinux_enforcing=$(cat /sys/fs/selinux/enforce) + if [[ "$selinux_enforcing" != "0" ]]; then + echo "SELinux is loaded and in enforcing mode: changing hhd file security contextes:" + # Fedora Atomic derived distros (e.g. bazzite-gnome) have /home as a symlink to /var/home + user_home_dir=$(readlink -f $HOME) + sudo semanage fcontext -a -t bin_t $user_home_dir/.local/share/hhd/venv/bin/'.*' + sudo restorecon -Rv $user_home_dir//.local/share/hhd/venv/bin/ + fi +fi + +# Start service and reboot +sudo systemctl enable --now hhd_local@$(whoami) + +echo "" +echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" +echo "!!! Do not forget to remove a Bundled Handheld Daemon if your distro preinstalls it. !!!" +echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" +echo "" +echo "Reboot!" diff --git a/local_hhd.sh b/local_hhd.sh new file mode 100755 index 00000000..7e8a7a5b --- /dev/null +++ b/local_hhd.sh @@ -0,0 +1,25 @@ +#!/usr/bin/bash +# Runs a new handheld daemon version until reboot +sudo systemctl stop hhd@$(whoami) +sudo systemctl stop hhd_local@$(whoami) +sudo pkill hhd + +rm -rf ~/.local/share/hhd-tmp +mkdir -p ~/.local/share/hhd-tmp +python -m venv --system-site-packages ~/.local/share/hhd-tmp/venv +~/.local/share/hhd-tmp/venv/bin/pip install git+https://github.com/hhd-dev/adjustor git+https://github.com/hhd-dev/hhd + +FINAL_URL='https://api.github.com/repos/hhd-dev/hhd-ui/releases/latest' +curl -L $(curl -s "${FINAL_URL}" | grep "browser_download_url" | cut -d '"' -f 4) -o $HOME/.local/share/hhd-tmp/hhd-ui +chmod +x $HOME/.local/share/hhd-tmp/hhd-ui + +nohup sudo \ + HHD_ALLY_POWERSAVE=1 \ + HHD_HORI_STEAM=1 \ + HHD_PPD_MASK=1 \ + HHD_HIDE_ALL=1 \ + HHD_GS_STEAMUI_HALFHZ=1 \ + HHD_GS_DPMS=1 \ + HHD_GS_STANDBY=1 \ + HHD_OVERLAY="$HOME/.local/share/hhd-tmp/hhd-ui" \ + ~/.local/share/hhd-tmp/venv/bin/hhd --user $(whoami) &> /dev/null & diff --git a/pyproject.toml b/pyproject.toml index 4a22bee0..82028a45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hhd" -version = "0.2.0" +version = "3.15.6" authors = [ { name="Kapenekakis Antheas", email="pypi@antheas.dev" }, ] @@ -8,28 +8,53 @@ description = "Handheld Daemon, a tool for configuring handheld devices." readme = "readme.md" requires-python = ">=3.10" classifiers = [ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Operating System :: POSIX :: Linux", ] dependencies = [ "evdev>=1.6.1", "PyYAML>=6.0.1", "rich>=13.5.2", + "setuptools>=65.5.0", + "python-xlib>=0.33", + "pyserial>=3.5", ] [project.urls] -"Homepage" = "https://github.com/antheas/hhd" -"Bug Tracker" = "https://github.com/antheas/hhd/issues" +"Homepage" = "https://github.com/hhd-dev/hhd" +"Bug Tracker" = "https://github.com/hhd-dev/hhd/issues" [project.entry-points."console_scripts"] hhd = "hhd.__main__:main" -"hhd.legion_go" = "hhd.device.legion_go:main" +hhdctl = "hhd.http.ctl:main" +"hhd.contrib" = "hhd.contrib:main" +"hhd.steamos" = "hhd.http.steamos:main" [project.entry-points."hhd.plugins"] legion_go = "hhd.device.legion_go:autodetect" +rog_ally = "hhd.device.rog_ally:autodetect" +gpd_win = "hhd.device.gpd.win:autodetect" +msi_claw = "hhd.device.claw:autodetect" +onexplayer = "hhd.device.oxp:autodetect" +orange_pi = "hhd.device.orange_pi:autodetect" +generic = "hhd.device.generic:autodetect" powerbuttond = "hhd.plugins.powerbutton:autodetect" +rgb = "hhd.plugins.rgb:autodetect" +overlay = "hhd.plugins.overlay:autodetect" +bootc = "hhd.plugins.bootc:autodetect" +debug = "hhd.plugins.debug:autodetect" +power = "hhd.plugins.power:autodetect" +# display = "hhd.plugins.display:autodetect" + +[project.entry-points."hhd.i18n"] +hhd = "hhd.i18n:locales" + +[project.entry-points."babel.extractors"] +hhd_yaml = "hhd.contrib.i18n:extract_hhd_yaml" [build-system] requires = ["setuptools>=61.0", "wheel"] diff --git a/readme.md b/readme.md index bb54feba..2e360447 100644 --- a/readme.md +++ b/readme.md @@ -1,287 +1,288 @@ -# Handheld Daemon (HHD) -Handheld Daemon is a project that aims to provide utilities for managing handheld -devices. -With features ranging from TDP controls, to controller remappings, and gamescope -session management. -This is done through a plugin system, and a dbus daemon, which will expose the -settings of the plugins in a UI agnostic way. - -For the time being, the daemon is not d-bus based, and relies on static configuration -stored on `~/.config/hhd`. -The current version contains a fully functional Dual Sense 5 Edge emulator for -the Legion Go (including touchpad, gyro, and LED support). -It is the aim of this project to provide generic hid-based emulators for most -mainstream controllers (xbox Elite, DS4, DS5, Joycons), so that users of devices -can pick the best target for their device and its controls, which may change -depending on the game. - -*Current Features*: -- Fully functional Dual Sense 5 Emulator (Legion Go) - - All buttons supported - - Rumble feedback - - Touchpad support (steam input as well) - - LED remapping -- Power Button plugin - - Short press makes steam deck sleep - - Long press opens steam power menu - -*Planned Features (in that order)*: -- Steam Deck controller emulation - - No weird glyphs -- TDP Plugin (Legion Go) - - Will provide parity with Legion Space, hardware is already reverse engineered -- d-Bus based Configuration - - Right now, functionality can be tweaked through config files - - Not ideal for a portable device - - A d-Bus daemon and a plugin system will allow safe, polkit based - access to hardware configuration. -- High-end Over/Downclocking Utility for Ryzen processors - - By hooking into the manufacturer ACPI API of the Ryzen platform, - it will expose all TDP related parameters manufacturers have access to - when spec'ing laptops. - - RyzenAdj Successor - - No memory-relaxed requirement - - Safer, as it is the method used by manufacturers - (provided you stay within limits). - - May require DSDT patch on boot, TBD. - +

+ + + + Handheld Daemon Logo. + +

+ +[![PyPI package version](https://badge.fury.io/py/hhd.svg)](https://pypi.org/project/hhd/) +[![Python version 3.10+](https://img.shields.io/badge/python-3.10%2B-informational.svg)](https://pypi.org/project/pasteur/) +[![Code style is Black](https://img.shields.io/badge/code%20style-black-black.svg)](https://github.com/psf/black) + + +# Handheld Daemon +Handheld Daemon provides hardware enablement for Windows handhelds, so that they run correctly in Linux. It acts as a vendor interface replacement (e.g., Armoury Crate equivalent), with fan curves, TDP controls, controller emulation including gyro, back buttons, and SteamOS shortcuts, and RGB remapping. All of this is through a gamescope overlay, accessible through double tapping the side menu button of the device, and a desktop app. + +## Showcase +![Overlay](./docs/overlay.gif) + +## Supported Devices +Handheld Daemon features great support for Lenovo, Asus, GPD, OneXPlayer, and Ayn. It also features some support for Ayaneo devices, Anbernic, and MSI. We aim to support new models by these manufacturers as they release, so if you don't see your device below, chances are it will still work or just needs to have its config included. + +- Lenovo Legion + - Go + - Go S +- Asus ROG + - Ally + - Ally X +- GPD Win (all model years) + - Win 4 + - Win Mini + - Win Max 2 +- OneXPlayer + - X1 (AMD, Intel w/o TDP) + - X1 Mini + - F1, F1 EVA-01, F1L, F1 OLED, F1 Pro + - 2, 2 APR23, 2 PRO APR23, 2 PRO APR23 EVA-01 + - Mini A07 + - Mini Pro + - ONE XPLAYER +- MSI + - Claw 1st Gen, 7/8 AI+ (only front buttons; suspend issues) +- Zotac + - Zotac Gaming Zone (1st gen; only front buttons) +- Ayn + - Loki MiniPro/Zero/Max +- Ayaneo + - Air Standard/Plus/Pro + - 1S/1S Limited + - 2/2S + - GEEK, GEEK 1S + - NEXT Lite/Pro/Advance + - SLIDE + - 2021 Standard/Pro/Pro Retro Power + - NEO 2021/Founder + - KUN (only front buttons) +- AOKZOE (No LEDs) + - A1 Normal/Pro +- Anbernic + - Win600 (no keyboard button yet) +- TECNO + - Pocket Go (all buttons except bottom switch and gyro; no RGB) ## Installation Instructions -You can install the latest stable version of `hhd` from AUR or PiPy. - -### ChimeraOS -ChimeraOS does not ship with `gcc` to compile hhd dependencies and the functionality -of `handygccs` which fixes the QAM button by default conflicts with hhd. -The easiest way to install is to unlock the filesystem, install hhd, and remove -handygccs. - +For Arch and Fedora see [here](#os-install). +For others, you can use the following script to install a local version of +Handheld Daemon that updates independently of the system. ```bash -# Unlock filesystem -sudo frzr-unlock - -# Run installer -sudo pacman -S base-devel -sudo pikaur -S hhd -sudo pacman -R handygccs-git - -# Enable and reboot -sudo systemctl enable hhd@$(whoami) -sudo reboot +curl -L https://github.com/hhd-dev/hhd/raw/master/install.sh | bash ``` -Then, repeat every time you update Chimera. As a bonus, you will get new HHD -features as well 😊. +This script does not automatically install system dependencies. +A partial list for Ubuntu/Debian can be found [here](#debian). +This includes `acpi_call` for TDP on devices other than the Ally. +For all devices, use the [bazzite kernel](https://github.com/hhd-dev/kernel-bazzite) +for best support or Bazzite. Some caveats for certain devices are listed below. -#### Uninstall -Just run the steps in reverse or switch to a locked Chimera version. +### Uninstall +We are sorry to see you go, use the following to uninstall: ```bash -sudo systemctl disable hhd@$(whoami) - -sudo pikaur -S handygccs-git -sudo pacman -R hhd - -sudo systemctl enable handygccs -sudo reboot +curl -L https://github.com/hhd-dev/hhd/raw/master/uninstall.sh | bash ``` -### PyPi Based installation (Nobara/Read only fs) -If you have a read only fs or are on a fedora based system, you may opt to install -a local version of hhd. +### Using an older version +If you find any issues with the latest version of Handheld Daemon +you can use any version by specifying it with the command below. ```bash -# (nobara) Install Python Headers since evdev has no wheels -# and nobara does not ship them (but arch does) -sudo dnf install python-devel +sudo systemctl stop hhd_local@$(whoami) +~/.local/share/hhd/venv/bin/pip install hhd==2.6.0 +sudo systemctl start hhd_local@$(whoami) +``` -# Install hhd to ~/.local/share/hhd -mkdir -p ~/.local/share/hhd -cd ~/.local/share/hhd +### After Install Instructions -python -m venv venv -source venv/bin/activate -pip install hhd +#### Extra steps for ROG Ally +You can hold the ROG Crate button to switch to the ROG Ally's Mouse mode to turn +the right stick into a mouse. -# Install udev rules and create a service file -sudo curl https://raw.githubusercontent.com/antheas/hhd/master/usr/lib/udev/rules.d/83-hhd.rules -o /etc/udev/rules.d/83-hhd.rules -sudo curl https://raw.githubusercontent.com/antheas/hhd/master/usr/lib/systemd/system/hhd_local%40.service -o /etc/systemd/system/hhd_local@.service +Combinations with the ROG, Armory Crate buttons is not supported in the Ally, +but you can use ROG swap for that. -# Start service and reboot -sudo systemctl enable hhd_local@$(whoami) -sudo reboot -``` +#### Extra steps GPD Win Devices +Swipe the left top of the screen to show handheld daemon in gamescope or open +the desktop app and head to the WinControls tab. There, press apply to remap +the back buttons correctly. -#### Update Instructions -Of course, you will want to update HHD to catch up to latest features -```bash -sudo systemctl stop hhd_local@$(whoami) -~/.local/share/hhd/venv/bin/pip install --upgrade hhd -sudo systemctl start hhd_local@$(whoami) -``` +For the GPD Win 4, the Menu button is used as a combo (Short Pres QAM, +long press Xbox button, double press hhd) and select can be used for +SteamOS chords (e.g., Select + RT is screenshot). For other devices, the R4 +button is used to bring up QAM (single tap), and HHD (double tap/hold). +You can customize to your tastes in the Controller section. -#### Uninstall instructions -To uninstall, simply stop the service and remove the added files. -```bash -sudo systemctl disable hhd_local@$(whoami) -sudo systemctl stop hhd_local@$(whoami) -rm -rf ~/.local/share/hhd -sudo rm /etc/udev/rules.d/83-hhd.rules -sudo rm /etc/systemd/system/hhd_local@.service -# Delete your configuration -rm -r ~/.config/hhd -``` +#### Extra steps for Ayaneo/Ayn +You might experience a tiny amount of lag with the Ayaneo LEDs. +The paddles of the Ayn Loki Max are not remappable as far as we know. -> The above should work on read only fs, provided the /etc directory is not read -> only. +#### Extra steps for Legion Go +If you have set any mappings on Legion Space, they will interfere with Handheld +Daemon. +You can factory reset the Controllers from the Handheld Daemon settings. + +The controller gyros of the Legion Go tend to drift sometimes. Calibrate them +with the built-in calibration by pressing LT + LS and RT + RS, then turning +the Joysticks twice and pressing the triggers. Finally, the controllers will +vibrate and flash the leds, zeroing the gyroscope. + +## Distribution Install +You can install Handheld Daemon from [AUR](https://aur.archlinux.org/packages/hhd) +(Arch) or [COPR](https://copr.fedorainfracloud.org/coprs/hhd-dev/hhd/) (Fedora). +Both update automatically every time there is a new release. +For Debian/Ubuntu see below. -### Arch-based Installation (AUR) ```bash -# Install using your AUR package manager -sudo pikaur -S hhd -sudo yay -S hhd -sudo pacman -S hhd # manjaro only +# Arch +yay -S hhd adjustor hhd-ui + +# Fedora +sudo dnf copr enable hhd-dev/hhd +sudo dnf install hhd adjustor hhd-ui -# Enable and reboot sudo systemctl enable hhd@$(whoami) -sudo reboot ``` -But I dont want to reboot... +### Debian/Ubuntu +The following packages are required for local install to work on Ubuntu/Debian. +Handheld daemon is not packaged for apt yet. ```bash -# Reload hhd's udev rules -sudo udevadm control --reload-rules && sudo udevadm trigger -# Restart iio-proxy-service to stop it -# from polling the accelerometer -sudo systemctl restart iio-sensor-proxy -# Start the service for your user -sudo systemctl start hhd@$(whoami) +sudo apt install \ + libgirepository1.0-dev \ + libcairo2-dev \ + libpython3-dev \ + python3-venv \ + libhidapi-hidraw0 ``` -> To ensure the gyro of the Legion Go and other devices with AMD SFH runs smoothly, -> a udev rule is included that disables the use of the accelerometer by the -> system (e.g., iio-sensor-proxy). -> This limitation will be lifted in the future, if a new driver is written for -> amd-sfh. - -#### Updating/Uninstalling in Arch -HHD will update automatically with your system from then on, or you can update it -manually with your AUR package manager. -To uninstall, just uninstall the package and reboot. +### ā„ļø NixOS +Handheld Daemon (core and overlay, no TDP) is on `nixpkgs` in the `unstable` channel. -```bash -# Update using your AUR package manager -sudo pikaur -S hhd -sudo yay -S hhd -sudo pacman -S hhd # manjaro only - -# Remove to uninstall -sudo pacman -R hhd -sudo reboot +Add the following to your `configuration.nix` to enable: +```nix + services.handheld-daemon = { + enable = true; + user = ""; + ui.enable = true; + }; ``` -## Configuring HHD -The reason you added your username to the hhd service was to bind the hhd daemon -to your user. -This allows HHD to add configuration files with appropriate permissions to your -user dir, which is the following: -```bash -~/.config/hhd -``` +### Bazzite +Handheld Daemon comes pre-installed on [Bazzite](https://bazzite.gg) and +updates along-side the system. +Most users of Handheld Daemon are on Bazzite and Bazzite releases +often happen for Handheld Daemon to update. +Bazzite contains all kernel patches and quirks required for all supported handhelds +to work (to the extent they can; certain Ayaneo devices have issues). -Configuration for plugins will appear in the plugins directory. -Only the legion controller plugin has configuration options for now. -```bash -~/.config/hhd/plugins -``` +If you want to test the development Handheld Daemon version you +can use `ujust _hhd-dev` and give feedback. +It will only last until you reboot and leave no changes to your system. +After changes are deemed stable, they usually are incorporated to Bazzite +after a few days. -Restart `hhd` to reload the configurations afterwards. -```bash -# Arch -sudo systemctl restart hhd@$(whoami) -# Local install -sudo systemctl restart hhd_local@$(whoami) -``` +See [supported devices](#supported-devices) to check the status of your device and +[after install](#issues) for specific device quirks. -## Quirks -### Playstation Driver -There is a small touchpad issue with the playstation driver loaded. -Where a cursor might appear when using the touchpad in steam input. -This should be fixed in the latest version. -If not, you can fix it by blacklisting the playstation driver. -However, you will get a lot of issues if you dont exclusively use steam input -afterwards so do not do it otherwise. -You will not be able to use the touchpad as a touchpad anymore and that is the -only way to wake up the screen in desktop mode. -Games that do not support Dual Sense natively (e.g., wine games) will not have -a correct gamepad profile and will not work either. +## Contributing +### Finding the correct axis for your device +To figure the correct axis from your device, go to steam calibration settings. +Then, in the overlay (double press/hold side button) switch `Motion Axis` to +`Override` and tweak only the axis (without invert) of your device until they +match the glyphs in steam. + +Then, jump in a first person game and turn on `Gyro to Mouse` or `Camera`. +By default (`Yaw`), rotating your device like a steering wheel should turn left +to right, +and rotating it to face down or up should look up or down. +Fix the invert settings of the axis so that it is intuitive. +Finally, switch the setting `Gyro Turning Axis` from `Yaw` (rotate like a steering +wheel) to `Roll` (turn left to right), and fix the remaining axis inversion. + +You can now either take a picture of your screen or translate the settings into +text (e.g., x is k, y is l inverted, z is j) and open an issue. +The override setting also displays the make and model of your device, which +are required to add the mappings to Handheld Daemon. + +### Localizing Handheld Daemon +Handheld Daemon fully supports localization through standard `PO`, `POT` files. +Contribution instructions in progress!!! + +#### For maintainers +You can find `pot` and `po` files for Handheld Daemon under the `i18n` directory. +You can clone/download this repository and open the `./i18n` directory. +Then, just copy the `*.pot` files into `/LC_MESSAGES/*.po` +and begin translating with your favorite text editor, or by using +tool such as [Lokalize](https://apps.kde.org/lokalize/). + +As far as your locale goes, unless you have a good reason to, skip the territory +code (e.g., `el` instead of `el_GR`). + +The files can be updated for a new version with the following commands: ```bash -sudo curl https://raw.githubusercontent.com/antheas/hhd/master/usr/lib/modprobe.d/hhd.conf -o /etc/udev/modprobe.d/hhd.conf -``` +# Prepare dev environment +git clone https://github.com/hhd-dev/hhd +cd hhd +python -m venv venv +pip install babel +pip install -e . -### Other gamepad modes -HHD remaps the xinput mode of the Legion Go controllers into a DS5 controller. -All other modes function as normal. -In addition, HHD adds a shortcuts device that allows remapping the back buttons -and all Legion L, R + button combinations into shortcuts that will work accross -all modes. - -### Freezing Gyro -The gyro used for the DS5 controller is found in the display. -It may freeze occasionally. This is due to the accelerometer driver being -designed to be polled at 5hz, not 100hz. -If that is the case, you need to reboot. - -The gyro may exhibit stutters when being polled by `iio-sensor-proxy` to determine -screen orientation. -However, a udev rule that is installed by default disables this. - -If you do not need gyro support, you should disable it for a .2% cpu utilisation -reduction. -By default, the accelerometer is disabled for this reason. - -You need to set both `gyro` and `gyro-fix` to `False` in the config to disable -gyro support. - -### HandyGCCS -HHD replicates all functionality of HandyGCCS for the Legion Go, so it is not -required. In addition, it will break HHD by hiding the controller. -You should uninstall it with `sudo pacman -R handygccs-git`. -``` - ERROR Device with the following not found: evdev.py:122 - Vendor ID: ['17ef'] - Product ID: ['6182'] - Name: ['Generic X-Box pad'] +# Regenerate POT files +pybabel extract --no-location -F i18n/babel.cfg -o i18n/hhd.pot src/hhd +# Assuming adjustor is in an adjacent directory +pybabel extract --no-location -F i18n/babel.cfg -o i18n/adjustor.pot ../adjustor/src/adjustor + +YOUR_LANG=el + +# Generate PO files for your language if they do not exist +pybabel init -i i18n/hhd.pot -d i18n -D hhd -l $YOUR_LANG +pybabel init -i i18n/adjustor.pot -d i18n -D adjustor -l $YOUR_LANG + +# Update current PO files for your language +pybabel update -i i18n/hhd.pot -d i18n -D hhd -l $YOUR_LANG +pybabel update -i i18n/adjustor.pot -d i18n -D adjustor -l $YOUR_LANG ``` -## Contributing -You should install from source if you aim to contribute or want to pull from master. +### Creating a Local Repo version +Either follow `Automatic Install` or `Manual Local Install` to install the base rules. +Then, clone, optionally install the userspace rules, and run. ```bash -# Install hhd to ~/.local/share/hhd -mkdir -p ~/.local/share/ -git clone https://github.com/antheas/hhd ~/.local/share/hhd - -cd ~/.local/share/hhd +# Clone Handheld Daemon +git clone https://github.com/hhd-dev/hhd +cd hhd python -m venv venv source venv/bin/activate pip install -e . -# Install udev rules and create a service file -sudo curl https://raw.githubusercontent.com/antheas/hhd/master/usr/lib/udev/rules.d/83-hhd.rules -o /etc/udev/rules.d/83-hhd.rules -sudo curl https://raw.githubusercontent.com/antheas/hhd/master/usr/lib/systemd/system/hhd_local%40.service -o /etc/systemd/system/hhd_local@.service - -# Install udev rules to allow running in userspace (optional; great for debugging) -sudo curl https://raw.githubusercontent.com/antheas/hhd/master/usr/lib/udev/rules.d/83-hhd-user.rules -o /etc/udev/rules.d/83-hhd-user.rules +# Install udev rules to allow running without sudo (optional) +# but great for debugging (not all devices will run properly, the rules need to be expanded) +sudo curl https://raw.githubusercontent.com/hhd-dev/hhd/master/usr/lib/udev/rules.d/83-hhd-user.rules -o /etc/udev/rules.d/83-hhd-user.rules # Modprobe uhid to avoid rw errors -sudo curl https://raw.githubusercontent.com/antheas/hhd/master/usr/lib/modules-load.d/hhd-user.conf -o /etc/modules-load.d/hhd-user.conf - -# Reboot -sudo reboot - +sudo curl https://raw.githubusercontent.com/hhd-dev/hhd/master/usr/lib/modules-load.d/hhd-user.conf -o /etc/modules-load.d/hhd-user.conf # You can now run hhd in userspace! hhd -# Add user when running with sudo + +# Use the following to run with sudo sudo hhd --user $(whoami) ``` -## License -An open source license will be chosen in the following days. -It will probably be the Apache license, so if that affects your use case reach -out for feedback. \ No newline at end of file +# License +Handheld Daemon is licensed under THE GNU GPLv3+. See LICENSE for details. +A small number of files are dual licensed with MIT, and contain +SPDX headers denoting so. +Versions prior to and excluding 2.0.0 are licensed using MIT. + +# Credits +Much like a lot of open-source projects, Handheld Daemon is a community effort. +It relies on the kernel drivers +[oxp-sensors](https://github.com/torvalds/linux/blob/master/drivers/hwmon/oxp-sensors.c), [ayn-platform](https://github.com/ShadowBlip/ayn-platform), +[ayaneo-platform](https://github.com/ShadowBlip/ayaneo-platform), +[bmi260](https://github.com/hhd-dev/bmi260), [gpdfan](https://github.com/Cryolitia/gpd-fan-driver/), +and [asus-wmi](https://github.com/torvalds/linux/blob/master/drivers/platform/x86/asus-wmi.c). +In addition, certain parts of Handheld Daemon reference the reverse engineering +efforts of [asus-linux](https://gitlab.com/asus-linux), +the [Handheld Companion](https://github.com/Valkirie/HandheldCompanion) project, +the [ValvePython](https://github.com/ValvePython) project, [pyWinControls](https://github.com/pelrun/pyWinControls), and the [HandyGCCS](https://github.com/ShadowBlip/HandyGCCS) project. +Finally, its functionality is made possible thanks to thousands of hours of +volunteer testing, who have provided feedback and helped shape the project. +Some of those volunteers integrated support for their devices directly, especially +in the case of Ayaneo, GPD, and for the initial support of OneXPlayer, and ROG Ally +devices. diff --git a/src/hhd/__main__.py b/src/hhd/__main__.py index 58f2267b..b7272682 100644 --- a/src/hhd/__main__.py +++ b/src/hhd/__main__.py @@ -3,12 +3,15 @@ import logging import os import signal +import subprocess +import sys +import time from os.path import join -from threading import Condition, RLock +from threading import Condition from threading import Event as TEvent -from threading import Lock +from threading import RLock from time import sleep -from typing import Sequence, cast +from typing import Sequence import pkg_resources @@ -23,29 +26,48 @@ load_relative_yaml, ) from .plugins.settings import ( + Validator, get_default_state, + get_settings_hash, + load_blacklist_yaml, load_profile_yaml, load_state_yaml, merge_settings, + parse_defaults, + save_blacklist_yaml, save_profile_yaml, save_state_yaml, validate_config, ) -from .utils import expanduser, fix_perms, get_context +from .utils import ( + GIT_ADJ, + GIT_HHD, + HHD_DEV_DIR, + expanduser, + fix_perms, + get_ac_status, + get_ac_status_fn, + get_context, + get_os, + switch_priviledge, +) logger = logging.getLogger(__name__) CONFIG_DIR = os.environ.get("HHD_CONFIG_DIR", "~/.config/hhd") +PLUGIN_WHITELIST = os.environ.get("HHD_PLUGINS", "") ERROR_DELAY = 5 +INIT_DELAY = 0.4 POLL_DELAY = 2 -MODIFY_DELAY = 0.1 +SLEEP_MIN_T = 8 class EmitHolder(Emitter): - def __init__(self, condition: Condition) -> None: + def __init__(self, condition: Condition, ctx, info) -> None: self._events = [] self._condition = condition + super().__init__(ctx=ctx, info=info) def __call__(self, event: Event | Sequence[Event]) -> None: with self._condition: @@ -77,6 +99,31 @@ def _inner(sig, frame): return _inner +def get_wakeup_count(): + try: + with open("/sys/power/wakeup_count", "r") as f: + return int(f.read().strip()) + except Exception: + return -1 + + +def print_token(ctx): + token_fn = expanduser(join(CONFIG_DIR, "token"), ctx) + + try: + with open(token_fn, "r") as f: + token = f.read().strip() + + logger.info(f'Current HHD token (for user "{ctx.name}") is: "{token}"') + except Exception as e: + logger.error(f"Token not found or could not be read, error:\n{e}") + logger.info( + "Enable the http endpoint to generate a token automatically.\n" + + "Or place it under '~/.config/hhd/token' manually.\n" + + "'chown 600 ~/.config/hhd/token' for security reasons!" + ) + + def main(): parser = argparse.ArgumentParser( prog="HHD: Handheld Daemon main interface.", @@ -89,6 +136,12 @@ def main(): help="The user whose home directory will be used to store the files (~/.config/hhd).", dest="user", ) + parser.add_argument( + "command", + nargs="*", + default=[], + help="The command to run. If empty, run as daemon. Right now, only the command `token` is supported.", + ) args = parser.parse_args() user = args.user @@ -101,26 +154,88 @@ def main(): detectors: dict[str, HHDAutodetect] = {} plugins: dict[str, Sequence[HHDPlugin]] = {} cfg_fds = [] + switch_root = None # HTTP data https = None prev_http_cfg = None + updated = False + last_event = None + ac_fn = get_ac_status_fn() + info = Config() + info["ac"] = None + ac_status = None + + # Check we are in a virtual environment + # TODO: Improve + exe_python = sys.executable try: + # Create nested hhd dir + # This might mess up permissions in upward directories + # So try to deescalate + hhd_dir = expanduser(CONFIG_DIR, ctx) + try: + switch_priviledge(ctx, False) + os.makedirs(hhd_dir, exist_ok=True) + switch_priviledge(ctx, True) + fix_perms(hhd_dir, ctx) + except Exception: + pass + + # Remove old dir + try: + os.rename( + join(hhd_dir, "plugins"), join(hhd_dir, "plugins_old_USE_STATEYML") + ) + except Exception: + pass + set_log_plugin("main") setup_logger(join(CONFIG_DIR, "log"), ctx=ctx) + if args.command: + if args.command[0] == "token": + print_token(ctx) + return + else: + logger.error(f"Command '{args.command[0]}' is unknown. Ignoring...") + + # Get OS Info + info["os"] = get_os() + + # Use blacklist + blacklist_fn = join(hhd_dir, "plugins.yml") + blacklist = load_blacklist_yaml(blacklist_fn) + + logger.info(f"Running autodetection...") + + detector_names = [] + whitelist = PLUGIN_WHITELIST.split(",") if PLUGIN_WHITELIST else [] for autodetect in pkg_resources.iter_entry_points("hhd.plugins"): + name = autodetect.name + detector_names.append(name) + if name in blacklist: + logger.info(f"Skipping blacklisted provider '{name}'.") + if whitelist and name not in whitelist: + logger.info(f"Skipping provider '{name}' due to whitelist.") + continue + detectors[autodetect.name] = autodetect.resolve() + # Save new blacklist file + save_blacklist_yaml(blacklist_fn, detector_names, blacklist) + fix_perms(blacklist_fn, ctx) + logger.info(f"Found plugin providers: {', '.join(list(detectors))}") - logger.info(f"Running autodetection...") for name, autodetect in detectors.items(): plugins[name] = autodetect([]) plugin_str = "Loaded the following plugins:" for pkg_name, sub_plugins in plugins.items(): + if not sub_plugins: + continue plugin_str += ( f"\n - {pkg_name:>8s}: {', '.join(p.name for p in sub_plugins)}" ) @@ -131,15 +246,34 @@ def main(): for plugs in plugins.values(): sorted_plugins.extend(plugs) sorted_plugins.sort(key=lambda x: x.priority) + validator: Validator = lambda tags, config, value: any( + p.validate(tags, config, value) for p in sorted_plugins + ) if not sorted_plugins: logger.error(f"No plugins started, exiting...") return + # Load locales + locales = [] + for register in pkg_resources.iter_entry_points("hhd.i18n"): + locales.extend(register.resolve()()) + locales.sort(key=lambda x: x["priority"], reverse=True) + + if locales: + lstr = "Loaded the following locales:\n" + for locale in locales: + lstr += ( + f" - {locale['domain']} ({locale['priority']}): {locale['dir']}\n" + ) + logger.info(lstr[:-1]) + else: + logger.info("No locales found.") + # Open plugins lock = RLock() cond = Condition(lock) - emit = EmitHolder(cond) + emit = EmitHolder(cond, ctx, info) for p in sorted_plugins: set_log_plugin(getattr(p, "log") if hasattr(p, "log") else "ukwn") p.open(emit, ctx) @@ -150,6 +284,7 @@ def main(): state_fn = expanduser(join(CONFIG_DIR, "state.yml"), ctx) token_fn = expanduser(join(CONFIG_DIR, "token"), ctx) settings: HHDSettings = {} + shash = None # Load profiles profiles = {} @@ -161,49 +296,112 @@ def main(): # Monitor config files for changes should_initialize = TEvent() - should_initialize.set() + initial_run = True + reset = False should_exit = TEvent() signal.signal(signal.SIGPOLL, notifier(should_initialize, cond)) signal.signal(signal.SIGINT, notifier(should_exit, cond)) signal.signal(signal.SIGTERM, notifier(should_exit, cond)) + # Get wakeup count for sleep detection + wakeup_count = get_wakeup_count() + while not should_exit.is_set(): # # Configuration # # Initialize if files changed - if should_initialize.is_set(): + if should_initialize.is_set() or initial_run: + # wait a bit to allow other processes to save files + if not initial_run: + sleep(INIT_DELAY) + initial_run = False set_log_plugin("main") logger.info(f"Reloading configuration.") # Settings hhd_settings = {"hhd": load_relative_yaml("settings.yml")} + # TODO: Improve check + try: + if "venv" not in exe_python: + del hhd_settings["hhd"]["settings"]["children"]["update_stable"] + del hhd_settings["hhd"]["settings"]["children"]["update_beta"] + except Exception as e: + logger.warning(f"Could not hide update settings. Error:\n{e}") settings = merge_settings( - [*[p.settings() for p in sorted_plugins], hhd_settings] + [hhd_settings, *[p.settings() for p in sorted_plugins]] ) + # Force general settings to be last + if "hhd" in settings: + settings = dict(settings) + tmp = settings.pop("hhd") + settings["hhd"] = tmp + shash = get_settings_hash(settings) # State - new_conf = load_state_yaml(state_fn, settings) - if not new_conf: - if conf.conf: - logger.warning(f"Using previous configuration.") - else: - logger.info(f"Using default configuration.") - conf = get_default_state(settings) + if reset: + logger.warning(f"Resetting settings.") + conf = get_default_state(settings) + conf.updated = True + reset = False else: - conf = new_conf + new_conf = load_state_yaml(state_fn, settings) + if not new_conf: + if conf.conf: + logger.warning(f"Using previous configuration.") + else: + logger.info(f"Using default configuration.") + conf = get_default_state(settings) + else: + conf = new_conf + + from importlib.metadata import version + + try: + ver = version("hhd") + conf["hhd.settings.version"] = ver + logger.info(f"Handheld Daemon Version: {ver}") + except Exception: + pass + + try: + ver = version("adjustor") + conf["hhd.settings.version_adj"] = ver + logger.info(f"Adjustor Version: {ver}") + except Exception: + conf["hhd.settings.version_adj"] = "Not Installed" + logger.info(f"Adjustor not installed") + + try: + from hhd.plugins.overlay.overlay import ( + find_overlay_exe, + get_overlay_version, + ) + + exe = find_overlay_exe(ctx) + if exe: + ver = get_overlay_version(exe, ctx) + conf["hhd.settings.version_ui"] = ver + logger.info(f"Overlay Version: {ver}") + else: + conf["hhd.settings.version_ui"] = "Not Installed" + logger.info(f"Overlay not installed") + except Exception: + logger.info(exe) # Profiles profiles = {} templates = {} + os.makedirs(profile_dir, exist_ok=True) + fix_perms(profile_dir, ctx) for fn in os.listdir(profile_dir): if not fn.endswith(".yml"): continue name = fn.replace(".yml", "") s = load_profile_yaml(join(profile_dir, fn)) if s: - validate_config(s, settings, use_defaults=False) + validate_config(s, settings, validator, use_defaults=False) if name.startswith("_"): templates[name] = s else: @@ -221,6 +419,7 @@ def main(): # Monitor files for changes for fd in cfg_fds: try: + fcntl.fcntl(fd, fcntl.F_NOTIFY, 0) os.close(fd) except Exception: pass @@ -230,106 +429,219 @@ def main(): join(CONFIG_DIR, "profiles"), ] for fn in cfg_fns: - fd = os.open(expanduser(fn, ctx), os.O_RDONLY) - fcntl.fcntl( - fd, - fcntl.F_NOTIFY, - fcntl.DN_CREATE | fcntl.DN_DELETE | fcntl.DN_MODIFY, - ) + fd = -1 + try: + fd = os.open(expanduser(fn, ctx), os.O_RDONLY) + fcntl.fcntl( + fd, + fcntl.F_NOTIFY, + fcntl.DN_CREATE + | fcntl.DN_DELETE + | fcntl.DN_MODIFY + | fcntl.DN_RENAME + | fcntl.DN_MULTISHOT, + ) + except Exception: + if fd != -1: + os.close(fd) + continue cfg_fds.append(fd) + should_initialize.clear() + logger.info(f"Initialization Complete!") - # Initialize http server - http_cfg = conf["hhd.http"] - if http_cfg != prev_http_cfg: - prev_http_cfg = http_cfg - if https: - https.close() - if http_cfg["enable"]: - from .http import HHDHTTPServer - - port = http_cfg["port"].to(int) - localhost = http_cfg["localhost"].to(bool) - use_token = http_cfg["token"].to(bool) - - # Generate security token - if use_token: + # Initialize http server + http_cfg = conf["hhd.http"] + if http_cfg != prev_http_cfg: + prev_http_cfg = http_cfg + if https: + https.close() + if http_cfg["enable"].to(bool): + from .http import HHDHTTPServer + + port = http_cfg["port"].to(int) + localhost = http_cfg["localhost"].to(bool) + use_token = http_cfg["token"].to(bool) + + # Generate security token + if use_token: + if not os.path.isfile(token_fn): import hashlib import random token = hashlib.sha256( str(random.random()).encode() - ).hexdigest() + ).hexdigest()[:12] with open(token_fn, "w") as f: os.chmod(token_fn, 0o600) f.write(token) - - sleep(MODIFY_DELAY) - should_initialize.clear() + fix_perms(token_fn, ctx) else: - token = None + with open(token_fn, "r") as f: + token = f.read().strip() + else: + token = None - set_log_plugin("rest") - https = HHDHTTPServer(localhost, port, token) - https.update(settings, conf, profiles, emit) + set_log_plugin("rest") + https = HHDHTTPServer(localhost, port, token) + https.update(settings, conf, info, profiles, emit, locales, ctx) + try: https.open() - update_log_plugins() - set_log_plugin("main") - - logger.info(f"Initialization Complete!") + except Exception as e: + logger.error( + f"Could not start http API on port {port}.\n" + + "Is another version of Handheld Daemon open?\n" + + "Closing." + ) + return + update_log_plugins() + set_log_plugin("main") # # Plugin loop # # Process events + set_log_plugin("main") settings_changed = False - for ev in emit.get_events(): + events = emit.get_events() + + new_wakeup_count = get_wakeup_count() + curr = time.time() + # Debounce sleep event to avoid spurious wakeup triggers + # This loop will run every 2 seconds, perhaps 4 seconds if there is + # a delay. Unless 8 seconds lapse, ignore the event + if ( + new_wakeup_count != wakeup_count + and last_event + and curr > last_event + SLEEP_MIN_T + ): + logger.info( + f"System woke up from sleep. Wakeup count: {new_wakeup_count} from {wakeup_count}." + ) + events: Sequence[Event] = [ + *events, + { + "type": "special", + "event": "wakeup", + "data": { + "count": new_wakeup_count + }, # FIXME: Count might be removed in the future + }, + ] + wakeup_count = new_wakeup_count + last_event = curr + + # AC status + if ac_fn: + new_status = get_ac_status(ac_fn) + if new_status != ac_status: + logger.info(f"AC status is: {new_status}") + ac_status = new_status + info["ac"] = ac_status + + for ev in events: match ev["type"]: case "settings": settings_changed = True case "profile": - if ev["name"] in profiles: - profiles[ev["name"]].update(ev["config"].conf) - else: + new_conf = ev["config"] + if new_conf: with lock: profiles[ev["name"]] = ev["config"] - validate_config( - profiles[ev["name"]], settings, use_defaults=False - ) + validate_config( + profiles[ev["name"]], + settings, + validator, + use_defaults=False, + ) + else: + with lock: + if ev["name"] in profiles: + del profiles[ev["name"]] case "apply": if ev["name"] in profiles: conf.update(profiles[ev["name"]].conf) case "state": conf.update(ev["config"].conf) + case "special": + if ev["event"] == "restart_dev": + should_exit.set() + switch_root = True + break + elif ev["event"] == "shutdown_dev": + should_exit.set() + # Trigger restart + updated = True + case "acpi" | "tdp" | "ppd" | "energy": + pass case other: logger.error(f"Invalid event type submitted: '{other}'") - # Validate config - validate_config(conf, settings) - # If settings changed, the configuration needs to reload # but it needs to be saved first if settings_changed: - should_initialize.set() + logger.info(f"Reloading settings.") + + # Settings + settings_base = {k: {} for k in load_relative_yaml("sections.yml")['sections']} + hhd_settings = {"hhd": load_relative_yaml("settings.yml")} + # TODO: Improve check + try: + if "venv" not in exe_python: + del hhd_settings["hhd"]["settings"]["children"]["update_stable"] + del hhd_settings["hhd"]["settings"]["children"]["update_beta"] + except Exception as e: + logger.warning(f"Could not hide update settings. Error:\n{e}") + settings = merge_settings( + [ + settings_base, + hhd_settings, + *[p.settings() for p in sorted_plugins], + ] + ) + # Force general settings to be last + if "hhd" in settings: + settings = dict(settings) + tmp = settings.pop("hhd") + settings["hhd"] = tmp + shash = get_settings_hash(settings) - # Plugins are promised that once they emit a - # settings change they are not called with the old settings - if not settings_changed: - # - # Plugin event loop - # + # Add new defaults + conf = Config([parse_defaults(settings), conf.conf]) + conf.updated = True - for p in reversed(sorted_plugins): - set_log_plugin(getattr(p, "log") if hasattr(p, "log") else "ukwn") - p.prepare(conf) - update_log_plugins() + # Validate config + validate_config(conf, settings, validator) + + # + # Plugin event loop + # + # Allow plugins to process events + if events: for p in sorted_plugins: set_log_plugin(getattr(p, "log") if hasattr(p, "log") else "ukwn") - p.update(conf) + p.notify(events) update_log_plugins() - set_log_plugin("ukwn") + + # Run prepare loop + for p in reversed(sorted_plugins): + set_log_plugin(getattr(p, "log") if hasattr(p, "log") else "ukwn") + p.prepare(conf) + update_log_plugins() + + # Run update loop + for p in sorted_plugins: + set_log_plugin(getattr(p, "log") if hasattr(p, "log") else "ukwn") + p.update(conf) + update_log_plugins() + set_log_plugin("ukwn") + + # Notify that events were applied + # Before saving to reduce delay (yaml files take 100ms :( ) + if https: + https.update(settings, conf, info, profiles, emit, locales, ctx) # # Save loop @@ -338,32 +650,135 @@ def main(): has_new = should_initialize.is_set() saved = False # Save existing profiles if open - if save_state_yaml(state_fn, settings, conf): + if save_state_yaml(state_fn, settings, conf, shash): fix_perms(state_fn, ctx) saved = True + conf.updated = False for name, prof in profiles.items(): fn = join(profile_dir, name + ".yml") - if save_profile_yaml(fn, settings, prof): + if save_profile_yaml(fn, settings, prof, shash): fix_perms(fn, ctx) saved = True + prof.updated = False + for prof in os.listdir(profile_dir): + if prof.startswith("_") or not prof.endswith(".yml"): + continue + name = prof[:-4] + if name not in profiles: + fn = join(profile_dir, prof) + try: + new_fn = fn + ".bak" + os.rename(fn, new_fn) + saved = True + except Exception as e: + logger.error( + f"Failed removing profile {name} at:\n{fn}\nWith error:\n{e}" + ) - # Add template config - if save_profile_yaml( - join(profile_dir, "_template.yml"), - settings, - templates.get("_template", None), - ): - fix_perms(join(profile_dir, "_template.yml"), ctx) - saved = True + # Causes unnecessary writes, is not used anyway. + # # Add template config + # if save_profile_yaml( + # join(profile_dir, "_template.yml"), + # settings, + # templates.get("_template", None), + # shash, + # ): + # fix_perms(join(profile_dir, "_template.yml"), ctx) + # saved = True if not has_new and saved: # We triggered the interrupt, clear - sleep(MODIFY_DELAY) should_initialize.clear() - # Notify that events were applied - if https: - https.update(settings, conf, profiles, emit) + upd_stable = conf.get("hhd.settings.update_stable", False) + upd_beta = conf.get("hhd.settings.update_beta", False) + + if upd_stable or upd_beta: + set_log_plugin("main") + conf["hhd.settings.update_stable"] = False + conf["hhd.settings.update_beta"] = False + + switch_priviledge(ctx, False) + try: + logger.info(f"Updating Handheld Daemon.") + if "venv" in exe_python: + subprocess.check_call( + [ + exe_python, + "-m", + "pip", + "uninstall", + "-y", + "hhd", + "adjustor", + ] + ) + subprocess.check_call( + [ + exe_python, + "-m", + "pip", + "install", + "--upgrade", + "--cache-dir", + "/tmp/__hhd_update_cache", + (GIT_HHD if upd_beta else "hhd"), + (GIT_ADJ if upd_beta else "adjustor"), + ] + ) + + if not upd_beta: + # No beta version for the UI yet, skip updating it + import json + import stat + import urllib.request + + with urllib.request.urlopen( + "https://api.github.com/repos/hhd-dev/hhd-ui/releases/latest" + ) as f: + release_data = json.load(f) + + for asset in release_data["assets"]: + os.makedirs( + expanduser("~/.local/bin", ctx), exist_ok=True + ) + if "hhd-ui.AppImage" == asset["name"]: + REPORT_POINTS = 8 + + def progress(idx, blockSize, total): + interval = int( + total / blockSize / REPORT_POINTS + ) + if idx % interval == 0: + logger.info( + f"Downloading overlay: {100*idx*blockSize / total:.1f}%" + ) + + out_fn = expanduser("~/.local/bin/hhd-ui", ctx) + urllib.request.urlretrieve( + asset["browser_download_url"], + out_fn, + reporthook=progress, + ) + + st = os.stat(out_fn) + os.chmod(out_fn, st.st_mode | stat.S_IEXEC) + break + + # Set updated + updated = True + else: + logger.error( + f"Could not update, python executable is not within a venv (checked for 'venv' in path name):\n{exe_python}" + ) + except Exception as e: + err = f"Error while updating:\n{e}" + conf["hhd.settings.update_error"] = err + logger.error(err) + switch_priviledge(ctx, True) + + if updated: + should_exit.set() # Wait for events with lock: @@ -375,8 +790,14 @@ def main(): ): cond.wait(timeout=POLL_DELAY) + # Check reset + if conf["hhd.settings.reset"].to(bool): + conf["hhd.settings.reset"] = False + should_initialize.set() + reset = True + set_log_plugin("main") - logger.info(f"HHD Daemon received interrupt, stopping plugins and exiting.") + logger.info(f"Received interrupt or updated. Stopping plugins and exiting.") finally: for fd in cfg_fds: try: @@ -394,6 +815,26 @@ def main(): set_log_plugin(getattr(p, "log") if hasattr(p, "log") else "ukwn") p.close() + set_log_plugin("main") + try: + logger.info("Closing cached controllers.") + from hhd.controller.virtual.dualsense import Dualsense + from hhd.controller.virtual.uinput import UInputDevice + + UInputDevice.close_cached() + Dualsense.close_cached() + except Exception as e: + logger.error("Could not close cached controllers with error:\n{e}") + + if updated: + # Use error code to restart service + sys.exit(-1) + + if switch_root: + os.environ["HHD_SWITCH_ROOT"] = "1" + o = subprocess.run([HHD_DEV_DIR + "/bin/hhd", *sys.argv], check=False) + sys.exit(o.returncode) + if __name__ == "__main__": main() diff --git a/src/hhd/contrib/__init__.py b/src/hhd/contrib/__init__.py new file mode 100644 index 00000000..21db616e --- /dev/null +++ b/src/hhd/contrib/__init__.py @@ -0,0 +1,3 @@ +from .main import main + +__all__ = ["main"] diff --git a/src/hhd/contrib/__main__.py b/src/hhd/contrib/__main__.py new file mode 100644 index 00000000..40e2b013 --- /dev/null +++ b/src/hhd/contrib/__main__.py @@ -0,0 +1,4 @@ +from .main import main + +if __name__ == "__main__": + main() diff --git a/src/hhd/contrib/dev.py b/src/hhd/contrib/dev.py new file mode 100644 index 00000000..d9045838 --- /dev/null +++ b/src/hhd/contrib/dev.py @@ -0,0 +1,252 @@ +def evdev(dev: str | None): + from typing import cast + from evdev import list_devices, InputDevice, categorize, ecodes + from time import sleep, perf_counter + + cache = {} + + def B(b: str): + if b not in cache: + cache[b] = getattr(ecodes, b) + return cast(int, cache[b]) + + def RV(type: int, code: int): + if (type, code) not in cache: + try: + v = ecodes.bytype[type][code] + except KeyError: + v = "UNKWN" + if isinstance(v, list) or isinstance(v, tuple): + v = v[0] + cache[(type, code)] = v + else: + v = cache[(type, code)] + return v + + def EVT(type): + if type not in cache: + v = getattr(ecodes, "EV")[type] + cache[type] = v + else: + v = cache[type] + return v + + print("Available Devices with the Current Permissions") + avail = list_devices() + for d in avail: + print(f" - {str(InputDevice(d))}") + print() + + if dev and dev != "nograb": + print(f"Using argument '{dev}'.") + try: + sel = f"/dev/input/event{int(dev)}" + except Exception: + sel = dev + if sel not in avail: + print(f"Device '{sel}' not found.") + return + else: + sel = None + while sel not in avail: + try: + sel = input("Enter device path (/dev/input/event# or #): ") + except EOFError: + return + try: + sel = f"/dev/input/event{int(sel)}" + except Exception as e: + pass + print() + + d = InputDevice(sel) + print(f"Selected device `{d}`.") + print() + print("Capabilities") + for (cap_str, cap), vals in d.capabilities(verbose=True).items(): + print(f" - {cap_str} ({cap:x})") + for (names, code) in vals: + if not isinstance(code, int): + abs_info = code + names, code = names + else: + abs_info = "" + print(f" 0x{code:04x}: {', '.join(names) if isinstance(names, list) else names}") + if abs_info: + print(f" > [{str(abs_info)}]") + try: + print() + if dev != "nograb": + print("Attempting to grab device.") + d.grab() + print("Device grabbed, system will not see its events.") + except Exception as e: + print(f"Could not grab device, system will still see events. Error:\n{e}") + print("\nReading events still work.") + print() + print(f"Reading from: `{d}`.") + + endcap = True + start = perf_counter() + ofs = None + prev = 0 + out = "" + for ev in d.read_loop(): + if ofs == None: + ofs = ev.timestamp() + curr = perf_counter() - start + hz = f"{1/(curr - prev):6.1f} Hz" if prev and curr != prev else " NaN Hz" + if ev.code == 0 and ev.type == 0 and ev.value == 0: + out += ( + f"ā”” SYN ─ {curr:7.3f}s ─ {hz} ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜" + ) + print(out) + out = "" + prev = curr + endcap = True + else: + if endcap: + out += "\nā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”\n" + endcap = False + + evstr = ( + f"{ev.timestamp() - ofs:7.3f}s /" + + f" {EVT(ev.type):>6s} ({ev.type:02x}) /" + + f" {RV(ev.type, ev.code):>21s} (x{ev.code:03x}):" + ) + + if ev.type == B("EV_KEY"): + match ev.value: + case 0: + act = "released" + case 1: + act = " pressed" + case 2: + act = "repeated" + case val: + act = f"{val:8d}" + evstr += f" {act}" + elif ev.type == B("EV_ABS"): + evstr += f" {ev.value:8d}" + else: + hexval = f"{ev.value:04X}" + evstr += f" {hexval:>8s}" + + out += f"│ {evstr:>65s} │\n" + sleep(0.00005) + +def device_str(d): + from hhd.controller.lib.common import hexify + + return ( + f"{d['path'].decode():13s} {hexify(d['vendor_id'])}:{hexify(d['product_id'])}" + + f" Usage Page: 0x{hexify(d['usage_page'])} Usage: 0x{hexify(d['usage'])}" + + f" Interface: {d['interface_number']} Names: '{d['manufacturer_string']}': '{d['product_string']}'" + ) + +def hidraw(dev: str | None, *cmds: str): + from hhd.controller.lib.hid import enumerate_unique, Device + from time import sleep, perf_counter + + avail = [] + infos = {} + devs = {} + for i, d in enumerate(enumerate_unique()): + avail.append(d["path"]) + n = int(d["path"].decode().split("hidraw")[1]) + infos[n] = ( + f" - {device_str(d)}" + ) + devs[d['path']] = d + + if dev: + print(f"Using argument '{dev}'.") + try: + sel = f"/dev/hidraw{int(dev)}".encode() + except Exception: + sel = dev.encode() + if sel not in avail or not sel: + print(f"Device '{sel.decode()}' not found.") + return + else: + print("Available Devices with the Current Permissions") + print("\n".join([infos[k] for k in sorted(infos)])) + print() + + sel = None + while not sel or sel not in avail: + try: + sel = input("Enter device path (/dev/hidraw# or #): ") + except EOFError: + return + try: + sel = f"/dev/hidraw{int(sel)}".encode() + except Exception: + sel = sel.encode() + print() + + d = Device(path=sel) + + if cmds: + print(f"Device: {device_str(devs[sel])}") + print() + print(f"Writing provided commands to device:") + for cmd in cmds: + # Cleanup and get type + cmd = cmd.lower().strip() + if cmd.startswith('set:'): + cmd_type = "set" + cmd = cmd[4:] + elif cmd.startswith('get:'): + cmd_type = "get" + cmd = cmd[4:] + else: + cmd_type = "write" + cmd = cmd.replace(' ', '').replace(':', '') + + match cmd_type: + case "write": + print(f" - {cmd}") + try: + d.write(bytes.fromhex(cmd)) + except Exception as e: + print(f"Error writing command '{cmd}':\n{e}") + return + case "set": + print(f" - SET {cmd}") + try: + d.send_feature_report(bytes.fromhex(cmd)) + except Exception as e: + print(f"Error setting feature '{cmd}':\n{e}") + return + case "get": + print(f" - GET {cmd}") + try: + print(d.get_feature_report(int(cmd, 16)).hex()) + except Exception as e: + print(f"Error getting feature '{cmd}':\n{e}") + return + return + + print(f"Device Information:") + for k, v in devs[sel].items(): + print(f" - {k:>20s}: {f'0x{v:04x}' if isinstance(v, int) else v}") + + try: + from .hid_desc import print_descriptor + print('\nDevice HID Descriptor:') + print_descriptor(d.fd) + except Exception as e: + print(f"Could not get descriptor:\n{e}") + + print() + print(f"Selected device:\n{device_str(devs[sel])}\n") + + start = perf_counter() + prev = 0 + for i in range(100000000): + curr = perf_counter() - start + hz = f"{1/(curr - prev):6.1f} Hz" if prev and curr != prev else " NaN Hz" + prev = curr + print(f"{i:6d}: {curr:8.4f}s ({hz})", d.read().hex()) + sleep(0.0005) diff --git a/src/hhd/contrib/gs.py b/src/hhd/contrib/gs.py new file mode 100644 index 00000000..778bdbe0 --- /dev/null +++ b/src/hhd/contrib/gs.py @@ -0,0 +1,64 @@ +from Xlib.display import Display + +from hhd.plugins.overlay.x11 import ( + QamHandlerGamescope, + get_gamescope_displays, + get_overlay_display, + print_debug, +) + + +def gamescope_debug(args: list[str]): + if "qam" in args or "menu" in args: + force_disp = None + for arg in args: + if arg.startswith(":"): + force_disp = arg + + open_menu = "menu" in args + win = "menu" if open_menu else "QAM" + print(f"Opening Steam {win}.") + c = QamHandlerGamescope(force_disp=force_disp, compat_send=False) + success = c(open_menu) + c.close() + if not success: + import sys + + print(f"Error, could not open {win}.") + sys.exit(1) + return + + if not args or not args[0].startswith(":"): + ds = get_gamescope_displays() + print(f"Gamescope displays found: {str(ds)}") + d = get_overlay_display(ds) + if not d: + print(f"Overlay display not found, exitting...") + return + d, name = d + else: + did = args[0] + name = did + d = Display(did) + args = args[1:] + print(f"Overlay display is '{name}'") + + cmd_sent = False + if args: + for arg in args: + if "=" in arg: + from Xlib import Xatom + + atom, val = arg.split("=", 1) + + print(f"Setting {atom} to {val}") + d.screen().root.change_property( + d.get_atom(atom), Xatom.CARDINAL, 32, [int(val)] + ) + cmd_sent = True + + if cmd_sent: + d.flush() + else: + print("\nDebug Data:") + print_debug(d, args) diff --git a/src/hhd/contrib/hid_data/0000_undefined.hut b/src/hhd/contrib/hid_data/0000_undefined.hut new file mode 100644 index 00000000..cc5aff58 --- /dev/null +++ b/src/hhd/contrib/hid_data/0000_undefined.hut @@ -0,0 +1,3 @@ +(00) Undefined +00 Undefined +01-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/0001_generic_desktop.hut b/src/hhd/contrib/hid_data/0001_generic_desktop.hut new file mode 100644 index 00000000..4023c960 --- /dev/null +++ b/src/hhd/contrib/hid_data/0001_generic_desktop.hut @@ -0,0 +1,109 @@ +(01) Generic Desktop +00 Undefined +01 Pointer +02 Mouse +03 Reserved +04 Joystick +05 Game Pad +06 Keyboard +07 Keypad +08 Multi Axis +09 Reserved +0A Water Cooling Device +0B Computer Chassis Device +0C Wireless Radio Controls +0D Portable Device Control +0E System Multi-Axis Controller +0F Spatial Controller +10 Assistive Control +11-2F Reserved +30 X +31 Y +32 Z +33 Rx +34 Ry +35 Rz +36 Slider +37 Dial +38 Wheel +39 Hat switch +3A Counted Buffer +3B Byte Count +3C Motion +3D Start +3E Select +3F Reserved +40 Vx +41 Vy +42 Vz +43 Vbrx +44 Vbry +45 Vbrz +46 Vno +47 Feature +48 Resolution Multiplier +49 Qx +4A Qy +4B Qz +4C Qw +4D-7F Reserved +80 System Control +81 System Power Down +82 System Sleep +83 System Wake Up +84 System Context Menu +85 System Main Menu +86 System App Menu +87 System Help Menu +88 System Menu Exit +89 System Menu Select +8A System Menu Right +8B System Menu Left +8C System Menu Up +8D System Menu Down +8E System Cold Restart +8F System Warm Restart +90 D-Pad Up +91 D-Pad Down +92 D-Pad Right +93 D-Pad Left +94 Index Trigger +95 Palm Trigger +96 Thumbstick +97 System Function Shift +98 System Function Shift Lock +99 System Function Shift Lock Indicator +9A System Dismiss Notification +9B-9F Reserved +A0 System Dock +A1 System UnDock +A2 System Setup +A3 System Break +A4 System Debugger Break +A5 Application Break +A6 Application Debugger Break +A7 System Speaker Mute +A8 System Hibernate +A9-AF Reserved +B0 System Display Invert +B1 System Display Internal +B2 System Display External +B3 System Display Both +B4 System Display Dual +B5 System Display Toggle Internal External +B6 System Display Swap Primary Secondary +B7 System Display LCDAuto Scale +B8-BF Reserved +C0 Sensor Zone +C1 RPM +C2 Coolant Level +C3 Coolant Critical Level +C4 Coolant Pump +C5 Chassis Enclosure +C6 Wireless Radio Button +C7 Wireless Radio LED +C8 Wireless Radio Slider Switch +C9 System Display Rotation Lock Button +CA System Display Rotation Lock Slider Switch +CB Control Enable +CC-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/0002_simulation_controls.hut b/src/hhd/contrib/hid_data/0002_simulation_controls.hut new file mode 100644 index 00000000..c39091f3 --- /dev/null +++ b/src/hhd/contrib/hid_data/0002_simulation_controls.hut @@ -0,0 +1,57 @@ +(02) Simulation Controls +00 Undefined +01 Flight Simulation Device +02 Automobile Simulation Device +03 Tank Simulation Device +04 Spaceship Simulation Device +05 Submarine Simulation Device +06 Sailing Simulation Device +07 Motorcycle Simulation Device +08 Sports Simulation Device +09 Airplane Simulation Device +0A Helicopter Simulation Device +0B Magic Carpet Simulation Device +0C Bicycle +0D-1F reserved +20 Flight Control Stick +21 Flight Stick +22 Cyclic Control +23 Cyclic Trim +24 Flight Yoke +25 Track Control +26 Driving Control +27-CF reserved +B0 Aileron +B1 Aileron Trim +B2 Anti-Torque Control +B3 Auto-pilot enable +B4 Chaff Release +B5 Collective Control +B6 Dive Brake +B7 Electronic Counter Measures +B8 Elevator +B9 Elevator Trim +BA Rudder +BB Throttle +BC Flight Communication +BD Flare Release +BE Landing Gear +BF Toe Brake +C0 Trigger +C1 Weapons Arm +C2 Weapons Select +C3 Wing Flaps +C4 Accelerator +C5 Brake +C6 Clutch +C7 Shifter +C8 Steering +C9 Turret Direction +CA Barrel Elevation +CB Dive Plane +CC Ballast +CD Bicycle Crank +CE Handle Bars +CF Front Brake +D0 Rear Brake +D1-FFFF reserved diff --git a/src/hhd/contrib/hid_data/0003_vr_controls.hut b/src/hhd/contrib/hid_data/0003_vr_controls.hut new file mode 100644 index 00000000..0c9eb40f --- /dev/null +++ b/src/hhd/contrib/hid_data/0003_vr_controls.hut @@ -0,0 +1,16 @@ +(03) VR Controls +00 Unidentified +01 Belt +02 Body Suit +03 Flexor +04 Glove +05 Head Tracker +06 Head Mounted Display +07 Hand Tracker +08 Oculometer +09 Vest +0A Animatronic Device +0B-1F Reserved +20 Stereo Enable +21 Display Enable +22-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/0004_sports_controls.hut b/src/hhd/contrib/hid_data/0004_sports_controls.hut new file mode 100644 index 00000000..cbedc18d --- /dev/null +++ b/src/hhd/contrib/hid_data/0004_sports_controls.hut @@ -0,0 +1,39 @@ +(04) Sports Controls +00 Unidentified +01 Baseball Bat +02 Golf Club +03 Rowing Machine +04 Treadmill +05-2F Reserved +30 Oar +31 Slope +32 Rate +33 Stick Speed +34 Stick Face Angle +35 Stick Heel/Toe +36 Stick Follow Through +37 Stick Tempo +38 Stick Type +39 Stick Height +3A-4F Reserved +50 Putter +51 1 Iron +52 2 Iron +53 3 Iron +54 4 Iron +55 5 Iron +56 6 Iron +57 7 Iron +58 8 Iron +59 9 Iron +5A 10 Iron +5B 11 Iron +5C Sand Wedge +5D Loft Wedge +5E Power Wedge +5F 1 Wood +60 3 Wood +61 5 Wood +62 7 Wood +63 9 Wood +64-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/0005_gaming_controls.hut b/src/hhd/contrib/hid_data/0005_gaming_controls.hut new file mode 100644 index 00000000..335191de --- /dev/null +++ b/src/hhd/contrib/hid_data/0005_gaming_controls.hut @@ -0,0 +1,34 @@ +(05) Gaming Controls +00 Undefined +01 3D Game Controller +02 Pinball Device +03 Gun Device +04-1F Reserved +20 Point of View +21 Turn Right/Left +22 Pitch Forward/Backward +23 Roll Right/Left +24 Move Right/Left +25 Move Forward/Backward +26 Move Up/Down +27 Lean Right/Left +28 Lean Forward/Backward +29 Height of POV +2A Flipper +2B Secondary Flipper +2C Bump +2D New Game +2E Shoot Ball +2F Player +30 Gun Bolt +31 Gun Clip +32 Gun Selector +33 Gun Single Shot +34 Gun Burst +35 Gun Automatic +36 Gun Safety +37 Gamepad Fire/Jump +38 Reserved +39 Gamepad Trigger +3A Form-fitting gamepad +3B-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/0006_generic_device_controls.hut b/src/hhd/contrib/hid_data/0006_generic_device_controls.hut new file mode 100644 index 00000000..17dc8775 --- /dev/null +++ b/src/hhd/contrib/hid_data/0006_generic_device_controls.hut @@ -0,0 +1,29 @@ +(06) Generic Device Controls +00 Unidentified +01 Background Controls +02-1F Reserved +20 Battery Strength +21 Wireless Channel +22 Wireless ID +23 Discover Wireless Control +24 Security Code Character Entered +25 Security Code Character Erased +26 Security Code Cleared +27 Sequence ID +28 Sequence ID Reset +29 RF Signal Strength +2A Software Version +2B Protocol Version +2C Hardware Version +2D Major +2E Minor +2F Revision +30 Handedness +31 Either Hand +32 Left Hand +33 Right Hand +34 Both Hands +35-3F Reserved +40 Grip Pose Offset +41 Pointer Pose Offset +42-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/0007_keyboard.hut b/src/hhd/contrib/hid_data/0007_keyboard.hut new file mode 100644 index 00000000..89fcc7d1 --- /dev/null +++ b/src/hhd/contrib/hid_data/0007_keyboard.hut @@ -0,0 +1,176 @@ +(07) Keyboard +00 Reserved (no event indicated) +01 ErrorRollOver +02 POSTFail +03 ErrorUndefine +04 a and A +05 b and B +06 c and C +07 d and D +08 e and E +09 f and F +0A g and G +0B h and H +0C i and I +0D j and J +0E k and K +0F l and L +10 m and M +11 n and N +12 o and O +13 p and P +14 q and Q +15 r and R +16 s and S +17 t and T +18 u and U +19 v and V +1A w and W +1B x and X +1C y and Y +1D z and Z +1E 1 and ! +1F 2 and @ +20 3 and # +21 4 and $ +22 5 and % +23 6 and ^ +24 7 and & +25 8 and * +26 9 and ( +27 0 and ) +28 Return (ENTER) +29 ESCAPE +2A DELETE (Backspace) +2B Tab +2C Spacebar +2D - and (underscore) +2E = and + +2F [ and { +30 ] and } +31 \ and | +32 Non-US # and ~ +33 ; and : +34 ' and " +35 Grave Accent and Tilde +36 Keyboard, and < +37 . and > +38 / and ? +39 Caps Lock +3A F1 +3B F2 +3C F3 +3D F4 +3E F5 +3F F6 +40 F7 +41 F8 +42 F9 +43 F10 +44 F11 +45 F12 +46 PrintScreen +47 Scroll Lock +48 Pause +49 Insert +4A Home +4B PageUp +4C Delete Forward +4D End +4E PageDown +4F RightArrow +50 LeftArrow +51 DownArrow +52 UpArrow +53 Keypad Num Lock and Clear +54 Keypad / +55 Keypad * +56 Keypad - +57 Keypad + +58 Keypad ENTER +59 Keypad 1 and End +5A Keypad 2 and Down Arrow +5B Keypad 3 and PageDn +5C Keypad 4 and Left Arrow +5D Keypad 5 +5E Keypad 6 and Right Arrow +5F Keypad 7 and Home +60 Keypad 8 and Up Arrow +61 Keypad 9 and PageUp +62 Keypad 0 and Insert +63 Keypad . and Delete +64 Non-US \ and | +65 Application +66 Power +67 Keypad = +68 F13 +69 F14 +6A F15 +6B F16 +6C F17 +6D F18 +6E F19 +6F F20 +70 F21 +71 F22 +72 F23 +73 F24 +74 Execute +75 Help +76 Menu +77 Select +78 Stop +79 Again +7A Undo +7B Cut +7C Copy +7D Paste +7E Find +7F Mute +80 Volume Up +81 Volume Down +82 Locking Caps Lock +83 Locking Num Lock +84 Locking Scroll Lock +85 Keypad Comma +86 Keypad Equal Sign +87 Kanji1 +88 Kanji2 +89 Kanji3 +8A Kanji4 +8B Kanji5 +8C Kanji6 +8D Kanji7 +8E Kanji8 +8F Kanji9 +90 LANG1 +91 LANG2 +92 LANG3 +93 LANG4 +94 LANG5 +95 LANG6 +96 LANG7 +97 LANG8 +98 LANG9 +99 Alternate Erase +9A SysReq/Attention +9B Cancel +9C Clear +9D Prior +9E Return +9F Separator +A0 Out +A1 Oper +A2 Clear/Again +A3 CrSel/Props +A4 ExSel +A5-DF Reserved +E0 LeftControl +E1 LeftShift +E2 LeftAlt +E3 Left GUI +E4 RightControl +E5 RightShift +E6 RightAlt +E7 Right GUI +E8-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/0008_leds.hut b/src/hhd/contrib/hid_data/0008_leds.hut new file mode 100644 index 00000000..9f35c1be --- /dev/null +++ b/src/hhd/contrib/hid_data/0008_leds.hut @@ -0,0 +1,100 @@ +(08) LEDs +00 Undefined +01 Num Lock +02 Caps Lock +03 Scroll Lock +04 Compose +05 Kana +06 Power +07 Shift +08 Do Not Disturb +09 Mute +0A Tone Enable +0B High Cut Filter +0C Low Cut Filter +0D Equalizer Enable +0E Sound Field On +0F Surround field On +10 Repeat +11 Stereo +12 Sampling Rate Detect +13 Spinning +14 CAV +15 CLV +16 Recording Format Detect +17 Off-Hook +18 Ring +19 Message Waiting +1A Data Mode +1B Battery Operation +1C Battery OK +1D Battery Low +1E Speaker +1F Head Set +20 Hold +21 Microphone +22 Coverage +23 Night Mode +24 Send Calls +25 Call Pickup +26 Conference +27 Stand-by +28 Camera On +29 Camera Off +2A On-Line +2B Off-Line +2C Busy +2D Ready +2E Paper-Out +2F Paper-Jam +30 Remote +31 Forward +32 Reverse +33 Stop +34 Rewind +35 Fast Forward +36 Play +37 Pause +38 Record +39 Error +3A Usage Selected Indicator +3B Usage In Use Indicator +3C Usage Multi Mode Indicator +3D Indicator On +3E Indicator Flash +3F Indicator Slow Blink +40 Indicator Fast Blink +41 Indicator Off +42 Flash On Time +43 Slow Blink On Time +44 Slow Blink Off Time +45 Fast Blink On Time +46 Fast Blink Off Time +47 Usage Indicator Color +48 Indicator Red +49 Indicator Green +4A Indicator Amber +4B Generic Indicator +4C System Suspend +4D External Power Connected +4E Indicator Blue +4F Indicator Orange +50 Good Status +51 Warning Status +52 RGBLED +53 Red LEDChannel +54 Greed LEDChannel +55 Blue LEDChannel +56 LEDIntensity +57-FFFF Reserved +57-5F Reserved +60 Player Indicator +61 Player 1 +62 Player 2 +63 Player 3 +64 Player 4 +65 Player 5 +66 Player 6 +67 Player 7 +68 Player 8 +69-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/0009_button.hut b/src/hhd/contrib/hid_data/0009_button.hut new file mode 100644 index 00000000..bff3b750 --- /dev/null +++ b/src/hhd/contrib/hid_data/0009_button.hut @@ -0,0 +1,2 @@ +(09) Button +00 No Buttons Pressed diff --git a/src/hhd/contrib/hid_data/000a_ordinals.hut b/src/hhd/contrib/hid_data/000a_ordinals.hut new file mode 100644 index 00000000..de43c71b --- /dev/null +++ b/src/hhd/contrib/hid_data/000a_ordinals.hut @@ -0,0 +1,2 @@ +(0a) Ordinals +00 Unused diff --git a/src/hhd/contrib/hid_data/000b_telephony_devices.hut b/src/hhd/contrib/hid_data/000b_telephony_devices.hut new file mode 100644 index 00000000..d625693d --- /dev/null +++ b/src/hhd/contrib/hid_data/000b_telephony_devices.hut @@ -0,0 +1,112 @@ +(0b) Telephony Devices +00 Unassigned +01 Phone +02 Answering Machine +03 Message Controls +04 Handset +05 Headset +06 Telephony Key Pad +07 Programmable Button +08-1F Reserved +20 Hook Switch +21 Flash +22 Feature +23 Hold +24 Redial +25 Transfer +26 Drop +27 Park +28 Forward Calls +29 Alternate Function +2A Line OSC +2B Speaker Phone +2C Conference +2D Ring Enable +2E Ring Select +2F Phone Mute +30 Caller ID +31 Send +32-4F Reserved +50 Speed Dial +51 Store Number +52 Recall Number +53 Phone Directory +54-6F Reserved +70 Voice Mail +71 Screen Calls +72 Do Not Disturb +73 Message +74 Answer On/Off +75-8F Reserved +90 Inside Dial Tone +91 Outside Dial Tone +92 Inside Ring Tone +93 Outside Ring Tone +94 Priority Ring Tone +95 Inside Ringback +96 Priority Ringback +97 Line Busy Tone +98 Reorder Tone +99 Call Waiting Tone +9A Confirmation Tone 1 +9B Confirmation Tone 2 +9C Tones Off +9D Outside Ringback +9E Ringer +9F-AF Reserved +B0 Phone Key 0 +B1 Phone Key 1 +B2 Phone Key 2 +B3 Phone Key 3 +B4 Phone Key 4 +B5 Phone Key 5 +B6 Phone Key 6 +B7 Phone Key 7 +B8 Phone Key 8 +B9 Phone Key 9 +BA Phone Key Star +BB Phone Key Pound +BC Phone Key A +BD Phone Key B +BE Phone Key C +BF Phone Key D +C0 Phone Call History Key +C1 Phone Caller ID Key +C2 Phone Settings Key +C3-DF Reserved +F0 Host Control +F1 Host Available +F2 Host Call Active +F3 Activate Handset Audio +F4 Ring Type +F5 Re-dialable Phone Number +F6-F7 Reserved +F8 Stop Ring Tone +F9 PSTN Ring Tone +FA Host Ring Tone +FB Alert Sound Error +FC Alert Sound Confirm +FD Alert Sound Notification +FE Silent Ring +FF-107 Reserved +108 Email Message Waiting +109 oicemail Message Waiting +10A ost Hold +109-10F Reserved +110 Incoming Call History Count +111 Outgoing Call History Count +112 Incoming Call History +113 Outgoing Call History +114 Phone Locale +115-13F Reserved +140 Phone Time Second +141 Phone Time Minute +142 Phone Time Hour +143 Phone Date Day +144 Phone Date Month +145 Phone Date Year +146 Handset Nickname +147 Address Book ID +14A Call Duration +14B Dual Mode Phone +14C-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/000c_consumer_devices.hut b/src/hhd/contrib/hid_data/000c_consumer_devices.hut new file mode 100644 index 00000000..5a8f7dd0 --- /dev/null +++ b/src/hhd/contrib/hid_data/000c_consumer_devices.hut @@ -0,0 +1,464 @@ +(0c) Consumer Devices +01 Consumer Control +02 Numeric Key Pad +03 Programmable Buttons +04 Microphone +05 Headphone +06 Graphic Equalizer +07-1F Reserved +20 +10 +21 +100 +22 AM/PM +23-2F Reserved +30 Power +31 Reset +32 Sleep +33 Sleep After +34 Sleep Mode +35 Illumination +36 Function Buttons +37-3F Reserved +40 Menu +41 Menu Pick +42 Menu Up +43 Menu Down +44 Menu Left +45 Menu Right +46 Menu Escape +47 Menu Value Increase +48 Menu Value Decrease +49-5F Reserved +60 Data On Screen +61 Closed Caption +62 Closed Caption Select +63 VCR/TV +64 Broadcast Mode +65 Snapshot +66 Still +67 Picture-in-Picture Toggle +68 Picture-in-Picture Swap +69 Red Menu Button +6A Green Menu Button +6B Blue Menu Button +6C Yellow Menu Button +6D Aspect +6E 3D Mode Select +6F Display Brightness Increment +70 Display Brightness Decrement +71 Display Brightness +72 Display Backlight Toggle +73 Display Set Brightness to Minimum +74 Display Set Brightness to Maximum +75 Display Set Auto Brightness +76 Camera Access Enabled +77 Camera Access Disabled +78 Camera Access Toggle +79 Keyboard Brightness Increment +7A Keyboard Brightness Decrement +7B Keyboard Backlight Set Level +7C Keyboard Backlight OOC +7D Keyboard Backlight Set Minimum +7E Keyboard Backlight Set Maximum +7F Keyboard Backlight Auto +80 Selection +81 Assign Selection +82 Mode Step +83 Recall Last +84 Enter Channel +85 Order Movie +86 Channel +87 Media Selection +88 Media Select Computer +89 Media Select TV +8A Media Select WWW +8B Media Select DVD +8C Media Select Telephone +8D Media Select Program Guide +8E Media Select Video Phone +8F Media Select Games +90 Media Select Messages +91 Media Select CD +92 Media Select VCR +93 Media Select Tuner +94 Quit +95 Help +96 Media Select Tape +97 Media Select Cable +98 Media Select Satellite +99 Media Select Security +9A Media Select Home +9B Media Select Call +9C Channel Increment +9D Channel Decrement +9E Media Select SAP +9F-9F Reserved +A0 VCR Plus +A1 Once +A2 Daily +A3 Weekly +A4 Monthly +A5-AF Reserved +B0 Play +B1 Pause +B2 Record +B3 Fast Forward +B4 Rewind +B5 Scan Next Track +B6 Scan Previous Track +B7 Stop +B8 Eject +B9 Random Play +BA Select Disc +BB Enter Disc +BC Repeat +BD Tracking +BE Track Normal +BF Slow Tracking +C0 Frame Forward +C1 Frame Back +C2 Mark +C3 Clear Mark +C4 Repeat From Mark +C5 Return To Mark +C6 Search Mark Forward +C7 Search Mark Backwards +C8 Counter Reset +C9 Show Counter +CA Tracking Increment +CB Tracking Decrement +CC Stop/Eject +CD Play/Pause +CE Play/Skip +CF Voice Command +D0 Invoke Capture Interface +D1 Start or Stop Game Recording +D2 Historical Game Capture +D3 Capture Game Screenshot +D4 Show or Hide Recording Indicator +D5 Start or Stop Microphone Capture +D6 Start or Stop Camera Capture +D7 Start or Stop Game Broadcast +D8-DF Reserved +E0 Volume +E1 Balance +E2 Mute +E3 Bass +E4 Treble +E5 Bass Boost +E6 Surround Mode +E7 Loudness +E8 MPX +E9 Volume Up +EA Volume Down +EB-EF Reserved +F0 Speed Select +F1 Playback Speed +F2 Standard Play +F3 Long Play +F4 Extended Play +F5 Slow +F6-FF Reserved +100 Fan Enable +101 Fan Speed +102 Light Enable +103 Light Illumination Level +104 Climate Control Enable +105 Room Temperature +106 Security Enable +107 Fire Alarm +108 Police Alarm +109 Proximity +10A Motion +10B Duress Alarm +10C Holdup Alarm +10D Medical Alarm +10E-14F Reserved +150 Balance Right +151 Balance Left +152 Bass Increment +153 Bass Decrement +154 Treble Increment +155 Treble Decrement +156-15F Reserved +160 Speaker System +161 Channel Left +162 Channel Right +163 Channel Center +164 Channel Front +165 Channel Center Front +166 Channel Side +167 Channel Surround +168 Channel Low Freq Enhancement +169 Channel Top +16A Channel Unknown +16B-16F Reserved +170 Sub-channel +171 Sub-channel Increment +172 Sub-channel Decrement +173 Alternate Audio Increment +174 Alternate Audio Decrement +175-17F Reserved +180 Application Launch Buttons +181 AL Launch Button Config Tool +182 AL Programmable Button Config +183 AL Consumer Control Config +184 AL Word Processor +185 AL Text Editor +186 AL Spreadsheet +187 AL Graphics Editor +188 AL Presentation App +189 AL Database App +18A AL Email Reader +18B AL Newsreader +18C AL Voicemail +18D AL Contacts/Address Book +18E AL Calendar/Schedule +18F AL Task/Project Manager +190 AL Log/Journal/Timecard +191 AL Checkbook/Finance +192 AL Calculator +193 AL A/VCapture/Playback +194 AL Local Machine Browser +195 AL LAN/WANBrowser +196 AL Internet Browser +197 AL Remote Networking/ISPConnect +198 AL Network Conference +199 AL Network Chat +19A AL Telephony/Dialer +19B AL Logon +19C AL Logoff +19D AL Logon/Logoff +19E AL Terminal Lock/Screensaver +19F AL Control Panel +1A0 AL Command Line Processor/Run +1A1 AL Process/Task Manager +1A2 AL Select Task/Application +1A3 AL Next Task/Application +1A4 AL Previous Task/Application +1A5 AL Preempt Halt Task/Application +1A6 AL Integrated Help Center +1A7 AL Documents +1A8 AL Thesaurus +1A9 AL Dictionary +1AA AL Desktop +1AB AL Spell Check +1AC AL Grammar Check +1AD AL Wireless Status +1AE AL Keyboard Layout +1AF AL Virus Protection +1B0 AL Encryption +1B1 AL Screen Saver +1B2 AL Alarms +1B3 AL Clock +1B4 AL File Browser +1B5 AL Power Status +1B6 AL Image Browser +1B7 AL Audio Browser +1B8 AL Movie Browser +1B9 AL Digital Rights Manager +1BA AL Digital Wallet +1BB-1BB Reserved +1BC AL Instant Messaging +1BD AL OEMFeatures Tips Tuto Browser +1BE AL OEMHelp +1BF AL Online Community +1C0 AL Entertainment Content Browser +1C1 AL Online Shopping Browser +1C2 AL Smart Card Information/Help +1C3 AL Market Monitor Finance Browser +1C4 AL Customized Corp News Browser +1C5 AL Online Activity Browser +1C6 AL Research/Search Browser +1C7 AL Audio Player +# Duplicate in HUTRR75: 1C8 AL Navigation +1C8 AL Message Status +1C9 AL Contact Sync +1CA-1FF Reserved +200 Generic GUIApplication Controls +201 AC New +202 AC Open +203 AC Close +204 AC Exit +205 AC Maximize +206 AC Minimize +207 AC Save +208 AC Print +209 AC Properties +20A-219 Reserved +21A AC Undo +21B AC Copy +21C AC Cut +21D AC Paste +21E AC Select All +21F AC Find +220 AC Findand Replace +221 AC Search +222 AC Go To +223 AC Home +224 AC Back +225 AC Forward +226 AC Stop +227 AC Refresh +228 AC Previous Link +229 AC Next Link +22A AC Bookmarks +22B AC History +22C AC Subscriptions +22D AC Zoom In +22E AC Zoom Out +22F AC Zoom +230 AC Full Screen View +231 AC Normal View +232 AC View Toggle +233 AC Scroll Up +234 AC Scroll Down +235 AC Scroll +236 AC Pan Left +237 AC Pan Right +238 AC Pan +239 AC New Window +23A AC Tile Horizontally +23B AC Tile Vertically +23C AC Format +23D AC Edit +23E AC Bold +23F AC Italics +240 AC Underline +241 AC Strikethrough +242 AC Subscript +243 AC Superscript +244 AC All Caps +245 AC Rotate +246 AC Resize +247 AC Fliphorizontal +248 AC Flip Vertical +249 AC Mirror Horizontal +24A AC Mirror Vertical +24B AC Font Select +24C AC Font Color +24D AC Font Size +24E AC Justify Left +24F AC Justify Center H +250 AC Justify Right +251 AC Justify Block H +252 AC Justify Top +253 AC Justify Center V +254 AC Justify Bottom +255 AC Justify Block V +256 AC Indent Decrease +257 AC Indent Increase +258 AC Numbered List +259 AC Restart Numbering +25A AC Bulleted List +25B AC Promote +25C AC Demote +25D AC Yes +25E AC No +25F AC Cancel +260 AC Catalog +261 AC Buy/Checkout +262 AC Addto Cart +263 AC Expand +264 AC Expand All +265 AC Collapse +266 AC Collapse All +267 AC Print Preview +268 AC Paste Special +269 AC Insert Mode +26A AC Delete +26B AC Lock +26C AC Unlock +26D AC Protect +26E AC Unprotect +26F AC Attach Comment +270 AC Delete Comment +271 AC View Comment +272 AC Select Word +273 AC Select Sentence +274 AC Select Paragraph +275 AC Select Column +276 AC Select Row +277 AC Select Table +278 AC Select Object +279 AC Redo/Repeat +27A AC Sort +27B AC Sort Ascending +27C AC Sort Descending +27D AC Filter +27E AC Set Clock +27F AC View Clock +280 AC Select Time Zone +281 AC Edit Time Zones +282 AC Set Alarm +283 AC Clear Alarm +284 AC Snooze Alarm +285 AC Reset Alarm +286 AC Synchronize +287 AC Send/Receive +288 AC Send To +289 AC Reply +28A AC Reply All +28B AC Forward Msg +28C AC Send +28D AC Attach File +28E AC Upload +28F AC Download(Save Target As) +290 AC Set Borders +291 AC Insert Row +292 AC Insert Column +293 AC Insert File +294 AC Insert Picture +295 AC Insert Object +296 AC Insert Symbol +297 AC Saveand Close +298 AC Rename +299 AC Merge +29A AC Split +29B AC Disribute Horizontally +29C AC Distribute Vertically +29D AC Next Keyboard Layout Select +29E AC Navigation Guidance +29F AC Desktop Show All Windows +# Duplicate in HUTRR77: 2A0 AC Desktop Show All Applications +2A0 ACSoft Key Left +2A1 ACSoft Key Right +2A2-2AF Reserved +2B0 AC Idle Keep Alive +2B1-2BF Reserved +2C0 Extended Keyboard Attributes Collection +2C1 Keyboard Form Factor +2C2 Keyboard Key Type +2C3 Keyboard Physical Layout +2C4 Vendor-Specific Keyboard Physical Layout +2C5 Keyboard IETF Language Tag Index +2C6 Implemented Keyboard Input AssistControls +2C7 Keyboard Input Assist Previous +2C8 Keyboard Input Assist NextS +2C9 Keyboard Input Assist Previous Group +2CA Keyboard Input Assist NextGroup +2CB Keyboard Input Assist Accept +2CC Keyboard Input Assist Cancel +2CB-0x2DF Reserved +2E0-4FF Reserved +500 Contact Edited +501 Contact Added +502 Contact Record Active +503 Contact Index +504 Contact Nickname +505 Contact First Name +506 Contact Last Name +507 Contact Full Name +508 Contact Phone Number Personal +509 Contact Phone Number Business +50A Contact Phone Number Mobile +50B Contact Phone Number Pager +50C Contact Phone Number Fax +50D Contact Phone Number Other +50E Contact Email Personal +50F Contact Email Business +510 Contact Email Other +511 Contact Email Main +512 Contact Speed Dial Number +513 Contact Status Flag +514 Contact Misc. +515-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/000d_digitizers.hut b/src/hhd/contrib/hid_data/000d_digitizers.hut new file mode 100644 index 00000000..bab52ed7 --- /dev/null +++ b/src/hhd/contrib/hid_data/000d_digitizers.hut @@ -0,0 +1,113 @@ +(0d) Digitizers +00 Undefined +01 Digitizer +02 Pen +03 Light Pen +04 Touch Screen +05 Touch Pad +06 White Board +07 Coordinate Measuring Machine +08 3-D Digitizer +09 Stereo Plotter +0A Articulated Arm +0B Armature +0C Multiple Point Digitizer +0D Free Space Wand +0E Device Configuration +0F Capacitive Heat Map Digitizer +10-1F Reserved +20 Stylus +21 Puck +22 Finger +23 Device Settings +24 Character Gesture +25-2F Reserved +30 Tip Pressure +31 Barrel Pressure +32 In Range +33 Touch +34 Untouch +35 Tap +36 Quality +37 Data Valid +38 Transducer Index +39 Tablet Function Keys +3A Program Change Keys +3B Battery Strength +3C Invert +3D X Tilt +3E Y Tilt +3F Azimuth +40 Altitude +41 Twist +42 Tip Switch +43 Secondary Tip Switch +44 Barrel Switch +45 Eraser +46 Tablet Pick +47 Confidence +48 Width +49 Height +4A-50 Reserved +51 Contact Id +52 Inputmode +53 Device Index +54 Contact Count +55 Contact Max +56 Scan Time +57 Surface Switch +58 Button Switch +59 Button Type +5A Secondary Barrel Switch +5B Transducer Serial Number +5C Preferred Inking Color +5D Preferred Color is Locked +5E Preferred Line Width +5F Preferred Line Width is Locked +61 Gesture Character Quality +62 Character Gesture Data Length +63 Character Gesture Data +64 Gesture Character Encoding +65 UTF8 Character Gesture Encoding Sel +66 UTF16 Little Endian Character Gesture Encoding Sel +67 UTF16 Big Endian Character Gesture Encoding Sel +68 UTF32 Little Endian Character Gesture Encoding +69 UTF32 Big Endian Character Gesture Encoding +# Duplicate in HUTRR87: 6A Capacitive Heat Map Protocol Vendor ID +6A Gesture Character Enable +6B Capacitive Heat Map Protocol Version +6C Capacitive Heat Map Frame Data +6B-6F Reserved +70 Preferred Line Style +71 Preferred Line Style is Locked +72 Ink +73 Pencil +74 Highlighter +75 Chisel Marker +76 Brush +77 No preference +78-7F Reserved for future line styles +80 Digitizer Diagnostic +81 Digitizer Error +82 Err Normal Status +83 Err Transducers Exceeded +84 Err Full Trans Features Unavail +85 Err Charge Low +86-8F Reserved for future errors +90 Transducer Software Info. +91 Transducer Vendor ID +92 Transducer Product ID +93 Device Supported Protocols +94 Transducer Supported Protocols +95 No Protocol +96 Wacom AES Protocol +97 USI Protocol +98 Microsoft Pen Protocol +99-9F Reserved for future transducer protocols +A0 Supported Report Rates +A1 Report Rate +A2 Transducer Connected +A3 Switch Disabled +A4 Switch Unimplemented +A5 Transducer Switches +A6-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/000e_haptic_page.hut b/src/hhd/contrib/hid_data/000e_haptic_page.hut new file mode 100644 index 00000000..c5ab1157 --- /dev/null +++ b/src/hhd/contrib/hid_data/000e_haptic_page.hut @@ -0,0 +1,28 @@ +(0e) Haptic +01 Simple Haptic Controller +02-0F Reserved +10 Waveform +11 Duration +12-1F Reserved +20 Auto Trigger +21 Manual Trigger +22 Auto Trigger Associated Control +23 Intensity +24 Repeat Count +25 Retrigger Period +26 Waveform Vendor Page +27 Waveform Vendor ID +28 Waveform Cutoff Time +29-0x0FFF Reserved +1000 Reserved +1001 WAVEFORM_NONE +1002 WAVEFORM_STOP +1003 WAVEFORM_CLICK +1004 WAVEFORM_BUZZ_CONTINUOUS +1005 WAVEFORM_RUMBLE_CONTINUOUS +1006 WAVEFORM_PRESS +1007 WAVEFORM_RELEASE +1006-1FFF Reserved for standard waveforms +2000 Reserved +2001-2FFF Reserved: Vendor Waveforms +3000-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/0010_unicode.hut b/src/hhd/contrib/hid_data/0010_unicode.hut new file mode 100644 index 00000000..6541fea2 --- /dev/null +++ b/src/hhd/contrib/hid_data/0010_unicode.hut @@ -0,0 +1 @@ +(10) Unicode diff --git a/src/hhd/contrib/hid_data/0012_eye_and_head_trackers.hut b/src/hhd/contrib/hid_data/0012_eye_and_head_trackers.hut new file mode 100644 index 00000000..407838a1 --- /dev/null +++ b/src/hhd/contrib/hid_data/0012_eye_and_head_trackers.hut @@ -0,0 +1,43 @@ +(12) Eye and Head Trackers +00 Reserved +01 Eye Tracker +02 Head Tracker +03‐0F Reserved +10 Tracking Data +11 Capabilities +12 Configuration +13 Status +14 Control +15‐1F Reserved +20 Sensor Timestamp +21 Position X +22 Position Y +23 Position Z +24 Gaze Point +25 Left Eye Position +26 Right Eye Position +27 Head Position +28 Head Direction Point +29 Rotation about X axis +2A Rotation about Y axis +2B Rotation about Z axis +2C‐FF Reserved +100 Tracker Quality +101 Minimum Tracking Distance +102 Optimum Tracking Distance +103 Maximum Tracking Distance +104 Maximum Screen Plane Width +105 Maximum Screen Plane Height +106‐01FF Reserved +200 Display Manufacturer ID +201 Display Product ID +202 Display Serial Number +203 Display Manufacturer Date +204 Calibrated Screen Width +205 Calibrated Screen Height +206-02FF Reserved +300 Sampling Frequency +301 Configuration Status +302-03FF Reserved +400 Device Mode Request +401-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/0014_auxiliary_display.hut b/src/hhd/contrib/hid_data/0014_auxiliary_display.hut new file mode 100644 index 00000000..7f9dd747 --- /dev/null +++ b/src/hhd/contrib/hid_data/0014_auxiliary_display.hut @@ -0,0 +1,86 @@ +(14) Auxiliary Display +00 Undefined +01 Alphanumeric Display +02 Auxiliary Display +03-1F Reserved +20 Display Attributes Report +21 ASCII Character Set +22 Data Read Back +23 Font Read Back +24 Display Control Report +25 Clear Display +26 Display Enable +27 Screen Saver Delay +28 Screen Saver Enable +29 Vertical Scroll +2A Horizontal Scroll +2B Character Report +2C Display Data +2D Display Status +2E Stat Not Ready +2F Stat Ready +30 Err Not a loadable character +31 Err Font data cannot be read +32 Cursor Position Report +33 Row +34 Column +35 Rows +36 Columns +37 Cursor Pixel Positioning +38 Cursor Mode +39 Cursor Enable +3A Cursor Blink +3B Font Report +3C Font Data +3D Character Width +3E Character Height +3F Character Spacing Horizontal +40 Character Spacing Vertical +41 Unicode Character Set +42 Font 7-Segment +43 7-Segment Direct Map +44 Font 14-Segment +45 14-Segment Direct Map +46 Display Brightness +47 Display Contrast +48 Character Attribute +49 Attribute Readback +4A Attribute Data +4B Char Attr Enhance +4C Char Attr Underline +4D Char Attr Blink +4E-7F Reserved +80 Bitmap Size X +81 Bitmap Size Y +82 Max Blit Size +83 Bit Depth Format +84 Display Orientation +85 Palette Report +86 Palette Data Size +87 Palette Data Offset +88 Palette Data +89 Reserved +8A Blit Report +8B Blit Rectangle X1 +8C Blit Rectangle Y1 +8D Blit Rectangle X2 +8E Blit Rectangle Y2 +8F Blit Data +90 Soft Button +91 Soft Button ID +92 Soft Button Side +93 Soft Button Offset1 +94 Soft Button Offset2 +95 Soft Button Report +96-C1 Reserved +C2 Soft Keys +C3-CB Reserved +CC Display Data Extensions +DD-CE Reserved +CF Character Mapping +D0-DC Reserved +DD Unicode Equivalent +DE Reserved +DF Character Page Mapping +F0-FE Reserved +FF Request Report diff --git a/src/hhd/contrib/hid_data/0020_sensor.hut b/src/hhd/contrib/hid_data/0020_sensor.hut new file mode 100644 index 00000000..4d233b7f --- /dev/null +++ b/src/hhd/contrib/hid_data/0020_sensor.hut @@ -0,0 +1,645 @@ +(20) Sensor +00 Undefined +01 Sensor +02-0F Reserved +10 Biometric +11 Biometric Human Presence +12 Biometric Human Proximity +13 Biometric Human Touch +14 Biometric: Blood Pressure +15 Biometric: Body Temperature +16 Biometric: Heart Rate +17 Biometric: Heart Rate Variability +18 Biometric: Peripheral Oxygen Saturation +19 Biometric: Respiratory Rate +1A-1F Biometric: Reserved +20 Electrical +21 Electrical: Capacitance +22 Electrical: Current +23 Electrical: Power +24 Electrical: Inductance +25 Electrical: Resistance +26 Electrical: Voltage +27 Electrical: Potentiometer +28 Electrical: Frequency +29 Electrical: Period +2A-2F Electrical: Reserved +30 Environmental +31 Environmental: Atmospheric Pressure +32 Environmental: Humidity +33 Environmental: Temperature +34 Environmental: Wind Direction +35 Environmental: Wind Speed +36 Environmental: Air Quality +37 Environmental: Heat Index +38 Environmental: Surface Temperature +39 Environmental: Volatile Organic Compounds +3A Environmental: Object Presence +3B Environmental: Object Proximity +3C-3F Environmental: Reserved +40 Light +41 Light: Ambient Light +42 Light: Consumer Infrared +43 Light: Infrared Light +44 Light: Visible Light +45 Light: Ultraviolet Light +46-4F Light: Reserved +50 Location +51 Location: Broadcast +52 Location: Dead Reckoning +53 Location: GPS +54 Location: Lookup +55 Location: Other +56 Location: Static +57 Location: Triangulation +58-5F Location: Reserved +60 Mechanical +61 Mechanical: Boolean Switch +62 Mechanical: Boolean Switch Array +63 Mechanical: Multivalue Switch +64 Mechanical: Force +65 Mechanical: Pressure +66 Mechanical: Strain +67 Mechanical: Weight +68 Mechanical: Haptic Vibrator +69 Mechanical: Hall Effect Switch +6A-6F Mechanical: Reserved +70 Motion +71 Motion: Accelerometer 1D +72 Motion: Accelerometer 2D +73 Motion: Accelerometer 3D +74 Motion: Gyrometer 1D +75 Motion: Gyrometer 2D +76 Motion: Gyrometer 3D +77 Motion: Motion Detector +78 Motion: Speedometer +79 Motion: Accelerometer +7A Motion: Gyrometer +7B Motion: Gravity Vector +7C Motion: Linear Accelerometer +7D-7F Motion: Reserved +80 Orientation +81 Orientation: Compass 1D +82 Orientation: Compass 2D +83 Orientation: Compass 3D +84 Orientation: Inclinometer 1D +85 Orientation: Inclinometer 2D +86 Orientation: Inclinometer 3D +87 Orientation: Distance 1D +88 Orientation: Distance 2D +89 Orientation: Distance 3D +8A Orientation: Device Orientation +8B Orientation: Compass +8C Orientation: Inclinometer +8D Orientation: Distance +8E Orientation: Relative Orientation +8F Orientation: Simple Orientation +90 Scanner +91 Scanner: Barcode +92 Scanner: RFID +93 Scanner: NFC +94-9F Scanner: Reserved +A0 Time +A1 Time: Alarm Timer +A2 Time: Real Time Clock +A3-AF Time: Reserved +B0 Personal Activity +B1 Personal Activity: Activity Detection +B2 Personal Activity: Device Position +B3 Personal Activity: Pedometer +B4 Personal Activity: Step Detection +B5-BF Personal Activity: Reserved +C0 Orientation Extended +C1 Orientation Extended: Geomagnetic Orientation +C2 Orientation Extended: Magnetometer +C3-CF Orientation Extended: Reserved +D0-DF Reserved +E0 Other +E1 Other: Custom +E2 Other: Generic +E3 Other: Generic Enumerator +E4-EF Other: Reserved +F0-FF Reserved for Vendors/OEMs +0100-01FF Reserved +0200 Event +0201 Event: Sensor State +0202 Event: Sensor Event +0203-02FF Reserved +0300 Property +0301 Property: Friendly Name +0302 Property: Persistent Unique ID +0303 Property: Sensor Status +0304 Property: Minimum Report Interval +0305 Property: Sensor Manufacturer +0306 Property: Sensor Model +0307 Property: Sensor Serial Number +0308 Property: Sensor Description +0309 Property: Sensor Connection Type +030A Property: Sensor Device Path +030B Property: Hardware Revision +030C Property: Firmware Version +030D Property: Release Date +030E Property: Report Interval +030F Property: Change Sensitivity Absolute +0310 Property: Change Sensitivity Percent of Range +0311 Property: Change Sensitivity Percent Relative +0312 Property: Accuracy +0313 Property: Resolution +0314 Property: Maximum +0315 Property: Minimum +0316 Property: Reporting State +031A Property: Maximum FIFO Events +031B Property: Report Latency +031C Property: Flush FIFO Events +031D Property: Maximum Power Consumption +031E-03FF Property: Reserved +0400 Data Field: Location +0401 Data Field: Location Reserved +0402 Data Field: Altitude Antenna Sea Level +0403 Data Field: Differential Reference Station ID +0404 Data Field: Altitude Ellipsoid Error +0405 Data Field: Altitude Ellipsoid +0406 Data Field: Altitude Sea Level Error +0407 Data Field: Altitude Sea Level +0408 Data Field: Differential GPS Data Age +0409 Data Field: Error Radius +040A Data Field: Fix Quality +040B Data Field: Fix Type +040C Data Field: Geoidal Separation +040D Data Field: GPS Operation Mode +040E Data Field: GPS Selection Mode +040F Data Field: GPS Status +0410 Data Field: Position Dilution of Precision +0411 Data Field: Horizontal Dilution of Precision +0412 Data Field: Vertical Dilution of Precision +0413 Data Field: Latitude +0414 Data Field: Longitude +0415 Data Field: True Heading +0416 Data Field: Magnetic Heading +0417 Data Field: Magnetic Variation +0418 Data Field: Speed +0419 Data Field: Satellites in View +041A Data Field: Satellites in View Azimuth +041B Data Field: Satellites in View Elevation +041C Data Field: Satellites in View IDs +041D Data Field: Satellites in View PRNs +041E Data Field: Satellites in View S/N Ratios +041F Data Field: Satellites Used Count +0420 Data Field: Satellites Used PRNs +0421 Data Field: NMEA Sentence +0422 Data Field: Address Line 1 +0423 Data Field: Address Line 2 +0424 Data Field: City +0425 Data Field: State or Province +0426 Data Field: Country or Region +0427 Data Field: Postal Code +0428-0429 Data Field: Location Reserved +042A Property: Location +042B Property: Location Desired Accuracy +042C-042F Property: Location Reserved +0430 Data Field: Environmental +0431 Data Field: Atmospheric Pressure +0432 Data Field: Reserved +0433 Data Field: Relative Humidity +0434 Data Field: Temperature +0435 Data Field: Wind Direction +0436 Data Field: Wind Speed +0437 Data Field: Air Quality Index +0438 Data Field: Equivalent CO2 +0439 Data Field: Volatile Organic Compound Concentration +043A Data Field: Object Presence +043B Data Field: Object Proximity Range +043C Data Field: Object Proximity Out of Range +043D-043F Data Field: Environmental Reserved +0440 Property: Environmental +0441 Property: Reference Pressure +0442-044F Property: Environmental Reserved +0450 Data Field: Motion +0451 Data Field: Motion State +0452 Data Field: Acceleration +0453 Data Field: Acceleration Axis X +0454 Data Field: Acceleration Axis Y +0455 Data Field: Acceleration Axis Z +0456 Data Field: Angular Velocity +0457 Data Field: Angular Velocity about X Axis +0458 Data Field: Angular Velocity about Y Axis +0459 Data Field: Angular Velocity about Z Axis +045A Data Field: Angular Position +045B Data Field: Angular Position about X Axis +045C Data Field: Angular Position about Y Axis +045D Data Field: Angular Position about Z Axis +045E Data Field: Motion Speed +045F Data Field: Motion Intensity +0460-046F Data Field: Motion Reserved +0470 Data Field: Orientation +0471 Data Field: Heading +0472 Data Field: Heading X Axis +4073 Data Field: Heading Y Axis +0474 Data Field: Heading Z Axis +0475 Data Field: Heading Compensated Magnetic North +0476 Data Field: Heading Compensated True North +0477 Data Field: Heading Magnetic North +0478 Data Field: Heading True North +0479 Data Field: Distance +047A Data Field: Distance X Axis +047B Data Field: Distance Y Axis +047C Data Field: Distance Z Axis +047D Data Field: Distance Out-of-Range +047E Data Field: Tilt +047F Data Field: Tilt X Axis +0480 Data Field: Tilt Y Axis +0481 Data Field: Tilt Z Axis +0482 Data Field: Rotation Matrix +0483 Data Field: Quaternion +0484 Data Field: Magnetic Flux +0485 Data Field: Magnetic Flux X Axis +0486 Data Field: Magnetic Flux Y Axis +0487 Data Field: Magnetic Flux Z Axis +0488 Data Field: Magnetometer Accuracy +0489 Data Field: Simple Orientation Direction +048A-048F Data Field: Orientation Reserved +0490 Data Field: Mechanical +0491 Data Field: Boolean Switch State +0492 Data Field: Boolean Switch Array States +0493 Data Field: Multivalue Switch Value +0494 Data Field: Force +0495 Data Field: Absolute Pressure +0496 Data Field: Gauge Pressure +0497 Data Field: Strain +0498 Data Field: Weight +0498-049F Data Field: Mechanical Reserved +04A0 Property: Mechanical +04A1 Property: Vibration State +04A2 Property: Forward Vibration Speed +04A3 Property: Backward Vibration Speed +04A4-04AF Property: Mechanical Reserved +04B0 Data Field: Biometric +04B1 Data Field: Human Presence +04B2 Data Field: Human Proximity Range +04B3 Data Field: Human Proximity Out of Range +04B4 Data Field: Human Touch State +04B5 Data Field: Blood Pressure +04B6 Data Field: Blood Pressure Diastolic +04B7 Data Field: Blood Pressure Systolic +04B8 Data Field: Heart Rate +04B9 Data Field: Resting Heart Rate +04BA Data Field: Heartbeat Interval +04BB Data Field: Respiratory Rate +04BC Data Field: SpO2 +04BD-04CF Data Field: Biometric Reserved +04D0 Data Field: Light +04D1 Data Field: Illuminance +04D2 Data Field: Color Temperature +04D3 Data Field: Chromaticity +04D4 Data Field: Chromaticity X +04D5 Data Field: Chromaticity Y +04D6 Data Field: Consumer IR Sentence Receive +04D7 Data Field: Infrared Light +04D8 Data Field: Red Light +04D9 Data Field: Green Light +04DA Data Field: Blue Light +04DB Data Field: Ultraviolet A Light +04DC Data Field: Ultraviolet B Light +04DD Data Field: Ultraviolet Index +04DE-04DF Data Field: Light Reserved +04E0 Property: Light +04E1 Property: Consumer IR Sentence Send +04E2-04EF Property: Light Reserved +04F0 Data Field: Scanner +04F1 Data Field: RFID Tag 40 Bit +04F2 Data Field: NFC Sentence Receive +04F3-04F7 Data Field: Scanner Reserved +04F8 Property: Scanner +04F9 Property: NFC Sentence Send +04FA-04FF Property: Scanner Reserved +0500 Data Field: Electrical +0501 Data Field: Capacitance +0502 Data Field: Current +0503 Data Field: Electrical Power +0504 Data Field: Inductance +0505 Data Field: Resistance +0506 Data Field: Voltage +0507 Data Field: Frequency +0508 Data Field: Period +0509 Data Field: Percent of Range +050A-051F Data Field: Electrical Reserved +0520 Data Field: Time +0521 Data Field: Year +0522 Data Field: Month +0523 Data Field: Day +0524 Data Field: Day of Week + +0525 Data Field: Hour +0526 Data Field: Minute +0527 Data Field: Second +0528 Data Field: Millisecond +0529 Data Field: Timestamp +052A Data Field: Julian Day of Year +052B Data Field: Time Since System Boot +052C-052F Data Field: Time Reserved +0530 Property: Time +0531 Property: Time Zone Offset from UTC +0532 Property: Time Zone Name +0533 Property: Daylight Savings Time Observed +0534 Property: Time Trim Adjustment +0535 Property: Arm Alarm +0535-053F Property: Time Reserved +0540 Data Field: Custom +0541 Data Field: Custom Usage +0542 Data Field: Custom Boolean Array +0543 Data Field: Custom Value +0544 Data Field: Custom Value 1 +0545 Data Field: Custom Value 2 +0546 Data Field: Custom Value 3 +0547 Data Field: Custom Value 4 +0548 Data Field: Custom Value 5 +0549 Data Field: Custom Value 6 +054A Data Field: Custom Value 7 +054B Data Field: Custom Value 8 +054C Data Field: Custom Value 9 +054D Data Field: Custom Value 10 +054E Data Field: Custom Value 11 +054F Data Field: Custom Value 12 +0550 Data Field: Custom Value 13 +0551 Data Field: Custom Value 14 +0552 Data Field: Custom Value 15 +0553 Data Field: Custom Value 16 +0554 Data Field: Custom Value 17 +0555 Data Field: Custom Value 18 +0556 Data Field: Custom Value 19 +0557 Data Field: Custom Value 20 +0558 Data Field: Custom Value 21 +0559 Data Field: Custom Value 22 +055A Data Field: Custom Value 23 +055B Data Field: Custom Value 24 +055C Data Field: Custom Value 25 +055D Data Field: Custom Value 26 +055E Data Field: Custom Value 27 +055F Data Field: Custom Value 28 +0560 Data Field: Generic +0561 Data Field: Generic GUID or PROPERTYKEY +0562 Data Field: Generic Category GUID +0563 Data Field: Generic Type GUID +0564 Data Field: Generic Event PROPERTYKEY +0565 Data Field: Generic Property PROPERTYKEY +0566 Data Field: Generic Data Field PROPERTYKEY +0567 Data Field: Generic Event +0568 Data Field: Generic Property +0569 Data Field: Generic Data Field +056A Data Field: Enumerator Table Row Index +056B Data Field: Enumerator Table Row Count +056C Data Field: Generic GUID or PROPERTYKEY kind +056D Data Field: Generic GUID +056E Data Field: Generic PROPERTYKEY +056F Data Field: Generic Top Level Collection ID +0570 Data Field: Generic Report ID +0571 Data Field: Generic Report Item Position Index +0572 Data Field: Generic Firmware VARTYPE +0573 Data Field: Generic Unit of Measure +0574 Data Field: Generic Unit Exponent +0575 Data Field: Generic Report Size +0576 Data Field: Generic Report Count +0577-057F Data Field: Generic Reserved +0580 Property: Generic +0581 Property: Enumerator Table Row Index +0582 Property: Enumerator Table Row Count +0583-058F Property: Generic Reserved +0590 Data Field: Personal Activity +0591 Data Field: Activity Type +0592 Data Field: Activity State +0593 Data Field: Device Position +0594 Data Field: Step Count +0595 Data Field: Step Count Reset +0596 Data Field: Step Duration +0597 Data Field: Step Type +0598-059F Data Field: Personal Activity Reserved +05A0 Property: Minimum Activity Detection Interval +05A1 Property: Supported Activity Types +05A2 Property: Subscribed Activity Types +05A3 Property: Supported Step Types +05A4 Property: Subscribed Step Types +05A5 Property: Floor Height +05A6-05AF Property: Personal Activity Reserved +05B0 Data Field: Custom Type ID +05B1-05BF Data Field: Custom Reserved +05C0-07FF Reserved for future use as Sensor Types, Data Fields and Properties + +0800-0FF Reserved for use as Selection Values + +0800 Sensor State: Undefined +0801 Sensor State: Ready +0802 Sensor State: Not Available +0803 Sensor State: No Data Sel +0804 Sensor State: Initializing +0805 Sensor State: Access Denied +0806 Sensor State: Error +0810 Sensor Event: Unknown +0811 Sensor Event: State Changed +0812 Sensor Event: Property Changed +0813 Sensor Event: Data Updated +0814 Sensor Event: Poll Response +0815 Sensor Event: Change Sensitivity +0816 Sensor Event: Range Maximum Reached +0817 Sensor Event: Range Minimum Reached +0818 Sensor Event: High Threshold Cross Upward +0819 Sensor Event: High Threshold Cross Downward +081A Sensor Event: Low Threshold Cross Upward +081B Sensor Event: Low Threshold Cross Downward +081C Sensor Event: Zero Threshold Cross Upward +081D Sensor Event: Zero Threshold Cross Downward +081E Sensor Event: Period Exceeded +081F Sensor Event: Frequency Exceeded +0820 Sensor Event: Complex Trigger + +0830 Connection Type: PC Integrated +0831 Connection Type: PC Attached +0832 Connection Type: PC External + +0840 Reporting State: Report No Events +0841 Reporting State: Report All Events +0842 Reporting State: Report Threshold Events +0843 Reporting State: Wake On No Events +0844 Reporting State: Wake On All Events +0845 Reporting State: Wake On Threshold Events +0317 Property: Sampling Rate +0318 Property: Response Curve +0319 Property: Power State +0850 Power State: Undefined +0851 Power State: D0 Full Power +0852 Power State: D1 Low Power +0853 Power State: D2 Standby Power with Wakeup +0854 Power State: D3 Sleep with Wakeup +0855 Power State: D4 Power Off + +0860 Accuracy: Default +0861 Accuracy: High +0862 Accuracy: Medium +0863 Accuracy: Low + +0870 Fix Quality: No Fix +0871 Fix Quality: GPS +0872 Fix Quality: DGPS +040B Data Field: Fix Type NAry 1.10 +0880 Fix Type: No Fix +0881 Fix Type: GPS SPS Mode, Fix Valid +0882 Fix Type: DGPS SPS Mode, Fix Valid +0883 Fix Type: GPS PPS Mode, Fix Valid +0884 Fix Type: Real Time Kinematic +0885 Fix Type: Float RTK +0886 Fix Type: Estimated (dead reckoned) +0887 Fix Type: Manual Input Mode +0888 Fix Type: Simulator Mode +0890 GPS Operation Mode: Manual +0891 GPS Operation Mode: Automatic +08A0 GPS Selection Mode: Autonomous +08A1 GPS Selection Mode: DGPS +08A2 GPS Selection Mode: Estimated (dead reckoned) +08A3 GPS Selection Mode: Manual Input +08A4 GPS Selection Mode: Simulator +08A5 GPS Selection Mode: Data Not Valid +08B0 GPS Status: Data Valid +08B1 GPS Status: Data Not Valid + +08C0 Day of Week: Sunday +08C1 Day of Week: Monday +08C2 Day of Week: Tuesday +08C3 Day of Week: Wednesday +08C4 Day of Week: Thursday +08C5 Day of Week: Friday +08C6 Day of Week: Saturday + +08D0 Kind: Category +08D1 Kind: Type +08D2 Kind: Event +08D3 Kind: Property +08D4 Kind: Data Field + +08E0 Magnetometer Accuracy: Low +08E1 Magnetometer Accuracy: Medium +08E2 Magnetometer Accuracy: High + +08F0 Simple Orientation Direction: Not Rotated +08F1 Simple Orientation Direction: Rotated 90 Degrees +08F2 Simple Orientation Direction: Rotated 180 Degrees +08F3 Simple Orientation Direction: Rotated 270 Degrees +08F4 Simple Orientation Direction: Face Up +08F5 Simple Orientation Direction: Face Down + +0900 VT_NULL: Empty +0901 VT_BOOL: Boolean +0902 VT_UI1: Byte +0903 VT_I1: Character +0904 VT_UI2: Unsigned Short +0905 VT_I2: Short +0906 VT_UI4: Unsigned Long +0907 VT_I4: Long +0908 VT_UI8: Unsigned Long Long +0909 VT_I8: Long Long +090A VT_R4: Float +090B VT_R8: Double +090C VT_WSTR: Wide String +090D VT_STR: Narrow String +090E VT_CLSID: Guid +090F VT_VECTOR|VT_UI1: Opaque Structure +0910 VT_F16E0: HID 16-bit Float with Unit Exponent 0 +0911 VT_F16E1: HID 16-bit Float with Unit Exponent 1 +0912 VT_F16E2: HID 16-bit Float with Unit Exponent 2 +0913 VT_F16E3: HID 16-bit Float with Unit Exponent 3 +0914 VT_F16E4: HID 16-bit Float with Unit Exponent 4 +0915 VT_F16E5: HID 16-bit Float with Unit Exponent 5 +0916 VT_F16E6: HID 16-bit Float with Unit Exponent 6 +0917 VT_F16E7: HID 16-bit Float with Unit Exponent 7 +0918 VT_F16E8: HID 16-bit Float with Unit Exponent 8 +0919 VT_F16E9: HID 16-bit Float with Unit Exponent 9 +091A VT_F16EA: HID 16-bit Float with Unit Exponent A +091B VT_F16EB: HID 16-bit Float with Unit Exponent B +091C VT_F16EC: HID 16-bit Float with Unit Exponent C +091D VT_F16ED: HID 16-bit Float with Unit Exponent D +091E VT_F16EE: HID 16-bit Float with Unit Exponent E +091F VT_F16EF: HID 16-bit Float with Unit Exponent F +0920 VT_F32E0: HID 32-bit Float with Unit Exponent 0 +0921 VT_F32E1: HID 32-bit Float with Unit Exponent 1 +0922 VT_F32E2: HID 32-bit Float with Unit Exponent 2 +0923 VT_F32E3: HID 32-bit Float with Unit Exponent 3 +0924 VT_F32E4: HID 32-bit Float with Unit Exponent 4 +0925 VT_F32E5: HID 32-bit Float with Unit Exponent 5 +0926 VT_F32E6: HID 32-bit Float with Unit Exponent 6 +0927 VT_F32E7: HID 32-bit Float with Unit Exponent 7 +0928 VT_F32E8: HID 32-bit Float with Unit Exponent 8 +0929 VT_F32E9: HID 32-bit Float with Unit Exponent 9 +092A VT_F32EA: HID 32-bit Float with Unit Exponent A +092B VT_F32EB: HID 32-bit Float with Unit Exponent B +092C VT_F32EC: HID 32-bit Float with Unit Exponent C +092D VT_F32ED: HID 32-bit Float with Unit Exponent D +092E VT_F32EE: HID 32-bit Float with Unit Exponent E +092F VT_F32EF: HID 32-bit Float with Unit Exponent F +0930 Activity Type: Unknown +0931 Activity Type: Stationary +0932 Activity Type: Fidgeting +0933 Activity Type: Walking +0934 Activity Type: Running +0935 Activity Type: In Vehicle +0936 Activity Type: Biking +0937 Activity Type: Idle + + +0940 Unit: Not Specified +0941 Unit: Lux +0942 Unit: Degrees Kelvin +0943 Unit: Degrees Celsius +0944 Unit: Pascal +0945 Unit: Newton +0946 Unit: Meters/Second +0947 Unit: Kilogram +0948 Unit: Meter +0949 Unit: Meters/Second/Second +094A Unit: Farad +094B Unit: Ampere +094C Unit: Watt +094D Unit: Henry +094E Unit: Ohm +094F Unit: Volt +0950 Unit: Hertz +0951 Unit: Bar +0952 Unit: Degrees Anti-clockwise +0953 Unit: Degrees Clockwise +0954 Unit: Degrees +0955 Unit: Degrees/Second +0956 Unit: Degrees/Second/Second +0957 Unit: Knot +0958 Unit: Percent +0959 Unit: Second +095A Unit: Millisecond +095B Unit: G +095C Unit: Bytes +095D Unit: Milligauss +095E Unit: Bits +0960 Activity State: No State Change +0961 Activity State: Start Activity +0962 Activity State: End Activity +0970 Exponent 0: 1 +0971 Exponent 1: 10 +0972 Exponent 2: 100 +0973 Exponent 3: 1 000 +0974 Exponent 4: 10 000 +0975 Exponent 5: 100 000 +0976 Exponent 6: 1 000 000 +0977 Exponent 7: 10 000 000 +0978 Exponent 8: 0.00 000 001 +0979 Exponent 9: 0.0 000 001 +097A Exponent A: 0.000 001 +097B Exponent B: 0.00 001 +097C Exponent C: 0.0 001 +097D Exponent D: 0.001 +097E Exponent E: 0.01 +097F Exponent F: 0.1 +0980 Device Position: Unknown +0981 Device Position: Unchanged +0982 Device Position: On Desk +0983 Device Position: In Hand +0984 Device Position: Moving in Bag +0985 Device Position: Stationary in Bag + +09A0-09FF Reserved for use as Selection Values +1000-EFFF Reserved for use as ā€œData Fields with Modifiersā€ +F000-FFFF Reserved for Vendors/OEMs diff --git a/src/hhd/contrib/hid_data/0040_medical_instruments.hut b/src/hhd/contrib/hid_data/0040_medical_instruments.hut new file mode 100644 index 00000000..86da77ec --- /dev/null +++ b/src/hhd/contrib/hid_data/0040_medical_instruments.hut @@ -0,0 +1,38 @@ +(40) Medical Instruments +00 Undefined +01 Medical Ultrasound +02-1F Reserved +20 VCR/Acquisition +21 Freeze/Thaw +22 Clip Store +23 Update +24 Next +25 Save +26 Print +27 Microphone Enable +28-3F Reserved +40 Cine +41 Transmit Power +42 Volume +43 Focus +44 Depth +45-5F Reserved +60 Soft Step-Primary +61 Soft Step-Secondary +62-6F Reserved +70 Depth Gain Compensation +71-7F Reserved +80 Zoom Select +81 Zoom Adjust +82 Spectral Doppler Mode Select +83 Spectral Doppler Adjust +84 Color Doppler Mode Select +85 Color Doppler Adjust +86 Motion Mode Select +87 Motion Mode Adjust +88 2-D Mode Select +89 2-D Mode Adjust +8A-9F Reserved +A0 Soft Control Select +A1 Soft Control Adjust +A2-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/0041_braille_display.hut b/src/hhd/contrib/hid_data/0041_braille_display.hut new file mode 100644 index 00000000..bb1359fb --- /dev/null +++ b/src/hhd/contrib/hid_data/0041_braille_display.hut @@ -0,0 +1,46 @@ +(41) Braille Display +00 Undefined +01 Braille Display +02 Braille Row +03 8 Dot Braille Cell +04 6 Dot Braille Cell +05 Number of Braille Cells +06 Screen Reader Control +07 Screen Reader Identifier +08‐F9 Reserved +FA Router Set 1 +FB Router Set 2 +FC Router Set 3 +100 Router Button +200 Braille Buttons +201 Braille Keyboard Dot 1 +202 Braille Keyboard Dot 2 +203 Braille Keyboard Dot 3 +204 Braille Keyboard Dot 4 +205 Braille Keyboard Dot 5 +206 Braille Keyboard Dot 6 +207 Braille Keyboard Dot 7 +208 Braille Keyboard Dot 8 +209 Braille Keyboard Space +20A Braille Keyboard Left Space +20B Braille Keyboard Right Space +20C Braille Face Controls +20D Braille Left Controls +20E Braille Right Controls +20F Braille Top Controls +210 Braille Joystick Center +211 Braille Joystick Up +212 Braille Joystick Down +213 Braille Joystick Left +224 Braille Joystick Right +225 Braille D ‐ Pad Center +226 Braille D ‐ Pad Up +217 Braille D ‐ Pad Down +218 Braille D ‐ Pad Left +219 Braille D ‐ Pad Right +21A Braille Pan Left +21B Braille Pan Right +21C Braille Rocker Up +21D Braille Rocker Down +21E Braille Rocker Press +21F‐2FF Reserved diff --git a/src/hhd/contrib/hid_data/0059_lighting_and_illumination.hut b/src/hhd/contrib/hid_data/0059_lighting_and_illumination.hut new file mode 100644 index 00000000..17f99cba --- /dev/null +++ b/src/hhd/contrib/hid_data/0059_lighting_and_illumination.hut @@ -0,0 +1,40 @@ +(59) Lighting and Illumination +00 Undefined +01 Lamp Array +02 Lamp Array Attributes Report +03 Lamp Count +04 Bounding Box Width In Micrometers +05 Bounding Box Height In Micrometers +06 Bounding Box Depth In Micrometers +07 Lamp Array Kind +08 Min Update Interval In Microseconds +09-1F Reserved +20 Lamp Attributes Request Report +21 Lamp Id +22 Lamp Attributes Response Report +23 Position X In Micrometers +24 Position Y In Micrometers +25 Position Z In Micrometers +26 Lamp Purposes +27 Update Latency In Microseconds +28 Red Level Count +29 Green Level Count +2A Blue Level Count +2B Intensity Level Count +2C Is Programmable +2D Input Binding +2E-4F Reserved +50 Lamp Multi Update Report +51 Red Update Channel +52 Green Update Channel +53 Blue Update Channel +54 Intensity Update Channel +55 Lamp Update Flags +56-5F Reserved +60 Lamp Range Update Report +61 Lamp Id Start +62 Lamp Id End +63-6F Reserved +70 Lamp Array Control Report +71 Autonomous Mode +72-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/0080_monitor.hut b/src/hhd/contrib/hid_data/0080_monitor.hut new file mode 100644 index 00000000..5cab9b65 --- /dev/null +++ b/src/hhd/contrib/hid_data/0080_monitor.hut @@ -0,0 +1,15 @@ +(80) Monitor +00 Undefined +01 Monitor Control +02 EDID Information +03 VDIF Information +04 VESA Version +05 On Screen Display +06 Auto Size Center +07 Polarity Horz Synch +08 Polarity Vert Synch +09 Sync Type +0A Screen Position +0B Horizontal Frequency +0C Vertical Frequency +0D-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/0081_monitor_enumerated_values.hut b/src/hhd/contrib/hid_data/0081_monitor_enumerated_values.hut new file mode 100644 index 00000000..0ce0c70c --- /dev/null +++ b/src/hhd/contrib/hid_data/0081_monitor_enumerated_values.hut @@ -0,0 +1,2 @@ +(81) Monitor Enumerated Values +00 unassigned diff --git a/src/hhd/contrib/hid_data/0082_vesa_virtual_controls.hut b/src/hhd/contrib/hid_data/0082_vesa_virtual_controls.hut new file mode 100644 index 00000000..1be54ab2 --- /dev/null +++ b/src/hhd/contrib/hid_data/0082_vesa_virtual_controls.hut @@ -0,0 +1,38 @@ +(82) VESA Virtual Controls +00-0F Reserved +10 Brightness +12 Contrast +16 Video Gain Red +18 Video Gain Green +1A Video Gain Blue +1C Focus +20 Horizontal Position +22 Horizontal Size +24 Horizontal Pincushion +26 Horizontal Pincushion Balance +28 Horizontal Misconvergence +2A Horizontal Linearity +2C Horizontal Linearity Balance +30 Vertical Position +32 Vertical Size +34 Vertical Pincushion +36 Vertical Pincushion Balance +38 Vertical Misconvergence +3A Vertical Linearity +3C Vertical Linearity Balance +40 Parallelogram Distortion +42 Trapezoidal Distortion +44 Tilt +46 Top Corner Distortion Control +48 Top Corner Distortion Balance +4A Bottom Corner Distortion Control +4C Bottom Corner Distortion Balance +56 MoirĆ© Horizontal +58 MoirĆ© Vertical +5E Input Level Select +60 Input Source Select +62 Stereo Mode +6C Video Black Level Red +6E Video Black Level Green +70 Video Black Level Blue +71-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/0083_vesa_command.hut b/src/hhd/contrib/hid_data/0083_vesa_command.hut new file mode 100644 index 00000000..6a331116 --- /dev/null +++ b/src/hhd/contrib/hid_data/0083_vesa_command.hut @@ -0,0 +1,5 @@ +(83) VESA Command +00 Undefined +01 Settings +02 Degauss +03-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/0084_power_device.hut b/src/hhd/contrib/hid_data/0084_power_device.hut new file mode 100644 index 00000000..55f90826 --- /dev/null +++ b/src/hhd/contrib/hid_data/0084_power_device.hut @@ -0,0 +1,3 @@ +(84) Power Device +06 Peripheral Device +07-0F Reserved diff --git a/src/hhd/contrib/hid_data/0085_battery_system.hut b/src/hhd/contrib/hid_data/0085_battery_system.hut new file mode 100644 index 00000000..9f2f17b4 --- /dev/null +++ b/src/hhd/contrib/hid_data/0085_battery_system.hut @@ -0,0 +1 @@ +(85) Battery System diff --git a/src/hhd/contrib/hid_data/008c_bar_code_scanner.hut b/src/hhd/contrib/hid_data/008c_bar_code_scanner.hut new file mode 100644 index 00000000..dc15598d --- /dev/null +++ b/src/hhd/contrib/hid_data/008c_bar_code_scanner.hut @@ -0,0 +1,3 @@ +(8c) Bar Code Scanner +00 Undefined +01-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/008d_scale.hut b/src/hhd/contrib/hid_data/008d_scale.hut new file mode 100644 index 00000000..12d7d7e3 --- /dev/null +++ b/src/hhd/contrib/hid_data/008d_scale.hut @@ -0,0 +1,3 @@ +(8d) Scale +00 Undefined +01-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/008e_magnetic_stripe_reading.hut b/src/hhd/contrib/hid_data/008e_magnetic_stripe_reading.hut new file mode 100644 index 00000000..3ac8de8d --- /dev/null +++ b/src/hhd/contrib/hid_data/008e_magnetic_stripe_reading.hut @@ -0,0 +1,3 @@ +(8e) Magnetic Stripe Reading +00 Undefined +01-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/0090_camera_control.hut b/src/hhd/contrib/hid_data/0090_camera_control.hut new file mode 100644 index 00000000..73401209 --- /dev/null +++ b/src/hhd/contrib/hid_data/0090_camera_control.hut @@ -0,0 +1,5 @@ +(90) Camera Control +00 Undefined +01-1F Reserved +20 Camera Auto-focus +21 Camera Shutter diff --git a/src/hhd/contrib/hid_data/0091_arcade_page_oaaf.hut b/src/hhd/contrib/hid_data/0091_arcade_page_oaaf.hut new file mode 100644 index 00000000..e18508be --- /dev/null +++ b/src/hhd/contrib/hid_data/0091_arcade_page_oaaf.hut @@ -0,0 +1,3 @@ +(91) Arcade Page OAAF +00 Undefined +01-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/0092_gaming_device.hut b/src/hhd/contrib/hid_data/0092_gaming_device.hut new file mode 100644 index 00000000..f98e7abc --- /dev/null +++ b/src/hhd/contrib/hid_data/0092_gaming_device.hut @@ -0,0 +1 @@ +(92) Gaming Device diff --git a/src/hhd/contrib/hid_data/f1d0_fast_identity_online_alliance.hut b/src/hhd/contrib/hid_data/f1d0_fast_identity_online_alliance.hut new file mode 100644 index 00000000..8c5c701f --- /dev/null +++ b/src/hhd/contrib/hid_data/f1d0_fast_identity_online_alliance.hut @@ -0,0 +1,7 @@ +(F1D0) FIDO Alliance +00 Undefined +01 U2F Authenticator Device +02-1F Reserved +20 Input Report Data +21 Output Report Data +22-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/ff00_vendor_defined_page_1.hut b/src/hhd/contrib/hid_data/ff00_vendor_defined_page_1.hut new file mode 100644 index 00000000..236d7b79 --- /dev/null +++ b/src/hhd/contrib/hid_data/ff00_vendor_defined_page_1.hut @@ -0,0 +1,5 @@ +(ff00) Vendor Defined Page 1 +00 Undefined +01 Vendor Usage 1 +02 Vendor Usage 2 +03-FFFF Reserved diff --git a/src/hhd/contrib/hid_data/ff0d_wacom.hut b/src/hhd/contrib/hid_data/ff0d_wacom.hut new file mode 100644 index 00000000..c83b285c --- /dev/null +++ b/src/hhd/contrib/hid_data/ff0d_wacom.hut @@ -0,0 +1,112 @@ +(ff0d) Wacom +01 Wacom Digitizer +02 Wacom Pen +03 Light Pen +04 Touch Screen +05 Touch Pad +06 White Board +07 Coordinate Measuring Machine +08 3-D Digitizer +09 Stereo Plotter +0A Articulated Arm +0B Armature +0C Multiple Point Digitizer +0D Free Space Wand +0E Device Configuration +0F-1F Reserved +20 Stylus +21 Puck +22 Finger +23 Device Settings +24-2F Reserved +30 Tip Pressure +31 Barrel Pressure +32 In Range +33 Touch +34 Untouch +35 Tap +36 Wacom Sense +37 Data Valid +38 Transducer Index +39 Wacom DigitizerFnKeys +3A Program Change Keys +3B Battery Strength +3C Invert +3D X Tilt +3E Y Tilt +3F Azimuth +40 Altitude +41 Twist +42 Tip Switch +43 Secondary Tip Switch +44 Barrel Switch +45 Eraser +46 Tablet Pick +47 Confidence +48 Width +49 Height +4A-50 Reserved +51 Contact Id +52 Inputmode +53 Device Index +54 Contact Count +55 Contact Max +56 Scan Time +57 Surface Switch +58 Button Switch +59 Button Type +5A Secondary Barrel Switch +5B Transducer Serial Number +5C Wacom SerialHi +5D Preferred Color is Locked +5E Preferred Line Width +5F Preferred Line Width is Locked +70 Preferred Line Style +71 Preferred Line Style is Locked +72 Ink +73 Pencil +74 Highlighter +75 Chisel Marker +76 Brush +77 Wacom ToolType +78-7F Reserved for future line styles +80 Digitizer Diagnostic +81 Digitizer Error +82 Err Normal Status +83 Err Transducers Exceeded +84 Err Full Trans Features Unavail +85 Err Charge Low +86-8F Reserved for future errors +0130 X +0131 Y +0132 Wacom Distance +0136 Wacom TouchStrip +0137 Wacom TouchStrip2 +0138 Wacom TouchRing +0139 Wacom TouchRingStatus +0401 Wacom Accelerometer X +0402 Wacom Accelerometer Y +0403 Wacom Accelerometer Z +0404 Wacom Battery Charging +0454 Wacom TouchOnOff +043B Wacom Battery Level +0910 Wacom ExpressKey00 +0950 Wacom ExpressKeyCap00 +0980 Wacom Mode Change +0981 Wacom Button Desktop Center +0982 Wacom Button On Screen Keyboard +0983 Wacom Button Display Setting +0986 Wacom Button Touch On/Off +0990 Wacom Button Home +0991 Wacom Button Up +0992 Wacom Button Down +0993 Wacom Button Left +0994 Wacom Button Right +0995 Wacom Button Center +0D03 Wacom FingerWheel +0D30 Wacom Offset Left +0D31 Wacom Offset Top +0D32 Wacom Offset Right +0D33 Wacom Offset Bottom +1002 Wacom DataMode +1013 Wacom Digitizer Info diff --git a/src/hhd/contrib/hid_desc.py b/src/hhd/contrib/hid_desc.py new file mode 100644 index 00000000..0e14fa0d --- /dev/null +++ b/src/hhd/contrib/hid_desc.py @@ -0,0 +1,2473 @@ +#!/bin/env python3 +# -*- coding: utf-8 -*- +# +# Copyright (c) 2024 Antheas Kapenekakis +# Copyright (c) 2012-2017 Benjamin Tissoires +# Copyright (c) 2012-2017 Red Hat, Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + + +import copy +import enum +import functools +import itertools +import logging +import re +import os +import sys +from collections.abc import ItemsView, Iterable +from typing import ( + IO, + Annotated, + Any, + Dict, + Final, + Hashable, + Iterator, + List, + NamedTuple, + Optional, + Tuple, + Type, + TypeAlias, + Union, + cast, +) + +_Type = Type + + +class ValueRange(NamedTuple): + min: int + max: int + + +DATA_DIRNAME = "hid_data" +SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__)) +DATA_DIR = os.path.join(SCRIPT_DIR, DATA_DIRNAME) + +U8 = Annotated[int, ValueRange(0, 0xFF)] +U16 = Annotated[int, ValueRange(0, 0xFFFF)] +U32 = Annotated[int, ValueRange(0, 0xFFFFFFFF)] + +dict_items_usage: TypeAlias = ItemsView[U16, "HidUsage"] +dict_items_usagePage: TypeAlias = ItemsView[U16, "HidUsagePage"] + + +@functools.total_ordering +class HidUsage(Hashable): + """ + A HID Usage entry as defined in the HID Usage Tablets. :: + + > usage_page = hidtools.hut.HUT[0x01] # Generic Desktop + > usage = usage_page[0x02] + > print(usage.usage) + 2 + > print(usage) + Mouse + > print(usage.name) + Mouse + + :param HidUsagePage usage_page: the Usage Page this Usage belongs to + :param int usage: the 16-bit Usage assigned by the HID Usage Tables + :param str name: the usage_name + + .. attribute:: usage + + the 16-bit Usage assigned by the HId Usage Tables + + .. attribute:: name + + the semantic name for this Usage + + .. attribute:: usage_page + + the :class:`HidUsagePage` this Usage belongs to + + """ + + def __init__( + self: "HidUsage", usage_page: "HidUsagePage", usage: U16, name: str + ) -> None: + self.usage_page = usage_page + self.usage = usage + self.name = name + + # Route everything down to the name, this way we basically behave like a + # string + def __getattr__(self: "HidUsage", attr: str) -> Any: + return getattr(self.name, attr) + + def __repr__(self: "HidUsage") -> str: + return self.name + + def __hash__(self: "HidUsage") -> int: + return hash(self.name) + + def __str__(self: "HidUsage") -> str: + return self.name + + def __eq__(self: "HidUsage", other: object) -> bool: + if isinstance(other, HidUsage): + return self.name == other.name + elif not isinstance(other, str): + return NotImplemented + return self.name == other + + def __lt__(self: "HidUsage", other: object) -> bool: + if isinstance(other, HidUsage): + return self.name < other.name + elif not isinstance(other, str): + return NotImplemented + return self.name < other + + +class HidUsagePage(object): + """ + A dictionary of HID Usages in the form ``{usage: usage_name}``, + representing all Usages in this Usage Page. + + A HID Usage is named semantical identifier that describe how a given + field in a HID report is to be used. A Usage Page is a logical grouping + of those identifiers, e.g. "Generic Desktop", "Telephony Devices", or + "Digitizers". :: + + > print(usage_page.page_name) + Generic Desktop + > print(usage_page.page_id) + 1 + > print(usage_page[0x02]) + Mouse + > print(usage_page['Mouse']) + Mouse + > usage = usage_page.from_name["Mouse"] + > print(usage.usage) + 2 + > print(usage.name) + Mouse + > print(usage) + Mouse + + .. attribute:: page_id + + The Page ID for this Usage Page, e.g. ``01`` (Generic Desktop) + + .. attribute:: page_name + + The assigned name for this usage Page, e.g. "Generic Desktop" + """ + + def __init__(self: "HidUsagePage") -> None: + self._usages: Dict[U16, HidUsage] = {} + + def __setitem__(self: "HidUsagePage", key: U16, value: HidUsage) -> None: + self._usages[key] = value + + def __getitem__(self: "HidUsagePage", key: Union[str, U16, U32]) -> HidUsage: + if isinstance(key, str): + return self.from_name[key] + + # extract the usage if we have a 32-bit usage and the page ID + # matches + if key > 0xFFFF and key & 0xFFFF0000 == self.page_id << 16: + key &= 0xFFFF + return self._usages[key] + + def __delitem__(self: "HidUsagePage", key: U16) -> None: + del self._usages[key] + + def __iter__(self: "HidUsagePage") -> Iterator[U16]: + return iter(self._usages) + + def __len__(self: "HidUsagePage") -> int: + return len(self._usages) + + def __str__(self: "HidUsagePage") -> str: + return self.page_name + + def __repr__(self: "HidUsagePage") -> str: + return self.page_name + + def items(self: "HidUsagePage") -> dict_items_usage: + """ + Iterate over all elements, see :meth:`dict.items` + """ + return self._usages.items() + + @property + def page_id(self: "HidUsagePage") -> U16: + """ + The numerical page ID for this usage page + """ + return self._page_id + + @page_id.setter + def page_id(self: "HidUsagePage", page_id: U16) -> None: + self._page_id = page_id + + @property + def page_name(self: "HidUsagePage") -> str: + """ + The assigned name for this Usage Page + """ + return self._name + + @page_name.setter + def page_name(self: "HidUsagePage", name: str) -> None: + self._name = name + + @property + def from_name(self: "HidUsagePage") -> Dict[str, HidUsage]: + """ + A dictionary using ``{ name: usage }`` mapping, to look up the + :class:`HidUsage` based on a name. + """ + try: + return self._inverted + except AttributeError: + self._inverted: Dict[str, HidUsage] = {} + for _, v in self.items(): + self._inverted[v.name] = v + return self._inverted + + @property + def from_usage(self: "HidUsagePage") -> Dict[U16, HidUsage]: + """ + A dictionary using ``{ usage: name }`` mapping, to look up the name + based on a page ID . This is the same as using the object itself. + """ + return cast(Dict[U16, HidUsage], self) + + +class HidUsageTable(object): + """ + This effectively a dictionary of all HID Usages known to man. Or to this + module at least. This object is a singleton, it is available as + ``hidtools.hut.HUT``. + + Elements of this dictionary are :class:`HidUsagePage` objects. + + This object is a dictionary, use like this: :: + + > hut = hidtools.hut.HUT + > print(hut[0x01].page_name) + Generic Desktop + > print(hut['Generic Desktop'].page_name) + Generic Desktop + > print(hut.usage_pages[0x01].page_name) + Generic Desktop + > print(hut.usage_page_names['Generic Desktop'].page_name) + Generic Desktop + > print(hut[0x01].page_id) + 1 + > print(hut.usage_page_from_name('Generic Desktop').page_id) + 1 + > print(hut.usage_page_from_page_id(0x01).page_name) + Generic Desktop + """ + + def __init__(self: "HidUsageTable") -> None: + self._pages: Dict[U16, HidUsagePage] = {} + + def __setitem__(self: "HidUsageTable", key: U16, value: HidUsagePage) -> None: + self._pages[key] = value + + def __getitem__(self: "HidUsageTable", key: Union[str, U16]) -> HidUsagePage: + if isinstance(key, str): + return self.usage_page_names[key] + + # shift the usage page bits down if we have a 32-bit usage + if key & 0xFFFF0000 == key: + key >>= 16 + return self._pages[key] + + def __delitem__(self: "HidUsageTable", key) -> None: + del self._pages[key] + + def __iter__(self: "HidUsageTable") -> Iterator[HidUsagePage]: + return iter(self._pages) + + def __len__(self: "HidUsageTable") -> int: + return len(self._pages) + + def items(self: "HidUsageTable") -> dict_items_usagePage: + """ + Iterate over all elements, see :meth:`dict.items` + """ + return self._pages.items() + + @property + def usage_pages(self: "HidUsageTable") -> Dict[U16, HidUsagePage]: + """ + A dictionary mapping ``{page_id : object}``. These two are + equivalent calls: :: + + HUT[0x1] + HUT.usage_pages[0x1] + + """ + return self._pages + + @property + def usage_page_names(self: "HidUsageTable") -> Dict[str, HidUsagePage]: + """ + A dictionary mapping ``{page_name : object}``. These two are + equivalent calls: :: + + HUT['Generic Desktop'] + HUT.usage_page_names['Generic Desktop'] + + """ + return {v.page_name: v for _, v in self.items()} + + def usage_page_from_name( + self: "HidUsageTable", page_name: str + ) -> Optional[HidUsagePage]: + """ + Look up the usage page based on the page name (e.g. "Generic + Desktop"). This is identical to :: + + self.usage_page_names[page_name] + + except that this function returns ``None`` if the page name is + unknown. + + :return: the :meth:`HidUsagePage` or None + """ + try: + return self[page_name] + except KeyError: + return None + + def usage_page_from_page_id( + self: "HidUsageTable", page_id: U16 + ) -> Optional[HidUsagePage]: + """ + Look up the usage page based on the page ID. This is identical to :: + + self.usage_pages[page_id] + + except that this function returns ``None`` if the page ID is unknown. + + :return: the :meth:`HidUsagePage` or None + """ + try: + return self[page_id] + except KeyError: + return None + + @classmethod + def _parse_usages(cls: Type["HidUsageTable"], f: Iterable[str]) -> HidUsagePage: + """ + Parse a single HUT file. The file format is a set of lines in three + formats: :: + + (01)Usage Page name + A0Name + F0-FFReserved for somerange + + All numbers in hex. + + Only one Usage Page per file + + Usages are parsed into a dictionary[number] = name. + + The return value is a single HidUsagePage where page[idx] = idx-name. + """ + usage_page = None + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + + # Usage Page, e.g. '(01) Generic Desktop' + if line.startswith("("): + assert usage_page is None + + r = re.match(r"\((?P[0-9a-fA-F]+)\)\t(?P.+)", line) + assert r is not None + usage_page = HidUsagePage() + usage_page.page_id = r["idx"] # type: ignore + usage_page.page_name = r["page_name"] + continue + + assert usage_page is not None + + # Reserved ranges, e.g '0B-1F Reserved' + # "{:x}-{:x}\t{name}" + r = re.match( + r"(?P[0-9a-fA-FxX]+)-(?P[0-9a-fA-FxX]+)\S+(?P.+)", + line, + ) + if r: + if "reserved" not in r["name"].lower(): + print(line) + continue + + # Single usage, e.g. 36 Slider + r = re.match(r"(?P[0-9a-fA-FxX]+)\S+(?P.+)", line) + assert r is not None, f'"{line}"' + if "reserved" in r["name"].lower(): + continue + + u = int(r["usage"], 16) + usage = HidUsage(usage_page, u, r["name"]) + + usage_page[u] = usage + + if usage_page is None: + raise Exception + + return usage_page + + @classmethod + def _from_hut_data(cls: Type["HidUsageTable"]) -> "HidUsageTable": + """ + Return the HID Usage Tables, the keys are the numeric Usage Page and + the values are the respective :class:`hidtools.HidUsagePage` object. + + :: + + > usages = hidtools.hut.HUT() + > print(usages[0x01].page_name) + Generic Desktop + > print(usages.usage_pages[0x01].page_name) + Generic Desktop + > print(usages[0x01].page_id) + 1 + + :return: a :class:`hidtools.HidUsageTable` object + """ + hut = HidUsageTable() + for filename in os.listdir(DATA_DIR): + if filename.endswith(".hut"): + with open(os.path.join(DATA_DIR, filename), "r", encoding="utf-8") as f: + try: + usage_page = cls._parse_usages(f) + hut[usage_page.page_id] = usage_page + except: + print(filename) + raise + + return hut + + +HUT = HidUsageTable._from_hut_data() +""" +The HID Usage Tables as a :class:`hidtools.HidUsageTable` object, +a dictionary where the keys are the numeric Usage Page and the values are +the respective :class:`hidtools.HidUsagePage` object. :: + + > usages = hidtools.hut.HUT() + > print(usages[0x01].page_name) + Generic Desktop + > print(usages.usage_pages[0x01].page_name) + Generic Desktop + > print(usages[0x01].page_id) + 1 +""" + + +class BusType(enum.IntEnum): + """ + The numerical bus type (``0x3`` for USB, ``0x5`` for Bluetooth, see + ``linux/input.h``) + """ + + PCI = 0x01 + ISAPNP = 0x02 + USB = 0x03 + HIL = 0x04 + BLUETOOTH = 0x05 + VIRTUAL = 0x06 + ISA = 0x10 + I8042 = 0x11 + XTKBD = 0x12 + RS232 = 0x13 + GAMEPORT = 0x14 + PARPORT = 0x15 + AMIGA = 0x16 + ADB = 0x17 + I2C = 0x18 + HOST = 0x19 + GSC = 0x1A + ATARI = 0x1B + SPI = 0x1C + RMI = 0x1D + CEC = 0x1E + INTEL_ISHTP = 0x1F + AMD_SFH = 0x20 + + +def twos_comp(val, bits): + """compute the 2's complement of val. + + :param int val: + the value to compute the two's complement for + + :param int bits: + size of val in bits + """ + if bits and (val & (1 << (bits - 1))) != 0: + val = val - (1 << bits) + return val + + +def to_twos_comp(val, bits): + return val & ((1 << bits) - 1) + + +logger = logging.getLogger("hidtools.hid") + +# mypy is confused by the various .bytes properties, so redefine the bytes type +Bytes: TypeAlias = bytes + +hid_items: Final = { + "Main": { + "Input": 0b10000000, # noqa: E203 + "Output": 0b10010000, # noqa: E203 + "Feature": 0b10110000, # noqa: E203 + "Collection": 0b10100000, # noqa: E203 + "End Collection": 0b11000000, # noqa: E203 + }, + "Global": { + "Usage Page": 0b00000100, # noqa: E203 + "Logical Minimum": 0b00010100, # noqa: E203 + "Logical Maximum": 0b00100100, # noqa: E203 + "Physical Minimum": 0b00110100, # noqa: E203 + "Physical Maximum": 0b01000100, # noqa: E203 + "Unit Exponent": 0b01010100, # noqa: E203 + "Unit": 0b01100100, # noqa: E203 + "Report Size": 0b01110100, # noqa: E203 + "Report ID": 0b10000100, # noqa: E203 + "Report Count": 0b10010100, # noqa: E203 + "Push": 0b10100100, # noqa: E203 + "Pop": 0b10110100, # noqa: E203 + }, # noqa: E203 + "Local": { + "Usage": 0b00001000, # noqa: E203 + "Usage Minimum": 0b00011000, # noqa: E203 + "Usage Maximum": 0b00101000, # noqa: E203 + "Designator Index": 0b00111000, # noqa: E203 + "Designator Minimum": 0b01001000, # noqa: E203 + "Designator Maximum": 0b01011000, # noqa: E203 + "String Index": 0b01111000, # noqa: E203 + "String Minimum": 0b10001000, # noqa: E203 + "String Maximum": 0b10011000, # noqa: E203 + "Delimiter": 0b10101000, # noqa: E203 + }, +} + +superscripts: Final = { + "0": "⁰", + "1": "¹", + "2": "²", + "3": "³", + "4": "⁓", + "5": "⁵", + "6": "⁶", + "7": "⁷", + "8": "⁸", + "9": "⁹", + "-": "⁻", +} + + +class HidUnit(object): + """ + A parsed field of a HID Report Descriptor Unit specification. + + .. attribute:: units + + A dict of { unit: exponent } of the applicable units. + Where the Unit is ``None``, the return value is ``None``. + + .. attribute:: system + + The system the units belong to, one of :class:`HidUnit.System`. + + """ + + NONE: Final = cast("HidUnit", None) # For Unit(None), makes the code more obvious + + class System(enum.IntEnum): + NONE = 0 + SI_LINEAR = 1 + SI_ROTATION = 2 + ENGLISH_LINEAR = 3 + ENGLISH_ROTATION = 4 + + @classmethod + def _stringmap(cls: _Type["HidUnit.System"]) -> Dict["HidUnit.System", str]: + return { + HidUnit.System.NONE: "None", + HidUnit.System.SI_LINEAR: "SILinear", + HidUnit.System.SI_ROTATION: "SIRotation", + HidUnit.System.ENGLISH_LINEAR: "EnglishLinear", + HidUnit.System.ENGLISH_ROTATION: "EnglishRotation", + } + + def __str__(self: "HidUnit.System") -> str: + return self._stringmap()[self] + + @classmethod + def from_string( + cls: _Type["HidUnit.System"], string: str + ) -> Optional["HidUnit.System"]: + """ + Returns the correct :class:`HidUnit.System` given the string. + """ + try: + return {v: k for k, v in cls._stringmap().items()}[string] + except KeyError: + return None + + @property + def length(self: "HidUnit.System") -> Optional["Unit"]: + """ + Returns the right :class:`Unit` for the length measurement in + this system. + """ + return { + HidUnit.System.NONE: None, + HidUnit.System.SI_LINEAR: Unit.CENTIMETER, + HidUnit.System.SI_ROTATION: Unit.RADIANS, + HidUnit.System.ENGLISH_LINEAR: Unit.INCH, + HidUnit.System.ENGLISH_ROTATION: Unit.DEGREES, + }[self] + + @property + def mass(self: "HidUnit.System") -> Optional["Unit"]: + """ + Returns the right :class:`Unit` for the mass measurement in + this system. + """ + return { + HidUnit.System.NONE: None, + HidUnit.System.SI_LINEAR: Unit.GRAM, + HidUnit.System.SI_ROTATION: Unit.GRAM, + HidUnit.System.ENGLISH_LINEAR: Unit.SLUG, + HidUnit.System.ENGLISH_ROTATION: Unit.SLUG, + }[self] + + @property + def time(self: "HidUnit.System") -> Optional["Unit"]: + """ + Returns the right :class:`Unit` for the time measurement in + this system. + """ + return { + HidUnit.System.NONE: None, + HidUnit.System.SI_LINEAR: Unit.SECONDS, + HidUnit.System.SI_ROTATION: Unit.SECONDS, + HidUnit.System.ENGLISH_LINEAR: Unit.SECONDS, + HidUnit.System.ENGLISH_ROTATION: Unit.SECONDS, + }[self] + + @property + def temperature(self: "HidUnit.System") -> Optional["Unit"]: + """ + Returns the right :class:`Unit` for the temperature measurement + in this system. + """ + return { + HidUnit.System.NONE: None, + HidUnit.System.SI_LINEAR: Unit.KELVIN, + HidUnit.System.SI_ROTATION: Unit.KELVIN, + HidUnit.System.ENGLISH_LINEAR: Unit.FAHRENHEIT, + HidUnit.System.ENGLISH_ROTATION: Unit.FAHRENHEIT, + }[self] + + @property + def current(self: "HidUnit.System") -> Optional["Unit"]: + """ + Returns the right :class:`Unit` for the current measurement + in this system. + """ + return { + HidUnit.System.NONE: None, + HidUnit.System.SI_LINEAR: Unit.AMPERE, + HidUnit.System.SI_ROTATION: Unit.AMPERE, + HidUnit.System.ENGLISH_LINEAR: Unit.AMPERE, + HidUnit.System.ENGLISH_ROTATION: Unit.AMPERE, + }[self] + + @property + def luminous_intensity(self: "HidUnit.System") -> Optional["Unit"]: + """ + Returns the right :class:`Unit` for the luminous intensity + measurement in this system. + """ + return { + HidUnit.System.NONE: None, + HidUnit.System.SI_LINEAR: Unit.CANDELA, + HidUnit.System.SI_ROTATION: Unit.CANDELA, + HidUnit.System.ENGLISH_LINEAR: Unit.CANDELA, + HidUnit.System.ENGLISH_ROTATION: Unit.CANDELA, + }[self] + + def __init__( + self: "HidUnit", system: "HidUnit.System", units: Dict[Optional["Unit"], U16] + ) -> None: + self.units = units + self.system = system + + @classmethod + def _parse(cls: _Type["HidUnit"], data: Bytes) -> "HidUnit": + assert data and len(data) >= 1 + + def nibbles(data): + for element in data: + yield element & 0xF + yield (element >> 4) & 0xF + + systems = ( + "System", + "Length", + "Mass", + "Time", + "Temperature", + "Current", + "Intensity", + "Reserved", + ) + + # Creates a dict with the type of system as key and the value of the + # nibble (the exponent) as value. + exponents = dict(itertools.zip_longest(systems, nibbles(data))) + system = HidUnit.System(exponents["System"]) + if system == HidUnit.System.NONE: + return HidUnit.NONE + + def convert(exponent: Optional[U16]) -> Optional[U16]: + return twos_comp(exponent, 4) if exponent is not None else None + + # Now create the mapping of correct unit types with their exponents, e.g. + # {CENTIMETER: 2, SECONDS: -1}. + units = { + # system: convert(exponents['System']), + system.length: convert(exponents["Length"]), + system.mass: convert(exponents["Mass"]), + system.time: convert(exponents["Time"]), + system.temperature: convert(exponents["Temperature"]), + system.current: convert(exponents["Current"]), + system.luminous_intensity: convert(exponents["Intensity"]), + } + + # Filter out any parts that aren't set + units = {k: v for k, v in units.items() if v is not None and v} + if units: + return HidUnit(system, units) # type: ignore ### bug in mypy it detects v from above as U16 | None + else: + return HidUnit.NONE + + @classmethod + def from_bytes(cls: _Type["HidUnit"], data: Bytes) -> "HidUnit": + """ + Converts the given data bytes into a :class:`HidUnit` object. + The data bytes must not include the 0b011001nn prefix byte. + + Where the HID unit system is None, the returned value is None. + """ + assert 1 <= len(data) <= 4 + return HidUnit._parse(data) + + @classmethod + def from_value(cls: _Type["HidUnit"], value: Union[U8, U16, U32]) -> "HidUnit": + """ + Converts the given 8, 16 or 32-bit value into a :class:`HidUnit` + object. + + Where the HID unit system is None, the returned value is None. + """ + assert value <= 0xFFFFFFFF + return cls.from_bytes(value.to_bytes(byteorder="little", length=4)) + + def __eq__(self: "HidUnit", other: Any) -> bool: + if not isinstance(other, HidUnit): + raise NotImplementedError + return self.system == other.system and self.units == other.units + + def __str__(self: "HidUnit") -> str: + units = [] + for u, exp in self.units.items(): + if exp == 1: + s = "" + else: + s = "".join([superscripts[c] for c in str(exp)]) + if u is not None: + units.append((u.value, s)) + + # python 3.6 seems to not use __str__() for enums, leading to errors + # in the test suite + return f"{str(self.system)}: " + " * ".join( + (f"{unit}{exp}" for unit, exp in units) + ) + + @classmethod + def from_string(cls: _Type["HidUnit"], string: str) -> "HidUnit": + """ + The inverse of __str__() + """ + system_string, unit_string = string.split(": ") + system = HidUnit.System.from_string(system_string) + if system is None: + return HidUnit.NONE + + unitstrings = unit_string.split(" * ") + units: Dict[Optional["Unit"], U16] = {} + for s in unitstrings: + match: Optional[re.Match[str]] + match = re.match(r"(?P[a-zA-z]+)(?P[⁰¹²³⁓⁵⁶⁷⁸⁹⁻]{1,})?", s) + if match is None: + continue + unitstring, expstring = match["unit"], match["exp"] + + unit = Unit(unitstring) + ssinv = {v: k for k, v in superscripts.items()} + if expstring: + exponent = int("".join([ssinv[c] for c in expstring])) + else: + exponent = 1 + + units[unit] = exponent + return HidUnit(system, units) + + @property + def value(self: "HidUnit") -> U32: + """ + Returns the numerical value for this unit as required by the HID + specification. + """ + v = self.system.value + + def unit_value(unit_type): + if unit_type in self.units: + return to_twos_comp(self.units[unit_type], 4) + return 0 + + v |= unit_value(self.system.length) << 4 + v |= unit_value(self.system.mass) << 8 + v |= unit_value(self.system.time) << 12 + v |= unit_value(self.system.temperature) << 16 + v |= unit_value(self.system.current) << 20 + v |= unit_value(self.system.luminous_intensity) << 24 + return v + + +class HidCollection: + class Type(enum.IntEnum): + PHYSICAL = 0 + APPLICATION = 1 + LOGICAL = 2 + REPORT = 3 + NAMED_ARRAY = 4 + USAGE_SWITCH = 5 + USAGE_MODIFIER = 6 + + def __init__(self: "HidCollection", value: U8) -> None: + assert value <= 0xFF + self.value = value + self.name = str(self) + self.type: Optional[HidCollection.Type] + try: + self.type = HidCollection.Type(value) + except ValueError: + self.type = None + + @property + def is_reserved(self: "HidCollection") -> bool: + return 0x07 <= self.value <= 0x7F + + @property + def is_vendor_defined(self: "HidCollection") -> bool: + return 0x80 <= self.value <= 0xFF + + @classmethod + def from_str(cls: _Type["HidCollection"], string: str) -> U16: + """ + Return the value of this HidCollection given the human-readable + string + """ + for v in HidCollection.Type: + if v.name == string.strip().upper(): + return v.value + match = re.match( + r"\s*(vendor[ -_]defined)\s+(0x|x)?(?P[0-9a-f]{2,})", + string, + flags=re.IGNORECASE, + ) + if not match: + raise ValueError(f"Invalid HidCollection: {string}") + + return int(match["value"], 16) + + def __str__(self: "HidCollection") -> str: + try: + return HidCollection.Type(self.value).name + except ValueError: + if self.is_reserved: + c = f"RESERVED {self.value:#x}" + elif self.is_vendor_defined: + c = f"VENDOR_DEFINED {self.value:#x}" + else: # not supposed to happen + raise + return c + + +sensor_mods: Final = { + 0x00: "Mod None", + 0x10: "Mod Change Sensitivity Abs", + 0x20: "Mod Max", + 0x30: "Mod Min", + 0x40: "Mod Accuracy", + 0x50: "Mod Resolution", + 0x60: "Mod Threshold High", + 0x70: "Mod Threshold Low", + 0x80: "Mod Calibration Offset", + 0x90: "Mod Calibration Multiplier", + 0xA0: "Mod Report Interval", + 0xB0: "Mod Frequency Max", + 0xC0: "Mod Period Max", + 0xD0: "Mod Change Sensitivity Range Percent", + 0xE0: "Mod Change Sensitivity Rel Percent", + 0xF0: "Mod Vendor Reserved", +} + +inv_hid: Dict[U16, str] = {} # e.g 0b10000000 : "Input" +hid_type: Dict[str, str] = {} # e.g. "Input" : "Main" +for type, items in hid_items.items(): + for k, v in items.items(): + inv_hid[v] = k + hid_type[k] = type + + +class ParseError(Exception): + """Exception thrown during report descriptor parsing""" + + pass + + +class RangeError(Exception): + """Exception thrown for an out-of-range value + + .. attribute:: value + + The invalid value + + .. attribute:: range + + A ``(min, max)`` tuple for the allowed logical range + + .. attribute:: field + + The :class:`HidField` that triggered this exception + """ + + def __init__(self: "RangeError", field: "HidField", value: int) -> None: + self.field = field + self.range = field.logical_min, field.logical_max + self.value = value + + def __str__(self: "RangeError") -> str: + min, max = self.range + return f"Value {self.value} is outside range {min}, {max} for {self.field.usage_name}" + + +class Unit(enum.Enum): + CENTIMETER = "cm" + RADIANS = "rad" + INCH = "in" + DEGREES = "deg" + GRAM = "g" + SLUG = "slug" + SECONDS = "s" + KELVIN = "K" + FAHRENHEIT = "F" + AMPERE = "A" + CANDELA = "cd" + RESERVED = "reserved" + + +class HidField(object): + """ + Represents one field in a HID report. A field is one element of a HID + report that matches a specific set of bits in that report. + + .. attribute:: usage + + The numerical HID field's Usage, e.g. 0x38 for "Wheel". If the field + has multiple usages, this refers to the first one. + + .. attribute:: usage_page + + The numerical HID field's Usage Page, e.g. 0x01 for "Generic + Desktop" + + .. attribute:: report_ID + + The numeric Report ID this HID field belongs to + + .. attribute:: logical_min + + The logical minimum of this HID field + + .. attribute:: logical_max + + The logical maximum of this HID field + + .. attribute:: physical_min + + The physical minimum of this HID field + + .. attribute:: physical_max + + The physical maximum of this HID field + + .. attribute:: unit + + The unit of this HID field + + .. attribute:: unit_exp + + The unit exponent of this HID field + + .. attribute:: size + + Report Size in bits for this HID field + + .. attribute:: count + + Report Count for this HID field + """ + + def __init__( + self: "HidField", + report_ID: U8, + logical: Optional[U32], + physical: Optional[U32], + application: Optional[U32], + collection: Optional[Tuple[U32, U32, U32]], + value: U32, + usage_page: U16, + usage: U32, + logical_min: U32, + logical_max: U32, + physical_min: U32, + physical_max: U32, + unit: U16, + unit_exp: U8, + item_size: U8, + count: U8, + ) -> None: + self.report_ID = report_ID + self.logical = logical + self.physical = physical + self.application = application + self.collection = collection + self.type = value + self.usage_page = usage_page + self.usage = usage + self.usages: Optional[List[U32]] = None + self.logical_min = logical_min + self.logical_max = logical_max + self.physical_min = physical_min + self.physical_max = physical_max + self.unit = unit + self.unit_exp = unit_exp + self.size = item_size + self.count = count + self.start = 0 + + def copy(self: "HidField") -> "HidField": + """ + Return a full copy of this :class:`HIDField`. + """ + c = copy.copy(self) + if self.usages is not None: + c.usages = self.usages[:] + return c + + def _usage_name(self: "HidField", usage: U32) -> str: + usage_page: U16 = usage >> 16 + value: U16 = usage & 0x0000FFFF + if usage_page in HUT: # type: ignore ### Operator "in" not supported for types "int" and "HidUsageTable" + if HUT[usage_page].page_name == "Button": + name = f"B{str(value)}" + else: + try: + name = HUT[usage_page][value].name + except KeyError: + name = f"0x{usage:04x}" + else: + name = f"0x{usage:04x}" + return name + + @property + def usage_name(self: "HidField") -> str: + """ + The Usage name for this field (e.g. "Wheel"). + """ + return self._usage_name(self.usage) + + def get_usage_name(self: "HidField", index: int) -> Optional[str]: + """ + Return the Usage name for this field at the given index. Use this + function when the HID field has multiple Usages. + """ + if self.usages is not None: + return self._usage_name(self.usages[index]) + return None + + @property + def physical_name(self: "HidField") -> Optional[str]: + """ + The physical name or ``None`` + """ + phys = self.physical + if phys is None: + return phys + + _phys = "" + try: + page_id = phys >> 16 + value = phys & 0xFF + _phys = HUT[page_id][value].name + except KeyError: + try: + _phys = f"0x{phys:04x}" + except ValueError: + pass + return _phys + + @property + def logical_name(self: "HidField") -> Optional[str]: + """ + The logical name or ``None`` + """ + logical = self.logical + if logical is None: + return None + + _logical = "" + + try: + page_id = logical >> 16 + value = logical & 0xFF + _logical = HUT[page_id][value].name + except KeyError: + try: + _logical = f"0x{logical:04x}" + except ValueError: + pass + return _logical + + def _get_value(self: "HidField", report: List[U8], idx: int) -> Union[U32, str]: + """ + Extract the bits that are this HID field in the list of bytes + ``report`` + + :param list report: a list of bytes that represent a HID report + :param int idx: which field index to fetch, only greater than 0 if + :attr:`count` is larger than 1 + """ + value = 0 + start_bit = self.start + self.size * idx + end_bit = start_bit + self.size * (idx + 1) + data = report[int(start_bit / 8) : int(end_bit / 8 + 1)] + if len(data) == 0: + return "<.>" + for d in range(len(data)): + value |= data[d] << (8 * d) + + bit_offset = start_bit % 8 + value = value >> bit_offset + garbage = (value >> self.size) << self.size + value = value - garbage + if self.logical_min < 0 and self.size > 1: + value = twos_comp(value, self.size) + return value + + def get_values(self: "HidField", report: List[U8]) -> List[Union[U32, str]]: + """ + Assume ``report`` is a list of bytes that are a full HID report, + extract the values that are this HID field. + + Example: + + - if this field is Usage ``X`` , this returns ``[x-value]`` + - if this field is Usage ``X``, ``Y``, this returns ``[x, y]`` + - if this field is a button mask, this returns ``[1, 0, 1, ...]``, i.e. one value for each + button + + :param list report: a list of bytes that are a HID report + :returns: a list of integer values of len :attr:`count` + """ + return [self._get_value(report, i) for i in range(self.count)] + + def _fill_value(self: "HidField", report: List[U8], value: U32, idx: int) -> None: + start_bit = self.start + self.size * idx + n = self.size + + max = (1 << n) - 1 + if value > max: + raise Exception( + f"_set_value(): value {value} is larger than size {self.size}" + ) + + byte_idx = int(start_bit / 8) + bit_shift = start_bit % 8 + bits_to_set = 8 - bit_shift + + while n - bits_to_set >= 0: + report[byte_idx] &= ~(0xFF << bit_shift) + report[byte_idx] |= (value << bit_shift) & 0xFF + value >>= bits_to_set + n -= bits_to_set + bits_to_set = 8 + bit_shift = 0 + byte_idx += 1 + + # last nibble + if n: + bit_mask = (1 << n) - 1 + report[byte_idx] &= ~(bit_mask << bit_shift) + report[byte_idx] |= value << bit_shift + + def fill_values_array(self: "HidField", report: List[U8], data: List[Any]) -> None: + """ + Assuming ``data`` is the value for this HID field array and ``report`` + is a HID report's bytes, this method sets those bits in ``report`` that + are his HID field to ``value``. + + Example: + - if this field is an array of keys, use + ``fill_values(report, ['a or A', 'b or B', ...]``, i.e. one string + representation for each pressed key + + + ``data`` must have at most :attr:`count` elements, matching this + field's Report Count. + + + :param list report: an integer array representing this report, + modified in place + :param list data: the data for this hid field with one element for + each Usage. + """ + if len(data) > self.count: + raise Exception("-EINVAL") + + array: List[int] = [] + + for usage_name in data: + try: + usage = HUT[self.usage_page].from_name[usage_name] + except KeyError: + continue + + full_usage = usage.usage_page.page_id << 16 | usage.usage + + if self.usages is not None and full_usage in self.usages: + idx = self.usages.index(full_usage) + array.append(idx) + + for idx in range(self.count): + try: + v = array[idx] + except IndexError: + v = 0 + + v += self.logical_min + + self._fill_value(report, v, idx) + + def fill_values(self: "HidField", report: List[U8], data: List[U32]) -> None: + """ + Assuming ``data`` is the value for this HID field and ``report`` is + a HID report's bytes, this method sets those bits in ``report`` that + are his HID field to ``value``. + + Example: + + - if this field is Usage ``X`` , use ``fill_values(report, [x-value])`` + - if this field is Usage ``X``, ``Y``, use ``fill_values(report, [x, y])`` + - if this field is a button mask, use + ``fill_values(report, [1, 0, 1, ...]``, i.e. one value for each + button + + ``data`` must have at least :attr:`count` elements, matching this + field's Report Count. + + + :param list report: an integer array representing this report, + modified in place + :param list data: the data for this hid field with one element for + each Usage. + """ + if len(data) != self.count: + raise Exception("-EINVAL") + + for idx in range(self.count): + v = data[idx] + + if self.is_null: + # FIXME: handle the signed case too + if v >= (1 << self.size): + raise RangeError(self, v) + elif self.usage_name not in ["Contact Id", "Contact Max", "Contact Count"]: + if v and not (self.logical_min <= v <= self.logical_max): + raise RangeError(self, v) + if self.logical_min < 0: + v = to_twos_comp(v, self.size) + self._fill_value(report, v, idx) + + @property + def is_array(self: "HidField") -> bool: + """ + ``True`` if this HID field is an array + """ + return not (self.type & (0x1 << 1)) # Variable + + @property + def is_const(self: "HidField") -> bool: + """ + ``True`` if this HID field is const + """ + return bool(self.type & (0x1 << 0)) + + @property + def is_null(self: "HidField") -> bool: + """ + ``True`` if this HID field is null + """ + return bool(self.type & (0x1 << 6)) + + @property + def usage_page_name(self: "HidField") -> str: + """ + The Usage Page name for this field, e.g. "Generic Desktop" + """ + usage_page_name = "" + usage_page = self.usage_page >> 16 + try: + usage_page_name = HUT[usage_page].page_name + except KeyError: + pass + return usage_page_name + + @classmethod + def getHidFields( + cls: _Type["HidField"], + report_ID: U8, + logical: Optional[U32], + physical: Optional[U32], + application: Optional[U32], + collection: Optional[Tuple[U32, U32, U32]], + value: U32, + usage_page: U16, + usages: List[U32], + usage_min: U32, + usage_max: U32, + logical_min: U32, + logical_max: U32, + physical_min: U32, + physical_max: U32, + unit: U16, + unit_exp: U8, + item_size: U8, + count: int, + ): + """ + This is a function to be called by a HID report descriptor parser. + + Given the current parser state and the various arguments, create the + required number of :class:`HidField` objects. + + :returns: a list of :class:`HidField` objects + """ + + # Note: usage_page is a 32-bit value here and usage + # is usage_page | usage + + usage: U32 = usage_min + if len(usages) > 0: + usage = usages[0] + + # for arrays, we don't have a given usage + # use either the logical if given or the application + if not value & 0x3: + if logical is not None and logical: + usage = logical + elif application is not None: + usage = application + + item = cls( + report_ID, + logical, + physical, + application, + collection, + value, + usage_page, + usage, + logical_min, + logical_max, + physical_min, + physical_max, + unit, + unit_exp, + item_size, + 1, + ) + items = [] + + if value & 0x3: # Const or Variable item + if usage_min and usage_max: + usage = usage_min + for i in range(count): + item = item.copy() + item.usage = usage + items.append(item) + if usage < usage_max: + usage += 1 + elif usages: + for i in range(count): + if i < len(usages): + usage = usages[i] + else: + usage = usages[-1] + item = item.copy() + item.usage = usage + items.append(item) + # A const field used for padding may not have any usages + else: + item.size *= count + return [item] + else: # Array item + if usage_min and usage_max: + usages = list(range(usage_min, usage_max + 1)) + item.usages = usages + item.count = count + return [item] + return items + + +class HidReport(object): + """ + Represents a HidReport, one of ``Input``, ``Output``, ``Feature``. A + :class:`ReportDescriptor` may contain one or more + HidReports of different types. These comprise of a number of + :class:`HidField` making up the exact description of a + report. + + :param int report_ID: the report ID + :param int application: the HID application + + .. attribute:: fields + + The :class:`HidField` elements comprising this report + + """ + + class Type(enum.Enum): + """The type of a :class:`HidReport`""" + + INPUT = enum.auto() + OUTPUT = enum.auto() + FEATURE = enum.auto() + + def __init__( + self: "HidReport", + report_ID: U8, + application: Optional[U32], + type: "HidReport.Type", + ) -> None: + self.fields: List[HidField] = [] + self.report_ID = report_ID + self.application = application + self._application_name: Optional[str] = None + self._bitsize = 0 + if self.numbered: + self._bitsize = 8 + self._type = type + self.prev_collection: Optional[Tuple[U32, U32, U32]] = None + + def append(self: "HidReport", field: HidField) -> None: + """ + Add a :class:`HidField` to this report + + :param HidField field: the object to add to this report + """ + self.fields.append(field) + field.start = self._bitsize + self._bitsize += field.size + + def extend(self: "HidReport", fields: List[HidField]) -> None: + """ + Extend this report by the list of :class:`HidField` + objects + + :param list fields: a list of objects to append to this report + """ + self.fields.extend(fields) + for f in fields: + f.start = self._bitsize + self._bitsize += f.size * f.count + + @property + def application_name(self: "HidReport") -> str: + if self.application is None: + return "Vendor" + + try: + page_id = self.application >> 16 + value = self.application & 0xFF + return HUT[page_id][value].name + except KeyError: + return "Vendor" + + @property + def type(self: "HidReport") -> "HidReport.Type": + """ + One of the types in :class:`HidReport.Type` + """ + return self._type + + @property + def numbered(self: "HidReport") -> bool: + """ + True if the HidReport was initialized with a report ID + """ + return self.report_ID >= 0 + + @property + def bitsize(self: "HidReport") -> int: + """ + The size of the HidReport in bits + """ + return self._bitsize + + @property + def size(self: "HidReport") -> int: + """ + The size of the HidReport in bytes + """ + return self._bitsize >> 3 + + +class _HidRDescItem(object): + """ + Represents one item in the Report Descriptor. This is a variable-sized + element with one header byte and 0, 1, 2, 4 payload bytes. + + :param int index_in_report: + The index within the report descriptor + :param int hid: + The numerical hid type (e.g. ``0b00000100`` for Usage Page) + :param int value: + The 8, 16, or 32 bit value + :param list raw_values: + The payload bytes' raw values, LSB first + + + These items are usually parsed from a report descriptor, see + :meth:`from_bytes`. The report descriptor + bytes are:: + + H P P H H P H P + + where each header byte looks like this + + +---------+---+---+---+---+---+---+---+---+ + | bit | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | + +=========+===+===+===+===+===+===+===+===+ + | | hid item type | size | + +---------+-----------------------+-------+ + + .. note:: a size of 0x3 means payload size 4 + + To create a _HidRDescItem from a human-readable description, use + :meth:`from_human_descr`. + + + + .. attribute:: index_in_report + + The numerical index of this item in the report descriptor. + + .. attribute:: raw_value + + A list of the payload's raw values + + .. attribute:: hid + + The hid item as number (e.g. ``0b00000100`` for Usage Page) + + .. attribute:: item + + The hid item as string (e.g. "Usage Page") + + .. attribute:: value + + The payload value as single number + + """ + + def __init__( + self: "_HidRDescItem", + index_in_report: int, + hid: U16, + value: int, + raw_values: List[U8], + ) -> None: + self.index_in_report = index_in_report + self.raw_value = raw_values + self.hid = hid + self.value = value + self.usage_page: U16 = 0 + try: + self.item = inv_hid[self.hid] + except KeyError: + error = f"error while parsing {hid:02x}" + raise KeyError(error) + + if self.item in ( + "Logical Minimum", + "Physical Minimum", + # "Logical Maximum", + # "Physical Maximum", + ): + self._twos_comp() + if self.item == "Unit Exponent" and self.value > 7: + self.value -= 16 + + def _twos_comp(self: "_HidRDescItem") -> int: + self.value = twos_comp(self.value, (self.size - 1) * 8) + return self.value + + @property + def size(self: "_HidRDescItem") -> int: + """The size in bytes, including header byte""" + return 1 + len(self.raw_value) + + @property + def bytes(self: "_HidRDescItem") -> List[U8]: + """ + Return this in the original format in bytes, i.e. a header byte + followed by (if any) payload bytes. + + :returns: a list of bytes that are this item + """ + if len(self.raw_value) == 4: + h = self.hid | 0x3 + else: + h = self.hid | len(self.raw_value) + return [h] + self.raw_value.copy() + + def __repr__(self: "_HidRDescItem") -> str: + data = [f"{i:02x}" for i in self.bytes] + return f'{" ".join(data)}' + + def _get_raw_values(self: "_HidRDescItem") -> str: + """The raw values as comma-separated hex numbers""" + data = str(self) + # prefix each individual value by "0x" and insert "," in between + data = f'0x{data.replace(" ", ", 0x")},' + return data + + def get_human_descr(self: "_HidRDescItem", indent: int) -> Tuple[str, int]: + """ + Return a human-readable description of this item + + :param int indent: The indentation to prefix + """ + item = self.item + value = self.value + up = self.usage_page + descr = item + # Use a prefix to signify attrs that apply to the next input/output + prefix = " ." + if item in ( + "Report ID", + "Usage Minimum", + "Usage Maximum", + "Logical Minimum", + "Physical Minimum", + "Logical Maximum", + "Physical Maximum", + "Report Size", + "Report Count", + "Unit Exponent", + ): + if item != "Report ID": + descr = prefix + descr + descr += f" ({str(value)})" + elif item == "Collection": + descr += f" ({HidCollection(value).name.capitalize()})" + indent += 1 + elif item == "End Collection": + indent -= 1 + elif item == "Usage Page": + try: + descr += f" ({HUT[value].page_name})" + except KeyError: + descr += f" (Vendor Usage Page 0x{value:02x})" + elif item == "Usage": + usage = value | up + try: + descr += f" ({HUT[up >> 16][value]})" + except KeyError: + sensor = HUT.usage_page_from_name("Sensor") + if sensor is not None and (up >> 16) == sensor.page_id: + mod = (usage & 0xF000) >> 8 + usage &= ~0xF000 + mod_descr = sensor_mods[mod] + page_id = (usage & 0xFF00) >> 16 + try: + descr += f" ({HUT[page_id][usage & 0xFF]} | {mod_descr})" + except KeyError: + descr += f" (Unknown Usage 0x{value:02x})" + else: + descr += f" (Vendor Usage 0x{value:02x})" + elif item == "Input" or item == "Output" or item == "Feature": + descr += " (" + if value & (0x1 << 0): + descr += "Cnst," + else: + descr += "Data," + if value & (0x1 << 1): + descr += "Var," + else: + descr += "Arr," + if value & (0x1 << 2): + descr += "Rel" + else: + descr += "Abs" + if value & (0x1 << 3): + descr += ",Wrap" + if value & (0x1 << 4): + descr += ",NonLin" + if value & (0x1 << 5): + descr += ",NoPref" + if value & (0x1 << 6): + descr += ",Null" + if value & (0x1 << 7): + descr += ",Vol" + if value & (0x1 << 8): + descr += ",Buff" + descr += ")" + elif item == "Unit": + unit = HidUnit.from_value(value) + descr += f" ({unit})" + elif item == "Push": + pass + elif item == "Pop": + pass + eff_indent = indent + if item == "Collection": + eff_indent -= 1 + return " " * eff_indent + descr, indent + + @classmethod + def _one_item_from_bytes( + cls: _Type["_HidRDescItem"], rdesc: Union[Bytes, List[U8]] + ) -> Optional["_HidRDescItem"]: + """ + Parses a single (the first) item from the given report descriptor. + + :param rdesc: a series of bytes representing the report descriptor + + :returns: a single _HidRDescItem from the first ``item.size`` bytes + of the descriptor + + .. note:: ``item.index_in_report`` is always 0 when using this function + """ + idx = 0 + header = rdesc[idx] + if header == 0 and idx == len(rdesc) - 1: + # some devices present a trailing 0, skipping it + return None + + index_in_report = 0 # always zero, oh well + size = header & 0x3 + if size == 3: + size = 4 + hid = header & 0xFC + if hid == 0: + raise ParseError(f"Unexpected HID type 0 in {header:02x}") + + value = 0 + raw_values = [] + + idx += 1 + if size >= 1: + v = rdesc[idx] + idx += 1 + raw_values.append(v) + value |= v + if size >= 2: + v = rdesc[idx] + idx += 1 + raw_values.append(v) + value |= v << 8 + if size >= 4: + v = rdesc[idx] + idx += 1 + raw_values.append(v) + value |= v << 16 + v = rdesc[idx] + idx += 1 + raw_values.append(v) + value |= v << 24 + + return _HidRDescItem(index_in_report, hid, value, raw_values) + + @classmethod + def from_bytes( + cls: _Type["_HidRDescItem"], + rdesc: Union[ + Bytes, + List[U8], + ], + ) -> List["_HidRDescItem"]: + """ + Parses a series of bytes into items. + + :param list rdesc: a series of bytes that are a HID report + descriptor + + :returns: a list of items representing this report descriptor + """ + items = [] + idx = 0 + while idx < len(rdesc): + item = _HidRDescItem._one_item_from_bytes(rdesc[idx:]) + if item is None: + break + item.index_in_report = idx + items.append(item) + idx += item.size + + return items + + @classmethod + def from_human_descr( + cls: _Type["_HidRDescItem"], line: str, usage_page: U16 + ) -> "_HidRDescItem": + """ + Parses a line from human-readable HID report descriptor e.g.:: + + Usage Page (Digitizers) + Usage (Finger) + Collection (Logical) + Report Size (1) + Report Count (1) + Logical Minimum (0) + Logical Maximum (1) + Usage (Tip Switch) + Input (Data,Var,Abs) + + + :param str line: a single line in the report descriptor + :param int usage_page: the usage page to set for this item + + :returns: a single item representing the current line + """ + data = None + if "(" in line: + m = re.match(r"\s*(?P[^(]+)\((?P.+)\)", line) + assert m is not None + name = m.group("name").strip() + data = m.group("data") + if data.lower().startswith("0x"): + try: + data = int(data[2:], 16) + except ValueError: + pass + else: + try: + data = int(data) + except ValueError: + pass + # Strip any superfluous stuff from an EndCollection line + elif "End Collection" in line: + name = "End Collection" + else: + name = line.strip() + + value = None + + def hex_value(string: str, prefix: str) -> Optional[U16]: + if string.startswith(prefix): + return int(string[len(prefix) :], 16) + return None + + if isinstance(data, str): + if name == "Usage Page": + up = HUT.usage_page_from_name(data) + if up is None: + prefix = "Vendor Usage Page " + assert data.startswith(prefix) + value = hex_value(data, prefix) + else: + page = HUT.usage_page_from_name(data) + if page is not None: + value = page.page_id + if value is not None: + usage_page = value + elif name == "Usage": + try: + value = HUT[usage_page].from_name[data].usage + except KeyError: + value = hex_value(data, "Vendor Usage ") + if value is None: + raise + elif name == "Collection": + value = HidCollection.from_str(data) + elif name in "Input Output Feature": + value = 0 + possible_types = ( + "Cnst", + "Var", + "Rel", + "Wrap", + "NonLin", + "NoPref", + "Null", + "Vol", + "Buff", + ) + for i, t in enumerate(possible_types): + if t in data: + value |= 0x1 << i + elif name == "Unit": + if data == "None": + value = 0 + else: + value = HidUnit.from_string(data).value + else: # data has been converted to an int already + if name == "Usage Page" and data is not None: + usage_page = data + value = data + + v_count = 0 + if value is not None: + if value <= 0xFF: + v_count = 1 + elif value <= 0xFFFF: + v_count = 2 + else: + v_count = 4 + else: + value = 0 + tag = hid_items[hid_type[name]][name] + + if value < 0: + if name == "Unit Exponent": + value += 16 + value = to_twos_comp(value, v_count * 8) + elif name in ("Logical Minimum", "Physical Minimum"): + value = to_twos_comp(value, v_count * 8) + + assert value is not None + + v: U16 = value + vs = [] + for i in range(v_count): + vs.append(v & 0xFF) + v >>= 8 + + item = _HidRDescItem(0, tag, value, vs) + item.usage_page = usage_page << 16 + + return item + + def dump_rdesc_kernel(self: "_HidRDescItem", indent: int, dump_file: IO) -> int: + """ + Write the HID item to the file a C-style format, e.g. :: + + 0x05, 0x01, /* Usage Page (Generic Desktop) */ + + :param int indent: indentation to prefix + :param File dump_file: file to write to + """ + # offset = self.index_in_report + line = self._get_raw_values() + line += "\t" * (int((40 - len(line)) / 8)) + + descr, indent = self.get_human_descr(indent) + + descr += "\t" * (int((52 - len(descr)) / 8)) + # dump_file.write(f'{line}/* {descr} {str(offset)} */\n') + dump_file.write(f"\t{line}/* {descr}*/\n") + return indent + + def dump_rdesc_array(self: "_HidRDescItem", indent: int, dump_file: IO) -> int: + """ + Format the hid item in hexadecimal format with a + double-slash comment, e.g. :: + + 0x05, 0x01, // Usage Page (Generic Desktop) 0 + + :param int indent: indentation to prefix + :param File dump_file: file to write to + """ + offset = self.index_in_report + line = self._get_raw_values() + + descr, indent = self.get_human_descr(indent) + + dump_file.write(f"{line:18s} // {offset:03x}: {descr}\n") + return indent + + def dump_rdesc_human(self: "_HidRDescItem", indent: int, dump_file: IO) -> int: + """ + Format the hid item in human-only format e.g. :: + + Usage Page (Generic Desktop) 0 + + :param int indent: indentation to prefix + :param File dump_file: file to write to + """ + offset = self.index_in_report + descr, indent = self.get_human_descr(indent) + descr += " " * (35 - len(descr)) + dump_file.write(f"{descr} {str(offset)}\n") + return indent + + +class ReportDescriptor(object): + """ + Represents a fully parsed HID report descriptor. + + When creating a ``ReportDescriptor`` object, + + - if your source is a stream of bytes, use + :meth:`from_bytes` + - if your source is a human-readable descriptor, use + :meth:`from_human_descr` + + .. attribute:: win8 + + ``True`` if the device is Windows8 compatible, ``False`` otherwise + + .. attribute:: input_reports + + All :class:`HidReport` of type ``Input``, addressable by the report ID + + .. attribute:: output_reports + + All :class:`HidReport` of type ``Output``, addressable by the report ID + + .. attribute:: feature_reports + + All :class:`HidReport` of type ``Feature``, addressable by the report ID + """ + + class _Globals(object): + """ + HID report descriptors uses a stack-based model where some values + are pushed to the global state and apply to all subsequent items + until changed or reset. + """ + + def __init__( + self: "ReportDescriptor._Globals", + other: Optional["ReportDescriptor._Globals"] = None, + ) -> None: + self.usage_page: U16 = 0 + self.logical: Optional[U32] = None + self.physical: Optional[U32] = None + self.application: Optional[U32] = None + self.logical_min: U32 = 0 + self.logical_max: U32 = 0 + self.physical_min: U32 = 0 + self.physical_max: U32 = 0 + self.unit: U32 = 0 + self.unit_exp: U32 = 0 + self.count: int = 0 + self.item_size: int = 0 + if other is not None: + self.usage_page = other.usage_page + self.logical = other.logical + self.physical = other.physical + self.application = other.application + self.logical_min = other.logical_min + self.logical_max = other.logical_max + self.physical_min = other.physical_min + self.physical_max = other.physical_max + self.unit = other.unit + self.unit = other.unit_exp + self.count = other.count + self.item_size = other.item_size + + class _Locals(object): + """ + HID report descriptors uses a stack-based model where values + apply until the next Output/InputReport/FeatureReport item. + """ + + def __init__(self: "ReportDescriptor._Locals") -> None: + self.usages: List[U32] = [] + self.usage_sizes: List[int] = [] + self.usage_min: U32 = 0 + self.usage_max: U32 = 0 + self.usage_max_size: U32 = 0 + self.report_ID: U8 = -1 + + def __init__(self: "ReportDescriptor", items: List[_HidRDescItem]) -> None: + self.input_reports: Dict[U8, HidReport] = {} + self.feature_reports: Dict[U8, HidReport] = {} + self.output_reports: Dict[U8, HidReport] = {} + self.win8: bool = False + self.rdesc_items = items + + # variables only used during parsing + self.global_stack: List["ReportDescriptor._Globals"] = [] + self.collection: List[U32] = [0, 0, 0] # application, physical, logical + self.local = ReportDescriptor._Locals() + self.glob: "ReportDescriptor._Globals" = ReportDescriptor._Globals() + self.current_item = None + + index_in_report = 0 + for item in items: + item.index_in_report = index_in_report + index_in_report += item.size + self._parse_item(item) + + # Drop the parsing-only variables so we don't leak them later + del self.current_item + del self.glob + del self.global_stack + del self.local + del self.collection + + def get( + self: "ReportDescriptor", reportID: U8, reportSize: int + ) -> Optional[HidReport]: + """ + Return the input report with the given Report ID or ``None`` + """ + try: + report = self.input_reports[reportID] + except KeyError: + try: + report = self.input_reports[-1] + except KeyError: + return None + + # if the report is larger than it should be, it's OK, + # extra bytes will just be ignored + if report.size <= reportSize: + return report + + return None + + def get_report_from_application( + self: "ReportDescriptor", application: Union[str, U32] + ) -> Optional[HidReport]: + """ + Return the Input report that matches the application or ``None`` + """ + for r in self.input_reports.values(): + if r.application == application or r.application_name == application: + return r + return None + + def _get_current_report(self: "ReportDescriptor", type: str) -> HidReport: + report_lists = { + "Input": self.input_reports, + "Output": self.output_reports, + "Feature": self.feature_reports, + } + report_type = { + "Input": HidReport.Type.INPUT, + "Output": HidReport.Type.OUTPUT, + "Feature": HidReport.Type.FEATURE, + } + + assert type in report_lists + + try: + cur = report_lists[type][self.local.report_ID] + except KeyError: + cur = HidReport( + self.local.report_ID, self.glob.application, report_type[type] # type: ignore + ) + report_lists[type][self.local.report_ID] = cur + return cur + + def _concatenate_usages(self: "ReportDescriptor") -> None: + if self.local.usage_max and self.local.usage_max_size <= 2: + if self.local.usage_max & 0xFFFF0000 != self.glob.usage_page: + self.local.usage_max &= 0xFFFF + self.local.usage_max |= self.glob.usage_page + self.local.usage_min &= 0xFFFF + self.local.usage_min |= self.glob.usage_page + + for i, v in reversed(list(enumerate(self.local.usages))): + if self.local.usage_sizes[i] > 2: + continue + if v & 0xFFFF0000 == self.glob.usage_page: + break + self.local.usages[i] = v & 0xFFFF | self.glob.usage_page + + def _parse_item(self: "ReportDescriptor", rdesc_item: _HidRDescItem) -> None: + # store current usage_page in rdesc_item + rdesc_item.usage_page = self.glob.usage_page + item = rdesc_item.item + value = rdesc_item.value + size = rdesc_item.size - 1 + + if item == "Report ID": + self.local.report_ID = value + elif item == "Push": + self.global_stack.append(self.glob) + self.glob = ReportDescriptor._Globals(self.glob) + elif item == "Pop": + self.glob = self.global_stack.pop() + elif item == "Usage Page": + self.glob.usage_page = value << 16 + elif item == "Collection": + self._concatenate_usages() + + c = HidCollection(value) + try: + if c.type == HidCollection.Type.PHYSICAL: + self.collection[1] += 1 + self.glob.physical = self.local.usages[-1] + elif c.type == HidCollection.Type.APPLICATION: + self.collection[0] += 1 + self.glob.application = self.local.usages[-1] + elif c.type == HidCollection.Type.LOGICAL: + self.collection[2] += 1 + self.glob.logical = self.local.usages[-1] + except IndexError: + pass + # reset the usage list + self.local.usages = [] + self.local.usage_sizes = [] + self.local.usage_min = 0 + self.local.usage_max = 0 + self.local.usage_max_size = 0 + elif item == "Usage Minimum": + if size <= 2: + self.local.usage_min = value | self.glob.usage_page + else: + self.local.usage_min = value + elif item == "Usage Maximum": + if size <= 2: + self.local.usage_max = value | self.glob.usage_page + else: + self.local.usage_max = value + self.local.usage_max_size = size + elif item == "Logical Minimum": + self.glob.logical_min = value + elif item == "Logical Maximum": + self.glob.logical_max = value + elif item == "Physical Minimum": + self.glob.physical_min = value + elif item == "Physical Maximum": + self.glob.physical_max = value + elif item == "Unit": + self.glob.unit = value + elif item == "Unit Exponent": + self.glob.unit_exp = value + elif item == "Usage": + if size <= 2: + self.local.usages.append(value | self.glob.usage_page) + else: + self.local.usages.append(value) + self.local.usage_sizes.append(size) + elif item == "Report Count": + self.glob.count = value + elif item == "Report Size": + self.glob.item_size = value + elif item in ("Input", "Feature", "Output"): + self.current_input_report = self._get_current_report(item) + + self._concatenate_usages() + + inputItems = HidField.getHidFields( + self.local.report_ID, + self.glob.logical, + self.glob.physical, + self.glob.application, + cast(Tuple[U32, U32, U32], tuple(self.collection)), + value, + self.glob.usage_page, + self.local.usages, + self.local.usage_min, + self.local.usage_max, + self.glob.logical_min, + self.glob.logical_max, + self.glob.physical_min, + self.glob.physical_max, + self.glob.unit, + self.glob.unit_exp, + self.glob.item_size, + self.glob.count, + ) + self.current_input_report.extend(inputItems) + if ( + item == "Feature" + and len(self.local.usages) > 0 + and self.local.usages[-1] == 0xFF0000C5 + ): + self.win8 = True + self.local.usages = [] + self.local.usage_sizes = [] + self.local.usage_min = 0 + self.local.usage_max = 0 + self.local.usage_max_size = 0 + + def dump( + self: "ReportDescriptor", dump_file=sys.stdout, output_type="default" + ) -> None: + """ + Write this ReportDescriptor into the given file + + The "default" format prints each item as hexadecimal format with a + double-slash comment, e.g. :: + + 0x05, 0x01, // Usage Page (Generic Desktop) 0 + 0x09, 0x02, // Usage (Mouse) 2 + + + The "kernel" format prints each item in valid C format, for easy + copy-paste into a kernel or C source file: :: + + 0x05, 0x01, /* Usage Page (Generic Desktop) */ + 0x09, 0x02, /* Usage (Mouse) */ + + :param File dump_file: the file to write to + :param str output_type: the output format, one of "default" or "kernel" + """ + assert output_type in ["default", "kernel", "human"] + + indent = 0 + for rdesc_item in self.rdesc_items: + if output_type == "default": + indent = rdesc_item.dump_rdesc_array(indent, dump_file) + elif output_type == "kernel": + indent = rdesc_item.dump_rdesc_kernel(indent, dump_file) + elif output_type == "human": + indent = rdesc_item.dump_rdesc_human(indent, dump_file) + + @property + def size(self: "ReportDescriptor") -> int: + """ + Returns the size of the report descriptor in bytes. + """ + return sum([item.size for item in self.rdesc_items]) + + @property + def bytes(self: "ReportDescriptor") -> List[U8]: + """ + This report descriptor as a list of 8-bit integers. + """ + data = [] + for item in self.rdesc_items: + data.extend(item.bytes) + return data + + @classmethod + def from_bytes( + cls: _Type["ReportDescriptor"], rdesc: Union[Bytes, List[U8]] + ) -> "ReportDescriptor": + """ + Parse the given list of 8-bit integers. + + :param list rdesc: a list of bytes that are this report descriptor + """ + items = _HidRDescItem.from_bytes(rdesc) + + return ReportDescriptor(items) + + @classmethod + def from_string(cls: _Type["ReportDescriptor"], rdesc: str) -> "ReportDescriptor": + """ + Parse a string in the format of series of hex numbers:: + + 12 34 ab cd ... + + and the first number in that series is the count of bytes, excluding + that first number. This is the format returned by your + ``/dev/hidraw`` event node, so just pass it along. + + + :param list rdesc: a string that represents the list of bytes + """ + + irdesc = [int(r, 16) for r in rdesc.split()[1:]] + items = _HidRDescItem.from_bytes(irdesc) + + return ReportDescriptor(items) + + @classmethod + def from_human_descr( + cls: _Type["ReportDescriptor"], rdesc_str: str + ) -> "ReportDescriptor": + """ + Parse the given human-readable report descriptor, e.g. :: + + Usage Page (Digitizers) + Usage (Finger) + Collection (Logical) + Report Size (1) + Report Count (1) + Logical Minimum (0) + Logical Maximum (1) + Usage (Tip Switch) + Input (Data,Var,Abs) + Report Size (7) + Logical Maximum (127) + Input (Cnst,Var,Abs) + Report Size (8) + Logical Maximum (255) + Usage (Contact Id) + + """ + usage_page = 0 + items = [] + for line in rdesc_str.splitlines(): + line = line.strip() + if not line: + continue + item = _HidRDescItem.from_human_descr(line, usage_page) + usage_page = item.usage_page >> 16 + items.append(item) + + return ReportDescriptor(items) + + +def get_descriptor(fd: int): + from hhd.controller.lib.ioctl import HIDIOCGRDESC, HIDIOCGRDESCSIZE + from fcntl import ioctl + import ctypes + + size = ctypes.c_int32() + ioctl(fd, HIDIOCGRDESCSIZE, size) + c_mask = ctypes.create_string_buffer(4096+4) + c_mask[:4] = size.value.to_bytes(4, byteorder=sys.byteorder) + ioctl(fd, HIDIOCGRDESC, c_mask) + rdesc = c_mask.raw + return rdesc[4:size.value+4] + +def print_descriptor(fd: int, format: str = 'default'): + desc = get_descriptor(fd) + return ReportDescriptor.from_bytes(desc).dump(output_type=format) diff --git a/src/hhd/contrib/i18n.py b/src/hhd/contrib/i18n.py new file mode 100644 index 00000000..c54286f4 --- /dev/null +++ b/src/hhd/contrib/i18n.py @@ -0,0 +1,59 @@ +from typing import Sequence, Mapping, Any + +KEYWORDS = ["title", "hint", "options"] + + +def parse(data: Any, names: Sequence[str] = []): + if not isinstance(data, Mapping): + return [] + + if "sections" in data and isinstance(data["sections"], Mapping): + return [ + (0, None, v, [f"Section Name for: {k}"]) + for k, v in data["sections"].items() + ] + + out = [] + fun_name = ".".join(names) + comment = f"Setting: '{fun_name}'" + messages = [] + if "title" in data: + comment = f"Setting: {data['title']}" + messages.append(("Field: title", data["title"])) + if "hint" in data: + messages.append(("Field: hint", data["hint"])) + + if "options" in data and isinstance(data["options"], Mapping): + messages.extend([(f"Option: {k}", v) for k, v in data["options"].items()]) + + for field, msg in messages: + out.append((0, None, msg, [comment, field])) + + for k, v in data.items(): + out.extend(parse(v, [*names, k] if k not in ("modes", "children") else names)) + + return out + + +def extract_hhd_yaml( + fileobj, + keywords: Sequence[str] = [], + comment_tags: Sequence[str] = [], + options: Mapping[str, Any] = {}, +): + """Extract messages from XXX files. + + :param fileobj: the file-like object the messages should be extracted + from + :param keywords: a list of keywords (i.e. function names) that should + be recognized as translation functions + :param comment_tags: a list of translator tags to search for and + include in the results + :param options: a dictionary of additional options (optional) + :return: an iterator over ``(lineno, funcname, message, comments)`` + tuples + :rtype: ``iterator`` + """ + import yaml + + return parse(yaml.safe_load(fileobj)) diff --git a/src/hhd/contrib/main.py b/src/hhd/contrib/main.py new file mode 100644 index 00000000..a138167b --- /dev/null +++ b/src/hhd/contrib/main.py @@ -0,0 +1,38 @@ +import argparse + + +def main(): + parser = argparse.ArgumentParser( + prog="HHD: Handheld Daemon contribution helper scripts", + description="Scripts to automate the capture of events, etc.", + ) + parser.add_argument( + "command", + nargs="+", + default=[], + help="Supported commands: `evdev`, `hidraw`, `gamescope`", + ) + args = parser.parse_args() + + cmds = args.command + try: + match cmds[0]: + case "evdev": + from .dev import evdev + + evdev(cmds[1] if len(cmds) > 1 else None) + case "hidraw": + from .dev import hidraw + + if len(cmds) > 1: + hidraw(*cmds[1:]) + else: + hidraw(None) + case "gamescope": + from .gs import gamescope_debug + + gamescope_debug(cmds[1:]) + case _: + print(f"Command `{cmds[0]}` not supported.") + except KeyboardInterrupt: + pass diff --git a/src/hhd/controller/__init__.py b/src/hhd/controller/__init__.py index 66894a3e..32ca7ffd 100644 --- a/src/hhd/controller/__init__.py +++ b/src/hhd/controller/__init__.py @@ -1,4 +1,19 @@ -from .base import Consumer, Event, Producer, can_read +from .base import ( + Consumer, + Event, + KeyboardWrapper, + Multiplexer, + Producer, + TouchpadCorrection, + TouchpadCorrectionType, + can_read, + correct_touchpad, + SpecialEvent, + ControllerEmitter, + DEBUG_MODE, + RgbMode, + RgbCapabilities, +) from .const import Axis, Button, Configuration __all__ = [ @@ -9,4 +24,14 @@ "Consumer", "Producer", "can_read", + "TouchpadCorrectionType", + "TouchpadCorrection", + "correct_touchpad", + "KeyboardWrapper", + "Multiplexer", + "SpecialEvent", + "ControllerEmitter", + "DEBUG_MODE", + "RgbMode", + "RgbCapabilities", ] diff --git a/src/hhd/controller/base.py b/src/hhd/controller/base.py index db1e9423..2f4b2dd1 100644 --- a/src/hhd/controller/base.py +++ b/src/hhd/controller/base.py @@ -1,73 +1,62 @@ +import logging +import os +import random import select -from typing import Any, Literal, Sequence, TypedDict import time -from .const import Axis, Button, Configuration - - -class EnvelopeEffect(TypedDict): - type: Literal["constant", "ramp"] - attack_length: int - attack_level: int - fade_length: int - fade_level: int - - -class ConditionSide(TypedDict): - # TODO: replace with enums - right_saturation: int - left_saturation: int - right_coeff: int - left_coeff: int - deadband: int - center: int - - -class RumbleEffect(TypedDict): - type: Literal["rumble"] - strong_magnitude: float - weak_magnitude: float - - -class ConditionEffect(TypedDict): - type: Literal["condition"] - left: ConditionSide - right: ConditionSide - +from threading import RLock +from typing import Any, Callable, Literal, Mapping, NamedTuple, Sequence, TypedDict -class PeriodicEffect(TypedDict): - type: Literal["periodic"] - # Todo: replace with enums - waveform: int - period: int - magnitude: int - offset: int - phase: int +try: + # Try to maintain compat with python 3.10 + from typing import NotRequired # type: ignore +except ImportError: + from typing import Optional as NotRequired - attack_length: int - attack_level: int - fade_length: int - fade_level: int - - custom: bytes - - -class EffectEvent(TypedDict): - # Always effect, better for filterring - type: Literal["effect"] - # Event target. Not part of the standard but required for e.g., DS5. - code: Literal["main", "left", "right"] - - id: int - # TODO: Upgrade to literal - direction: int - - trigger_button: str - trigger_interval: int - - replay_length: int - replay_delay: int +from .const import Axis, Button, Configuration - effect: EnvelopeEffect | ConditionEffect | PeriodicEffect | RumbleEffect +logger = logging.getLogger(__name__) + +DEBUG_MODE = bool(os.environ.get("HHD_DEBUG", False)) + + +class SpecialEvent(TypedDict): + type: Literal["special"] + event: Literal[ + "guide", + # Builtin Controller + "qam_single", + "qam_predouble", + "qam_double", + "qam_triple", + "qam_hold", + "overlay", + # Shortcuts + "xbox_b", + "xbox_y", + "kbd_meta_press", + "kbd_meta_hold", + "swipe_left_top", + "swipe_left_bottom", + "swipe_right_top", + "swipe_right_bottom", + "swipe_bottom", + # TDP Cycle animation + "tdp_cycle_quiet", + "tdp_cycle_balanced", + "tdp_cycle_performance", + "tdp_cycle_custom", + # Sleep information + "wakeup", + # Powerbutton presses + "pbtn_short", + "pbtn_long", + "pbtn_double", # todo + # Debug + "restart_dev", + "shutdown_dev", + "refresh", + ] + data: "NotRequired[Any]" class RumbleEvent(TypedDict): @@ -79,34 +68,82 @@ class RumbleEvent(TypedDict): weak_magnitude: float +RgbMode = Literal["disabled", "solid", "pulse", "rainbow", "spiral", "duality", "oxp"] +RgbSettings = Literal[ + "color", "brightness", "speed", "brightnessd", "speedd", "direction", "dual", "oxp" +] + +# Mono is a single zone (main only) +# Dual has per side RGB +# Quad has two zones per stick (Ally) +# TODO: This code needs to be refactored +RgbZones = Literal["mono", "dual", "quad"] +OxpModes = Literal[ + "monster_woke", + "flowing", + "sunset", + "neon", + "dreamy", + "cyberpunk", + "colorful", + "aurora", + "sun", + "classic", +] + + class RgbLedEvent(TypedDict): """Inspired by new controllers with RGB leds, especially below the buttons. Instead of code, this event type exposes multiple properties, including mode.""" type: Literal["led"] - - # The led - code: Literal["main", "left", "right"] + initialize: bool + + # Controls the LED zone. Main sets all zones. + # Left all left zones, right all right zones. + # One and Two are used for quad zone control and three is used for per side 3 zones. + # The third zone would be on the bumpers, such as on the Ayn Loki, if it + # supported per zone RGB. + code: Literal[ + "main", + "left", + "right", + "left_left", + "left_right", + "right_left", + "right_right", + ] # Various lighting modes supported by the led. - mode: Literal["disable", "solid", "blinking", "rainbow", "spiral"] + mode: RgbMode # Brightness range is from 0 to 1 # If the response device does not support brightness control, it shall - # convert the rgb color to hue, assume saturation is 1, and derive a new - # RGB value from the brightness below + # devide the rgb values by the brightness and round. brightness: float # The speed the led should blink if supported by the led speed: float + # For the Ally, has three brightness levels + # (and a forth off, use disabled mode for that) + brightnessd: Literal["low", "medium", "high"] + speedd: Literal["low", "medium", "high"] + direction: Literal["left", "right"] + # Color values for the led, may be ommited depending on the mode, by being # set to 0 red: int green: int blue: int + red2: int + green2: int + blue2: int + + oxp: None | OxpModes + class ButtonEvent(TypedDict): type: Literal["button"] @@ -126,14 +163,323 @@ class ConfigurationEvent(TypedDict): value: Any -Event = ( - EffectEvent - | ButtonEvent - | AxisEvent - | ConfigurationEvent - | RgbLedEvent - | RumbleEvent -) +class RgbCapabilities(TypedDict): + modes: dict[RgbMode, Sequence[RgbSettings]] | None + controller: bool + zones: RgbZones + + +class ControllerCapabilities(TypedDict): + buttons: dict # TODO + supports_qam: bool + rgb: RgbCapabilities | None + + +Event = ButtonEvent | AxisEvent | ConfigurationEvent | RgbLedEvent | RumbleEvent + +GRAB_TIMEOUT = 5 + +QueueEvent = tuple[Any, Sequence[Event]] + + +class ControllerEmitter: + + def __init__(self, ctx=None) -> None: + self.intercept_lock = RLock() + self._intercept = None + self._controller_cb = None + self._qam_cb = None + self.ctx = ctx + self._simple_qam = False + self._cap = None + self.cid = "" + self._evs = [] + + def send_qam(self, expanded: bool = False): + with self.intercept_lock: + if self._qam_cb: + return self._qam_cb(expanded) + return False + + def open_steam(self, expanded: bool = False): + if not self.send_qam(expanded): + return self.inject( + {"type": "configuration", "code": "steam", "value": expanded} + ) + return True + + def set_simple_qam(self, val: bool): + with self.intercept_lock: + self._simple_qam = val + + def simple_qam(self): + with self.intercept_lock: + return self._simple_qam + + def register_qam(self, cb: Callable[..., bool]): + with self.intercept_lock: + self._qam_cb = cb + + def grab(self, enable: bool): + with self.intercept_lock: + if enable: + self._intercept = time.perf_counter() + else: + self._intercept = None + + def register_intercept(self, cb: Callable[[Any, Sequence[Event]], None]): + with self.intercept_lock: + self._controller_cb = cb + + def should_intercept(self): + with self.intercept_lock: + return self._intercept is not None + + def intercept(self, cid: Any, evs: Sequence[Event]): + with self.intercept_lock: + if self._intercept: + if self._intercept + GRAB_TIMEOUT < time.perf_counter(): + logger.error( + f"Intercept timeout triggered, deactivating controller grab." + ) + self.grab(False) + return False + elif evs and self._controller_cb: + self._controller_cb(cid, evs) + return True + else: + return False + else: + return False + + def inject(self, ev: Sequence[Event] | Event): + if not isinstance(ev, Sequence): + ev = [ev] + with self.intercept_lock: + if not self.cid or (self._cap and not self._cap.get("supports_qam", True)): + # Avoid writing events if no controller is connected + return False + for e in ev: + self._evs.append((e, 0)) + return True + + def inject_timed(self, evs: Sequence[tuple[Event, float]]): + # Unfortunately here we have to clear the previous events to avoid conflicts + # TODO: Clean this up. It is only used by the RGB module. + with self.intercept_lock: + self._evs = list(evs) + + def inject_recv(self): + with self.intercept_lock: + if not self.cid: + # Avoid writing events if no controller is connected + return [] + + if not self._evs: + return [] + + curr = time.time() + removed = [] + tmp = [] + for i, (ev, t) in enumerate(self._evs): + if curr >= t: + tmp.append(ev) + removed.insert(0, i) # prepend to remove in opposite order + + for i in removed: + self._evs.pop(i) + return tmp + + def set_capabilities(self, cid, cap: ControllerCapabilities | None): + with self.intercept_lock: + self._cap = cap + self.cid = cid + + def get_capabilities(self) -> dict[str, ControllerCapabilities]: + with self.intercept_lock: + if self._cap: + return {self.cid: self._cap} + return {} + + def __call__(self, event: SpecialEvent | Sequence[SpecialEvent]) -> None: + pass + + +class TouchpadCorrection(NamedTuple): + x_mult: float = 1 + x_ofs: float = 0 + x_clamp: tuple[float, float] = (0, 1) + y_mult: float = 1 + y_ofs: float = 0 + y_clamp: tuple[float, float] = (0, 1) + + +TouchpadCorrectionType = Literal[ + "stretch", + "crop_center", + "crop_start", + "crop_end", + "contain_start", + "contain_end", + "contain_center", + "left", + "right", + "center", + "legos", + "disabled", +] + + +def correct_touchpad( + width: int, height: int, aspect: float, method: TouchpadCorrectionType +): + dst = width / height + src = aspect + ratio = dst / src + + match method: + case "left": + if ratio > 2: + new_width = width / ratio + return TouchpadCorrection( + x_mult=new_width, + x_ofs=0, + y_mult=height, + y_ofs=0, + ) + else: + new_height = height * ratio / 2 + return TouchpadCorrection( + x_mult=width / 2, + x_ofs=0, + y_mult=new_height, + y_ofs=(height - new_height), + ) + case "right" | "legos": + if ratio > 2: + new_width = width / ratio + return TouchpadCorrection( + x_mult=new_width, + x_ofs=(width - new_width), + y_mult=height, + y_ofs=0, + ) + else: + new_height = height * ratio / 2 + return TouchpadCorrection( + x_mult=width / 2, + x_ofs=width / 2, + y_mult=new_height, + y_ofs=(height - new_height), + ) + case "center": + if ratio > 1: + new_width = width / ratio + return TouchpadCorrection( + x_mult=new_width, + x_ofs=(width - new_width) / 2, + y_mult=height, + y_ofs=0, + ) + else: + new_height = height * ratio + return TouchpadCorrection( + x_mult=width, + x_ofs=0, + y_mult=new_height, + y_ofs=(height - new_height) / 2, + ) + case "crop_center": + if ratio > 1: + new_width = width / ratio + return TouchpadCorrection( + x_mult=new_width, + x_ofs=(width - new_width) / 2, + y_mult=height, + y_ofs=0, + ) + else: + new_height = height * ratio + return TouchpadCorrection( + x_mult=width, + x_ofs=0, + y_mult=new_height, + y_ofs=(height - new_height) / 2, + ) + case "crop_start": + if ratio > 1: + new_width = width / ratio + return TouchpadCorrection( + x_mult=new_width, + x_ofs=0, + y_mult=height, + y_ofs=0, + ) + else: + new_height = height * ratio + return TouchpadCorrection( + x_mult=width, + x_ofs=0, + y_mult=new_height, + y_ofs=0, + ) + case "crop_end": + if ratio > 1: + new_width = width / ratio + return TouchpadCorrection( + x_mult=new_width, + x_ofs=(width - new_width), + y_mult=height, + y_ofs=0, + ) + else: + new_height = height * ratio + return TouchpadCorrection( + x_mult=width, + x_ofs=0, + y_mult=new_height, + y_ofs=(height - new_height), + ) + case "contain_center": + if ratio > 1: + bound = (ratio - 1) / ratio / 2 + return TouchpadCorrection( + x_mult=width, y_mult=height, y_clamp=(bound, 1 - bound) + ) + else: + bound = (1 - ratio) / 2 + return TouchpadCorrection( + x_mult=width, y_mult=height, x_clamp=(bound, 1 - bound) + ) + case "contain_start": + if ratio > 1: + bound = (ratio - 1) / ratio + return TouchpadCorrection( + x_mult=width, y_mult=height, y_clamp=(0, 1 - bound) + ) + else: + bound = (1 - ratio) / 2 + return TouchpadCorrection( + x_mult=width, y_mult=height, x_clamp=(0, 1 - bound) + ) + case "contain_end": + if ratio > 1: + bound = (ratio - 1) / ratio + return TouchpadCorrection( + x_mult=width, y_mult=height, y_clamp=(bound, 1) + ) + else: + bound = (1 - ratio) / 2 + return TouchpadCorrection( + x_mult=width, y_mult=height, x_clamp=(bound, 1) + ) + case "stretch" | "disabled": + return TouchpadCorrection(x_mult=width, y_mult=height) + # case "legos": + # return TouchpadCorrection(x_mult=800, y_mult=800, x_ofs=1055, y_ofs=140) + + logger.error(f"Touchpad correction method '{method}' not found.") + return TouchpadCorrection(x_mult=width, y_mult=height) class Producer: @@ -145,11 +491,13 @@ def close(self, exit: bool) -> bool: """Called to close the device. If `exit` is true, the program is about to - close. If it is false, the controller is entering power save mode because - it is unused. In this case, if this service is required, you may forgo - closing and return false. If true, it is assumed this producer is closed. + close. If it is false, the controller may be performing a configuration + change. - `open()` will be called again once the consumers are ready.""" + In the first versions of Handheld Daemon, this API was meant to be used + for the controller to enter power saving mode. However, it turns out + that steam and the kernel do not let the controller disconnect, + so it was repurposed to skip controller hiding.""" return False def produce(self, fds: Sequence[int]) -> Sequence[Event]: @@ -171,20 +519,66 @@ def consume(self, events: Sequence[Event]): pass +TouchpadAction = Literal["disabled", "left_click", "right_click"] + + class Multiplexer: - QAM_DELAY = 0.2 + QAM_HOLD_TIME = 0.4 + QAM_MULTI_PRESS_DELAY = 0.2 + QAM_TAP_TIME = 0.04 + QAM_DELAY = 0.15 + REBOOT_HOLD_SELECT = 9 + REBOOT_HOLD_TURBO = 4 + REBOOT_VIBRATION_STRENGTH = 1 + REBOOT_VIBRATION_ON = 0.4 + REBOOT_VIBRATION_OFF = 1.2 + REBOOT_VIBRATION_NUM = 3 + STEAM_CHECK_INTERVAL = 3 + STARTSELECT_TRIGGER_THRESHOLD = 0.6 def __init__( self, - swap_guide: None | Literal["guide_is_start", "guide_is_select"] = None, + swap_guide: ( + None + | Literal[ + "guide_is_start", + "guide_is_select", + "select_is_guide", + "start_is_keyboard", + ] + ) = None, trigger: None | Literal["analog_to_discrete", "discrete_to_analogue"] = None, - dpad: None | Literal["analog_to_discrete"] = None, + dpad: ( + None + | Literal["analog_to_discrete"] + | Literal["discrete_to_analog"] + | Literal["both"] + ) = None, led: None | Literal["left_to_main", "right_to_main", "main_to_sides"] = None, - touchpad: None - | Literal["left_to_main", "right_to_main", "main_to_sides"] = None, + touchpad: ( + None | Literal["left_to_main", "right_to_main", "main_to_sides"] + ) = None, status: None | Literal["both_to_main"] = None, share_to_qam: bool = False, trigger_discrete_lvl: float = 0.99, + touchpad_short: TouchpadAction = "disabled", + touchpad_right: TouchpadAction = "left_click", + touchpad_hold: TouchpadAction = "disabled", + r3_to_share: bool = False, + select_reboots: bool = False, + share_reboots: bool = False, + nintendo_mode: bool = False, + qam_button: str | None = None, + emit: ControllerEmitter | None = None, + imu: None | Literal["left_to_main", "right_to_main", "main_to_sides"] = None, + params: Mapping[str, Any] = {}, + qam_multi_tap: bool = True, + qam_no_release: bool = False, + qam_hhd: bool = False, + qam_hold: Literal["hhd", "mode"] = "hhd", + keyboard_is: Literal["steam_qam", "qam", "keyboard"] = "keyboard", + keyboard_no_release: bool = False, + startselect_chord: str = "disabled", ) -> None: self.swap_guide = swap_guide self.trigger = trigger @@ -193,24 +587,284 @@ def __init__( self.touchpad = touchpad self.status = status self.trigger_discrete_lvl = trigger_discrete_lvl - self.share_to_qam = share_to_qam + self.touchpad_short = touchpad_short + self.touchpad_hold = touchpad_hold + self.touchpad_right = touchpad_right + self.reboot_button = None + if select_reboots: + self.reboot_button = "select" + self.reboot_time = self.REBOOT_HOLD_SELECT + if share_reboots: + self.reboot_button = "share" + self.reboot_time = self.REBOOT_HOLD_TURBO + self.r3_to_share = r3_to_share + self.nintendo_mode = nintendo_mode + self.emit = emit + self.send_xbox_b = None + self.imu = imu + self.qam_hhd = qam_hhd + self.qam_hold = qam_hold + self.keyboard_is = keyboard_is + self.keyboard_no_release = keyboard_no_release + self.startselect_chord = startselect_chord + self.startselect_pressed = None self.state = {} - self.queue: list[tuple[Event, float]] = [] - + self.touchpad_x = 0 + self.touchpad_y = 0 + self.touchpad_down = None + self.queue: list[tuple[Event | Literal["reboot"], float]] = [] + self.reboot_pressed = None + self.select_is_held = False + self.reboot_is_held = False + self.qam_kbd = False + self.qam_button = qam_button + if share_to_qam: + self.qam_button = "share" + self.has_qam = params.get("has_qam", False) + + self.noob_mode = params.get("noob_mode", False) + self.qam_pressed = None + self.qam_pre_sent = False + self.qam_released = None + self.qam_times = 0 + self.qam_multi_tap = qam_multi_tap + self.qam_no_release = qam_no_release + self.qam_simple = os.environ.get("HHD_QAM_MULTI_DISABLE", None) or ( + self.emit and self.emit.simple_qam() + ) + self.guide_pressed = False + self.steam_check = params.get("steam_check", None) + self.steam_check_last = time.perf_counter() + self.steam_check_fn = params.get("steam_check_fn", None) + self.nintendo_qam = params.get("nintendo_qam", False) + self.open_steam_kbd = params.get("steam_kbd", lambda open: False) + + self.unique = str(time.perf_counter_ns()) assert touchpad is None, "touchpad rewiring not supported yet" - def process(self, events: Sequence[Event]): + uses_rgb: bool = params.get("rgb_used", False) + rgb_modes: dict[RgbMode, Sequence[RgbSettings]] | None = params.get( + "rgb_modes", None + ) + rgb_zones: RgbZones = params.get("rgb_zones", "mono") + if self.emit: + rgb = None + if rgb_modes: + rgb: RgbCapabilities | None = { + "modes": rgb_modes, + "controller": uses_rgb, + "zones": rgb_zones, + } + self.emit.set_capabilities( + self.unique, + { + "buttons": {}, + "rgb": rgb, + "supports_qam": params.get("supports_qam", True), + }, + ) + + def process(self, events: Sequence[Event]) -> Sequence[Event]: out: list[Event] = [] status_events = set() + touched = False + send_steam_qam = False + send_steam_expand = False curr = time.perf_counter() + + # Send old events while len(self.queue) and self.queue[0][1] < curr: - out.append(self.queue.pop(0)[0]) + ev = self.queue.pop(0)[0] + if ev == "reboot": + if self.reboot_is_held: + try: + import os + + os.system("systemctl reboot") + logger.info("rebooting") + except Exception as e: + logger.error(f"Rebooting failed with error:\n{type(e)}:{e}") + elif self.reboot_is_held or not ev.get("from_reboot", False): + out.append({**ev, "from_queue": True}) # type: ignore + + # Check for steam for touchpad emulation + if ( + self.steam_check_fn + and self.steam_check is not None + and self.steam_check_last + Multiplexer.STEAM_CHECK_INTERVAL < curr + ): + self.steam_check_last = curr + if self.steam_check: + msg = "Gamepadui closed. Restarting controller to disable touchpad emulation." + else: + msg = "Gamepadui launched. Restarting controller to enable touchpad emulation." + + assert self.steam_check_fn() == self.steam_check, msg + + if self.reboot_pressed and self.reboot_pressed + self.reboot_time < curr: + self.reboot_pressed = None + for i in range(self.REBOOT_VIBRATION_NUM): + self.queue.append( + ( + { + "type": "rumble", + "code": "main", + "strong_magnitude": self.REBOOT_VIBRATION_STRENGTH, + "weak_magnitude": self.REBOOT_VIBRATION_STRENGTH, + "from_reboot": True, + }, # type: ignore + curr + + i * (self.REBOOT_VIBRATION_ON + self.REBOOT_VIBRATION_OFF), + ) + ) + self.queue.append( + ( + { + "type": "rumble", + "code": "main", + "strong_magnitude": 0, + "weak_magnitude": 0, + "from_reboot": True, + }, # type: ignore + curr + + i * (self.REBOOT_VIBRATION_ON + self.REBOOT_VIBRATION_OFF) + + self.REBOOT_VIBRATION_ON, + ) + ) + self.queue.append(("reboot", curr)) + + if ( + self.touchpad_hold != "disabled" + and self.touchpad_down + and self.touchpad_down[3] + and curr - self.touchpad_down[0] > 0.8 + ): + action = ( + "touchpad_left" + if self.touchpad_hold == "left_click" + else "touchpad_right" + ) + self.queue.append( + ( + { + "type": "button", + "code": action, + "value": True, + }, + curr, + ) + ) + self.queue.append( + ( + { + "type": "button", + "code": action, + "value": False, + }, + curr + self.QAM_DELAY, + ) + ) + self.touchpad_down = None + elif self.touchpad_down and ( + abs(self.touchpad_down[1] - self.touchpad_x) > 0.13 + or abs(self.touchpad_down[2] - self.touchpad_y) > 0.13 + ): + self.touchpad_down[3] = False for ev in events: match ev["type"]: case "axis": + match self.imu: + case "left_to_main": + match ev["code"]: + case "left_accel_x": + ev["code"] = "accel_x" + case "left_accel_y": + ev["code"] = "accel_y" + case "left_accel_z": + ev["code"] = "accel_z" + case "left_gyro_x": + ev["code"] = "gyro_x" + case "left_gyro_y": + ev["code"] = "gyro_y" + case "left_gyro_z": + ev["code"] = "gyro_z" + case "left_imu_ts": + ev["code"] = "imu_ts" + case "right_to_main": + match ev["code"]: + case "right_accel_x": + ev["code"] = "accel_x" + case "right_accel_y": + ev["code"] = "accel_y" + case "right_accel_z": + ev["code"] = "accel_z" + case "right_gyro_x": + ev["code"] = "gyro_x" + case "right_gyro_y": + ev["code"] = "gyro_y" + case "right_gyro_z": + ev["code"] = "gyro_z" + case "right_imu_ts": + ev["code"] = "imu_ts" + case "main_to_sides": + match ev["code"]: + case "accel_x": + ev["code"] = "right_accel_x" + ev["code"] = "left_accel_x" + case "accel_y": + ev["code"] = "right_accel_y" + ev["code"] = "left_accel_y" + case "accel_z": + ev["code"] = "right_accel_z" + ev["code"] = "left_accel_z" + case "gyro_x": + ev["code"] = "right_gyro_x" + ev["code"] = "left_gyro_x" + case "gyro_y": + ev["code"] = "right_gyro_y" + ev["code"] = "left_gyro_y" + case "gyro_z": + ev["code"] = "right_gyro_z" + ev["code"] = "left_gyro_z" + case "imu_ts": + ev["code"] = "right_imu_ts" + ev["code"] = "left_imu_ts" + + if ( + self.startselect_pressed == "wait" + and ev["code"] + in ( + "lt", + "rt", + "hat_x", + "hat_y", + ) + and abs(ev["value"]) > self.STARTSELECT_TRIGGER_THRESHOLD + ): + out.append( + { + "type": "button", + "code": "mode", + "value": True, + } + ) + self.startselect_pressed = "pressed" + if self.startselect_pressed == "pressed": + self.queue.append( + ( + { + "type": "axis", + "code": ev["code"], + "value": ev["value"], + }, + curr + self.QAM_DELAY, + ) + ) + ev["code"] = "" # type: ignore + if self.trigger == "analog_to_discrete" and ev["code"] in ( "lt", "rt", @@ -223,28 +877,36 @@ def process(self, events: Sequence[Event]): } ) - if self.dpad == "analog_to_discrete" and ev["code"] in ( + if ( + self.dpad == "analog_to_discrete" or self.dpad == "both" + ) and ev["code"] in ( "hat_x", "hat_y", ): out.append( { "type": "button", - "code": "dpad_up" - if ev["code"] == "hat_y" - else "dpad_right", + "code": ( + "dpad_down" + if ev["code"] == "hat_y" + else "dpad_right" + ), "value": ev["value"] > 0.5, } ) out.append( { "type": "button", - "code": "dpad_down" - if ev["code"] == "hat_y" - else "dpad_left", + "code": ( + "dpad_up" if ev["code"] == "hat_y" else "dpad_left" + ), "value": ev["value"] < -0.5, } ) + if ev["code"] == "touchpad_x": + self.touchpad_x = ev["value"] + if ev["code"] == "touchpad_y": + self.touchpad_y = ev["value"] case "button": if self.trigger == "discrete_to_analog" and ev["code"] in ( "lt", @@ -258,44 +920,267 @@ def process(self, events: Sequence[Event]): } ) + if ev["code"] == "select": + if ev["value"]: + self.select_is_held = True + else: + self.select_is_held = False + + if self.reboot_button and ev["code"] == self.reboot_button: + if ev["value"]: + self.reboot_pressed = curr + self.reboot_is_held = True + else: + self.reboot_pressed = None + self.reboot_is_held = False + if self.swap_guide and ev["code"] in ( "start", "select", "mode", "share", + "keyboard", ): match ev["code"]: + # TODO: Refactor the logic of this file, + # the arguments do not make sense. case "start": - ev["code"] = "mode" + match self.swap_guide: + case "start_is_keyboard": + ev["code"] = "keyboard" + case "select_is_guide": + ev["code"] = "share" + case _: + ev["code"] = "mode" case "select": - ev["code"] = "share" + match self.swap_guide: + case "start_is_keyboard": + ev["code"] = "mode" + case "select_is_guide": + ev["code"] = "mode" + case _: + ev["code"] = "share" case "mode": if self.swap_guide == "guide_is_start": ev["code"] = "start" else: ev["code"] = "select" case "share": - if self.swap_guide == "guide_is_start": - ev["code"] = "select" - else: + match self.swap_guide: + case "start_is_keyboard": + pass + case "guide_is_start": + ev["code"] = "select" + case _: + ev["code"] = "start" + case "keyboard": + if self.swap_guide == "start_is_keyboard": ev["code"] = "start" - if self.share_to_qam and ev["code"] == "share": + if ( + self.startselect_chord != "disabled" and ev["code"] == "select" + ) or ( + self.startselect_chord == "start_select" + and ev["code"] == "start" + ): + if self.startselect_pressed == "pressed": + self.queue.append( + ( + { + "type": "button", + "code": "mode", + "value": False, + }, + curr + self.QAM_DELAY, + ) + ) + self.startselect_pressed = None + if ev["value"]: - ev["code"] = "mode" + self.startselect_pressed = "wait" + elif self.startselect_pressed == "wait": + self.startselect_pressed = None + out.append( + { + "type": "button", + "code": ev["code"], + "value": True, + } + ) self.queue.append( ( { "type": "button", - "code": "a", - "value": True, + "code": ev["code"], + "value": False, }, curr + self.QAM_DELAY, ) ) + ev["code"] = "" # type: ignore + + if self.emit and ev["code"] == "mode": + # Steam might do weirdness, emit an event to prepare + # the overlay + self.guide_pressed = ev["value"] + if ev["value"]: + self.emit({"type": "special", "event": "guide"}) + + if ( + self.dpad == "discrete_to_analog" or self.dpad == "both" + ) and ev["code"] in ( + "dpad_up", + "dpad_down", + "dpad_left", + "dpad_right", + ): + # FIXME: To be done properly you'd need to save the previous + # state so that if going from -1 to 1 in one go it would be + # preserved. Since this is only used for the legion go + # passthrough that is not an issue. + match ev["code"]: + case "dpad_up": + code = "hat_y" + val = -1 + case "dpad_down": + code = "hat_y" + val = 1 + case "dpad_right": + code = "hat_x" + val = 1 + case "dpad_left": + code = "hat_x" + val = -1 + + out.append( + { + "type": "axis", + "code": code, + "value": ev["value"] * val, + } + ) + + if ( + self.qam_button is not None and ev["code"] == self.qam_button + ) or (self.keyboard_is == "qam" and ev["code"] == "keyboard"): + self.qam_kbd = ev["code"] == "keyboard" + ev["code"] = "" # type: ignore + if not self.qam_simple: + if (not self.qam_kbd and self.qam_no_release) or ( + self.qam_kbd and self.keyboard_no_release + ): + # Fix for the ally having no hold event + if ev["value"]: + self.qam_times += 1 + self.qam_released = curr + self.QAM_TAP_TIME + self.qam_pressed = None + else: + if ev["value"]: + self.qam_times += 1 + self.qam_pressed = curr + self.qam_released = None + else: + # Only apply if qam_pressed was not yanked + if self.qam_pressed: + self.qam_released = curr + self.qam_pressed = None + else: + if self.has_qam: + out.append( + { + "type": "button", + "code": "share", + "value": ev["value"], + }, + ) + else: + if ev["value"]: + out.append( + { + "type": "button", + "code": "mode", + "value": True, + }, + ) + self.queue.append( + ( + { + "type": "button", + "code": ( + "b" if self.nintendo_qam else "a" + ), + "value": True, + }, + curr + self.QAM_DELAY, + ), + ) + self.queue.append( + ( + { + "type": "button", + "code": ( + "b" if self.nintendo_qam else "a" + ), + "value": False, + }, + curr + 2 * self.QAM_DELAY, + ), + ) + self.queue.append( + ( + { + "type": "button", + "code": "mode", + "value": False, + }, + curr + 2 * self.QAM_DELAY, + ), + ) + + if ev["code"] == "keyboard": + if ev["value"]: + if self.keyboard_is == "steam_qam": + logger.info(f"Keyboard button opens QAM.") + send_steam_qam = True + elif self.keyboard_is == "keyboard": + self.open_steam_kbd(True) + ev["code"] = "" + + if self.noob_mode and ev["code"] == "extra_l1" and ev["value"]: + ev["code"] = "" # type: ignore + if self.open_steam_kbd(True): + logger.info(f"Opened steam keyboard directly.") else: - # TODO: Clean this up - ev["code"] = "" # type: ignore + logger.warning( + f"Could not open steam keyboard directly. Sending chord." + ) + out.append( + { + "type": "button", + "code": "mode", + "value": True, + }, + ) + self.queue.append( + ( + { + "type": "button", + "code": "y" if self.nintendo_qam else "x", + "value": True, + }, + curr + self.QAM_DELAY, + ) + ) + self.queue.append( + ( + { + "type": "button", + "code": "y" if self.nintendo_qam else "x", + "value": False, + }, + curr + 2 * self.QAM_DELAY, + ), + ) self.queue.append( ( { @@ -303,26 +1188,127 @@ def process(self, events: Sequence[Event]): "code": "mode", "value": False, }, - curr + self.QAM_DELAY, + curr + 2 * self.QAM_DELAY, ), ) + + if self.noob_mode and ev["code"] == "extra_r1" and ev["value"]: + ev["code"] = "" + if self.emit: + self.emit({"type": "special", "event": "overlay"}) + + if ev["code"] == "touchpad_right": + match self.touchpad_right: + case "disabled": + # TODO: Cleanup + ev["code"] = "" # type: ignore + case "left_click": + ev["code"] = "touchpad_left" + case "right_click": + pass + + if ev["code"] == "touchpad_touch": + if ( + self.touchpad_short != "disabled" + and not ev["value"] + and self.touchpad_down + and curr - self.touchpad_down[0] < 0.2 + and abs(self.touchpad_down[1] - self.touchpad_x) < 0.04 + and abs(self.touchpad_down[2] - self.touchpad_y) < 0.04 + ): + action = ( + "touchpad_left" + if self.touchpad_short == "left_click" + else "touchpad_right" + ) + self.queue.append( + ( + { + "type": "button", + "code": action, + "value": True, + }, + curr, + ) + ) self.queue.append( ( { "type": "button", - "code": "a", + "code": action, "value": False, }, curr + self.QAM_DELAY, - ), + ) ) + if ev["value"]: + touched = True + else: + self.touchpad_down = None # append A after QAM_DELAY s - # TODO: Make it a proper config option - # Remap M2 to the mute button - if ev["code"] == "extra_r3": + if self.r3_to_share and ev["code"] == "extra_r3": ev["code"] = "share" + + if self.nintendo_mode: + match ev["code"]: + case "a": + ev["code"] = "b" + case "b": + ev["code"] = "a" + case "x": + ev["code"] = "y" + case "y": + ev["code"] = "x" + + # Assume we own Xbox + Y if the user is not using the recording feature + if ( + (self.guide_pressed or self.select_is_held) + and self.emit + and ev["code"] == "y" + and ev["value"] + ): + self.emit({"type": "special", "event": "xbox_y"}) + + # Assume we can only use Xbox + B for short presses + if ( + (self.guide_pressed or self.select_is_held) + and self.emit + and ev["code"] == "b" + ): + if ev["value"]: + self.send_xbox_b = time.time() + else: + if ( + self.send_xbox_b + and time.time() - self.send_xbox_b < 0.3 + ): + self.emit({"type": "special", "event": "xbox_b"}) + self.send_xbox_b = None + + # Apply start/select qam + if self.startselect_pressed == "wait" and ev["code"]: + out.append( + { + "type": "button", + "code": "mode", + "value": True, + } + ) + self.startselect_pressed = "pressed" + if self.startselect_pressed == "pressed": + self.queue.append( + ( + { + "type": "button", + "code": ev["code"], + "value": ev["value"], + }, + curr + self.QAM_DELAY, + ) + ) + ev["code"] = "" # type: ignore case "led": if self.led == "left_to_main" and ev["code"] == "left": out.append({**ev, "code": "main"}) @@ -342,6 +1328,14 @@ def process(self, events: Sequence[Event]): case "is_connected_left" | "is_connected_right": status_events.add("is_connected") + if touched: + self.touchpad_down = [ + curr, + self.touchpad_x, + self.touchpad_y, + bool(True), + ] + for s in status_events: match s: case "battery": @@ -378,9 +1372,243 @@ def process(self, events: Sequence[Event]): } ) - out.extend(events) + # Remove empty events + for ev in events: + if ev["type"] != "button" or ev["code"]: + out.append(ev) + + # Handle QAM button + # Below is the multitap implementation + # If it was disabled, the code is a NO-OP + qam_apply = False + was_held = True + # Apply hold + if self.qam_pressed and curr - self.qam_pressed > self.QAM_HOLD_TIME: + qam_apply = True + # Apply double tap + if self.qam_released and ( + curr - self.qam_released > self.QAM_MULTI_PRESS_DELAY + ): + qam_apply = True + # Apply if double tap disabled + if not self.qam_multi_tap and self.qam_released: + qam_apply = True + + qam_hhd = self.qam_hhd and not self.qam_kbd + if ( + self.qam_pressed + and self.qam_times == (1 if qam_hhd else 2) + and not self.qam_pre_sent + and self.emit + ): + # Send event instantly after double press to eat delay + self.emit({"type": "special", "event": "qam_predouble"}) + self.qam_pre_sent = True + + send_steam_qam = send_steam_qam or ( + qam_apply and not qam_hhd and self.qam_released and self.qam_times == 1 + ) + send_steam_expand = ( + qam_apply and self.qam_pressed and was_held and self.qam_hold == "mode" + ) + if qam_apply and self.emit: + if qam_hhd: + match self.qam_times: + case 0: + pass + case 1: + self.emit({"type": "special", "event": "qam_double"}) + case _: + self.emit({"type": "special", "event": "qam_triple"}) + else: + # FIXME: hiding the event based on qam_hold should not happen + # instead the handler should not open hhd + if self.qam_pressed and was_held and self.qam_hold == "hhd": + self.emit({"type": "special", "event": "qam_hold"}) + else: + match self.qam_times: + case 0: + pass + case 1: + self.emit({"type": "special", "event": "qam_single"}) + case 2: + self.emit({"type": "special", "event": "qam_double"}) + case _: + self.emit({"type": "special", "event": "qam_triple"}) + if qam_apply: + held = " then held" if self.qam_pressed else "" + logger.info(f"QAM Pressed {self.qam_times}{held}.") + self.qam_pressed = None + self.qam_released = None + self.qam_pre_sent = False + self.qam_times = 0 + + if self.emit: + evs = self.emit.inject_recv() + # Handle special case for steam + for ev in evs: + if ev["type"] == "configuration" and ev["code"] == "steam": + if ev["value"]: + send_steam_expand = True + else: + send_steam_qam = True + out.extend(evs) + + # Grab all events from controller if grab is on + # Remove queued events such as qam and xbox to avoid leaking them + # to the overlay + if self.emit and self.emit.intercept( + self.unique, [o for o in out if not o.get("from_queue", False)] + ): + # Hiding the gyro and sending fake accel values causes + # issues with emulators and gyro + # accel = random.random() * 10 + # fake_accel: Sequence[Event] = [ + # {"type": "axis", "code": "accel_x", "value": accel}, + # {"type": "axis", "code": "left_accel_x", "value": accel}, + # {"type": "axis", "code": "right_accel_x", "value": accel}, + # ] + # return fake_accel + [ + # o + # for o in out + # if o["type"] not in ("button", "axis") or "ts" in o.get("code", "") + # ] + return [ + o + for o in out + if o["type"] not in ("button", "axis") + or "ts" in o.get("code", "") + or "accel" in o.get("code", "") + or "gyro" in o.get("code", "") + ] + elif send_steam_qam: + # Send steam qam only if not intercepting + if not self.emit or not self.emit.send_qam(): + if self.has_qam: + out.append( + { + "type": "button", + "code": "share", + "value": True, + }, + ) + self.queue.append( + ( + { + "type": "button", + "code": "share", + "value": False, + }, + curr + self.QAM_DELAY, + ) + ) + else: + # Have a fallback if gamescope is not working + out.append( + { + "type": "button", + "code": "mode", + "value": True, + }, + ) + self.queue.append( + ( + { + "type": "button", + "code": "b" if self.nintendo_qam else "a", + "value": True, + }, + curr + self.QAM_DELAY, + ) + ) + self.queue.append( + ( + { + "type": "button", + "code": "b" if self.nintendo_qam else "a", + "value": False, + }, + curr + 2 * self.QAM_DELAY, + ), + ) + self.queue.append( + ( + { + "type": "button", + "code": "mode", + "value": False, + }, + curr + 2 * self.QAM_DELAY, + ), + ) + elif send_steam_expand: + out.append( + { + "type": "button", + "code": "mode", + "value": True, + }, + ) + self.queue.append( + ( + { + "type": "button", + "code": "mode", + "value": False, + }, + curr + self.QAM_DELAY, + ) + ) + return out + + +class KeyboardWrapper(Producer, Consumer): + def __init__( + self, parent: Producer, button_map: Sequence[tuple[set[Button], Button]] + ) -> None: + self.parent = parent + self.button_map = button_map + + self.active_in: set[Button] = set() + self.active_out: set[Button] = set() + + def open(self) -> Sequence[int]: + self.active_in: set[Button] = set() + self.active_out: set[Button] = set() + return self.parent.open() + + def close(self, exit: bool) -> bool: + return self.parent.close(exit) + + def produce(self, fds: Sequence[int]) -> Sequence[Event]: + evs: Sequence[Event] = self.parent.produce(fds) + # Update in map + for ev in evs: + logger.info(f"Internal kbd event: {ev}") + if ev["type"] == "button": + if ev["value"]: + self.active_in.add(ev["code"]) + elif ev["code"] in self.active_in: + self.active_in.remove(ev["code"]) + + # Debounce and output + out: Sequence[Event] = [] + for bset, action in self.button_map: + is_sub = bset.issubset(self.active_in) + is_active = action in self.active_out + if is_sub and not is_active: + self.active_out.add(action) + out.append({"type": "button", "code": action, "value": True}) + elif not is_sub and is_active: + self.active_out.remove(action) + out.append({"type": "button", "code": action, "value": False}) + return out + def consume(self, events: Sequence[Event]): + if isinstance(self.parent, Consumer): + return self.parent.consume(events) + def can_read(fd: int): return select.select([fd], [], [], 0)[0] diff --git a/src/hhd/controller/const.py b/src/hhd/controller/const.py index 1489e3f7..5343d9a5 100644 --- a/src/hhd/controller/const.py +++ b/src/hhd/controller/const.py @@ -20,13 +20,14 @@ "accel_x", "accel_y", "accel_z", - "accel_ts", + "accel_ts", # deprecated # Gyroscope # Values should be in deg/s "gyro_x", "gyro_y", "gyro_z", - "gyro_ts", + "gyro_ts", # deprecated + "imu_ts", # Touchpad # Both width and height should go from [0, 1]. Aspect ratio is a setting. # It is up to the device whether to stretch, crop and how to crop (either @@ -41,6 +42,7 @@ "left_gyro_x", "left_gyro_y", "left_gyro_z", + "left_imu_ts", "left_touchpad_x", "left_touchpad_y", # Right @@ -50,6 +52,7 @@ "right_gyro_x", "right_gyro_y", "right_gyro_z", + "right_imu_ts", "right_touchpad_x", "right_touchpad_y", ] @@ -89,8 +92,10 @@ # Misc "mode", "share", + # Touchpad "touchpad_touch", - "touchpad_click", + "touchpad_left", + "touchpad_right", ] MouseButton = Literal["btn_left", "btn_right", "btn_middle", "btn_side", "btn_extra"] @@ -270,10 +275,13 @@ "key_yen", # 124 # ? "key_unknown", # 240, + # Prog for the ally + "key_prog1", + "key_prog2", ] Axis = AbsAxis | RelAxis -Button = GamepadButton | KeyboardButton | MouseButton +Button = Literal["", "keyboard"] | GamepadButton | KeyboardButton | MouseButton Configuration = Literal[ # Misc @@ -291,4 +299,6 @@ "is_connected_right", "is_attached_left", "is_attached_right", + # Commands + "steam", ] diff --git a/src/hhd/controller/lib/ccache.py b/src/hhd/controller/lib/ccache.py new file mode 100644 index 00000000..3372b178 --- /dev/null +++ b/src/hhd/controller/lib/ccache.py @@ -0,0 +1,97 @@ +from threading import Condition, Thread, Event +import time +import random + +CACHE_TIMEOUT = 20 +UPDATE_FREQ = 25 +UPDATE_T = 1 / UPDATE_FREQ + + +class ControllerCache: + def __init__(self, cache_timeout=CACHE_TIMEOUT) -> None: + self._t = None + self._cond = Condition() + self._cached = None + self._should_exit = Event() + self.cache_timeout = cache_timeout + + def _close_cached(self): + with self._cond: + start = time.perf_counter() + curr = time.perf_counter() + while curr - start < self.cache_timeout and not self._should_exit.is_set(): + self._cond.wait(UPDATE_T) + next = time.perf_counter() + if self._cached: + # Send fake events to keep everyone happy + # Both steam and kernel + self._cached.produce([self._cached.fd]) + # Bridge timestamps to prevent jump after the error + # when disconnecting and reconnecting the controller + # provided the imu timestamp does not break after reloading + # Use .95 to prevent racing ahead of the IMU ts + if hasattr(self._cached, "last_imu_ts"): + ctime = getattr(self._cached, "last_imu_ts") + int( + (next - curr) * 0.95 * 1e9 + ) + else: + ctime = int(next * 1e9) + + # Send a lot of noise to the accel value to avoid + # steam recalibrating. Only RPCS3 and dolphin might + # have an issue with this. + accel = random.random() * 10 + self._cached.consume( + [ + {"type": "axis", "code": "left_imu_ts", "value": ctime}, + {"type": "axis", "code": "right_imu_ts", "value": ctime}, + {"type": "axis", "code": "imu_ts", "value": ctime}, + {"type": "axis", "code": "accel_x", "value": accel}, + {"type": "axis", "code": "left_accel_x", "value": accel}, + {"type": "axis", "code": "right_accel_x", "value": accel}, + ] + ) + else: + # Exit if cached became null during sleep + break + curr = next + if self._cached: + self._cached.close(True, in_cache=True) + self._cached = None + + def add(self, c): + tmp = None + with self._cond: + if self._t: + self._should_exit.set() + self._cond.notify_all() + tmp = self._t + self._t = None + if tmp: + tmp.join() + + with self._cond: + self._cached = c + self._should_exit.clear() + self._t = Thread(target=self._close_cached) + self._t.start() + + def get(self): + with self._cond: + tmp = self._cached + self._cached = None + self._should_exit.set() + self._cond.notify_all() + tmp2 = self._t + self._t = None + if tmp2: + tmp2.join() + return tmp + + def close(self): + with self._cond: + if self._cached: + self._cached.close(True, in_cache=True) + self._cached = None + self._should_exit.set() + self._cond.notify_all() diff --git a/src/hhd/controller/lib/common.py b/src/hhd/controller/lib/common.py index 23f5a5fe..77b71b91 100644 --- a/src/hhd/controller/lib/common.py +++ b/src/hhd/controller/lib/common.py @@ -24,6 +24,7 @@ class AM(NamedTuple): scale: float | None = None offset: float = 0 flipped: bool = False + bounds: tuple[int, int] | None = None class CM(NamedTuple): @@ -82,7 +83,7 @@ def decode_axis(buff: bytes, t: AM): o = int.from_bytes( buff[t.loc >> 3 : (t.loc >> 3) + 1], t.order, signed=False ) - (1 << 7) - s = (1 << 7) - 1 + s = (1 << 7) case _: assert False, f"Invalid formatting {t.type}." @@ -103,6 +104,8 @@ def encode_axis(buff: bytearray, t: AM, val: float): if t.scale: new_val = int(t.scale * val + t.offset) + if t.bounds: + new_val = min(max(new_val, t.bounds[0]), t.bounds[1]) else: new_val = None @@ -121,7 +124,7 @@ def encode_axis(buff: bytearray, t: AM, val: float): ) case "m32": if not new_val: - new_val = int(((1 << 31) - 1) * val + (1 << 31)) + new_val = int(round(((1 << 31) - 1) * val + (1 << 31) - 1)) buff[t.loc >> 3 : (t.loc >> 3) + 4] = int.to_bytes( new_val, 4, t.order, signed=False ) @@ -139,7 +142,7 @@ def encode_axis(buff: bytearray, t: AM, val: float): ) case "m16": if not new_val: - new_val = int(((1 << 15) - 1) * val + (1 << 15)) + new_val = int(round(((1 << 15) - 1) * val + (1 << 15) - 1)) buff[t.loc >> 3 : (t.loc >> 3) + 2] = int.to_bytes( new_val, 2, t.order, signed=False ) @@ -157,7 +160,7 @@ def encode_axis(buff: bytearray, t: AM, val: float): ) case "m8": if not new_val: - new_val = int(((1 << 7) - 1) * val + (1 << 7)) + new_val = int(round(((1 << 7) - 1) * val + (1 << 7) - 1)) buff[t.loc >> 3 : (t.loc >> 3) + 1] = int.to_bytes( new_val, 1, t.order, signed=False ) diff --git a/src/hhd/controller/lib/hid.py b/src/hhd/controller/lib/hid.py index a4a3d799..324c0ade 100644 --- a/src/hhd/controller/lib/hid.py +++ b/src/hhd/controller/lib/hid.py @@ -1,4 +1,5 @@ -# Sourced from the library hid, modified. +# SPDX-License-Identifier: MIT and GPL-3.0-only +# Forked from https://github.com/apmorton/pyhidapi/blob/master/hid/__init__.py import os import ctypes import atexit @@ -167,11 +168,18 @@ def enumerate(vid=0, pid=0): return ret -def enumerate_unique(vid=0, pid=0): +def enumerate_unique(vid=0, pid=0, usage_page=0, usage=0): """Returns the current connected devices, sorted by path.""" return sorted( - list({v["path"]: v for v in enumerate(vid, pid)}.values()), + list( + { + v["path"]: v + for v in enumerate(vid, pid) + if (not usage_page or usage_page == v.get("usage_page", None)) + and (not usage or usage == v.get("usage", None)) + }.values() + ), key=lambda l: l["path"], ) diff --git a/src/hhd/controller/lib/hide.py b/src/hhd/controller/lib/hide.py index 6a97b93f..ac27b231 100644 --- a/src/hhd/controller/lib/hide.py +++ b/src/hhd/controller/lib/hide.py @@ -1,21 +1,30 @@ import subprocess import os +import logging +import threading +from .ioctl import EVIOCREVOKEALL, JSIOCREVOKEALL +from fcntl import ioctl -def get_syspath(devpath: str): +logger = logging.getLogger(__name__) + +ENHANCED_HIDING = os.environ.get("HHD_EVIOC_IOCTL", "0") == "1" +HIDE_ALL = os.environ.get("HHD_HIDE_ALL", "0") == "1" + +_hidden = [] + +def get_device_info(devpath: str): + syspath = None for line in subprocess.run( ["udevadm", "info", devpath], capture_output=True ).stdout.splitlines(): if line.startswith(b"P: "): return line[3:].decode() - return None + return syspath -def get_gamepad_name(devpath: str): - syspath = get_syspath(devpath) - if not syspath: - return None +def get_gamepad_name(syspath: str): parts = syspath.split("/") if len(parts) < 3: return None @@ -25,15 +34,15 @@ def get_gamepad_name(devpath: str): return input_dev -def get_parent_sysfs(devpath: str): - syspath = get_syspath(devpath) - if not syspath: - return None - +def get_parent_sysfs(syspath: str): return syspath[: syspath.rindex("/")] + # return syspath.split("/input/")[0] -def reload_children(parent: str): +_reload_thread = None + + +def _reload_children_worker(parent: str): stat = subprocess.run( ["udevadm", "control", "--reload-rules"], capture_output=True, @@ -42,7 +51,7 @@ def reload_children(parent: str): return False for action in ["remove", "add"]: stat = subprocess.run( - ["udevadm", "trigger", "--action", action, f"-b", parent], + ["udevadm", "trigger", "--action", action, "-b", parent], capture_output=True, ) if stat.returncode: @@ -50,38 +59,150 @@ def reload_children(parent: str): return True -def hide_gamepad(devpath: str): - input_dev = get_gamepad_name(devpath) - parent = get_parent_sysfs(devpath) +def reload_children(parent: str): + global _reload_thread + + if _reload_thread: + _reload_thread.join() + _reload_thread = None + + _reload_thread = threading.Thread(target=_reload_children_worker, args=(parent,)) + _reload_thread.start() + + +def hide_gamepad(devpath: str, vid: int, pid: int) -> str | None: + syspath = get_device_info(devpath) + if not syspath: + return None + input_dev = get_gamepad_name(syspath) + parent = get_parent_sysfs(syspath) if not input_dev or not parent: - return False + return None + + if HIDE_ALL: + # Hide all devices with the same vid pid + root = f"{vid:04x}-{pid:04x}" + # Certain devices emulate a USB Xbox controller. So match the USB bus + # to hopefully not affect bluetooth devices. + extra = 'ENV{ID_BUS}=="usb"' + else: + root = input_dev + extra = f'KERNELS=="{input_dev}", ' + + out_fn = f"/run/udev/rules.d/95-hhd-devhide-{root}.rules" + if os.path.exists(out_fn): + # Skip hiding controller on reloads + return input_dev rule = f"""\ # Hides device gamepad devices stemming from {input_dev} # Managed by HHD, this file will be autoremoved during configuration changes. -SUBSYSTEMS=="input", KERNELS=="{input_dev}", GOTO="hhd_valid" +SUBSYSTEMS=="input", {extra}ATTRS{{id/vendor}}=="{vid:04x}", ATTRS{{id/product}}=="{pid:04x}", GOTO="hhd_valid" GOTO="hhd_end" LABEL="hhd_valid" -KERNEL=="js[0-9]*|event[0-9]*", SUBSYSTEM=="input", MODE="000", GROUP="root", TAG="", RUN+="/bin/chmod 000 /dev/input/%k" +KERNEL=="js[0-9]*|event[0-9]*", SUBSYSTEM=="input", MODE="000", GROUP="root", TAG-="uaccess", RUN+="/bin/chmod 000 /dev/input/%k" LABEL="hhd_end" -""" # , RUN+="/bin/chmod 000 /sys/%p" +""" + + # # Hide usb xinput, be very careful to only match that usb + # if "/" in parent: + # usb_root = parent[parent.rindex("/") + 1 :] + # if re.match(r"\d-+\d+", usb_root) or re.match(r"\d+-\d+:\d+\.\d+", usb_root): + # rule += f""" + # # Hides the Xinput/Hidraw input node so that certain games that access it directly. + # SUBSYSTEMS=="usb", ATTRS{{idVendor}}=="{vid:04x}", ATTRS{{idProduct}}=="{pid:04x}",\ + # KERNEL=="{usb_root}", TAG-="uaccess", GROUP="root", MODE="000" + # """ + try: + # Add udev rules to strip the device perms from the system os.makedirs("/run/udev/rules.d/", exist_ok=True) - with open(f"/run/udev/rules.d/95-hhd-devhide-{input_dev}.rules", "w") as f: + with open(out_fn, "w") as f: f.write(rule) - return reload_children(parent) + # Reload the rules for that device to make it owned by root + reload_children(parent) + _hidden.append(parent) + + # Use flag until further testing + if not ENHANCED_HIDING: + return input_dev + + # Now that only we can access the device, revoke open fds + # Custom kernel feature. NOOP if it fails. + try: + for fn in os.listdir("/sys/" + parent): + if fn.startswith("event"): + ioc = EVIOCREVOKEALL + elif fn.startswith("js"): + ioc = JSIOCREVOKEALL + else: + continue + + fd = None + try: + dev = os.path.join("/dev/input", fn) + fd = os.open(dev, os.O_RDONLY) + ioctl(fd, ioc, 0) + logger.info(f"Revoked access to device '{dev}'.") + finally: + if fd: + os.close(fd) + except Exception as e: + logger.exception( + f"Failed to run EV/JSIOCREVOKEALL. Games may remember the controller. Error:\n{e}" + ) + + return input_dev except Exception: + return None + + +def unhide_gamepad(devpath: str, root: str | None = None): + if HIDE_ALL: + # Do not unhide device to be ready when the next one shows up return False + try: + # Remove file before searching for device + if root is not None: + os.remove(f"/run/udev/rules.d/95-hhd-devhide-{root}.rules") + except Exception: + return False -def unhide_gamepad(devpath: str): - input_dev = get_gamepad_name(devpath) + syspath = get_device_info(devpath) + if not syspath: + return False + input_dev = get_gamepad_name(syspath) parent = get_parent_sysfs(devpath) if not input_dev or not parent: return False + if parent in _hidden: + _hidden.remove(parent) + try: - os.remove(f"/run/udev/rules.d/95-hhd-devhide-{input_dev}.rules") + if root is None: + os.remove(f"/run/udev/rules.d/95-hhd-devhide-{input_dev}.rules") return reload_children(parent) except Exception: return False + + +def unhide_all(): + removed = False + try: + for rule in os.listdir("/run/udev/rules.d/"): + if rule.startswith("95-hhd-devhide"): + os.remove(os.path.join("/run/udev/rules.d/", rule)) + logger.info(f"Removed rule '{rule}'.") + removed = True + except Exception: + pass + + if not removed: + return True + + # We have to reload affected devices if we removed rules + for parent in _hidden: + reload_children(parent) + _hidden.clear() \ No newline at end of file diff --git a/src/hhd/controller/lib/ioctl.py b/src/hhd/controller/lib/ioctl.py new file mode 100644 index 00000000..788efd91 --- /dev/null +++ b/src/hhd/controller/lib/ioctl.py @@ -0,0 +1,262 @@ +# SPDX-License-Identifier: MIT and GPL-3.0-only +# Sourced from library python-ioctl +# https://github.com/olavmrk/python-ioctl +import ctypes +import platform + + +class _IoctlGeneric(object): + _IOC_NRBITS = 8 + _IOC_TYPEBITS = 8 + _IOC_SIZEBITS = 14 + _IOC_DIRBITS = 2 + _IOC_NONE = 0 + _IOC_WRITE = 1 + _IOC_READ = 2 + + @classmethod + def ioc(cls, direction, request_type, request_nr, size): + _IOC_NRSHIFT = 0 + _IOC_TYPESHIFT = _IOC_NRSHIFT + cls._IOC_NRBITS + _IOC_SIZESHIFT = _IOC_TYPESHIFT + cls._IOC_TYPEBITS + _IOC_DIRSHIFT = _IOC_SIZESHIFT + cls._IOC_SIZEBITS + return ( + (direction << _IOC_DIRSHIFT) + | (request_type << _IOC_TYPESHIFT) + | (request_nr << _IOC_NRSHIFT) + | (size << _IOC_SIZESHIFT) + ) + + +class _IoctlAlpha(_IoctlGeneric): + _IOC_NRBITS = 8 + _IOC_TYPEBITS = 8 + _IOC_SIZEBITS = 13 + _IOC_DIRBITS = 3 + _IOC_NONE = 1 + _IOC_READ = 2 + _IOC_WRITE = 4 + + +class _IoctlMips(_IoctlGeneric): + _IOC_SIZEBITS = 13 + _IOC_DIRBITS = 3 + _IOC_NONE = 1 + _IOC_READ = 2 + _IOC_WRITE = 4 + + +class _IoctlParisc(_IoctlGeneric): + _IOC_NONE = 0 + _IOC_WRITE = 2 + _IOC_READ = 1 + + +class _IoctlPowerPC(_IoctlGeneric): + _IOC_SIZEBITS = 13 + _IOC_DIRBITS = 3 + _IOC_NONE = 1 + _IOC_READ = 2 + _IOC_WRITE = 4 + + +class _IoctlSparc(_IoctlGeneric): + _IOC_NRBITS = 8 + _IOC_TYPEBITS = 8 + _IOC_SIZEBITS = 13 + _IOC_DIRBITS = 3 + _IOC_NONE = 1 + _IOC_READ = 2 + _IOC_WRITE = 4 + + +_machine_ioctl_map = { + "alpha": _IoctlAlpha, + "mips": _IoctlMips, + "mips64": _IoctlMips, + "parisc": _IoctlParisc, + "parisc64": _IoctlParisc, + "ppc": _IoctlPowerPC, + "ppcle": _IoctlPowerPC, + "ppc64": _IoctlPowerPC, + "ppc64le": _IoctlPowerPC, + "sparc": _IoctlSparc, + "sparc64": _IoctlSparc, +} + + +def _machine_ioctl_calculator(): + machine = platform.machine() + return _machine_ioctl_map.get(machine, _IoctlGeneric) + + +def _ioc_type_size(size): + if isinstance(size, type) and issubclass(size, ctypes._SimpleCData): + return ctypes.sizeof(size) + elif isinstance(size, int): + return size + else: + raise TypeError( + "Invalid type for size: {size_type}".format( + size_type=size.__class__.__name__ + ) + ) + + +def _ioc_request_type(request_type): + if isinstance(request_type, int): + return request_type + if isinstance(request_type, str): + if len(request_type) > 1: + raise ValueError("request_type string too long.") + elif len(request_type) == 0: + raise ValueError("request_type cannot be an empty string.") + return ord(request_type) + else: + raise ValueError( + "request_type must be an integer or a string, but was: {request_type_type}".format( + request_type_type=request_type.__class__.__name__ + ) + ) + + +def _IOC(direction, request_type, request_nr, size): + """Python implementation of the ``_IOC(...)`` macro from Linux. + + This is a portable implementation of the ``_IOC(...)`` macro from Linux. + It takes a set of parameters, and calculates a ioctl request number based on those parameters. + + :param direction: Direction of data transfer in this ioctl. This can be one of: + + * ``None``: No data transfer. + * ``'r'``: Read-only (input) data. + * ``'w'``: Write-only (output) data. + * ``'rw'``: Read-write (input and output) data. + :param request_type: The ioctl request type. This can be specified as either a string ``'R'`` or an integer ``123``. + :param request_nr: The ioctl request number. This is an integer. + :param size: The number of data bytes transferred in this ioctl. + :return: The calculated ioctl request number. + """ + + calc = _machine_ioctl_calculator() + + if direction is None: + direction = calc._IOC_NONE + elif direction == "r": + direction = calc._IOC_READ + elif direction == "w": + direction = calc._IOC_WRITE + elif direction == "rw": + direction = calc._IOC_READ | calc._IOC_WRITE + else: + raise ValueError("direction must be None, 'r', 'w' or 'rw'.") + + request_type = _ioc_request_type(request_type) + return calc.ioc(direction, request_type, request_nr, size) + + +def _IO(request_type, request_nr): + """Python implementation of the ``_IO(...)`` macro from Linux. + + This is a portable implementation of the ``_IO(...)`` macro from Linux. + The ``_IO(...)`` macro calculates a ioctl request number for ioctl request that do not transfer any data. + + :param request_type: The ioctl request type. This can be specified as either a string ``'R'`` or an integer ``123``. + :param request_nr: The ioctl request number. This is an integer. + :return: The calculated ioctl request number. + """ + + calc = _machine_ioctl_calculator() + request_type = _ioc_request_type(request_type) + return calc.ioc(calc._IOC_NONE, request_type, request_nr, 0) + + +def _IOR(request_type, request_nr, size): + """Python implementation of the ``_IOR(...)`` macro from Linux. + + This is a portable implementation of the ``_IOR(...)`` macro from Linux. + The `_IOR(...)`` macro calculates a ioctl request number for ioctl request that only pass read-only (input) data. + + :param request_type: The ioctl request type. This can be specified as either a string ``'R'`` or an integer ``123``. + :param request_nr: The ioctl request number. This is an integer. + :param size: The size of the associated data. This can either be an integer or a ctypes type. + :return: The calculated ioctl request number. + """ + + calc = _machine_ioctl_calculator() + request_type = _ioc_request_type(request_type) + size = _ioc_type_size(size) + return calc.ioc(calc._IOC_READ, request_type, request_nr, size) + + +def _IOW(request_type, request_nr, size): + """Python implementation of the ``_IOW(...)`` macro from Linux. + + This is a portable implementation of the ``_IOW(...)`` macro from Linux. + The ``_IOW(...)`` macro calculates a ioctl request number for ioctl request that only pass write-only (output) data. + + :param request_type: The ioctl request type. This can be specified as either a string ``'R'`` or an integer ``123``. + :param request_nr: The ioctl request number. This is an integer. + :param size: The size of the associated data. This can either be an integer or a ctypes type. + :return: The calculated ioctl request number. + """ + + calc = _machine_ioctl_calculator() + request_type = _ioc_request_type(request_type) + size = _ioc_type_size(size) + return calc.ioc(calc._IOC_WRITE, request_type, request_nr, size) + + +def _IOWR(request_type, request_nr, size): + """Python implementation of the ``_IOWR(...)`` macro from Linux. + + This is a portable implementation of the ``_IOWR(...)`` macro from Linux. + The ``_IOWR(...)`` macro calculates a ioctl request number for ioctl request that use the data for both reading (input) and writing (output). + + :param request_type: The ioctl request type. This can be specified as either a string ``'R'`` or an integer ``123``. + :param request_nr: The ioctl request number. This is an integer. + :param size: The size of the associated data. This can either be an integer or a ctypes type. + :return: The calculated ioctl request number. + """ + + calc = _machine_ioctl_calculator() + request_type = _ioc_request_type(request_type) + size = _ioc_type_size(size) + return calc.ioc(calc._IOC_READ | calc._IOC_WRITE, request_type, request_nr, size) + + +# struct input_mask { +# __u32 type; +# __u32 codes_size; +# __u64 codes_ptr; +# }; +EVIOCGMASK = _IOR("E", 0x92, 4 + 4 + 8) +EVIOCSMASK = _IOW("E", 0x93, 4 + 4 + 8) +# char * is 8 +UINPUT_IOCTL_BASE = "U" +UI_SET_UNIQ_STR = lambda l: _IOC("w", UINPUT_IOCTL_BASE, 112, l) +UI_GET_SYSNAME = lambda l: _IOC("r", UINPUT_IOCTL_BASE, 44, l) + +# Revokes access to an evdev device +EVIOCREVOKEALL = _IOW("E", 0x94, 4) +JSIOCREVOKEALL = _IOW("j", 0x94, 4) + +# Grab clean ioctl +EVIOCGRABCLEAN = _IOW("E", 0x95, 4) + +# Hidraw Descriptors +HIDIOCGRDESCSIZE = _IOR("H", 0x01, 4) +HIDIOCGRDESC = _IOR("H", 0x02, 4096 + 4) + +__all__ = ( + "_IOC", + "_IO", + "_IOR", + "_IOW", + "_IOWR", + "EVIOCSMASK", + "EVIOCGMASK", + "EVIOCREVOKEALL", + "JSIOCREVOKEALL", + "UI_SET_UNIQ_STR", +) diff --git a/src/hhd/controller/lib/uhid.py b/src/hhd/controller/lib/uhid.py index e756ddef..1a822079 100644 --- a/src/hhd/controller/lib/uhid.py +++ b/src/hhd/controller/lib/uhid.py @@ -1,4 +1,4 @@ -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: MIT and GPL-3.0-only # Sourced from library python-uhid from __future__ import annotations @@ -311,5 +311,5 @@ def send_get_report_reply(self, id: int, err: int, data: bytes): self.send_event(ev) def send_set_report_reply(self, id: int, err: int): - ev = struct.pack("< L L H", UHID_GET_REPORT_REPLY, id, err) + ev = struct.pack("< L L H", UHID_SET_REPORT_REPLY, id, err) self.send_event(ev) diff --git a/src/hhd/controller/physical/evdev.py b/src/hhd/controller/physical/evdev.py index 23eef5e8..ed13de7b 100644 --- a/src/hhd/controller/physical/evdev.py +++ b/src/hhd/controller/physical/evdev.py @@ -2,21 +2,20 @@ import logging import os import re -import select import stat import subprocess -from typing import Mapping, Sequence, TypeVar, cast, Collection +import time +from typing import Collection, Mapping, Sequence, TypeVar, cast import evdev from evdev import ecodes, ff -from hhd.controller import Axis, Button, Consumer, Event, Producer +from hhd.controller import Axis, Button, Consumer, Event, Producer, can_read from hhd.controller.base import Event +from hhd.controller.const import AbsAxis, GamepadButton, KeyboardButton from hhd.controller.lib.common import hexify, matches_patterns from hhd.controller.lib.hide import hide_gamepad, unhide_gamepad -from ..const import AbsAxis, GamepadButton, KeyboardButton - logger = logging.getLogger(__name__) @@ -76,6 +75,58 @@ def to_map(b: dict[A, Sequence[int]]) -> dict[int, A]: } ) +DINPUT_AXIS_MAP: dict[int, Axis] = to_map( + { + # Sticks + # Values should range from -1 to 1 + "ls_x": [B("ABS_X")], + "ls_y": [B("ABS_Y")], + "rs_x": [B("ABS_Z")], + "rs_y": [B("ABS_RZ")], + # Triggers + # Values should range from -1 to 1 + "rt": [B("ABS_BRAKE")], + "lt": [B("ABS_GAS")], + # Hat, implemented as axis. Either -1, 0, or 1 + "hat_x": [B("ABS_HAT0X")], + "hat_y": [B("ABS_HAT0Y")], + } +) +DINPUT_AXIS_POSTPROCESS = { + "ls_x": {"zero_is_middle": True}, + "ls_y": {"zero_is_middle": True}, + "rs_x": {"zero_is_middle": True}, + "rs_y": {"zero_is_middle": True}, +} + +if calib := os.environ.get("HHD_CALIB"): + # TODO: Move this into the gui + # Accepts the following format: : + # Everything below deadzone gets set to 0. + # If axis is positive, it gets scaled so that it is 1 when max is hit + # If negative, it is scaled to be -1 when it reaches the min value + # HHD_CALIB=$(cat <<-END + # { + # "ls_x": {"min": -1, "max": 1, "deadzone": 0.05}, + # "ls_y": {"min": -1, "max": 1, "deadzone": 0.05}, + # "rs_x": {"min": -1, "max": 1, "deadzone": 0.05}, + # "rs_y": {"min": -1, "max": 1, "deadzone": 0.05}, + # "lt": {"min": 0, "max": 1, "deadzone": 0.05}, + # "rt": {"min": 0, "max": 1, "deadzone": 0.05} + # } + # END + # ) + import json + + try: + AXIS_CALIBRATION = json.loads(calib) + logger.info(f"Loaded calibration:\n{calib}") + except Exception as e: + logger.info(f"Could not load Axis Calibration:\n{calib}\nError:{e}") + AXIS_CALIBRATION = {} +else: + AXIS_CALIBRATION = {} + def list_joysticks(input_device_dir="/dev/input"): return glob.glob(f"{input_device_dir}/js*") @@ -99,7 +150,85 @@ def find_joystick(ev: str): return other +def is_device(fn): + """Check if ``fn`` is a readable and writable character device.""" + + if not os.path.exists(fn): + return False + + try: + m = os.stat(fn)[stat.ST_MODE] + if not stat.S_ISCHR(m): + return False + + if not os.access(fn, os.R_OK | os.W_OK): + return False + except Exception: + return False + return True + + +def list_evs(filter_valid: bool = False, fn: str = "/proc/bus/input/devices"): + with open(fn, "r") as f: + data = f.read() + + devs = {} + for d in data.split("\n\n"): + out = {} + out["hash"] = hash(d) + for line in d.split("\n"): + if not line: + continue + match line[0]: + case "I": + for attr in line[3:-1].split(" "): + name, val = attr.split("=") + out[name.lower()] = int(val, 16) + case "N": + out["name"] = line[len('N: Name="') : -1] + case "B": + if "byte" not in out: + out["byte"] = {} + head, raw = line[3:].split("=") + arr = bytearray() + for x in raw.split(" "): + if not x: + continue + arr.extend(int(x, 16).to_bytes(8, "big")) + # Array is stacked using big endianness, so + # we reverse it to little endian + out["byte"][head.lower()] = bytes(reversed(arr)) + case "P": + out["phys"] = line[len('P: Phys="') : -1] + case "S": + if "Sysfs" in line: + out["sysfs"] = line[len('S: Sysfs="') : -1] + case "H": + if len(line) < len("H: Handlers=") + 1: + continue + for handler in line[len("H: Handlers=") : -1].split(" "): + if "event" in handler: + pth = "/dev/input/" + handler + if not filter_valid or is_device(pth): + devs[pth] = out + + return devs + + +def enumerate_evs( + vid: int | None = None, pid: int | None = None, filter_valid: bool = False +): + evs = list_evs(filter_valid) + return { + k: v + for k, v in evs.items() + if (vid is None or vid == v.get("vendor", None)) + and (pid is None or pid == v.get("product", None)) + } + + class GenericGamepadEvdev(Producer, Consumer): + def __init__( self, vid: Sequence[int], @@ -111,6 +240,11 @@ def __init__( aspect_ratio: float | None = None, required: bool = True, hide: bool = False, + grab: bool = True, + msc_map: Mapping[int, Button] = {}, + msc_delay: float = 0.1, + postprocess: dict[str, dict] = AXIS_CALIBRATION, + requires_start: bool = False, ) -> None: self.vid = vid self.pid = pid @@ -119,23 +253,31 @@ def __init__( self.btn_map = btn_map self.axis_map = axis_map + self.msc_map = msc_map + self.msc_delay = msc_delay self.aspect_ratio = aspect_ratio self.dev: evdev.InputDevice | None = None self.fd = 0 self.required = required self.hide = hide - self.hidden = False + self.grab = grab + self.hidden = None + self.queue = [] + self.postprocess = postprocess + self.start_pressed = None + self.start_held = False + self.requires_start = requires_start def open(self) -> Sequence[int]: - for d in evdev.list_devices(): - dev = evdev.InputDevice(d) - if not matches_patterns(dev.info.vendor, self.vid): + for d, info in list_evs(filter_valid=True).items(): + if not matches_patterns(info.get("vendor", ""), self.vid): continue - if not matches_patterns(dev.info.product, self.pid): + if not matches_patterns(info.get("product", ""), self.pid): continue - if not matches_patterns(dev.name, self.name): + if not matches_patterns(info.get("name", ""), self.name): continue + dev = evdev.InputDevice(d) if self.capabilities: matches = True dev_cap = cast(dict[int, Sequence[int]], dev.capabilities()) @@ -143,17 +285,25 @@ def open(self) -> Sequence[int]: if cap_id not in dev_cap: matches = False break + if cap_id != B("EV_ABS"): + dev_caps = dev_cap[cap_id] + else: + dev_caps = [c[0] for c in dev_cap[cap_id]] # type: ignore for cap in caps: - if cap not in dev_cap[cap_id]: + if cap not in dev_caps: matches = False break if not matches: continue + # hide_gamepad will destroy the current fds, so run it before + # creating the final device if self.hide: # Check we are root if not os.getuid(): - self.hidden = hide_gamepad(dev.path) + self.hidden = hide_gamepad( + dev.path, dev.info.vendor, dev.info.product + ) if not self.hidden: logger.warning(f"Could not hide device:\n{dev}") else: @@ -161,14 +311,27 @@ def open(self) -> Sequence[int]: f"Not running as root, device '{dev.name}' could not be hid." ) - self.dev = dev - self.dev.grab() - self.ranges = { - a: (i.min, i.max) for a, i in self.dev.capabilities().get(B("EV_ABS"), []) # type: ignore - } - self.fd = dev.fd - self.started = True - self.effect_id = -1 + try: + # Close the previous device + # Will have been destroyed by hiding + dev.close() + self.dev = evdev.InputDevice(d) + if self.grab: + self.dev.grab() + self.ranges = { + a: (i.min, i.max) for a, i in self.dev.capabilities().get(B("EV_ABS"), []) # type: ignore + } + self.supports_vibration = B("EV_FF") in dev.capabilities() + self.fd = self.dev.fd + self.started = True + self.effect_id = -1 + self.queue = [] + except Exception as e: + # Prevent leftover rules in case of error + if self.hidden: + unhide_gamepad(d, self.hidden) + raise e + return [self.fd] err = f"Device with the following not found:\n" @@ -187,8 +350,8 @@ def open(self) -> Sequence[int]: def close(self, exit: bool) -> bool: if self.dev: - if self.hidden: - unhide_gamepad(self.dev.path) + if self.hidden and exit: + unhide_gamepad(self.dev.path, self.hidden) self.dev.close() self.dev = None self.fd = 0 @@ -201,6 +364,9 @@ def consume(self, events: Sequence[Event]): for ev in events: match ev["type"]: case "rumble": + if not self.supports_vibration: + continue + # Erase old effect if self.effect_id != -1: self.dev.erase_effect(self.effect_id) @@ -230,10 +396,26 @@ def consume(self, events: Sequence[Event]): self.dev.write(getattr(ecodes, "EV_FF"), self.effect_id, 1) def produce(self, fds: Sequence[int]) -> Sequence[Event]: + out: list[Event] = [] + curr = time.time() + if self.queue: + ev, t = self.queue[0] + if curr >= t: + out.append(ev) + self.queue.pop(0) + if self.start_pressed and curr - self.start_pressed > 0.07: + self.start_pressed = None + out.append( + { + "type": "button", + "code": self.btn_map[B("KEY_LEFTMETA")], + "value": True, + } + ) + if not self.dev or not self.fd in fds: - return [] + return out - out: list[Event] = [] if self.started and self.aspect_ratio is not None: self.started = False out.append( @@ -244,200 +426,262 @@ def produce(self, fds: Sequence[int]) -> Sequence[Event]: } ) - while select.select([self.fd], [], [], 0)[0]: + while can_read(self.fd): for e in self.dev.read(): if e.type == B("EV_KEY"): - if e.code in self.btn_map: - out.append( - { - "type": "button", - "code": self.btn_map[e.code], - "value": bool(e.value), - } - ) + if e.code == B("KEY_LEFTMETA"): + self.start_held = e.value != 0 + if e.code in self.btn_map and ( + not self.requires_start or self.start_held or not e.value + ): + # Only 1 is valid for press (look at sysrq) + if e.code == B("KEY_LEFTMETA") and e.value: + # start requires special handling + # If it exists on the button map, it may + # also be used for other shortcuts. + # So we have to wait a bit to see if it is + # a standalone press + self.start_pressed = curr + elif e.value == 0 or e.value == 1: + out.append( + { + "type": "button", + "code": self.btn_map[e.code], + "value": bool(e.value), + } + ) + self.start_pressed = None elif e.type == B("EV_ABS"): if e.code in self.axis_map: - # Normalize - val = e.value / abs( - self.ranges[e.code][1 if e.value >= 0 else 0] - ) + ax = self.axis_map[e.code] + if ax in self.postprocess and self.postprocess[ax].get( + "zero_is_middle", False + ): + mmax = self.ranges[e.code][1] + 1 + val = (e.value - mmax // 2 + 1) / mmax * 2 + else: + # Normalize + val = e.value / abs( + self.ranges[e.code][1 if e.value >= 0 else 0] + ) + + # Calibrate + if ax in self.postprocess: + calib = self.postprocess[ax] + if val < 0 and "min" in calib: + m = calib["min"] + if m: + # avoid division by 0 + val = -max(m, val) / m + elif val > 0 and "max" in calib: + m = calib["max"] + val = min(m, val) / m + + if "deadzone" in calib: + d = calib["deadzone"] + if abs(val) < d: + val = 0 out.append( { "type": "axis", - "code": self.axis_map[e.code], + "code": ax, "value": val, } ) + elif e.type == B("EV_MSC"): + if e.code in self.msc_map: + out.append( + { + "type": "button", + "code": self.btn_map[e.code], + "value": True, + } + ) + self.queue.append( + ( + { + "type": "button", + "code": self.btn_map[e.code], + "value": False, + }, + curr + self.msc_delay, + ) + ) + return out -KEYBOARD_MAP: dict[int, KeyboardButton] = to_map( - { - "key_esc": [B("KEY_ESC")], # 1 - "key_enter": [B("KEY_ENTER")], # 28 - "key_leftctrl": [B("KEY_LEFTCTRL")], # 29 - "key_leftshift": [B("KEY_LEFTSHIFT")], # 42 - "key_leftalt": [B("KEY_LEFTALT")], # 56 - "key_rightctrl": [B("KEY_RIGHTCTRL")], # 97 - "key_rightshift": [B("KEY_RIGHTSHIFT")], # 54 - "key_rightalt": [B("KEY_RIGHTALT")], # 100 - "key_leftmeta": [B("KEY_LEFTMETA")], # 125 - "key_rightmeta": [B("KEY_RIGHTMETA")], # 126 - "key_capslock": [B("KEY_CAPSLOCK")], # 58 - "key_numlock": [B("KEY_NUMLOCK")], # 69 - "key_scrolllock": [B("KEY_SCROLLLOCK")], # 70 - "key_sysrq": [B("KEY_SYSRQ")], # 99 - "key_minus": [B("KEY_MINUS")], # 12 - "key_equal": [B("KEY_EQUAL")], # 13 - "key_backspace": [B("KEY_BACKSPACE")], # 14 - "key_tab": [B("KEY_TAB")], # 15 - "key_leftbrace": [B("KEY_LEFTBRACE")], # 26 - "key_rightbrace": [B("KEY_RIGHTBRACE")], # 27 - "key_space": [B("KEY_SPACE")], # 57 - "key_up": [B("KEY_UP")], # 103 - "key_left": [B("KEY_LEFT")], # 105 - "key_right": [B("KEY_RIGHT")], # 106 - "key_down": [B("KEY_DOWN")], # 108 - "key_home": [B("KEY_HOME")], # 102 - "key_end": [B("KEY_END")], # 107 - "key_pageup": [B("KEY_PAGEUP")], # 104 - "key_pagedown": [B("KEY_PAGEDOWN")], # 109 - "key_insert": [B("KEY_INSERT")], # 110 - "key_delete": [B("KEY_DELETE")], # 111 - "key_semicolon": [B("KEY_SEMICOLON")], # 39 - "key_apostrophe": [B("KEY_APOSTROPHE")], # 40 - "key_grave": [B("KEY_GRAVE")], # 41 - "key_backslash": [B("KEY_BACKSLASH")], # 43 - "key_comma": [B("KEY_COMMA")], # 51 - "key_dot": [B("KEY_DOT")], # 52 - "key_slash": [B("KEY_SLASH")], # 53 - "key_102nd": [B("KEY_102ND")], # 86 - "key_ro": [B("KEY_RO")], # 89 - "key_power": [B("KEY_POWER")], # 116 - "key_compose": [B("KEY_COMPOSE")], # 127 - "key_stop": [B("KEY_STOP")], # 128 - "key_again": [B("KEY_AGAIN")], # 129 - "key_props": [B("KEY_PROPS")], # 130 - "key_undo": [B("KEY_UNDO")], # 131 - "key_front": [B("KEY_FRONT")], # 132 - "key_copy": [B("KEY_COPY")], # 133 - "key_open": [B("KEY_OPEN")], # 134 - "key_paste": [B("KEY_PASTE")], # 135 - "key_cut": [B("KEY_CUT")], # 137 - "key_find": [B("KEY_FIND")], # 136 - "key_help": [B("KEY_HELP")], # 138 - "key_calc": [B("KEY_CALC")], # 140 - "key_sleep": [B("KEY_SLEEP")], # 142 - "key_www": [B("KEY_WWW")], # 150 - "key_screenlock": [B("KEY_SCREENLOCK")], # 152 - "key_back": [B("KEY_BACK")], # 158 - "key_refresh": [B("KEY_REFRESH")], # 173 - "key_edit": [B("KEY_EDIT")], # 176 - "key_scrollup": [B("KEY_SCROLLUP")], # 177 - "key_scrolldown": [B("KEY_SCROLLDOWN")], # 178 - "key_1": [B("KEY_1")], # 2 - "key_2": [B("KEY_2")], # 3 - "key_3": [B("KEY_3")], # 4 - "key_4": [B("KEY_4")], # 5 - "key_5": [B("KEY_5")], # 6 - "key_6": [B("KEY_6")], # 7 - "key_7": [B("KEY_7")], # 8 - "key_8": [B("KEY_8")], # 9 - "key_9": [B("KEY_9")], # 10 - "key_0": [B("KEY_0")], # 11 - "key_a": [B("KEY_A")], # 30 - "key_b": [B("KEY_B")], # 48 - "key_c": [B("KEY_C")], # 46 - "key_d": [B("KEY_D")], # 32 - "key_e": [B("KEY_E")], # 18 - "key_f": [B("KEY_F")], # 33 - "key_g": [B("KEY_G")], # 34 - "key_h": [B("KEY_H")], # 35 - "key_i": [B("KEY_I")], # 23 - "key_j": [B("KEY_J")], # 36 - "key_k": [B("KEY_K")], # 37 - "key_l": [B("KEY_L")], # 38 - "key_m": [B("KEY_M")], # 50 - "key_n": [B("KEY_N")], # 49 - "key_o": [B("KEY_O")], # 24 - "key_p": [B("KEY_P")], # 25 - "key_q": [B("KEY_Q")], # 16 - "key_r": [B("KEY_R")], # 19 - "key_s": [B("KEY_S")], # 31 - "key_t": [B("KEY_T")], # 20 - "key_u": [B("KEY_U")], # 22 - "key_v": [B("KEY_V")], # 47 - "key_w": [B("KEY_W")], # 17 - "key_x": [B("KEY_X")], # 45 - "key_y": [B("KEY_Y")], # 21 - "key_z": [B("KEY_Z")], # 44 - "key_kpasterisk": [B("KEY_KPASTERISK")], # 55 - "key_kpminus": [B("KEY_KPMINUS")], # 74 - "key_kpplus": [B("KEY_KPPLUS")], # 78 - "key_kpdot": [B("KEY_KPDOT")], # 83 - "key_kpjpcomma": [B("KEY_KPJPCOMMA")], # 95 - "key_kpenter": [B("KEY_KPENTER")], # 96 - "key_kpslash": [B("KEY_KPSLASH")], # 98 - "key_kpequal": [B("KEY_KPEQUAL")], # 117 - "key_kpcomma": [B("KEY_KPCOMMA")], # 121 - "key_kpleftparen": [B("KEY_KPLEFTPAREN")], # 179 - "key_kprightparen": [B("KEY_KPRIGHTPAREN")], # 180 - "key_kp0": [B("KEY_KP0")], # 82 - "key_kp1": [B("KEY_KP1")], # 79 - "key_kp2": [B("KEY_KP2")], # 80 - "key_kp3": [B("KEY_KP3")], # 81 - "key_kp4": [B("KEY_KP4")], # 75 - "key_kp5": [B("KEY_KP5")], # 76 - "key_kp6": [B("KEY_KP6")], # 77 - "key_kp7": [B("KEY_KP7")], # 71 - "key_kp8": [B("KEY_KP8")], # 72 - "key_kp9": [B("KEY_KP9")], # 73 - "key_f1": [B("KEY_F1")], # 59 - "key_f2": [B("KEY_F2")], # 60 - "key_f3": [B("KEY_F3")], # 61 - "key_f4": [B("KEY_F4")], # 62 - "key_f5": [B("KEY_F5")], # 63 - "key_f6": [B("KEY_F6")], # 64 - "key_f7": [B("KEY_F7")], # 65 - "key_f8": [B("KEY_F8")], # 66 - "key_f9": [B("KEY_F9")], # 67 - "key_f11": [B("KEY_F11")], # 87 - "key_f12": [B("KEY_F12")], # 88 - "key_f10": [B("KEY_F10")], # 68 - "key_f13": [B("KEY_F13")], # 183 - "key_f14": [B("KEY_F14")], # 184 - "key_f15": [B("KEY_F15")], # 185 - "key_f16": [B("KEY_F16")], # 186 - "key_f17": [B("KEY_F17")], # 187 - "key_f18": [B("KEY_F18")], # 188 - "key_f19": [B("KEY_F19")], # 189 - "key_f20": [B("KEY_F20")], # 190 - "key_f21": [B("KEY_F21")], # 191 - "key_f22": [B("KEY_F22")], # 192 - "key_f23": [B("KEY_F23")], # 193 - "key_f24": [B("KEY_F24")], # 194 - "key_playpause": [B("KEY_PLAYPAUSE")], # 164 - "key_pause": [B("KEY_PAUSE")], # 119 - "key_mute": [B("KEY_MUTE")], # 113 - "key_stopcd": [B("KEY_STOPCD")], # 166 - "key_forward": [B("KEY_FORWARD")], # 159 - "key_ejectcd": [B("KEY_EJECTCD")], # 161 - "key_nextsong": [B("KEY_NEXTSONG")], # 163 - "key_previoussong": [B("KEY_PREVIOUSSONG")], # 165 - "key_volumedown": [B("KEY_VOLUMEDOWN")], # 114 - "key_volumeup": [B("KEY_VOLUMEUP")], # 115 - "key_katakana": [B("KEY_KATAKANA")], # 90 - "key_hiragana": [B("KEY_HIRAGANA")], # 91 - "key_henkan": [B("KEY_HENKAN")], # 92 - "key_katakanahiragana": [B("KEY_KATAKANAHIRAGANA")], # 93 - "key_muhenkan": [B("KEY_MUHENKAN")], # 94 - "key_zenkakuhankaku": [B("KEY_ZENKAKUHANKAKU")], # 85 - "key_hanguel": [B("KEY_HANGUEL")], # 122 - "key_hanja": [B("KEY_HANJA")], # 123 - "key_yen": [B("KEY_YEN")], # 124 - "key_unknown": [B("KEY_UNKNOWN")], # 240, - } -) +_kbd_raw: dict[KeyboardButton, Sequence[int]] = { + "key_esc": [B("KEY_ESC")], # 1 + "key_enter": [B("KEY_ENTER")], # 28 + "key_leftctrl": [B("KEY_LEFTCTRL")], # 29 + "key_leftshift": [B("KEY_LEFTSHIFT")], # 42 + "key_leftalt": [B("KEY_LEFTALT")], # 56 + "key_rightctrl": [B("KEY_RIGHTCTRL")], # 97 + "key_rightshift": [B("KEY_RIGHTSHIFT")], # 54 + "key_rightalt": [B("KEY_RIGHTALT")], # 100 + "key_leftmeta": [B("KEY_LEFTMETA")], # 125 + "key_rightmeta": [B("KEY_RIGHTMETA")], # 126 + "key_capslock": [B("KEY_CAPSLOCK")], # 58 + "key_numlock": [B("KEY_NUMLOCK")], # 69 + "key_scrolllock": [B("KEY_SCROLLLOCK")], # 70 + "key_sysrq": [B("KEY_SYSRQ")], # 99 + "key_minus": [B("KEY_MINUS")], # 12 + "key_equal": [B("KEY_EQUAL")], # 13 + "key_backspace": [B("KEY_BACKSPACE")], # 14 + "key_tab": [B("KEY_TAB")], # 15 + "key_leftbrace": [B("KEY_LEFTBRACE")], # 26 + "key_rightbrace": [B("KEY_RIGHTBRACE")], # 27 + "key_space": [B("KEY_SPACE")], # 57 + "key_up": [B("KEY_UP")], # 103 + "key_left": [B("KEY_LEFT")], # 105 + "key_right": [B("KEY_RIGHT")], # 106 + "key_down": [B("KEY_DOWN")], # 108 + "key_home": [B("KEY_HOME")], # 102 + "key_end": [B("KEY_END")], # 107 + "key_pageup": [B("KEY_PAGEUP")], # 104 + "key_pagedown": [B("KEY_PAGEDOWN")], # 109 + "key_insert": [B("KEY_INSERT")], # 110 + "key_delete": [B("KEY_DELETE")], # 111 + "key_semicolon": [B("KEY_SEMICOLON")], # 39 + "key_apostrophe": [B("KEY_APOSTROPHE")], # 40 + "key_grave": [B("KEY_GRAVE")], # 41 + "key_backslash": [B("KEY_BACKSLASH")], # 43 + "key_comma": [B("KEY_COMMA")], # 51 + "key_dot": [B("KEY_DOT")], # 52 + "key_slash": [B("KEY_SLASH")], # 53 + "key_102nd": [B("KEY_102ND")], # 86 + "key_ro": [B("KEY_RO")], # 89 + "key_power": [B("KEY_POWER")], # 116 + "key_compose": [B("KEY_COMPOSE")], # 127 + "key_stop": [B("KEY_STOP")], # 128 + "key_again": [B("KEY_AGAIN")], # 129 + "key_props": [B("KEY_PROPS")], # 130 + "key_undo": [B("KEY_UNDO")], # 131 + "key_front": [B("KEY_FRONT")], # 132 + "key_copy": [B("KEY_COPY")], # 133 + "key_open": [B("KEY_OPEN")], # 134 + "key_paste": [B("KEY_PASTE")], # 135 + "key_cut": [B("KEY_CUT")], # 137 + "key_find": [B("KEY_FIND")], # 136 + "key_help": [B("KEY_HELP")], # 138 + "key_calc": [B("KEY_CALC")], # 140 + "key_sleep": [B("KEY_SLEEP")], # 142 + "key_www": [B("KEY_WWW")], # 150 + "key_screenlock": [B("KEY_SCREENLOCK")], # 152 + "key_back": [B("KEY_BACK")], # 158 + "key_refresh": [B("KEY_REFRESH")], # 173 + "key_edit": [B("KEY_EDIT")], # 176 + "key_scrollup": [B("KEY_SCROLLUP")], # 177 + "key_scrolldown": [B("KEY_SCROLLDOWN")], # 178 + "key_1": [B("KEY_1")], # 2 + "key_2": [B("KEY_2")], # 3 + "key_3": [B("KEY_3")], # 4 + "key_4": [B("KEY_4")], # 5 + "key_5": [B("KEY_5")], # 6 + "key_6": [B("KEY_6")], # 7 + "key_7": [B("KEY_7")], # 8 + "key_8": [B("KEY_8")], # 9 + "key_9": [B("KEY_9")], # 10 + "key_0": [B("KEY_0")], # 11 + "key_a": [B("KEY_A")], # 30 + "key_b": [B("KEY_B")], # 48 + "key_c": [B("KEY_C")], # 46 + "key_d": [B("KEY_D")], # 32 + "key_e": [B("KEY_E")], # 18 + "key_f": [B("KEY_F")], # 33 + "key_g": [B("KEY_G")], # 34 + "key_h": [B("KEY_H")], # 35 + "key_i": [B("KEY_I")], # 23 + "key_j": [B("KEY_J")], # 36 + "key_k": [B("KEY_K")], # 37 + "key_l": [B("KEY_L")], # 38 + "key_m": [B("KEY_M")], # 50 + "key_n": [B("KEY_N")], # 49 + "key_o": [B("KEY_O")], # 24 + "key_p": [B("KEY_P")], # 25 + "key_q": [B("KEY_Q")], # 16 + "key_r": [B("KEY_R")], # 19 + "key_s": [B("KEY_S")], # 31 + "key_t": [B("KEY_T")], # 20 + "key_u": [B("KEY_U")], # 22 + "key_v": [B("KEY_V")], # 47 + "key_w": [B("KEY_W")], # 17 + "key_x": [B("KEY_X")], # 45 + "key_y": [B("KEY_Y")], # 21 + "key_z": [B("KEY_Z")], # 44 + "key_kpasterisk": [B("KEY_KPASTERISK")], # 55 + "key_kpminus": [B("KEY_KPMINUS")], # 74 + "key_kpplus": [B("KEY_KPPLUS")], # 78 + "key_kpdot": [B("KEY_KPDOT")], # 83 + "key_kpjpcomma": [B("KEY_KPJPCOMMA")], # 95 + "key_kpenter": [B("KEY_KPENTER")], # 96 + "key_kpslash": [B("KEY_KPSLASH")], # 98 + "key_kpequal": [B("KEY_KPEQUAL")], # 117 + "key_kpcomma": [B("KEY_KPCOMMA")], # 121 + "key_kpleftparen": [B("KEY_KPLEFTPAREN")], # 179 + "key_kprightparen": [B("KEY_KPRIGHTPAREN")], # 180 + "key_kp0": [B("KEY_KP0")], # 82 + "key_kp1": [B("KEY_KP1")], # 79 + "key_kp2": [B("KEY_KP2")], # 80 + "key_kp3": [B("KEY_KP3")], # 81 + "key_kp4": [B("KEY_KP4")], # 75 + "key_kp5": [B("KEY_KP5")], # 76 + "key_kp6": [B("KEY_KP6")], # 77 + "key_kp7": [B("KEY_KP7")], # 71 + "key_kp8": [B("KEY_KP8")], # 72 + "key_kp9": [B("KEY_KP9")], # 73 + "key_f1": [B("KEY_F1")], # 59 + "key_f2": [B("KEY_F2")], # 60 + "key_f3": [B("KEY_F3")], # 61 + "key_f4": [B("KEY_F4")], # 62 + "key_f5": [B("KEY_F5")], # 63 + "key_f6": [B("KEY_F6")], # 64 + "key_f7": [B("KEY_F7")], # 65 + "key_f8": [B("KEY_F8")], # 66 + "key_f9": [B("KEY_F9")], # 67 + "key_f11": [B("KEY_F11")], # 87 + "key_f12": [B("KEY_F12")], # 88 + "key_f10": [B("KEY_F10")], # 68 + "key_f13": [B("KEY_F13")], # 183 + "key_f14": [B("KEY_F14")], # 184 + "key_f15": [B("KEY_F15")], # 185 + "key_f16": [B("KEY_F16")], # 186 + "key_f17": [B("KEY_F17")], # 187 + "key_f18": [B("KEY_F18")], # 188 + "key_f19": [B("KEY_F19")], # 189 + "key_f20": [B("KEY_F20")], # 190 + "key_f21": [B("KEY_F21")], # 191 + "key_f22": [B("KEY_F22")], # 192 + "key_f23": [B("KEY_F23")], # 193 + "key_f24": [B("KEY_F24")], # 194 + "key_playpause": [B("KEY_PLAYPAUSE")], # 164 + "key_pause": [B("KEY_PAUSE")], # 119 + "key_mute": [B("KEY_MUTE")], # 113 + "key_stopcd": [B("KEY_STOPCD")], # 166 + "key_forward": [B("KEY_FORWARD")], # 159 + "key_ejectcd": [B("KEY_EJECTCD")], # 161 + "key_nextsong": [B("KEY_NEXTSONG")], # 163 + "key_previoussong": [B("KEY_PREVIOUSSONG")], # 165 + "key_volumedown": [B("KEY_VOLUMEDOWN")], # 114 + "key_volumeup": [B("KEY_VOLUMEUP")], # 115 + "key_katakana": [B("KEY_KATAKANA")], # 90 + "key_hiragana": [B("KEY_HIRAGANA")], # 91 + "key_henkan": [B("KEY_HENKAN")], # 92 + "key_katakanahiragana": [B("KEY_KATAKANAHIRAGANA")], # 93 + "key_muhenkan": [B("KEY_MUHENKAN")], # 94 + "key_zenkakuhankaku": [B("KEY_ZENKAKUHANKAKU")], # 85 + "key_hanguel": [B("KEY_HANGUEL")], # 122 + "key_hanja": [B("KEY_HANJA")], # 123 + "key_yen": [B("KEY_YEN")], # 124 + "key_unknown": [B("KEY_UNKNOWN")], # 240, + "key_prog1": [B("KEY_PROG1")], # 148 + "key_prog2": [B("KEY_PROG2")], # 149, +} + +KEYBOARD_MAP_REV: dict[KeyboardButton, int] = {k: v[0] for k, v in _kbd_raw.items()} + +KEYBOARD_MAP: dict[int, KeyboardButton] = to_map(_kbd_raw) __all__ = ["GenericGamepadEvdev", "XBOX_BUTTON_MAP", "XBOX_AXIS_MAP", "B", "to_map"] diff --git a/src/hhd/controller/physical/hidraw.py b/src/hhd/controller/physical/hidraw.py index 6a1ba34d..771e3b34 100644 --- a/src/hhd/controller/physical/hidraw.py +++ b/src/hhd/controller/physical/hidraw.py @@ -41,12 +41,14 @@ def __init__( product: Sequence[str | re.Pattern] = [], usage_page: Sequence[int] = [], usage: Sequence[int] = [], + interface: int | None = None, btn_map: dict[int | None, dict[Button, BM]] = {}, axis_map: dict[int | None, dict[Axis, AM]] = {}, config_map: dict[int | None, dict[Configuration, CM]] = {}, callback: EventCallback | None = None, report_size: int = MAX_REPORT_SIZE, required: bool = True, + lossless: bool = True, ) -> None: self.vid = vid self.pid = pid @@ -54,6 +56,7 @@ def __init__( self.product = product self.usage_page = usage_page self.usage = usage + self.interface = interface self.report_size = report_size self.btn_map = btn_map @@ -61,6 +64,7 @@ def __init__( self.config_map = config_map self.callback = callback self.required = required + self.lossless = lossless self.path = None self.dev: Device | None = None @@ -82,6 +86,11 @@ def open(self) -> Sequence[int]: continue if not matches_patterns(d["usage"], self.usage): continue + if ( + self.interface is not None + and d.get("interface_number", None) != self.interface + ): + continue self.path = d["path"] self.dev = Device(path=self.path) self.fd = self.dev.fd @@ -119,9 +128,13 @@ def produce(self, fds: Sequence[int]) -> Sequence[Event]: return [] rep = None - # Throw away stale events - while can_read(self.fd): + if self.lossless: + # Keep all events rep = self.dev.read(self.report_size) + else: + # Throw away stale events + while can_read(self.fd): + rep = self.dev.read(self.report_size) # If we could not read (?) return if not rep: @@ -170,5 +183,11 @@ def consume(self, events: Sequence[Event]): if self.callback and self.dev: self.callback(self.dev, events) + def close(self, exit: bool) -> bool: + if self.dev: + self.dev.close() + self.dev = None + return True + __all__ = ["GenericGamepadHidraw", "BM", "AM"] diff --git a/src/hhd/controller/physical/imu.py b/src/hhd/controller/physical/imu.py index 0cd93508..4f0998c9 100644 --- a/src/hhd/controller/physical/imu.py +++ b/src/hhd/controller/physical/imu.py @@ -1,10 +1,10 @@ +import logging +import os import select +from threading import Event as TEvent, Thread from typing import Any, Generator, Literal, NamedTuple, Sequence -from hhd.controller import Axis, Event, Axis, Producer -import os - -import logging +from hhd.controller import Axis, Event, Producer logger = logging.getLogger(__name__) @@ -30,21 +30,37 @@ class DeviceInfo(NamedTuple): sysfs: str -ACCEL_MAPPINGS: dict[str, tuple[Axis, float | None]] = { - "accel_x": ("accel_z", 3), - "accel_y": ("accel_x", 3), - "accel_z": ("accel_y", 3), - "timestamp": ("accel_ts", None), +ACCEL_NAMES = ["accel_3d"] +GYRO_NAMES = ["gyro_3d"] +# FIXME: this :00 needs to be cleaned up +IMU_NAMES = ["bmi323-imu", "BMI0160", "BMI0260", "i2c-10EC5280:00", "i2c-BOSC0260:00"] +SYSFS_TRIG_CONFIG_DIR = os.environ.get("HHD_MOUNT_TRIG_SYSFS", "/var/trig_sysfs_config") + +ACCEL_MAPPINGS: dict[str, tuple[Axis, str | None, float, float | None]] = { + "accel_x": ("accel_z", "accel", 1, None), + "accel_y": ("accel_x", "accel", 1, None), + "accel_z": ("accel_y", "accel", 1, None), + "timestamp": ("accel_ts", None, 1, None), +} +GYRO_MAPPINGS: dict[str, tuple[Axis, str | None, float, float | None]] = { + "anglvel_x": ("gyro_z", "anglvel", 1, None), + "anglvel_y": ("gyro_x", "anglvel", 1, None), + "anglvel_z": ("gyro_y", "anglvel", 1, None), + "timestamp": ("imu_ts", None, 1, None), } -GYRO_MAPPINGS: dict[str, tuple[Axis, float | None]] = { - "anglvel_x": ("gyro_z", None), - "anglvel_y": ("gyro_x", None), - "anglvel_z": ("gyro_y", None), - "timestamp": ("gyro_ts", None), + +BMI_MAPPINGS: dict[str, tuple[Axis, str | None, float, float | None]] = { + "accel_x": ("accel_z", "accel", -1, None), + "accel_y": ("accel_x", "accel", 1, None), + "accel_z": ("accel_y", "accel", -1, None), + "anglvel_x": ("gyro_z", "anglvel", -1, None), + "anglvel_y": ("gyro_x", "anglvel", 1, None), + "anglvel_z": ("gyro_y", "anglvel", -1, None), + "timestamp": ("imu_ts", None, 1, None), } -def find_sensor(sensor: str): +def find_sensor(sensors: Sequence[str]): IIO_BASE_DIR = "/sys/bus/iio/devices/" for d in os.listdir(IIO_BASE_DIR): @@ -58,13 +74,13 @@ def find_sensor(sensor: str): continue with open(name_fn, "r") as f: - name = f.read() + name = f.read().strip() - if name.strip() == sensor: - logger.info(f"Found device '{sensor}' at\n{sensor_dir}") - return sensor_dir + if any(sensor in name for sensor in sensors): + logger.info(f"Found device '{name}' at\n{sensor_dir}") + return sensor_dir, name - return None + return None, None def write_sysfs(dir: str, fn: str, val: Any): @@ -72,17 +88,23 @@ def write_sysfs(dir: str, fn: str, val: Any): f.write(str(val)) -def read_sysfs(dir: str, fn: str): - with open(os.path.join(dir, fn), "r") as f: - return f.read().strip() +def read_sysfs(dir: str, fn: str, default: str | None = None): + try: + with open(os.path.join(dir, fn), "r") as f: + return f.read().strip() + except Exception as e: + if default is not None: + return default + raise e def prepare_dev( sensor_dir: str, type: str, - attr: str, - freq: int | None, - mappings: dict[str, tuple[Axis, float | None]], + attr: Sequence[str], + freq: Sequence[int] | None, + scales: Sequence[str | None] | None, + mappings: dict[str, tuple[Axis, str | None, float, float | None]], update_trigger: bool, ) -> DeviceInfo | None: # Prepare device buffer @@ -92,9 +114,42 @@ def prepare_dev( # Set sampling frequency if freq is not None: - sfn = os.path.join(sensor_dir, f"in_{attr}_sampling_frequency") - if os.path.isfile(sfn): - write_sysfs(sensor_dir, f"in_{attr}_sampling_frequency", freq) + for a, f in zip(attr, freq): + sfn = os.path.join(sensor_dir, f"in_{a}_sampling_frequency") + if os.path.isfile(sfn): + try: + write_sysfs(sensor_dir, f"in_{a}_sampling_frequency", f) + except Exception as e: + logger.error(f"Could not set sampling frequency for {a}:\n{e}") + try: + # Select closest higher frequency instead + sfn = os.path.join( + sensor_dir, f"in_{a}_sampling_frequency_available" + ) + if os.path.isfile(sfn): + freqs = map( + float, + read_sysfs( + sensor_dir, f"in_{a}_sampling_frequency_available" + ).split(), + ) + f = next((x for x in freqs if x >= f), None) + if f: + write_sysfs(sensor_dir, f"in_{a}_sampling_frequency", f) + logger.info( + f"Selected higher sampling frequency {f} for {a}" + ) + except Exception as e: + logger.error(f"Could not set higher sampling frequency for {a}:\n{e}") + + # Set scale + if scales is not None: + for a, s in zip(attr, scales): + if not s: + continue + sfn = os.path.join(sensor_dir, f"in_{a}_scale") + if os.path.isfile(sfn): + write_sysfs(sensor_dir, f"in_{a}_scale", s) # Set trigger if update_trigger: @@ -139,20 +194,39 @@ def prepare_dev( # Prepare scan metadata if fn in mappings: - ax, max_val = mappings[fn] + ax, atr, scale_usr, max_val = mappings[fn] + if atr: + offset = float(read_sysfs(sensor_dir, f"in_{atr}_offset", "0")) + try: + scale = float(read_sysfs(sensor_dir, f"in_{atr}_scale")) + except Exception as e: + scales = read_sysfs(sensor_dir, f"in_{atr}_scale_available") + scale = float(scales.split(" ", 1)[0]) + write_sysfs(sensor_dir, f"in_{atr}_scale", scale) + + logger.warning( + f"Could not read scale for {atr}, setting it to first choice {scale} (avail: {scales}). Error:\n{e}" + ) + else: + offset = 0 + scale = 1 + scale *= scale_usr write_sysfs(sensor_dir, f"scan_elements/in_{fn}_en", 1) else: ax = max_val = None - - if fn != "timestamp": - offset = float(read_sysfs(sensor_dir, f"in_{attr}_offset")) - scale = float(read_sysfs(sensor_dir, f"in_{attr}_scale")) - else: offset = 0 scale = 1 axis[idx] = ScanElement( - ax, endianness, signed, bits, storage_bits, shift, scale, offset, max_val + ax, + endianness, + signed, + bits, + storage_bits, + shift, + scale, + offset, + max_val, ) write_sysfs(sensor_dir, "buffer/enable", 1) @@ -177,33 +251,44 @@ def get_size(dev: DeviceInfo): class IioReader(Producer): def __init__( self, - type: str, - attr: str, - freq: int | None, - mappings: dict[str, tuple[Axis, float | None]], + types: Sequence[str], + attr: Sequence[str], + freq: Sequence[int] | None, + scale: Sequence[str | None] | None, + mappings: dict[str, tuple[Axis, str | None, float, float | None]], update_trigger: bool = False, + legion_fix: bool = False, ) -> None: - self.type = type + self.types = types self.attr = attr self.freq = freq + self.scale = scale self.mappings = mappings self.update_trigger = update_trigger - self.fd = 0 + self.fd = -1 + self.dev = None + self.legion_fix = legion_fix def open(self): - sens_dir = find_sensor(self.type) - if not sens_dir: + sens_dir, type = find_sensor(self.types) + if not sens_dir or not type: return [] dev = prepare_dev( sens_dir, - self.type, + type, self.attr, self.freq, + self.scale, self.mappings, self.update_trigger, ) + if not dev: + logger.error( + "IMU not found for this device, gyro will not work.\n" + + "You need to install the IMU driver for your device, see the readme." + ) return [] self.buf = None @@ -215,12 +300,14 @@ def open(self): return [self.fd] def close(self, exit: bool): - if self.fd: - os.close(self.fd) - self.fd = 0 - if self.dev: - close_dev(self.dev) - self.dev = None + try: + if self.dev: + close_dev(self.dev) + self.dev = None + finally: + if self.fd != -1: + os.close(self.fd) + self.fd = -1 return True def produce(self, fds: Sequence[int]) -> Sequence[Event]: @@ -247,10 +334,10 @@ def produce(self, fds: Sequence[int]) -> Sequence[Event]: if se.axis: # TODO: Implement parsing iio fully, by adding shifting and cutoff d = data[ofs >> 3 : (ofs >> 3) + (se.storage_bits >> 3)] - d = int.from_bytes(d, byteorder=se.endianness, signed=se.signed) + d_raw = int.from_bytes(d, byteorder=se.endianness, signed=se.signed) # d = d >> se.shift # d &= (1 << se.bits) - 1 - d = d * se.scale + se.offset + d = d_raw * se.scale + se.offset if se.max_val is not None: if d > 0: @@ -259,13 +346,27 @@ def produce(self, fds: Sequence[int]) -> Sequence[Event]: d = max(d, -se.max_val) if se.axis not in self.prev or self.prev[se.axis] != d: - out.append( - { - "type": "axis", - "code": se.axis, - "value": d, - } - ) + if not ( + self.legion_fix and (d_raw == -124 or d_raw // 1000 == -125) + ): + # Legion go likes to overflow to -124 in both directions + # skip this number to avoid jitters + # With a kernel patch to allow higher resolution, this happens + # with the following numbers + # 4d 95 f3 c7: -124715 + # 33 97 f3 c7: -124718 + # Reported by hhd: -124422, -124419 + # Legion go axis tester + # import time + # if se.axis == "gyro_x": + # print(f"{time.time() % 1:.3f} {d_raw}") + out.append( + { + "type": "axis", + "code": se.axis, + "value": d, + } + ) self.prev[se.axis] = d ofs += se.storage_bits @@ -277,13 +378,41 @@ def produce(self, fds: Sequence[int]) -> Sequence[Event]: class AccelImu(IioReader): - def __init__(self, freq=None) -> None: - super().__init__("accel_3d", "accel", freq, ACCEL_MAPPINGS) + def __init__(self, freq=None, scale=None) -> None: + super().__init__( + ACCEL_NAMES, ["accel"], [freq] if freq else None, [scale], ACCEL_MAPPINGS + ) class GyroImu(IioReader): - def __init__(self, freq=None) -> None: - super().__init__("gyro_3d", "anglvel", freq, GYRO_MAPPINGS) + def __init__( + self, freq=None, scale=None, map=None, legion_fix: bool = False + ) -> None: + super().__init__( + GYRO_NAMES, + ["anglvel"], + [freq] if freq else None, + [scale], + map if map else GYRO_MAPPINGS, + legion_fix=legion_fix, + ) + + +class CombinedImu(IioReader): + def __init__( + self, + freq: int = 400, + map: dict[str, tuple[Axis, str | None, float, float | None]] | None = None, + gyro_scale: str | None = None, + accel_scale: str | None = None, + ) -> None: + super().__init__( + IMU_NAMES, + ["anglvel", "accel"], + [freq, freq] if freq else None, + [gyro_scale, accel_scale], + map if map is not None else BMI_MAPPINGS, + ) class ForcedSampler: @@ -296,7 +425,7 @@ def open(self): self.fds = [] self.paths = [] for d in self.devices: - f = find_sensor(d) + f, _ = find_sensor([d]) if not f: continue if "accel" in d: @@ -326,60 +455,267 @@ def close(self): os.close(fd) -# class SoftwareTrigger(IioReader): -# BEGIN_ID: int = 72 -# ATTEMPTS: int = 3 - -# def __init__(self, devices: Sequence[str]) -> None: -# self.devices = devices -# self.old_triggers = {} - -# def open(self): -# for id in range( -# SoftwareTrigger.BEGIN_ID, -# SoftwareTrigger.BEGIN_ID + SoftwareTrigger.ATTEMPTS, -# ): -# try: -# with open( -# "/sys/bus/iio/devices/iio_sysfs_trigger/add_trigger", "w" -# ) as f: -# f.write(str(id)) -# break -# except Exception as e: -# print(e) -# else: -# logger.error(f"Failed to create software trigger.") -# return -# self.id = id - -# self.old_triggers = {} -# for d in self.devices: -# s = find_sensor(d) -# if not s: -# continue -# with open(os.path.join(s, "buffer/enable"), "w") as f: -# f.write("0") -# trig_fn = os.path.join(s, "trigger/current_trigger") -# with open(trig_fn, "r") as f: -# self.old_triggers[trig_fn] = f.read() -# with open(trig_fn, "w") as f: -# f.write(f"sysfstrig{self.id}") - -# def close(self): -# for trig, name in self.old_triggers.items(): -# try: -# with open(trig, "w") as f: -# f.write(name) -# except Exception: -# logger.error(f"Could not restore original trigger:\n{trig} to {name}") - -# try: -# with open( -# "/sys/bus/iio/devices/iio_sysfs_trigger/remove_trigger", "w" -# ) as f: -# f.write(str(self.id)) -# except Exception: -# logger.error(f"Could not delete sysfs trigger with id {self.id}") - - -__all__ = ["IioReader", "AccelImu", "GyroImu"] +class HrtimerTrigger(IioReader): + ACCEL_NAMES = ACCEL_NAMES + GYRO_NAMES = GYRO_NAMES + IMU_NAMES = IMU_NAMES + + def __init__( + self, + freq: int, + devices: Sequence[Sequence[str]] = [IMU_NAMES, GYRO_NAMES, ACCEL_NAMES], + ) -> None: + self.freq = freq + self.devices = devices + self.old_triggers = {} + self.opened = False + + def open(self): + import subprocess + + # Initialize modules + try: + subprocess.run(["modprobe", "industrialio-sw-trigger"], capture_output=True) + subprocess.run(["modprobe", "iio-trig-sysfs"], capture_output=True) + subprocess.run(["modprobe", "iio-trig-hrtimer"], capture_output=True) + os.makedirs(SYSFS_TRIG_CONFIG_DIR, exist_ok=True) + subprocess.run( + ["mount", "-t", "configfs", "none", SYSFS_TRIG_CONFIG_DIR], + capture_output=True, + ) + except Exception as e: + logger.warning( + f"Could not initialize software hrtimer. It may be initialized. Error:\n{e}" + ) + + # Create trigger + try: + trig_dir = os.path.join(SYSFS_TRIG_CONFIG_DIR, "iio/triggers/hrtimer/hhd") + if not os.path.isdir(trig_dir): + os.makedirs(trig_dir, exist_ok=True) + except Exception as e: + logger.error( + f"Could not create 'hhd' trigger. IMU will not work. Error:\n{e}" + ) + return False + self.opened = True + + # Find trigger + trig = None + for fn in os.listdir("/sys/bus/iio/devices"): + if not fn.startswith("trigger"): + continue + with open(os.path.join("/sys/bus/iio/devices", fn, "name"), "r") as f: + if f.read().strip() == "hhd": + trig = fn + break + if not trig: + logger.warning("Imu timer trigger not found, IMU will not work.") + return False + + # Set frequency + try: + with open( + os.path.join("/sys/bus/iio/devices", trig, "sampling_frequency"), "w" + ) as f: + f.write(str(self.freq)) + except Exception as e: + logger.warning("Could not set sampling frequency, IMU will not work.") + return False + + self.old_triggers = {} + found = False + for d in self.devices: + s, _ = find_sensor(d) + if not s: + continue + + buff_fn = os.path.join(s, "buffer/enable") + trig_fn = os.path.join(s, "trigger/current_trigger") + with open(buff_fn, "w") as f: + f.write("0") + with open(trig_fn, "r") as f: + self.old_triggers[trig_fn] = (f.read(), buff_fn) + with open(trig_fn, "w") as f: + f.write(f"hhd") + found = True + + if not found: + self.close() + logger.error( + "IMU not found for this device, gyro will not work.\n" + + "You need to install the IMU driver for your device, see the readme." + ) + return False + + return True + + def close(self): + if not self.opened: + return + self.opened = False + + for trig, (name, buff) in self.old_triggers.items(): + try: + with open(buff, "w") as f: + f.write("0") + with open(trig, "w") as f: + f.write(name) + except Exception: + logger.error(f"Could not restore original trigger:\n{trig} to {name}") + + try: + trig_dir = os.path.join(SYSFS_TRIG_CONFIG_DIR, "iio/triggers/hrtimer/hhd") + os.rmdir(trig_dir) + except Exception as e: + logger.error(f"Could not delete hrtimer trigger. Error:\n{e}") + + +def _sysfs_trig_sampler(ev: TEvent, trigger: int, rate: int = 65): + import time + + trig = None + for fn in os.listdir("/sys/bus/iio/devices/"): + if not fn.startswith("trigger"): + continue + tmp = os.path.join("/sys/bus/iio/devices/", fn) + with open(os.path.join(tmp, "name")) as f: + name = f.read().strip() + + if name == f"sysfstrig{trigger}": + trig = os.path.join(tmp, "trigger_now") + break + + if trig is None: + logger.warning(f"Trigger `sysfstrig{trigger}` not found.") + return + + fd = -1 + delay = 1 / rate + try: + fd = os.open(trig, os.O_WRONLY) + while not ev.is_set(): + os.write(fd, b"1") + os.lseek(fd, 0, os.SEEK_SET) + time.sleep(delay) + except KeyboardInterrupt: + raise + except Exception as e: + logger.warning(f"Trig sampler failed with error:\n{e}") + finally: + if fd != -1: + os.close(fd) + + +class SoftwareTrigger(IioReader): + ACCEL_NAMES = ACCEL_NAMES + GYRO_NAMES = GYRO_NAMES + IMU_NAMES = IMU_NAMES + + BEGIN_ID: int = 5335 + ATTEMPTS: int = 900 + + def __init__( + self, + freq: int, + devices: Sequence[Sequence[str]] = [IMU_NAMES, GYRO_NAMES, ACCEL_NAMES], + ) -> None: + self.devices = devices + self.old_triggers = {} + self.freq = freq + self.opened = False + self.ev = None + self.thread = None + + def open(self): + import time + + try: + os.system("modprobe iio-trig-sysfs") + except Exception: + logger.warning(f"Could not modprobe software triggers") + + for id in range( + SoftwareTrigger.BEGIN_ID, + SoftwareTrigger.BEGIN_ID + SoftwareTrigger.ATTEMPTS, + ): + # Try to remove stale trigger + try: + with open( + "/sys/bus/iio/devices/iio_sysfs_trigger/remove_trigger", "w" + ) as f: + f.write(str(id)) + except Exception: + pass + # Add new trigger + try: + with open( + "/sys/bus/iio/devices/iio_sysfs_trigger/add_trigger", "w" + ) as f: + f.write(str(id)) + break + except Exception: + pass + time.sleep(0.02) + else: + logger.error(f"Failed to create software trigger.") + return False + self.id = id + + self.old_triggers = {} + for d in self.devices: + s, _ = find_sensor(d) + if not s: + continue + + buff_fn = os.path.join(s, "buffer/enable") + trig_fn = os.path.join(s, "trigger/current_trigger") + with open(buff_fn, "w") as f: + f.write("0") + with open(trig_fn, "r") as f: + self.old_triggers[trig_fn] = (f.read(), buff_fn) + with open(trig_fn, "w") as f: + f.write(f"sysfstrig{self.id}") + + self.ev = TEvent() + self.thread = Thread(target=_sysfs_trig_sampler, args=(self.ev, id, self.freq)) + self.thread.start() + self.opened = True + + return True + + def close(self): + if not self.opened: + return + + # Stop trigger + self.opened = False + if self.ev: + self.ev.set() + if self.thread: + self.thread.join() + self.ev = None + self.thread = None + + # Remove from current sensors + for trig, (name, buff) in self.old_triggers.items(): + try: + with open(buff, "w") as f: + f.write("0") + with open(trig, "w") as f: + f.write(name) + except Exception: + logger.error(f"Could not restore original trigger:\n{trig} to {name}") + + # Delete trigger + try: + logger.info(f"Closing trigger {self.id}") + with open( + "/sys/bus/iio/devices/iio_sysfs_trigger/remove_trigger", "w" + ) as f: + f.write(str(self.id)) + except Exception: + logger.error(f"Could not delete sysfs trigger with id {self.id}") + + +__all__ = ["IioReader", "AccelImu", "GyroImu", "HrtimerTrigger"] diff --git a/src/hhd/controller/physical/rgb.py b/src/hhd/controller/physical/rgb.py new file mode 100644 index 00000000..3d4d9f59 --- /dev/null +++ b/src/hhd/controller/physical/rgb.py @@ -0,0 +1,174 @@ +import logging +import os +import time +from threading import Event as TEvent +from threading import Thread +from typing import Any, Sequence + +from hhd.controller import Consumer +from hhd.controller.base import Event, RgbLedEvent + +LED_BASE = "/sys/class/leds/" +LED_PATHS = [ + "multicolor:chassis", + ":rgb:joystick_rings", +] + +logger = logging.getLogger(__name__) + + +def write_sysfs(dir: str, fn: str, val: Any): + logger.info(f'Writing `{str(val)}` to \n"{os.path.join(dir, fn)}"') + with open(os.path.join(dir, fn), "w") as f: + f.write(str(val)) + + +def read_sysfs(dir: str, fn: str, default: str | None = None): + try: + with open(os.path.join(dir, fn), "r") as f: + return f.read().strip() + except Exception as e: + if default is not None: + return default + raise e + + +def get_led_path(): + for fn in os.listdir(LED_BASE): + for p in LED_PATHS: + if p in fn: + return os.path.join(LED_BASE, fn) + return None + + +def is_led_supported(): + return get_led_path() is not None + + +def chassis_led_set(ev: RgbLedEvent, init: bool = True): + if ev["type"] != "led": + return + + led_path = get_led_path() + if not led_path: + return + + match ev["mode"]: + case "solid": + r_mode = 1 + case _: + r_mode = 0 + + max_brightness = 255 + try: + max_brightness = int(read_sysfs(led_path, "max_brightness", "255")) + except Exception: + pass + + r_brightness = max(min(int(ev["brightness"] * max_brightness), max_brightness), 0) + r_red = max(min(ev["red"], 255), 0) + r_green = max(min(ev["green"], 255), 0) + r_blue = max(min(ev["blue"], 255), 0) + + # Mode only exists on ayn devices + if init: + try: + write_sysfs(led_path, "led_mode", r_mode) + except Exception: + logger.info( + "Could not write led_mode (not applicable for Ayaneo, only Ayn)." + ) + try: + write_sysfs(led_path, "device/led_mode", r_mode) + except Exception: + logger.info("Could not write led_mode to secondary path.") + + write_sysfs(led_path, "brightness", r_brightness) + + pattern = read_sysfs(led_path, "multi_index", "red green blue") + arr = [] + for color in pattern.split(" "): + if color == "red": + arr.append(r_red) + elif color == "green": + arr.append(r_green) + elif color == "blue": + arr.append(r_blue) + else: + arr.append(0) + + write_sysfs(led_path, "multi_intensity", " ".join(map(str, arr))) + + +def thread_chassis_led_set(ev: RgbLedEvent, pending: TEvent, error: TEvent): + try: + chassis_led_set(ev) + except Exception as e: + logger.error(f"Setting leds failed with error:\n{e}") + # Turn off support + error.set() + chassis_led_set(ev) + pending.clear() + + +class LedDevice(Consumer): + def __init__(self, rate_limit: float = 10, threading: bool = False) -> None: + self.supported = is_led_supported() + self.min_delay = 1 / rate_limit + self.queued = None + self.last = time.time() - self.min_delay + + self.threading = threading + self.pending = TEvent() + self.error = TEvent() + self.t = None + self.init = False + + def consume(self, events: Sequence[Event]): + if not self.supported: + return + + if self.error.isSet(): + self.supported = False + return + + curr = time.time() + ev = None + + # Pop queued event if possible + if self.queued: + e, t = self.queued + if curr > t: + e = ev + self.queued = None + + # Find newer event if it exists + for e in events: + if e["type"] == "led": + ev = e + # Clear queue since there + # is a newer event + self.queued = None + + # If no led event return + if ev is None: + return + + if curr > self.last + self.min_delay and not self.pending.is_set(): + if self.threading: + self.pending.set() + self.t = Thread( + target=thread_chassis_led_set, args=(ev, self.pending, self.error) + ) + self.t.start() + else: + try: + chassis_led_set(ev, not self.init) + self.init = True + except Exception as e: + logger.error(f"Setting leds failed with error:\n{e}") + # Turn off support + self.supported = False + self.last = curr + else: + self.queued = (ev, curr + self.min_delay) diff --git a/src/hhd/controller/virtual/ds5/__init__.py b/src/hhd/controller/virtual/dualsense/__init__.py similarity index 50% rename from src/hhd/controller/virtual/ds5/__init__.py rename to src/hhd/controller/virtual/dualsense/__init__.py index c67654e1..ab7756e8 100644 --- a/src/hhd/controller/virtual/ds5/__init__.py +++ b/src/hhd/controller/virtual/dualsense/__init__.py @@ -1,19 +1,22 @@ import logging import time from collections import defaultdict -from typing import Literal, NamedTuple, Sequence, cast - -from hhd.controller import Consumer, Event, Producer +from typing import Sequence, cast, Literal + +from hhd.controller import ( + Consumer, + Event, + Producer, + TouchpadCorrectionType, + correct_touchpad, +) from hhd.controller.lib.common import encode_axis, set_button -from hhd.controller.lib.uhid import UhidDevice, BUS_USB, BUS_BLUETOOTH +from hhd.controller.lib.uhid import BUS_BLUETOOTH, BUS_USB, UhidDevice +from hhd.controller.lib.ccache import ControllerCache from .const import ( - DS5_USB_AXIS_MAP, - DS5_USB_BTN_MAP, DS5_BT_AXIS_MAP, DS5_BT_BTN_MAP, - DS5_INPUT_REPORT_BT_OFS, - DS5_INPUT_REPORT_USB_OFS, DS5_EDGE_COUNTRY, DS5_EDGE_DELTA_TIME_NS, DS5_EDGE_DESCRIPTOR_BT, @@ -22,160 +25,77 @@ DS5_EDGE_MIN_REPORT_FREQ, DS5_EDGE_NAME, DS5_EDGE_PRODUCT, - prefill_ds5_report, + DS5_EDGE_REPORT_PAIRING, + DS5_EDGE_REPORT_PAIRING_ID, DS5_EDGE_STOCK_REPORTS, DS5_EDGE_TOUCH_HEIGHT, - sign_crc32_append, - DS5_FEATURE_CRC32_SEED, - DS5_INPUT_CRC32_SEED, - sign_crc32_inplace, DS5_EDGE_TOUCH_WIDTH, - DS5_EDGE_VENDOR, DS5_EDGE_VERSION, + DS5_FEATURE_CRC32_SEED, + DS5_INPUT_CRC32_SEED, + DS5_INPUT_REPORT_BT_OFS, + DS5_INPUT_REPORT_USB_OFS, + DS5_NAME, + DS5_PRODUCT, + DS5_USB_AXIS_MAP, + DS5_USB_BTN_MAP, + DS5_NAME_LEFT, + DS5_VENDOR, patch_dpad_val, + prefill_ds5_report, + sign_crc32_append, + sign_crc32_inplace, ) REPORT_MAX_DELAY = 1 / DS5_EDGE_MIN_REPORT_FREQ REPORT_MIN_DELAY = 1 / DS5_EDGE_MAX_REPORT_FREQ DS5_EDGE_MIN_TIMESTAMP_INTERVAL = 1500 +MAX_IMU_SYNC_DELAY = 2 logger = logging.getLogger(__name__) +_cache = ControllerCache() +_cache_left = ControllerCache() -class TouchpadCorrection(NamedTuple): - x_mult: float = 1 - x_ofs: float = 0 - x_clamp: tuple[float, float] = (0, 1) - y_mult: float = 1 - y_ofs: float = 0 - y_clamp: tuple[float, float] = (0, 1) - - -TouchpadCorrectionType = Literal[ - "stretch", - "crop_center", - "crop_start", - "crop_end", - "contain_start", - "contain_end", - "contain_center", - "disabled", -] - - -def correct_touchpad( - width: int, height: int, aspect: float, method: TouchpadCorrectionType -): - dst = width / height - src = aspect - ratio = dst / src - - match method: - case "crop_center": - if ratio > 1: - new_width = width / ratio - return TouchpadCorrection( - x_mult=new_width, - x_ofs=(width - new_width) / 2, - y_mult=height, - y_ofs=0, - ) - else: - new_height = height * ratio - return TouchpadCorrection( - x_mult=width, - x_ofs=0, - y_mult=new_height, - y_ofs=(height - new_height) / 2, - ) - case "crop_start": - if ratio > 1: - new_width = width / ratio - return TouchpadCorrection( - x_mult=new_width, - x_ofs=0, - y_mult=height, - y_ofs=0, - ) - else: - new_height = height * ratio - return TouchpadCorrection( - x_mult=width, - x_ofs=0, - y_mult=new_height, - y_ofs=0, - ) - case "crop_end": - if ratio > 1: - new_width = width / ratio - return TouchpadCorrection( - x_mult=new_width, - x_ofs=(width - new_width), - y_mult=height, - y_ofs=0, - ) - else: - new_height = height * ratio - return TouchpadCorrection( - x_mult=width, - x_ofs=0, - y_mult=new_height, - y_ofs=(height - new_height), - ) - case "contain_center": - if ratio > 1: - bound = (ratio - 1) / ratio / 2 - return TouchpadCorrection( - x_mult=width, y_mult=height, y_clamp=(bound, 1 - bound) - ) - else: - bound = (1 - ratio) / 2 - return TouchpadCorrection( - x_mult=width, y_mult=height, x_clamp=(bound, 1 - bound) - ) - case "contain_start": - if ratio > 1: - bound = (ratio - 1) / ratio - return TouchpadCorrection( - x_mult=width, y_mult=height, y_clamp=(0, 1 - bound) - ) - else: - bound = (1 - ratio) / 2 - return TouchpadCorrection( - x_mult=width, y_mult=height, x_clamp=(0, 1 - bound) - ) - case "contain_end": - if ratio > 1: - bound = (ratio - 1) / ratio - return TouchpadCorrection( - x_mult=width, y_mult=height, y_clamp=(bound, 1) - ) - else: - bound = (1 - ratio) / 2 - return TouchpadCorrection( - x_mult=width, y_mult=height, x_clamp=(bound, 1) - ) - case "stretch" | "disabled": - return TouchpadCorrection(x_mult=width, y_mult=height) - - logger.error(f"Touchpad correction method '{method}' not found.") - return TouchpadCorrection(x_mult=width, y_mult=height) +class Dualsense(Producer, Consumer): + @staticmethod + def close_cached(): + _cache.close() + _cache_left.close() -class DualSense5Edge(Producer, Consumer): def __init__( self, touchpad_method: TouchpadCorrectionType = "crop_end", + edge_mode: bool = True, use_bluetooth: bool = True, fake_timestamps: bool = False, + enable_touchpad: bool = True, + enable_rgb: bool = True, + sync_gyro: bool = False, + flip_z: bool = True, + paddles_to_clicks: Literal["disabled", "top", "bottom"] = "disabled", + controller_id: int = 0, + left_motion: bool = False, + cache: bool = False, ) -> None: self.available = False self.report = None self.dev = None self.start = 0 self.use_bluetooth = use_bluetooth + self.edge_mode = edge_mode self.fake_timestamps = fake_timestamps self.touchpad_method: TouchpadCorrectionType = touchpad_method + self.enable_touchpad = enable_touchpad + self.enable_rgb = enable_rgb + self.sync_gyro = sync_gyro + self.flip_z = flip_z + self.paddles_to_clicks = paddles_to_clicks + self.controller_id = controller_id + self.left_motion = left_motion + self.cache = cache + self.last_imu_ts = 0 self.ofs = ( DS5_INPUT_REPORT_BT_OFS if use_bluetooth else DS5_INPUT_REPORT_USB_OFS @@ -186,17 +106,50 @@ def __init__( def open(self) -> Sequence[int]: self.available = False self.report = bytearray(prefill_ds5_report(self.use_bluetooth)) - self.dev = UhidDevice( - vid=DS5_EDGE_VENDOR, - pid=DS5_EDGE_PRODUCT, - bus=BUS_BLUETOOTH if self.use_bluetooth else BUS_USB, - version=DS5_EDGE_VERSION, - country=DS5_EDGE_COUNTRY, - name=DS5_EDGE_NAME, - report_descriptor=DS5_EDGE_DESCRIPTOR_BT - if self.use_bluetooth - else DS5_EDGE_DESCRIPTOR_USB, + + cached = cast( + Dualsense | None, _cache_left.get() if self.left_motion else _cache.get() + ) + + # Use cached controller to avoid disconnects + self.dev = None + if cached: + if ( + self.edge_mode == cached.edge_mode + and self.use_bluetooth == cached.use_bluetooth + and self.controller_id == cached.controller_id + ): + logger.warning( + f"Using cached controller node for Dualsense {'left motions device' if self.left_motion else 'controller'}." + ) + self.dev = cached.dev + if self.dev and self.dev.fd: + self.fd = self.dev.fd + else: + logger.warning( + f"Throwing away cached Dualsense for {'left motions device' if self.left_motion else 'controller'}." + ) + cached.close(True, in_cache=True) + name = ( + (DS5_EDGE_NAME if self.edge_mode else DS5_NAME) + if not self.left_motion + else DS5_NAME_LEFT ) + if not self.dev: + self.dev = UhidDevice( + vid=DS5_VENDOR, + pid=DS5_EDGE_PRODUCT if self.edge_mode else DS5_PRODUCT, + bus=BUS_BLUETOOTH if self.use_bluetooth else BUS_USB, + version=DS5_EDGE_VERSION, + country=DS5_EDGE_COUNTRY, + name=name, + report_descriptor=( + DS5_EDGE_DESCRIPTOR_BT + if self.use_bluetooth + else DS5_EDGE_DESCRIPTOR_USB + ), + ) + self.fd = self.dev.open() self.touch_correction = correct_touchpad( DS5_EDGE_TOUCH_WIDTH, DS5_EDGE_TOUCH_HEIGHT, 1, self.touchpad_method @@ -205,16 +158,26 @@ def open(self) -> Sequence[int]: self.state: dict = defaultdict(lambda: 0) self.rumble = False self.touchpad_touch = False - self.start = time.perf_counter_ns() - self.fd = self.dev.open() + curr = time.perf_counter() + self.touchpad_down = curr + self.last_imu = curr + self.imu_failed = False + self.start = time.perf_counter() + + logger.info(f"Starting '{name.decode()}'.") + assert self.fd return [self.fd] - def close(self, exit: bool) -> bool: - if not exit: - """This is a consumer, so we would deadlock if it was disabled.""" - return False - - if self.dev: + def close(self, exit: bool, in_cache: bool = False) -> bool: + if not in_cache and self.cache and time.perf_counter() - self.start: + logger.warning( + f"Caching Dualsense {'left motions device' if self.left_motion else 'controller'} to avoid reconnection." + ) + if self.left_motion: + _cache_left.add(self) + else: + _cache.add(self) + elif self.dev: self.dev.send_destroy() self.dev.close() self.dev = None @@ -237,7 +200,14 @@ def produce(self, fds: Sequence[int]) -> Sequence[Event]: self.available = False case "get_report": if ev["rnum"] in DS5_EDGE_STOCK_REPORTS: - rep = DS5_EDGE_STOCK_REPORTS[ev["rnum"]] + num = ev["rnum"] + if num == DS5_EDGE_REPORT_PAIRING_ID: + # Customize pairing report to have per-controller + # calibration + rep = DS5_EDGE_REPORT_PAIRING(self.controller_id) + else: + rep = DS5_EDGE_STOCK_REPORTS[num] + if self.use_bluetooth: rep = sign_crc32_append(rep, DS5_FEATURE_CRC32_SEED) self.dev.send_get_report_reply(ev["id"], 0, rep) @@ -254,7 +224,6 @@ def produce(self, fds: Sequence[int]) -> Sequence[Event]: # Check report num if ev["report"] != 0x01: invalid = True - # Check report ids depending on modes if not self.use_bluetooth and ev["data"][0] != 0x02: invalid = True @@ -268,7 +237,6 @@ def produce(self, fds: Sequence[int]) -> Sequence[Event]: continue rep = ev["data"] - if self.use_bluetooth: # skip seq_tag, tag sent by bluetooth report # rest is the same @@ -283,7 +251,12 @@ def produce(self, fds: Sequence[int]) -> Sequence[Event]: else: rep = rep[0:1] + rep[3:] - if rep[2] & 4: # DS_OUTPUT_VALID_FLAG1_LIGHTBAR_CONTROL_ENABLE + flag0 = rep[1] + flag1 = rep[2] + flag2 = rep[39] + if self.enable_rgb and ( + flag1 & 4 + ): # DS_OUTPUT_VALID_FLAG1_LIGHTBAR_CONTROL_ENABLE # Led data is being set led_brightness = rep[43] player_leds = rep[44] @@ -300,48 +273,76 @@ def produce(self, fds: Sequence[int]) -> Sequence[Event]: # Skip rare SDL led initialization that is offset continue logger.info(f"Changing leds to RGB: {red} {green} {blue}") + + # Crunch lower values since steam is bugged + if red < 3 and green < 3 and blue < 3: + red = 0 + green = 0 + blue = 0 + out.append( { "type": "led", "code": "main", "mode": "solid", - "brightness": led_brightness / 63 - if led_brightness - else 1, + # "brightness": led_brightness / 63 + # if led_brightness + # else 1, + "initialize": False, + "direction": "left", "speed": 0, + "brightness": 1, + "speedd": "high", + "brightnessd": "high", "red": red, "blue": blue, "green": green, + "red2": 0, # disable for OXP + "blue2": 0, + "green2": 0, + "oxp": None, } ) - elif (rep[39] & 2) and (rep[42] & 2): - # flag2 is DS_OUTPUT_VALID_FLAG2_LIGHTBAR_SETUP_CONTROL_ENABLE - # lightbar_setup is DS_OUTPUT_LIGHTBAR_SETUP_LIGHT_OUT - # FIXME: Disable for now to avoid hid_playstation messing - # with the leds - # out.append( - # { - # "type": "led", - # "code": "main", - # "mode": "disable", - # "brightness": 0, - # "speed": 0, - # "red": 0, - # "blue": 0, - # "green": 0, - # } - # ) - pass - - if rep[1] & 0x02: + # elif (rep[39] & 2) and (rep[42] & 2): + # # flag2 is DS_OUTPUT_VALID_FLAG2_LIGHTBAR_SETUP_CONTROL_ENABLE + # # lightbar_setup is DS_OUTPUT_LIGHTBAR_SETUP_LIGHT_OUT + # # FIXME: Disable for now to avoid hid_playstation messing + # # with the leds + # out.append( + # { + # "type": "led", + # "code": "main", + # "mode": "disable", + # "brightness": 0, + # "speed": 0, + # "red": 0, + # "blue": 0, + # "green": 0, + # } + # ) + # pass + + # Rumble + # Flag 1 + # Death stranding uses 0x40 to turn on vibration + # SDL uses 0x02 to disable audio haptics + # old version used flag0 & 0x02 + # Initial compatibility rumble is flag0 0x01 + # Improved is flag2 0x04 + if flag0 & 0x01 or flag2 & 0x04: right = rep[3] left = rep[4] + + # If vibration mode is in flag0 use different scale + scale = 2 if flag0 & 0x01 else 1 + out.append( { "type": "rumble", "code": "main", - "strong_magnitude": left / 63, - "weak_magnitude": right / 63, + # For some reason goes to 127 + "strong_magnitude": left / 255 * scale, + "weak_magnitude": right / 255 * scale, } ) self.rumble = True @@ -361,15 +362,39 @@ def produce(self, fds: Sequence[int]) -> Sequence[Event]: def consume(self, events: Sequence[Event]): assert self.dev and self.report + # To fix gyro to mouse in latest steam + # only send updates when gyro sends a timestamp + send = not self.sync_gyro + curr = time.perf_counter() new_rep = bytearray(self.report) for ev in events: + code = ev["code"] match ev["type"]: case "axis": - if ev["code"] in self.axis_map: - encode_axis(new_rep, self.axis_map[ev["code"]], ev["value"]) + if not self.enable_touchpad and code.startswith("touchpad"): + continue + if self.left_motion: + # Only left keep imu events for left motion + if ( + "left_gyro_" in code + or "left_accel_" in code + or "left_imu_" in code + ): + code = code.replace("left_", "") + else: + continue + if code in self.axis_map: + if self.flip_z and code == "gyro_z": + ev["value"] = -ev["value"] + try: + encode_axis(new_rep, self.axis_map[code], ev["value"]) + except Exception: + logger.warning( + f"Encoding '{ev['code']}' with {ev['value']} overflowed." + ) # DPAD is weird - match ev["code"]: + match code: case "hat_x": self.state["hat_x"] = ev["value"] patch_dpad_val( @@ -408,19 +433,54 @@ def consume(self, events: Sequence[Event]): (y & 0x0F) << 4 ) new_rep[self.ofs + 35] = y >> 4 - case "gyro_ts": + case "gyro_ts" | "accel_ts" | "imu_ts": + send = True + self.last_imu = time.perf_counter() + self.last_imu_ts = ev["value"] new_rep[self.ofs + 27 : self.ofs + 31] = int( ev["value"] / DS5_EDGE_DELTA_TIME_NS ).to_bytes(8, byteorder="little", signed=False)[:4] case "button": - if ev["code"] in self.btn_map: - set_button(new_rep, self.btn_map[ev["code"]], ev["value"]) - - # Fix touchpad click requiring touch, and also activate second - # button for right click - if ev["code"] == "touchpad_touch": + if self.left_motion: + # skip buttons for left motion + continue + if not self.enable_touchpad and code.startswith("touchpad"): + continue + if (self.paddles_to_clicks == "top" and code == "extra_l1") or ( + self.paddles_to_clicks == "bottom" and code == "extra_l2" + ): + # Place finger on correct place and click + new_rep[self.ofs + 33] = 0x80 + new_rep[self.ofs + 34] = 0x01 + new_rep[self.ofs + 35] = 0x20 + # Replace code with click + ev = {**ev, "code": "touchpad_left"} + code = "touchpad_left" + if (self.paddles_to_clicks == "top" and code == "extra_r1") or ( + self.paddles_to_clicks == "bottom" and code == "extra_r2" + ): + # Place finger on correct place and click + new_rep[self.ofs + 33] = 0x00 + new_rep[self.ofs + 34] = 0x06 + new_rep[self.ofs + 35] = 0x20 + # Replace code with click + ev = {**ev, "code": "touchpad_left"} + code = "touchpad_left" + + if code in self.btn_map: + set_button(new_rep, self.btn_map[code], ev["value"]) + + # Fix touchpad click requiring touch + if code == "touchpad_touch": self.touchpad_touch = ev["value"] - if ev["code"] == "touchpad_click": + if code == "touchpad_left": + set_button( + new_rep, + self.btn_map["touchpad_touch"], + ev["value"] or self.touchpad_touch, + ) + # Also add right click + if code == "touchpad_right": set_button( new_rep, self.btn_map["touchpad_touch"], @@ -433,7 +493,9 @@ def consume(self, events: Sequence[Event]): ) case "configuration": - match ev["code"]: + if self.left_motion: + continue + match code: case "touchpad_aspect_ratio": self.aspect_ratio = cast(float, ev["value"]) self.touch_correction = correct_touchpad( @@ -452,11 +514,21 @@ def consume(self, events: Sequence[Event]): ) # Cache - if new_rep == self.report and not self.fake_timestamps: - return + # Caching can cause issues since receivers expect reports + # at least a couple of times per second + # if new_rep == self.report and not self.fake_timestamps: + # return self.report = new_rep - if self.fake_timestamps: + # If the IMU breaks, smoothly re-enable the controller + failover = self.last_imu + MAX_IMU_SYNC_DELAY < curr + if self.sync_gyro and failover and not self.imu_failed: + self.imu_failed = True + logger.error( + f"IMU Did not send information for {MAX_IMU_SYNC_DELAY}s. Disabling Gyro Sync." + ) + + if self.fake_timestamps or failover: new_rep[self.ofs + 27 : self.ofs + 31] = int( time.perf_counter_ns() / DS5_EDGE_DELTA_TIME_NS ).to_bytes(8, byteorder="little", signed=False)[:4] @@ -472,4 +544,5 @@ def consume(self, events: Sequence[Event]): if self.use_bluetooth: sign_crc32_inplace(self.report, DS5_INPUT_CRC32_SEED) - self.dev.send_input_report(self.report) + if send or failover: + self.dev.send_input_report(self.report) diff --git a/src/hhd/controller/virtual/ds5/const.py b/src/hhd/controller/virtual/dualsense/const.py similarity index 94% rename from src/hhd/controller/virtual/ds5/const.py rename to src/hhd/controller/virtual/dualsense/const.py index 27e773c5..ad90c9af 100644 --- a/src/hhd/controller/virtual/ds5/const.py +++ b/src/hhd/controller/virtual/dualsense/const.py @@ -1,13 +1,15 @@ from binascii import crc32 -from hhd.controller import Axis, Button from hhd.controller.lib.common import AM, BM -DS5_EDGE_VENDOR = 0x054C +DS5_VENDOR = 0x054C +DS5_PRODUCT = 0x0CE6 DS5_EDGE_PRODUCT = 0x0DF2 DS5_EDGE_VERSION = 256 DS5_EDGE_COUNTRY = 0 -DS5_EDGE_NAME = b"Emulated Sony DS5 Edge Controller" +DS5_NAME = b"Sony Interactive Entertainment DualSense Wireless Controller" +DS5_EDGE_NAME = b"Sony Interactive Entertainment DualSense Edge Wireless Controller" +DS5_NAME_LEFT = b"Handheld Daemon Left Motions Device (Dualsense)" DS5_EDGE_MIN_REPORT_FREQ = 25 DS5_EDGE_MAX_REPORT_FREQ = 1000 @@ -115,6 +117,11 @@ def prefill_ds5_report(bluetooth: bool): # bat = 10*lvl + 5 d[ofs + 52] = (0x0 << 4) + 8 + # Add dummy data in case the gyro is broken + d[ofs + 27 : ofs + 31] = int(28742700000000 / DS5_EDGE_DELTA_TIME_NS).to_bytes( + 8, byteorder="little", signed=False + )[:4] + return bytes(d) @@ -125,12 +132,18 @@ def prefill_ds5_report(bluetooth: bool): "rs_y": AM(((ofs + 3) << 3), "m8"), "rt": AM(((ofs + 4) << 3), "u8"), "lt": AM(((ofs + 5) << 3), "u8"), - "gyro_x": AM(((ofs + 15) << 3), "i16", scale=20), - "gyro_y": AM(((ofs + 17) << 3), "i16", scale=20), - "gyro_z": AM(((ofs + 19) << 3), "i16", scale=20, flipped=True), - "accel_x": AM(((ofs + 21) << 3), "i16", scale=10000), - "accel_y": AM(((ofs + 23) << 3), "i16", scale=10000), - "accel_z": AM(((ofs + 25) << 3), "i16", scale=10000), + "gyro_x": AM(((ofs + 15) << 3), "i16", scale=20 * 180 / 3.14), + "gyro_y": AM(((ofs + 17) << 3), "i16", scale=20 * 180 / 3.14), + "gyro_z": AM(((ofs + 19) << 3), "i16", scale=20 * 180 / 3.14), + "accel_x": AM( + ((ofs + 21) << 3), "i16", scale=1019, bounds=(-(2**15) + 2, 2**15 - 1) + ), + "accel_y": AM( + ((ofs + 23) << 3), "i16", scale=1019, bounds=(-(2**15) + 2, 2**15 - 1) + ), + "accel_z": AM( + ((ofs + 25) << 3), "i16", scale=1019, bounds=(-(2**15) + 2, 2**15 - 1) + ), } DS5_BT_AXIS_MAP = get_axis_map(DS5_INPUT_REPORT_BT_OFS) @@ -157,7 +170,7 @@ def prefill_ds5_report(bluetooth: bool): "share": BM(((ofs + 9) << 3) + 5), "touchpad_touch": BM(((ofs + 32) << 3), flipped=True), "touchpad_touch2": BM(((ofs + 36) << 3), flipped=True), - "touchpad_click": BM(((ofs + 9) << 3) + 6), + "touchpad_left": BM(((ofs + 9) << 3) + 6), "mode": BM(((ofs + 9) << 3) + 7), } @@ -991,7 +1004,33 @@ def prefill_ds5_report(bluetooth: bool): ] ) -DS5_EDGE_MAC_ADDR = [0x74, 0xE7, 0xD6, 0x3A, 0x47, 0xE8] +DS5_EDGE_MAC_ADDR = [0x74, 0xE7, 0xD6, 0x3A, 0x53, 0x35] # 0x47, 0xE8] +DS5_EDGE_REPORT_PAIRING_ID = 9 +DS5_EDGE_REPORT_PAIRING = lambda idx: bytes( + # Customize Pairing Report Id + [ + 0x09, + DS5_EDGE_MAC_ADDR[0], + DS5_EDGE_MAC_ADDR[1], + DS5_EDGE_MAC_ADDR[2], + idx or DS5_EDGE_MAC_ADDR[3], + DS5_EDGE_MAC_ADDR[4], + DS5_EDGE_MAC_ADDR[5], + 0x08, + 0x25, + 0x00, + 0x1E, + 0x00, + 0xEE, + 0x74, + 0xD0, + 0xBC, + 0x00, + 0x00, + 0x00, + 0x00, + ] +) DS5_EDGE_STOCK_REPORTS = { 0x09: bytes( # Pairing [ diff --git a/src/hhd/controller/virtual/sd/__init__.py b/src/hhd/controller/virtual/sd/__init__.py index d51e9b0f..a5bd84c6 100644 --- a/src/hhd/controller/virtual/sd/__init__.py +++ b/src/hhd/controller/virtual/sd/__init__.py @@ -18,6 +18,19 @@ logger = logging.getLogger(__name__) +def trim(rep: bytes): + if not rep: + return rep + idx = len(rep) - 1 + while idx > 0 and rep[idx] == 0x00: + idx -= 1 + return rep[: idx + 1] + + +def pad(rep): + return bytes(rep) + bytes([0 for _ in range(64 - len(rep))]) + + class SteamdeckOLEDController(Producer, Consumer): def __init__( self, @@ -26,6 +39,7 @@ def __init__( self.report = None self.dev = None self.start = 0 + self.last_rep = None def open(self) -> Sequence[int]: self.available = False @@ -61,7 +75,48 @@ def close(self, exit: bool) -> bool: return True def produce(self, fds: Sequence[int]) -> Sequence[Event]: - return [] + if not self.fd or not self.dev or self.fd not in fds: + return [] + + # Process queued events + out: Sequence[Event] = [] + assert self.dev + while ev := self.dev.read_event(): + match ev["type"]: + case "open": + logger.info(f"OPENED") + case "close": + logger.info(f"CLOSED") + case "get_report": + match self.last_rep: + case 0xAE: + rep = bytes( + [ + 0x00, + 0xAE, + 0x15, + 0x01, + *[0x10 for _ in range(15)], + ] + ) + case _: + rep = bytes([]) + self.dev.send_get_report_reply(ev["id"], 0, pad(rep)) + logger.info( + f"GET_REPORT: {ev}\nRESPONSE({self.last_rep:02x}): {rep.hex()}" + ) + case "set_report": + self.dev.send_set_report_reply(ev["id"], 0) + logger.info( + f"SET_REPORT({ev['rnum']:02x}:{ev['rtype']:02x}): {trim(ev['data']).hex()}" + ) + self.last_rep = ev["data"][3] + case "output": + logger.info(f"OUTPUT") + case _: + logger.warning(f"UKN_EVENT: {ev}") + + return out def consume(self, events: Sequence[Event]): pass diff --git a/src/hhd/controller/virtual/uinput.py b/src/hhd/controller/virtual/uinput.py deleted file mode 100644 index f662bdab..00000000 --- a/src/hhd/controller/virtual/uinput.py +++ /dev/null @@ -1,117 +0,0 @@ -import logging -from typing import Sequence, cast - -import evdev -from evdev import UInput - -from hhd.controller import Axis, Button, Consumer, Producer -from hhd.controller.base import Event, can_read - - -def B(b: str): - return cast(int, getattr(evdev.ecodes, b)) - - -logger = logging.getLogger(__name__) - -GAMEPAD_BTN_CAPABILITIES = { - B("EV_KEY"): [ - B("BTN_TL"), - B("BTN_TR"), - B("BTN_SELECT"), - B("BTN_START"), - B("BTN_MODE"), - B("BTN_THUMBL"), - B("BTN_THUMBR"), - B("BTN_A"), - B("BTN_B"), - B("BTN_X"), - B("BTN_Y"), - B("BTN_MODE"), - B("BTN_TRIGGER_HAPPY1"), - B("BTN_TRIGGER_HAPPY2"), - B("BTN_TRIGGER_HAPPY3"), - B("BTN_TRIGGER_HAPPY4"), - B("BTN_TRIGGER_HAPPY5"), - B("BTN_TRIGGER_HAPPY6"), - ] -} -STANDARD_BUTTON_MAP: dict[Button, int] = { - # Gamepad - "a": B("BTN_A"), - "b": B("BTN_B"), - "x": B("BTN_X"), - "y": B("BTN_Y"), - # Sticks - "ls": B("BTN_THUMBL"), - "rs": B("BTN_THUMBR"), - # Bumpers - "lb": B("BTN_TL"), - "rb": B("BTN_TR"), - # Select - "start": B("BTN_START"), - "select": B("BTN_SELECT"), - # Misc - "mode": B("BTN_MODE"), - # Back buttons - "extra_l1": B("BTN_TRIGGER_HAPPY1"), - "extra_l2": B("BTN_TRIGGER_HAPPY2"), - "extra_l3": B("BTN_TRIGGER_HAPPY5"), - "extra_r1": B("BTN_TRIGGER_HAPPY3"), - "extra_r2": B("BTN_TRIGGER_HAPPY4"), - "extra_r3": B("BTN_TRIGGER_HAPPY6"), -} - - -class UInputDevice(Consumer, Producer): - def __init__( - self, - capabilities=GAMEPAD_BTN_CAPABILITIES, - btn_map: dict[Button, int] = STANDARD_BUTTON_MAP, - axis_map: dict[Axis, int] = {}, - vid: int = 2, - pid: int = 2, - name: str = "HHD Shortcuts Device", - ) -> None: - self.capabilities = capabilities - self.btn_map = btn_map - self.axis_map = axis_map - self.dev = None - self.name = name - self.vid = vid - self.pid = pid - - def open(self) -> Sequence[int]: - logger.info(f"Opening virtual device '{self.name}'") - self.dev = UInput( - events=self.capabilities, name=self.name, vendor=self.vid, product=self.pid - ) - self.fd = self.dev.fd - return [self.fd] - - def close(self, exit: bool) -> bool: - if self.dev: - self.dev.close() - self.input = None - self.fd = None - return True - - def consume(self, events: Sequence[Event]): - if not self.dev: - return - for ev in events: - match ev["type"]: - case "axis": - # if ev["code"] in self.axis_map: - # self.dev.write(B("EV_ABS"), self.axis_map[ev["code"]], ev['value']) - # TODO: figure out normalization - if ev['value']: - logger.error(f"Outputing axis not supported yet. Event:\n{ev}") - case "button": - if ev["code"] in self.btn_map: - self.dev.write( - B("EV_KEY"), - self.btn_map[ev["code"]], - 1 if ev["value"] else 0, - ) - self.dev.syn() diff --git a/src/hhd/controller/virtual/uinput/__init__.py b/src/hhd/controller/virtual/uinput/__init__.py new file mode 100644 index 00000000..3e307b32 --- /dev/null +++ b/src/hhd/controller/virtual/uinput/__init__.py @@ -0,0 +1,331 @@ +import logging +import time +from typing import Sequence + +from evdev import UInput + +from hhd.controller.base import Consumer, Event, Producer, can_read +from hhd.controller.const import Axis, Button + +from .const import * +from .monkey import UInputMonkey +from hhd.controller.lib.ccache import ControllerCache + +logger = logging.getLogger(__name__) + +_cache = ControllerCache() +_cache_motions = ControllerCache() +_cache_volume = ControllerCache() + +MAX_IMU_SYNC_DELAY = 2 + + +class UInputDevice(Consumer, Producer): + @staticmethod + def close_cached(): + _cache.close() + _cache_motions.close() + + @staticmethod + def close_volume_cached(): + _cache_volume.close() + + def __init__( + self, + capabilities=GAMEPAD_CAPABILITIES, + btn_map: dict[Button, int] = GAMEPAD_BUTTON_MAP, + axis_map: dict[Axis, AX] = GAMEPAD_AXIS_MAP, + vid: int = HHD_VID, + pid: int = HHD_PID_GAMEPAD, + bus: int = 0x03, + name: str = "Handheld Daemon Controller", + phys: str = "phys-hhd-gamepad", + output_imu_timestamps: str | bool = False, + output_timestamps: bool = False, + input_props: Sequence[int] = [], + ignore_cmds: bool = False, + uniq: str | None = None, + version: int = 1, + cache: bool = False, + motions_device: bool = False, + volume_keyboard: bool = False, + sync_gyro: bool = False, + ) -> None: + self.capabilities = capabilities + self.btn_map = btn_map + self.axis_map = axis_map + self.dev = None + self.name = name + self.vid = vid + self.pid = pid + self.bus = bus + self.phys = phys + self.uniq = uniq + self.output_imu_timestamps = output_imu_timestamps + self.output_timestamps = output_timestamps + self.last_imu_ts = 0 + self.start = 0 + self.input_props = input_props + self.ignore_cmds = ignore_cmds + self.version = version + self.cache = cache + self.motions_device = motions_device + self.volume_keyboard = volume_keyboard + self.sync_gyro = sync_gyro + self.imu_failed = False + self.last_imu = 0 + self.wrote = False + if volume_keyboard: + self.cache = True + + self.rumble: Event | None = None + + def open(self) -> Sequence[int]: + logger.info(f"Opening virtual device '{self.name}'.") + self.dev = None + + if self.cache: + if self.motions_device: + name = "left motions device" + cache = _cache_motions.get() + elif self.volume_keyboard: + name = "volume keyboard" + cache = _cache_volume.get() + else: + name = "controller" + cache = _cache.get() + + cached = cast(UInputDevice | None, cache) + if cached: + if ( + self.capabilities == cached.capabilities + and self.name == cached.name + and self.vid == cached.vid + and self.pid == cached.pid + and self.bus == cached.bus + and self.phys == cached.phys + and self.input_props == cached.input_props + and self.uniq == cached.uniq + ): + logger.warning(f"Using cached controller node for {name}.") + self.dev = cached.dev + else: + cached.close(True, in_cache=True) + + if not self.dev: + try: + self.dev = UInputMonkey( + events=self.capabilities, + name=self.name, + vendor=self.vid, + product=self.pid, + version=self.version, + bustype=self.bus, + phys=self.phys, + input_props=self.input_props, + uniq=self.uniq, + ) + except Exception as e: + logger.error( + f"Monkey patch probably failed. Could not create evdev device with uniq:\n{e}" + ) + self.dev = UInput( + events=self.capabilities, + name=self.name, + vendor=self.vid, + product=self.pid, + bustype=self.bus, + version=self.version, + phys=self.phys, + input_props=self.input_props, + ) + + self.touchpad_aspect = 1 + self.touch_id = 1 + self.fd = self.dev.fd + self.start = time.perf_counter() + self.last_imu = time.perf_counter() + self.imu_failed = False + self.wrote = False + + if self.ignore_cmds: + # Do not wake up if we ignore to save utilization + # When the output contains a timestamp, it is fed back to evdev + # causing double wake-ups. + return [] + return [self.fd] + + def close(self, exit: bool, in_cache: bool = False) -> bool: + if not in_cache and self.cache: + if self.motions_device: + name = "left motions device" + _cache_motions.add(self) + elif self.volume_keyboard: + name = "volume keyboard" + _cache_volume.add(self) + else: + name = "controller" + _cache.add(self) + logger.warning(f"Caching {name} to avoid reconnection.") + elif self.dev: + self.dev.close() + self.dev = None + self.input = None + self.fd = None + return True + + def consume(self, events: Sequence[Event]): + if not self.dev: + return + + should_syn = not self.sync_gyro + wrote = {} + ts = 0 + for ev in reversed(events): + key = (ev["type"], ev["code"]) + if key in wrote: + # skip duplicate events that were caused due to a delay + # only keep the last button value by iterating reversed + continue + match ev["type"]: + case "axis": + if not should_syn and "imu_ts" in ev["code"]: + self.imu_failed = False + self.last_imu = time.perf_counter() + should_syn = True + + if ev["code"] in self.axis_map: + ax = self.axis_map[ev["code"]] + if ev["code"] == "touchpad_x": + val = int( + self.touchpad_aspect + * (ax.scale * ev["value"] + ax.offset) + ) + else: + val = int(ax.scale * ev["value"] + ax.offset) + if ax.bounds: + val = min(max(val, ax.bounds[0]), ax.bounds[1]) + self.dev.write(B("EV_ABS"), ax.id, val) + wrote[key] = val + + if ev["code"] == "touchpad_x": + self.dev.write(B("EV_ABS"), B("ABS_MT_POSITION_X"), val) + elif ev["code"] == "touchpad_y": + self.dev.write(B("EV_ABS"), B("ABS_MT_POSITION_Y"), val) + + elif ( + self.output_imu_timestamps is True + and ev["code"] + in ( + "accel_ts", + "gyro_ts", + "imu_ts", + ) + ) or ev["code"] == self.output_imu_timestamps: + # We have timestamps with ns accuracy. + # Evdev expects us accuracy + self.last_imu_ts = ev["value"] + ts = (ev["value"] // 1000) % (2**31) + self.dev.write(B("EV_MSC"), B("MSC_TIMESTAMP"), ts) + wrote[key] = ts + case "button": + if ev["code"] in self.btn_map: + if ev["code"] == "touchpad_touch": + self.dev.write( + B("EV_ABS"), + B("ABS_MT_TRACKING_ID"), + self.touch_id if ev["value"] else -1, + ) + self.dev.write( + B("EV_KEY"), + B("BTN_TOOL_FINGER"), + 1 if ev["value"] else 0, + ) + self.touch_id += 1 + if self.touch_id > 500: + self.touch_id = 1 + self.dev.write( + B("EV_KEY"), + self.btn_map[ev["code"]], + 1 if ev["value"] else 0, + ) + wrote[key] = ev["value"] + + case "configuration": + if ev["code"] == "touchpad_aspect_ratio": + self.touchpad_aspect = float(ev["value"]) + + if wrote and self.output_timestamps: + # We have timestamps with ns accuracy. + # Evdev expects us accuracy + ts = (time.perf_counter_ns() // 1000) % (2**31) + self.dev.write(B("EV_MSC"), B("MSC_TIMESTAMP"), ts) + + if self.sync_gyro: + curr = time.perf_counter() + if curr - self.last_imu > MAX_IMU_SYNC_DELAY and not self.imu_failed: + self.imu_failed = True + logger.error( + f"IMU Did not send information for {MAX_IMU_SYNC_DELAY}s. Disabling Gyro Sync." + ) + + self.wrote = self.wrote or bool(wrote) + if ( + self.wrote + and (should_syn or not self.sync_gyro or self.imu_failed) + and (not self.output_imu_timestamps or ts) + ): + self.dev.syn() + self.wrote = False + + def produce(self, fds: Sequence[int]) -> Sequence[Event]: + if self.ignore_cmds or not self.fd or not self.fd in fds or not self.dev: + return [] + + out: Sequence[Event] = [] + + while can_read(self.fd): + for ev in self.dev.read(): + if ev.type == B("EV_MSC") and ev.code == B("MSC_TIMESTAMP"): + # Skip timestamp feedback + # TODO: Figure out why it feedbacks + pass + elif ev.type == B("EV_UINPUT"): + if ev.code == B("UI_FF_UPLOAD"): + # Keep uploaded effect to apply on input + upload = self.dev.begin_upload(ev.value) + if upload.effect.type == B("FF_RUMBLE"): + data = upload.effect.u.ff_rumble_effect + + self.rumble = { + "type": "rumble", + "code": "main", + "weak_magnitude": data.weak_magnitude / 0xFFFF, + "strong_magnitude": data.strong_magnitude / 0xFFFF, + } + self.dev.end_upload(upload) + elif ev.code == B("UI_FF_ERASE"): + # Ignore erase events + erase = self.dev.begin_erase(ev.value) + erase.retval = 0 + self.dev.end_erase(erase) + elif ev.type == B("EV_FF") and ev.value: + if self.rumble: + out.append(self.rumble) + else: + logger.warning( + f"Rumble requested but a rumble effect has not been uploaded.\n{ev}" + ) + elif ev.type == B("EV_FF") and not ev.value: + out.append( + { + "type": "rumble", + "code": "main", + "weak_magnitude": 0, + "strong_magnitude": 0, + } + ) + else: + logger.info(f"Controller ev received unhandled event:\n{ev}") + + return out diff --git a/src/hhd/controller/virtual/uinput/const.py b/src/hhd/controller/virtual/uinput/const.py new file mode 100644 index 00000000..86ad6d26 --- /dev/null +++ b/src/hhd/controller/virtual/uinput/const.py @@ -0,0 +1,539 @@ +import logging +from typing import cast, Sequence, NamedTuple + +import evdev +from evdev import AbsInfo + +from hhd.controller.const import Axis, Button + + +HHD_VID = 0x5335 +HHD_PID_GAMEPAD = 0x01 +HHD_PID_KEYBOARD = 0x02 +HHD_PID_MOUSE = 0x03 +HHD_PID_TOUCHPAD = 0x04 +HHD_PID_MOTION = 0x11 +HHD_PID_VENDOR = 0x7000 + +CONTROLLER_THEMES: dict[str, tuple[int, int, str]] = { + "hhd": (0x5335, 0x0001, "Handheld Daemon Controller"), + # Sony + "ps3": (0x054C, 0x0268, "DualShock 3"), + "ps4": (0x054C, 0x05C4, "DualShock 4"), + "ps5": (0x054C, 0x0CE6, "DualSense"), + "ps5e": (0x054C, 0x0DF2, "DualSense Edge"), + # Microsoft + "xbox_360": (0x045E, 0x028F, "Xbox 360"), + "xbox_one": (0x045E, 0x02D1, "Xbox One"), + "xbox_one_elite": (0x045E, 0x02E3, "Xbox Elite"), + "xbox_sx": (0x045E, 0x0B12, "Xbox Series X"), + # Nintendo + "joycon_left": (0x057E, 0x2006, "JoyCon Left"), + "joycon_right": (0x057E, 0x2007, "JoyCon Right"), + "joycon_pair": (0x057E, 0x2008, "Nintendo Switch Combined Joy-Cons"), + "joycon_grip": (0x057E, 0x200E, "JoyCon Grip"), + "switch_pro": (0x057E, 0x2009, "Switch Pro"), + # Valve + "steam_deck": (0x28DE, 0x1205, "Steam Deck"), + "steam_controller": (0x28DE, 0x1202, "Steam Controller"), + "steam_input": (0x28DE, 0x11FF, "Steam Input"), + "hori_steam": (0x0F0D, 0x0196, "Steam Controller (HHD)"), +} + + +def B(b: str | Sequence[str], num: int | None = None): + if num is not None: + return num + assert b, f"No value provided." + if not isinstance(b, str): + b = b[0] + + try: + # .ecodes misses UInput stuff, grab from runtime if it exists + import evdev.ecodes_runtime as ecodes_runtime + + return cast(int, getattr(ecodes_runtime, b)) + except Exception: + pass + + return cast(int, getattr(evdev.ecodes, b)) + + +class AX(NamedTuple): + id: int + scale: float = 1 + offset: float = 0 + bounds: tuple[int, int] | None = None + + +logger = logging.getLogger(__name__) + +GAMEPAD_CAPABILITIES = { + # B("EV_SYN", 0): [ + # B("SYN_REPORT", 0), + # B("SYN_CONFIG", 1), + # B("SYN_DROPPED", 3), + # B("?", 21), + # ], + B("EV_KEY", 1): [ + B(["BTN_A", "BTN_GAMEPAD", "BTN_SOUTH"], 304), + B(["BTN_B", "BTN_EAST"], 305), + B(["BTN_NORTH", "BTN_X"], 307), + B(["BTN_WEST", "BTN_Y"], 308), + B("BTN_TL", 310), + B("BTN_TR", 311), + B("BTN_SELECT", 314), + B("BTN_START", 315), + B("BTN_MODE", 316), + B("BTN_THUMBL", 317), + B("BTN_THUMBR", 318), + B("BTN_TRIGGER_HAPPY1"), + B("BTN_TRIGGER_HAPPY2"), + B("BTN_TRIGGER_HAPPY3"), + B("BTN_TRIGGER_HAPPY4"), + B("BTN_TRIGGER_HAPPY5"), + B("BTN_TRIGGER_HAPPY6"), + B("BTN_TRIGGER_HAPPY7"), + B("BTN_TRIGGER_HAPPY8"), + B("BTN_TRIGGER_HAPPY9"), + B("BTN_TRIGGER_HAPPY10"), + B("BTN_TRIGGER_HAPPY11"), + B("BTN_TRIGGER_HAPPY12"), + B("BTN_TRIGGER_HAPPY13"), + B("BTN_TRIGGER_HAPPY14"), + B("BTN_TRIGGER_HAPPY15"), + B("BTN_TRIGGER_HAPPY16"), + B("BTN_TRIGGER_HAPPY17"), + B("BTN_TRIGGER_HAPPY18"), + B("BTN_TRIGGER_HAPPY19"), + B("BTN_TRIGGER_HAPPY20"), + ], + B("EV_ABS", 3): [ + ( + B("ABS_X", 0), + AbsInfo(value=0, min=-32768, max=32767, fuzz=16, flat=128, resolution=0), + ), + ( + B("ABS_Y", 1), + AbsInfo(value=0, min=-32768, max=32767, fuzz=16, flat=128, resolution=0), + ), + (B("ABS_Z", 2), AbsInfo(value=0, min=0, max=255, fuzz=0, flat=0, resolution=0)), + ( + B("ABS_RX", 3), + AbsInfo(value=0, min=-32768, max=32767, fuzz=16, flat=128, resolution=0), + ), + ( + B("ABS_RY", 4), + AbsInfo(value=0, min=-32768, max=32767, fuzz=16, flat=128, resolution=0), + ), + ( + B("ABS_RZ", 5), + AbsInfo(value=0, min=0, max=255, fuzz=0, flat=0, resolution=0), + ), + ( + B("ABS_HAT0X", 16), + AbsInfo(value=0, min=-1, max=1, fuzz=0, flat=0, resolution=0), + ), + ( + B("ABS_HAT0Y", 17), + AbsInfo(value=0, min=-1, max=1, fuzz=0, flat=0, resolution=0), + ), + ], + B("EV_FF", 21): [ + B(["FF_EFFECT_MIN", "FF_RUMBLE"], 80), + B("FF_PERIODIC", 81), + B(["FF_SQUARE", "FF_WAVEFORM_MIN"], 88), + B("FF_TRIANGLE", 89), + B("FF_SINE", 90), + B(["FF_GAIN", "FF_MAX_EFFECTS"], 96), + ], +} + +MOTION_CAPABILITIES = { + # B("EV_SYN", 0): [B("SYN_REPORT", 0), B("SYN_DROPPED", 3), B("?", 4)], + B("EV_ABS", 3): [ + ( + B("ABS_X", 0), + AbsInfo(value=0, min=-32768, max=32768, fuzz=16, flat=0, resolution=8192), + ), + ( + B("ABS_Y", 1), + AbsInfo(value=0, min=-32768, max=32768, fuzz=16, flat=0, resolution=8192), + ), + ( + B("ABS_Z", 2), + AbsInfo(value=0, min=-32768, max=32768, fuzz=16, flat=0, resolution=8192), + ), + ( + B("ABS_RX", 3), + AbsInfo( + value=0, min=-2097152, max=2097152, fuzz=16, flat=0, resolution=1024 + ), + ), + ( + B("ABS_RY", 4), + AbsInfo( + value=0, min=-2097152, max=2097152, fuzz=16, flat=0, resolution=1024 + ), + ), + ( + B("ABS_RZ", 5), + AbsInfo( + value=0, min=-2097152, max=2097152, fuzz=16, flat=0, resolution=1024 + ), + ), + ], + B("EV_MSC", 4): [B("MSC_TIMESTAMP", 5)], +} + +KEYBOARD_CAPABILITIES = { + # B("EV_SYN", 0): [ + # B("SYN_REPORT", 0), + # B("SYN_CONFIG", 1), + # B("?", 4), + # B("?", 17), + # B("?", 20), + # ], + B("EV_KEY", 1): [ + B("KEY_ESC", 1), + B("KEY_1", 2), + B("KEY_2", 3), + B("KEY_3", 4), + B("KEY_4", 5), + B("KEY_5", 6), + B("KEY_6", 7), + B("KEY_7", 8), + B("KEY_8", 9), + B("KEY_9", 10), + B("KEY_0", 11), + B("KEY_MINUS", 12), + B("KEY_EQUAL", 13), + B("KEY_BACKSPACE", 14), + B("KEY_TAB", 15), + B("KEY_Q", 16), + B("KEY_W", 17), + B("KEY_E", 18), + B("KEY_R", 19), + B("KEY_T", 20), + B("KEY_Y", 21), + B("KEY_U", 22), + B("KEY_I", 23), + B("KEY_O", 24), + B("KEY_P", 25), + B("KEY_LEFTBRACE", 26), + B("KEY_RIGHTBRACE", 27), + B("KEY_ENTER", 28), + B("KEY_LEFTCTRL", 29), + B("KEY_A", 30), + B("KEY_S", 31), + B("KEY_D", 32), + B("KEY_F", 33), + B("KEY_G", 34), + B("KEY_H", 35), + B("KEY_J", 36), + B("KEY_K", 37), + B("KEY_L", 38), + B("KEY_SEMICOLON", 39), + B("KEY_APOSTROPHE", 40), + B("KEY_GRAVE", 41), + B("KEY_LEFTSHIFT", 42), + B("KEY_BACKSLASH", 43), + B("KEY_Z", 44), + B("KEY_X", 45), + B("KEY_C", 46), + B("KEY_V", 47), + B("KEY_B", 48), + B("KEY_N", 49), + B("KEY_M", 50), + B("KEY_COMMA", 51), + B("KEY_DOT", 52), + B("KEY_SLASH", 53), + B("KEY_RIGHTSHIFT", 54), + B("KEY_KPASTERISK", 55), + B("KEY_LEFTALT", 56), + B("KEY_SPACE", 57), + B("KEY_CAPSLOCK", 58), + B("KEY_F1", 59), + B("KEY_F2", 60), + B("KEY_F3", 61), + B("KEY_F4", 62), + B("KEY_F5", 63), + B("KEY_F6", 64), + B("KEY_F7", 65), + B("KEY_F8", 66), + B("KEY_F9", 67), + B("KEY_F10", 68), + B("KEY_NUMLOCK", 69), + B("KEY_SCROLLLOCK", 70), + B("KEY_KP7", 71), + B("KEY_KP8", 72), + B("KEY_KP9", 73), + B("KEY_KPMINUS", 74), + B("KEY_KP4", 75), + B("KEY_KP5", 76), + B("KEY_KP6", 77), + B("KEY_KPPLUS", 78), + B("KEY_KP1", 79), + B("KEY_KP2", 80), + B("KEY_KP3", 81), + B("KEY_KP0", 82), + B("KEY_KPDOT", 83), + B("KEY_ZENKAKUHANKAKU", 85), + B("KEY_102ND", 86), + B("KEY_F11", 87), + B("KEY_F12", 88), + B("KEY_RO", 89), + B("KEY_KATAKANA", 90), + B("KEY_HIRAGANA", 91), + B("KEY_HENKAN", 92), + B("KEY_KATAKANAHIRAGANA", 93), + B("KEY_MUHENKAN", 94), + B("KEY_KPJPCOMMA", 95), + B("KEY_KPENTER", 96), + B("KEY_RIGHTCTRL", 97), + B("KEY_KPSLASH", 98), + B("KEY_SYSRQ", 99), + B("KEY_RIGHTALT", 100), + B("KEY_HOME", 102), + B("KEY_UP", 103), + B("KEY_PAGEUP", 104), + B("KEY_LEFT", 105), + B("KEY_RIGHT", 106), + B("KEY_END", 107), + B("KEY_DOWN", 108), + B("KEY_PAGEDOWN", 109), + B("KEY_INSERT", 110), + B("KEY_DELETE", 111), + B(["KEY_MIN_INTERESTING", "KEY_MUTE"], 113), + B("KEY_VOLUMEDOWN", 114), + B("KEY_VOLUMEUP", 115), + B("KEY_POWER", 116), + B("KEY_KPEQUAL", 117), + B("KEY_PAUSE", 119), + B("KEY_KPCOMMA", 121), + B(["KEY_HANGEUL", "KEY_HANGUEL"], 122), + B("KEY_HANJA", 123), + B("KEY_YEN", 124), + B("KEY_LEFTMETA", 125), + B("KEY_RIGHTMETA", 126), + B("KEY_COMPOSE", 127), + B("KEY_STOP", 128), + B("KEY_AGAIN", 129), + B("KEY_PROPS", 130), + B("KEY_UNDO", 131), + B("KEY_FRONT", 132), + B("KEY_COPY", 133), + B("KEY_OPEN", 134), + B("KEY_PASTE", 135), + B("KEY_FIND", 136), + B("KEY_CUT", 137), + B("KEY_HELP", 138), + B("KEY_CALC", 140), + B("KEY_SLEEP", 142), + B("KEY_WWW", 150), + B(["KEY_COFFEE", "KEY_SCREENLOCK"], 152), + B("KEY_BACK", 158), + B("KEY_FORWARD", 159), + B("KEY_EJECTCD", 161), + B("KEY_NEXTSONG", 163), + B("KEY_PLAYPAUSE", 164), + B("KEY_PREVIOUSSONG", 165), + B("KEY_STOPCD", 166), + B("KEY_REFRESH", 173), + B("KEY_EDIT", 176), + B("KEY_SCROLLUP", 177), + B("KEY_SCROLLDOWN", 178), + B("KEY_KPLEFTPAREN", 179), + B("KEY_KPRIGHTPAREN", 180), + B("KEY_F13", 183), + B("KEY_F14", 184), + B("KEY_F15", 185), + B("KEY_F16", 186), + B("KEY_F17", 187), + B("KEY_F18", 188), + B("KEY_F19", 189), + B("KEY_F20", 190), + B("KEY_F21", 191), + B("KEY_F22", 192), + B("KEY_F23", 193), + B("KEY_F24", 194), + B("KEY_UNKNOWN", 240), + ], + B("EV_MSC", 4): [B("MSC_SCAN", 4)], + B("EV_LED", 17): [B("LED_NUML", 0), B("LED_CAPSL", 1), B("LED_SCROLLL", 2)], +} + +MOUSE_CAPABILITIES = { + # B("EV_SYN", 0): [ + # B("SYN_REPORT", 0), + # B("SYN_CONFIG", 1), + # B("SYN_MT_REPORT", 2), + # B("?", 4), + # ], + B("EV_KEY", 1): [ + B(["BTN_LEFT", "BTN_MOUSE"], 272), + B("BTN_RIGHT", 273), + B("BTN_MIDDLE", 274), + B("BTN_SIDE", 275), + B("BTN_EXTRA", 276), + ], + B("EV_REL", 2): [ + B("REL_X", 0), + B("REL_Y", 1), + B("REL_WHEEL", 8), + B("REL_WHEEL_HI_RES", 11), + ], + B("EV_MSC", 4): [B("MSC_SCAN", 4)], +} + +TOUCHPAD_CAPABILITIES = { + B("EV_KEY", 1): [ + B(["BTN_LEFT", "BTN_MOUSE"], 272), + B("BTN_RIGHT"), + B("BTN_TOOL_FINGER", 325), + B("BTN_TOUCH", 330), + B("BTN_TOOL_DOUBLETAP", 333), + B("BTN_TOOL_TRIPLETAP", 334), + ], + B("EV_ABS", 3): [ + ( + B("ABS_X", 0), + AbsInfo(value=172, min=0, max=2048, fuzz=0, flat=0, resolution=36), + ), + ( + B("ABS_Y", 1), + AbsInfo(value=472, min=0, max=2048, fuzz=0, flat=0, resolution=36), + ), + ( + B("ABS_MT_SLOT", 47), + AbsInfo(value=0, min=0, max=2, fuzz=0, flat=0, resolution=0), + ), + ( + B("ABS_MT_POSITION_X", 53), + AbsInfo(value=0, min=0, max=2048, fuzz=0, flat=0, resolution=36), + ), + ( + B("ABS_MT_POSITION_Y", 54), + AbsInfo(value=0, min=0, max=2048, fuzz=0, flat=0, resolution=36), + ), + ( + B("ABS_MT_TOOL_TYPE", 55), + AbsInfo(value=0, min=0, max=2, fuzz=0, flat=0, resolution=0), + ), + ( + B("ABS_MT_TRACKING_ID", 57), + AbsInfo(value=0, min=0, max=65535, fuzz=0, flat=0, resolution=0), + ), + ], + B("EV_MSC", 4): [B("MSC_TIMESTAMP", 5)], +} +GAMEPAD_BASE_BUTTON_MAP: dict[Button, int] = { + # Gamepad + "a": B("BTN_A"), + "b": B("BTN_B"), + "x": B("BTN_X"), + "y": B("BTN_Y"), + # Sticks + "ls": B("BTN_THUMBL"), + "rs": B("BTN_THUMBR"), + # Bumpers + "lb": B("BTN_TL"), + "rb": B("BTN_TR"), + # Select + "start": B("BTN_START"), + "select": B("BTN_SELECT"), + # Misc + "mode": B("BTN_MODE"), + "share": B("BTN_TRIGGER_HAPPY20"), +} + +GAMEPAD_BUTTON_MAP: dict[Button, int] = { + **GAMEPAD_BASE_BUTTON_MAP, + # Back buttons + "extra_l1": B("BTN_TRIGGER_HAPPY1"), + "extra_r1": B("BTN_TRIGGER_HAPPY2"), + "extra_l2": B("BTN_TRIGGER_HAPPY3"), + "extra_r2": B("BTN_TRIGGER_HAPPY4"), + "extra_l3": B("BTN_TRIGGER_HAPPY5"), + "extra_r3": B("BTN_TRIGGER_HAPPY6"), +} + +XBOX_ELITE_BUTTON_MAP: dict[Button, int] = { + **GAMEPAD_BASE_BUTTON_MAP, + # Back buttons + "extra_l1": B("BTN_TRIGGER_HAPPY7"), + "extra_r1": B("BTN_TRIGGER_HAPPY5"), + "extra_l2": B("BTN_TRIGGER_HAPPY8"), + "extra_r2": B("BTN_TRIGGER_HAPPY6"), +} + +HORIPAD_STEAM_BUTTON_MAP: dict[Button, int] = { + **GAMEPAD_BASE_BUTTON_MAP, + # a:b0,b:b1,back:b4,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b5, + # leftshoulder:b9,leftstick:b7,lefttrigger:a4,leftx:a0,lefty:a1, + # rightshoulder:b10,rightstick:b8,righttrigger:a5,rightx:a2,righty:a3,start:b6,x:b2,y:b3, + # From SDL: The Wireless HORIPad for Steam has QAM, Steam, Capsense L/R Sticks, 2 rear buttons, and 2 misc buttons + # paddle1:b13,paddle2:b12,paddle3:b15,paddle4:b14,misc2:b11,misc3:b16,misc4:b17 + # Try to use the same mapping + "share": B("BTN_TRIGGER_HAPPY1"), + "extra_l1": B("BTN_TRIGGER_HAPPY2"), + "extra_r1": B("BTN_TRIGGER_HAPPY3"), + "extra_l2": B("BTN_TRIGGER_HAPPY4"), + "extra_r2": B("BTN_TRIGGER_HAPPY5"), + "extra_l3": B("BTN_TRIGGER_HAPPY6"), + "extra_r3": B("BTN_TRIGGER_HAPPY7"), + # No Capsense :/ +} + +GAMEPAD_AXIS_MAP: dict[Axis, AX] = { + "ls_x": AX(B("ABS_X"), 2**15 - 1), + "ls_y": AX(B("ABS_Y"), 2**15 - 1), + "rs_x": AX(B("ABS_RX"), 2**15 - 1), + "rs_y": AX(B("ABS_RY"), 2**15 - 1), + "rt": AX(B("ABS_Z"), 2**8 - 1), + "lt": AX(B("ABS_RZ"), 2**8 - 1), + "hat_x": AX(B("ABS_HAT0X")), + "hat_y": AX(B("ABS_HAT0Y")), +} + +MOTION_AXIS_MAP: dict[Axis, AX] = { + "accel_x": AX(B("ABS_X"), 8192 / 9.8, bounds=(-32768, 32768)), + "accel_y": AX(B("ABS_Y"), 8192 / 9.8, bounds=(-32768, 32768)), + "accel_z": AX(B("ABS_Z"), 8192 / 9.8, bounds=(-32768, 32768)), + "gyro_x": AX(B("ABS_RX"), 1024 * 180 / 3.14, bounds=(-2097152, 2097152)), + "gyro_y": AX(B("ABS_RY"), 1024 * 180 / 3.14, bounds=(-2097152, 2097152)), + "gyro_z": AX(B("ABS_RZ"), 1024 * 180 / 3.14, bounds=(-2097152, 2097152)), +} + +MOTION_AXIS_MAP_FLIP_Z: dict[Axis, AX] = { + "accel_x": AX(B("ABS_X"), 8192 / 9.8, bounds=(-32768, 32768)), + "accel_y": AX(B("ABS_Y"), 8192 / 9.8, bounds=(-32768, 32768)), + "accel_z": AX(B("ABS_Z"), 8192 / 9.8, bounds=(-32768, 32768)), + "gyro_x": AX(B("ABS_RX"), 1024 * 180 / 3.14, bounds=(-2097152, 2097152)), + "gyro_y": AX(B("ABS_RY"), 1024 * 180 / 3.14, bounds=(-2097152, 2097152)), + "gyro_z": AX(B("ABS_RZ"), -1024 * 180 / 3.14, bounds=(-2097152, 2097152)), +} + +MOTION_LEFT_AXIS_MAP: dict[Axis, AX] = { + "left_" + k: v for k, v in MOTION_AXIS_MAP.items() # type: ignore +} +MOTION_LEFT_AXIS_MAP_FLIP_Z: dict[Axis, AX] = { + "left_" + k: v for k, v in MOTION_AXIS_MAP_FLIP_Z.items() # type: ignore +} + +MOTION_RIGHT_AXIS_MAP: dict[Axis, AX] = { + "right_" + k: v for k, v in MOTION_AXIS_MAP.items() # type: ignore +} + +MOTION_INPUT_PROPS = [B("INPUT_PROP_ACCELEROMETER")] + +TOUCHPAD_AXIS_MAP: dict[Axis, AX] = { + "touchpad_x": AX(B("ABS_X"), 1023, bounds=(0, 2048)), + "touchpad_y": AX(B("ABS_Y"), 1023, bounds=(0, 2048)), +} + +TOUCHPAD_BUTTON_MAP: dict[Button, int] = { + "touchpad_touch": B("BTN_TOUCH"), + "touchpad_right": B("BTN_RIGHT"), + "touchpad_left": B("BTN_LEFT"), +} diff --git a/src/hhd/controller/virtual/uinput/controllerdb.txt b/src/hhd/controller/virtual/uinput/controllerdb.txt new file mode 100644 index 00000000..5d61fb9f --- /dev/null +++ b/src/hhd/controller/virtual/uinput/controllerdb.txt @@ -0,0 +1,15 @@ +030007a1355300000100000001000000,Handheld Daemon Controller,a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +03006d6e5e040000e302000001000000,Xbox One Elite Controller (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +0300dce64c050000f20d000001000000,DualSense Edge (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +030045fe7e0500000920000001000000,Switch Pro (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +0300a3b44c050000e60c000001000000,PS5 Controller (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +0300788c4c0500006802000001000000,PS3 Controller (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +0300394e4c050000c405000001000000,PS4 Controller (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +030002725e0400008f02000001000000,Xbox 360 Controller (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +030038555e040000d102000001000000,Xbox One Controller (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +0300a3935e040000120b000001000000,Xbox Series X Controller (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +0300f4c47e0500000820000001000000,JoyCon Pair (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +030037677e0500000620000001000000,JoyCon Left (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +0300d4a37e0500000720000001000000,JoyCon Right (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +030081747e0500000e20000001000000,JoyCon Grip (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +060000000d0f00009601000000000000,Steam Controller (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,paddle1:b13,paddle2:b12,paddle3:b15,paddle4:b14,misc2:b11,misc3:b16,misc4:b17,crc:ea35, \ No newline at end of file diff --git a/src/hhd/controller/virtual/uinput/monkey.py b/src/hhd/controller/virtual/uinput/monkey.py new file mode 100644 index 00000000..37f71e86 --- /dev/null +++ b/src/hhd/controller/virtual/uinput/monkey.py @@ -0,0 +1,104 @@ +from evdev import UInput, _uinput, ecodes # type: ignore +import ctypes +import os + + +class UInputMonkey(UInput): + def __init__( + self, + events=None, + name="py-evdev-uinput", + vendor=0x1, + product=0x1, + version=0x1, + bustype=0x3, + devnode="/dev/uinput", + phys="py-evdev-uinput", + input_props=None, + uniq=None, + ): + self.fd = -1 + try: + self._new_init( + events=events, + name=name, + vendor=vendor, + product=product, + version=version, + bustype=bustype, + devnode=devnode, + phys=phys, + input_props=input_props, + uniq=uniq, + ) + except Exception as e: + if self.fd != -1: + os.close(self.fd) + raise e + + def _new_init( + self, + events=None, + name="py-evdev-uinput", + vendor=0x1, + product=0x1, + version=0x1, + bustype=0x3, + devnode="/dev/uinput", + phys="py-evdev-uinput", + input_props=None, + uniq=None, + ): + self.name = name #: Uinput device name. + self.vendor = vendor #: Device vendor identifier. + self.product = product #: Device product identifier. + self.version = version #: Device version identifier. + self.bustype = bustype #: Device bustype - e.g. ``BUS_USB``. + self.phys = phys #: Uinput device physical path. + self.devnode = devnode #: Uinput device node - e.g. ``/dev/uinput/``. + + if not events: + events = {ecodes.EV_KEY: ecodes.keys.keys()} # type: ignore + + self._verify() + + #: Write-only, non-blocking file descriptor to the uinput device node. + self.fd = _uinput.open(devnode) + + # Prepare the list of events for passing to _uinput.enable and _uinput.setup. + absinfo, prepared_events = self._prepare_events(events) + + # Set phys name + _uinput.set_phys(self.fd, phys) + + # Set properties + input_props = input_props or [] + for prop in input_props: + _uinput.set_prop(self.fd, prop) + + for etype, code in prepared_events: + _uinput.enable(self.fd, etype, code) + + try: + _uinput.setup(self.fd, name, vendor, product, version, bustype, absinfo, ecodes.FF_MAX_EFFECTS) # type: ignore + except TypeError: + _uinput.setup(self.fd, name, vendor, product, version, bustype, absinfo) + + if uniq: + from fcntl import ioctl + from ...lib.ioctl import UI_SET_UNIQ_STR + + c_uniq = ctypes.create_string_buffer(uniq.encode()) + ioctl(self.fd, UI_SET_UNIQ_STR(len(c_uniq)), c_uniq, False) + + # Create the uinput device. + _uinput.create(self.fd) + + self.dll = ctypes.CDLL(_uinput.__file__) + self.dll._uinput_begin_upload.restype = ctypes.c_int + self.dll._uinput_end_upload.restype = ctypes.c_int + + self.device = None + + def _find_device(self, fd): + return None diff --git a/src/hhd/device/claw/__init__.py b/src/hhd/device/claw/__init__.py new file mode 100644 index 00000000..b6cd20e9 --- /dev/null +++ b/src/hhd/device/claw/__init__.py @@ -0,0 +1,135 @@ +from threading import Event, Thread +from typing import Sequence +import os + +from hhd.controller.physical.rgb import is_led_supported +from hhd.plugins import ( + Config, + Context, + Emitter, + HHDPlugin, + get_gyro_config, + get_outputs_config, + load_relative_yaml, +) +from hhd.plugins.settings import HHDSettings +import logging + +from .const import CONFS + +logger = logging.getLogger(__name__) + + +class ClawControllerPlugin(HHDPlugin): + name = "claw_controller" + priority = 18 + log = "claw" + + def __init__(self, dmi: str, dconf: dict) -> None: + self.t = None + self.should_exit = None + self.updated = Event() + self.woke_up = Event() + self.started = False + self.t = None + + self.dmi = dmi + self.dconf = dconf + self.name = f"claw_controller@'{dconf.get('name', 'ukn')}'" + + def open( + self, + emit: Emitter, + context: Context, + ): + self.emit = emit + self.context = context + self.prev = None + + # FIXME: move this somewhere else + try: + with open("/sys/bus/serio/devices/serio0/power/wakeup", "w") as f: + f.write("disabled") + logger.warning("Disabling keyboard wakeups so volume button does not wake device.") + except Exception as e: + logger.error(f"Failed to disable keyboard wakeup wakeup: {e}") + + def settings(self) -> HHDSettings: + base = {"controllers": {"claw": load_relative_yaml("controllers.yml")}} + base["controllers"]["claw"]["children"]["controller_mode"].update( + get_outputs_config( + can_disable=True, + has_leds=is_led_supported(), + start_disabled=self.dconf.get("untested", False), + extra_buttons=self.dconf.get("extra_buttons", "dual"), + ) + ) + + return base + + def update(self, conf: Config): + new_conf = conf["controllers.claw"] + if new_conf == self.prev: + return + if self.prev is None: + self.prev = new_conf + else: + self.prev.update(new_conf.conf) + + self.updated.set() + self.start(self.prev) + + def start(self, conf): + from .base import plugin_run + + if self.started: + return + self.started = True + + self.close() + self.should_exit = Event() + self.t = Thread( + target=plugin_run, + args=( + conf, + self.emit, + self.context, + self.should_exit, + self.updated, + self.dconf, + self.woke_up, + ), + ) + self.t.start() + + def close(self): + if not self.should_exit or not self.t: + return + self.should_exit.set() + self.t.join() + self.should_exit = None + self.t = None + + def notify(self, events: Sequence): + for ev in events: + if ev["type"] == "special" and ev.get("event", None) == "wakeup": + self.woke_up.set() + + +def autodetect(existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: + if len(existing): + return existing + + # Match just product name + # if a device exists here its officially supported + with open("/sys/devices/virtual/dmi/id/board_name") as f: + dmi = f.read().strip() + + dconf = CONFS.get(dmi, None) + if dconf: + return [ClawControllerPlugin(dmi, dconf)] + + if os.environ.get("HHD_FORCE_CLAW", "0") == "1": + return [ClawControllerPlugin("forced", CONFS["MS"])] + + return [] diff --git a/src/hhd/device/claw/base.py b/src/hhd/device/claw/base.py new file mode 100644 index 00000000..3eeb6483 --- /dev/null +++ b/src/hhd/device/claw/base.py @@ -0,0 +1,465 @@ +import logging +import select +import time +from threading import Event as TEvent + +from hhd.controller import DEBUG_MODE, Multiplexer, can_read +from hhd.controller.lib.hide import unhide_all +from hhd.controller.physical.hidraw import GenericGamepadHidraw +from hhd.controller.physical.evdev import B as EC +from hhd.controller.physical.evdev import GenericGamepadEvdev, enumerate_evs +from hhd.controller.virtual.uinput import UInputDevice +from hhd.plugins import Config, Context, Emitter, get_outputs +from hhd.controller.physical.evdev import DINPUT_AXIS_POSTPROCESS, AbsAxis +from hhd.controller.physical.evdev import ( + GamepadButton, + GenericGamepadEvdev, + enumerate_evs, + to_map, +) +from typing import Sequence +from hhd.controller import DEBUG_MODE, Event, Multiplexer + +from .const import MSI_CLAW_MAPPINGS + +FIND_DELAY = 0.1 +ERROR_DELAY = 0.3 +LONGER_ERROR_DELAY = 3 +LONGER_ERROR_MARGIN = 1.3 + +logger = logging.getLogger(__name__) + +CLAW_SET_M1 = bytes( + [0x0F, 0x00, 0x00, 0x3C, 0x21, 0x01, 0x00, 0x7A, 0x05, 0x01, 0x00, 0x00, 0x11, 0x00] +) +CLAW_SET_M2 = bytes( + [0x0F, 0x00, 0x00, 0x3C, 0x21, 0x01, 0x01, 0x1F, 0x05, 0x01, 0x00, 0x00, 0x12, 0x00] +) +CLAW_SYNC_ROM = bytes([0x0F, 0x00, 0x00, 0x3C, 0x22]) +CLAW_SET_DINPUT = bytes([0x0F, 0x00, 0x00, 0x3C, 0x24, 0x02, 0x00]) +CLAW_SET_MSI = bytes([0x0F, 0x00, 0x00, 0x3C, 0x24, 0x03, 0x00]) + +MSI_CLAW_VID = 0x0DB0 +MSI_CLAW_XINPUT_PID = 0x1901 +MSI_CLAW_DINPUT_PID = 0x1902 + +KBD_VID = 0x0001 +KBD_PID = 0x0001 + +BACK_BUTTON_DELAY = 0.1 + + +def set_rgb_cmd(brightness, red, green, blue): + return bytes( + [ + # Preamble + 0x0F, + 0x00, + 0x00, + 0x3C, + # Write first profile + 0x21, + 0x01, + # Start at + 0x01, + 0xFA, + # Write 31 bytes + 0x20, + # Index, Frame num, Effect, Speed, Brightness + 0x00, + 0x01, + 0x09, + 0x03, + max(0, min(100, int(brightness * 100))), + ] + ) + 9 * bytes([red, green, blue]) + + +class ClawDInputHidraw(GenericGamepadHidraw): + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.init = False + + def consume(self, events: Sequence[Event]) -> None: + if not self.dev: + return + + for ev in events: + if ev["type"] == "rumble" and ev["code"] == "main": + # Same as dualshock 4 + # "0501 0000 right left" + cmd = bytes( + [ + 0x05, + 0x01, + 0x00, + 0x00, + min(255, int(ev["weak_magnitude"] * 255)), + min(255, int(ev["strong_magnitude"] * 255)), + 00, + 00, + 00, + 00, + 00, + ] + ) + self.dev.write(cmd) + elif ev["type"] == "led": + if ev["mode"] == "solid": + cmd = set_rgb_cmd( + ev["brightness"], + ev["red"], + ev["green"], + ev["blue"], + ) + self.dev.write(cmd) + elif ev["mode"] == "disabled": + cmd = set_rgb_cmd( + 0, + 0, + 0, + 0, + ) + self.dev.write(cmd) + + def set_dinput_mode(self, init: bool = False) -> None: + if not self.dev: + return + + # Make sure M1/M2 are recognizable + if init: + self.dev.write(CLAW_SET_M1) + time.sleep(0.3) + self.dev.write(CLAW_SET_M2) + time.sleep(0.3) + self.dev.write(CLAW_SYNC_ROM) + time.sleep(0.3) + self.dev.write(CLAW_SET_MSI) + time.sleep(2) + + # Set the device to dinput mode + self.dev.write(CLAW_SET_DINPUT) + + +DINPUT_BUTTON_MAP: dict[int, GamepadButton] = to_map( + { + # Gamepad + "a": [EC("BTN_B")], + "b": [EC("BTN_C")], + "x": [EC("BTN_A")], + "y": [EC("BTN_NORTH")], + # Sticks + "ls": [EC("BTN_SELECT")], + "rs": [EC("BTN_START")], + # Bumpers + "lb": [EC("BTN_WEST")], + "rb": [EC("BTN_Z")], + # Select + "start": [EC("BTN_TR2")], + "select": [EC("BTN_TL2")], + # Misc + "extra_l1": [EC("BTN_TRIGGER_HAPPY")], + "extra_r1": [0x013F], + } +) +DINPUT_AXIS_MAP: dict[int, AbsAxis] = to_map( + { + # Sticks + # Values should range from -1 to 1 + "ls_x": [EC("ABS_X")], + "ls_y": [EC("ABS_Y")], + "rs_x": [EC("ABS_Z")], + "rs_y": [EC("ABS_RZ")], + # Triggers + # Values should range from -1 to 1 + "rt": [EC("ABS_RX")], + "lt": [EC("ABS_RY")], + # Hat, implemented as axis. Either -1, 0, or 1 + "hat_x": [EC("ABS_HAT0X")], + "hat_y": [EC("ABS_HAT0Y")], + } +) + + +def plugin_run( + conf: Config, + emit: Emitter, + context: Context, + should_exit: TEvent, + updated: TEvent, + dconf: dict, + woke_up: TEvent, +): + first = True + first_disabled = True + init = time.perf_counter() + repeated_fail = False + while not should_exit.is_set(): + if conf["controller_mode.mode"].to(str) == "disabled": + time.sleep(ERROR_DELAY) + if first_disabled: + UInputDevice.close_volume_cached() + unhide_all() + first_disabled = False + continue + else: + first_disabled = True + + try: + is_xinput = bool(enumerate_evs(vid=MSI_CLAW_VID, pid=MSI_CLAW_XINPUT_PID)) + found_device = bool( + enumerate_evs(vid=MSI_CLAW_VID, pid=MSI_CLAW_DINPUT_PID) + ) + except Exception: + logger.warning("Failed finding device, skipping check.") + time.sleep(LONGER_ERROR_DELAY) + found_device = True + is_xinput = False + + if is_xinput: + d_vend = ClawDInputHidraw( + vid=[MSI_CLAW_VID], + pid=[MSI_CLAW_XINPUT_PID], + usage_page=[0xFFA0], + usage=[0x0001], + required=True, + ) + try: + d_vend.open() + d_vend.set_dinput_mode(init=True) + d_vend.close(True) + time.sleep(2) + except Exception as e: + logger.error(f"Failed to set device into dinput mode.\n{type(e)}: {e}") + time.sleep(1) + + if not found_device: + if first: + logger.info("Controller not found. Waiting...") + time.sleep(FIND_DELAY) + first = False + continue + + try: + logger.info("Launching emulated controller.") + updated.clear() + init = time.perf_counter() + controller_loop(conf.copy(), should_exit, updated, dconf, emit, woke_up) + repeated_fail = False + except Exception as e: + failed_fast = init + LONGER_ERROR_MARGIN > time.perf_counter() + sleep_time = ( + LONGER_ERROR_DELAY if repeated_fail and failed_fast else ERROR_DELAY + ) + repeated_fail = failed_fast + logger.error(f"Received the following error:\n{type(e)}: {e}") + logger.error( + f"Assuming controllers disconnected, restarting after {sleep_time}s." + ) + first = True + # Raise exception + if DEBUG_MODE: + raise e + time.sleep(sleep_time) + + # Unhide all devices before exiting and close keyboard cache + UInputDevice.close_volume_cached() + unhide_all() + + +class DesktopDetectorEvdev(GenericGamepadEvdev): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.desktop = False + + def produce(self, fds: Sequence[int]): + if not self.dev or self.fd not in fds: + return [] + + while can_read(self.fd): + for e in self.dev.read(): + self.desktop = True + + return [] + + +def controller_loop( + conf: Config, + should_exit: TEvent, + updated: TEvent, + dconf: dict, + emit: Emitter, + woke_up: TEvent, +): + debug = DEBUG_MODE + + # Output + d_producers, d_outs, d_params = get_outputs( + conf["controller_mode"], + None, + emit=emit, + rgb_modes={"disabled": [], "solid": ["color"]}, + ) + + # Inputs + d_xinput = GenericGamepadEvdev( + vid=[MSI_CLAW_VID], + pid=[MSI_CLAW_DINPUT_PID], + # name=["Generic X-Box pad"], + capabilities={EC("EV_KEY"): [EC("BTN_A")]}, + required=True, + hide=True, + btn_map=DINPUT_BUTTON_MAP, + axis_map=DINPUT_AXIS_MAP, + postprocess=DINPUT_AXIS_POSTPROCESS, + ) + + d_kbd_1 = GenericGamepadEvdev( + vid=[KBD_VID], + pid=[KBD_PID], + required=False, + grab=True, + btn_map=dconf.get("btn_mapping", MSI_CLAW_MAPPINGS), + ) + + # Mute these so after suspend we do not get stray keypresses + d_kbd_2 = DesktopDetectorEvdev( + vid=[MSI_CLAW_VID], + pid=[MSI_CLAW_DINPUT_PID], + required=False, + grab=True, + capabilities={EC("EV_KEY"): [EC("KEY_ESC")]}, + ) + d_mouse = DesktopDetectorEvdev( + vid=[MSI_CLAW_VID], + pid=[MSI_CLAW_DINPUT_PID], + required=False, + grab=True, + capabilities={EC("EV_KEY"): [EC("BTN_MOUSE")]}, + ) + + kargs = {} + + multiplexer = Multiplexer( + trigger="analog_to_discrete", + dpad="analog_to_discrete", + share_to_qam=True, + select_reboots=conf.get("select_reboots", False), + nintendo_mode=conf["nintendo_mode"].to(bool), + emit=emit, + params=d_params, + startselect_chord=conf.get("main_chords", "disabled"), + swap_guide="select_is_guide" if conf["swap_guide"].to(bool) else None, + keyboard_no_release=True, + **kargs, + ) + + d_volume_btn = UInputDevice( + name="Handheld Daemon Volume Keyboard", + phys="phys-hhd-vbtn", + capabilities={EC("EV_KEY"): [EC("KEY_VOLUMEUP"), EC("KEY_VOLUMEDOWN")]}, + btn_map={ + "key_volumeup": EC("KEY_VOLUMEUP"), + "key_volumedown": EC("KEY_VOLUMEDOWN"), + }, + pid=KBD_PID, + vid=KBD_VID, + output_timestamps=True, + ) + + d_vend = ClawDInputHidraw( + vid=[MSI_CLAW_VID], + pid=[MSI_CLAW_DINPUT_PID], + usage_page=[0xFFF0], + usage=[0x0040], + required=True, + ) + + REPORT_FREQ_MIN = 25 + REPORT_FREQ_MAX = 400 + + REPORT_DELAY_MAX = 1 / REPORT_FREQ_MIN + REPORT_DELAY_MIN = 1 / REPORT_FREQ_MAX + + fds = [] + devs = [] + fd_to_dev = {} + + def prepare(m): + devs.append(m) + fs = m.open() + fds.extend(fs) + for f in fs: + fd_to_dev[f] = m + + try: + prepare(d_xinput) + prepare(d_volume_btn) + prepare(d_kbd_1) + prepare(d_kbd_2) + prepare(d_mouse) + for d in d_producers: + prepare(d) + prepare(d_vend) + + logger.info("Emulated controller launched, have fun!") + switch_to_dinput = None + while not should_exit.is_set() and not updated.is_set(): + start = time.perf_counter() + # Add timeout to call consumers a minimum amount of times per second + r, _, _ = select.select(fds, [], [], REPORT_DELAY_MAX) + evs = [] + to_run = set() + for f in r: + to_run.add(id(fd_to_dev[f])) + + for d in devs: + if id(d) in to_run: + evs.extend(d.produce(r)) + + # Detect if we are in desktop mode through events + desktop_mode = d_mouse.desktop or d_kbd_2.desktop + d_mouse.desktop = False + d_kbd_2.desktop = False + + if desktop_mode or (switch_to_dinput and start > switch_to_dinput): + logger.info("Setting controller to dinput mode.") + d_vend.set_dinput_mode() + switch_to_dinput = None + # elif woke_up.is_set(): + # woke_up.clear() + # # Switch to dinput after 4 seconds without input to avoid + # # being stuck in desktop mode, as not all buttons trigger + # # the other quirk (especially bumpers) + # switch_to_dinput = time.perf_counter() + 4 + + evs = multiplexer.process(evs) + if evs: + switch_to_dinput = None + if debug: + logger.info(evs) + + d_volume_btn.consume(evs) + d_xinput.consume(evs) + d_vend.consume(evs) + + for d in d_outs: + d.consume(evs) + + t = time.perf_counter() + elapsed = t - start + if elapsed < REPORT_DELAY_MIN: + time.sleep(REPORT_DELAY_MIN - elapsed) + + except KeyboardInterrupt: + raise + finally: + d_vend.close(not updated.is_set()) + for d in reversed(devs): + try: + d.close(not updated.is_set()) + except Exception as e: + logger.error(f"Error while closing device '{d}' with exception:\n{e}") + if debug: + raise e diff --git a/src/hhd/device/claw/const.py b/src/hhd/device/claw/const.py new file mode 100644 index 00000000..ff1337f5 --- /dev/null +++ b/src/hhd/device/claw/const.py @@ -0,0 +1,33 @@ +from hhd.controller.physical.evdev import B + +MSI_CLAW_MAPPINGS = { + B("KEY_VOLUMEUP"): "key_volumeup", + B("KEY_VOLUMEDOWN"): "key_volumedown", + B("KEY_F15"): "mode", + B("KEY_F16"): "share", +} + +CONFS = { + "MS-1T41": { + "name": "MSI Claw (1st gen)", + # "hrtimer": True, Uses sensor fusion garbage? From intel? Will need custom work + "extra_buttons": "dual", + "btn_mapping": MSI_CLAW_MAPPINGS, + "type": "claw", + "display_gyro": False, + }, + "MS-1T42": { + "name": "MSI Claw 7 (2nd gen)", + "extra_buttons": "dual", + "btn_mapping": MSI_CLAW_MAPPINGS, + "type": "claw", + "display_gyro": False, + }, + "MS-1T52": { + "name": "MSI Claw 8", + "extra_buttons": "dual", + "btn_mapping": MSI_CLAW_MAPPINGS, + "type": "claw", + "display_gyro": False, + }, +} diff --git a/src/hhd/device/claw/controllers.yml b/src/hhd/device/claw/controllers.yml new file mode 100644 index 00000000..b77a294a --- /dev/null +++ b/src/hhd/device/claw/controllers.yml @@ -0,0 +1,49 @@ +type: container +tags: [lgc] +title: Claw Controller +hint: >- + Allows for configuring your handheld's controller to a unified output. + +children: + controller_mode: + type: mode + tags: [controller_mode] + title: Controller Emulation + hint: >- + Emulate different controller types to fuse your device's features. + + swap_guide: + type: bool + title: Swap Guide and Menu/View + tags: [non-essential] + hint: >- + Swaps the Guide and QAM buttons with start and select. + default: False + + main_chords: + type: multiple + tags: [ non-essential ] + title: Start/Select do SteamOS Combos + hint: >- + When holding Select or Start, if another button is pressed, they become + the Xbox button, which allows doing SteamOS combos (Select+RT is + screenshot). + options: + disabled: "Disabled" + select: "Select Only" + start_select: "Start+Select" + default: disabled + + nintendo_mode: + type: bool + title: Nintendo Mode (A-B Swap) + tags: [ non-essential ] + hint: >- + Swaps A with B and X with Y. + default: False + + select_reboots: + type: bool + tags: [non-essential] + title: Hold View to Reboot + default: False diff --git a/src/hhd/device/generic/__init__.py b/src/hhd/device/generic/__init__.py new file mode 100644 index 00000000..c161656d --- /dev/null +++ b/src/hhd/device/generic/__init__.py @@ -0,0 +1,139 @@ +from threading import Event, Thread +from typing import Any, Sequence + +from hhd.controller.physical.rgb import is_led_supported +from hhd.plugins import ( + Config, + Context, + Emitter, + HHDPlugin, + get_gyro_config, + get_outputs_config, + load_relative_yaml, +) +from hhd.plugins.settings import HHDSettings + +from .const import CONFS, DEFAULT_MAPPINGS, get_default_config + + +class GenericControllersPlugin(HHDPlugin): + name = "generic_controllers" + priority = 18 + log = "genc" + + def __init__(self, dmi: str, dconf: dict) -> None: + self.t = None + self.should_exit = None + self.updated = Event() + self.started = False + self.t = None + + self.dmi = dmi + self.dconf = dconf + self.name = f"generic_controllers@'{dconf.get('name', 'ukn')}'" + + def open( + self, + emit: Emitter, + context: Context, + ): + self.emit = emit + self.context = context + self.prev = None + + def settings(self) -> HHDSettings: + base = {"controllers": {"handheld": load_relative_yaml("controllers.yml")}} + base["controllers"]["handheld"]["children"]["controller_mode"].update( + get_outputs_config( + can_disable=True, + has_leds=is_led_supported(), + start_disabled=self.dconf.get("untested", False), + extra_buttons=self.dconf.get("extra_buttons", "dual"), + ) + ) + + if self.dconf.get("display_gyro", True): + base["controllers"]["handheld"]["children"]["imu_axis"] = get_gyro_config( + self.dconf.get("mapping", DEFAULT_MAPPINGS) + ) + else: + del base["controllers"]["handheld"]["children"]["imu_axis"] + del base["controllers"]["handheld"]["children"]["imu"] + + return base + + def update(self, conf: Config): + new_conf = conf["controllers.handheld"] + if new_conf == self.prev: + return + if self.prev is None: + self.prev = new_conf + else: + self.prev.update(new_conf.conf) + + self.updated.set() + self.start(self.prev) + + def start(self, conf): + from .base import plugin_run + + if self.started: + return + self.started = True + + self.close() + self.should_exit = Event() + self.t = Thread( + target=plugin_run, + args=( + conf, + self.emit, + self.context, + self.should_exit, + self.updated, + self.dconf, + ), + ) + self.t.start() + + def close(self): + if not self.should_exit or not self.t: + return + self.should_exit.set() + self.t.join() + self.should_exit = None + self.t = None + + +def autodetect(existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: + if len(existing): + return existing + + # Match just product name + # if a device exists here its officially supported + with open("/sys/devices/virtual/dmi/id/product_name") as f: + dmi = f.read().strip() + + dconf = CONFS.get(dmi, None) + if dconf: + return [GenericControllersPlugin(dmi, dconf)] + + try: + with open("/sys/devices/virtual/dmi/id/sys_vendor") as f: + vendor = f.read().strip().lower() + if vendor == "ayn": + return [GenericControllersPlugin(dmi, get_default_config(dmi, "AYN"))] + except Exception: + pass + + # Fallback to chassis vendor for aya + try: + with open("/sys/class/dmi/id/board_vendor") as f: + vendor = f.read().lower().strip() + + if "ayaneo" in vendor: + return [GenericControllersPlugin(dmi, get_default_config(dmi, "AYA"))] + except Exception: + return [] + + return [] diff --git a/src/hhd/device/generic/base.py b/src/hhd/device/generic/base.py new file mode 100644 index 00000000..88022eef --- /dev/null +++ b/src/hhd/device/generic/base.py @@ -0,0 +1,296 @@ +import logging +import os +import select +import time +from threading import Event as TEvent + +from hhd.controller import DEBUG_MODE, Multiplexer +from hhd.controller.lib.hide import unhide_all +from hhd.controller.physical.hidraw import GenericGamepadHidraw +from hhd.controller.physical.evdev import B as EC +from hhd.controller.physical.evdev import GenericGamepadEvdev, enumerate_evs +from hhd.controller.physical.imu import CombinedImu, HrtimerTrigger +from hhd.controller.physical.rgb import LedDevice, is_led_supported +from hhd.controller.virtual.uinput import UInputDevice +from hhd.plugins import Config, Context, Emitter, get_gyro_state, get_outputs + +from .const import BTN_MAPPINGS, DEFAULT_MAPPINGS, TECNO_RAW_INTERFACE_BTN_MAP + +FIND_DELAY = 0.1 +ERROR_DELAY = 0.3 +LONGER_ERROR_DELAY = 3 +LONGER_ERROR_MARGIN = 1.3 + +logger = logging.getLogger(__name__) + + +GAMEPAD_VID = 0x045E +GAMEPAD_PID = 0x028E + +TECNO_VID = 0x2993 +TECNO_PID = 0x2001 + +ZOTAC_VID = 0x1EE9 +ZOTAC_PID = 0x1590 + +KBD_VID = 0x0001 +KBD_PID = 0x0001 + +BACK_BUTTON_DELAY = 0.1 + + +def plugin_run( + conf: Config, + emit: Emitter, + context: Context, + should_exit: TEvent, + updated: TEvent, + dconf: dict, +): + first = True + first_disabled = True + init = time.perf_counter() + repeated_fail = False + while not should_exit.is_set(): + if conf["controller_mode.mode"].to(str) == "disabled": + time.sleep(ERROR_DELAY) + if first_disabled: + UInputDevice.close_volume_cached() + unhide_all() + first_disabled = False + continue + else: + first_disabled = True + + try: + match dconf.get("type", None): + case "tecno": + vid = TECNO_VID + case "zotac": + vid = ZOTAC_VID + case _: + vid = GAMEPAD_VID + found_device = bool(enumerate_evs(vid=vid)) + except Exception: + logger.warning("Failed finding device, skipping check.") + time.sleep(LONGER_ERROR_DELAY) + found_device = True + + if not found_device: + if first: + logger.info("Controller not found. Waiting...") + time.sleep(FIND_DELAY) + first = False + continue + + try: + logger.info("Launching emulated controller.") + updated.clear() + init = time.perf_counter() + controller_loop(conf.copy(), should_exit, updated, dconf, emit) + repeated_fail = False + except Exception as e: + failed_fast = init + LONGER_ERROR_MARGIN > time.perf_counter() + sleep_time = ( + LONGER_ERROR_DELAY if repeated_fail and failed_fast else ERROR_DELAY + ) + repeated_fail = failed_fast + logger.error(f"Received the following error:\n{type(e)}: {e}") + logger.error( + f"Assuming controllers disconnected, restarting after {sleep_time}s." + ) + first = True + # Raise exception + if DEBUG_MODE: + raise e + time.sleep(sleep_time) + + # Unhide all devices before exiting and close keyboard cache + UInputDevice.close_volume_cached() + unhide_all() + + +def controller_loop( + conf: Config, should_exit: TEvent, updated: TEvent, dconf: dict, emit: Emitter +): + debug = DEBUG_MODE + dtype = dconf.get("type", "generic") + dgyro = dconf.get("display_gyro", True) + + # Output + d_producers, d_outs, d_params = get_outputs( + conf["controller_mode"], + None, + dgyro and conf["imu"].to(bool), + emit=emit, + rgb_modes={"disabled": [], "solid": ["color"]} if is_led_supported() else None, + ) + motion = d_params.get("uses_motion", True) + + # Imu + if dgyro: + d_imu = CombinedImu( + conf["imu_hz"].to(int), + get_gyro_state(conf["imu_axis"], dconf.get("mapping", DEFAULT_MAPPINGS)), + ) + else: + d_imu = None + d_timer = HrtimerTrigger(conf["imu_hz"].to(int), [HrtimerTrigger.IMU_NAMES]) + + # Inputs + d_xinput = GenericGamepadEvdev( + vid=[GAMEPAD_VID, TECNO_VID, ZOTAC_VID], + pid=[GAMEPAD_PID, TECNO_PID, ZOTAC_PID], + # name=["Generic X-Box pad"], + capabilities={EC("EV_KEY"): [EC("BTN_A")]}, + required=True, + hide=True, + ) + + d_kbd_1 = GenericGamepadEvdev( + vid=[KBD_VID], + pid=[KBD_PID], + required=False, + grab=True, + btn_map=dconf.get("btn_mapping", BTN_MAPPINGS), + ) + d_kbd_2 = None + + kargs = {} + if dtype == "tecno": + kargs = { + "keyboard_is": "steam_qam", + "qam_hhd": True, + } + + multiplexer = Multiplexer( + trigger="analog_to_discrete", + dpad="analog_to_discrete", + share_to_qam=True, + select_reboots=conf.get("select_reboots", False), + nintendo_mode=conf["nintendo_mode"].to(bool), + emit=emit, + params=d_params, + startselect_chord=conf.get("main_chords", "disabled"), + swap_guide="select_is_guide" if conf["swap_guide"].to(bool) else None, + **kargs, + ) + + d_volume_btn = UInputDevice( + name="Handheld Daemon Volume Keyboard", + phys="phys-hhd-vbtn", + capabilities={EC("EV_KEY"): [EC("KEY_VOLUMEUP"), EC("KEY_VOLUMEDOWN")]}, + btn_map={ + "key_volumeup": EC("KEY_VOLUMEUP"), + "key_volumedown": EC("KEY_VOLUMEDOWN"), + }, + pid=KBD_PID, + vid=KBD_VID, + output_timestamps=True, + ) + + d_rgb = LedDevice() + if d_rgb.supported: + logger.info(f"RGB Support activated through kernel driver.") + + REPORT_FREQ_MIN = 25 + REPORT_FREQ_MAX = 400 + + if motion: + REPORT_FREQ_MAX = max(REPORT_FREQ_MAX, conf["imu_hz"].to(float)) + + REPORT_DELAY_MAX = 1 / REPORT_FREQ_MIN + REPORT_DELAY_MIN = 1 / REPORT_FREQ_MAX + + fds = [] + devs = [] + fd_to_dev = {} + + def prepare(m): + devs.append(m) + fs = m.open() + fds.extend(fs) + for f in fs: + fd_to_dev[f] = m + + try: + if dtype == "tecno": + d_kbd_2 = GenericGamepadHidraw( + vid=[TECNO_VID], + pid=[TECNO_PID], + usage_page=[0xFFA0], + usage=[0x0001], + required=True, + btn_map=TECNO_RAW_INTERFACE_BTN_MAP, + ) + if dtype == "zotac": + d_kbd_1 = GenericGamepadEvdev( + vid=[ZOTAC_VID], + pid=[ZOTAC_PID], + required=True, + btn_map=dconf.get("btn_mapping", BTN_MAPPINGS), + capabilities={EC("EV_KEY"): [EC("KEY_F17"), EC("KEY_F18")]}, + ) + + prepare(d_xinput) + if motion and d_imu: + start_imu = True + if dconf.get("hrtimer", False): + start_imu = d_timer.open() + if start_imu: + prepare(d_imu) + prepare(d_volume_btn) + prepare(d_kbd_1) + if d_kbd_2: + prepare(d_kbd_2) + for d in d_producers: + prepare(d) + + logger.info("Emulated controller launched, have fun!") + while not should_exit.is_set() and not updated.is_set(): + start = time.perf_counter() + # Add timeout to call consumers a minimum amount of times per second + r, _, _ = select.select(fds, [], [], REPORT_DELAY_MAX) + evs = [] + to_run = set() + for f in r: + to_run.add(id(fd_to_dev[f])) + + for d in devs: + if id(d) in to_run: + evs.extend(d.produce(r)) + + evs = multiplexer.process(evs) + if evs: + if debug: + logger.info(evs) + + d_volume_btn.consume(evs) + d_xinput.consume(evs) + + d_rgb.consume(evs) + for d in d_outs: + d.consume(evs) + + t = time.perf_counter() + elapsed = t - start + if elapsed < REPORT_DELAY_MIN: + time.sleep(REPORT_DELAY_MIN - elapsed) + + except KeyboardInterrupt: + raise + finally: + # d_vend.close(not updated.is_set()) + try: + d_timer.close() + except Exception as e: + logger.error(f"Error while closing device '{d}' with exception:\n{e}") + if debug: + raise e + for d in reversed(devs): + try: + d.close(not updated.is_set()) + except Exception as e: + logger.error(f"Error while closing device '{d}' with exception:\n{e}") + if debug: + raise e diff --git a/src/hhd/device/generic/const.py b/src/hhd/device/generic/const.py new file mode 100644 index 00000000..932dbd15 --- /dev/null +++ b/src/hhd/device/generic/const.py @@ -0,0 +1,199 @@ +from hhd.controller import Axis, Button, Configuration +from hhd.controller.physical.evdev import B, to_map +from hhd.plugins import gen_gyro_state +from hhd.controller.physical.hidraw import AM, BM, CM + +DEFAULT_MAPPINGS: dict[str, tuple[Axis, str | None, float, float | None]] = { + "accel_x": ("accel_z", "accel", 1, None), + "accel_y": ("accel_x", "accel", -1, None), + "accel_z": ("accel_y", "accel", -1, None), + "anglvel_x": ("gyro_z", "anglvel", 1, None), + "anglvel_y": ("gyro_x", "anglvel", -1, None), + "anglvel_z": ("gyro_y", "anglvel", -1, None), + "timestamp": ("imu_ts", None, 1, None), +} + +BTN_MAPPINGS: dict[int, Button] = { + # Volume buttons come from the same keyboard + B("KEY_VOLUMEUP"): "key_volumeup", + B("KEY_VOLUMEDOWN"): "key_volumedown", + # + # Loki + # + B("KEY_T"): "share", # T + LCTRL + LSHFT + LALT +} + +ANBERNIC_MAPPINGS: dict[int, str] = { + B("KEY_VOLUMEUP"): "key_volumeup", + B("KEY_VOLUMEDOWN"): "key_volumedown", + B("KEY_LEFTMETA"): "share", + B("KEY_G"): "mode", +} + +ZOTAC_ZONE_MAPPINGS = { + # ctrl start f17/f18 + B("KEY_F17"): "mode", + B("KEY_F18"): "share", +} + +TECNO_BTN_MAPPINGS = { + B("KEY_VOLUMEUP"): "key_volumeup", + B("KEY_VOLUMEDOWN"): "key_volumedown", + B("KEY_F1"): "share", # Center button (shift+alt+ctrl+f1) +} + +TECNO_RAW_INTERFACE_BTN_MAP: dict[int | None, dict[Button, BM]] = { + 0x74: { + # Misc + "mode": BM((5 << 3) + 7), # 1: Bottom left + "keyboard": BM((5 << 3) + 6), # 2: Bottom right + "extra_l1": BM((7 << 3) + 2), # 3: Keyboard switch button + } +} + + +AYANEO_DEFAULT_MAPPINGS: dict[str, tuple[Axis, str | None, float, float | None]] = { + "accel_x": ("accel_z", "accel", 1, None), + "accel_y": ("accel_x", "accel", -1, None), + "accel_z": ("accel_y", "accel", -1, None), + "anglvel_x": ("gyro_z", "anglvel", 1, None), + "anglvel_y": ("gyro_x", "anglvel", -1, None), + "anglvel_z": ("gyro_y", "anglvel", -1, None), + "timestamp": ("imu_ts", None, 1, None), +} + +AYANEO_AIR_PLUS_MAPPINGS: dict[str, tuple[Axis, str | None, float, float | None]] = { + "accel_x": ("accel_z", "accel", -1, None), + "accel_y": ("accel_x", "accel", -1, None), + "accel_z": ("accel_y", "accel", 1, None), + "anglvel_x": ("gyro_z", "anglvel", -1, None), + "anglvel_y": ("gyro_x", "anglvel", -1, None), + "anglvel_z": ("gyro_y", "anglvel", 1, None), + "timestamp": ("imu_ts", None, 1, None), +} + +AYANEO_BTN_MAPPINGS: dict[int, str] = { + # Volume buttons come from the same keyboard + B("KEY_VOLUMEUP"): "key_volumeup", + B("KEY_VOLUMEDOWN"): "key_volumedown", + # Air Plus mappings + B("KEY_F17"): "mode", # Big Button + B("KEY_D"): "share", # Small Button + B("KEY_F15"): "extra_l1", # LC Button + B("KEY_F16"): "extra_r1", # RC Button + # NEXT mappings + B( + "KEY_F12" + ): "mode", # Big Button NEXT:[[96, 105, 133], [88, 97, 125]] ; Air [88, 97, 125] + # B("KEY_D"): "share", # Small Button [[40, 133], [32, 125]] + # 2021 Mappings + B("KEY_DELETE"): "share", # TM Button [97,100,111] + B("KEY_ESC"): "extra_l1", # ESC Button [1] + B("KEY_O"): "extra_r1", # KB Button [97, 24, 125] + # B("KEY_LEFTMETA"): "extra_r1", # Win Button [125], Conflict with KB Button + # Air mappings + B("KEY_F11"): "extra_l1", # LC Button [87, 97, 125] F11 + LCTRL + LMETA + B("KEY_F10"): "extra_r1", # Rc Button [68, 97, 125] F10 + LCTRL + LMETA +} + +AYA_DEFAULT_CONF = { + "hrtimer": True, + "btn_mapping": AYANEO_BTN_MAPPINGS, + "mapping": AYANEO_DEFAULT_MAPPINGS, +} +ONEX_DEFAULT_CONF = { + "hrtimer": True, +} + +CONFS = { + # Ayn + "Loki MiniPro": { + "name": "Loki MiniPro", + "hrtimer": True, + "mapping": gen_gyro_state("x", False, "z", False, "y", True), + "extra_buttons": "none", + }, + "Loki Max": { + "name": "Loki Max", + "hrtimer": True, + "mapping": gen_gyro_state("x", False, "z", False, "y", True), + "extra_buttons": "none", + }, + "Loki Zero": { + "name": "Loki Zero", + "hrtimer": True, + "mapping": gen_gyro_state("x", False, "z", False, "y", True), + "extra_buttons": "none", + }, + # Ayaneo + "AIR Plus": { + "name": "AYANEO AIR Plus", + **AYA_DEFAULT_CONF, + "mapping": AYANEO_AIR_PLUS_MAPPINGS, + }, + "AIR 1S": {"name": "AIR 1S", **AYA_DEFAULT_CONF}, + "AIR 1S Limited": {"name": "AIR 1S Limited", **AYA_DEFAULT_CONF}, + "AYANEO 2": {"name": "AYANEO 2", **AYA_DEFAULT_CONF}, + "AYANEO 2S": {"name": "AYANEO S2", **AYA_DEFAULT_CONF}, + "GEEK": {"name": "AYANEO GEEK", **AYA_DEFAULT_CONF}, + "GEEK 1S": {"name": "AYANEO GEEK 1S", **AYA_DEFAULT_CONF}, + "AIR": {"name": "AYANEO AIR", **AYA_DEFAULT_CONF}, + "AIR Pro": {"name": "AYANEO AIR Pro", **AYA_DEFAULT_CONF}, + "NEXT Advance": {"name": "AYANEO NEXT Advance", **AYA_DEFAULT_CONF}, + "NEXT Lite": {"name": "AYANEO NEXT Lite", **AYA_DEFAULT_CONF}, + "NEXT Pro": {"name": "AYANEO NEXT Pro", **AYA_DEFAULT_CONF}, + "NEXT": {"name": "AYANEO NEXT", **AYA_DEFAULT_CONF}, + "SLIDE": { + "name": "AYANEO SLIDE", + **AYA_DEFAULT_CONF, + "mapping": gen_gyro_state("z", False, "x", False, "y", False), + }, + "AYA NEO FOUNDER": {"name": "AYA NEO FOUNDER", **AYA_DEFAULT_CONF}, + "AYA NEO 2021": {"name": "AYA NEO 2021", **AYA_DEFAULT_CONF}, + "AYANEO 2021": {"name": "AYANEO 2021", **AYA_DEFAULT_CONF}, + "AYANEO 2021 Pro": {"name": "AYANEO 2021 Pro", **AYA_DEFAULT_CONF}, + "AYANEO 2021 Pro Retro Power": { + "name": "AYANEO 2021 Pro Retro Power", + **AYA_DEFAULT_CONF, + }, + "KUN": {"name": "AYANEO 2021 Kun", **AYA_DEFAULT_CONF}, + "AYANEO KUN": {"name": "AYANEO Kun", **AYA_DEFAULT_CONF}, + # Anbernic + "Win600": { + "name": "Anbernic Win600", + "btn_mapping": ANBERNIC_MAPPINGS, + "extra_buttons": "none", + }, + # TECNO + "Pocket Go": { + "name": "TECNO (Displayless)", + "extra_buttons": "none", + "btn_mapping": TECNO_BTN_MAPPINGS, + "type": "tecno", + "display_gyro": False, + }, + # Zotac Zone 1st Gen + # board name: G0A1W + "ZOTAC GAMING ZONE": { + "name": "Zotac Gaming Zone (1st gen)", + "extra_buttons": "none", # not yet + "btn_mapping": ZOTAC_ZONE_MAPPINGS, + "type": "zotac", + "display_gyro": False, + }, +} + + +def get_default_config(product_name: str, manufacturer: str): + out = { + "name": product_name, + "manufacturer": manufacturer, + "hrtimer": True, + "untested": True, + } + + if manufacturer == "AYA": + out["btn_mapping"] = AYANEO_BTN_MAPPINGS + out["mapping"] = AYANEO_DEFAULT_MAPPINGS + + return out diff --git a/src/hhd/device/generic/controllers.yml b/src/hhd/device/generic/controllers.yml new file mode 100644 index 00000000..71a7f270 --- /dev/null +++ b/src/hhd/device/generic/controllers.yml @@ -0,0 +1,70 @@ +type: container +tags: [lgc] +title: Handheld +hint: >- + Allows for configuring your handheld's controller to a unified output. + +children: + controller_mode: + type: mode + tags: [controller_mode] + title: Controller Emulation + hint: >- + Emulate different controller types to fuse your device's features. + + main_chords: + type: multiple + tags: [ non-essential ] + title: Start/Select do SteamOS Combos + hint: >- + When holding Select or Start, if another button is pressed, they become + the Xbox button, which allows doing SteamOS combos (Select+RT is + screenshot). + options: + disabled: "Disabled" + select: "Select Only" + start_select: "Start+Select" + default: select + + swap_guide: + type: bool + title: Swap Guide and Menu/View + tags: [non-essential] + hint: >- + Swaps the Guide and QAM buttons with start and select. + default: False + + # + # Common settings + # + imu: + type: bool + title: Motion Support + hint: >- + Enable gyroscope/accelerometer (IMU) support (.3% background CPU use) + default: True + + imu_hz: + type: discrete + title: Motion Hz + tags: [ non-essential ] + hint: >- + Sets the sampling frequency for the IMU. + options: [100, 200, 400, 800] + default: 400 + + imu_axis: + + nintendo_mode: + type: bool + title: Nintendo Mode (A-B Swap) + tags: [ non-essential ] + hint: >- + Swaps A with B and X with Y. + default: False + + select_reboots: + type: bool + tags: [non-essential] + title: Hold View to Reboot + default: False diff --git a/src/hhd/device/gpd/win/__init__.py b/src/hhd/device/gpd/win/__init__.py new file mode 100644 index 00000000..a9e41b08 --- /dev/null +++ b/src/hhd/device/gpd/win/__init__.py @@ -0,0 +1,311 @@ +from threading import Event, Thread +from typing import Any, Sequence + +from hhd.controller.lib.hid import enumerate_unique +from hhd.plugins import ( + Config, + Context, + Emitter, + HHDPlugin, + get_gyro_config, + get_outputs_config, + get_touchpad_config, + load_relative_yaml, +) +from hhd.plugins.settings import HHDSettings +from hhd.utils import get_distro_color, hsb_to_rgb + +from .const import ( + GPD_WIN_4_8840U_MAPPINGS, + GPD_WIN_DEFAULT_MAPPINGS, + GPD_WIN_MAX_2_2023_MAPPINGS, +) + +GPD_CONFS = { + "G1618-03": { # Old model, has no gyro/touchpad + "name": "GPD Win 3", + "touchpad": False, + "hrtimer": False, + }, + "G1618-04": { + "name": "GPD Win 4", + "hrtimer": True, + "wincontrols": True, + "rgb": True, + "combo": "menu", + "chord": "select", + }, + "G1617-01": { + "name": "GPD Win Mini", + "touchpad": True, + "wincontrols": True, + }, + "G1617-02": { + "name": "GPD Win Mini (2025)", + "touchpad": True, + "wincontrols": False, + }, + "G1619-04": { + "name": "GPD Win Max 2 (04)", + "hrtimer": True, + "touchpad": True, + "mapping": GPD_WIN_MAX_2_2023_MAPPINGS, + "wincontrols": True, + }, + "G1619-05": { + "name": "GPD Win Max 2 (05)", + "hrtimer": True, + "touchpad": True, + "mapping": GPD_WIN_MAX_2_2023_MAPPINGS, + "wincontrols": True, + }, +} + + +def get_default_config(product_name: str): + return { + "name": product_name, + "hrtimer": True, + "untested": True, + } + + +class GpdWinControllersPlugin(HHDPlugin): + name = "gpd_win_controllers" + priority = 18 + log = "gpdw" + + def __init__(self, dmi: str, dconf: dict) -> None: + self.t = None + self.should_exit = None + self.updated = Event() + self.started = False + self.t = None + + self.dmi = dmi + self.dconf = dconf + self.name = f"gpd_win_controllers@'{dconf.get('name', 'ukn')}'" + + def open( + self, + emit: Emitter, + context: Context, + ): + self.emit = emit + self.context = context + self.prev = None + + def settings(self) -> HHDSettings: + base = {"controllers": {"gpd_win": load_relative_yaml("controllers.yml")}} + base["controllers"]["gpd_win"]["children"]["controller_mode"].update( + get_outputs_config( + can_disable=True, + has_leds=False, + start_disabled=self.dconf.get("untested", False), + ) + ) + + # Tweak defaults for l4r4menu and main_chords + base["controllers"]["gpd_win"]["children"]["l4r4"]["default"] = self.dconf.get( + "combo", "r4" + ) + base["controllers"]["gpd_win"]["children"]["main_chords"]["default"] = ( + self.dconf.get("chord", "disabled") + ) + + if self.dconf.get("touchpad", False): + base["controllers"]["gpd_win"]["children"][ + "touchpad" + ] = get_touchpad_config() + else: + del base["controllers"]["gpd_win"]["children"]["touchpad"] + + base["controllers"]["gpd_win"]["children"]["imu_axis"] = get_gyro_config( + self.dconf.get("mapping", GPD_WIN_DEFAULT_MAPPINGS) + ) + return base + + def update(self, conf: Config): + new_conf = conf["controllers.gpd_win"] + if new_conf == self.prev: + return + if self.prev is None: + self.prev = new_conf + else: + self.prev.update(new_conf.conf) + + self.updated.set() + self.start(self.prev) + + def start(self, conf): + from .base import plugin_run + + if self.started: + return + self.started = True + + self.close() + self.should_exit = Event() + self.t = Thread( + target=plugin_run, + args=( + conf, + self.emit, + self.context, + self.should_exit, + self.updated, + self.dconf, + ), + ) + self.t.start() + + def close(self): + if not self.should_exit or not self.t: + return + self.should_exit.set() + self.t.join() + self.should_exit = None + self.t = None + + +class GpdWinControlsPlugin(HHDPlugin): + name = "gpd_wincontrols" + priority = 18 + log = "gpdc" + + def __init__(self, dmi: str, dconf: dict) -> None: + self.dmi = dmi + self.dconf = dconf + self.name = f"gpd_wincontrols@'{dconf.get('name', 'ukn')}'" + + def open( + self, + emit: Emitter, + context: Context, + ): + self.emit = emit + self.context = context + + def settings(self) -> HHDSettings: + base = {"wincontrols": {"wincontrols": load_relative_yaml("wincontrols.yml")}} + + if self.dconf.get("rgb", False): + hue = get_distro_color() + base["wincontrols"]["wincontrols"]["children"]["leds"]["modes"]["solid"][ + "children" + ]["hue"]["default"] = hue + base["wincontrols"]["wincontrols"]["children"]["leds"]["modes"]["pulse"][ + "children" + ]["hue"]["default"] = hue + else: + del base["wincontrols"]["wincontrols"]["children"]["leds"] + + return base + + def update(self, conf: Config): + if not conf.get_action(f"wincontrols.wincontrols.apply"): + return + + from .wincontrols import ( + BACKBUTTONS_DEFAULT, + BACKBUTTONS_HHD, + BUTTONS_DEFAULT, + BUTTONS_PHAWX, + BUTTONS_TRIGGERS_DEFAULT, + BUTTONS_TRIGGERS_STEAMOS, + update_config, + ) + + c = conf["wincontrols.wincontrols"] + vibration = c.get("vibration", "off") + + buttons = {} + delays = {} + deadzones = {} + + match c.get("mouse_mode", "unchanged"): + case "mouse": + buttons.update(BUTTONS_DEFAULT) + case "wasd": + buttons.update(BUTTONS_PHAWX) + + match c.get("mouse_mode_triggers", "unchanged"): + case "gpd": + buttons.update(BUTTONS_TRIGGERS_DEFAULT) + case "steamos": + buttons.update(BUTTONS_TRIGGERS_STEAMOS) + + match c.get("l4r4", "unchanged"): + case "hhd": + buttons.update(BACKBUTTONS_HHD["buttons"]) + delays.update(BACKBUTTONS_HHD["delays"]) + case "default": + buttons.update(BACKBUTTONS_DEFAULT["buttons"]) + delays.update(BACKBUTTONS_DEFAULT["delays"]) + + if c.get("deadzones.mode", "unchanged") == "custom": + deadzones.update(c.get("deadzones.custom", {})) + + rgb_mode = "off" + rgb_color = (0, 0, 0) + match c.get("leds.mode", "disabled"): + case "disabled": + rgb_mode = "off" + case "solid": + rgb_mode = "constant" + hue = c.get("leds.solid.hue", 0) + rgb_color = tuple(hsb_to_rgb(hue, 100, 100)) + case "pulse": + rgb_mode = "breathed" + hue = c.get("leds.pulse.hue", 0) + rgb_color = tuple(hsb_to_rgb(hue, 100, 100)) + case "rainbow": + rgb_mode = "rotated" + + try: + conf["wincontrols.wincontrols.fwver"] = update_config( + buttons, + delays, + rumble=vibration, + rgb_mode=rgb_mode, + rgb_color=rgb_color, # type: ignore + deadzones=deadzones, + ) + except Exception as e: + conf["wincontrols.wincontrols.status"] = f"{e}" + conf["wincontrols.wincontrols.fwver"] = f"" + + +def autodetect(existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: + if len(existing): + return existing + + try: + # Match just product number, should be enough for now + with open("/sys/devices/virtual/dmi/id/product_name") as f: + dmi = f.read().strip() + dconf = GPD_CONFS.get(dmi, None) + + if dmi == "G1618-04": + with open("/proc/cpuinfo") as f: + cpuinfo = f.read().strip() + # 8840U has a different gyro mapping + if "AMD Ryzen 7 8840U" in cpuinfo: + dconf = dict(GPD_CONFS["G1618-04"]) + dconf["name"] = "GPD Win 4 (8840U)" + dconf["mapping"] = GPD_WIN_4_8840U_MAPPINGS + + if dconf: + base: list[HHDPlugin] = [GpdWinControllersPlugin(dmi, dconf)] + if dconf.get("wincontrols", False): + base.append(GpdWinControlsPlugin(dmi, dconf)) + return base + + with open("/sys/devices/virtual/dmi/id/sys_vendor") as f: + vendor = f.read().strip().lower() + if vendor == "gpd": + return [GpdWinControllersPlugin(dmi, get_default_config(dmi))] + except Exception: + pass + + return [] diff --git a/src/hhd/device/gpd/win/base.py b/src/hhd/device/gpd/win/base.py new file mode 100644 index 00000000..0c1a2301 --- /dev/null +++ b/src/hhd/device/gpd/win/base.py @@ -0,0 +1,416 @@ +import logging +import re +import select +import time +from threading import Event as TEvent +from typing import Sequence + +import evdev + +from hhd.controller import DEBUG_MODE, Event, Multiplexer, can_read +from hhd.controller.base import Event +from hhd.controller.lib.hide import unhide_all +from hhd.controller.physical.evdev import B as EC +from hhd.controller.physical.evdev import GenericGamepadEvdev +from hhd.controller.physical.hidraw import GenericGamepadHidraw +from hhd.controller.physical.imu import CombinedImu, HrtimerTrigger +from hhd.plugins import Config, Context, Emitter, get_gyro_state, get_outputs + +from .const import ( + GPD_TOUCHPAD_AXIS_MAP, + GPD_TOUCHPAD_BUTTON_MAP, + GPD_WIN_DEFAULT_MAPPINGS, +) + +ERROR_DELAY = 0.3 +SELECT_TIMEOUT = 1 +ERROR_DELAY = 0.3 +LONGER_ERROR_DELAY = 3 +LONGER_ERROR_MARGIN = 1.3 + +logger = logging.getLogger(__name__) + +# Old devices were 2f24:0135 +# New 2025 Win mini uses 045e:002d +GPD_WIN_VIDS = [0x2F24, 0x045e] +GPD_WIN_PIDS = [0x0135, 0x002d] +GAMEPAD_VID = 0x045E +GAMEPAD_PID = 0x028E + +# Win Max 2 +TOUCHPAD_VID = 0x093A +TOUCHPAD_PID = 0x0255 +# Win Minis +TOUCHPAD_VID_2 = 0x0911 +TOUCHPAD_PID_2 = 0x5288 + +BACK_BUTTON_DELAY = 0.025 + +# /dev/input/event17 Microsoft X-Box 360 pad usb-0000:73:00.3-4.1/input0 +# bus: 0003, vendor 045e, product 028e, version 0101 + +# back buttons +# /dev/input/event15 Mouse for Windows usb-0000:73:00.3-4.2/input1 +# bus: 0003, vendor 2f24, product 0135, version 0110 + +# physical keyboard +# /dev/input/event13 Mouse for Windows usb-0000:73:00.3-4.2/input0 +# bus: 0003, vendor 2f24, product 0135, version 0110 + +# hidraw back buttons {'path': b'/dev/hidraw6', +# 'vendor_id': 12068, 'product_id': 309, 'serial_number': '', +# 'release_number': 256, 'manufacturer_string': ' ', +# 'product_string': 'Mouse for Windows', +# 'usage_page': 1, 'usage': 6, 'interface_number': 1}, + +LEFT_BUTTONS = { + EC("KEY_SYSRQ"), + EC("KEY_F20"), +} + +RIGHT_BUTTONS = { + EC("KEY_PAUSE"), + EC("KEY_F21"), +} + + +class BackbuttonsEvdev(GenericGamepadEvdev): + def __init__(self, *args, **kwargs) -> None: + self.left_pressed = False + self.left_released = None + self.right_pressed = False + self.right_released = None + super().__init__(*args, **kwargs) + + def produce(self, fds: Sequence[int]): + if not self.dev: + return [] + + # GPD events execute micro sequences + # Inbetween the sequences, there is a ~20ms gap in which the + # button is not pressed. Therefore, record when the button was + # pressed and if more than ~25ms has passed, consider it released. + curr = time.perf_counter() + out = [] + while self.fd in fds and can_read(self.dev): + for e in self.dev.read(): + if e.type != EC("EV_KEY"): + continue + + pressed = e.value != 0 + + if e.code in LEFT_BUTTONS: + if pressed: + if not self.left_pressed: + out.append( + {"type": "button", "code": "extra_l1", "value": True} + ) + self.left_pressed = True + self.left_released = None + else: + self.left_released = curr + if e.code in RIGHT_BUTTONS: + if pressed: + if not self.right_pressed: + out.append( + { + "type": "button", + "code": "extra_r1", + "value": True, + } + ) + self.right_pressed = True + self.right_released = None + else: + self.right_released = curr + + if self.left_released and curr - self.left_released > BACK_BUTTON_DELAY: + out.append({"type": "button", "code": "extra_l1", "value": False}) + self.left_released = None + self.left_pressed = False + if self.right_released and curr - self.right_released > BACK_BUTTON_DELAY: + out.append({"type": "button", "code": "extra_r1", "value": False}) + self.right_released = None + self.right_pressed = False + + return out + + +def plugin_run( + conf: Config, + emit: Emitter, + context: Context, + should_exit: TEvent, + updated: TEvent, + dconf: dict, +): + first = True + first_disabled = True + init = time.perf_counter() + repeated_fail = False + while not should_exit.is_set(): + if conf["controller_mode.mode"].to(str) == "disabled": + time.sleep(ERROR_DELAY) + if first_disabled: + unhide_all() + first_disabled = False + continue + else: + first_disabled = True + + found_gamepad = False + try: + for d in evdev.list_devices(): + dev = evdev.InputDevice(d) + if dev.info.vendor == GAMEPAD_VID and dev.info.product == GAMEPAD_PID: + found_gamepad = True + break + except Exception: + logger.warning("Failed finding device, skipping check.") + found_gamepad = True + + if not found_gamepad: + if first: + logger.info("Controller in Mouse mode. Waiting...") + time.sleep(ERROR_DELAY) + first = False + continue + + try: + logger.info("Launching emulated controller.") + updated.clear() + init = time.perf_counter() + controller_loop(conf.copy(), should_exit, updated, dconf, emit) + repeated_fail = False + except Exception as e: + failed_fast = init + LONGER_ERROR_MARGIN > time.perf_counter() + sleep_time = ( + LONGER_ERROR_DELAY if repeated_fail and failed_fast else ERROR_DELAY + ) + repeated_fail = failed_fast + logger.exception( + f"Assuming controllers disconnected, restarting after {sleep_time}s." + ) + # Raise exception + if DEBUG_MODE: + import traceback + logger.error(traceback.format_exc()) + time.sleep(sleep_time) + + # Unhide all devices before exiting + unhide_all() + + +def controller_loop( + conf: Config, should_exit: TEvent, updated: TEvent, dconf: dict, emit: Emitter +): + debug = DEBUG_MODE + has_touchpad = dconf.get("touchpad", False) + + # Output + d_producers, d_outs, d_params = get_outputs( + conf["controller_mode"], + conf["touchpad"] if has_touchpad else None, + conf["imu"].to(bool), + emit=emit, + ) + motion = d_params.get("uses_motion", True) + + # Imu + d_imu = CombinedImu( + conf["imu_hz"].to(int), + get_gyro_state( + conf["imu_axis"], dconf.get("mapping", GPD_WIN_DEFAULT_MAPPINGS) + ), + ) + d_timer = HrtimerTrigger(conf["imu_hz"].to(int), [HrtimerTrigger.IMU_NAMES]) + + # Inputs + d_xinput = GenericGamepadEvdev( + vid=[GAMEPAD_VID], + pid=[GAMEPAD_PID], + # name=["Generic X-Box pad"], + capabilities={EC("EV_KEY"): [EC("BTN_A")]}, + required=True, + hide=True, + ) + + # "PNP0C50:00 0911:5288 Touchpad" on Win Max 2 2023 + # "PNP0C50:00 093A:0255 Touchpad" on Win Mini + d_touch = GenericGamepadEvdev( + vid=[TOUCHPAD_VID, TOUCHPAD_VID_2], + pid=[TOUCHPAD_PID, TOUCHPAD_PID_2], + name=[re.compile(".+Touchpad")], + capabilities={EC("EV_KEY"): [EC("BTN_MOUSE")]}, + btn_map=GPD_TOUCHPAD_BUTTON_MAP, + axis_map=GPD_TOUCHPAD_AXIS_MAP, + aspect_ratio=1.333, + required=False, + ) + + # Vendor + d_kbd_1 = BackbuttonsEvdev( + vid=GPD_WIN_VIDS, + pid=GPD_WIN_PIDS, + capabilities={EC("EV_KEY"): [EC("KEY_SYSRQ"), EC("KEY_PAUSE")]}, + required=True, + grab=True, + # btn_map={EC("KEY_SYSRQ"): "extra_l1", EC("KEY_PAUSE"): "extra_r1"}, + ) + + match conf["l4r4"].to(str): + case "l4": + qam_button = "extra_l1" + l4r4_enabled = True + qam_hold = "hhd" + case "r4": + qam_button = "extra_r1" + l4r4_enabled = True + qam_hold = "hhd" + case "menu": + qam_button = "mode" + l4r4_enabled = True + qam_hold = "mode" + case "disabled": + qam_button = None + l4r4_enabled = False + qam_hold = "hhd" + case _: + qam_button = None + l4r4_enabled = True + qam_hold = "hhd" + + if has_touchpad: + touch_actions = ( + conf["touchpad.controller"] + if conf.get("touchpad.mode", None) == "controller" + else conf["touchpad.emulation"] + ) + + multiplexer = Multiplexer( + trigger="analog_to_discrete", + dpad="analog_to_discrete", + touchpad_short=touch_actions.get("short", "disabled"), + touchpad_hold=touch_actions.get("hold", "disabled"), + nintendo_mode=conf["nintendo_mode"].to(bool), + qam_button=qam_button, + emit=emit, + params=d_params, + # qam_multi_tap=qam_multi_tap, # supports it now + qam_hold=qam_hold, + startselect_chord=conf.get("main_chords", "disabled"), + ) + else: + multiplexer = Multiplexer( + trigger="analog_to_discrete", + dpad="analog_to_discrete", + nintendo_mode=conf["nintendo_mode"].to(bool), + qam_button=qam_button, + emit=emit, + params=d_params, + # qam_multi_tap=qam_multi_tap, # supports it now + qam_hold=qam_hold, + startselect_chord=conf.get("main_chords", "disabled"), + ) + + REPORT_FREQ_MIN = 25 + REPORT_FREQ_MAX = 400 + + if motion: + REPORT_FREQ_MAX = max(REPORT_FREQ_MAX, conf["imu_hz"].to(float)) + + REPORT_DELAY_MAX = 1 / REPORT_FREQ_MIN + REPORT_DELAY_MIN = 1 / REPORT_FREQ_MAX + + fds = [] + devs = [] + fd_to_dev = {} + + def prepare(m): + devs.append(m) + fs = m.open() + fds.extend(fs) + for f in fs: + fd_to_dev[f] = m + + try: + if l4r4_enabled: + kbd_fds = d_kbd_1.open() + fds.extend(kbd_fds) + else: + kbd_fds = [] + prepare(d_xinput) + if motion: + start_imu = True + if dconf.get("hrtimer", False): + start_imu = d_timer.open() + if start_imu: + prepare(d_imu) + if has_touchpad and d_params["uses_touch"]: + prepare(d_touch) + for d in d_producers: + prepare(d) + + logger.info("Emulated controller launched, have fun!") + while not should_exit.is_set() and not updated.is_set(): + start = time.perf_counter() + # Add timeout to call consumers a minimum amount of times per second + r, _, _ = select.select(fds, [], [], REPORT_DELAY_MAX) + evs = [] + to_run = set() + for f in r: + # skip kbd_1 to always run it + if f not in kbd_fds: + to_run.add(id(fd_to_dev[f])) + + for d in devs: + if id(d) in to_run: + evs.extend(d.produce(r)) + evs.extend(d_kbd_1.produce(r)) + + evs = multiplexer.process(evs) + if evs: + if debug: + logger.info(evs) + d_xinput.consume(evs) + + for d in d_outs: + d.consume(evs) + + # If unbounded, the total number of events per second is the sum of all + # events generated by the producers. + # For Legion go, that would be 100 + 100 + 500 + 30 = 730 + # Since the controllers of the legion go only update at 500hz, this is + # wasteful. + # By setting a target refresh rate for the report and sleeping at the + # end, we ensure that even if multiple fds become ready close to each other + # they are combined to the same report, limiting resource use. + # Ideally, this rate is smaller than the report rate of the hardware controller + # to ensure there is always a report from that ready during refresh + t = time.perf_counter() + elapsed = t - start + if elapsed < REPORT_DELAY_MIN: + time.sleep(REPORT_DELAY_MIN - elapsed) + + except KeyboardInterrupt: + raise + finally: + try: + d_kbd_1.close(not updated.is_set()) + except Exception as e: + logger.error(f"Error while closing device '{d}' with exception:\n{e}") + if debug: + raise e + try: + d_timer.close() + except Exception as e: + logger.error(f"Error while closing device '{d}' with exception:\n{e}") + if debug: + raise e + for d in reversed(devs): + try: + d.close(not updated.is_set()) + except Exception as e: + logger.error(f"Error while closing device '{d}' with exception:\n{e}") + if debug: + raise e diff --git a/src/hhd/device/gpd/win/const.py b/src/hhd/device/gpd/win/const.py new file mode 100644 index 00000000..28ba2a92 --- /dev/null +++ b/src/hhd/device/gpd/win/const.py @@ -0,0 +1,40 @@ +from hhd.controller import Axis, Button +from hhd.controller.physical.evdev import B, to_map +from hhd.plugins import gen_gyro_state + +GPD_TOUCHPAD_BUTTON_MAP: dict[int, Button] = to_map( + { + "touchpad_touch": [B("BTN_TOOL_FINGER")], # also BTN_TOUCH + "touchpad_right": [B("BTN_TOOL_DOUBLETAP"), B("BTN_RIGHT")], + "touchpad_left": [B("BTN_MOUSE")], + } +) + +GPD_TOUCHPAD_AXIS_MAP: dict[int, Axis] = to_map( + { + "touchpad_x": [B("ABS_X")], # also ABS_MT_POSITION_X + "touchpad_y": [B("ABS_Y")], # also ABS_MT_POSITION_Y + } +) + +GPD_WIN_DEFAULT_MAPPINGS: dict[str, tuple[Axis, str | None, float, float | None]] = { + "accel_x": ("accel_x", "accel", 1, None), + "accel_y": ("accel_z", "accel", 1, None), + "accel_z": ("accel_y", "accel", -1, None), + "anglvel_x": ("gyro_x", "anglvel", 1, None), + "anglvel_y": ("gyro_z", "anglvel", 1, None), + "anglvel_z": ("gyro_y", "anglvel", -1, None), + "timestamp": ("imu_ts", None, 1, None), +} + +GPD_WIN_MAX_2_2023_MAPPINGS: dict[str, tuple[Axis, str | None, float, float | None]] = { + "accel_x": ("accel_z", "accel", -1, None), + "accel_y": ("accel_x", "accel", -1, None), + "accel_z": ("accel_y", "accel", -1, None), + "anglvel_x": ("gyro_z", "anglvel", -1, None), + "anglvel_y": ("gyro_x", "anglvel", -1, None), + "anglvel_z": ("gyro_y", "anglvel", -1, None), + "timestamp": ("imu_ts", None, 1, None), +} + +GPD_WIN_4_8840U_MAPPINGS = gen_gyro_state("z", True, "x", False, "y", True) \ No newline at end of file diff --git a/src/hhd/device/gpd/win/controllers.yml b/src/hhd/device/gpd/win/controllers.yml new file mode 100644 index 00000000..28f0a2dc --- /dev/null +++ b/src/hhd/device/gpd/win/controllers.yml @@ -0,0 +1,75 @@ +type: container +tags: [lgc] +title: GPD Controller +hint: >- + Allows for configuring the gpd win controllers to a unified output. + +children: + controller_mode: + type: mode + tags: [gpd_gen_3_controller_mode] + title: Controller Emulation + hint: >- + Emulate different controller types to fuse gpd features. + + l4r4: + type: multiple + tags: [ non-essential ] + title: Menu/L4/R4 Mapping + options: + disabled: "Disabled" + generic: "Paddles, no Combo" + menu: "Menu is Combo" + l4: "L4 is Combo" + r4: "R4 is Combo" + default: menu + hint: >- + Maps L4/R4 to Steam Input (requires L4/R4 as HHD in Wincontrols + tab). If disabled, they are keyboard buttons. Menu/L4/R4 can be + combos: Menu is single-press QAM, double HHD, hold + Xbox. L4/R4 are single-press QAM, hold HHD (legacy). + + main_chords: + type: multiple + tags: [ non-essential ] + title: Start/Select do SteamOS Combos + hint: >- + When holding Select or Start, if another button is pressed, they become + the Xbox button, which allows doing SteamOS combos (Select+RT is + screenshot). + options: + disabled: "Disabled" + select: "Select Only" + start_select: "Start+Select" + default: select + + # + # Common settings + # + imu: + type: bool + title: Motion Support + hint: >- + Enable gyroscope/accelerometer (IMU) support (.3% background CPU use) + default: True + + imu_hz: + type: discrete + title: Motion Hz + tags: [ non-essential ] + hint: >- + Sets the sampling frequency for the IMU. + options: [100, 200, 400, 800] + default: 400 + + imu_axis: + + touchpad: + + nintendo_mode: + type: bool + tags: [ non-essential ] + title: Nintendo Mode (A-B Swap) + hint: >- + Swaps A with B and X with Y. + default: False diff --git a/src/hhd/device/gpd/win/wincontrols.py b/src/hhd/device/gpd/win/wincontrols.py new file mode 100644 index 00000000..efa14b2c --- /dev/null +++ b/src/hhd/device/gpd/win/wincontrols.py @@ -0,0 +1,656 @@ +import logging +import time + +from hhd.controller.lib.hid import Device, enumerate_unique + +logger = logging.getLogger(__name__) + +ACTION_MAP = { + "none": 0x00, + # Standard HID codes + # Letters + "a": 0x04, + "b": 0x05, + "c": 0x06, + "d": 0x07, + "e": 0x08, + "f": 0x09, + "g": 0x0A, + "h": 0x0B, + "i": 0x0C, + "j": 0x0D, + "k": 0x0E, + "l": 0x0F, + "m": 0x10, + "n": 0x11, + "o": 0x12, + "p": 0x13, + "q": 0x14, + "r": 0x15, + "s": 0x16, + "t": 0x17, + "u": 0x18, + "v": 0x19, + "w": 0x1A, + "x": 0x1B, + "y": 0x1C, + "z": 0x1D, + # Numbers + "1": 0x1E, + "2": 0x1F, + "3": 0x20, + "4": 0x21, + "5": 0x22, + "6": 0x23, + "7": 0x24, + "8": 0x25, + "9": 0x26, + "0": 0x27, + # Special characters + "enter": 0x28, + "escape": 0x29, + "backspace": 0x2A, + "tab": 0x2B, + "space": 0x2C, + "minus": 0x2D, + "equal": 0x2E, + "leftbrace": 0x2F, + "rightbrace": 0x30, + "backslash": 0x31, + "hashtilde": 0x32, + "semicolon": 0x33, + "apostrophe": 0x34, + "grave": 0x35, + "comma": 0x36, + "dot": 0x37, + "slash": 0x38, + "capslock": 0x39, + "f1": 0x3A, + "f2": 0x3B, + "f3": 0x3C, + "f4": 0x3D, + "f5": 0x3E, + "f6": 0x3F, + "f7": 0x40, + "f8": 0x41, + "f9": 0x42, + "f10": 0x43, + "f11": 0x44, + "f12": 0x45, + "sysrq": 0x46, + "scrolllock": 0x47, + "pause": 0x48, + "insert": 0x49, + "home": 0x4A, + "pageup": 0x4B, + "delete": 0x4C, + "end": 0x4D, + "pagedown": 0x4E, + "right": 0x4F, + "left": 0x50, + "down": 0x51, + "up": 0x52, + "numlock": 0x53, + "kpslash": 0x54, + "kpasterisk": 0x55, + "kpminus": 0x56, + "kpplus": 0x57, + "kpenter": 0x58, + "kp1": 0x59, + "kp2": 0x5A, + "kp3": 0x5B, + "kp4": 0x5C, + "kp5": 0x5D, + "kp6": 0x5E, + "kp7": 0x5F, + "kp8": 0x60, + "kp9": 0x61, + "kp0": 0x62, + "kpdot": 0x63, + "102nd": 0x64, + "compose": 0x65, + "power": 0x66, + "kpequal": 0x67, + "f13": 0x68, + "f14": 0x69, + "f15": 0x6A, + "f16": 0x6B, + "f17": 0x6C, + "f18": 0x6D, + "f19": 0x6E, + "f20": 0x6F, + "f21": 0x70, + "f22": 0x71, + "f23": 0x72, + "f24": 0x73, + "open": 0x74, + "help": 0x75, + "props": 0x76, + "front": 0x77, + "stop": 0x78, + "again": 0x79, + "undo": 0x7A, + "cut": 0x7B, + "copy": 0x7C, + "paste": 0x7D, + "find": 0x7E, + "mute": 0x7F, + "volumeup": 0x80, + "volumedown": 0x81, + "kpcomma": 0x85, + "ro": 0x87, + "katakanahiragana": 0x88, + "yen": 0x89, + "henkan": 0x8A, + "muhenkan": 0x8B, + "kpjpcomma": 0x8C, + "hangeul": 0x90, + "hanja": 0x91, + "katakana": 0x92, + "hiragana": 0x93, + "zenkakuhankaku": 0x94, + "kpleftparen": 0xB6, + "kprightparen": 0xB7, + "leftctrl": 0xE0, + "leftshift": 0xE1, + "leftalt": 0xE2, + "leftmeta": 0xE3, + "rightctrl": 0xE4, + "rightshift": 0xE5, + "rightalt": 0xE6, + "rightmeta": 0xE7, + "mouse_wheelup": 0xE8, + "mouse_wheeldown": 0xE9, + "mouse_left": 0xEA, + "mouse_right": 0xEB, + "mouse_middle": 0xEC, + "mouse_fast": 0xED, + # Vendor Mappings by GPD + # Gamepad Buttons + "dpad_up": 0xFF01, + "dpad_down": 0xFF02, + "dpad_left": 0xFF03, + "dpad_right": 0xFF04, + "btn_a": 0xFF05, + "btn_b": 0xFF06, + "btn_x": 0xFF07, + "btn_y": 0xFF08, + "ls_up": 0xFF09, + "ls_down": 0xFF0A, + "ls_left": 0xFF0B, + "ls_right": 0xFF0C, + "ls": 0xFF0D, + "rs": 0xFF0E, + "start": 0xFF0F, + "select": 0xFF10, + "menu": 0xFF11, + "lb": 0xFF12, + "rb": 0xFF13, + "lt": 0xFF14, + "rt": 0xFF15, + "rs_up": 0xFF16, + "rs_down": 0xFF17, + "rs_left": 0xFF18, + "rs_right": 0xFF19, +} + + +BUTTON_MAP = { + # Standard buttons (rs is mouse always) + "dpad_up": 0, + "dpad_down": 2, + "dpad_left": 4, + "dpad_right": 6, + "a": 8, + "b": 10, + "x": 12, + "y": 14, + "ls_up": 16, + "ls_down": 18, + "ls_left": 20, + "ls_right": 22, + "ls": 24, + "rs": 26, + "start": 28, + "select": 30, + "menu": 32, + "lb": 34, + "rb": 36, + "lt": 38, + "rt": 40, + # Macro chains + "extra_l1": 50, + "extra_l2": 52, + "extra_l3": 54, + "extra_l4": 56, + "extra_r1": 58, + "extra_r2": 60, + "extra_r3": 62, + "extra_r4": 64, +} + +DEADZONE_MAP = { + "ls_boundary": 72, + "ls_center": 73, + "rs_boundary": 74, + "rs_center": 75, +} + +DELAY_MAP = { + "extra_l1": 80, + "extra_l2": 82, + "extra_l3": 84, + "extra_l4": 86, + "extra_r1": 88, + "extra_r2": 90, + "extra_r3": 92, + "extra_r4": 94, +} + +RGB_MODES = { + "off": 0, + "constant": 1, + "breathed": 0x11, + "rotated": 0x21, +} + +RUMBLE_MODES = { + "off": 0, + "medium": 1, + "high": 2, +} + +BACKBUTTONS_HHD = { + "buttons": { + "extra_l1": "f20", # "sysrq", + "extra_l2": "none", + "extra_l3": "none", + "extra_l4": "none", + "extra_r1": "f21", # "pause", + "extra_r2": "none", + "extra_r3": "none", + "extra_r4": "none", + }, + "delays": { + "extra_l1": 0, + "extra_l2": 0, + "extra_l3": 0, + "extra_l4": 25, + "extra_r1": 0, + "extra_r2": 0, + "extra_r3": 0, + "extra_r4": 25, + }, +} +BACKBUTTONS_DEFAULT = { + "buttons": { + "extra_l1": "sysrq", + "extra_l2": "none", + "extra_l3": "none", + "extra_l4": "none", + "extra_r1": "pause", + "extra_r2": "none", + "extra_r3": "none", + "extra_r4": "none", + }, + "delays": { + "extra_l1": 0, + "extra_l2": 0, + "extra_l3": 0, + "extra_l4": 300, + "extra_r1": 0, + "extra_r2": 0, + "extra_r3": 0, + "extra_r4": 300, + }, +} + +BUTTONS_DEFAULT = { + "dpad_up": "mouse_wheelup", + "dpad_down": "mouse_wheeldown", + "dpad_left": "home", + "dpad_right": "end", + "a": "down", + "b": "right", + "x": "left", + "y": "up", + "ls_up": "w", + "ls_down": "s", + "ls_left": "a", + "ls_right": "d", + "ls": "space", + "rs": "enter", + "start": "none", + "select": "none", + "menu": "none", +} + +BUTTONS_PHAWX = { + "dpad_up": "up", + "dpad_down": "down", + "dpad_left": "left", + "dpad_right": "right", + "a": "space", + "b": "leftctrl", + "x": "z", + "y": "leftalt", + "ls_up": "w", + "ls_down": "s", + "ls_left": "a", + "ls_right": "d", + "ls": "leftshift", + "rs": "enter", + "start": "escape", + "select": "enter", + "menu": "none", +} + +BUTTONS_TRIGGERS_DEFAULT = { + "lb": "mouse_left", + "rb": "mouse_right", + "lt": "mouse_middle", + "rt": "mouse_fast", +} + +BUTTONS_TRIGGERS_STEAMOS = { + "lb": "mouse_middle", + "rb": "mouse_fast", + "lt": "mouse_right", + "rt": "mouse_left", +} + +WSIZE = 33 +RSIZE = 64 + + +def get_command(cid: int, ofs: int = 0, payload: bytes = b"") -> bytes: + base = bytes([0x01, 0xA5, cid, 0x5A, 0xFF ^ cid, 0x00, ofs, 0x00]) + payload + return base + bytes([0x00] * (33 - len(base))) + + +PAUSE = 0.05 + +GM_SUPPROTED_VERSIONS = {3: 0x14, 4: 0x09, 5: 0x10} # Win Max 2 # Win 4 # Win Mini +EXT_SUPPORTED_VERSIONS = {1: 0x23, 4: 0x07, 5: 0x04} + + +def check_fwver(res: bytes): + ready = res[8] == 0xAA + gm_major_ver = res[9] + gm_minor_ver = res[10] + ext_major_ver = res[11] + ext_minor_ver = res[12] + + fwver = f"X{gm_major_ver}{gm_minor_ver:02x}K{ext_major_ver}{ext_minor_ver:02x}" + + # Version check + for k, v in GM_SUPPROTED_VERSIONS.items(): + if gm_major_ver == k: + assert ( + gm_minor_ver <= v + ), f"Unsupported gamepad firmware version {fwver} (up to X{k}{v:02x})" + break + else: + raise ValueError(f"Unsupported gamepad major version {gm_major_ver} in {fwver}") + for k, v in EXT_SUPPORTED_VERSIONS.items(): + if ext_major_ver == k: + assert ( + ext_minor_ver <= v + ), f"Unsupported extendboard firmware {fwver} version (up to K{k}{v:02x})" + break + else: + raise ValueError( + f"Unsupported extendboard major version {gm_major_ver} in {fwver}" + ) + + return ready, fwver + + +def read_config(d: Device) -> tuple[str, bytes]: + ready = False + while not ready: + d.send_feature_report(get_command(0x10)) + time.sleep(PAUSE) + fw_data = d.get_feature_report(0x01) + time.sleep(PAUSE) + ready, fwver = check_fwver(fw_data) + + logger.info(f"Device ready, firmware version: {fwver}") + + cfg = bytes() + for i in range(4): + d.send_feature_report(get_command(0x11, i)) + time.sleep(PAUSE) + cfg += d.get_feature_report(0x01) + time.sleep(PAUSE) + + logger.info("") + logger.info("Config:") + tmp = cfg + i = 0 + while sum(tmp): + logger.info(f"{i:02d}: {tmp[:16].hex()}") + tmp = tmp[16:] + i += 1 + + logger.info("") + d.send_feature_report(get_command(0x12)) + time.sleep(PAUSE) + + fw_data = d.get_feature_report(0x1) + ready, fwver_new = check_fwver(fw_data) + assert fwver == fwver_new, "Firmware version changed during read." + assert ready, "Device not ready after read." + + chash = int.from_bytes(fw_data[24:28], "little") + logger.info(f"Sum: x{chash:x}") + # What is this number, it is on 0x10 too + # chash2 = int.from_bytes(fw_data[28:32], "little") + # logger.info(f"Ukn: " + str(chash2)) + computed = sum(cfg) + logger.info(f"Computed Sum: x{computed:x}") + + assert computed == chash, "Did not read config properly: hash mismatch" + return fwver, cfg + + +def write_config(d: Device, fwver: str, cfg: bytes): + d.send_feature_report(get_command(0x21)) + time.sleep(PAUSE) + fw_data = d.get_feature_report(0x01) + time.sleep(PAUSE) + ready = False + while not ready: + ready, fwver_new = check_fwver(fw_data) + assert fwver == fwver_new, "Firmware version changed during write." + + logger.info(f"Firmware version: {fwver}") + logger.info("") + logger.info("Writing Config:") + for i in range(8): + sls = cfg[16 * i : 16 * i + 16] + logger.info(f"{i:02d}: {sls.hex()}") + d.send_feature_report(get_command(0x21, i, sls)) + + d.send_feature_report(get_command(0x22)) + time.sleep(PAUSE) + fw_data = d.get_feature_report(0x1) + ready, fwver_new = check_fwver(fw_data) + assert ready, "Device not ready after write." + assert fwver == fwver_new, "Firmware version changed during write." + + chash = int.from_bytes(fw_data[24:28], "little") + logger.info("") + logger.info(f"Sum: x{chash:x}") + computed = sum(cfg) + logger.info(f"Computed Sum: x{computed:x}") + + assert computed == chash, "Did not write config properly: hash mismatch" + + logger.info("Writing config to memory and restarting device.") + d.send_feature_report(get_command(0x23)) + + +def update_config( + buttons: dict[str, str] = {}, + delays: dict[str, int] = {}, + deadzones: dict[str, int] = {}, + rumble: str | None = None, + rgb_mode: str | None = None, + rgb_color: tuple[int, int, int] | None = None, +): + devs = enumerate_unique(0x2F24, 0x0135, 0xFF00, 0x0001) + assert devs, "No devices found." + + dev = devs[0] + with Device(path=dev["path"]) as d: + fwver, cfg = read_config(d) + + # Apply changes + init_cfg = cfg + cfg = bytearray(cfg) + + for k, v in buttons.items(): + assert k in BUTTON_MAP, f"Unknown button {k}" + assert v in ACTION_MAP, f"Unknown action {v}" + cfg[BUTTON_MAP[k] : BUTTON_MAP[k] + 2] = ACTION_MAP[v].to_bytes(2, "little") + + for k, v in delays.items(): + assert k in DELAY_MAP, f"Unknown delay {k}" + cfg[DELAY_MAP[k] : DELAY_MAP[k] + 2] = v.to_bytes(2, "little") + + for k, v in deadzones.items(): + assert k in DEADZONE_MAP, f"Unknown deadzone {k}" + cfg[DEADZONE_MAP[k] : DEADZONE_MAP[k] + 1] = v.to_bytes( + 1, "little", signed=True + ) + + if rumble is not None: + assert rumble in RUMBLE_MODES, f"Unknown rumble mode {rumble}" + cfg[66 : 66 + 2] = RUMBLE_MODES[rumble].to_bytes(2, "little") + + deadzones = {k: min(max(v, -10), 10) for k, v in deadzones.items()} + for k, v in deadzones.items(): + assert k in DEADZONE_MAP, f"Unknown deadzone {k}" + cfg[DEADZONE_MAP[k] : DEADZONE_MAP[k] + 1] = v.to_bytes( + 1, "little", signed=True + ) + + if "K4" in fwver: + # Limit RGB changes to Win 4 with firmware 40X + if rgb_mode is not None: + assert rgb_mode in RGB_MODES, f"Unknown rgb mode {rgb_mode}" + cfg[68] = RGB_MODES[rgb_mode] + + if rgb_color is not None: + assert len(rgb_color) == 3, "RGB color must be a tuple of 3 integers" + cfg[69 : 69 + 3] = bytes(rgb_color) + + if all(i == j for i, j in zip(cfg, init_cfg)): + logger.info("No changes to apply. Skipping write.") + return fwver + + with Device(path=dev["path"]) as d: + write_config(d, fwver, bytes(cfg)) + + return fwver + + +def explain_config(): + ACTION_MAP_REV = {v: k for k, v in ACTION_MAP.items()} + RGB_MODES_REV = {v: k for k, v in RGB_MODES.items()} + + devs = enumerate_unique(0x2F24, 0x0135, 0xFF00, 0x0001) + assert devs, "No devices found." + + dev = devs[0] + with Device(path=dev["path"]) as d: + _, cfg = read_config(d) + + logger.info("\nButtons:") + for k, v in BUTTON_MAP.items(): + val = int.from_bytes(cfg[v : v + 2], "little") + val = ACTION_MAP_REV.get(val, f"0x{val:02x}") + logger.info(f" {k}: {val}") + + logger.info("\nMacro Delays:") + for k, v in DELAY_MAP.items(): + val = int.from_bytes(cfg[v : v + 2], "little") + logger.info(f" {k}: {val}ms") + + logger.info("\nDeadzones:") + for k, v in DEADZONE_MAP.items(): + val = int.from_bytes(cfg[v : v + 2], "little", signed=True) + logger.info(f" {k}: {val}") + + rumbl = "ukn" + match int.from_bytes(cfg[66 : 66 + 2], "little"): + case 0: + rumbl = "off" + case 1: + rumbl = "medium" + case 2: + rumbl = "high" + logger.info(f"\nRumble: {rumbl}") + + rgb_mode = RGB_MODES_REV.get(cfg[68], "ukn") + rgb_color = cfg[69 : 69 + 3].hex() + + logger.info(f"RGB: {rgb_mode} #{rgb_color}") + + +# From factory, the following are the default values: +# Buttons: +# dpad_up: mouse_wheelup +# dpad_down: mouse_wheeldown +# dpad_left: home +# dpad_right: end +# a: down +# b: right +# x: left +# y: up +# ls_up: w +# ls_down: s +# ls_left: a +# ls_right: d +# ls: space +# rs: enter +# start: none +# select: none +# menu: none +# lb: mouse_left +# rb: mouse_right +# lt: mouse_middle +# rt: mouse_fast +# extra_l1: sysrq +# extra_l2: none +# extra_l3: none +# extra_l4: none +# extra_r1: pause +# extra_r2: none +# extra_r3: none +# extra_r4: none +# +# I might have changed the macro delays +# Macro Delays: +# extra_l1: 0ms +# extra_l2: 0ms +# extra_l3: 0ms +# extra_l4: 300ms +# extra_r1: 0ms +# extra_r2: 0ms +# extra_r3: 0ms +# extra_r4: 300ms +# +# Deadzones: +# ls_deadzone: 0 +# ls_center: 0 +# rs_deadzone: 0 +# rs_center: 0 + +# Rumble: medium +# RGB: rotated #0000ff + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(message)s") + update_config(**BACKBUTTONS_HHD) diff --git a/src/hhd/device/gpd/win/wincontrols.yml b/src/hhd/device/gpd/win/wincontrols.yml new file mode 100644 index 00000000..32d0d357 --- /dev/null +++ b/src/hhd/device/gpd/win/wincontrols.yml @@ -0,0 +1,168 @@ +type: container +tags: [non-essential] +title: WinControls +hint: >- + Specialized settings for GPD devices. +children: + leds: + type: mode + title: RGB Mode + default: rainbow + + modes: + disabled: + type: container + title: "Off" + tags: [ non-essential, rgb, disabled ] + hint: >- + Turns the LEDs off. + + solid: + type: container + title: Solid + tags: [ non-essential, rgb, solid ] + hint: >- + Maintains the LEDs at a solid color. + children: + hue: &hue + type: int + tags: [hue, rgb] + title: Hue + min: 0 + max: 360 + step: 5 + unit: "°" + default: 30 + + # Required for colors to show + saturation: &saturation + type: int + tags: [saturation, rgb, hidden] + min: 0 + max: 100 + default: 80 + + brightness: &brightness + type: int + tags: [brightness, rgb, hidden] + min: 0 + max: 100 + default: 80 + + pulse: + type: container + title: Pulse + tags: [ non-essential, rgb, pulse ] + hint: >- + Slowly pulses the LEDs as a prespecified color. + children: + hue: *hue + saturation: *saturation + brightness: *brightness + + rainbow: + type: container + title: Rainbow + tags: [ non-essential, rainbow ] + hint: >- + Cycles through the different colors. + + mouse_mode: + type: multiple + title: Mouse Mode Mapping + options: + unchanged: "Do not change" + mouse: "GPD Mouse Mode" + wasd: "For Games" + default: unchanged + + mouse_mode_triggers: + type: multiple + title: Mouse Mode Triggers + options: + unchanged: "Do not change" + gpd: "GPD (RT is Fast Mouse)" + steamos: "SteamOS (LT/RT are R/L Clicks)" + default: unchanged + + l4r4: + type: multiple + title: L4/R4 Mapping + options: + unchanged: "Do not change" + hhd: "For HHD (F20/F21)" + default: "Default (Pause/PrntScr)" + default: hhd + + deadzones: + type: mode + title: Deadzones + default: unchanged + + modes: + unchanged: + type: container + title: "Do not change" + tags: [ non-essential, deadzone, unchanged ] + hint: >- + Do not change the deadzones. + + custom: + type: container + title: "Set Deadzones" + tags: [ non-essential, deadzone, custom ] + hint: >- + Use custom deadzones. + children: + ls_center: + type: int + title: Left Stick Center + min: -10 + max: 10 + default: 0 + + ls_boundary: + type: int + title: Left Stick Boundary + min: -10 + max: 10 + default: 0 + + rs_center: + type: int + title: Right Stick Center + min: -10 + max: 10 + default: 0 + + rs_boundary: + type: int + title: Right Stick Boundary + min: -10 + max: 10 + default: 0 + + vibration: + type: multiple + title: Vibration Strength + tags: [ non-essential, vibration, ordinal ] + options: + "off": "Off" + medium: Medium + high: High + default: medium + + fwver: + type: display + title: Firmware + tags: [ non-essential, slim ] + + status: + type: display + title: Error + tags: [ non-essential, slim ] + + apply: + type: action + title: Apply Settings + tags: [ non-essential, apply ] \ No newline at end of file diff --git a/src/hhd/device/legion_go/__init__.py b/src/hhd/device/legion_go/__init__.py index dfc7eb5b..33a7bcd4 100644 --- a/src/hhd/device/legion_go/__init__.py +++ b/src/hhd/device/legion_go/__init__.py @@ -1,56 +1,32 @@ -from threading import Event, Thread -from typing import Any, Sequence - -from hhd.plugins import Config, Context, Emitter, HHDPlugin, load_relative_yaml -from hhd.plugins.settings import HHDSettings - - -class LegionControllersPlugin(HHDPlugin): - name = "legion_go_controllers" - priority = 18 - log = "llgo" - - def __init__(self) -> None: - self.t = None - self.event = None - - def open( - self, - emit: Emitter, - context: Context, - ): - self.emit = emit - self.context = context - self.prev = None - - def settings(self) -> HHDSettings: - return {"controllers": {"legion_go": load_relative_yaml("controllers.yaml")}} - - def update(self, conf: Config): - if conf["controllers.legion_go"] == self.prev: - return - self.prev = conf["controllers.legion_go"] - - self.start(self.prev) - - def start(self, conf): - from .base import plugin_run - - self.close() - self.event = Event() - self.t = Thread( - target=plugin_run, - args=(conf, self.emit, self.context, self.event), - ) - self.t.start() - - def close(self): - if not self.event or not self.t: - return - self.event.set() - self.t.join() - self.event = None - self.t = None +from typing import Sequence + +from hhd.plugins import ( + HHDPlugin, +) + +from .slim import LegionGoSControllerPlugin +from .tablet import LegionGoControllersPlugin + +LEGION_GO_CONFS = { + "83E1": { + "name": "Legion Go", + }, +} + +LEGION_S_CONFS = { + "83L3": { + "name": "Legion Go S Z2 Go", + }, + "83N6": { + "name": "Legion Go S Z1E", + }, + "83Q2": { + "name": "Legion Go S", + }, + "83Q3": { + "name": "Legion Go S", + }, +} def autodetect(existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: @@ -59,7 +35,12 @@ def autodetect(existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: # Match just product number, should be enough for now with open("/sys/devices/virtual/dmi/id/product_name") as f: - if not f.read().strip() == "83E1": - return [] + dmi = f.read().strip() + + if dmi in LEGION_S_CONFS: + return [LegionGoSControllerPlugin(dconf=LEGION_S_CONFS[dmi])] + + if dmi in LEGION_GO_CONFS: + return [LegionGoControllersPlugin(dconf=LEGION_GO_CONFS[dmi])] - return [LegionControllersPlugin()] + return [] diff --git a/src/hhd/device/legion_go/base.py b/src/hhd/device/legion_go/base.py deleted file mode 100644 index bf9025b2..00000000 --- a/src/hhd/device/legion_go/base.py +++ /dev/null @@ -1,363 +0,0 @@ -import argparse -import logging -import re -import select -import sys -import time -from threading import Event as TEvent -from typing import Sequence, cast - -from hhd.controller import Button, Consumer, Event, Producer -from hhd.controller.base import Multiplexer -from hhd.controller.lib.hid import enumerate_unique -from hhd.controller.physical.evdev import B as EC -from hhd.controller.physical.evdev import GenericGamepadEvdev -from hhd.controller.physical.hidraw import GenericGamepadHidraw -from hhd.controller.physical.imu import AccelImu, GyroImu -from hhd.controller.virtual.ds5 import DualSense5Edge, TouchpadCorrectionType -from hhd.controller.virtual.uinput import UInputDevice -from hhd.plugins import Config, Context, Emitter - -from .const import ( - LGO_RAW_INTERFACE_AXIS_MAP, - LGO_RAW_INTERFACE_BTN_ESSENTIALS, - LGO_RAW_INTERFACE_BTN_MAP, - LGO_RAW_INTERFACE_CONFIG_MAP, - LGO_TOUCHPAD_AXIS_MAP, - LGO_TOUCHPAD_BUTTON_MAP, -) -from .gyro_fix import GyroFixer -from .hid import rgb_callback - -ERROR_DELAY = 1 - -logger = logging.getLogger(__name__) - -LEN_VID = 0x17EF -LEN_PIDS = { - 0x6182: "xinput", - 0x6183: "dinput", - 0x6184: "dual_dinput", - 0x6185: "fps", -} - - -def plugin_run(conf: Config, emit: Emitter, context: Context, should_exit: TEvent): - if gyro_fix := conf.get("gyro_fix", False): - gyro_fixer = GyroFixer(int(gyro_fix) if int(gyro_fix) > 10 else 100) - else: - gyro_fixer = None - - while not should_exit.is_set(): - try: - controller_mode = None - pid = None - while not controller_mode: - devs = enumerate_unique(LEN_VID) - if not devs: - logger.error( - f"Legion go controllers not found, waiting {ERROR_DELAY}s." - ) - time.sleep(ERROR_DELAY) - continue - - for d in devs: - if d["product_id"] in LEN_PIDS: - pid = d["product_id"] - controller_mode = LEN_PIDS[pid] - break - else: - logger.error( - f"Legion go controllers not found, waiting {ERROR_DELAY}s." - ) - time.sleep(ERROR_DELAY) - continue - - match controller_mode: - case "xinput": - logger.info("Launching DS5 controller instance.") - if gyro_fixer: - gyro_fixer.open() - controller_loop_xinput(conf, should_exit) - case _: - logger.info( - f"Controllers in non-supported (yet) mode: {controller_mode}. Launching a shortcuts device." - ) - controller_loop_rest( - controller_mode, pid if pid else 2, conf, should_exit - ) - except Exception as e: - logger.error(f"Received the following error:\n{e}") - logger.error( - f"Assuming controllers disconnected, restarting after {ERROR_DELAY}s." - ) - time.sleep(ERROR_DELAY) - finally: - if gyro_fixer: - gyro_fixer.close() - - -def controller_loop_rest(mode: str, pid: int, conf: Config, should_exit: TEvent): - debug = conf.get("debug", False) - - d_raw = SelectivePassthrough( - GenericGamepadHidraw( - vid=[LEN_VID], - pid=list(LEN_PIDS), - usage_page=[0xFFA0], - usage=[0x0001], - report_size=64, - axis_map=LGO_RAW_INTERFACE_AXIS_MAP, - btn_map=LGO_RAW_INTERFACE_BTN_MAP, - required=True, - ) - ) - - multiplexer = Multiplexer( - dpad="analog_to_discrete", - trigger="analog_to_discrete", - share_to_qam=conf["share_to_qam"].to(bool), - ) - d_uinput = UInputDevice(name=f"HHD Shortcuts Device (Legion Mode: {mode})", pid=pid) - - d_shortcuts = GenericGamepadEvdev( - vid=[LEN_VID], - pid=list(LEN_PIDS), - # name=[re.compile(r"Legion-Controller \d-.. Keyboard")], - capabilities={EC("EV_KEY"): [EC("KEY_1")]}, - required=True, - ) - - try: - fds = [] - fds.extend(d_raw.open()) - fds.extend(d_shortcuts.open()) - fds.extend(d_uinput.open()) - - while not should_exit.is_set(): - select.select(fds, [], []) - d_shortcuts.produce(fds) - d_uinput.produce(fds) - evs = multiplexer.process(d_raw.produce(fds)) - if debug and evs: - logger.info(evs) - d_uinput.consume(evs) - finally: - d_shortcuts.close(True) - d_raw.close(True) - d_uinput.close(True) - - -def controller_loop_xinput(conf: Config, should_exit: TEvent): - debug = conf.get("debug", False) - - # Output - d_ds5 = DualSense5Edge( - touchpad_method=conf["touchpad_mode"].to(TouchpadCorrectionType) - ) - # from hhd.controller.virtual.sd import SteamdeckOLEDController - # d_ds5 = SteamdeckOLEDController() - - # Imu - d_accel = AccelImu() - d_gyro = GyroImu() - - # Inputs - d_xinput = GenericGamepadEvdev( - vid=[0x17EF], - pid=[0x6182], - # name=["Generic X-Box pad"], - capabilities={EC("EV_KEY"): [EC("BTN_A")]}, - required=True, - hide=True, - ) - d_touch = GenericGamepadEvdev( - vid=[0x17EF], - pid=[0x6182], - # name=[" Legion Controller for Windows Touchpad"], - capabilities={EC("EV_KEY"): [EC("BTN_MOUSE")]}, - btn_map=LGO_TOUCHPAD_BUTTON_MAP, - axis_map=LGO_TOUCHPAD_AXIS_MAP, - aspect_ratio=1, - required=True, - ) - d_raw = SelectivePassthrough( - GenericGamepadHidraw( - vid=[LEN_VID], - pid=list(LEN_PIDS), - usage_page=[0xFFA0], - usage=[0x0001], - report_size=64, - axis_map=LGO_RAW_INTERFACE_AXIS_MAP, - btn_map=LGO_RAW_INTERFACE_BTN_MAP, - config_map=LGO_RAW_INTERFACE_CONFIG_MAP, - callback=rgb_callback - if conf["xinput.ds5e.led_support"] - else None, - required=True, - ) - ) - # Mute keyboard shortcuts, mute - d_shortcuts = GenericGamepadEvdev( - vid=[LEN_VID], - pid=list(LEN_PIDS), - # name=[" Legion Controller for Windows Keyboard"], - capabilities={EC("EV_KEY"): [EC("KEY_1")]}, - # report_size=64, - required=True, - ) - - match conf["swap_legion"].to(str): - case "disabled": - swap_guide = None - case "l_is_start": - swap_guide = "guide_is_start" - case "l_is_select": - swap_guide = "guide_is_select" - case val: - assert False, f"Invalid value for `swap_legion`: {val}" - - multiplexer = Multiplexer( - swap_guide=swap_guide, - trigger="analog_to_discrete", - dpad="analog_to_discrete", - led="main_to_sides", - status="both_to_main", - share_to_qam=conf["share_to_qam"].to(bool), - ) - - REPORT_FREQ_MIN = 25 - REPORT_FREQ_MAX = 400 - - REPORT_DELAY_MAX = 1 / REPORT_FREQ_MIN - REPORT_DELAY_MIN = 1 / REPORT_FREQ_MAX - - fds = [] - devs = [] - fd_to_dev = {} - - def prepare(m): - fs = m.open() - devs.append(m) - fds.extend(fs) - for f in fs: - fd_to_dev[f] = m - - try: - prepare(d_xinput) - if conf.get("accel", False): - prepare(d_accel) - if conf.get("gyro", False): - prepare(d_gyro) - prepare(d_shortcuts) - if conf["touchpad_mode"].to(str) != "disabled": - prepare(d_touch) - prepare(d_raw) - prepare(d_ds5) - - logger.info("DS5 controller instance launched, have fun!") - while not should_exit.is_set(): - start = time.perf_counter() - # Add timeout to call consumers a minimum amount of times per second - r, _, _ = select.select(fds, [], [], REPORT_DELAY_MAX) - evs = [] - to_run = set() - for f in r: - to_run.add(id(fd_to_dev[f])) - - for d in devs: - if id(d) in to_run: - evs.extend(d.produce(r)) - - evs = multiplexer.process(evs) - if evs: - if debug: - logger.info(evs) - - d_xinput.consume(evs) - d_raw.consume(evs) - d_ds5.consume(evs) - - # If unbounded, the total number of events per second is the sum of all - # events generated by the producers. - # For Legion go, that would be 100 + 100 + 500 + 30 = 730 - # Since the controllers of the legion go only update at 500hz, this is - # wasteful. - # By setting a target refresh rate for the report and sleeping at the - # end, we ensure that even if multiple fds become ready close to each other - # they are combined to the same report, limiting resource use. - # Ideally, this rate is smaller than the report rate of the hardware controller - # to ensure there is always a report from that ready during refresh - t = time.perf_counter() - elapsed = t - start - if elapsed < REPORT_DELAY_MIN: - time.sleep(REPORT_DELAY_MIN - elapsed) - - except KeyboardInterrupt: - raise - finally: - for d in reversed(devs): - d.close(True) - - -class SelectivePassthrough(Producer, Consumer): - def __init__( - self, - parent, - forward_buttons: Sequence[Button] = ("share", "mode"), - passthrough: Sequence[Button] = list( - next(iter(LGO_RAW_INTERFACE_BTN_ESSENTIALS.values())) - ), - ): - self.parent = parent - self.state = False - - self.forward_buttons = forward_buttons - self.passthrough = passthrough - - self.to_disable_btn = set() - self.to_disable_axis = set() - - def open(self) -> Sequence[int]: - return self.parent.open() - - def close(self, exit: bool) -> bool: - return super().close(exit) - - def produce(self, fds: Sequence[int]) -> Sequence[Event]: - evs: Sequence[Event] = self.parent.produce(fds) - - out = [] - prev_state = self.state - for ev in evs: - if ev["type"] == "button" and ev["code"] in self.forward_buttons: - self.state = ev.get("value", False) - - if ev["type"] == "configuration": - out.append(ev) - elif ev["type"] == "button" and ev["code"] in self.passthrough: - out.append(ev) - elif ev["type"] == "button": - self.to_disable_btn.add(ev["code"]) - elif ev["type"] == "axis": - self.to_disable_axis.add(ev["code"]) - - if self.state: - # If mode is pressed, forward all events - return evs - elif prev_state: - # If prev_state, meaning the user released the mode or share button - # turn off all buttons that were pressed during it - for btn in self.to_disable_btn: - out.append({"type": "button", "code": btn, "value": False}) - self.to_disable_btn = set() - for axis in self.to_disable_axis: - out.append({"type": "axis", "code": axis, "value": 0}) - self.to_disable_axis = set() - return out - else: - # Otherwise, just return the standard buttons - return out - - def consume(self, events: Sequence[Event]): - return self.parent.consume(events) diff --git a/src/hhd/device/legion_go/controllers.yaml b/src/hhd/device/legion_go/controllers.yaml deleted file mode 100644 index bb8f34fb..00000000 --- a/src/hhd/device/legion_go/controllers.yaml +++ /dev/null @@ -1,129 +0,0 @@ -type: container -tags: [lgc] -title: Legion Controllers Configuration -hint: >- - Allows for configuring the legion controllers using the built in firmware - commands and enabling emulation modes for various controller types. - -children: - xinput: - type: mode - tags: [lgc_xinput] - title: X-input Emulation Mode - hint: >- - Emulate different controller types when the Legion Controllers are in X-Input mode. - - default: ds5e - modes: - # - # No emulation - # - disabled: - type: container - tags: [lgc_emulation_disabled] - title: Disabled - hint: >- - Does not modify the default controller. - - children: - shortcuts: - type: bool - title: Enable Shortcuts Device - hint: >- - Enables the shortcuts device which will re-enable QAM, Xbox - button, and their combinations. - default: True - # - # Dual Sense 5 - # - ds5e: - type: container - tags: [lgc_emulation_ds5e, ds5e] - title: Dual Sense 5 Edge - hint: >- - Emulates the expensive DS5 Edge sony controller that maps 1-1 to - the legion go. - - children: - led_support: - type: bool - title: LED Support - hint: >- - Passes through the LEDs to the controller, which allows games - to control them. - default: True - - # - # Common settings - # - gyro: - type: bool - title: Gyroscope - hint: >- - Enables gyroscope support (.3% background CPU use) - default: True - accel: - type: bool - title: Accelerometer - hint: >- - Enables accelerometer support (not currently supported properly). - default: False - gyro_fix: - type: discrete - title: Gyro Fix (hz) - hint: >- - Adds polling to the legion go gyroscope, to fix the low polling rate - (required for gyroscope support). Set to 0 to disable. - Due to hardware limitations, there is a marginal difference above - 100hz. - options: [0, 40, 60, 75, 100, 125, 200, 300] - default: 100 - swap_legion: - type: multiple - title: Swap Legion Buttons with Start/Select - hint: >- - Swaps the legion buttons with start select. - options: - disabled: "Disabled" - l_is_start: "Left is Start" - l_is_select: "Left is Select" - default: disabled - share_to_qam: - type: bool - title: Map the Legion L button to QAM (instead of Mute) - default: True - touchpad_mode: - type: multiple - title: Touchpad correction type - hint: >- - The legion touchpad is square, whereas the DS5 one is rectangular. - Therefore, it needs to be corrected. - "Contain" maintain the whole DS5 touchpad and part of the Legion - one is unused. "Crop" uses the full legion touchpad, and limits - the area of the DS5. "Stretch" uses both fully (distorted). - "Crop End" enables use in steam input as the right touchpad. - Set to "Disabled" to not remap. - options: - disabled: "Disabled" - stretch: "Stretch" - crop_center: "Crop Center" - crop_start: "Crop Start" - crop_end: "Crop End" - contain_start: "Contain Start" - contain_end: "Contain End" - contain_center: "Contain Center" - default: crop_end - debug: - type: bool - title: Debug - hint: >- - Output controller events to the console - default: False - - shortcuts: - type: bool - title: Enable Shortcuts Controller - hint: >- - When in other modes (dinput, dual dinput, and fps), enable a shortcuts - controller to restore Guide, QAM, and shortcut functionality. - default: True diff --git a/src/hhd/device/legion_go/hid.py b/src/hhd/device/legion_go/hid.py deleted file mode 100644 index 65f7ef6a..00000000 --- a/src/hhd/device/legion_go/hid.py +++ /dev/null @@ -1,159 +0,0 @@ -from enum import Enum -from typing import Literal, Sequence - -from hhd.controller import Event -from hhd.controller.lib.hid import Device - -Controller = Literal["left", "right"] -RgbMode = Literal["solid", "pulse", "dynamic", "spiral"] - -RGB_MODE_PULSE = 0x02 -RGB_MODE_DYNAMIC = 0x03 -RGB_MODE_SPIRAL = 0x04 - - -def _get_controller(c: Controller): - if c == "left": - return 0x03 - elif c == "right": - return 0x04 - assert False, f"Controller '{c}' not supported." - - -def rgb_set_profile( - controller: Controller, - profile: Literal[1, 2, 3], - mode: RgbMode, - red: int, - green: int, - blue: int, - brightness: float = 1, - speed: float = 1, -): - r_controller = _get_controller(controller) - assert profile in (1, 2, 3), f"Invalid profile '{profile}' selected." - - match mode: - case "solid": - r_mode = 1 - case "pulse": - r_mode = 2 - case "dynamic": - r_mode = 3 - case "spiral": - r_mode = 4 - case _: - assert False, f"Mode '{mode}' not supported. " - - r_brightness = min(max(int(64 * brightness), 0), 63) - r_period = min(max(int(64 * (1 - speed)), 0), 63) - - return bytes( - [ - 0x05, - 0x0C, - 0x72, - 0x01, - r_controller, - r_mode, - red, - green, - blue, - r_brightness, - r_period, - profile, - 0x01, - ] - ) - - -def rgb_load_profile( - controller: Controller, - profile: Literal[1, 2, 3], -): - r_controller = _get_controller(controller) - - return bytes( - [ - 0x05, - 0x06, - 0x73, - 0x02, - r_controller, - profile, - 0x01, - ] - ) - - -def rgb_enable(controller: Controller, enable: bool): - r_enable = enable & 0x01 - r_controller = _get_controller(controller) - return bytes( - [ - 0x05, - 0x06, - 0x70, - 0x02, - r_controller, - r_enable, - 0x01, - ] - ) - - -def rgb_multi_load_settings( - mode: RgbMode, - profile: Literal[1, 2, 3], - red: int, - green: int, - blue: int, - brightness: float = 1, - speed: float = 1, -): - return [ - rgb_set_profile("right", profile, mode, red, green, blue, brightness, speed), - rgb_set_profile("left", profile, mode, red, green, blue, brightness, speed), - rgb_load_profile("left", profile), - rgb_load_profile("right", profile), - rgb_enable("left", True), - rgb_enable("right", True), - ] - - -def rgb_multi_disable(): - return [ - rgb_enable("left", False), - rgb_enable("right", False), - ] - - -def rgb_callback(dev: Device, events: Sequence[Event]): - for ev in events: - if ev["type"] == "led": - if ev["mode"] == "disable": - reps = rgb_multi_disable() - else: - match ev["mode"]: - case "blinking": - mode = "pulse" - case "rainbow": - mode = "dynamic" - case "solid": - mode = "solid" - case "spiral": - mode = "spiral" - case _: - assert False, f"Mode '{ev['mode']}' not supported." - reps = rgb_multi_load_settings( - mode, - 0x03, - ev["red"], - ev["green"], - ev["blue"], - ev["brightness"], - ev["speed"], - ) - - for r in reps: - dev.write(r) diff --git a/src/hhd/device/legion_go/slim/__init__.py b/src/hhd/device/legion_go/slim/__init__.py new file mode 100644 index 00000000..754b4e30 --- /dev/null +++ b/src/hhd/device/legion_go/slim/__init__.py @@ -0,0 +1,91 @@ +from threading import Event, Thread + +from hhd.plugins import ( + Config, + Context, + Emitter, + HHDPlugin, + load_relative_yaml, + get_outputs_config, +) +from hhd.plugins.settings import HHDSettings + + +class LegionGoSControllerPlugin(HHDPlugin): + name = "legion_go_slim_controller" + priority = 18 + log = "lgos" + + def __init__(self, dconf) -> None: + self.dconf = dconf + self.t = None + self.should_exit = None + self.updated = Event() + self.started = False + self.t = None + self.prev = None + + def open( + self, + emit: Emitter, + context: Context, + ): + self.emit = emit + self.context = context + self.prev = None + + def settings(self) -> HHDSettings: + base = {"controllers": {"legion_gos": load_relative_yaml("controller.yml")}} + base["controllers"]["legion_gos"]["children"]["xinput"].update( + get_outputs_config(extra_buttons="dual") + ) + return base + + def update(self, conf: Config): + new_conf = conf["controllers.legion_gos"] + reset = conf["controllers.legion_gos.factory_reset"].to(bool) + conf["controllers.legion_gos.factory_reset"] = False + + if new_conf == self.prev: + return + if self.prev is None: + self.prev = new_conf + else: + self.prev.update(new_conf.conf) + + if reset: + self.started = False + else: + self.updated.set() + self.start(self.prev, reset) + + def start(self, conf, reset=False): + from .base import plugin_run + + if self.started: + return + self.started = True + + self.close() + self.should_exit = Event() + self.t = Thread( + target=plugin_run, + args=( + conf, + self.emit, + self.context, + self.should_exit, + self.updated, + self.dconf, + {"reset": reset}, + ), + ) + self.t.start() + + def close(self): + if not self.should_exit or not self.t: + return + self.should_exit.set() + self.t.join() + self.should_exit = None + self.t = None diff --git a/src/hhd/device/legion_go/slim/base.py b/src/hhd/device/legion_go/slim/base.py new file mode 100644 index 00000000..aec0894e --- /dev/null +++ b/src/hhd/device/legion_go/slim/base.py @@ -0,0 +1,453 @@ +import logging +import re +import select +import time +from threading import Event as TEvent +from typing import Sequence + +from hhd.controller import DEBUG_MODE, Button, Consumer, Event, Producer +from hhd.controller.base import Multiplexer +from hhd.controller.lib.hide import unhide_all +from hhd.controller.physical.evdev import B as EC +from hhd.controller.physical.evdev import GenericGamepadEvdev, enumerate_evs +from hhd.controller.virtual.uinput import HHD_PID_VENDOR, UInputDevice +from hhd.plugins import Config, Context, Emitter, get_outputs + +from .const import ( + GOS_INTERFACE_AXIS_MAP, + GOS_INTERFACE_BTN_ESSENTIALS, + GOS_INTERFACE_BTN_MAP, + GOS_TOUCHPAD_BUTTON_MAP, + GOS_TOUCHPAD_AXIS_MAP, +) +from .hid import LegionHidraw, LegionHidrawTs, rgb_callback + + +FIND_DELAY = 0.1 +ERROR_DELAY = 0.5 +LONGER_ERROR_DELAY = 3 +LONGER_ERROR_MARGIN = 1.3 +SELECT_TIMEOUT = 1 + +logger = logging.getLogger(__name__) + +GOS_VID = 0x1A86 +GOS_XINPUT = 0xE310 +GOS_PIDS = { + GOS_XINPUT: "xinput", + 0xE311: "dinput", +} + + +def plugin_run( + conf: Config, + emit: Emitter, + context: Context, + should_exit: TEvent, + updated: TEvent, + dconf: dict, + others: dict, +): + reset = others.get("reset", False) + init = time.perf_counter() + repeated_fail = False + + while not should_exit.is_set(): + try: + controller_mode = None + pid = None + first = True + while not controller_mode and not should_exit.is_set(): + devs = enumerate_evs(vid=GOS_VID) + if not devs: + if first: + first = False + logger.warning(f"Legion Go S controller not found, waiting...") + time.sleep(FIND_DELAY) + continue + + for d in devs.values(): + if d.get("product", None) in GOS_PIDS: + pid = d["product"] + controller_mode = GOS_PIDS[pid] + break + else: + logger.error( + f"Legion Go S controller not found, waiting {ERROR_DELAY}s." + ) + time.sleep(ERROR_DELAY) + continue + + if not controller_mode: + # If should_exit was set controller_mode will be null + continue + + conf_copy = conf.copy() + updated.clear() + if ( + controller_mode == "xinput" + and conf["xinput.mode"].to(str) != "disabled" + ): + logger.info("Launching emulated controller.") + init = time.perf_counter() + controller_loop_xinput(conf_copy, should_exit, updated, emit, reset) + else: + if controller_mode != "xinput": + logger.info( + f"Controller in non-supported (yet) mode: {controller_mode}." + ) + else: + logger.info(f"Controller in xinput mode but emulation is disabled.") + init = time.perf_counter() + controller_loop_rest( + pid if pid else 2, + conf_copy, + should_exit, + updated, + emit, + reset, + ) + repeated_fail = False + except Exception as e: + failed_fast = init + LONGER_ERROR_MARGIN > time.perf_counter() + sleep_time = ( + LONGER_ERROR_DELAY if repeated_fail and failed_fast else ERROR_DELAY + ) + repeated_fail = failed_fast + logger.error(f"Received the following error:\n{type(e)}: {e}") + logger.error( + f"Assuming controller disconnected, restarting after {sleep_time}s." + ) + # Raise exception + if DEBUG_MODE: + try: + import traceback + + traceback.print_exc() + except Exception: + pass + time.sleep(sleep_time) + reset = False + + # Unhide all devices before exiting + unhide_all() + + +def controller_loop_rest( + pid: int, + conf: Config, + should_exit: TEvent, + updated: TEvent, + emit: Emitter, + reset: bool, +): + debug = DEBUG_MODE + shortcuts_enabled = conf["shortcuts"].to(bool) + # FIXME: Sleep when shortcuts are disabled instead of polling raw interface + if shortcuts_enabled: + logger.info(f"Launching a shortcuts device.") + else: + logger.info(f"Shortcuts disabled. Waiting for controllers to change modes.") + + d_raw = SelectivePassthrough( + LegionHidrawTs( + vid=[GOS_VID], + pid=list(GOS_PIDS), + usage_page=[0xFFA0], + usage=[0x0001], + report_size=64, + interface=6, + axis_map={None: GOS_INTERFACE_AXIS_MAP}, + btn_map={None: GOS_INTERFACE_BTN_MAP}, + required=True, + motion=False, + ), + passthrough_pressed=True, + ) + d_cfg = LegionHidraw( + vid=[GOS_VID], + pid=list(GOS_PIDS), + usage_page=[0xFFA0], + usage=[0x0001], + report_size=64, + interface=3, + callback=rgb_callback, + required=True, + ).with_settings(reset=reset) + + multiplexer = Multiplexer( + dpad="both", + trigger="analog_to_discrete", + share_to_qam=True, + nintendo_mode=conf["nintendo_mode"].to(bool), + emit=emit, + ) + d_uinput = UInputDevice( + name=f"HHD Shortcuts (Legion Mode: dinput)", + pid=HHD_PID_VENDOR | 0x0200 | (pid & 0xF), + phys=f"phys-hhd-shortcuts-gos", + ) + + d_shortcuts = GenericGamepadEvdev( + vid=[GOS_VID], + pid=list(GOS_PIDS), + capabilities={EC("EV_KEY"): [EC("KEY_1")]}, + required=True, + ) + + try: + fds = [] + fds.extend(d_raw.open()) + fds.extend(d_cfg.open()) + if shortcuts_enabled: + fds.extend(d_shortcuts.open()) + fds.extend(d_uinput.open()) + + while not should_exit.is_set() and not updated.is_set(): + select.select(fds, [], [], SELECT_TIMEOUT) + evs = multiplexer.process(d_raw.produce(fds)) + + if shortcuts_enabled: + d_shortcuts.produce(fds) + d_uinput.produce(fds) + if debug and evs: + logger.info(evs) + d_uinput.consume(evs) + d_cfg.consume(evs) + finally: + d_uinput.close(True) + d_shortcuts.close(True) + d_raw.close(True) + d_cfg.close(True) + + +def controller_loop_xinput( + conf: Config, should_exit: TEvent, updated: TEvent, emit: Emitter, reset: bool +): + debug = DEBUG_MODE + + # Output + touchpad_enable = "disabled" # conf.get("touchpad", "disabled") + d_producers, d_outs, d_params = get_outputs( + conf["xinput"], + None, + conf["imu"].to(bool), + emit=emit, + touchpad_enable=touchpad_enable, # type: ignore + rgb_modes={ + "disabled": [], + "solid": ["color"], + "pulse": ["color", "speed"], + "rainbow": ["brightness", "speed"], + "spiral": ["brightness", "speed"], + }, + ) + swap_legion = conf["swap_legion"].to(bool) + + # Inputs + d_xinput = GenericGamepadEvdev( + vid=[GOS_VID], + pid=[GOS_XINPUT], + capabilities={EC("EV_KEY"): [EC("BTN_A")]}, + required=True, + hide=True, + ) + d_raw = SelectivePassthrough( + LegionHidrawTs( + vid=[GOS_VID], + pid=list(GOS_PIDS), + usage_page=[0xFFA0], + usage=[0x0001], + report_size=64, + interface=6, + axis_map={None: GOS_INTERFACE_AXIS_MAP}, + btn_map={None: GOS_INTERFACE_BTN_MAP}, + required=True, + motion=d_params.get("uses_motion", True), + ) + ) + + uses_touch = d_params.get("uses_touch", False) + d_touch = GenericGamepadEvdev( + vid=[GOS_VID], + pid=list(GOS_PIDS), + capabilities={ + EC("EV_KEY"): [EC("BTN_LEFT")], + EC("EV_ABS"): [EC("ABS_MT_POSITION_Y")], + }, + btn_map=GOS_TOUCHPAD_BUTTON_MAP, + axis_map=GOS_TOUCHPAD_AXIS_MAP, + aspect_ratio=1, + required=True, + ) + + freq = conf.get("freq", None) + os = conf.get("mapping.mode", None) + d_cfg = LegionHidraw( + vid=[GOS_VID], + pid=list(GOS_PIDS), + usage_page=[0xFFA0], + usage=[0x0001], + report_size=64, + interface=3, + callback=rgb_callback, + required=True, + ).with_settings( + reset=reset, + os=os, + turbo=conf.get("mapping.windows.turbo", None) if os == "windows" else None, + freq=freq, + touchpad="absolute" if uses_touch else "relative", + ) + + # Mute keyboard shortcuts, mute + d_shortcuts = GenericGamepadEvdev( + vid=[GOS_VID], + pid=list(GOS_PIDS), + name=[re.compile(".+Keyboard")], # " Legion Controller for Windows Keyboard" + # capabilities={EC("EV_KEY"): [EC("KEY_1")]}, + # report_size=64, + required=True, + ) + + multiplexer = Multiplexer( + trigger="analog_to_discrete", + dpad="both", + share_to_qam=True, + swap_guide="guide_is_select" if swap_legion else None, + select_reboots=conf["select_reboots"].to(bool), + nintendo_mode=conf["nintendo_mode"].to(bool), + emit=emit, + params=d_params, + ) + + REPORT_FREQ_MIN = 25 + REPORT_FREQ_MAX = 1000 if freq == "1000hz" else 500 + + REPORT_DELAY_MAX = 1 / REPORT_FREQ_MIN + REPORT_DELAY_MIN = 1 / REPORT_FREQ_MAX + + fds = [] + devs = [] + fd_to_dev = {} + + def prepare(m): + devs.append(m) + fs = m.open() + fds.extend(fs) + for f in fs: + fd_to_dev[f] = m + + try: + prepare(d_xinput) + prepare(d_shortcuts) + if uses_touch: + prepare(d_touch) + prepare(d_cfg) + prepare(d_raw) + for d in d_producers: + prepare(d) + + logger.info("Emulated controller launched, have fun!") + while not should_exit.is_set() and not updated.is_set(): + start = time.perf_counter() + # Add timeout to call consumers a minimum amount of times per second + r, _, _ = select.select(fds, [], [], REPORT_DELAY_MAX) + evs = [] + to_run = set() + for f in r: + to_run.add(id(fd_to_dev[f])) + + for d in devs: + if id(d) in to_run: + evs.extend(d.produce(r)) + + evs = multiplexer.process(evs) + if evs: + if debug: + logger.info(evs) + + d_xinput.consume(evs) + d_raw.consume(evs) + d_cfg.consume(evs) + + for d in d_outs: + d.consume(evs) + + t = time.perf_counter() + elapsed = t - start + if elapsed < REPORT_DELAY_MIN: + time.sleep(REPORT_DELAY_MIN - elapsed) + + except KeyboardInterrupt: + raise + finally: + for d in reversed(devs): + try: + d.close(not updated.is_set()) + except Exception as e: + logger.error(f"Error while closing device '{d}' with exception:\n{e}") + if debug: + raise e + + +class SelectivePassthrough(Producer, Consumer): + + def __init__( + self, + parent, + forward_buttons: Sequence[Button] = ("share", "mode"), + passthrough: Sequence[Button] = list(GOS_INTERFACE_BTN_ESSENTIALS.keys()), + passthrough_pressed: bool = False, + ): + self.parent = parent + self.state = False + + self.forward_buttons = forward_buttons + self.passthrough = passthrough + self.pressed_time = None + self.pressed_vals = set() + self.passthrough_pressed = passthrough_pressed + + self.to_disable_btn = set() + self.to_disable_axis = set() + + def open(self) -> Sequence[int]: + return self.parent.open() + + def close(self, exit: bool) -> bool: + return self.parent.close(exit) + + def produce(self, fds: Sequence[int]) -> Sequence[Event]: + evs: Sequence[Event] = self.parent.produce(fds) + + out = [] + curr = time.perf_counter() + if self.passthrough_pressed: + passthrough = bool(self.pressed_vals) + else: + passthrough = self.pressed_time and (curr - self.pressed_time < 1) + + for ev in evs: + if ev["type"] == "button" and ev["code"] in self.forward_buttons: + if ev.get("value", False): + self.pressed_time = curr + self.pressed_vals.add(ev["code"]) + else: + self.pressed_vals.discard(ev["code"]) + + if ev["type"] == "button" and ev["code"] in self.passthrough: + out.append(ev) + elif ev["type"] == "axis" and ( + "imu" in ev["code"] or "accel" in ev["code"] or "gyro" in ev["code"] + ): + out.append(ev) + + if passthrough: + # If mode is pressed, forward all events + return evs + else: + return out + + def consume(self, events: Sequence[Event]): + return self.parent.consume(events) diff --git a/src/hhd/device/legion_go/slim/const.py b/src/hhd/device/legion_go/slim/const.py new file mode 100644 index 00000000..8e375209 --- /dev/null +++ b/src/hhd/device/legion_go/slim/const.py @@ -0,0 +1,74 @@ +from hhd.controller import Axis, Button, Configuration +from hhd.controller.physical.evdev import B, to_map +from hhd.controller.physical.hidraw import AM, BM, CM + +GOS_INTERFACE_BTN_ESSENTIALS: dict[Button, BM] = { + # Misc + "mode": BM((0 << 3) + 7), + "share": BM((0 << 3) + 6), + # Back buttons + "extra_l1": BM((2 << 3) + 7), + "extra_r1": BM((2 << 3) + 6), +} + + +GOS_INTERFACE_BTN_MAP: dict[Button, BM] = { + # Misc + "mode": BM((0 << 3) + 7), + "share": BM((0 << 3) + 6), + # Sticks + "ls": BM((0 << 3) + 5), + "rs": BM((0 << 3) + 4), + # D-PAD + "dpad_up": BM((0 << 3) + 3), + "dpad_down": BM((0 << 3) + 2), + "dpad_left": BM((0 << 3) + 1), + "dpad_right": BM((0 << 3) + 0), + # Thumbpad + "a": BM((1 << 3) + 7), + "b": BM((1 << 3) + 6), + "x": BM((1 << 3) + 5), + "y": BM((1 << 3) + 4), + # Bumpers + "lb": BM((1 << 3) + 3), + "lt": BM((1 << 3) + 2), + "rb": BM((1 << 3) + 1), + "rt": BM((1 << 3) + 0), + # Back buttons + "extra_l1": BM((2 << 3) + 7), + "extra_r1": BM((2 << 3) + 6), + # Select + "start": BM((2 << 3) + 0), + "select": BM((2 << 3) + 1), +} + + +GOS_INTERFACE_AXIS_MAP: dict[Axis, AM] = { + "ls_x": AM(4 << 3, "m8"), + "ls_y": AM(5 << 3, "m8"), + "rs_x": AM(6 << 3, "m8"), + "rs_y": AM(7 << 3, "m8"), + "rt": AM(12 << 3, "u8"), + "lt": AM(13 << 3, "u8"), + # # Controller IMU + "accel_x": AM(14 << 3, "i16", scale=-0.00212, order="little"), + "accel_z": AM(16 << 3, "i16", scale=-0.00212, order="little"), + "accel_y": AM(18 << 3, "i16", scale=-0.00212, order="little"), + "gyro_x": AM(20 << 3, "i16", scale=-0.0005325, order="little"), + "gyro_z": AM(22 << 3, "i16", scale=0.0005325, order="little"), + "gyro_y": AM(24 << 3, "i16", scale=-0.0005325, order="little"), +} + +GOS_TOUCHPAD_BUTTON_MAP: dict[int, Button] = to_map( + { + "touchpad_touch": [B("BTN_TOOL_FINGER")], # also BTN_TOUCH + "touchpad_left": [B("BTN_LEFT")], + } +) + +GOS_TOUCHPAD_AXIS_MAP: dict[int, Axis] = to_map( + { + "touchpad_x": [B("ABS_X")], # also ABS_MT_POSITION_X + "touchpad_y": [B("ABS_Y")], # also ABS_MT_POSITION_Y + } +) diff --git a/src/hhd/device/legion_go/slim/controller.yml b/src/hhd/device/legion_go/slim/controller.yml new file mode 100644 index 00000000..cf9b875f --- /dev/null +++ b/src/hhd/device/legion_go/slim/controller.yml @@ -0,0 +1,116 @@ +type: container +tags: [lgc] +title: Legion Controller +hint: >- + Configure the Legion Controller emulation modes. + +children: + xinput: + type: mode + tags: [lgsc_xinput] + title: Emulation Mode (X-Input) + hint: >- + Emulate different controller types when in X-Input mode. + + # + # Common settings + # + imu: + type: bool + title: Motion Support + hint: >- + Enable gyroscope/accelerometer (IMU) support + default: True + + mapping: + type: mode + title: Mapping Style + tags: [ non-essential] + hint: >- + Choose OS mapping style. + default: "windows" + + modes: + windows: + type: container + title: Windows + hint: >- + Windows style mapping. + children: + turbo: + type: multiple + tags: [ non-essential, ordinal ] + title: Turbo Mode (Repeat Buttons) + hint: >- + Hold Y1/Y2 for 2s, then press the buttons that will repeat. + To stop, hold Y1/Y2 for 5s. + options: + disabled: Disabled + 2hz: 2Hz + 5hz: 5Hz + 8hz: 8Hz + default: disabled + + steamos: + type: container + title: SteamOS + hint: >- + SteamOS style mapping. + + # touchpad: + # type: multiple + # title: Touchpad Emulation + # default: disabled + # tags: [ non-essential, ordinal ] + # hint: >- + # Passthrough the touchpad to the controller (Dualsense only). + # options: + # disabled: Disabled + # gamemode: Gamemode + # always: Always + + freq: + type: multiple + title: Controller Frequency + tags: [ non-essential, ordinal ] + options: + 125hz: 125Hz + 250hz: 250Hz + 500hz: 500Hz + 1000hz: 1000Hz + default: 500hz + + swap_legion: + type: bool + title: Swap Legion with Menu/View + tags: [ non-essential ] + default: False + + nintendo_mode: + type: bool + title: Nintendo Mode (A-B Swap) + tags: [ non-essential ] + hint: >- + Swaps A with B and X with Y. + default: False + + select_reboots: + type: bool + title: Hold View to Reboot + tags: [ non-essential ] + default: True + + shortcuts: + type: bool + title: Enable Shortcuts Controller + tags: [ non-essential ] + hint: >- + When in dinput mode, enable a controller for shortcuts. + default: True + + factory_reset: + type: action + title: Reset Controller + tags: [ non-essential, verify ] + hint: >- + Resets the controller to stock settings. diff --git a/src/hhd/device/legion_go/slim/hid.py b/src/hhd/device/legion_go/slim/hid.py new file mode 100644 index 00000000..7d72bf15 --- /dev/null +++ b/src/hhd/device/legion_go/slim/hid.py @@ -0,0 +1,325 @@ +import logging +from enum import Enum +from typing import Literal, Sequence + +from hhd.controller import Event +from hhd.controller.lib.hid import Device +from hhd.controller.physical.hidraw import GenericGamepadHidraw + +logger = logging.getLogger(__name__) + +Controller = Literal["left", "right"] +RgbMode = Literal["solid", "pulse", "dynamic", "spiral"] + + +def to_bytes(s: str): + return bytes.fromhex(s.replace(" ", "")) + + +def config_device( + os: Literal["steamos", "windows"] | None, + turbo: Literal["disabled", "2hz", "5hz", "8hz"] | None, + touchpad: Literal["absolute", "relative"] | None, + freq: Literal["125hz", "250hz", "500hz", "1000hz"] | None, +): + out = [] + + if os: + # Disable OS autodetection + out.append(to_bytes("040900")) + + if os == "steamos": + # set OS type to steamos + out.append(to_bytes("040a01")) + # set touchpad config (steamos) + if touchpad: + out.append(bytes([0x06, 0x04, 0x01 if touchpad == "absolute" else 0x00])) + elif os == "windows": + # set OS type to windows + out.append(to_bytes("040a00")) + if touchpad: + # set touchpad config (windows) + out.append(bytes([0x06, 0x03, 0x01 if touchpad == "absolute" else 0x00])) + + # set turbo mode disable + if turbo: + out.append(bytes([0x12, 0x10, 0x00 if turbo != "disabled" else 0x01])) + match turbo: + case "2hz": + out.append(to_bytes("120301")) + case "5hz": + out.append(to_bytes("120302")) + case "8hz": + out.append(to_bytes("120303")) + case "disabled": + # Disable all turbo mappings to avoid having them stick + out.append(to_bytes("12020000000000")) + + match freq: + case "125hz": + out.append(to_bytes("041000")) + case "250hz": + out.append(to_bytes("041001")) + case "500hz": + out.append(to_bytes("041002")) + case "1000hz": + out.append(to_bytes("041003")) + + return out + + +def rgb_set_profile( + profile: Literal[1, 2, 3], + mode: RgbMode, + red: int, + green: int, + blue: int, + brightness: float = 1, + speed: float = 1, +): + assert profile in (1, 2, 3), f"Invalid profile '{profile}' selected." + + match mode: + case "solid": + r_mode = 0 + case "pulse": + r_mode = 1 + case "dynamic": + r_mode = 2 + case "spiral": + r_mode = 3 + case _: + assert False, f"Mode '{mode}' not supported. " + + r_brightness = min(max(int(64 * brightness), 0), 63) + r_speed = min(max(int(64 * speed), 0), 63) + + return bytes( + [ + 0x10, + profile + 2, + r_mode, + red, + green, + blue, + r_brightness, + r_speed, + ] + ) + + +def rgb_load_profile( + profile: Literal[1, 2, 3], +): + return bytes([0x10, 0x02, profile]) + + +def rgb_enable(enable: bool): + r_enable = enable & 0x01 + return bytes([0x04, 0x06, r_enable]) + + +def controller_factory_reset(): + return [ + # Reset XInput mapping + to_bytes( + "12010108038203000000000482040000000005820500000000068206000000000782070000000008820800000000098209000000000a820a0000000000000000" + ), + to_bytes( + "120102080b820b000000000c820c000000000d820d000000000e820e000000000f820f0000000010821000000000128212000000001382130000000000000000" + ), + to_bytes( + "120103081482140000000015821500000000168216000000001782170000000018821800000000198219000000001c821c000000001d821d0000000000000000" + ), + to_bytes( + "12010402238223000000002482240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ), + # Enable touchpad + to_bytes("040801"), + # Disable touchpad vibration + to_bytes("080300"), + # Disable controller hibernation + to_bytes("040400"), + # Enable gyro + to_bytes("040701"), + to_bytes("040501"), # hid imu for display rotation + # Set controller to 500hz + to_bytes("041002"), + # todo... + ] + + +def controller_legion_swap(enabled): + return [to_bytes(f"0506 69 0401 {'02' if enabled else '01'} 01")] + + +def rgb_multi_load_settings( + mode: RgbMode, + profile: Literal[1, 2, 3], + red: int, + green: int, + blue: int, + brightness: float = 1, + speed: float = 1, + init: bool = True, +): + base = [ + rgb_set_profile(profile, mode, red, green, blue, brightness, speed), + ] + # Always update + if not init: + return base + + return [ + rgb_enable(True), + rgb_load_profile(profile), + *base, + ] + + +class RgbCallback: + def __init__(self) -> None: + self.prev_mode = None + self.prev_event = None + + def __call__(self, dev: Device, events: Sequence[Event]): + try: + for ev in events: + if ev["type"] != "led": + continue + + reps = None + mode = None + match ev["mode"]: + case "disabled": + pass + case "pulse": + mode = "pulse" + case "rainbow": + mode = "dynamic" + case "solid": + if ev["red"] or ev["green"] or ev["blue"]: + mode = "solid" + else: + # Disable if brightness is 0 + mode = None + case "spiral": + mode = "spiral" + case _: + pass + + # On rgb modes such as the rainbow vomit, reiniting causes + # a flicker, so we only update if the values have changed + if self.prev_event: + pv = self.prev_event + if ( + pv["mode"] == ev["mode"] + and pv["red"] == ev["red"] + and pv["green"] == ev["green"] + and pv["blue"] == ev["blue"] + and pv["brightness"] == ev["brightness"] + and pv["speed"] == ev["speed"] + ): + continue + if mode: + reps = rgb_multi_load_settings( + mode, + 0x03, + ev["red"], + ev["green"], + ev["blue"], + ev["brightness"], + ev["speed"], + self.prev_mode != mode, + ) + self.prev_event = ev + + else: + reps = [rgb_enable(False)] + + # Only init sparingly, to speed up execution + self.prev_mode = mode + + for r in reps: + dev.write(r) + except Exception as e: + logger.error(f"Error while setting RGB:\n{e}") + + +rgb_callback = RgbCallback() + + +class LegionHidraw(GenericGamepadHidraw): + + def with_settings( + self, + reset: bool, + os: Literal["steamos", "windows"] | None = None, + turbo: Literal["disabled", "2hz", "5hz", "8hz"] | None = None, + touchpad: Literal["absolute", "relative"] | None = None, + freq: Literal["125hz", "250hz", "500hz", "1000hz"] | None = None, + ): + self.reset = reset + self.os = os + self.turbo = turbo + self.touchpad = touchpad + self.freq = freq + + return self + + def open(self): + out = super().open() + if not out: + return out + if not self.dev: + return out + + cmds = [] + + if self.reset: + logger.warning(f"Resetting controllers") + cmds.extend(controller_factory_reset()) + + cmds.extend(config_device(self.os, self.turbo, self.touchpad, self.freq)) # type: ignore + + for r in cmds: + # logger.info(f"Sending command: {r.hex()}") + self.dev.write(r) + + return out + + def close(self, exit: bool) -> bool: + # Reset windows touchpad to relative to avoid windows having issues + try: + if ( + exit + and self.dev + and self.os == "windows" + and self.touchpad == "absolute" + ): + self.dev.write(to_bytes("060300")) + except Exception: + pass + + return super().close(exit) + + +class LegionHidrawTs(GenericGamepadHidraw): + def __init__(self, *args, motion: bool = True, **kwargs): + super().__init__(*args, **kwargs) + self.ts_count = 0 + self.motion = motion + + def produce(self, fds: Sequence[int]): + evs = super().produce(fds) + + if self.motion and self.fd in fds: + # If fd was readable, 8ms have passed + self.ts_count += 8_000_000 + + evs = [ + *evs, + {"type": "axis", "code": "imu_ts", "value": self.ts_count}, + ] + + return evs diff --git a/src/hhd/device/legion_go/tablet/__init__.py b/src/hhd/device/legion_go/tablet/__init__.py new file mode 100644 index 00000000..d299b308 --- /dev/null +++ b/src/hhd/device/legion_go/tablet/__init__.py @@ -0,0 +1,100 @@ +from threading import Event, Thread + +from hhd.plugins import ( + Config, + Context, + Emitter, + HHDPlugin, + load_relative_yaml, + get_outputs_config, + get_touchpad_config, +) +from hhd.plugins.settings import HHDSettings +from hhd.controller.physical.imu import BMI_MAPPINGS + + +class LegionGoControllersPlugin(HHDPlugin): + name = "legion_go_controllers" + priority = 18 + log = "llgo" + + def __init__(self, dconf) -> None: + self.dconf = dconf + self.t = None + self.should_exit = None + self.updated = Event() + self.started = False + self.t = None + self.prev = None + + def open( + self, + emit: Emitter, + context: Context, + ): + self.emit = emit + self.context = context + self.prev = None + + def settings(self) -> HHDSettings: + base = {"controllers": {"legion_go": load_relative_yaml("controllers.yml")}} + base["controllers"]["legion_go"]["children"]["xinput"].update( + get_outputs_config(extra_buttons="quad") + ) + base["controllers"]["legion_go"]["children"]["touchpad"] = get_touchpad_config() + return base + + def update(self, conf: Config): + new_conf = conf["controllers.legion_go"] + reset = conf["controllers.legion_go.factory_reset"].to(bool) + conf["controllers.legion_go.factory_reset"] = False + + # Migrate old setting + val = conf.get("controllers.legion_go.swap_legion", None) + if val and val != "disabled": + conf["controllers.legion_go.swap_legion"] = None + conf["controllers.legion_go.swap_legion_v2"] = True + + if new_conf == self.prev: + return + if self.prev is None: + self.prev = new_conf + else: + self.prev.update(new_conf.conf) + + if reset: + self.started = False + else: + self.updated.set() + self.start(self.prev, reset) + + def start(self, conf, reset=False): + from .base import plugin_run + + if self.started: + return + self.started = True + + self.close() + self.should_exit = Event() + self.t = Thread( + target=plugin_run, + args=( + conf, + self.emit, + self.context, + self.should_exit, + self.updated, + self.dconf, + {"reset": reset}, + ), + ) + self.t.start() + + def close(self): + if not self.should_exit or not self.t: + return + self.should_exit.set() + self.t.join() + self.should_exit = None + self.t = None diff --git a/src/hhd/device/legion_go/tablet/base.py b/src/hhd/device/legion_go/tablet/base.py new file mode 100644 index 00000000..e31bf19c --- /dev/null +++ b/src/hhd/device/legion_go/tablet/base.py @@ -0,0 +1,491 @@ +import logging +import re +import select +import time +from threading import Event as TEvent +from typing import Sequence + +from hhd.controller import Button, Consumer, Event, Producer, DEBUG_MODE +from hhd.controller.lib.hide import unhide_all +from hhd.controller.base import Multiplexer, TouchpadAction +from hhd.controller.physical.evdev import B as EC +from hhd.controller.physical.evdev import GenericGamepadEvdev, enumerate_evs +from hhd.controller.virtual.uinput import HHD_PID_VENDOR, UInputDevice +from hhd.plugins import Config, Context, Emitter, get_outputs + +from .const import ( + LGO_RAW_INTERFACE_AXIS_MAP, + LGO_RAW_INTERFACE_BTN_ESSENTIALS, + LGO_RAW_INTERFACE_BTN_MAP, + LGO_RAW_INTERFACE_CONFIG_MAP, + LGO_TOUCHPAD_AXIS_MAP, + LGO_TOUCHPAD_BUTTON_MAP, +) +from .hid import LegionHidraw, RgbCallback + +FIND_DELAY = 0.1 +ERROR_DELAY = 0.5 +LONGER_ERROR_DELAY = 3 +LONGER_ERROR_MARGIN = 1.3 +SELECT_TIMEOUT = 1 + +logger = logging.getLogger(__name__) + +LEN_VID = 0x17EF +LEN_PIDS = { + 0x6182: "xinput", + 0x6183: "dinput", + 0x6184: "dual_dinput", + 0x6185: "fps", +} + + +def plugin_run( + conf: Config, + emit: Emitter, + context: Context, + should_exit: TEvent, + updated: TEvent, + dconf: dict, + others: dict, +): + reset = others.get("reset", False) + init = time.perf_counter() + repeated_fail = False + + while not should_exit.is_set(): + try: + controller_mode = None + pid = None + first = True + while not controller_mode and not should_exit.is_set(): + devs = enumerate_evs(vid=LEN_VID) + if not devs: + if first: + first = False + logger.warning(f"Legion go controllers not found, waiting...") + time.sleep(FIND_DELAY) + continue + + for d in devs.values(): + if d.get("product", None) in LEN_PIDS: + pid = d["product"] + controller_mode = LEN_PIDS[pid] + break + else: + logger.error( + f"Legion go controllers not found, waiting {ERROR_DELAY}s." + ) + time.sleep(ERROR_DELAY) + continue + + if not controller_mode: + # If should_exit was set controller_mode will be null + continue + + conf_copy = conf.copy() + updated.clear() + if ( + controller_mode == "xinput" + and conf["xinput.mode"].to(str) != "disabled" + ): + logger.info("Launching emulated controller.") + init = time.perf_counter() + controller_loop_xinput(conf_copy, should_exit, updated, emit, reset) + else: + if controller_mode != "xinput": + logger.info( + f"Controllers in non-supported (yet) mode: {controller_mode}." + ) + else: + logger.info( + f"Controllers in xinput mode but emulation is disabled." + ) + init = time.perf_counter() + controller_loop_rest( + controller_mode, + pid if pid else 2, + conf_copy, + should_exit, + updated, + emit, + reset, + ) + repeated_fail = False + except Exception as e: + failed_fast = init + LONGER_ERROR_MARGIN > time.perf_counter() + sleep_time = ( + LONGER_ERROR_DELAY if repeated_fail and failed_fast else ERROR_DELAY + ) + repeated_fail = failed_fast + logger.error(f"Received the following error:\n{type(e)}: {e}") + logger.error( + f"Assuming controllers disconnected, restarting after {sleep_time}s." + ) + # Raise exception + if DEBUG_MODE: + raise e + time.sleep(sleep_time) + reset = False + + # Unhide all devices before exiting + unhide_all() + +def controller_loop_rest( + mode: str, + pid: int, + conf: Config, + should_exit: TEvent, + updated: TEvent, + emit: Emitter, + reset: bool, +): + debug = DEBUG_MODE + shortcuts_enabled = conf["shortcuts"].to(bool) + # FIXME: Sleep when shortcuts are disabled instead of polling raw interface + if shortcuts_enabled: + logger.info(f"Launching a shortcuts device.") + else: + logger.info(f"Shortcuts disabled. Waiting for controllers to change modes.") + + d_raw = SelectivePassthrough( + LegionHidraw( + vid=[LEN_VID], + pid=list(LEN_PIDS), + usage_page=[0xFFA0], + usage=[0x0001], + report_size=64, + axis_map=LGO_RAW_INTERFACE_AXIS_MAP, + btn_map=LGO_RAW_INTERFACE_BTN_MAP, + required=True, + ).with_settings( + gyro=None, reset=reset, swap_legion=conf["swap_legion_v2"].to(bool) + ), + passthrough_pressed=True, + ) + + multiplexer = Multiplexer( + dpad="both", + trigger="analog_to_discrete", + share_to_qam=True, + nintendo_mode=conf["nintendo_mode"].to(bool), + emit=emit, + ) + d_uinput = UInputDevice( + name=f"HHD Shortcuts (Legion Mode: {mode})", + pid=HHD_PID_VENDOR | 0x0200 | (pid & 0xF), + phys=f"phys-hhd-shortcuts-legion-{mode}", + ) + + d_shortcuts = GenericGamepadEvdev( + vid=[LEN_VID], + pid=list(LEN_PIDS), + name=[re.compile(r"Legion-Controller \d-.. Keyboard")], + capabilities={EC("EV_KEY"): [EC("KEY_1")]}, + required=True, + ) + + try: + fds = [] + fds.extend(d_raw.open()) + if shortcuts_enabled: + fds.extend(d_shortcuts.open()) + fds.extend(d_uinput.open()) + + while not should_exit.is_set() and not updated.is_set(): + select.select(fds, [], [], SELECT_TIMEOUT) + evs = multiplexer.process(d_raw.produce(fds)) + + if shortcuts_enabled: + d_shortcuts.produce(fds) + d_uinput.produce(fds) + if debug and evs: + logger.info(evs) + d_uinput.consume(evs) + finally: + d_uinput.close(True) + d_shortcuts.close(True) + d_raw.close(True) + + +def controller_loop_xinput( + conf: Config, should_exit: TEvent, updated: TEvent, emit: Emitter, reset: bool +): + debug = DEBUG_MODE + + # Output + dimu = conf["imu.mode"].to(str) + + match dimu: + case "left": + simu = "left_to_main" + cidx = 1 + case "right" | "both": + simu = "right_to_main" + cidx = 2 + case _: + simu = None + cidx = 0 + + d_producers, d_outs, d_params = get_outputs( + conf["xinput"], + conf["touchpad"], + dimu != "disabled", + controller_id=cidx, + emit=emit, + dual_motion=dimu == "both", + rgb_modes={ + "disabled": [], + "solid": ["color"], + "pulse": ["color", "speed"], + "rainbow": ["brightness", "speed"], + "spiral": ["brightness", "speed"], + }, + ) + motion = d_params.get("uses_motion", True) + dual_motion = d_params.get("uses_dual_motion", True) + swap_legion = conf["swap_legion_v2"].to(bool) + if not dual_motion and dimu == "both": + dimu = "right" + if not motion: + dimu = "disabled" + + # Inputs + d_xinput = GenericGamepadEvdev( + vid=[0x17EF], + pid=[0x6182], + # name=["Generic X-Box pad"], + capabilities={EC("EV_KEY"): [EC("BTN_A")]}, + required=True, + hide=True, + ) + d_touch = GenericGamepadEvdev( + vid=[0x17EF], + pid=[0x6182], + name=[re.compile(".+Touchpad")], # " Legion Controller for Windows Touchpad" + capabilities={EC("EV_KEY"): [EC("BTN_MOUSE")]}, + btn_map=LGO_TOUCHPAD_BUTTON_MAP, + axis_map=LGO_TOUCHPAD_AXIS_MAP, + aspect_ratio=1, + required=True, + ) + d_raw = SelectivePassthrough( + LegionHidraw( + vid=[LEN_VID], + pid=list(LEN_PIDS), + usage_page=[0xFFA0], + usage=[0x0001], + report_size=64, + axis_map=LGO_RAW_INTERFACE_AXIS_MAP, + btn_map=LGO_RAW_INTERFACE_BTN_MAP, + config_map=LGO_RAW_INTERFACE_CONFIG_MAP, + callback=RgbCallback(), + required=True, + ).with_settings( + gyro=dimu, + reset=reset, + use_touchpad=False, + swap_legion=swap_legion, + ) + ) + + # Mute keyboard shortcuts, mute + d_shortcuts = GenericGamepadEvdev( + vid=[LEN_VID], + pid=list(LEN_PIDS), + name=[re.compile(".+Keyboard")], # " Legion Controller for Windows Keyboard" + # capabilities={EC("EV_KEY"): [EC("KEY_1")]}, + # report_size=64, + required=True, + ) + + touch_actions = ( + conf["touchpad.controller"] + if conf["touchpad.mode"].to(TouchpadAction) == "controller" + else conf["touchpad.emulation"] + ) + multiplexer = Multiplexer( + trigger="analog_to_discrete", + dpad="both", + led="main_to_sides", + status="both_to_main", + share_to_qam=True, + swap_guide="guide_is_select" if swap_legion else None, + touchpad_short=touch_actions["short"].to(TouchpadAction), + touchpad_right=touch_actions["hold"].to(TouchpadAction), + select_reboots=conf["select_reboots"].to(bool), + r3_to_share=conf["m2_to_mute"].to(bool), + nintendo_mode=conf["nintendo_mode"].to(bool), + emit=emit, + imu=simu, + params=d_params, + ) + + REPORT_FREQ_MIN = 25 + REPORT_FREQ_MAX = 500 + + REPORT_DELAY_MAX = 1 / REPORT_FREQ_MIN + REPORT_DELAY_MIN = 1 / REPORT_FREQ_MAX + + fds = [] + devs = [] + fd_to_dev = {} + + def prepare(m): + devs.append(m) + fs = m.open() + fds.extend(fs) + for f in fs: + fd_to_dev[f] = m + + try: + prepare(d_xinput) + prepare(d_shortcuts) + if d_params["uses_touch"]: + prepare(d_touch) + prepare(d_raw) + for d in d_producers: + prepare(d) + + ts_count: dict[str, int] = {"left_imu_ts": 0, "right_imu_ts": 0} + ts_last: dict[str, int] = {"left_imu_ts": 0, "right_imu_ts": 0} + + logger.info("Emulated controller launched, have fun!") + while not should_exit.is_set() and not updated.is_set(): + start = time.perf_counter() + # Add timeout to call consumers a minimum amount of times per second + r, _, _ = select.select(fds, [], [], REPORT_DELAY_MAX) + evs = [] + to_run = set() + for f in r: + to_run.add(id(fd_to_dev[f])) + + for d in devs: + if id(d) in to_run: + evs.extend(d.produce(r)) + + # Patch timestamps to convert them to ns + # for d in ('x', 'y', 'z'): + # p = False + # for ev in evs: + # if f"accel_{d}" in ev["code"]: + # print( + # f"{ev['code'].split('accel_')[1]}: {ev['value']:12.5e} ", end="" + # ) + # p = True + # if not p: + # print(f"{d}: ", end='') + # print() + for ev in evs: + if ev["type"] == "axis" and "_imu_ts" in ev["code"]: + # Find diff between previous event + last = ts_last[ev["code"]] + curr = ev["value"] + diff = curr - last + if curr < last: + diff += 256 + ts_last[ev["code"]] = curr + # 8ms per count + ts_count[ev["code"]] += diff * 8_000_000 + ev["value"] = ts_count[ev["code"]] + if ev["type"] == "axis" and "gyro" in ev["code"]: + v = ev["value"] + if (abs(v / 0.001065) // 1) in (254, 255): + # Legion go controllers have a bug where they will + # randomly output 254 or 255. If that happens, drop event + ev["code"] = "" # type: ignore + + evs = multiplexer.process(evs) + if evs: + if debug: + logger.info(evs) + + d_xinput.consume(evs) + d_raw.consume(evs) + + for d in d_outs: + d.consume(evs) + + t = time.perf_counter() + elapsed = t - start + if elapsed < REPORT_DELAY_MIN: + time.sleep(REPORT_DELAY_MIN - elapsed) + + except KeyboardInterrupt: + raise + finally: + for d in reversed(devs): + try: + d.close(not updated.is_set()) + except Exception as e: + logger.error(f"Error while closing device '{d}' with exception:\n{e}") + if debug: + raise e + + +class SelectivePassthrough(Producer, Consumer): + + def __init__( + self, + parent, + forward_buttons: Sequence[Button] = ("share", "mode"), + passthrough: Sequence[Button] = list( + next(iter(LGO_RAW_INTERFACE_BTN_ESSENTIALS.values())) + ), + passthrough_pressed: bool = False, + ): + self.parent = parent + self.state = False + + self.forward_buttons = forward_buttons + self.passthrough = passthrough + self.pressed_time = None + self.pressed_vals = set() + self.passthrough_pressed = passthrough_pressed + + self.to_disable_btn = set() + self.to_disable_axis = set() + + def open(self) -> Sequence[int]: + return self.parent.open() + + def close(self, exit: bool) -> bool: + return self.parent.close(exit) + + def produce(self, fds: Sequence[int]) -> Sequence[Event]: + evs: Sequence[Event] = self.parent.produce(fds) + + out = [] + curr = time.perf_counter() + if self.passthrough_pressed: + passthrough = bool(self.pressed_vals) + else: + passthrough = self.pressed_time and (curr - self.pressed_time < 1) + + for ev in evs: + if ev["type"] == "button" and ev["code"] in self.forward_buttons: + if ev.get("value", False): + self.pressed_time = curr + self.pressed_vals.add(ev["code"]) + else: + self.pressed_vals.discard(ev["code"]) + + if ev["type"] == "configuration": + out.append(ev) + elif ev["type"] == "button" and ev["code"] in self.passthrough: + out.append(ev) + elif ev["type"] == "axis" and ( + "imu" in ev["code"] or "accel" in ev["code"] or "gyro" in ev["code"] + ): + out.append(ev) + elif "touchpad" in ev["code"]: + out.append(ev) + + if passthrough: + # If mode is pressed, forward all events + return evs + else: + return out + + def consume(self, events: Sequence[Event]): + return self.parent.consume(events) diff --git a/src/hhd/device/legion_go/const.py b/src/hhd/device/legion_go/tablet/const.py similarity index 73% rename from src/hhd/device/legion_go/const.py rename to src/hhd/device/legion_go/tablet/const.py index 052a512f..c6d6841d 100644 --- a/src/hhd/device/legion_go/const.py +++ b/src/hhd/device/legion_go/tablet/const.py @@ -5,7 +5,7 @@ LGO_TOUCHPAD_BUTTON_MAP: dict[int, Button] = to_map( { "touchpad_touch": [B("BTN_TOOL_FINGER")], # also BTN_TOUCH - "touchpad_click": [B("BTN_TOOL_DOUBLETAP")], + "touchpad_right": [B("BTN_TOOL_DOUBLETAP")], } ) @@ -80,10 +80,28 @@ # "mouse_wheel": AM(25 << 3, "m8", scale=1), # TODO: Fix weird behavior # "touchpad_x": AM(26 << 3, "u16"), # "touchpad_y": AM(28 << 3, "u16"), + # Legacy # "left_gyro_x": AM(30 << 3, "m8"), # "left_gyro_y": AM(31 << 3, "m8"), # "right_gyro_x": AM(32 << 3, "m8"), # "right_gyro_y": AM(33 << 3, "m8"), + # Per controller IMU + # Left + "left_imu_ts": AM(34 << 3, "u8", scale=1), + "left_accel_x": AM(35 << 3, "i16", scale=-0.00212, order="big"), + "left_accel_z": AM(37 << 3, "i16", scale=-0.00212, order="big"), + "left_accel_y": AM(39 << 3, "i16", scale=-0.00212, order="big"), + "left_gyro_x": AM(41 << 3, "i16", scale=-0.001065, order="big"), + "left_gyro_z": AM(43 << 3, "i16", scale=-0.001065, order="big"), + "left_gyro_y": AM(45 << 3, "i16", scale=-0.001065, order="big"), + # Right + "right_imu_ts": AM(47 << 3, "u8", scale=1), + "right_accel_z": AM(48 << 3, "i16", scale=0.00212, order="big"), + "right_accel_x": AM(50 << 3, "i16", scale=-0.00212, order="big"), + "right_accel_y": AM(52 << 3, "i16", scale=-0.00212, order="big"), + "right_gyro_z": AM(54 << 3, "i16", scale=0.001065, order="big"), + "right_gyro_x": AM(56 << 3, "i16", scale=-0.001065, order="big"), + "right_gyro_y": AM(58 << 3, "i16", scale=-0.001065, order="big"), } } diff --git a/src/hhd/device/legion_go/tablet/controllers.yml b/src/hhd/device/legion_go/tablet/controllers.yml new file mode 100644 index 00000000..5cb5a435 --- /dev/null +++ b/src/hhd/device/legion_go/tablet/controllers.yml @@ -0,0 +1,85 @@ +type: container +tags: [lgc] +title: Legion Controllers +hint: >- + Allows for configuring the Legion controllers using the built in firmware + commands and enabling emulation modes for various controller types. + +children: + xinput: + type: mode + tags: [lgc_xinput] + title: Emulation Mode (X-Input) + hint: >- + Emulate different controller types when the Legion Controllers are in X-Input mode. + + # + # Common settings + # + imu: + type: mode + title: Controller Motions Device + default: left + modes: + disabled: + type: container + title: Disabled + left: + type: container + title: Left Controller + right: + type: container + title: Right Controller + both: + type: container + title: Both Controllers + hint: >- + The main controller uses the right controller's motion sensor, and a + secondary controller is created for the left controller's motion sensor. + + swap_legion_v2: + type: bool + title: Swap Legion with Menu/View + tags: [ non-essential ] + default: False + + nintendo_mode: + type: bool + title: Nintendo Mode (A-B Swap) + tags: [ non-essential ] + hint: >- + Swaps A with B and X with Y. + default: False + + m2_to_mute: + type: bool + title: M2 As Xbox Share/Dualsense Mic Mute + tags: [ non-essential ] + hint: >- + Maps the M2 to the mute button on Dualsense and the share button on the + Xbox Elite controller. + default: False + + select_reboots: + type: bool + title: Hold View to Reboot + tags: [ non-essential ] + default: True + + touchpad: + + shortcuts: + type: bool + title: Enable Shortcuts Controller + tags: [ non-essential ] + hint: >- + When in other modes (dinput, dual dinput, and fps), enable a shortcuts + controller to restore Guide, QAM, and shortcut functionality. + default: True + + factory_reset: + type: action + title: Factory Reset Controllers + tags: [ non-essential, verify ] + hint: >- + Resets the controllers to factory settings. diff --git a/src/hhd/device/legion_go/gyro_fix.py b/src/hhd/device/legion_go/tablet/gyro_fix.py similarity index 94% rename from src/hhd/device/legion_go/gyro_fix.py rename to src/hhd/device/legion_go/tablet/gyro_fix.py index f035755f..3493f9b3 100644 --- a/src/hhd/device/legion_go/gyro_fix.py +++ b/src/hhd/device/legion_go/tablet/gyro_fix.py @@ -18,7 +18,7 @@ def gyro_fix(ev: Event, rate: int = 65): except KeyboardInterrupt: raise except Exception as e: - logger.warning(f"Gyro fix failed with error:{e}\n") + logger.warning(f"Gyro fix failed with error:\n{e}") finally: if g: g.close() diff --git a/src/hhd/device/legion_go/tablet/hid.py b/src/hhd/device/legion_go/tablet/hid.py new file mode 100644 index 00000000..38900ca6 --- /dev/null +++ b/src/hhd/device/legion_go/tablet/hid.py @@ -0,0 +1,338 @@ +import logging +from enum import Enum +from typing import Literal, Sequence + +from hhd.controller import Event +from hhd.controller.lib.hid import Device +from hhd.controller.physical.hidraw import GenericGamepadHidraw + +logger = logging.getLogger(__name__) + +Controller = Literal["left", "right"] +RgbMode = Literal["solid", "pulse", "dynamic", "spiral"] + +RGB_MODE_PULSE = 0x02 +RGB_MODE_DYNAMIC = 0x03 +RGB_MODE_SPIRAL = 0x04 + + +def to_bytes(s: str): + return bytes.fromhex(s.replace(" ", "")) + + +def _get_controller(c: Controller): + if c == "left": + return 0x03 + elif c == "right": + return 0x04 + assert False, f"Controller '{c}' not supported." + + +def rgb_set_profile( + controller: Controller, + profile: Literal[1, 2, 3], + mode: RgbMode, + red: int, + green: int, + blue: int, + brightness: float = 1, + speed: float = 1, +): + r_controller = _get_controller(controller) + assert profile in (1, 2, 3), f"Invalid profile '{profile}' selected." + + match mode: + case "solid": + r_mode = 1 + case "pulse": + r_mode = 2 + case "dynamic": + r_mode = 3 + case "spiral": + r_mode = 4 + case _: + assert False, f"Mode '{mode}' not supported. " + + r_brightness = min(max(int(64 * brightness), 0), 63) + r_period = min(max(int(64 * (1 - speed)), 0), 63) + + return bytes( + [ + 0x05, + 0x0C, + 0x72, + 0x01, + r_controller, + r_mode, + red, + green, + blue, + r_brightness, + r_period, + profile, + 0x01, + ] + ) + + +def rgb_load_profile( + controller: Controller, + profile: Literal[1, 2, 3], +): + r_controller = _get_controller(controller) + + return bytes( + [ + 0x05, + 0x06, + 0x73, + 0x02, + r_controller, + profile, + 0x01, + ] + ) + + +def rgb_enable(controller: Controller, enable: bool): + r_enable = enable & 0x01 + r_controller = _get_controller(controller) + return bytes( + [ + 0x05, + 0x06, + 0x70, + 0x02, + r_controller, + r_enable, + 0x01, + ] + ) + + +def controller_enable_gyro(controller: Controller): + rc = _get_controller(controller) + EN = 0x01 + M = 0x02 + return [ + # Enable the gyro if its disabled + bytes([0x05, 0x06, 0x6A, 0x02, rc, EN, 0x01]), + # Enable high quality report + bytes([0x05, 0x06, 0x6A, 0x07, rc, M, 0x01]), + ] + + +def controller_disable_gyro(controller: Controller): + rc = _get_controller(controller) + M = 0x01 + return [ + # Disable high quality report + bytes([0x05, 0x06, 0x6A, 0x07, rc, M, 0x01]), + ] + + +def controller_factory_reset(): + return [ + # RX + to_bytes("0405 05 01 01 01 01"), + # Dongle (?) + to_bytes("0405 05 01 01 02 01"), + # Left + to_bytes("0405 05 01 01 03 01"), + # Right + to_bytes("0405 05 01 01 04 01"), + ] + + +def controller_legion_swap(enabled): + return [to_bytes(f"0506 69 0401 {'02' if enabled else '01'} 01")] + + +def rgb_multi_load_settings( + mode: RgbMode, + profile: Literal[1, 2, 3], + red: int, + green: int, + blue: int, + brightness: float = 1, + speed: float = 1, + init: bool = True, +): + base = [ + rgb_set_profile("left", profile, mode, red, green, blue, brightness, speed), + rgb_set_profile("right", profile, mode, red, green, blue, brightness, speed), + ] + # Always update + # Old firmware has issues with new way + # if not init: + # return base + + return [ + *base, + rgb_load_profile("left", profile), + rgb_load_profile("right", profile), + rgb_enable("left", True), + rgb_enable("right", True), + ] + + +def rgb_multi_disable(): + return [ + rgb_enable("left", False), + rgb_enable("right", False), + ] + + +class RgbCallback: + def __init__(self) -> None: + self.prev_mode = None + + def __call__(self, dev: Device, events: Sequence[Event]): + try: + for ev in events: + if ev["type"] != "led": + continue + + reps = None + mode = None + match ev["mode"]: + case "disabled": + pass + case "pulse": + mode = "pulse" + case "rainbow": + mode = "dynamic" + case "solid": + if ev["red"] or ev["green"] or ev["blue"]: + mode = "solid" + else: + # Disable if brightness is 0 + mode = None + case "spiral": + mode = "spiral" + case _: + pass + + if mode: + reps = rgb_multi_load_settings( + mode, + 0x03, + ev["red"], + ev["green"], + ev["blue"], + ev["brightness"], + ev["speed"], + self.prev_mode != mode, + ) + # Only init sparingly, to speed up execution + self.prev_mode = mode + else: + reps = rgb_multi_disable() + + for r in reps: + dev.write(r) + except Exception as e: + logger.error(f"Error while setting RGB:\n{e}") + + +class LegionHidraw(GenericGamepadHidraw): + def with_settings( + self, + gyro: str | None, + reset: bool, + use_touchpad: bool = False, + swap_legion: bool = False, + ): + self.gyro = gyro + self.reset = reset + self.use_touchpad = use_touchpad + self.old_touched = False + self.old_x = 0 + self.old_y = 0 + self.touch_changed = None + self.touchpad_init = True + self.swap_legion = swap_legion + return self + + def open(self): + out = super().open() + if not out: + return out + if not self.dev: + return out + + cmds = [] + + if self.reset: + logger.warning(f"Factory Resetting controllers") + cmds.extend(controller_factory_reset()) + gyro_active = self.gyro in ("left", "right", "both") + if self.gyro in ("left", "both") or (not gyro_active and self.use_touchpad): + cmds.extend(controller_enable_gyro("left")) + if self.gyro in ("right", "both"): + cmds.extend(controller_enable_gyro("right")) + # Always disable legion swap and use our version + cmds.extend(controller_legion_swap(False)) + + for r in cmds: + self.dev.write(r) + + return out + + def produce(self, fds: Sequence[int]): + out = super().produce(fds) + + # TODO: Cleanup + # Or remove, since this option is problematic + # If removing, remove the produce function completely + if self.use_touchpad and self.report: + if self.touchpad_init: + self.touchpad_init = False + out = [ + *out, + { + "type": "configuration", + "code": "touchpad_aspect_ratio", + "value": 1, + }, + ] + + x = int.from_bytes(self.report[26:28]) + y = int.from_bytes(self.report[28:30]) + touched = bool(x or y) + changed = touched != self.old_touched or x != self.old_x or y != self.old_y + if changed: + out = list(out) + if touched != self.old_touched: + self.old_touched = touched + out.append( + {"type": "button", "code": "touchpad_touch", "value": touched} + ) + if x != self.old_x: + self.old_x = x + out.append( + {"type": "axis", "code": "touchpad_x", "value": x / 1000} + ) + if y != self.old_y: + self.old_y = y + out.append( + {"type": "axis", "code": "touchpad_y", "value": y / 1000} + ) + + return out + + def close(self, exit: bool) -> bool: + if not self.dev: + return super().close(exit) + + cmds = [] + # Always reset both gyros to avoid leaving them on + # in case they use battery + cmds.extend(controller_disable_gyro("left")) + cmds.extend(controller_disable_gyro("right")) + # Restore the windows legion swap version for continuity + cmds.extend(controller_legion_swap(self.swap_legion)) + for r in cmds: + self.dev.write(r) + + return super().close(exit) diff --git a/src/hhd/device/orange_pi/__init__.py b/src/hhd/device/orange_pi/__init__.py new file mode 100644 index 00000000..a86acc07 --- /dev/null +++ b/src/hhd/device/orange_pi/__init__.py @@ -0,0 +1,121 @@ +from threading import Event, Thread +from typing import Any, Sequence + +from hhd.controller.physical.rgb import is_led_supported +from hhd.plugins import ( + Config, + Context, + Emitter, + HHDPlugin, + get_gyro_config, + get_outputs_config, + load_relative_yaml, +) +from hhd.plugins.settings import HHDSettings + +from .const import CONFS, DEFAULT_MAPPINGS, get_default_config + + +class GenericControllersPlugin(HHDPlugin): + name = "orange_pi_controllers" + priority = 18 + log = "orpi" + + def __init__(self, dmi: str, dconf: dict) -> None: + self.t = None + self.should_exit = None + self.updated = Event() + self.started = False + self.t = None + + self.dmi = dmi + self.dconf = dconf + self.name = f"orange_pi_controllers@'{dconf.get('name', 'ukn')}'" + + def open( + self, + emit: Emitter, + context: Context, + ): + self.emit = emit + self.context = context + self.prev = None + + def settings(self) -> HHDSettings: + base = {"controllers": {"handheld": load_relative_yaml("controllers.yml")}} + base["controllers"]["handheld"]["children"]["controller_mode"].update( + get_outputs_config( + can_disable=True, + has_leds=is_led_supported(), + start_disabled=self.dconf.get("untested", False), + default_device="uinput", + ) + ) + + base["controllers"]["handheld"]["children"]["imu_axis"] = get_gyro_config( + self.dconf.get("mapping", DEFAULT_MAPPINGS) + ) + + return base + + def update(self, conf: Config): + new_conf = conf["controllers.handheld"] + if new_conf == self.prev: + return + if self.prev is None: + self.prev = new_conf + else: + self.prev.update(new_conf.conf) + + self.updated.set() + self.start(self.prev) + + def start(self, conf): + from .base import plugin_run + + if self.started: + return + self.started = True + + self.close() + self.should_exit = Event() + self.t = Thread( + target=plugin_run, + args=( + conf, + self.emit, + self.context, + self.should_exit, + self.updated, + self.dconf, + ), + ) + self.t.start() + + def close(self): + if not self.should_exit or not self.t: + return + self.should_exit.set() + self.t.join() + self.should_exit = None + self.t = None + + +def autodetect(existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: + if len(existing): + return existing + + # Match vendor first to avoid issues + try: + with open("/sys/class/dmi/id/sys_vendor", "r") as f: + vendor = f.read().lower().strip() + + if "orangepi" not in vendor: + return [] + except Exception: + return [] + + with open("/sys/devices/virtual/dmi/id/product_name", "r") as f: + dmi = f.read().strip() + + return [GenericControllersPlugin(dmi, CONFS.get(dmi, get_default_config(dmi)))] diff --git a/src/hhd/device/orange_pi/base.py b/src/hhd/device/orange_pi/base.py new file mode 100644 index 00000000..0bb15752 --- /dev/null +++ b/src/hhd/device/orange_pi/base.py @@ -0,0 +1,256 @@ +import logging +import os +import select +import time +from threading import Event as TEvent + +import evdev + +from hhd.controller import Multiplexer, DEBUG_MODE +from hhd.controller.lib.hide import unhide_all +from hhd.controller.physical.evdev import B as EC +from hhd.controller.physical.evdev import GenericGamepadEvdev +from hhd.controller.physical.imu import CombinedImu, HrtimerTrigger +from hhd.controller.physical.rgb import LedDevice +from hhd.plugins import Config, Context, Emitter, get_gyro_state, get_outputs + +from .const import AT_BTN_MAPPINGS, GAMEPAD_BTN_MAPPINGS, DEFAULT_MAPPINGS + +ERROR_DELAY = 1 +SELECT_TIMEOUT = 1 + +logger = logging.getLogger(__name__) + +GAMEPAD_VIDS = [0x045E, 0x0079] +GAMEPAD_PIDS = [0x028E, 0x181C] + +KBD_VID = 0x0001 +KBD_PID = 0x0001 + +BACK_BUTTON_DELAY = 0.1 + + +def plugin_run( + conf: Config, + emit: Emitter, + context: Context, + should_exit: TEvent, + updated: TEvent, + dconf: dict, +): + first = True + first_disabled = True + while not should_exit.is_set(): + if conf["controller_mode.mode"].to(str) == "disabled": + time.sleep(ERROR_DELAY) + if first_disabled: + unhide_all() + first_disabled = False + continue + else: + first_disabled = True + + found_gamepad = False + try: + for d in evdev.list_devices(): + dev = evdev.InputDevice(d) + if dev.info.vendor in GAMEPAD_VIDS and dev.info.product in GAMEPAD_PIDS: + found_gamepad = True + break + except Exception: + logger.warning("Failed finding device, skipping check.") + found_gamepad = True + + if not found_gamepad: + if first: + logger.info("Controller not found. Waiting...") + time.sleep(ERROR_DELAY) + first = False + continue + + try: + logger.info("Launching emulated controller.") + updated.clear() + controller_loop(conf.copy(), should_exit, updated, dconf, emit) + except Exception as e: + logger.error(f"Received the following error:\n{type(e)}: {e}") + logger.error( + f"Assuming controllers disconnected, restarting after {ERROR_DELAY}s." + ) + first = True + # Raise exception + if DEBUG_MODE: + raise e + time.sleep(ERROR_DELAY) + + # Unhide all devices before exiting + unhide_all() + +def controller_loop( + conf: Config, should_exit: TEvent, updated: TEvent, dconf: dict, emit: Emitter +): + debug = DEBUG_MODE + + # Output + d_producers, d_outs, d_params = get_outputs( + conf["controller_mode"], + None, + conf["imu"].to(bool), + emit=emit, + ) + motion = d_params.get("uses_motion", True) and conf.get("imu", True) + + # Imu + d_imu = CombinedImu( + conf["imu_hz"].to(int), + get_gyro_state(conf["imu_axis"], dconf.get("mapping", DEFAULT_MAPPINGS)), + ) + d_timer = HrtimerTrigger(conf["imu_hz"].to(int), [HrtimerTrigger.IMU_NAMES]) + + # Inputs + d_xinput = GenericGamepadEvdev( + vid=GAMEPAD_VIDS, + pid=GAMEPAD_PIDS, + # name=["Generic X-Box pad"], + capabilities={EC("EV_KEY"): [EC("BTN_A")]}, + required=True, + # axis_map=DINPUT_AXIS_MAP, + hide=True, + # postprocess=DINPUT_AXIS_POSTPROCESS, + ) + + d_kbd_1 = GenericGamepadEvdev( + vid=[KBD_VID], + pid=[KBD_PID], + required=False, + grab=False, + btn_map=dconf.get("at_mapping", AT_BTN_MAPPINGS), + ) + + d_kbd_2 = GenericGamepadEvdev( + vid=GAMEPAD_VIDS, + pid=GAMEPAD_PIDS, + required=False, + grab=False, + capabilities={EC("EV_KEY"): [EC("KEY_F16")]}, + btn_map=dconf.get("gamepad_mapping", GAMEPAD_BTN_MAPPINGS), + ) + + multiplexer = Multiplexer( + trigger="analog_to_discrete", + dpad="analog_to_discrete", + share_to_qam=True, + nintendo_mode=conf["nintendo_mode"].to(bool), + emit=emit, + params=d_params, + ) + + # d_volume_btn = UInputDevice( + # name="Handheld Daemon Volume Keyboard", + # phys="phys-hhd-vbtn", + # capabilities={EC("EV_KEY"): [EC("KEY_VOLUMEUP"), EC("KEY_VOLUMEDOWN")]}, + # btn_map={ + # "key_volumeup": EC("KEY_VOLUMEUP"), + # "key_volumedown": EC("KEY_VOLUMEDOWN"), + # }, + # pid=KBD_PID, + # vid=KBD_VID, + # output_timestamps=True, + # ) + + d_rgb = LedDevice() + if d_rgb.supported: + logger.info(f"RGB Support activated through kernel driver.") + + REPORT_FREQ_MIN = 25 + REPORT_FREQ_MAX = 400 + + if motion: + REPORT_FREQ_MAX = max(REPORT_FREQ_MAX, conf["imu_hz"].to(float)) + + REPORT_DELAY_MAX = 1 / REPORT_FREQ_MIN + REPORT_DELAY_MIN = 1 / REPORT_FREQ_MAX + + fds = [] + devs = [] + fd_to_dev = {} + + def prepare(m): + devs.append(m) + fs = m.open() + fds.extend(fs) + for f in fs: + fd_to_dev[f] = m + + try: + # d_vend.open() + prepare(d_xinput) + if motion: + start_imu = True + if dconf.get("hrtimer", False): + start_imu = d_timer.open() + if start_imu: + prepare(d_imu) + prepare(d_kbd_1) + prepare(d_kbd_2) + for d in d_producers: + prepare(d) + + logger.info("Emulated controller launched, have fun!") + while not should_exit.is_set() and not updated.is_set(): + start = time.perf_counter() + # Add timeout to call consumers a minimum amount of times per second + r, _, _ = select.select(fds, [], [], REPORT_DELAY_MAX) + evs = [] + to_run = set() + for f in r: + to_run.add(id(fd_to_dev[f])) + + for d in devs: + if id(d) in to_run: + evs.extend(d.produce(r)) + + evs = multiplexer.process(evs) + if evs: + if debug: + logger.info(evs) + + # d_volume_btn.consume(evs) + d_xinput.consume(evs) + + d_rgb.consume(evs) + for d in d_outs: + d.consume(evs) + + # If unbounded, the total number of events per second is the sum of all + # events generated by the producers. + # For Legion go, that would be 100 + 100 + 500 + 30 = 730 + # Since the controllers of the legion go only update at 500hz, this is + # wasteful. + # By setting a target refresh rate for the report and sleeping at the + # end, we ensure that even if multiple fds become ready close to each other + # they are combined to the same report, limiting resource use. + # Ideally, this rate is smaller than the report rate of the hardware controller + # to ensure there is always a report from that ready during refresh + t = time.perf_counter() + elapsed = t - start + if elapsed < REPORT_DELAY_MIN: + time.sleep(REPORT_DELAY_MIN - elapsed) + + except KeyboardInterrupt: + raise + finally: + # d_vend.close(not updated.is_set()) + try: + d_timer.close() + except Exception as e: + logger.error(f"Error while closing device '{d}' with exception:\n{e}") + if debug: + raise e + for d in reversed(devs): + try: + d.close(not updated.is_set()) + except Exception as e: + logger.error(f"Error while closing device '{d}' with exception:\n{e}") + if debug: + raise e diff --git a/src/hhd/device/orange_pi/const.py b/src/hhd/device/orange_pi/const.py new file mode 100644 index 00000000..a85c2f75 --- /dev/null +++ b/src/hhd/device/orange_pi/const.py @@ -0,0 +1,45 @@ +from hhd.controller import Axis, Button, Configuration +from hhd.controller.physical.evdev import B, to_map +from hhd.plugins import gen_gyro_state + +DEFAULT_MAPPINGS: dict[str, tuple[Axis, str | None, float, float | None]] = { + "accel_x": ("accel_x", "accel", 1, None), + "accel_y": ("accel_z", "accel", 1, None), + "accel_z": ("accel_y", "accel", -1, None), + "anglvel_x": ("gyro_x", "anglvel", 1, None), + "anglvel_y": ("gyro_z", "anglvel", 1, None), + "anglvel_z": ("gyro_y", "anglvel", -1, None), + "timestamp": ("imu_ts", None, 1, None), +} + +AT_BTN_MAPPINGS: dict[int, str] = { + # Volume buttons come from the same keyboard + B("KEY_F16"): "mode", # Big Button + B("KEY_F15"): "share", # Small Button + # B("KEY_F17"): "extra_l1", # LC Button + # B("KEY_F18"): "extra_r1", # RC Button +} + +GAMEPAD_BTN_MAPPINGS: dict[int, str] = { + # Volume buttons come from the same keyboard + # B("KEY_F16"): "mode", # Big Button + # B("KEY_F15"): "share", # Small Button + B("KEY_F17"): "extra_l1", # LC Button + B("KEY_F18"): "extra_r1", # RC Button +} + +CONFS = { + # New hardware new firmware, the unit below was dissassembled + # "G1621-02": {"name": "OrangePi G1621-02/G1621-02", "hrtimer": True}, + "NEO-01": {"name": "OrangePi NEO-01/NEO-01", "hrtimer": True}, +} + + +def get_default_config(product_name: str): + out = { + "name": product_name, + "hrtimer": True, + "untested": True, + } + + return out diff --git a/src/hhd/device/orange_pi/controllers.yml b/src/hhd/device/orange_pi/controllers.yml new file mode 100644 index 00000000..042af662 --- /dev/null +++ b/src/hhd/device/orange_pi/controllers.yml @@ -0,0 +1,42 @@ +type: container +tags: [lgc] +title: Orange Pi Neo +hint: >- + Allows for configuring your handheld's controller to a unified output. + +children: + controller_mode: + type: mode + tags: [controller_mode] + title: Controller Emulation + hint: >- + Emulate different controller types to fuse your device's features. + + # + # Common settings + # + imu: + type: bool + title: Motion Support + hint: >- + Enable gyroscope/accelerometer (IMU) support (.3% background CPU use) + default: True + + imu_hz: + type: discrete + title: Motion Hz + tags: [ non-essential ] + hint: >- + Sets the sampling frequency for the IMU. + options: [100, 200, 400, 800] + default: 400 + + imu_axis: + + nintendo_mode: + type: bool + title: Nintendo Mode (A-B Swap) + tags: [ non-essential ] + hint: >- + Swaps A with B and X with Y. + default: False diff --git a/src/hhd/device/oxp/__init__.py b/src/hhd/device/oxp/__init__.py new file mode 100644 index 00000000..795ab0b2 --- /dev/null +++ b/src/hhd/device/oxp/__init__.py @@ -0,0 +1,188 @@ +import logging +import os +from threading import Event, Thread +from typing import Any, Sequence + +from hhd.plugins import ( + Config, + Context, + Emitter, + HHDPlugin, + get_gyro_config, + get_outputs_config, + load_relative_yaml, +) +from hhd.plugins.settings import HHDSettings + +from .const import CONFS, DEFAULT_MAPPINGS, get_default_config + +logger = logging.getLogger(__name__) + + +class GenericControllersPlugin(HHDPlugin): + name = "onexplayer" + priority = 18 + log = "oxpc" + + def __init__(self, dmi: str, dconf: dict) -> None: + self.t = None + self.should_exit = None + self.updated = Event() + self.started = False + self.t = None + + self.dmi = dmi + self.dconf = dconf + self.name = f"onexplayer@'{dconf.get('name', 'ukn')}'" + + def open( + self, + emit: Emitter, + context: Context, + ): + self.emit = emit + self.context = context + self.prev = None + + # Use the oxp-platform driver if available + turbo = False + if self.dconf.get("turbo", True) and os.path.exists( + "/sys/devices/platform/oxp-platform/tt_toggle" + ): + try: + with open("/sys/devices/platform/oxp-platform/tt_toggle", "w") as f: + f.write("1") + logger.info(f"Turbo button takeover enabled") + turbo = True + + if os.path.exists("/sys/devices/platform/oxp-platform/tt_led"): + with open("/sys/devices/platform/oxp-platform/tt_led", "w") as f: + f.write("0") + except Exception: + logger.warning( + f"Turbo takeover failed. Ensure you have the latest oxp-sensors driver installed." + ) + self.turbo = turbo + + def notify(self, events: Sequence): + if not self.turbo: + return + + woke = False + for ev in events: + if ev["type"] == "special" and ev.get("event", None) == "wakeup": + woke = True + + if not woke: + return + + # We need to reset after hibernation + try: + logger.info(f"Turbo button takeover enabled") + with open("/sys/devices/platform/oxp-platform/tt_toggle", "w") as f: + f.write("1") + + if os.path.exists("/sys/devices/platform/oxp-platform/tt_led"): + with open("/sys/devices/platform/oxp-platform/tt_led", "w") as f: + f.write("0") + except Exception: + logger.warning( + f"Turbo takeover failed. Ensure you have the latest oxp-sensors driver installed." + ) + + def settings(self) -> HHDSettings: + base = {"controllers": {"oxp": load_relative_yaml("controllers.yml")}} + base["controllers"]["oxp"]["children"]["controller_mode"].update( + get_outputs_config( + can_disable=True, + has_leds=self.dconf.get("rgb", True), + start_disabled=self.dconf.get("untested", False), + extra_buttons=self.dconf.get("extra_buttons", "dual"), + ) + ) + + base["controllers"]["oxp"]["children"]["imu_axis"] = get_gyro_config( + self.dconf.get("mapping", DEFAULT_MAPPINGS) + ) + + if not self.dconf.get("x1", False): + del base["controllers"]["oxp"]["children"]["volume_reverse"] + # Maybe it is helpful for OneXFly users + # del base["controllers"]["oxp"]["children"]["swap_face"] + + if not self.turbo: + del base["controllers"]["oxp"]["children"]["extra_buttons"] + del base["controllers"]["oxp"]["children"]["turbo_reboots"] + + return base + + def update(self, conf: Config): + new_conf = conf["controllers.oxp"] + if new_conf == self.prev: + return + if self.prev is None: + self.prev = new_conf + else: + self.prev.update(new_conf.conf) + + self.updated.set() + self.start(self.prev) + + def start(self, conf): + from .base import plugin_run + + if self.started: + return + self.started = True + + self.close() + self.should_exit = Event() + self.t = Thread( + target=plugin_run, + args=( + conf, + self.emit, + self.context, + self.should_exit, + self.updated, + self.dconf, + self.turbo, + ), + ) + self.t.start() + + def close(self): + if not self.should_exit or not self.t: + return + self.should_exit.set() + self.t.join() + self.should_exit = None + self.t = None + + if self.turbo: + # Disable turbo button takeover + try: + with open("/sys/devices/platform/oxp-platform/tt_toggle", "w") as f: + f.write("0") + except Exception: + pass + + +def autodetect(existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: + if len(existing): + return existing + + # Match just product name + # if a device exists here its officially supported + with open("/sys/devices/virtual/dmi/id/product_name") as f: + dmi = f.read().strip() + + dconf = CONFS.get(dmi, None) + if dconf: + return [GenericControllersPlugin(dmi, dconf)] + + # Begin hw agnostic dmi match + if "ONEXPLAYER" in dmi: + return [GenericControllersPlugin(dmi, get_default_config(dmi, "ONEXPLAYER"))] + + return [] diff --git a/src/hhd/device/oxp/base.py b/src/hhd/device/oxp/base.py new file mode 100644 index 00000000..6fb135ee --- /dev/null +++ b/src/hhd/device/oxp/base.py @@ -0,0 +1,650 @@ +import logging +import os +import select +import time +from threading import Event as TEvent + + +from hhd.controller import Multiplexer, DEBUG_MODE +from hhd.controller.lib.hide import unhide_all +from hhd.controller.physical.evdev import B as EC +from hhd.controller.physical.evdev import GenericGamepadEvdev, enumerate_evs +from hhd.controller.physical.imu import CombinedImu, HrtimerTrigger +from hhd.controller.virtual.uinput import UInputDevice +from hhd.controller.physical.hidraw import enumerate_unique +from hhd.plugins import Config, Context, Emitter, get_gyro_state, get_outputs +from .serial import SerialDevice, get_serial +from .hid_v1 import OxpHidraw +from .hid_v2 import OxpHidrawV2 +from .const import BTN_MAPPINGS, DEFAULT_MAPPINGS, BTN_MAPPINGS_NONTURBO + +FIND_DELAY = 0.1 +ERROR_DELAY = 0.3 +LONGER_ERROR_DELAY = 3 +LONGER_ERROR_MARGIN = 1.3 +TURBO_DELAY = 5 +TURBO_CONTROLLER_CHECK = 2 + +logger = logging.getLogger(__name__) + + +GAMEPAD_VID = 0x045E +GAMEPAD_PID = 0x028E + +KBD_VID = 0x0001 +KBD_PID = 0x0001 + +X1_MINI_VID = 0x1A86 +X1_MINI_PID = 0xFE00 +X1_MINI_PAGE = 0xFF00 +X1_MINI_USAGE = 0x0001 + +XFLY_VID = 0x1A2C +XFLY_PID = 0xB001 +XFLY_PAGE = 0xFF01 +XFLY_USAGE = 0x0001 + +BACK_BUTTON_DELAY = 0.1 + +RGB_MODES_FULL = { + "disabled": [], + "oxp": ["oxp", "oxp-secondary"], + "solid": ["color"], + "duality": ["dual"], +} +RGB_MODES_STICKS = { + "disabled": [], + "oxp": ["oxp"], + "solid": ["color"], +} + + +def plugin_run( + conf: Config, + emit: Emitter, + context: Context, + should_exit: TEvent, + updated: TEvent, + dconf: dict, + turbo: bool = False, +): + first = True + init = time.perf_counter() + repeated_fail = False + switch_to_turbo = None + first_disabled = True + + while not should_exit.is_set(): + if conf["controller_mode.mode"].to(str) == "disabled": + time.sleep(ERROR_DELAY) + if first_disabled: + UInputDevice.close_volume_cached() + unhide_all() + first_disabled = False + continue + else: + first_disabled = True + + try: + found_device = bool(enumerate_evs(vid=GAMEPAD_VID, pid=GAMEPAD_PID)) + except Exception: + logger.warning("Failed finding device, skipping check.") + time.sleep(LONGER_ERROR_DELAY) + found_device = True + + try: + protocol = dconf.get("protocol", None) + # Serial device is always present + # Hid devices might not be, wait a bit for them + match protocol: + case "hid_v1": + found_vendor = bool( + enumerate_unique( + vid=X1_MINI_VID, + pid=X1_MINI_PID, + usage_page=X1_MINI_PAGE, + usage=X1_MINI_USAGE, + ) + ) + case "hid_v2": + found_vendor = bool( + enumerate_unique( + vid=XFLY_VID, + pid=XFLY_PID, + usage_page=XFLY_PAGE, + usage=XFLY_USAGE, + ) + ) + case "hid_v1_g1": + found_vendor = bool( + enumerate_unique( + vid=XFLY_VID, + pid=XFLY_PID, + usage_page=XFLY_PAGE, + usage=XFLY_USAGE, + ) + ) + case "mixed": + found_vendor = bool( + enumerate_unique( + vid=XFLY_VID, + pid=XFLY_PID, + usage_page=XFLY_PAGE, + usage=XFLY_USAGE, + ) + ) and bool(get_serial()[0]) + case "serial": + found_vendor = bool(get_serial()[0]) + case _: + found_vendor = True + except Exception: + logger.warning("Failed finding vendor device, skipping check.") + found_vendor = True + + turbo_start = False + if not found_device or not found_vendor: + curr = time.perf_counter() + if first: + logger.info("Controller not found. Waiting...") + switch_to_turbo = curr + TURBO_DELAY + time.sleep(FIND_DELAY) + first = False + if found_vendor and turbo and switch_to_turbo and curr > switch_to_turbo: + logger.info("Switching to turbo only button mode") + updated.clear() + turbo_start = True + first = False + else: + continue + + try: + logger.info("Launching emulated controller.") + updated.clear() + init = time.perf_counter() + if turbo_start: + turbo_loop(conf.copy(), should_exit, updated, dconf, emit) + else: + controller_loop(conf.copy(), should_exit, updated, dconf, emit, turbo) + repeated_fail = False + except Exception as e: + failed_fast = init + LONGER_ERROR_MARGIN > time.perf_counter() + sleep_time = ( + LONGER_ERROR_DELAY if repeated_fail and failed_fast else ERROR_DELAY + ) + repeated_fail = failed_fast + logger.error(f"Received the following error:\n{type(e)}: {e}") + logger.error( + f"Assuming controllers disconnected, restarting after {sleep_time}s." + ) + first = True + # Raise exception + if DEBUG_MODE: + raise e + time.sleep(sleep_time) + + # Close the volume keyboard cache + UInputDevice.close_volume_cached() + unhide_all() + + +def find_vendor(prepare, turbo, protocol: str | None): + d_ser = SerialDevice(turbo=turbo, required=True) + d_hidraw = OxpHidraw( + vid=[X1_MINI_VID], + pid=[X1_MINI_PID], + usage_page=[X1_MINI_PAGE], + usage=[X1_MINI_USAGE], + turbo=turbo, + required=True, + ) + d_hidraw_v2 = OxpHidrawV2( + vid=[XFLY_VID], + pid=[XFLY_PID], + usage_page=[XFLY_PAGE], + usage=[XFLY_USAGE], + turbo=turbo, + required=True, + ) + d_hidraw_g1 = OxpHidraw( + vid=[XFLY_VID], + pid=[XFLY_PID], + usage_page=[XFLY_PAGE], + usage=[XFLY_USAGE], + turbo=turbo, + required=True, + g1=True, + ) + + if protocol in ["serial", "mixed"]: + try: + prepare(d_ser) + # OneXFly uses serial only for the buttons and hidraw for RGB + # Initialize V2 selectcively on that one + try: + if d_ser.buttons_only: + if protocol == "serial": + logger.warning( + f"Device has protocol 'serial', but 'mixed' was detected." + ) + prepare(d_hidraw_v2) + return [d_ser, d_hidraw_v2] + except Exception as e: + logger.info( + f"Could not find V2 hidraw vendor device, RGB will not work, error:\n{e}" + ) + return [d_ser] + except Exception as e: + pass + + if protocol == "hid_v1": + try: + prepare(d_hidraw) + logger.info("Found OXP V1 hidraw vendor device.") + return [d_hidraw] + except Exception as e: + pass + + if protocol == "hid_v1_g1": + try: + prepare(d_hidraw_g1) + logger.info("Found OXP V1 hidraw vendor device.") + return [d_hidraw_g1] + except Exception as e: + pass + + if protocol == "hid_v2": + try: + prepare(d_hidraw_v2) + logger.info("Found OXP V2 hidraw vendor device.") + return [d_hidraw_v2] + except Exception as e: + pass + + logger.error("No vendor device found, RGB and back buttons will not work.") + return [] + + +def turbo_loop( + conf: Config, + should_exit: TEvent, + updated: TEvent, + dconf: dict, + emit: Emitter, +): + debug = DEBUG_MODE + + # Output + if dconf.get("rgb_secondary", False): + rgb_modes = RGB_MODES_FULL + elif dconf.get("rgb", True): + rgb_modes = RGB_MODES_STICKS + else: + rgb_modes = None + + d_producers, d_outs, d_params = get_outputs( + conf["controller_mode"], + None, + conf["imu"].to(bool), + emit=emit, + rgb_modes=rgb_modes, # type: ignore + controller_disabled=True, + ) + + d_kbd_1 = GenericGamepadEvdev( + vid=[KBD_VID], + pid=[KBD_PID], + required=False, + grab=True, + btn_map=BTN_MAPPINGS, + ) + + share_reboots = False + last_controller_check = 0 + keyboard_is = "keyboard" + qam_hhd = False + qam_no_release = False + if conf.get("turbo_reboots", False): + share_reboots = True + match conf.get("extra_buttons", "separate"): + case "separate": + keyboard_is = "steam_qam" + qam_hhd = True + case "combo": + keyboard_is = "qam" + qam_hhd = False + case "combo_hhd": + keyboard_is = "qam" + qam_hhd = True + + multiplexer = Multiplexer( + trigger="analog_to_discrete", + dpad="analog_to_discrete", + share_to_qam=True, + nintendo_mode=conf["nintendo_mode"].to(bool), + emit=emit, + params=d_params, + share_reboots=share_reboots, + keyboard_is=keyboard_is, + swap_guide="start_is_keyboard" if conf.get("swap_face", False) else None, + qam_hhd=qam_hhd, + qam_no_release=qam_no_release, + keyboard_no_release=not conf.get("swap_face", False), + ) + + if dconf.get("x1", False) and conf.get("volume_reverse", False): + logger.info("Reversing volume buttons.") + btn_map = { + "key_volumedown": EC("KEY_VOLUMEUP"), + "key_volumeup": EC("KEY_VOLUMEDOWN"), + } + else: + btn_map = { + "key_volumeup": EC("KEY_VOLUMEUP"), + "key_volumedown": EC("KEY_VOLUMEDOWN"), + } + + d_volume_btn = UInputDevice( + name="Handheld Daemon Volume Keyboard", + phys="phys-hhd-vbtn", + capabilities={EC("EV_KEY"): [EC("KEY_VOLUMEUP"), EC("KEY_VOLUMEDOWN")]}, + btn_map=btn_map, # type: ignore + pid=KBD_PID, + vid=KBD_VID, + output_timestamps=True, + volume_keyboard=True, + ) + + REPORT_FREQ_MIN = 25 + REPORT_FREQ_MAX = 25 + + REPORT_DELAY_MAX = 1 / REPORT_FREQ_MIN + REPORT_DELAY_MIN = 1 / REPORT_FREQ_MAX + + fds = [] + devs = [] + fd_to_dev = {} + + def prepare(m): + devs.append(m) + fs = m.open() + fds.extend(fs) + for f in fs: + fd_to_dev[f] = m + + try: + prepare(d_volume_btn) + d_vend = find_vendor(prepare, True, dconf.get("protocol", None)) + d_vend_id = [id(d) for d in d_vend] + + for d in d_producers: + prepare(d) + prepare(d_kbd_1) + + logger.info( + "Turbo only mode started, the turbo button of the device will still work." + ) + while not should_exit.is_set() and not updated.is_set(): + start = time.perf_counter() + + if start - last_controller_check > TURBO_CONTROLLER_CHECK: + last_controller_check = start + try: + found_device = bool(enumerate_evs(vid=GAMEPAD_VID, pid=GAMEPAD_PID)) + except Exception: + logger.warning("Failed finding device, skipping check.") + found_device = True + if found_device: + logger.info("Controller found, switching to controller mode.") + break + + r, _, _ = select.select(fds, [], [], REPORT_DELAY_MAX) + evs = [] + to_run = set() + for f in r: + to_run.add(id(fd_to_dev[f])) + + for d in devs: + d_id = id(d) + if d_id in to_run or d_id in d_vend_id: + evs.extend(d.produce(r)) + + evs = multiplexer.process(evs) + if evs: + if debug: + logger.info(evs) + + d_volume_btn.consume(evs) + + for d in d_vend: + d.consume(evs) + for d in d_outs: + d.consume(evs) + + t = time.perf_counter() + elapsed = t - start + if elapsed < REPORT_DELAY_MIN: + time.sleep(REPORT_DELAY_MIN - elapsed) + + except KeyboardInterrupt: + raise + finally: + for d in reversed(devs): + try: + d.close(not updated.is_set()) + except Exception as e: + logger.error(f"Error while closing device '{d}' with exception:\n{e}") + if debug: + raise e + + +def controller_loop( + conf: Config, + should_exit: TEvent, + updated: TEvent, + dconf: dict, + emit: Emitter, + turbo: bool = False, +): + debug = DEBUG_MODE + + # Output + d_producers, d_outs, d_params = get_outputs( + conf["controller_mode"], + None, + conf["imu"].to(bool), + emit=emit, + rgb_modes=( + RGB_MODES_FULL if dconf.get("rgb_secondary", False) else RGB_MODES_STICKS # type: ignore + ), + ) + motion = d_params.get("uses_motion", True) + + # Imu + d_imu = CombinedImu( + conf["imu_hz"].to(int), + get_gyro_state(conf["imu_axis"], dconf.get("mapping", DEFAULT_MAPPINGS)), + ) + d_timer = HrtimerTrigger(conf["imu_hz"].to(int), [HrtimerTrigger.IMU_NAMES]) + + # Inputs + d_xinput = GenericGamepadEvdev( + vid=[GAMEPAD_VID], + pid=[GAMEPAD_PID], + # name=["Generic X-Box pad"], + capabilities={EC("EV_KEY"): [EC("BTN_A")]}, + required=True, + hide=True, + ) + + if turbo: + # Switch buttons if turbo is enabled. + # This only affects AOKZOE and OneXPlayer devices with + # that button that have the nonturbo mapping as default + mappings = BTN_MAPPINGS + else: + mappings = BTN_MAPPINGS_NONTURBO + + d_kbd_1 = GenericGamepadEvdev( + vid=[KBD_VID], + pid=[KBD_PID], + required=False, + grab=True, + btn_map=mappings, + ) + # Touchpad keyboard + d_kbd_2 = GenericGamepadEvdev( + vid=[0x6080], + pid=[0x8060], + required=True, + grab=False, + btn_map=BTN_MAPPINGS, + capabilities={EC("EV_KEY"): [EC("KEY_D")]}, + requires_start=True, + ) + + share_reboots = False + keyboard_is = "keyboard" + qam_hhd = False + qam_no_release = False + if turbo: + if conf.get("turbo_reboots", False): + share_reboots = True + match conf.get("extra_buttons", "separate"): + case "separate": + keyboard_is = "steam_qam" + qam_hhd = True + case "combo": + keyboard_is = "qam" + qam_hhd = False + case "combo_hhd": + keyboard_is = "qam" + qam_hhd = True + else: + qam_no_release = True + + multiplexer = Multiplexer( + trigger="analog_to_discrete", + dpad="analog_to_discrete", + share_to_qam=True, + nintendo_mode=conf["nintendo_mode"].to(bool), + emit=emit, + params=d_params, + share_reboots=share_reboots, + keyboard_is=keyboard_is, + swap_guide="start_is_keyboard" if conf.get("swap_face", False) else None, + qam_hhd=qam_hhd, + qam_no_release=qam_no_release, + keyboard_no_release=not conf.get("swap_face", False), + ) + + if dconf.get("x1", False) and conf.get("volume_reverse", False): + logger.info("Reversing volume buttons.") + btn_map = { + "key_volumedown": EC("KEY_VOLUMEUP"), + "key_volumeup": EC("KEY_VOLUMEDOWN"), + } + else: + btn_map = { + "key_volumeup": EC("KEY_VOLUMEUP"), + "key_volumedown": EC("KEY_VOLUMEDOWN"), + } + + d_volume_btn = UInputDevice( + name="Handheld Daemon Volume Keyboard", + phys="phys-hhd-vbtn", + capabilities={EC("EV_KEY"): [EC("KEY_VOLUMEUP"), EC("KEY_VOLUMEDOWN")]}, + btn_map=btn_map, # type: ignore + pid=KBD_PID, + vid=KBD_VID, + output_timestamps=True, + volume_keyboard=True, + ) + + REPORT_FREQ_MIN = 25 + REPORT_FREQ_MAX = 125 + + if motion: + REPORT_FREQ_MAX = max(REPORT_FREQ_MAX, conf["imu_hz"].to(float)) + + REPORT_DELAY_MAX = 1 / REPORT_FREQ_MIN + REPORT_DELAY_MIN = 1 / REPORT_FREQ_MAX + + fds = [] + devs = [] + fd_to_dev = {} + + def prepare(m): + devs.append(m) + fs = m.open() + fds.extend(fs) + for f in fs: + fd_to_dev[f] = m + + try: + d_vend = find_vendor(prepare, turbo, dconf.get("protocol", None)) + d_vend_id = [id(d) for d in d_vend] + if dconf.get("g1", False): + prepare(d_kbd_2) + prepare(d_xinput) + if motion: + start_imu = True + if dconf.get("hrtimer", False): + start_imu = d_timer.open() + if start_imu: + prepare(d_imu) + prepare(d_volume_btn) + prepare(d_kbd_1) + + for d in d_producers: + prepare(d) + + logger.info("Emulated controller launched, have fun!") + while not should_exit.is_set() and not updated.is_set(): + start = time.perf_counter() + # Add timeout to call consumers a minimum amount of times per second + r, _, _ = select.select(fds, [], [], REPORT_DELAY_MAX) + evs = [] + to_run = set() + for f in r: + to_run.add(id(fd_to_dev[f])) + + for d in devs: + d_id = id(d) + if d_id in to_run or d_id in d_vend_id: + evs.extend(d.produce(r)) + + evs = multiplexer.process(evs) + if evs: + if debug: + logger.info(evs) + + d_volume_btn.consume(evs) + d_xinput.consume(evs) + + for d in d_vend: + d.consume(evs) + for d in d_outs: + d.consume(evs) + + t = time.perf_counter() + elapsed = t - start + if elapsed < REPORT_DELAY_MIN: + time.sleep(REPORT_DELAY_MIN - elapsed) + + except KeyboardInterrupt: + raise + finally: + # d_vend.close(not updated.is_set()) + try: + d_timer.close() + except Exception as e: + logger.error(f"Error while closing device '{d}' with exception:\n{e}") + if debug: + raise e + for d in reversed(devs): + try: + d.close(not updated.is_set()) + except Exception as e: + logger.error(f"Error while closing device '{d}' with exception:\n{e}") + if debug: + raise e diff --git a/src/hhd/device/oxp/const.py b/src/hhd/device/oxp/const.py new file mode 100644 index 00000000..de15166b --- /dev/null +++ b/src/hhd/device/oxp/const.py @@ -0,0 +1,158 @@ +from hhd.controller import Axis, Button, Configuration +from hhd.controller.physical.evdev import B, to_map +from hhd.plugins import gen_gyro_state + +DEFAULT_MAPPINGS: dict[str, tuple[Axis, str | None, float, float | None]] = { + "accel_x": ("accel_z", "accel", 1, None), + "accel_y": ("accel_x", "accel", -1, None), + "accel_z": ("accel_y", "accel", -1, None), + "anglvel_x": ("gyro_z", "anglvel", 1, None), + "anglvel_y": ("gyro_x", "anglvel", -1, None), + "anglvel_z": ("gyro_y", "anglvel", -1, None), + "timestamp": ("imu_ts", None, 1, None), +} + +X1_MAPPING = gen_gyro_state("x", True, "z", False, "y", False) +X1_MINI_MAPPING = gen_gyro_state("z", True, "x", False, "y", True) + +BTN_MAPPINGS: dict[int, Button] = { + # Volume buttons come from the same keyboard + B("KEY_VOLUMEUP"): "key_volumeup", + B("KEY_VOLUMEDOWN"): "key_volumedown", + # Turbo Button [29, 56, 125] KEY_LEFTCTRL + KEY_LEFTALT + KEY_LEFTMETA + B("KEY_LEFTALT"): "share", + # Short press orange [32, 125] KEY_D + KEY_LEFTMETA + B("KEY_D"): "mode", + # KB Button [24, 97, 125] KEY_O + KEY_RIGHTCTRL + KEY_LEFTMETA + B("KEY_O"): "keyboard", +} + +BTN_MAPPINGS_NONTURBO: dict[int, Button] = { + # Volume buttons come from the same keyboard + B("KEY_VOLUMEUP"): "key_volumeup", + B("KEY_VOLUMEDOWN"): "key_volumedown", + # Short press orange [32, 125] KEY_D + KEY_LEFTMETA + B("KEY_D"): "mode", + # KB Button [24, 97, 125] KEY_O + KEY_RIGHTCTRL + KEY_LEFTMETA + # If we do not have turbo takeover, let turbo do its turbo thing, and + # failover to having the keyboard button open the overlay + B("KEY_O"): "share", +} + +ONEX_DEFAULT_CONF = { + "hrtimer": True, +} + +OXP_F1_CONF = { + "name": "ONEXPLAYER ONEXFLY", + **ONEX_DEFAULT_CONF, + "protocol": "mixed", +} +OXP_2_CONF = { + "name": "ONEXPLAYER 2", + **ONEX_DEFAULT_CONF, + "protocol": "mixed", + "rgb": False, + "buttons": "none", + "protocol": "none", # explicitly disable +} +AOKZOE_CONF = { + "name": "AOKZOE A1", + "hrtimer": True, + "protocol": "none", + "rgb": False, +} + +CONFS = { + # Aokzoe + "AOKZOE A1 AR07": AOKZOE_CONF, + "AOKZOE A1 Pro": AOKZOE_CONF, + # Onexplayer + "ONE XPLAYER": {"name": "ONE XPLAYER", **ONEX_DEFAULT_CONF}, + "ONEXPLAYER Mini Pro": { + "name": "ONEXPLAYER Mini Pro", + **ONEX_DEFAULT_CONF, + "protocol": "hid_v2", + }, + "ONEXPLAYER mini A07": {"name": "ONEXPLAYER mini", **ONEX_DEFAULT_CONF}, + # OneXFly + "ONEXPLAYER F1": OXP_F1_CONF, + "ONEXPLAYER F1 EVA-01": OXP_F1_CONF, + "ONEXPLAYER F1L": OXP_F1_CONF, + "ONEXPLAYER F1 OLED": OXP_F1_CONF, + "ONEXPLAYER F1Pro": OXP_F1_CONF, + "ONEXPLAYER F1 EVA-02": OXP_F1_CONF, # F1Pro variant + # OXP 2 + "ONEXPLAYER 2": OXP_2_CONF, + "ONEXPLAYER 2 ARP23": OXP_2_CONF, + "ONEXPLAYER 2 GA18": OXP_2_CONF, + # Pro is a bit different + "ONEXPLAYER 2 PRO ARP23": OXP_2_CONF, + "ONEXPLAYER 2 PRO ARP23 EVA-01": OXP_2_CONF, + # X1 Line + "ONEXPLAYER X1 mini": { + **ONEX_DEFAULT_CONF, + "name": "ONEXPLAYER X1 mini", + "x1": True, + "mapping": X1_MINI_MAPPING, + "protocol": "hid_v1", + }, + "ONEXPLAYER X1 A": { + **ONEX_DEFAULT_CONF, + "name": "ONEXPLAYER X1 (AMD)", + "x1": True, + "rgb_secondary": True, + "mapping": X1_MAPPING, + "protocol": "serial", + }, + "ONEXPLAYER X1Pro": { + **ONEX_DEFAULT_CONF, + "name": "ONEXPLAYER X1 (AMD)", + "x1": True, + "rgb_secondary": True, + "mapping": X1_MAPPING, + "protocol": "serial", + }, + "ONEXPLAYER X1 i": { + **ONEX_DEFAULT_CONF, + "name": "ONEXPLAYER X1 (Intel)", + "x1": True, + "rgb_secondary": True, + "mapping": X1_MAPPING, + "protocol": "serial", + "turbo": False, # disable turbo takeover so that it can be used for TDP + }, + "ONEXPLAYER G1 i": { + **ONEX_DEFAULT_CONF, + "name": "ONEXPLAYER G1 (Intel)", + "g1": True, + "protocol": "hid_v1_g1", + "turbo": False, # disable turbo takeover so that it can be used for TDP + }, + "ONEXPLAYER G1 A": { + **ONEX_DEFAULT_CONF, + "name": "ONEXPLAYER G1 (AMD)", + "g1": True, + "protocol": "hid_v1_g1", + "turbo": True, # disable turbo takeover so that it can be used for TDP + }, +} + + +def get_default_config(product_name: str, manufacturer: str): + out = { + "name": product_name, + "manufacturer": manufacturer, + "hrtimer": True, + "untested": True, + "x1": "X1" in product_name, + } + + if "X1" in product_name and "mini" not in product_name.lower(): + out["rgb_secondary"] = True + + if "aokzoe" in manufacturer.lower(): + out["protocol"] = "none" + out["rgb"] = False + + return out diff --git a/src/hhd/device/oxp/controllers.yml b/src/hhd/device/oxp/controllers.yml new file mode 100644 index 00000000..f270cead --- /dev/null +++ b/src/hhd/device/oxp/controllers.yml @@ -0,0 +1,73 @@ +type: container +tags: [lgc] +title: OneXPlayer Controller +hint: >- + Allows for configuring your handheld's controller to a unified output. + +children: + controller_mode: + type: mode + tags: [controller_mode] + title: Controller Emulation + hint: >- + Emulate different controller types to fuse your device's features. + + extra_buttons: + type: multiple + tags: [ non-essential ] + title: "Keyboard and Turbo buttons are:" + default: combo_hhd + options: + oem: Keyboard, Combo + separate: Steam Menu, HHD + combo_hhd: Combo, HHD + combo: Combo, Combo + + swap_face: + type: bool + tags: [ non-essential ] + title: Swap View/Menu and Xbox/Kbd + default: False + + turbo_reboots: + type: bool + tags: [ non-essential ] + default: True + title: Holding Turbo Reboots + + volume_reverse: + type: bool + tags: [ non-essential ] + title: Reverse Volume Buttons + hint: >- + Reverse the volume buttons of the X1 style devices to match other tablets. + default: False + + # + # Common settings + # + imu: + type: bool + title: Motion Support + hint: >- + Enable gyroscope/accelerometer (IMU) support (.3% background CPU use) + default: True + + imu_hz: + type: discrete + title: Motion Hz + tags: [ non-essential ] + hint: >- + Sets the sampling frequency for the IMU. + options: [100, 200, 400, 800] + default: 100 + + imu_axis: + + nintendo_mode: + type: bool + title: Nintendo Mode (A-B Swap) + tags: [ non-essential ] + hint: >- + Swaps A with B and X with Y. + default: False diff --git a/src/hhd/device/oxp/hid_v1.py b/src/hhd/device/oxp/hid_v1.py new file mode 100644 index 00000000..d04a77f9 --- /dev/null +++ b/src/hhd/device/oxp/hid_v1.py @@ -0,0 +1,338 @@ +import logging +import time +from collections import deque +from typing import Literal + +from hhd.controller import can_read +from hhd.controller.physical.hidraw import GenericGamepadHidraw + +logger = logging.getLogger(__name__) + + +def gen_cmd(cid: int, cmd: bytes | list[int] | str, idx: int = 0x01, size: int = 64): + # Command: [idx, cid, 0x3f, *cmd, 0x3f, cid], idx is optional + if isinstance(cmd, str): + c = bytes.fromhex(cmd) + else: + c = bytes(cmd) + base = bytes([cid, 0x3F, idx, *c]) + return base + bytes([0] * (size - len(base) - 2)) + bytes([0x3F, cid]) + + +def gen_rgb_mode(mode: str): + mc = 0 + match mode: + case "monster_woke": + mc = 0x0D + case "flowing": + mc = 0x03 + case "sunset": + mc = 0x0B + case "neon": + mc = 0x05 + case "dreamy": + mc = 0x07 + case "cyberpunk": + mc = 0x09 + case "colorful": + mc = 0x0C + case "aurora": + mc = 0x01 + case "sun": + mc = 0x08 + return gen_cmd(0xB8, [mc, 0x00, 0x02]) + + +gen_intercept = lambda enable: gen_cmd(0xB2, [0x03 if enable else 0x00, 0x01, 0x02]) + + +def gen_brightness( + side: Literal[0, 3, 4], + enabled: bool, + brightness: Literal["low", "medium", "high"], +): + match brightness: + case "low": + bc = 0x01 + case "medium": + bc = 0x03 + case _: # "high": + bc = 0x04 + + return gen_cmd(0xB8, [0xFD, 0x00, 0x02, enabled, 0x05, bc]) + +# Sides on the g1 +# 1 = left controller +# 2 = right controller +# 3 = center V +# 4 = touch keyboard +# 5 = device color on the front (triangle) +def gen_rgb_solid(r, g, b, side: int = 0x00): + return gen_cmd(0xB8, [0xFE, side, 0x02] + 18 * [r, g, b] + [r, g]) + + +KBD_NAME = "keyboard" +HOME_NAME = "guide" +KBD_NAME_NON_TURBO = "share" +KBD_HOLD = 0.12 +OXP_BUTTONS = { + 0x24: KBD_NAME, + 0x21: HOME_NAME, + 0x22: "extra_l1", + 0x23: "extra_r1", +} + + +INITIALIZE = [ + gen_cmd( + 0xB4, + "0238020101010101000000020102000000030103000000040104000000050105000000060106000000070107000000080108000000090109000000", + ), + gen_cmd( + 0xB4, + "02380202010a010a0000000b010b0000000c010c0000000d010d0000000e010e0000000f010f000000100110000000220200000000230200000000", + ), + gen_intercept(False), +] + +INIT_DELAY = 4 +CONNECT_DELAY = 0.3 +WRITE_DELAY = 0.05 +SCAN_DELAY = 1 + +_init_done = False + + +class OxpHidraw(GenericGamepadHidraw): + def __init__(self, *args, turbo: bool = True, g1: bool = False, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.prev = {} + self.queue_kbd = None + self.queue_home = None + self.queue_cmd = deque(maxlen=10) + self.next_send = 0 + self.queue_led = None + self.turbo = turbo + + self.g1 = g1 + self.send_init = not g1 # g1 has no extra buttons + self.prev_brightness = None + self.prev_stick = None + self.prev_stick_enabled = None + # self.prev_center = None + # self.prev_center_enabled = None + + def open(self): + a = super().open() + self.queue_kbd = None + self.queue_home = None + self.prev = {} + + global _init_done + self.next_send = time.perf_counter() + CONNECT_DELAY + if self.send_init: + if not _init_done: + self.next_send = time.perf_counter() + INIT_DELAY + self.queue_cmd.extend(INITIALIZE) + # Setting the mappings is a bit aggressive and causes the device + # to flash its leds. Only do it during boot. + _init_done = True + else: + self.queue_cmd.append(gen_intercept(False)) + return a + + def consume(self, events): + if not self.dev: + return + + # Capture led events + for ev in events: + if ev["type"] == "led": + # if self.queue_led: + # logger.warning("OXP HID LED event queue overflow.") + self.queue_led = ev + + # Send queued event if applicable + curr = time.perf_counter() + if self.queue_cmd and curr - self.next_send > 0: + cmd = self.queue_cmd.popleft() + logger.info(f"OXP C: {cmd.hex()}") + self.dev.write(cmd) + self.next_send = curr + WRITE_DELAY + + # Queue needs to flush before switching to next event + # Also, there needs to be a led event to queue + if self.queue_cmd or not self.queue_led: + return + ev = self.queue_led + self.queue_led = None + + brightness = "high" + stick = None + stick_enabled = True + # center = None + # center_enabled = True + # init = ev["initialize"] + + match ev["mode"]: + case "solid": + stick = ev["red"], ev["green"], ev["blue"] + # r2, g2, b2 = ev["red2"], ev["green2"], ev["blue2"] + # center = r2, g2, b2 + # center_enabled = r2 > 10 or g2 > 10 or b2 > 10 + # case "duality": + # stick = ev["red"], ev["green"], ev["blue"] + # center = ev["red2"], ev["green2"], ev["blue2"] + case "oxp": + brightness = ev["brightnessd"] + stick = ev["oxp"] + if stick == "classic": + # Classic mode is a cherry red + stick = 0xB7, 0x30, 0x00 + # r2, g2, b2 = ev["red2"], ev["green2"], ev["blue2"] + # center = r2, g2, b2 + # center_enabled = r2 > 10 or g2 > 10 or b2 > 10 + # init = True + case _: # "disabled": + stick_enabled = False + # center_enabled = False + + # Force RGB to not initialize to workaround RGB breaking + # rumble when being set + if self.prev_stick_enabled is None: + self.prev_stick_enabled = stick_enabled + if self.prev_brightness is None: + self.prev_brightness = brightness + if self.prev_stick is None: + self.prev_stick = stick + + if ( + stick_enabled != self.prev_stick_enabled + or brightness != self.prev_brightness + ): + self.queue_cmd.append(gen_brightness(0, stick_enabled, brightness)) + self.prev_brightness = brightness + self.prev_stick_enabled = stick_enabled + + if stick_enabled and stick and stick != self.prev_stick: + if isinstance(stick, str): + self.queue_cmd.append(gen_rgb_mode(stick)) + else: + self.queue_cmd.append(gen_rgb_solid(*stick, side=0x00)) + self.prev_stick = stick + self.prev_brightness = brightness + self.prev_stick_enabled = stick_enabled + + # if center_enabled != self.prev_center_enabled: + # self.queue_cmd.append(gen_brightness(0x03, center_enabled, "high")) + # self.queue_cmd.append(gen_brightness(0x04, center_enabled, "high")) + # self.prev_center_enabled = center_enabled + + # # Only apply center colors on init on init + # if init and center_enabled and center and center != self.prev_center: + # self.queue_cmd.append(gen_rgb_solid(*center, side=0x03)) + # self.queue_cmd.append(gen_rgb_solid(*center, side=0x04)) + # self.prev_center = center + + def produce(self, fds): + if not self.dev: + return [] + + evs = [] + # A bit unclean with 2 buttons but it works + if self.queue_kbd: + curr = time.perf_counter() + if curr - KBD_HOLD > self.queue_kbd: + evs = [ + { + "type": "button", + "code": KBD_NAME if self.turbo else KBD_NAME_NON_TURBO, + "value": False, + } + ] + self.queue_kbd = None + if self.queue_home: + curr = time.perf_counter() + if curr - KBD_HOLD > self.queue_home: + evs = [ + { + "type": "button", + "code": HOME_NAME, + "value": False, + } + ] + self.queue_home = None + + if self.fd not in fds: + return evs + + while can_read(self.fd): + cmd = self.dev.read() + # logger.info(f"OXP R: {cmd.hex()}") + + cid = cmd[0] + valid = cmd[1] == 0x3F and cmd[-2] == 0x3F + + if not valid: + logger.warning(f"OXP HID invalid command: {cmd.hex()}") + continue + + if cid in (0xF5, 0xB8): + # Initialization (0xf5) and rgb (0xb8) command responses, skip + continue + + if cid != 0xB2: + logger.warning(f"OXP HID unknown command: {cmd.hex()}") + continue + + btn = cmd[6] + + if btn not in OXP_BUTTONS: + logger.warning( + f"OXP HID unknown button: {btn:x} from cmd:\n{cmd.hex()}" + ) + continue + + btn = OXP_BUTTONS[btn] + pressed = cmd[12] == 1 + + if btn == KBD_NAME: + if pressed and (btn not in self.prev or self.prev[btn] != pressed): + evs.append( + { + "type": "button", + "code": KBD_NAME if self.turbo else KBD_NAME_NON_TURBO, + "value": True, + } + ) + self.queue_kbd = time.perf_counter() + self.prev[btn] = pressed + continue + + if btn == HOME_NAME: + if pressed and (btn not in self.prev or self.prev[btn] != pressed): + evs.append( + { + "type": "button", + "code": HOME_NAME, + "value": True, + } + ) + self.queue_home = time.perf_counter() + self.prev[btn] = pressed + continue + + if btn in self.prev and self.prev[btn] == pressed: + # Debounce + continue + + self.prev[btn] = pressed + evs.append( + { + "type": "button", + "code": btn, + "value": pressed, + } + ) + + return evs diff --git a/src/hhd/device/oxp/hid_v2.py b/src/hhd/device/oxp/hid_v2.py new file mode 100644 index 00000000..ed503d1c --- /dev/null +++ b/src/hhd/device/oxp/hid_v2.py @@ -0,0 +1,293 @@ +import logging +import time +from collections import deque +from typing import Literal + +from hhd.controller import can_read +from hhd.controller.physical.hidraw import GenericGamepadHidraw + +logger = logging.getLogger(__name__) + + +def gen_cmd(cid: int, cmd: bytes | list[int] | str, size: int = 64): + # Command: [idx, cid, 0x3f, *cmd, 0x3f, cid], idx is optional + if isinstance(cmd, str): + c = bytes.fromhex(cmd) + else: + c = bytes(cmd) + base = bytes([cid, 0xFF, *c]) + return base + bytes([0] * (size - len(base))) + + +def gen_rgb_mode(mode: str): + mc = 0 + match mode: + case "monster_woke": + mc = 0x0D + case "flowing": + mc = 0x03 + case "sunset": + mc = 0x0B + case "neon": + mc = 0x05 + case "dreamy": + mc = 0x07 + case "cyberpunk": + mc = 0x09 + case "colorful": + mc = 0x0C + case "aurora": + mc = 0x01 + case "sun": + mc = 0x08 + return gen_cmd(0x07, [mc]) + + +gen_intercept = lambda enable: gen_cmd(0xB2, [0x03 if enable else 0x00, 0x01, 0x02]) + + +def gen_brightness( + enabled: bool, + brightness: Literal["low", "medium", "high"], +): + match brightness: + case "low": + bc = 0x01 + case "medium": + bc = 0x03 + case _: # "high": + bc = 0x04 + + return gen_cmd(0x07, [0xFD, enabled, 0x05, bc]) + + +def gen_rgb_solid(r, g, b): + return gen_cmd(0x07, [0xFE] + 20 * [r, g, b] + [0x00]) + + +KBD_NAME = "keyboard" +HOME_NAME = "guide" +KBD_NAME_NON_TURBO = "share" +KBD_HOLD = 0.2 +OXP_BUTTONS = { + 0x24: KBD_NAME, + 0x21: HOME_NAME, + 0x22: "extra_l1", + 0x23: "extra_r1", +} + + +INITIALIZE = [ + # gen_cmd( + # 0xF5, + # "010238020101010101000000020102000000030103000000040104000000050105000000060106000000070107000000080108000000090109000000", + # ), + # gen_cmd( + # 0xF5, + # "0102380202010a010a0000000b010b0000000c010c0000000d010d0000000e010e0000000f010f000000100110000000220200000000230200000000", + # ), + # gen_intercept(False), +] + +INIT_DELAY = 4 +WRITE_DELAY = 0.05 +SCAN_DELAY = 1 + + +class OxpHidrawV2(GenericGamepadHidraw): + def __init__(self, *args, turbo: bool = True, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.prev = {} + self.queue_kbd = None + self.queue_home = None + self.queue_cmd = deque(maxlen=10) + self.next_send = 0 + self.queue_led = None + self.turbo = turbo + + self.prev_brightness = None + self.prev_stick = None + self.prev_stick_enabled = None + # self.prev_center = None + # self.prev_center_enabled = None + + def open(self): + a = super().open() + self.queue_kbd = None + self.queue_home = None + self.prev = {} + self.next_send = time.perf_counter() + INIT_DELAY + + self.queue_cmd.extend(INITIALIZE) + return a + + def consume(self, events): + if not self.dev: + return + + # Capture led events + for ev in events: + if ev["type"] == "led": + # if self.queue_led: + # logger.warning("OXP HID LED event queue overflow.") + self.queue_led = ev + + # Send queued event if applicable + curr = time.perf_counter() + if self.queue_cmd and curr - self.next_send > 0: + cmd = self.queue_cmd.popleft() + logger.info(f"OXP C: {cmd.hex()}") + self.dev.write(cmd) + self.next_send = curr + WRITE_DELAY + + # Queue needs to flush before switching to next event + # Also, there needs to be a led event to queue + if self.queue_cmd or not self.queue_led: + return + ev = self.queue_led + self.queue_led = None + + brightness = "high" + stick = None + stick_enabled = True + + match ev["mode"]: + case "solid": + stick = ev["red"], ev["green"], ev["blue"] + case "oxp": + brightness = ev["brightnessd"] + stick = ev["oxp"] + if stick == "classic": + # Classic mode is a cherry red + stick = 0xb7, 0x30, 0x00 + case _: # "disabled": + stick_enabled = False + + # Force RGB to not initialize to workaround RGB breaking + # rumble when being set + if self.prev_stick_enabled is None: + self.prev_stick_enabled = stick_enabled + if self.prev_brightness is None: + self.prev_brightness = brightness + if self.prev_stick is None: + self.prev_stick = stick + + if ( + stick_enabled != self.prev_stick_enabled + or brightness != self.prev_brightness + ): + self.queue_cmd.append(gen_brightness(stick_enabled, brightness)) + self.prev_brightness = brightness + self.prev_stick_enabled = stick_enabled + + if stick_enabled and stick != self.prev_stick: + if isinstance(stick, str): + self.queue_cmd.append(gen_rgb_mode(stick)) + else: + self.queue_cmd.append(gen_rgb_solid(*stick)) + self.prev_stick = stick + self.prev_brightness = brightness + self.prev_stick_enabled = stick_enabled + + def produce(self, fds): + if not self.dev: + return [] + + evs = [] + # A bit unclean with 2 buttons but it works + if self.queue_kbd: + curr = time.perf_counter() + if curr - KBD_HOLD > self.queue_kbd: + evs = [ + { + "type": "button", + "code": KBD_NAME if self.turbo else KBD_NAME_NON_TURBO, + "value": False, + } + ] + self.queue_kbd = None + if self.queue_home: + curr = time.perf_counter() + if curr - KBD_HOLD > self.queue_home: + evs = [ + { + "type": "button", + "code": HOME_NAME, + "value": False, + } + ] + self.queue_home = None + + if self.fd not in fds: + return evs + + while can_read(self.fd): + cmd = self.dev.read() + # logger.info(f"OXP R: {cmd.hex()}") + + cid = cmd[0] + valid = cmd[1] == 0x3F and cmd[-2] == 0x3F + + if not valid: + logger.warning(f"OXP HID invalid command: {cmd.hex()}") + continue + + if cid in (0xF5, 0xB8): + # Initialization (0xf5) and rgb (0xb8) command responses, skip + continue + + if cid != 0xB2: + logger.warning(f"OXP HID unknown command: {cmd.hex()}") + continue + + btn = cmd[6] + + if btn not in OXP_BUTTONS: + logger.warning( + f"OXP HID unknown button: {btn:x} from cmd:\n{cmd.hex()}" + ) + continue + + btn = OXP_BUTTONS[btn] + pressed = cmd[12] == 1 + + if btn == KBD_NAME: + if pressed and (btn not in self.prev or self.prev[btn] != pressed): + evs.append( + { + "type": "button", + "code": KBD_NAME if self.turbo else KBD_NAME_NON_TURBO, + "value": True, + } + ) + self.queue_kbd = time.perf_counter() + self.prev[btn] = pressed + continue + + if btn == HOME_NAME: + if pressed and (btn not in self.prev or self.prev[btn] != pressed): + evs.append( + { + "type": "button", + "code": HOME_NAME, + "value": True, + } + ) + self.queue_home = time.perf_counter() + self.prev[btn] = pressed + continue + + if btn in self.prev and self.prev[btn] == pressed: + # Debounce + continue + + self.prev[btn] = pressed + evs.append( + { + "type": "button", + "code": btn, + "value": pressed, + } + ) + + return evs diff --git a/src/hhd/device/oxp/serial.py b/src/hhd/device/oxp/serial.py new file mode 100644 index 00000000..6bfd01dd --- /dev/null +++ b/src/hhd/device/oxp/serial.py @@ -0,0 +1,412 @@ +import logging +import os +import select +import subprocess +import time +from collections import deque +from typing import Literal + +from hhd.controller.base import Consumer, Producer + +logger = logging.getLogger(__name__) + + +def gen_cmd( + cid: int, cmd: bytes | list[int] | str, idx: int | None = None, size: int = 64 +): + # Command: [idx, cid, 0x3f, *cmd, 0x3f, cid], idx is optional + if isinstance(cmd, str): + c = bytes.fromhex(cmd) + else: + c = bytes(cmd) + base = bytes([cid, 0x3F, *c]) + if idx is not None: + base = bytes([idx]) + base + return base + bytes([0] * (size - len(base) - 2)) + bytes([0x3F, cid]) + + +def gen_rgb_mode(mode: str): + mc = 0 + match mode: + case "monster_woke": + mc = 0x0D + case "flowing": + mc = 0x03 + case "sunset": + mc = 0x0B + case "neon": + mc = 0x05 + case "dreamy": + mc = 0x07 + case "cyberpunk": + mc = 0x09 + case "colorful": + mc = 0x0C + case "aurora": + mc = 0x01 + case "sun": + mc = 0x08 + return gen_cmd(0xFD, [0x00, mc]) + + +gen_intercept = lambda enable: gen_cmd(0xA1, 2 * [int(enable)], idx=0x00) + + +def gen_brightness( + side: Literal[0, 3, 4], + enabled: bool, + brightness: Literal["low", "medium", "high"], +): + match brightness: + case "low": + bc = 0x01 + case "medium": + bc = 0x03 + case _: # "high": + bc = 0x04 + + return gen_cmd( + 0xFD, [side, 0xFD, 0x00 if side else 0x03, 0x00, int(enabled), 0x05, bc] + ) + + +def gen_rgb_solid(r, g, b, side: Literal[0x00, 0x03, 0x04] = 0x00): + start = [side, 0xFE, 0x00, 0x00] + end = [r, g] + return gen_cmd(0xFD, start + 18 * [r, g, b] + end) + + +KBD_NAME = "keyboard" +KBD_NAME_NON_TURBO = "share" +KBD_HOLD = 0.12 +OXP_BUTTONS = { + 0x24: KBD_NAME, + 0x22: "extra_l1", + 0x23: "extra_r1", +} + + +INITIALIZE = [ + # gen_intercept(True), + gen_cmd( + 0xF5, + "0000000001010101000000020102000000030103000000040104000000050105000000060106000000070107000000080108000000090109000000", + idx=0x01, + ), + gen_cmd( + 0xF5, + "00000000010a010a0000000b010b0000000c010c0000000d010d0000000e010e0000000f010f000000100110000000220200000000230200000000", + idx=0x02, + ), + # gen_intercept(False), # does not seem to be needed +] + +INIT_DELAY = 2 +WRITE_DELAY = 0.05 +SCAN_DELAY = 1 + +_mappings_init = True + + +def get_serial(): + + VID = "1a86" + PID = "7523" + + dev = None + buttons_only = False + for d in os.listdir("/dev"): + if not d.startswith("ttyUSB"): + continue + + path = os.path.join("/dev", d) + + out = subprocess.run( + ["udevadm", "info", "--name", path], + check=True, + capture_output=True, + text=True, + ) + + if f"ID_VENDOR_ID={VID}" not in out.stdout: + continue + + if f"ID_MODEL_ID={PID}" not in out.stdout: + continue + + dev = path + break + + for d in os.listdir("/dev"): + if not d.startswith("ttyS"): + continue + + path = os.path.join("/dev", d) + + out = subprocess.run( + ["udevadm", "info", "--name", path], + check=True, + capture_output=True, + text=True, + ) + + # OneXFly device is pnp + if "devices/pnp" not in out.stdout: + continue + + # TODO: We need to get a baseline to quirk this type properly + logger.info(f"Serial port information:\n{out.stdout}") + + dev = path + buttons_only = True + break + return dev, buttons_only + + +def init_serial(): + import serial + + dev, buttons_only = get_serial() + + if not dev: + logger.warning("OXP CH340 serial device not found.") + return None, buttons_only + + logger.info(f"OXP CH340 serial device found at {dev}") + + ser = serial.Serial( + dev, + 115200, + parity=serial.PARITY_EVEN, + stopbits=serial.STOPBITS_TWO, + bytesize=serial.EIGHTBITS, + timeout=0, + exclusive=True, + ) + + return ser, buttons_only + + +class SerialDevice(Consumer, Producer): + def __init__(self, required: bool = False, turbo: bool = True) -> None: + self.required = required + self.ser = None + self.buf = bytearray() + self.prev = {} + self.queue_kbd = None + self.queue_cmd = deque(maxlen=10) + self.last_sent = 0 + self.queue_led = None + self.turbo = turbo + self.buttons_only = False + + self.prev_brightness = None + self.prev_stick = None + self.prev_stick_enabled = None + self.prev_center = None + self.prev_center_enabled = None + + def open(self): + ser, self.buttons_only = init_serial() + if ser is None: + if self.required: + raise RuntimeError("OXP CH340 serial device not found.") + return [] + self.ser = ser + self.queue_kbd = None + self.prev = {} + + self.next_send = time.perf_counter() + INIT_DELAY + + global _mappings_init + if _mappings_init: + self.queue_cmd.extend(INITIALIZE) + _mappings_init = False + + return [ser.fd] + + def consume(self, events): + if not self.ser: + return + + # Capture led events + for ev in events: + if ev["type"] == "led": + # if self.queue_led: + # logger.warning("OXP CH340 LED event queue overflow.") + self.queue_led = ev + + # Send queued event if applicable + curr = time.perf_counter() + if self.queue_cmd and curr - self.last_sent > WRITE_DELAY: + cmd = self.queue_cmd.popleft() + logger.info(f"OXP C: {cmd.hex()}") + self.ser.write(cmd) + self.last_sent = time.perf_counter() + + # No LEDs, skip the rest + if self.buttons_only: + return + + # Queue needs to flush before switching to next event + # Also, there needs to be a led event to queue + if self.queue_cmd or not self.queue_led: + return + ev = self.queue_led + self.queue_led = None + + brightness = "high" + stick = None + stick_enabled = True + center = None + center_enabled = True + init = ev["initialize"] + + match ev["mode"]: + case "solid": + stick = ev["red"], ev["green"], ev["blue"] + r2, g2, b2 = ev["red2"], ev["green2"], ev["blue2"] + center = r2, g2, b2 + center_enabled = r2 > 10 or g2 > 10 or b2 > 10 + case "duality": + stick = ev["red"], ev["green"], ev["blue"] + center = ev["red2"], ev["green2"], ev["blue2"] + case "oxp": + brightness = ev["brightnessd"] + stick = ev["oxp"] + if stick == "classic": + # Classic mode is a cherry red + stick = 0xB7, 0x30, 0x00 + r2, g2, b2 = ev["red2"], ev["green2"], ev["blue2"] + center = r2, g2, b2 + center_enabled = r2 > 10 or g2 > 10 or b2 > 10 + init = True + case _: # "disabled": + stick_enabled = False + center_enabled = False + + # Force RGB to not initialize to workaround RGB breaking + # rumble when being set + if self.prev_stick_enabled is None: + self.prev_stick_enabled = stick_enabled + if self.prev_brightness is None: + self.prev_brightness = brightness + if self.prev_stick is None: + self.prev_stick = stick + + if ( + stick_enabled != self.prev_stick_enabled + or brightness != self.prev_brightness + ): + self.queue_cmd.append(gen_brightness(0, stick_enabled, brightness)) + self.prev_brightness = brightness + self.prev_stick_enabled = stick_enabled + + if stick_enabled and stick != self.prev_stick: + if isinstance(stick, str): + self.queue_cmd.append(gen_rgb_mode(stick)) + else: + self.queue_cmd.append(gen_rgb_solid(*stick, side=0x00)) + self.prev_stick = stick + self.prev_brightness = brightness + self.prev_stick_enabled = stick_enabled + + if center_enabled != self.prev_center_enabled: + self.queue_cmd.append(gen_brightness(0x03, center_enabled, "high")) + self.queue_cmd.append(gen_brightness(0x04, center_enabled, "high")) + self.prev_center_enabled = center_enabled + + # Only apply center colors on init on init + if init and center_enabled and center and center != self.prev_center: + self.queue_cmd.append(gen_rgb_solid(*center, side=0x03)) + self.queue_cmd.append(gen_rgb_solid(*center, side=0x04)) + self.prev_center = center + + def produce(self, fds): + if not self.ser: + return [] + + evs = [] + if self.queue_kbd: + curr = time.perf_counter() + if curr - KBD_HOLD > self.queue_kbd: + evs = [ + { + "type": "button", + "code": KBD_NAME if self.turbo else KBD_NAME_NON_TURBO, + "value": False, + } + ] + self.queue_kbd = None + + if self.ser.fd not in fds: + return evs + + CMD_LEN = 14 + + while out := self.ser.read(): + self.buf.extend(out) + + while len(self.buf) >= CMD_LEN: + # Align to start boundary + if self.buf[1] != 0x3F: + self.buf = self.buf[1:] + continue + + # Grab command id + cmd = self.buf[:CMD_LEN] + self.buf = self.buf[CMD_LEN:] + cid = cmd[0] + + valid = cmd[-2] == 0x3F and cmd[-1] == cid + if not valid: + logger.warning(f"OXP CH340 invalid command: {self.buf.hex()}") + continue + + if cid == 0xEF: + # Initialization command, skip + continue + + if cid != 0x1A: + logger.warning(f"OXP CH340 unknown command: {cmd.hex()}") + continue + + btn = cmd[2] + + if btn not in OXP_BUTTONS: + logger.warning( + f"OXP CH340 unknown button: {btn:x} from cmd:\n{cmd.hex()}" + ) + continue + + btn = OXP_BUTTONS[btn] + pressed = cmd[8] == 1 + + if btn == KBD_NAME: + if pressed and (btn not in self.prev or self.prev[btn] != pressed): + evs.append( + { + "type": "button", + "code": KBD_NAME if self.turbo else KBD_NAME_NON_TURBO, + "value": True, + } + ) + self.queue_kbd = time.perf_counter() + self.prev[btn] = pressed + continue + + # logger.info(f"OXP CH340 button: {btn} pressed: {pressed}") + if btn in self.prev and self.prev[btn] == pressed: + # Debounce + continue + + self.prev[btn] = pressed + evs.append( + { + "type": "button", + "code": btn, + "value": pressed, + } + ) + + return evs diff --git a/src/hhd/device/rog_ally/__init__.py b/src/hhd/device/rog_ally/__init__.py new file mode 100644 index 00000000..bc673161 --- /dev/null +++ b/src/hhd/device/rog_ally/__init__.py @@ -0,0 +1,117 @@ +from threading import Event, Thread +from typing import Any, Sequence + +from hhd.plugins import ( + Config, + Context, + Emitter, + HHDPlugin, + load_relative_yaml, + get_outputs_config, + get_limits_config, + fix_limits, +) +from hhd.plugins.settings import HHDSettings + + +class RogAllyControllersPlugin(HHDPlugin): + name = "rog_ally_controllers" + priority = 18 + log = "ally" + + def __init__(self, ally_x: bool = False) -> None: + self.t = None + self.should_exit = None + self.updated = Event() + self.started = False + self.t = None + self.ally_x = ally_x + + def open( + self, + emit: Emitter, + context: Context, + ): + self.emit = emit + self.context = context + self.prev = None + + def settings(self) -> HHDSettings: + from .base import LIMIT_DEFAULTS + + base = {"controllers": {"rog_ally": load_relative_yaml("controllers.yml")}} + base["controllers"]["rog_ally"]["children"]["controller_mode"].update( + get_outputs_config(can_disable=False) + ) + base["controllers"]["rog_ally"]["children"]["limits"] = get_limits_config( + LIMIT_DEFAULTS(self.ally_x) + ) + return base + + def update(self, conf: Config): + from .base import LIMIT_DEFAULTS + + fix_limits(conf, "controllers.rog_ally.limits", LIMIT_DEFAULTS(self.ally_x)) + + new_conf = conf["controllers.rog_ally"] + if new_conf == self.prev: + return + if self.prev is None: + self.prev = new_conf + else: + self.prev.update(new_conf.conf) + + self.updated.set() + self.start(self.prev) + + def start(self, conf): + from .base import plugin_run + + if self.started: + return + self.started = True + + self.close() + self.should_exit = Event() + self.t = Thread( + target=plugin_run, + args=( + conf, + self.emit, + self.context, + self.should_exit, + self.updated, + self.ally_x, + ), + ) + self.t.start() + + def close(self): + if not self.should_exit or not self.t: + return + self.should_exit.set() + self.t.join() + self.should_exit = None + self.t = None + + +def autodetect(existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: + if len(existing): + return existing + + # Match just product number, should be enough for now + with open("/sys/devices/virtual/dmi/id/product_name") as f: + # Different variants of the ally can have an additional _RC71L or not + dmi = f.read().strip() + + # First gen ally + # ROG Ally RC71L_Action or something else + if "ROG Ally RC71L" in dmi: + return [RogAllyControllersPlugin()] + + # Ally X + # ROG Ally X RC72LA_RC72LA_000123206 + if "ROG Ally X RC72LA" in dmi: + return [RogAllyControllersPlugin(ally_x=True)] + + return [] diff --git a/src/hhd/device/rog_ally/base.py b/src/hhd/device/rog_ally/base.py new file mode 100644 index 00000000..1bdc7124 --- /dev/null +++ b/src/hhd/device/rog_ally/base.py @@ -0,0 +1,518 @@ +import logging +import select +import time +from threading import Event as TEvent +from typing import Sequence + +from hhd.controller import DEBUG_MODE, Axis, Event, Multiplexer, can_read +from hhd.controller.lib.hide import unhide_all +from hhd.controller.physical.evdev import DINPUT_AXIS_POSTPROCESS, AbsAxis +from hhd.controller.physical.evdev import B as EC +from hhd.controller.physical.evdev import ( + GamepadButton, + GenericGamepadEvdev, + enumerate_evs, + to_map, +) +from hhd.controller.physical.hidraw import GenericGamepadHidraw, enumerate_unique +from hhd.controller.physical.imu import CombinedImu, HrtimerTrigger +from hhd.plugins import Config, Context, Emitter, get_limits, get_outputs + +from .const import config_rgb +from .hid import RgbCallback, switch_mode + +SELECT_TIMEOUT = 1 + +logger = logging.getLogger(__name__) + +ASUS_VID = 0x0B05 +ALLY_PID = 0x1ABE +ALLY_X_PID = 0x1B4C +GAMEPAD_VID = 0x045E +GAMEPAD_PID = 0x028E + +ALLY_MAPPINGS: dict[str, tuple[Axis, str | None, float, float | None]] = { + "accel_x": ("accel_x", "accel", 1, None), + "accel_y": ("accel_z", "accel", 1, None), + "accel_z": ("accel_y", "accel", -1, None), + "anglvel_x": ("gyro_x", "anglvel", 1, None), + "anglvel_y": ("gyro_z", "anglvel", 1, None), + "anglvel_z": ("gyro_y", "anglvel", -1, None), + "timestamp": ("imu_ts", None, 1, None), +} + +LIMIT_DEFAULTS = lambda allyx: { + "s_min": 0 if allyx else 5, + "s_max": 0x60 if allyx else 0x40, + "t_min": 5, + "t_max": 0x60 if allyx else 0x40, + # ally x vibration motor is too strong + "vibration": 50 if allyx else 100, +} + +MODE_DELAY = 0.15 +VIBRATION_DELAY = 0.1 +VIBRATION_ON: Event = { + "type": "rumble", + "code": "main", + "strong_magnitude": 0.5, + "weak_magnitude": 0.5, +} +VIBRATION_OFF: Event = { + "type": "rumble", + "code": "main", + "strong_magnitude": 0, + "weak_magnitude": 0, +} + +FIND_DELAY = 0.1 +ERROR_DELAY = 0.3 +LONGER_ERROR_DELAY = 3 +LONGER_ERROR_MARGIN = 1.3 + +# TODO: Work with upstream on the xpad (?) driver +# LB = BTN_WEST +# RB = BTN_Z +# X = BTN_C +# A = BTN_SOUTH +# B = BTN_EAST +# Y = BTN_NORTH +# Start (Menu) = BTN_TR +# Select (View) = BTN_TL +# RT = ABS_RZ +# LT = ABS_Z +# L3 = BTN_TL2 +# R3 = BTN_TR2 +ALLY_X_BUTTON_MAP: dict[int, GamepadButton] = to_map( + { + # Gamepad + "a": [EC("BTN_SOUTH")], + "b": [EC("BTN_EAST")], + "x": [EC("BTN_C")], + "y": [EC("BTN_NORTH")], + # Sticks + "ls": [EC("BTN_TL2")], + "rs": [EC("BTN_TR2")], + # Bumpers + "lb": [EC("BTN_WEST")], + "rb": [EC("BTN_Z")], + # Select + "start": [EC("BTN_TR")], + "select": [EC("BTN_TL")], + # Misc + # "mode": [EC("BTN_MODE")], + } +) + +ALLY_X_AXIS_MAP: dict[int, AbsAxis] = to_map( + { + # Sticks + # Values should range from -1 to 1 + "ls_x": [EC("ABS_X")], + "ls_y": [EC("ABS_Y")], + "rs_x": [EC("ABS_RX")], + "rs_y": [EC("ABS_RY")], + # Triggers + # Values should range from -1 to 1 + "rt": [EC("ABS_Z")], + "lt": [EC("ABS_RZ")], + # Hat, implemented as axis. Either -1, 0, or 1 + "hat_x": [EC("ABS_HAT0X")], + "hat_y": [EC("ABS_HAT0Y")], + } +) + + +class AllyHidraw(GenericGamepadHidraw): + def __init__(self, *args, kconf={}, rgb_boot, rgb_charging, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.kconf = kconf + self.mouse_mode = False + self.rgb_boot = rgb_boot + self.rgb_charging = rgb_charging + self.late_init = None + + def open(self) -> Sequence[int]: + self.queue: list[tuple[Event, float]] = [] + a = super().open() + if self.dev: + logger.info(f"Switching Ally Controller to gamepad mode.") + # Setup leds so they dont interfere after this + self.dev.write(config_rgb(self.rgb_boot, self.rgb_charging)) + switch_mode(self.dev, "default", self.kconf, first=True) + + self.mouse_mode = False + self.late_init = time.perf_counter() + return a + + def produce(self, fds: Sequence[int]) -> Sequence[Event]: + # If we can not read return + if not self.fd or not self.dev: + return [] + + # Process events + curr = time.perf_counter() + out: Sequence[Event] = [] + + # Remove up to one queued event + if len(self.queue): + ev, ofs = self.queue[0] + if ofs < curr: + out.append(ev) + self.queue.pop(0) + + # Force a re-init after 5 seconds in case the MCU did not get the message + # Hopefully this fixes the back buttons on the og ally. + if self.late_init and curr > self.late_init + 5: + logger.info(f"Re-initializing controller.") + self.late_init = None + if not self.mouse_mode: + switch_mode(self.dev, "default", self.kconf, first=True) + + # Read new events + while can_read(self.fd): + rep = self.dev.read(self.report_size) + # logger.warning(f"Received the following report (debug):\n{rep.hex()}") + if rep[0] != 0x5A: + continue + + match rep[1]: + case 0xA6: + # action = "left" + out.append({"type": "button", "code": "mode", "value": True}) + self.queue.append( + ( + {"type": "button", "code": "mode", "value": False}, + curr + MODE_DELAY, + ) + ) + case 0x38: + # action = "right" + out.append({"type": "button", "code": "share", "value": True}) + self.queue.append( + ( + {"type": "button", "code": "share", "value": False}, + curr + MODE_DELAY, + ) + ) + case 0xA7: + # right hold + # Mode switch + if self.mouse_mode: + switch_mode(self.dev, "default", self.kconf) + self.mouse_mode = False + out.append(VIBRATION_ON) + self.queue.append((VIBRATION_OFF, curr + VIBRATION_DELAY)) + else: + switch_mode(self.dev, "mouse", self.kconf) + self.mouse_mode = True + out.append(VIBRATION_ON) + self.queue.append((VIBRATION_OFF, curr + VIBRATION_DELAY)) + self.queue.append((VIBRATION_ON, curr + 2 * VIBRATION_DELAY)) + self.queue.append((VIBRATION_OFF, curr + 3 * VIBRATION_DELAY)) + case 0xA8: + # action = "right_hold_release" + pass # kind of useless + + return out + + +class AllyXHidraw(GenericGamepadHidraw): + def open(self) -> Sequence[int]: + super().open() + # Drop all events + return [] + + def consume(self, events: Sequence[Event]) -> None: + if not self.dev: + return + + for ev in events: + if ev["type"] != "rumble": + continue + + if ev["code"] != "main": + logger.warning( + f"Received rumble event with unsupported side: {ev['code']}" + ) + continue + + "0d 0f 00 00 31 31 ff 00 eb" + cmd = bytes( + [ + 0x0D, + 0x0F, + 0x00, + 0x00, + min(100, int(ev["weak_magnitude"] * 100)), + min(100, int(ev["strong_magnitude"] * 100)), + 0xFF, + 0x00, + 0xEB, + ] + ) + self.dev.write(cmd) + + +def plugin_run( + conf: Config, + emit: Emitter, + context: Context, + should_exit: TEvent, + updated: TEvent, + ally_x: bool, +): + init = time.perf_counter() + repeated_fail = False + first = True + while not should_exit.is_set(): + try: + gamepad_devs = enumerate_evs(vid=GAMEPAD_VID) + nkey_devs = enumerate_unique(vid=ASUS_VID) + + if (not gamepad_devs and not ally_x) or not nkey_devs: + if first: + first = False + logger.warning(f"Ally controller not found, waiting...") + time.sleep(FIND_DELAY) + continue + + logger.info("Launching emulated controller.") + updated.clear() + init = time.perf_counter() + controller_loop(conf.copy(), should_exit, updated, emit, ally_x) + repeated_fail = False + except Exception as e: + first = True + failed_fast = init + LONGER_ERROR_MARGIN > time.perf_counter() + sleep_time = ( + LONGER_ERROR_DELAY if repeated_fail and failed_fast else ERROR_DELAY + ) + repeated_fail = failed_fast + logger.error(f"Received the following error:\n{type(e)}:") + + try: + import traceback + + traceback.print_exc() + except Exception: + pass + + logger.error( + f"Assuming controllers disconnected, restarting after {sleep_time}s." + ) + # Raise exception + if DEBUG_MODE: + raise e + time.sleep(sleep_time) + + # Unhide all devices before exiting + unhide_all() + + +def controller_loop( + conf: Config, should_exit: TEvent, updated: TEvent, emit: Emitter, ally_x: bool +): + debug = DEBUG_MODE + + # Output + d_producers, d_outs, d_params = get_outputs( + conf["controller_mode"], + None, + conf["imu"].to(bool), + emit=emit, + rgb_modes={ + "disabled": [], + "solid": ["color"], + "pulse": ["color", "speedd"], + "duality": ["dual", "speedd"], + "rainbow": ["brightnessd"], + "spiral": ["brightnessd", "speedd", "direction"], + }, + rgb_zones="quad", + ) + motion = d_params.get("uses_motion", True) + + # Imu + d_imu = CombinedImu(conf["imu_hz"].to(int), ALLY_MAPPINGS, gyro_scale="0.000266") + d_timer = HrtimerTrigger(conf["imu_hz"].to(int), [HrtimerTrigger.IMU_NAMES]) + + # Inputs + if ally_x: + d_xinput = GenericGamepadEvdev( + vid=[ASUS_VID], + pid=[ALLY_X_PID], + btn_map=ALLY_X_BUTTON_MAP, + axis_map=ALLY_X_AXIS_MAP, + # name=["Generic X-Box pad"], + capabilities={EC("EV_KEY"): [EC("BTN_A")]}, + required=True, + postprocess=DINPUT_AXIS_POSTPROCESS, + hide=True, + ) + d_allyx = AllyXHidraw( + vid=[ASUS_VID], + pid=[ALLY_X_PID], + usage_page=[0x0F], + usage=[0x21], + required=True, + ) + else: + d_xinput = GenericGamepadEvdev( + vid=[GAMEPAD_VID], + pid=[GAMEPAD_PID], + # name=["Generic X-Box pad"], + capabilities={EC("EV_KEY"): [EC("BTN_A")]}, + required=True, + hide=True, + postprocess={}, # remove calibration as its supported by the GUI + ) + d_allyx = None + + # Vendor + kconf = get_limits(conf["limits"], defaults=LIMIT_DEFAULTS(ally_x)) + d_vend = AllyHidraw( + vid=[ASUS_VID], + pid=[ALLY_PID, ALLY_X_PID], + usage_page=[0xFF31], + usage=[0x0080], + required=True, + rgb_boot=conf.get("rgb_boot", False), + rgb_charging=conf.get("rgb_charging", False), + callback=RgbCallback(), + kconf=kconf, + ) + + # Grab shortcut keyboards + d_kbd_1 = GenericGamepadEvdev( + vid=[ASUS_VID], + pid=[ALLY_PID, ALLY_X_PID], + capabilities={EC("EV_KEY"): [EC("KEY_F23")]}, + required=True, + grab=False, + btn_map={EC("KEY_F17"): "extra_l1", EC("KEY_F18"): "extra_r1"}, + ) + d_kbd_grabbed = False + + multiplexer = Multiplexer( + trigger="analog_to_discrete", + dpad="analog_to_discrete", + share_to_qam=True, + select_reboots=conf["select_reboots"].to(bool), + nintendo_mode=conf["nintendo_mode"].to(bool), + emit=emit, + swap_guide="select_is_guide" if conf["swap_armory"].to(bool) else None, + qam_no_release=not conf["swap_armory"].to(bool), + params=d_params, + ) + + REPORT_FREQ_MIN = 25 + REPORT_FREQ_MAX = 400 + + if motion: + REPORT_FREQ_MAX = max(REPORT_FREQ_MAX, conf["imu_hz"].to(float)) + + REPORT_DELAY_MAX = 1 / REPORT_FREQ_MIN + REPORT_DELAY_MIN = 1 / REPORT_FREQ_MAX + + fds = [] + devs = [] + fd_to_dev = {} + + def prepare(m): + devs.append(m) + fs = m.open() + fds.extend(fs) + for f in fs: + fd_to_dev[f] = m + + try: + d_vend.open() + prepare(d_xinput) + if d_allyx: + prepare(d_allyx) + if motion: + if d_timer.open(): + prepare(d_imu) + prepare(d_kbd_1) + for d in d_producers: + prepare(d) + + logger.info("Emulated controller launched, have fun!") + while not should_exit.is_set() and not updated.is_set(): + start = time.perf_counter() + # Add timeout to call consumers a minimum amount of times per second + r, _, _ = select.select(fds, [], [], REPORT_DELAY_MAX) + evs = [] + to_run = set() + for f in r: + to_run.add(id(fd_to_dev[f])) + + for d in devs: + if id(d) in to_run: + evs.extend(d.produce(r)) + evs.extend(d_vend.produce(r)) + + evs = multiplexer.process(evs) + if evs: + if debug: + logger.info(evs) + + d_vend.consume(evs) + d_xinput.consume(evs) + if d_allyx: + d_allyx.consume(evs) + + for d in d_outs: + d.consume(evs) + + if d_vend.mouse_mode and d_kbd_grabbed and d_kbd_1.dev: + try: + d_kbd_1.dev.ungrab() + except Exception: + pass + d_kbd_grabbed = False + elif not d_vend.mouse_mode and not d_kbd_grabbed and d_kbd_1.dev: + try: + d_kbd_1.dev.grab() + except Exception: + pass + d_kbd_grabbed = True + + # If unbounded, the total number of events per second is the sum of all + # events generated by the producers. + # For Legion go, that would be 100 + 100 + 500 + 30 = 730 + # Since the controllers of the legion go only update at 500hz, this is + # wasteful. + # By setting a target refresh rate for the report and sleeping at the + # end, we ensure that even if multiple fds become ready close to each other + # they are combined to the same report, limiting resource use. + # Ideally, this rate is smaller than the report rate of the hardware controller + # to ensure there is always a report from that ready during refresh + t = time.perf_counter() + elapsed = t - start + if elapsed < REPORT_DELAY_MIN: + time.sleep(REPORT_DELAY_MIN - elapsed) + + except KeyboardInterrupt: + raise + finally: + try: + d_vend.close(not updated.is_set()) + except Exception as e: + logger.error(f"Error while closing device '{d}' with exception:\n{e}") + if debug: + raise e + try: + d_timer.close() + except Exception as e: + logger.error(f"Error while closing device '{d}' with exception:\n{e}") + if debug: + raise e + for d in reversed(devs): + try: + d.close(not updated.is_set()) + except Exception as e: + logger.error(f"Error while closing device '{d}' with exception:\n{e}") + if debug: + raise e diff --git a/src/hhd/device/rog_ally/const.py b/src/hhd/device/rog_ally/const.py new file mode 100644 index 00000000..1ccefbb7 --- /dev/null +++ b/src/hhd/device/rog_ally/const.py @@ -0,0 +1,1219 @@ +def buf(x): + return bytes(x) + bytes(64 - len(x)) + + +FEATURE_KBD_DRIVER = 0x5A +FEATURE_KBD_APP = 0x5D +FEATURE_KBD_ID = FEATURE_KBD_APP + +xpad_mode_game = 0x01 +xpad_mode_wasd = 0x02 +xpad_mode_mouse = 0x03 + +xpad_cmd_set_mode = 0x01 +xpad_cmd_set_mapping = 0x02 +xpad_cmd_set_js_dz = 0x04 +xpad_cmd_set_tr_dz = 0x05 +xpad_cmd_set_vibe_intensity = 0x06 +xpad_cmd_check_ready = 0x0A +xpad_cmd_set_calibration = 0x0D +xpad_cmd_set_turbo = 0x0F +xpad_cmd_set_response_curve = 0x13 +xpad_cmd_set_adz = 0x18 + +xpad_axis_xy_left = 0x01 +xpad_axis_xy_right = 0x02 +xpad_axis_z_left = 0x03 +xpad_axis_z_right = 0x04 + +btn_pair_dpad_u_d = 0x01 +btn_pair_dpad_l_r = 0x02 +btn_pair_ls_rs = 0x03 +btn_pair_lb_rb = 0x04 +btn_pair_a_b = 0x05 +btn_pair_x_y = 0x06 +btn_pair_view_menu = 0x07 +btn_pair_m1_m2 = 0x08 +btn_pair_lt_rt = 0x09 + +btn_pair_side_left = 0x00 +btn_pair_side_right = 0x01 + +PAD_A = 0x01 +PAD_B = 0x02 +PAD_X = 0x03 +PAD_Y = 0x04 + +PAD_LB = 0x05 +PAD_RB = 0x06 + +PAD_LS = 0x07 +PAD_RS = 0x08 + +PAD_DPAD_UP = 0x09 +PAD_DPAD_DOWN = 0x0A +PAD_DPAD_LEFT = 0x0B +PAD_DPAD_RIGHT = 0x0C + +PAD_VIEW = 0x11 +PAD_MENU = 0x12 +PAD_XBOX = 0x13 + +MODE_GAME = buf([FEATURE_KBD_ID, 0xD1, xpad_cmd_set_mode, 0x01, xpad_mode_game]) + +MODE_MOUSE = buf( + [FEATURE_KBD_ID, 0xD1, xpad_cmd_set_mode, 0x01, xpad_mode_mouse] +) + +REMAP_DPAD_UD = buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_mapping, + btn_pair_dpad_u_d, + 0x2C, # Length, 44 + # + # Each btn_block is 11 bytes. + # + # Four btn_blocks: + # Button 1 + # Button 1 Secondary + # Button 2 + # Button 2 Secondary + # + # Key Groups + # 1 = Gamepad + # 2 = Keyboard + # 3 = Mouse + # 4 = Key combo, Keyboard + # 5 = Media + # + # btn_block start + 0x01, # Key Group + PAD_DPAD_UP, # Xbox Keycode + 0x00, # Keyboard Keycode + 0x00, # Media Keycode + 0x00, # Mouse Keycode + 0x00, # Combo length + 0x00, # Combo Keycode + 0x00, # Combo Keycode + 0x00, # Combo Keycode + 0x00, # Combo Keycode + 0x00, # Combo Keycode + # + # btn_block start + 0x05, + 0x00, + 0x00, + 0x19, # MEDIA_SHOW_KEYBOARD + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x01, + PAD_DPAD_DOWN, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x04, + 0x00, + 0x00, + 0x00, + 0x00, + 0x03, # Length + 0x8C, # KB_LCTL + 0x88, # KB_LSHIFT + 0x76, # KB_ESC + 0x00, + 0x00, + ] +) + +REMAP_DPAD_UD_MOUSE = buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_mapping, + btn_pair_dpad_u_d, + 0x2C, # Length, 44 + # btn_block start + 0x02, + 0x00, + 0x98, # KB_DOWN_ARROW + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x05, + 0x00, + 0x00, + 0x19, # MEDIA_SHOW_KEYBOARD + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x02, + 0x00, + 0x99, # KB_UP_ARROW + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x04, + 0x00, + 0x00, + 0x00, + 0x00, + 0x03, # Length + 0x8C, # KB_LCTL + 0x88, # KB_LSHIFT + 0x76, # KB_ESC + 0x00, + 0x00, + ] +) + +REMAP_DPAD_LR = buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_mapping, + btn_pair_dpad_l_r, + 0x2C, # Length, 44 + # btn_block start + 0x01, + PAD_DPAD_LEFT, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x04, + 0x00, + 0x00, + 0x00, + 0x00, + 0x02, # Length + 0x82, # KB_META + 0x23, # KB_D + 0x00, + 0x00, + 0x00, + # btn_block start + 0x01, + PAD_DPAD_RIGHT, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x04, + 0x00, + 0x00, + 0x00, + 0x00, + 0x02, # Length + 0x82, # KB_META + 0x0D, # KB_TAB + 0x00, + 0x00, + 0x00, + ] +) + +REMAP_DPAD_LR_MOUSE = buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_mapping, + btn_pair_dpad_l_r, + 0x2C, # Length, 44 + # btn_block start + 0x02, + 0x00, + 0x9A, # KB_LEFT_ARROW + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x04, + 0x00, + 0x00, + 0x00, + 0x00, + 0x02, # Length + 0x82, # KB_META + 0x23, # KB_D + 0x00, + 0x00, + 0x00, + # btn_block start + 0x02, + 0x00, + 0x9B, # KB_RIGHT_ARROW + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x04, + 0x00, + 0x00, + 0x00, + 0x00, + 0x02, # Length + 0x82, # KB_META + 0x0D, # KB_TAB + 0x00, + 0x00, + 0x00, + ] +) + +REMAP_JOYSTICKS = buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_mapping, + btn_pair_ls_rs, + 0x2C, # Length, 44 + # btn_block start + 0x01, + PAD_LS, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x01, + PAD_RS, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] +) + +REMAP_JOYSTICKS_MOUSE = buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_mapping, + btn_pair_ls_rs, + 0x2C, # Length, 44 + # btn_block start + 0x02, + 0x00, + 0x88, # KB_LSHIFT + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x03, + 0x00, + 0x00, + 0x00, + 0x01, # RAT_LCLICK + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] +) + +REMAP_SHOULDERS = buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_mapping, + btn_pair_lb_rb, + 0x2C, # Length, 44 + # btn_block start + 0x01, + PAD_LB, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x01, + PAD_RB, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] +) + +REMAP_SHOULDERS_MOUSE = buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_mapping, + btn_pair_lb_rb, + 0x2C, # Length, 44 + # btn_block start + 0x02, + 0x00, + 0x0D, # KB_TAB + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x03, + 0x00, + 0x00, + 0x00, + 0x01, # RAT_LCLICK + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] +) + +REMAP_AB = buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_mapping, + btn_pair_a_b, + 0x2C, # Length, 44 + # btn_block start + 0x01, + PAD_A, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x05, + 0x00, + 0x00, + 0x16, # MEDIA_SCREENSHOT + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x01, + PAD_B, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x04, + 0x00, + 0x00, + 0x00, + 0x00, + 0x02, # Length + 0x82, # KB_META + 0x31, # KB_N + 0x00, + 0x00, + 0x00, + ] +) + +REMAP_AB_MOUSE = buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_mapping, + btn_pair_a_b, + 0x2C, # Length, 44 + # btn_block start + 0x02, + 0x00, + 0x5A, # KB_RET + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x05, + 0x00, + 0x00, + 0x16, # MEDIA_SCREENSHOT + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x02, + 0x00, + 0x76, # KB_ESC + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x04, + 0x00, + 0x00, + 0x00, + 0x00, + 0x02, # Length + 0x82, # KB_META + 0x31, # KB_N + 0x00, + 0x00, + 0x00, + ] +) + +REMAP_XY = buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_mapping, + btn_pair_x_y, + 0x2C, # Length, 44 + # btn_block start + 0x01, + PAD_X, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x04, + 0x00, + 0x00, + 0x00, + 0x00, + 0x02, # Length + 0x82, # KB_META + 0x4D, # KB_P + 0x00, + 0x00, + 0x00, + # btn_block start + 0x01, + PAD_Y, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x05, + 0x00, + 0x00, + 0x1E, # MEDIA_START_RECORDING + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] +) + +REMAP_XY_MOUSE = buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_mapping, + btn_pair_x_y, + 0x2C, # Length, 44 + # btn_block start + 0x02, + 0x00, + 0x97, # KB_PGDWN + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x04, + 0x00, + 0x00, + 0x00, + 0x00, + 0x02, # Length + 0x82, # KB_META + 0x4D, # KB_P + 0x00, + 0x00, + 0x00, + # btn_block start + 0x02, + 0x00, + 0x96, # KB_PGUP + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x05, + 0x00, + 0x00, + 0x1E, # MEDIA_START_RECORDING + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] +) + +REMAP_VIEW_MENU = buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_mapping, + btn_pair_view_menu, + 0x2C, # Length, 44 + # btn_block start + 0x01, + PAD_VIEW, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x01, + PAD_MENU, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] +) + +REMAP_M1M2_DEFAULT = buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_mapping, + btn_pair_m1_m2, + 0x2C, # Length, 44 + # btn_block start + 0x02, + 0x00, + 0x8E, # KB_M2 + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x02, + 0x00, + 0x8E, # KB_M2 + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x02, + 0x00, + 0x8F, # KB_M1 + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x02, + 0x00, + 0x8F, # KB_M1 + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] +) + +REMAP_M1M2_F17F18 = buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_mapping, + btn_pair_m1_m2, + 0x2C, # Length, 44 + # btn_block start + 0x02, + 0x00, + 0x28, # F17? + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x02, + 0x00, + 0x30, # F18? + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] +) + +REMAP_TRIGGERS = buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_mapping, + btn_pair_lt_rt, + 0x2C, # Length, 44 + # btn_block start + 0x01, + 0x0D, # LT + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x01, + 0x0E, # RT + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] +) + +REMAP_TRIGGERS_MOUSE = buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_mapping, + btn_pair_lt_rt, + 0x2C, # Length, 44 + # btn_block start + 0x04, + 0x00, + 0x00, + 0x00, + 0x00, + 0x02, + 0x88, # KB_LSHIFT + 0x0D, # KB_TAB + 0x00, + 0x00, + 0x00, + # btn_block start + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x03, + 0x00, + 0x00, + 0x00, + 0x02, # RAT_RCLICK + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # btn_block start + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] +) + +WAIT_READY = buf([FEATURE_KBD_ID, 0xD1, xpad_cmd_check_ready, 0x01]) + +COMMIT_RESET = lambda kconf: [ + buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_turbo, + 0x20, # Length, 32 + # Turbo buttons go here. + # Unknown how they are laid out. + ] + ), + buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_vibe_intensity, + 0x02, # Length + kconf.get("vibration_left", kconf.get("vibration", 0x64)), + kconf.get("vibration_right", kconf.get("vibration", 0x64)), + ] + ), + buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_js_dz, + 0x04, # Length + kconf.get("ls_min", 0x00), # Left Inner + kconf.get("ls_max", 0x40), # Left Outer + kconf.get("rs_min", 0x00), # Right Inner + kconf.get("rs_max", 0x40), # Right Outer + ] + ), + buf( + [ + FEATURE_KBD_ID, + 0xD1, + xpad_cmd_set_tr_dz, + 0x04, # Length + kconf.get("lt_min", 0x00), # Left Inner + kconf.get("lt_max", 0x40), # Left Outer + kconf.get("rt_min", 0x00), # Right Inner + kconf.get("rt_max", 0x40), # Right Outer + ] + ), +] + +COMMANDS_GAME = lambda kconf: [ + MODE_GAME, + REMAP_DPAD_LR, + REMAP_DPAD_UD, + REMAP_JOYSTICKS, + REMAP_SHOULDERS, + REMAP_AB, + REMAP_XY, + REMAP_VIEW_MENU, + REMAP_M1M2_F17F18, + REMAP_TRIGGERS, + *COMMIT_RESET(kconf), +] + +COMMANDS_MOUSE = lambda kconf: [ + MODE_MOUSE, + REMAP_DPAD_UD_MOUSE, + REMAP_DPAD_LR_MOUSE, + REMAP_JOYSTICKS_MOUSE, + REMAP_SHOULDERS_MOUSE, + REMAP_AB_MOUSE, + REMAP_XY_MOUSE, + REMAP_VIEW_MENU, + REMAP_M1M2_F17F18, + REMAP_TRIGGERS_MOUSE, + *COMMIT_RESET(kconf), +] + +RGB_APPLY = buf([FEATURE_KBD_ID, 0xB4]) +RGB_SET = buf([FEATURE_KBD_ID, 0xB5]) + +RGB_PKEY_INIT = lambda key: [ + # buf([key, 0xB9]), # ? + buf( + [ + key, + 0x41, + 0x53, + 0x55, + 0x53, + 0x20, + 0x54, + 0x65, + 0x63, + 0x68, + 0x2E, + 0x49, + 0x6E, + 0x63, + 0x2E, + ] + ), + # buf([key, 0x05, 0x20, 0x31, 0x00, 0x08]), # ? + # Disable AURA Sync + # buf([key, 0xB7, 0x00]), +] + +RGB_INIT = RGB_PKEY_INIT(FEATURE_KBD_ID) + + +# RGB on when +# "5a d1 09 01 0f <- val bit" +# 0f: all on +# 00: all off +# 09 (08 + 01): boot/shutdown +# 02: awake +# 04: charging sleep +def config_rgb(boot: bool, charging: bool) -> bytes: + # Always while awake, users can toggle RGB settings for that + val = 0x02 + if boot: + val += 0x09 + if charging: + val += 0x04 + return buf( + [ + FEATURE_KBD_ID, + 0xD1, + 0x09, + 0x01, + val, + ] + ) + + +# Calibration +# 5a d0 is the main command group +# For left trigger ally executes: +# 5a d0 06 01 01 +# 5a d0 01 0c 00 repeatedly (ally responds with data) +# 5a d0 06 01 02 +# 5a d0 03 02 20 + +# Aura mode +# Init / turn off +# 5a c0 00 01 <- this might fix some issues with leds being stuck +# Turn on (after init) +# 5a bc +# Then commands +# 5ad1080cff0000ff0000ff0000ff0000 diff --git a/src/hhd/device/rog_ally/controllers.yml b/src/hhd/device/rog_ally/controllers.yml new file mode 100644 index 00000000..df5a42b8 --- /dev/null +++ b/src/hhd/device/rog_ally/controllers.yml @@ -0,0 +1,69 @@ +type: container +tags: [lgc] +title: Ally Controller +hint: >- + Allows for configuring the ROG Ally controllers to a unified output. + +children: + controller_mode: + type: mode + tags: [rog_controller_mode] + title: Controller Emulation + hint: >- + Emulate different controller types to fuse ROG features. + + # + # Common settings + # + + swap_armory: + type: bool + title: Swap ROG and Menu/View + tags: [non-essential] + hint: >- + Swaps the Armory Crate and Command center buttons with start and select. + default: False + + imu: + type: bool + title: Motion Support + hint: >- + Enable gyroscope/accelerometer (IMU) support (.3% background CPU use) + default: True + + imu_hz: + type: discrete + title: Motion Hz + tags: [non-essential] + hint: >- + Sets the sampling frequency for the IMU. + options: [100, 200, 400, 800] + default: 200 + + limits: + + select_reboots: + type: bool + tags: [non-essential] + title: Hold View to Reboot + default: False + + nintendo_mode: + type: bool + tags: [non-essential] + title: Nintendo Mode (A-B Swap) + hint: >- + Swaps A with B and X with Y. + default: False + + rgb_boot: + type: bool + tags: [advanced] + title: RGB During Boot + default: False + + rgb_charging: + type: bool + tags: [advanced] + title: RGB During Charging Asleep + default: False diff --git a/src/hhd/device/rog_ally/hid.py b/src/hhd/device/rog_ally/hid.py new file mode 100644 index 00000000..c8093c9f --- /dev/null +++ b/src/hhd/device/rog_ally/hid.py @@ -0,0 +1,370 @@ +import logging +import time +from typing import Literal, Sequence + +from hhd.controller import Event +from hhd.controller.base import RgbMode, DEBUG_MODE +from hhd.controller.lib.hid import Device + +from .const import ( + COMMANDS_GAME, + COMMANDS_MOUSE, + RGB_APPLY, + RGB_INIT, + RGB_SET, + WAIT_READY, + buf, +) + +Zone = Literal["all", "left_left", "left_right", "right_left", "right_right"] +GamepadMode = Literal["default", "mouse", "macro"] +Brightness = Literal["off", "low", "medium", "high"] + +logger = logging.getLogger(__name__) + + +def rgb_set_brightness(brightness: Brightness): + match brightness: + case "high": + c = 0x03 + case "medium": + c = 0x02 + case "low": + c = 0x01 + case _: + c = 0x00 + return buf([0x5A, 0xBA, 0xC5, 0xC4, c]) + + +def rgb_command( + zone: Zone, + mode: RgbMode, + direction, + speed: str, + red: int, + green: int, + blue: int, + o_red: int, + o_green: int, + o_blue: int, +): + c_direction = 0x00 + set_speed = True + + match mode: + case "solid": + # Static + c_mode = 0x00 + set_speed = False + case "pulse": + # Strobing + # c_mode = 0x0A + # Spiral is agressive + # Use breathing instead + # Breathing + c_mode = 0x01 + o_red = 0 + o_green = 0 + o_blue = 0 + case "rainbow": + # Color cycle + c_mode = 0x02 + case "spiral": + # Rainbow + c_mode = 0x03 + red = 0 + green = 0 + blue = 0 + if direction == "left": + c_direction = 0x01 + case "duality": + # Breathing + c_mode = 0x01 + # case "direct": + # # Direct/Aura + # c_mode = 0xFF + # Should be used for dualsense emulation/ambilight stuffs + case _: + c_mode = 0x00 + + c_speed = 0xE1 + if set_speed: + match speed: + case "low": + c_speed = 0xE1 + case "medium": + c_speed = 0xEB + case _: # "high" + c_speed = 0xF5 + + match zone: + case "left_left": + c_zone = 0x01 + case "left_right": + c_zone = 0x02 + case "right_left": + c_zone = 0x03 + case "right_right": + c_zone = 0x04 + case _: + c_zone = 0x00 + + return buf( + [ + 0x5A, + 0xB3, + c_zone, # zone + c_mode, # mode + red, + green, + blue, + c_speed if mode != "solid" else 0x00, + c_direction, + 0x00, # breathing + o_red, # these only affect the breathing mode + o_green, + o_blue, + ] + ) + + +def rgb_set( + side: str, + mode: RgbMode, + direction: str, + speed: str, + red: int, + green: int, + blue: int, + red2: int, + green2: int, + blue2: int, +): + match side: + case "left_left" | "left_right" | "right_left" | "right_right": + return [ + rgb_command( + side, mode, direction, speed, red, green, blue, red2, green2, blue2 + ), + ] + case "left": + return [ + rgb_command( + "left_left", + mode, + direction, + speed, + red, + green, + blue, + red2, + green2, + blue2, + ), + rgb_command( + "left_right", + mode, + direction, + speed, + red, + green, + blue, + red2, + green2, + blue2, + ), + ] + case "right": + return [ + rgb_command( + "right_right", + mode, + direction, + speed, + red, + green, + blue, + red2, + green2, + blue2, + ), + rgb_command( + "right_left", + mode, + direction, + speed, + red, + green, + blue, + red2, + green2, + blue2, + ), + ] + case _: + return [ + rgb_command( + "all", mode, direction, speed, red, green, blue, red2, green2, blue2 + ), + ] + + +INIT_EVERY_S = 10 + + +def process_events( + events: Sequence[Event], + prev_mode: str | None, + global_init=True, +): + cmds = [] + mode = None + br_cmd = None + init = False + for ev in events: + if ev["type"] == "led": + if ev["initialize"]: + init = True + if ev["mode"] == "disabled": + mode = "disabled" + br_cmd = rgb_set_brightness("off") + # Certain RGB modes (e.g., rainbow) do not support being set to + # off. So switch the mode to solid without a color. + cmds.extend( + rgb_set(ev["code"], "solid", "left", "low", 0, 0, 0, 0, 0, 0) + ) + else: + match ev["mode"]: + case "pulse": + mode = "pulse" + set_level = False + case "rainbow": + mode = "rainbow" + set_level = True + case "duality": + mode = "duality" + set_level = False + case "solid": + mode = "solid" + set_level = False + case "spiral": + mode = "spiral" + set_level = True + case _: + assert False, f"Mode '{ev['mode']}' not supported." + + if set_level: + br_cmd = rgb_set_brightness(ev["brightnessd"]) + else: + br_cmd = rgb_set_brightness("high") + + cmds.extend( + rgb_set( + ev["code"], + mode, + ev["direction"], + ev["speedd"], + ev["red"], + ev["green"], + ev["blue"], + ev["red2"], + ev["green2"], + ev["blue2"], + ) + ) + + if not mode or (not cmds and mode != "disabled"): + # Avoid sending init commands without a mode. + # The exception being the disabled mode, which just sets the led + # brightness. + return [], None + + # Set brightness once per update + if mode != prev_mode: + init = True + if not br_cmd: + br_cmd = rgb_set_brightness("high") + + if br_cmd: + cmds.insert(0, br_cmd) + + if init: + # Init should switch modes + cmds = [ + *cmds, + RGB_SET, + RGB_APPLY, + ] + + # For now, use init since with global init, the gamepad might + # not initialize properly + if global_init or init: + cmds = [ + *RGB_INIT, + *cmds, + ] + + return cmds, mode + + +class RgbCallback: + def __init__(self) -> None: + self.prev_mode = None + self.global_init = True + + def __call__(self, dev: Device, events: Sequence[Event]): + cmds, mode = process_events( + events, self.prev_mode, self.global_init + ) + self.global_init = False + if mode: + self.prev_mode = mode + if not cmds: + return + BCK = "\n" + if DEBUG_MODE: + logger.warning( + f"Running RGB commands:\n{BCK.join([cmd[:20].hex() for cmd in cmds])}" + ) + for r in cmds: + dev.write(r) + + +def wait_for_ready(dev: Device, timeout: int = 1): + # Wait for the device to be ready + start = time.perf_counter() + + while time.perf_counter() - start < timeout: + dev.send_feature_report(WAIT_READY) + # rep = dev.get_feature_report(FEATURE_KBD_APP) # this is not the proper + # way for this + # logger.warning(f"Ready: {rep.hex()}") + + # FIXME: Temporary disable since certain allys have issues with it + return False + + # if rep and rep[0] == 0x5A and rep[2] == 0x0A: + # return True + # else: + # time.sleep(0.1) + + logger.error("Ready timeout lapsed.") + return False + + +def switch_mode(dev: Device, mode: GamepadMode, kconf={}, first: bool = False): + match mode: + case "default": + cmds = COMMANDS_GAME(kconf) + # case "macro": + # cmds = MODE_MACRO + case "mouse": + cmds = COMMANDS_MOUSE(kconf) + case _: + assert False, f"Mode '{mode}' not supported." + + for cmd in cmds: + # wait_for_ready(dev, timeout=5 if first else 1) + first = False + # logger.warning(f"Write: {cmd.hex()}") + dev.write(cmd) diff --git a/src/hhd/http.py b/src/hhd/http.py deleted file mode 100644 index 455be20f..00000000 --- a/src/hhd/http.py +++ /dev/null @@ -1,252 +0,0 @@ -import json -import logging -from http.server import BaseHTTPRequestHandler, HTTPServer -from threading import Condition, Thread -from typing import Any, Mapping -from urllib.parse import parse_qs, urlparse - -from .plugins import Config, Emitter, HHDSettings - -logger = logging.getLogger(__name__) - -STANDARD_HEADERS = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Credentials": "true", - "WWW-Authenticate": "Bearer", -} - -ERROR_HEADERS = {**STANDARD_HEADERS, "Content-type": "text / plain"} -AUTH_HEADERS = ERROR_HEADERS -OK_HEADERS = {**STANDARD_HEADERS, "Content-type": "text / json"} - - -def parse_path(path: str) -> tuple[list, dict[str, list[str]]]: - try: - url = urlparse(path) - if url.path: - segments = url.path[1:].split("/") - else: - segments = [] - - params = {k: v for k, v in parse_qs(url.query).items() if v} - return segments, params - except Exception: - return [], {} - - -class RestHandler(BaseHTTPRequestHandler): - settings: HHDSettings - cond: Condition - conf: Config - profiles: Mapping[str, Config] - emit: Emitter - token: str | None - - def set_response(self, code: int, headers: dict[str, str] = {}): - self.send_response(code) - for title, head in headers.items(): - self.send_header(title, head) - self.end_headers() - - def is_authenticated(self): - if not self.token: - return True - - auth = self.headers["Authorization"] - if not auth: - return False - - if not isinstance(auth, str): - return False - - if not auth.lower().startswith("bearer "): - return False - - return auth.lower()[len("Bearer ") :] == self.token - - def send_authenticate(self): - if self.is_authenticated(): - return True - - self.set_response(401, {"Content-type": "text / plain"}) - self.wfile.write( - f"Handheld Daemon Error: Authentication is on and you did not supply the proper bearer token.".encode() - ) - - return False - - def send_json(self, data: Any): - self.set_response_ok() - self.wfile.write(json.dumps(data).encode()) - - def set_response_ok(self): - self.set_response(200, STANDARD_HEADERS) - - def send_not_found(self, error: str): - self.set_response(400, ERROR_HEADERS) - self.wfile.write(b"Handheld Daemon Error (404, invalid endpoint):\n") - self.wfile.write(error.encode()) - - def send_error(self, error: str): - self.set_response(404, ERROR_HEADERS) - self.wfile.write(b"Handheld Daemon Error:\n") - self.wfile.write(error.encode()) - - def handle_profile( - self, segments: list[str], params: dict[str, list[str]], content: Any | None - ): - if not segments: - return self.send_not_found( - f"No endpoint provided for '/profile/...', (e.g., list, get, set, apply)" - ) - - with self.cond: - match segments[0]: - case "list": - self.send_json(list(self.profiles)) - case "get": - if "profile" not in params: - return self.send_error(f"Profile not specified") - profile = params["profile"][0] - if profile not in self.profiles: - return self.send_error(f"Profile '{profile}' not found.") - self.send_json(self.profiles[profile].conf) - case "set": - if "profile" not in params: - return self.send_error(f"Profile not specified") - if not content: - return self.send_error(f"Data for the profile not sent.") - - profile = params["profile"][0] - self.emit( - {"type": "profile", "name": profile, "config": Config(content)} - ) - # Wait for the profile to be processed - self.cond.wait() - - # Return the profile - if profile in self.profiles: - self.send_json(self.profiles[profile].conf) - else: - self.send_error(f"Applied profile not found (race condition?).") - case "apply": - if "profile" not in params: - return self.send_error(f"Profile not specified") - - profiles = params["profile"] - for p in profiles: - if p not in self.profiles: - return self.send_error(f"Profile '{p}' not found.") - - self.emit([{"type": "apply", "name": p} for p in profiles]) - # Wait for the profile to be processed - self.cond.wait() - # Return the profile - self.send_json(self.conf.conf) - - def v1_endpoint(self, content: Any | None): - segments, params = parse_path(self.path) - if not segments: - return self.send_not_found(f"Empty path.") - - if segments[0] != "v1": - return self.send_not_found( - f"Only v1 endpoint is supported by this version of hhd (requested '{segments[0]}')." - ) - - if len(segments) == 1: - return self.send_not_found(f"No command provided") - - command = segments[1].lower() - match command: - case "profile": - self.handle_profile(segments[2:], params, content) - case "settings": - self.set_response_ok() - with self.cond: - self.wfile.write(json.dumps(self.settings).encode()) - case "state": - self.set_response_ok() - with self.cond: - if content: - self.emit({"type": "state", "config": Config(content)}) - self.cond.wait() - self.wfile.write(json.dumps(self.conf.conf).encode()) - case other: - self.send_not_found(f"Command '{other}' not supported.") - - def do_GET(self): - if not self.send_authenticate(): - return - - self.v1_endpoint(None) - - def do_POST(self): - if not self.send_authenticate(): - return - - content_length = int(self.headers["Content-Length"]) - content = self.rfile.read(content_length) - try: - content_json = json.loads(content) - except Exception as e: - return self.send_error( - f"Parsing the POST content as json failed with the following error:\n{e}" - ) - self.v1_endpoint(content_json) - - def log_message(self, format: str, *args: Any) -> None: - pass - - -class HHDHTTPServer: - def __init__( - self, - localhost: bool, - port: int, - token: str | None, - ) -> None: - self.localhost = localhost - self.port = port - - # Have to subclass to create closure - class NewRestHandler(RestHandler): - pass - - cond = Condition() - NewRestHandler.cond = cond - NewRestHandler.token = token - self.cond = cond - self.handler = NewRestHandler - self.https = None - self.t = None - - def update( - self, - settings: HHDSettings, - conf: Config, - profiles: Mapping[str, Config], - emit: Emitter, - ): - with self.cond: - self.handler.settings = settings - self.handler.conf = conf - self.handler.profiles = profiles - self.handler.emit = emit - self.cond.notify_all() - - def open(self): - self.https = HTTPServer( - ("127.0.0.1" if self.localhost else "", self.port), self.handler - ) - self.t = Thread(target=self.https.serve_forever) - self.t.start() - - def close(self): - if self.https and self.t: - with self.cond: - self.cond.notify_all() - self.https.shutdown() - self.t.join() - self.https = None - self.t = None diff --git a/src/hhd/http/__init__.py b/src/hhd/http/__init__.py new file mode 100644 index 00000000..e5ea4809 --- /dev/null +++ b/src/hhd/http/__init__.py @@ -0,0 +1,4 @@ +from .api import HHDHTTPServer + + +__all__ = ["HHDHTTPServer"] diff --git a/src/hhd/http/api.py b/src/hhd/http/api.py new file mode 100644 index 00000000..0d664698 --- /dev/null +++ b/src/hhd/http/api.py @@ -0,0 +1,516 @@ +import itertools +import json +import logging +import os +import socket +from copy import deepcopy +from http.server import BaseHTTPRequestHandler, HTTPServer +from socketserver import ThreadingMixIn, TCPServer +from threading import Condition, Thread +from typing import Any, Mapping, Sequence, cast +from urllib.parse import parse_qs, urlparse + +from hhd.plugins import ( + Config, + Context, + Emitter, + HHDLocale, + HHDSettings, + get_relative_fn, + load_relative_yaml, +) + +from .i18n import get_user_lang, translate, translate_ver + +logger = logging.getLogger(__name__) + + +def sanitize_name(n: str): + import re + + return re.sub(r"[^ a-zA-Z0-9]+", "", n) + + +def sanitize_fn(n: str): + import re + + return re.sub(r"[^ a-zA-Z0-9\._/]+", "", n) + + +STANDARD_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "POST, GET, OPTIONS, DELETE", + "Access-Control-Allow-Headers": "*", + # "Access-Control-Expose-Headers": "*, Version", + "Access-Control-Max-Age": "86400", + "WWW-Authenticate": "Bearer", +} + +ERROR_HEADERS = {**STANDARD_HEADERS, "Content-type": "text/plain"} +AUTH_HEADERS = ERROR_HEADERS +OK_HEADERS = {**STANDARD_HEADERS, "Content-type": "application/json"} + +# https://en.wikipedia.org/wiki/List_of_Unicode_characters#Control_codes +_control_char_table = str.maketrans( + {c: rf"\x{c:02x}" for c in itertools.chain(range(0x20), range(0x7F, 0xA0))} +) +_control_char_table[ord("\\")] = r"\\" + +SECTIONS = load_relative_yaml("../sections.yml")["sections"] + + +def parse_path(path: str) -> tuple[list, dict[str, list[str]]]: + try: + url = urlparse(path) + if url.path: + segments = url.path[1:].split("/") + else: + segments = [] + + params = { + k: v for k, v in parse_qs(url.query, keep_blank_values=True).items() if v + } + return segments, params + except Exception: + return [], {} + + +class RestHandler(BaseHTTPRequestHandler): + settings: HHDSettings + cond: Condition + conf: Config + info: Config + profiles: Mapping[str, Config] + emit: Emitter + locales: Sequence[HHDLocale] + ctx: Context + user_lang: str | None + token: str | None + + def set_response(self, code: int, headers: dict[str, str] = {}): + # Allow skipping CORS by responding with specific origin + if og := self.headers.get("Origin", None): + headers = {**headers, "Access-Control-Allow-Origin": og} + self.send_response(code) + for title, head in headers.items(): + self.send_header(title, head) + self.end_headers() + + def do_OPTIONS(self): + self.set_response( + 204, + STANDARD_HEADERS, + ) + + def is_authenticated(self): + if not self.token: + return True + + auth = self.headers["Authorization"] + if not auth: + return False + + if not isinstance(auth, str): + return False + + if not auth.lower().startswith("bearer "): + return False + + return auth.lower()[len("Bearer ") :] == self.token + + def send_authenticate(self): + if self.is_authenticated(): + return True + + self.set_response(401, {"Content-type": "text/plain"}) + self.wfile.write( + f"Handheld Daemon Error: Authentication is on and you did not supply the proper bearer token.".encode() + ) + + return False + + def send_json(self, data: Any): + self.set_response_ok() + self.wfile.write(json.dumps(data).encode()) + + def set_response_ok(self, extra_headers={}): + self.set_response(200, {**OK_HEADERS, **extra_headers}) + + def send_not_found(self, error: str): + self.set_response(404, ERROR_HEADERS) + self.wfile.write(b"Handheld Daemon Error (404, invalid endpoint):\n") + self.wfile.write(error.encode()) + + def send_error_str(self, error: str): + self.set_response(400, ERROR_HEADERS) + self.wfile.write(b"Handheld Daemon Error:\n") + self.wfile.write(error.encode()) + + def send_error(self, *args, **kwargs): + if len(args) == 1: + return self.send_error_str(args[0]) + else: + for title, head in STANDARD_HEADERS.items(): + self.send_header(title, head) + return super().send_error(*args, **kwargs) + + def send_file(self, fn: str): + if not "." in fn: + return self.send_error(f"Invalid file: {fn}") + match fn[fn.rindex(".") :]: + case ".css": + ctype = "text/css" + case ".js": + ctype = "application/javascript" + case ".html" | ".htm" | ".php": + ctype = "text/html" + case other: + return self.send_error(f"File type '{other} of '{fn}' not supported.") + self.set_response(200, {**STANDARD_HEADERS, "Content-type": ctype}) + with open(get_relative_fn(fn), "rb") as f: + self.wfile.write(f.read()) + + def handle_profile( + self, segments: list[str], params: dict[str, list[str]], content: Any | None + ): + if not segments: + return self.send_not_found( + f"No endpoint provided for '/profile/...', (e.g., list, get, set, apply)" + ) + + with self.cond: + match segments[0]: + case "list": + self.send_json(list(self.profiles)) + case "get": + if "profile" not in params: + return self.send_error(f"Profile not specified") + profile = sanitize_name(params["profile"][0]) + if profile not in self.profiles: + return self.send_error(f"Profile '{profile}' not found.") + self.send_json(self.profiles[profile].conf) + case "set": + if "profile" not in params: + return self.send_error(f"Profile not specified") + if not content or not isinstance(content, Mapping): + return self.send_error(f"Data for the profile not sent.") + + profile = sanitize_name(params["profile"][0]) + self.emit( + {"type": "profile", "name": profile, "config": Config(content)} + ) + # Wait for the profile to be processed + self.cond.wait() + + # Return the profile + if profile in self.profiles: + self.send_json(self.profiles[profile].conf) + else: + self.send_error(f"Applied profile not found (race condition?).") + case "del": + if "profile" not in params: + return self.send_error(f"Profile not specified") + + profile = sanitize_name(params["profile"][0]) + if profile not in self.profiles: + return self.send_error(f"Profile '{profile}' not found.") + self.emit({"type": "profile", "name": profile, "config": None}) + # Wait for the profile to be processed + self.cond.wait() + + if profile in self.profiles: + self.send_error(f"Applied profile not found (race condition?).") + else: + self.set_response_ok() + case "apply": + if "profile" not in params: + return self.send_error(f"Profile not specified") + + profiles = [sanitize_name(p) for p in params["profile"]] + for p in profiles: + if p not in self.profiles: + return self.send_error(f"Profile '{p}' not found.") + + self.emit([{"type": "apply", "name": p} for p in profiles]) + # Wait for the profile to be processed + self.cond.wait() + # Return the profile + self.send_json(self.conf.conf) + case other: + self.send_not_found(f"Command 'profile/{other}' not supported.") + + def handle_image( + self, segments: list[str], params: dict[str, list[str]], content: Any | None + ): + if len(segments) < 2: + return self.send_not_found( + "Image data not provided. Syntax: /api/v1/image/{game}/{type}" + ) + + game = segments[0] + image_type = segments[1] + + img = self.emit.get_image(game, image_type) + if img is None: + return self.send_error(f"Image '{game}/{image_type}' not found.") + + if img.endswith(".jpg") or img.endswith(".jpeg"): + ctype = "image/jpeg" + elif img.endswith(".png"): + ctype = "image/png" + else: + return self.send_error(f"Image type '{img}' not supported.") + + if not os.path.exists(img): + return self.send_error(f"Image '{img}' not found.") + + self.set_response_ok(extra_headers={"Content-type": ctype}) + with open(img, "rb") as f: + self.wfile.write(f.read()) + + def v1_endpoint(self, content: Any | None): + segments, params = parse_path(self.path) + langs = params.get("lang", params.get("locale", None)) + lang = langs[0] if langs else None + + if not segments: + return self.send_not_found(f"Empty path.") + + if segments[0] != "api": + return self.send_not_found( + f"Only the API endpoint ('/api/v1') is supported for now." + ) + + if len(segments) < 2 or segments[1] != "v1": + return self.send_not_found( + f"Only v1 endpoint is supported by this version of hhd ('/api/v1')." + ) + + if len(segments) == 2: + return self.send_not_found(f"No command provided") + + command = segments[2].lower() + match command: + case "image" | "images": + self.handle_image(segments[3:], params, content) + case "profile": + self.handle_profile(segments[3:], params, content) + case "settings": + v = translate_ver(self.conf, lang=lang, user_lang=self.user_lang) + self.set_response_ok({"Version": v}) + with self.cond: + s = dict(deepcopy(self.settings)) + try: + s["hhd"]["version"] = { # type: ignore + "type": "version", + "tags": ["non-essential", "advanced", "expert", "hide"], + "value": v, + } + except Exception as e: + logger.error(f"Error while writing version hash to response.") + s = translate( + s, self.conf, self.locales, lang=lang, user_lang=self.user_lang + ) + self.wfile.write(json.dumps(s).encode()) + case "state": + self.set_response_ok() + with self.cond: + if content: + if not isinstance(content, Mapping): + return self.send_error( + f"State content should be a dictionary." + ) + self.emit({"type": "state", "config": Config(content)}) + self.cond.wait() + elif "poll" in params: + # Hang for the next update if the UI requests it. + self.cond.wait() + out = {**cast(dict, self.conf.conf), "info": self.info.conf} + out["version"] = translate_ver( + self.conf, lang=lang, user_lang=self.user_lang + ) + out = translate( + out, + self.conf, + self.locales, + lang=lang, + user_lang=self.user_lang, + ) + self.wfile.write(json.dumps(out).encode()) + case "version": + self.send_json({"version": 5}) + case "sections": + self.send_json( + translate( + SECTIONS, + self.conf, + self.locales, + lang=lang, + user_lang=self.user_lang, + ) + ) + case other: + self.send_not_found(f"Command '{other}' not supported.") + + def do_GET(self): + # Danger zone unauthenticated + # Be very careful + try: + path = sanitize_fn(urlparse(self.path).path) + if path.startswith("/"): + path = path[1:] + match path.split("/"): + case ["" | "index.html" | "index.php"]: + return self.send_file("./index.html") + case ["static", *other]: + return self.send_file(os.path.join("static", *other)) + case ["api", *other]: + if not self.send_authenticate(): + return + self.v1_endpoint(None) + case other: + return self.send_not_found(f"File not found:\n{path}") + except Exception as e: + logger.debug(f"Encountered error while serving unauthenticated request.") + try: + return self.send_error(f"Encountered error while serving request:\n{e}") + except Exception: + # Generated due to polling website going in the background + pass + + def do_POST(self): + if not self.send_authenticate(): + return + + content_length = int(self.headers["Content-Length"]) + content = self.rfile.read(content_length) + try: + content_json = json.loads(content) + except Exception as e: + return self.send_error( + f"Parsing the POST content as json failed with the following error:\n{e}" + ) + self.v1_endpoint(content_json) + + def log_message(self, format: str, *args: Any) -> None: + message = format % args + logger.error( + f"Received invalid request from '{self.address_string()}':\n{message.translate(_control_char_table)}" + ) + + def log_request(self, code="-", size="-"): + pass + + def __getattr__(self, val: str): + if not val.startswith("do_"): + raise AttributeError() + + logger.warning( + f"Received request type '{val[3:].translate(_control_char_table)}' from '{self.address_string()}'. Handling as GET." + ) + return self.do_GET + + +class ThreadingSimpleServer(ThreadingMixIn, HTTPServer): + pass + + +class UnixHTTPServer(ThreadingMixIn, TCPServer): + address_family = socket.AF_UNIX + + +class HHDHTTPServer: + def __init__( + self, + localhost: bool, + port: int, + token: str | None, + ) -> None: + self.localhost = localhost + self.port = port + cond = Condition() + + # Have to subclass to create closure + class NewRestHandler(RestHandler): + pass + + NewRestHandler.cond = cond + NewRestHandler.token = token + self.handler = NewRestHandler + + # Use another class for Unix handler without + # authentication since the file is root only + class NewUnixHandler(RestHandler): + pass + + NewUnixHandler.cond = cond + NewUnixHandler.token = None + self.uhandler = NewUnixHandler + + self.cond = cond + self.https = None + self.t = None + self.unix = None + self.tu = None + + def update( + self, + settings: HHDSettings, + conf: Config, + info: Config, + profiles: Mapping[str, Config], + emit: Emitter, + locales: Sequence[HHDLocale], + ctx: Context, + ): + with self.cond: + for handler in [self.handler, self.uhandler]: + handler.settings = settings + handler.conf = conf + handler.info = info + handler.profiles = profiles + handler.emit = emit + handler.locales = locales + handler.ctx = ctx + if not hasattr(self.handler, "user_lang"): + # Only load user lang once to avoid weirdness + self.handler.user_lang = get_user_lang(ctx) + self.uhandler.user_lang = self.handler.user_lang + self.cond.notify_all() + + def open(self): + self.https = ThreadingSimpleServer( + ("127.0.0.1" if self.localhost else "", self.port), self.handler + ) + self.t = Thread(target=self.https.serve_forever) + self.t.start() + + try: + if not os.path.exists("/run/hhd"): + os.mkdir("/run/hhd", 0o700) + else: + os.chmod("/run/hhd", 0o700) + if os.path.exists("/run/hhd/api"): + os.remove("/run/hhd/api") + self.unix = UnixHTTPServer("/run/hhd/api", self.uhandler) # type: ignore + self.tu = Thread(target=self.unix.serve_forever) + self.tu.start() + except Exception as e: + logger.error(f"Error starting server at '/run/hhd/api':\n{e}") + + def close(self): + if self.https and self.t: + with self.cond: + self.cond.notify_all() + self.https.shutdown() + self.t.join() + self.https = None + self.t = None + if self.unix and self.tu: + with self.cond: + self.cond.notify_all() + self.unix.shutdown() + self.tu.join() + self.unix = None + self.tu = None diff --git a/src/hhd/http/ctl.py b/src/hhd/http/ctl.py new file mode 100644 index 00000000..cb4222de --- /dev/null +++ b/src/hhd/http/ctl.py @@ -0,0 +1,240 @@ +import argparse +import json +import logging +import socket +import sys +from http.client import HTTPConnection + +logger = logging.getLogger(__name__) + +SOCKET_UNIX = "/run/hhd/api" +USAGE = """ +hhdctl [-h] [--sep SEP] [--values] {get,set,poll,track} [keys ...] + +Handheld Daemon CLI +This CLI is used to interact with Handheld Daemon (hhd) via its API. It requires +root access to connect to the UNIX socket at /run/hhd/api. The current version +allows for querying the state of Handheld Daemon and updating its values. + +Commands: + get: Get the current state of Handheld Daemon. If keys are provided, only + those keys are returned. Returned as KEY=VALUE pairs separated by \\n. + set: Update values in the current state. This call blocks until values are + updated and returns the new values. WARNING: the new values might not + be the ones that were set if they were rejected. Use None to remove a key. + poll: Same as get but will wait for the next Handheld Daemon event loop to + return. The loop runs every 2s or whenever an event is received. + track: Continuously track the provided values. Same as calling get and then + poll repeatedly. The separator between updates can be changed with --sep. + Default is \\n. track will print the values even if they did not change. + +Examples: + hhdctl get + hhdctl get/track/poll rgb.handheld.mode.mode + hhdctl set rgb.handheld.mode.mode=oxp + # For a single value, --values and --sep='' can be used to return the value + hhdctl get rgb.handheld.mode.mode --values --sep='' +""" + + +def _unroll_dict(d, prefix=""): + if isinstance(d, dict): + for k, v in d.items(): + if prefix: + k = f"{prefix}.{k}" + yield from _unroll_dict(v, k) + else: + yield prefix, d + + +def unroll_dict(d): + return dict(_unroll_dict(d)) + + +class UnixConnection(HTTPConnection): + def connect(self): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(SOCKET_UNIX) + + +def _request(*args, **kwargs): + con = UnixConnection(SOCKET_UNIX) + con.request(*args, **kwargs) + return con.getresponse() + + +def _get_state(poll: bool = False): + return _request("GET", f"/api/v1/state{'?poll' if poll else ''}") + + +def _set_state(state): + return _request("POST", f"/api/v1/state", body=json.dumps(state)) + + +def _get(keys, poll: bool = False, state=None, values: bool = False): + if state is not None: + state = _set_state(state) + else: + state = _get_state(poll) + + # Drop values in keys for ease of use + if keys: + keys = [k.split("=", 1)[0] for k in keys] + + if state.status != 200: + logger.error(f"Failed to get state with status: {state.status}") + return 2 + + out = "" + data = unroll_dict(json.loads(state.read())) + err = 0 + for k in keys or data: + if k not in data or data[k] is None: + # Eat none values as the purge happens after restart + logger.error(f"Key {k} not found in state") + err = 3 + continue + else: + vr = data[k] + + if isinstance(vr, bool): + v = "true" if vr else "false" + elif isinstance(vr, str): + # Escape newlines + v = vr.replace("\n", "\\n").replace("\t", "\\t") + else: + v = vr + + if values: + out += f"{v}\n" + else: + out += f"{k}={v}\n" + + sys.stdout.write(out) + sys.stdout.flush() + return err + + +def _track(keys, sep, values): + poll = False + while True: + _get(keys, poll=poll, values=values) + sys.stdout.write(sep) + sys.stdout.flush() + poll = True + + +def _set(keys, values): + if not keys: + logger.error("No keys provided to set") + return 1 + + state = {} + for key in keys: + try: + k, vr = key.split("=", 1) + except ValueError: + logger.error(f"Invalid KEY=VALUE: {key}") + return 1 + + if vr.lower() in ["true", "false"]: + v = vr.lower() == "true" + elif vr.lower() == "none": + v = None + elif vr.isdigit(): + v = int(vr) + elif vr.isnumeric(): + v = float(vr) + else: + v = vr + + state[k] = v + + return _get(keys, state=state, values=values) + + +def _main(): + logging.basicConfig( + level=logging.DEBUG, stream=sys.stderr, format="%(levelname)s - %(message)s" + ) + + parser = argparse.ArgumentParser( + usage=USAGE, + prog="hhdctl", + ) + + parser.add_argument( + "command", help="Command to execute", choices=["get", "set", "poll", "track"] + ) + parser.add_argument( + "keys", + help="Key(s) to get/set/track. Format: KEY=VAL for set and KEY for get, track. If not provided, get and track return all parameters.", + nargs="*", + ) + parser.add_argument( + "--sep", + help="Separator for updates in track", + default="\n", + ) + parser.add_argument( + "--values", + help="Hide the KEY= part in the response. The return value is 3 if a value is missing.", + action="store_true", + default=False, + ) + + args = parser.parse_args() + + match args.command: + case "get": + v = _get(args.keys, values=args.values) + case "set": + v = _set(args.keys, args.values) + case "track": + v = _track(args.keys, args.sep, args.values) + case "poll": + v = _get(args.keys, poll=True, values=args.values) + case _: + logger.error(f"Invalid command: '{args.command}'") + v = -1 + + if v is not None: + sys.exit(v) + + +def set_state(state): + res = _set_state(state) + if res.status != 200: + raise Exception(f"Failed to set state with status: {res.status}") + return json.loads(res.read()) + + +def get_state(poll: bool = False): + res = _get_state(poll) + if res.status != 200: + raise Exception(f"Failed to get state with status: {res.status}") + return json.loads(res.read()) + + +def main(): + try: + _main() + except KeyboardInterrupt: + sys.exit(0) + except PermissionError: + logger.error( + "Permission denied when trying to connect to the UNIX socket. Are you running as root?" + ) + sys.exit(1) + + +ALL = { + "set_state": set_state, + "get_state": get_state, + "unroll_dict": unroll_dict, + "main": main, +} + + +if __name__ == "__main__": + main() diff --git a/src/hhd/http/i18n.py b/src/hhd/http/i18n.py new file mode 100644 index 00000000..a35b735e --- /dev/null +++ b/src/hhd/http/i18n.py @@ -0,0 +1,115 @@ +import copy +import os +import subprocess +from gettext import GNUTranslations, find +from typing import Mapping, Sequence, cast + +from hhd.plugins import Config, Context, HHDLocale, HHDSettings + +_translations = {} + + +def get_user_lang(ctx: Context): + if not ctx: + return None + try: + out = subprocess.check_output( + ["sh", "-l", "-c", "locale"], + env={}, + user=ctx.euid, + group=ctx.egid, + ) + for ln in out.decode().split("\n"): + if "LANG" in ln: + return ln.strip().split("=")[-1] + # Alternative: + # return subprocess.check_output( + # ["sh", "-l", "-c", "echo $LANG"], + # env={}, + # user=ctx.euid, + # group=ctx.egid, + # ).decode() + except Exception: + return None + + +def translate_ver(conf: Config, lang: str | None = None, user_lang: str | None = None): + v = conf.get("version", "") + + if not lang: + lang = conf.get("hhd.settings.language", "") + if lang == "system" and user_lang: + lang = user_lang + return v + "-" + lang + + +def get_mo_files( + conf: Config, + locales: Sequence[HHDLocale], + lang: str | None = None, + user_lang: str | None = None, +): + if not lang: + lang = conf.get("hhd.settings.language", "") + if lang == "system" and user_lang: + lang = user_lang + if lang and lang != "system": + languages = [lang] + else: + languages = None + + fns = [] + for locale in locales: + fns.extend(find(locale["domain"], locale["dir"], languages, all=True)) + return fns + + +def translation( + conf: Config, + locales: Sequence[HHDLocale], + lang: str | None = None, + user_lang: str | None = None, +): + mofiles = get_mo_files(conf, locales, lang, user_lang) + result = None + for mofile in mofiles: + key = (GNUTranslations, os.path.abspath(mofile)) + t = _translations.get(key) + if t is None: + with open(mofile, "rb") as fp: + t = _translations.setdefault(key, GNUTranslations(fp)) + + t = copy.copy(t) + if result is None: + result = t + else: + result.add_fallback(t) + return result + + +def trn_dict(d: Mapping, trn: GNUTranslations): + out = dict(d) + for k, v in d.items(): + if isinstance(v, dict): + out[k] = trn_dict(v, trn) + elif isinstance(v, str) and v: + out[k] = trn.gettext(v) + elif isinstance(v, list): + out[k] = [trn.gettext(l) if l and isinstance(l, str) else l for l in v] + else: + out[k] = v + return out + + +def translate( + d: Mapping, + conf: Config, + locales: Sequence[HHDLocale], + lang: str | None = None, + user_lang: str | None = None, +): + trn = translation(conf, locales, lang, user_lang) + base = d + if trn: + base = trn_dict(base, trn) + return base diff --git a/src/hhd/http/index.html b/src/hhd/http/index.html new file mode 100644 index 00000000..811f6e79 --- /dev/null +++ b/src/hhd/http/index.html @@ -0,0 +1,17 @@ + + + + + + + HHD Settings + + + + + +

HHD API Endpoint

+

Download the hhd-ui appimage or use hhdctl!

+ + + \ No newline at end of file diff --git a/src/hhd/http/static/index.js b/src/hhd/http/static/index.js new file mode 100644 index 00000000..b6ac867e --- /dev/null +++ b/src/hhd/http/static/index.js @@ -0,0 +1,113 @@ +async function fetchSettings() { + // Replace this URL with your actual API endpoint + const response = await fetch('/api/v1/settings'); + if (!response.ok) { + throw new Error('Failed to fetch settings'); + } + return await response.json(); +} + +function createInputForSetting(setting) { + let input; + + switch (setting.type) { + case 'bool': + input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = setting.default; + break; + case 'int': + input = document.createElement('input'); + input.type = 'number'; + input.value = setting.default; + input.min = setting.min; + input.max = setting.max; + break; + case 'discrete': + input = document.createElement('select'); + setting.options.forEach(option => { + const optionElement = document.createElement('option'); + optionElement.value = option; + optionElement.textContent = option; + optionElement.selected = option === setting.default; + input.appendChild(optionElement); + }); + break; + case 'multiple': + input = document.createElement('select'); + Object.entries(setting.options).forEach(([key, value]) => { + const optionElement = document.createElement('option'); + optionElement.value = key; + optionElement.textContent = value; + optionElement.selected = key === setting.default; + input.appendChild(optionElement); + }); + break; + // Add other cases as needed + } + + return input; +} + +function createFormSection(children, containerId) { + const sectionContainer = document.createElement('div'); + sectionContainer.id = containerId; + + Object.entries(children).forEach(([key, setting]) => { + // Ensure each setting is processed correctly + if (!setting || typeof setting !== 'object') { + console.log(`Skipping invalid setting: ${key}`); + return; // Skip invalid settings + } + + const label = document.createElement('label'); + label.htmlFor = key; + label.textContent = setting.title; + + const input = createInputForSetting(setting); + if (!input) { + console.log(`Input not created for setting: ${key}`); + return; // Skip settings for which input could not be created + } + + const div = document.createElement('div'); + div.appendChild(label); + div.appendChild(input); + sectionContainer.appendChild(div); + }); + + return sectionContainer; +} + + +async function loadSettings() { + try { + const settings = await fetchSettings(); + + // Assuming 'settings' contains 'hhd' and 'controllers' keys at root level + if (settings.hhd && settings.hhd.http && settings.hhd.http.children) { + console.log('HHD HTTP children:', settings.hhd.http.children); + const hhdSectionForm = createFormSection(settings.hhd.http.children, 'hhd-http-form'); + document.getElementById('settingsContainer').appendChild(hhdSectionForm); + } else { + console.log('No children present in HHD HTTP settings'); + } + + if (settings.controllers && settings.controllers.legion_go && settings.controllers.legion_go.children) { + const controllersSectionForm = createFormSection(settings.controllers.legion_go.children, 'controllers-legion-go-form'); + if (controllersSectionForm) { + document.getElementById('settingsContainer').appendChild(controllersSectionForm); + } else { + console.log('Form section for Controllers Legion Go is not valid'); + } + } else { + console.log('No children present in Controllers Legion Go settings'); + } + } catch (error) { + console.error('Error loading settings:', error); + } +} + + +// Call loadSettings when the document is ready +document.addEventListener('DOMContentLoaded', loadSettings); diff --git a/src/hhd/http/static/style.css b/src/hhd/http/static/style.css new file mode 100644 index 00000000..ba01b21f --- /dev/null +++ b/src/hhd/http/static/style.css @@ -0,0 +1,7 @@ +h1 { + color: purple; +} + +body { + font-family: Arial, Helvetica, sans-serif; +} \ No newline at end of file diff --git a/src/hhd/http/steamos.py b/src/hhd/http/steamos.py new file mode 100644 index 00000000..7ad59e88 --- /dev/null +++ b/src/hhd/http/steamos.py @@ -0,0 +1,191 @@ +import argparse +import sys +import time +from .ctl import get_state, set_state, unroll_dict + +SOCKET_UNIX = "/run/hhd/api" +USAGE = """ +hhd.steamos [-h] {steamos-select-branch,steamos-update} [--fallback] [keys ...] + +Handheld Daemon steamos polkit stub +Allows mimicking the polkit behavior of SteamOS to perform updates, etc. +For specifics, refer to SteamOS. The --fallback option is provided which will +return 20 if handheld daemon cannot update the system. In this case, you can +use the legacy fallback to update the system. + +Commands: + steamos-select-branch: Select a branch that running steamos-update will update to. + Aliased to steamos-branch-select. Options are: rel, rc, beta, main, bc, -l, -c. + steamos-update: Perform an update. + +""" + + +ALL = { + "set_state": set_state, + "get_state": get_state, +} + +BRANCH_MAP = { + "rel": "stable", + "beta": "testing", + "preview": "testing", + "rc": "testing", + "bc": "unstable", + "pc": "unstable", + "main": "unstable", +} + +FALLBACK_CODE = 20 + + +def _select_branch(fallback, opts): + branch = "stable" + try: + state = unroll_dict(get_state()) + stage = state.get("updates.bootc.steamos-update", None) + # print(f"Stage: {stage}", file=sys.stderr) + incompatible = stage is None or stage == "incompatible" + + img = state.get("updates.bootc.image", None).split(":")[-1] + assert img is not None + for k, v in BRANCH_MAP.items(): + if img.startswith(v): + branch = k + break + except Exception as e: + incompatible = True + print(f"Error: {e}", file=sys.stderr) + if incompatible and fallback: + return FALLBACK_CODE + + if "-c" in opts: + print(branch) + return 0 + if "-l" in opts: + for v in BRANCH_MAP.keys(): + print(v) + return 0 + + if not opts: + print("No option provided", file=sys.stderr) + return 0 + + if incompatible: + print("Incompatible state", file=sys.stderr) + return 0 + + if not opts: + return 0 + + target = opts[0] + if target == branch: + return 0 + + target_os = None + for k, v in BRANCH_MAP.items(): + if target == k: + target_os = v + break + + if target_os is None: + print(f"Invalid branch: {target}", file=sys.stderr) + return 0 + + print("Ignoring request to rebase from SteamOS", file=sys.stderr) + return 0 + + +def _update(fallback, opts): + if "--supports-duplicate-detection" in opts: + return 0 + + check = "check" in opts + + # Check if there is an update + try: + if unroll_dict(get_state()).get("updates.bootc.steamos-update", None) in ( + "incompatible", + None, + ): + print("Incompatible state", file=sys.stderr) + return FALLBACK_CODE if fallback else 0 + + val = unroll_dict(set_state({"updates.bootc.steamos-update": "check"})).get( + "updates.bootc.steamos-update", None + ) + while val == "check": + val = unroll_dict(get_state(poll=True)).get( + "updates.bootc.steamos-update", None + ) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + if fallback: + return FALLBACK_CODE + return 1 + + if val != "has-update" and not (val and val.endswith("%")): + print(f"No updates available ({val})", file=sys.stderr) + return 7 + elif check: + print(f"Updates available ({val})", file=sys.stderr) + return 0 + + # Otherwise apply, bootc does not need separate steps + if not val or not val.endswith("%"): + val = unroll_dict(set_state({"updates.bootc.steamos-update": "apply"})).get( + "updates.bootc.steamos-update", None + ) + + curr = 0.2 + while not val or "%" in val or val == "apply": + if val and val.endswith("%"): + next = float(val[:-1]) + while curr < next: + # print(f"\r\033[K\r{curr:.2f}% 1m1s", end="") + print(f"\r{curr:.2f}%", end="") + sys.stdout.flush() + curr += min(5, next - curr) + time.sleep(0.2) + + val = unroll_dict(get_state(poll=True)).get( + "updates.bootc.steamos-update", None + ) + + if val != "updated": + return 1 + return 0 + + +def main(): + fallback = False + try: + if "--help" in sys.argv or "-h" in sys.argv or len(sys.argv) < 2: + print(USAGE) + sys.exit(0) + + fallback = "--fallback" in sys.argv + cmd = sys.argv[1] + + opts = [v for v in sys.argv[2:] if v != "--fallback"] + match cmd: + case "steamos-branch-select" | "steamos-select-branch": + v = _select_branch(fallback, opts) + case "steamos-update": + v = _update(fallback, opts) + case _: + print(f"Invalid command: '{cmd}'") + v = -1 + + if v is not None: + sys.exit(v) + + except KeyboardInterrupt: + sys.exit(0) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/hhd/i18n/.gitignore b/src/hhd/i18n/.gitignore new file mode 100644 index 00000000..708428b8 --- /dev/null +++ b/src/hhd/i18n/.gitignore @@ -0,0 +1,3 @@ +* +!__init__.py +!.gitignore \ No newline at end of file diff --git a/src/hhd/i18n/__init__.py b/src/hhd/i18n/__init__.py new file mode 100644 index 00000000..e91cbfd8 --- /dev/null +++ b/src/hhd/i18n/__init__.py @@ -0,0 +1,12 @@ +from hhd.plugins import HHDLocale, get_relative_fn + + +def _(arg: str): + return arg + + +def locales() -> list[HHDLocale]: + return [ + {"dir": get_relative_fn("./"), "domain": "hhd", "priority": 10}, + {"dir": get_relative_fn("./"), "domain": "adjustor", "priority": 20}, + ] diff --git a/src/hhd/logging.py b/src/hhd/logging.py index 816a115e..72c65361 100644 --- a/src/hhd/logging.py +++ b/src/hhd/logging.py @@ -7,7 +7,7 @@ from rich.logging import RichHandler from threading import local, Lock, get_ident, enumerate -from .utils import Context, expanduser +from .utils import Context, expanduser, fix_perms logger = logging.getLogger(__name__) @@ -60,6 +60,8 @@ def __init__( omit_repeated_times: bool = True, level_width=8, plugin_width=5, + print_time=True, + print_path=True, ) -> None: from rich.style import Style @@ -68,6 +70,8 @@ def __init__( self.level_width = level_width self._last_time = None self.plugin_width = plugin_width + self.print_time = print_time + self.print_path = print_path def __call__( self, @@ -87,7 +91,8 @@ def __call__( output = Table.grid(padding=(0, 1)) output.expand = True - output.add_column(style="log.time") + if self.print_time: + output.add_column(style="log.time") match plugin: case "main": color = "magenta" @@ -99,37 +104,39 @@ def __call__( output.add_column(style="log.level", width=self.level_width) output.add_column(ratio=1, style="log.message", overflow="fold") - # output.add_column(style="log.path") + if self.print_path: + output.add_column(style="log.path") row = [] - log_time = log_time or console.get_datetime() - time_format = time_format or self.time_format - if callable(time_format): - log_time_display = time_format(log_time) - else: - log_time_display = Text(log_time.strftime(time_format)) - if log_time_display == self._last_time and self.omit_repeated_times: - row.append(Text(" " * len(log_time_display))) - else: - row.append(log_time_display) - self._last_time = log_time_display + if self.print_time: + log_time = log_time or console.get_datetime() + time_format = time_format or self.time_format + if callable(time_format): + log_time_display = time_format(log_time) + else: + log_time_display = Text(log_time.strftime(time_format)) + if log_time_display == self._last_time and self.omit_repeated_times: + row.append(Text(" " * len(log_time_display))) + else: + row.append(log_time_display) + self._last_time = log_time_display row.append(plugin.upper() if plugin else "") row.append(level) # Find plugin row.append(Renderables(renderables)) - # if path: - # path_text = Text() - # path_text.append( - # path, style=f"link file://{link_path}" if link_path else "" - # ) - # if line_no: - # path_text.append(":") - # path_text.append( - # f"{line_no}", - # style=f"link file://{link_path}#{line_no}" if link_path else "", - # ) - # row.append(path_text) + if path and self.print_path: + path_text = Text() + path_text.append( + path, style=f"link file://{link_path}" if link_path else "" + ) + if line_no: + path_text.append(":") + path_text.append( + f"{line_no}", + style=f"link file://{link_path}#{line_no}" if link_path else "", + ) + row.append(path_text) output.add_row(*row) return output @@ -197,11 +204,20 @@ def setup_logger( if log_dir: log_dir = expanduser(log_dir, ctx) + # Do not print time when running as a systemd service + is_systemd = bool(os.environ.get("JOURNAL_STREAM", None)) + install() handlers = [] - handlers.append(PluginRichHandler(PluginLogRender())) + handlers.append( + PluginRichHandler( + PluginLogRender(print_time=not is_systemd, print_path=not is_systemd) + ) + ) if log_dir: os.makedirs(log_dir, exist_ok=True) + if ctx: + fix_perms(log_dir, ctx) handler = UserRotatingFileHandler( os.path.join(log_dir, "hhd.log"), maxBytes=10_000_000, diff --git a/src/hhd/plugins/__init__.py b/src/hhd/plugins/__init__.py index 4d21942c..34236d7b 100644 --- a/src/hhd/plugins/__init__.py +++ b/src/hhd/plugins/__init__.py @@ -1,29 +1,23 @@ from .conf import Config -from .plugin import HHDAutodetect, HHDPlugin, Context, Emitter, Event +from .inputs import gen_gyro_state, get_gyro_config, get_gyro_state, get_touchpad_config +from .outputs import ( + fix_limits, + get_limits, + get_limits_config, + get_outputs, + get_outputs_config, +) +from .plugin import ( + Context, + Emitter, + Event, + HHDAutodetect, + HHDLocale, + HHDLocaleRegister, + HHDPlugin, +) from .settings import HHDSettings - - -def get_relative_fn(fn: str): - """Returns the directory of a file relative to the script calling this function.""" - import inspect - import os - - script_fn = inspect.currentframe().f_back.f_globals["__file__"] # type: ignore - dirname = os.path.dirname(script_fn) - return os.path.join(dirname, fn) - - -def load_relative_yaml(fn: str): - """Returns the yaml data of a file in the relative dir provided.""" - import inspect - import os - import yaml - - script_fn = inspect.currentframe().f_back.f_globals["__file__"] # type: ignore - dirname = os.path.dirname(script_fn) - with open(os.path.join(dirname, fn), "r") as f: - return yaml.safe_load(f) - +from .utils import get_relative_fn, load_relative_yaml __all__ = [ "Config", @@ -35,4 +29,15 @@ def load_relative_yaml(fn: str): "Emitter", "Event", "Context", + "get_outputs_config", + "get_outputs", + "get_touchpad_config", + "get_gyro_config", + "get_gyro_state", + "gen_gyro_state", + "HHDLocale", + "HHDLocaleRegister", + "get_limits_config", + "get_limits", + "fix_limits", ] diff --git a/src/hhd/plugins/bootc/__init__.py b/src/hhd/plugins/bootc/__init__.py new file mode 100644 index 00000000..e7a13bf0 --- /dev/null +++ b/src/hhd/plugins/bootc/__init__.py @@ -0,0 +1,701 @@ +import json +import logging +import os +import select +import signal +import subprocess +import shutil +import time +from threading import Lock, Thread +from typing import Literal, Sequence + +from hhd.i18n import _ +from hhd.plugins import Context, HHDPlugin, HHDSettings, load_relative_yaml +from hhd.plugins.conf import Config + +logger = logging.getLogger(__name__) + +REFRESH_HZ = 3 +PROGRESS_STAGES = { + "pulling": (_("Downloading:"), 0, 80), + "importing": (_("Importing:"), 80, 10), + "staging": (_("Deploying:"), 90, 10), + "unknown": (_("Loading"), 100, 0), +} + +BOOTC_ENABLED = os.environ.get("HHD_BOOTC", "0") == "1" +BOOTC_PATH = os.environ.get("HHD_BOOTC_PATH", "bootc") +BRANCHES = os.environ.get( + "HHD_BOOTC_BRANCHES", "stable:Stable,testing:Testing,unstable:Unstable" +) + +REF_PREFIX = "§ " +DEFAULT_PREFIX = "ā—‰ " + +BOOTC_STATUS_CMD = [ + BOOTC_PATH, + "status", + "--format", + "json", +] + +RPM_OSTREE_RESET = [ + "rpm-ostree", + "reset", +] + +RPM_OSTREE_UPDATE = [ + "rpm-ostree", + "update", +] + +BOOTC_CHECK_CMD = [ + BOOTC_PATH, + "update", + "--check", +] + +BOOTC_ROLLBACKCMD = [ + BOOTC_PATH, + "rollback", +] + +BOOTC_UPDATE_CMD = [ + BOOTC_PATH, + "update", +] + +SKOPEO_REBASE_CMD = lambda ref: ["skopeo", "inspect", "docker://" + ref] + + +STAGES = Literal[ + "init", + "ready", + "ready_check", + "ready_updated", + "ready_reverted", + "ready_rebased", + "incompatible", + "rebase_dialog", + "loading", + "loading_rebase", + "loading_cancellable", +] + + +def get_bootc_status(): + try: + output = subprocess.check_output(BOOTC_STATUS_CMD).decode("utf-8") + return json.loads(output) + except Exception as e: + logger.error(f"Failed to get bootc status: {e}") + return {} + + +def get_ref_from_status(status: dict | None): + return (((status or {}).get("spec", None) or {}).get("image", None) or {}).get( + "image", "" + ) + + +def get_branch(ref: str, branches: dict, fallback: bool = True): + if ":" not in ref: + return next(iter(branches)) + curr_tag = ref[ref.rindex(":") + 1 :] + + for branch in branches: + if branch in curr_tag: + return branch + + if not fallback: + return None + # If no tag, assume it is the first one + return next(iter(branches)) + + +def get_rebase_refs(ref: str, tags, lim: int = 7, branches: dict = {}): + logger.info(f"Getting rebase refs for {ref}") + try: + output = subprocess.check_output(SKOPEO_REBASE_CMD(ref)).decode("utf-8") + data = json.loads(output) + versions = data.get("RepoTags", []) + + for branch in branches: + same_branch = [v for v in versions if v.startswith(branch) and v != branch] + same_branch.sort(reverse=True) + tags[branch] = same_branch[:lim] + + logger.info(f"Finished getting refs") + except Exception as e: + logger.error(f"Failed to get rebase refs: {e}") + + +def run_command_threaded(cmd: list): + try: + return subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + ) + except Exception as e: + logger.error(f"Failed to run command: {e}") + + +def _bootc_progress_reader(fd, emit, friendly, lock, obj): + last_update = 0 + try: + while select.select([fd.fileno()], [], [])[0]: + data = fd.readline() + if not data: + break + data = json.loads(data) + + text, start, length = PROGRESS_STAGES.get( + data.get("task", "unknown"), PROGRESS_STAGES["unknown"] + ) + + match data["type"]: + case "ProgressSteps": + curr = data.get("steps", 0) + total = data.get("stepsTotal", 0) + value = start + min(length, int((curr / (total + 1)) * length)) + if total > 1: + unit = f" {friendly} ({min(curr + 1, total)}/{total})" + else: + unit = f" {friendly}" + case "ProgressBytes": + curr = data.get("bytes", 0) + total = data.get("bytesTotal", 0) + value = start + min(length, int((curr / total) * length)) + unit = f" {friendly} ({curr/1e9:.1f}/{total/1e9 + 0.099:.1f} GB)" + case _: + continue + + with lock: + obj.update({"text": text, "value": value, "unit": unit}) + + # Increase the update rate of the UI + curr = time.perf_counter() + if curr - last_update > 1 / REFRESH_HZ and emit: + last_update = curr + emit({"type": "special", "event": "refresh"}) + finally: + fd.close() + + +def run_command_threaded_progress(cmd: list, emit, friendly, lock): + r = None + try: + r, w = os.pipe2(0) + proc = subprocess.Popen( + cmd + [f"--json-fd", str(w), "--quiet"], + pass_fds=[w], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + os.close(w) + + obj = {} + fd = os.fdopen(r, "r") + t = Thread(target=_bootc_progress_reader, args=(fd, emit, friendly, lock, obj)) + t.start() + return proc, obj + except Exception as e: + logger.error(f"Failed to run command: {e}") + if r: + os.close(r) + return None, None + + +def is_incompatible(status: dict): + if status.get("apiVersion", None) != "org.containers.bootc/v1": + return True + + boot_incompatible = ( + (status.get("status", None) or {}).get("booted", None) or {} + ).get("incompatible", False) + + if staged := ((status.get("status", None) or {}).get("staged", None) or {}): + return staged.get("incompatible", False) + + return boot_incompatible + + +def has_bootc_progress_support(): + return "--json-fd" in subprocess.check_output( + [BOOTC_PATH, "upgrade", "--help"] + ).decode("utf-8") + + +class BootcPlugin(HHDPlugin): + def __init__(self) -> None: + self.name = f"bootc" + self.priority = 70 + self.log = "bupd" + self.proc = None + self.branch_name = None + self.branch_ref = None + self.checked_update = False + self.t = None + self.t_data = None + self.progress_lock = Lock() + self.progress = None + self.staged = "" + self.cached_version = "" + self.emit = None + self.updating = False + + self.branches = {} + for branch in BRANCHES.split(","): + name, display = branch.split(":") + self.branches[name] = display + + self.status = None + self.enabled = True + self.state: STAGES = "init" + + def settings(self) -> HHDSettings: + sets = { + "updates": {"bootc": load_relative_yaml("settings.yml")}, + "hhd": {"settings": load_relative_yaml("general.yml")}, + } + + sets["updates"]["bootc"]["children"]["stage"]["modes"]["rebase"][ + "children" + ]["branch"]["options"] = self.branches + + return sets + + def open( + self, + emit, + context: Context, + ): + self.updated = False + self.bootc_progress = has_bootc_progress_support() + self.emit = emit + if self.bootc_progress: + logger.info("Bootc progress support detected") + else: + logger.warning("Bootc progress support not detected") + + def get_version(self, s): + assert self.status + return ( + (self.status.get("status", {}).get(s, None) or {}).get("image", None) or {} + ).get("version", "") + + def _init(self, conf: Config): + self.status = get_bootc_status() + self.updating = False + + if is_incompatible(self.status): + conf["updates.bootc.stage.mode"] = "incompatible" + self.state = "incompatible" + conf[f"updates.bootc.update"] = None + conf[f"updates.bootc.steamos-update"] = "incompatible" + return + + ref = ((self.status.get("spec", None) or {}).get("image", None) or {}).get( + "image", "" + ) + img = ref + if "/" in img: + img = img[img.rfind("/") + 1 :] + + # Find branch and replace tag + branch = get_branch(img, self.branches) + rebased_ver = None + self.branch_name = branch + self.branch_ref = None + has_rebased = False + if branch: + if ":" in img: + tag = img[img.rindex(":") + 1 :] + if tag != branch: + rebased_ver = tag + has_rebased = True + self.branch_ref = ref.split(":")[0] + ":" + branch + img = img[: img.rindex(":") + 1] + branch + if img: + conf["updates.bootc.image"] = img + + # If we have a staged update, that will boot first + self.staged = og = s = self.get_version("staged") + staged = False + if s: + s = DEFAULT_PREFIX + s + staged = True + if s and rebased_ver and og in rebased_ver: + s = REF_PREFIX + s + # Only apply one start to avoid confusion + rebased_ver = None + conf["updates.bootc.staged"] = s + + # Check if the user selected rollback + # Then that will be the default, provided there is a rollback + rollback = ( + not staged + and (self.status.get("spec", None) or {}).get("bootOrder", None) + == "rollback" + ) + s = self.get_version("rollback") + if s and rollback: + s = DEFAULT_PREFIX + s + else: + rollback = False + conf[f"updates.bootc.rollback"] = s + + # Otherwise, the booted version will be the default + og = s = self.get_version("booted") + if s and not rollback and not staged: + s = DEFAULT_PREFIX + s + if s and rebased_ver and og in rebased_ver: + s = REF_PREFIX + s + conf[f"updates.bootc.booted"] = s + + conf["updates.bootc.status"] = "" + self.updated = True + + cached = self.status.get("status", {}).get("booted", {}).get("cachedUpdate", {}) + cached_version = cached.get("version", "") if cached else "" + cached_img = cached.get("image", {}).get("image", "") if cached else "" + if "/" in cached_img: + cached_img = cached_img[cached_img.rfind("/") + 1 :] + self.cached_version = cached_version + + if self.checked_update: + conf[f"updates.bootc.update"] = _("No update available") + else: + conf[f"updates.bootc.update"] = None + + if ( + cached_version + and cached_img == img + and cached_version != self.get_version("staged") + ): + conf["updates.bootc.stage.mode"] = "ready" + self.state = "ready" + conf[f"updates.bootc.update"] = cached_version + conf[f"updates.bootc.steamos-update"] = "has-update" + elif self.get_version("staged"): + conf["updates.bootc.stage.mode"] = "ready_updated" + self.state = "ready_updated" + conf[f"updates.bootc.steamos-update"] = "updated" + elif has_rebased: + conf["updates.bootc.stage.mode"] = "ready_rebased" + self.state = "ready_rebased" + conf[f"updates.bootc.steamos-update"] = "updated" + elif rollback: + conf["updates.bootc.stage.mode"] = "ready_reverted" + self.state = "ready_reverted" + conf[f"updates.bootc.steamos-update"] = "updated" + else: + conf["updates.bootc.stage.mode"] = "ready_check" + self.state = "ready_check" + conf[f"updates.bootc.steamos-update"] = "ready" + + def update(self, conf: Config): + + # Detect reset and avoid breaking the UI + if conf.get("updates.bootc.stage.mode", None) is None: + self._init(conf) + return + + # Try to fill in basic info + match self.state: + case "init": + self._init(conf) + # Ready + case ( + "ready" + | "ready_check" + | "ready_updated" + | "ready_reverted" + | "ready_rebased" as e + ): + update = conf.get_action(f"updates.bootc.stage.{e}.update") + revert = conf.get_action(f"updates.bootc.stage.{e}.revert") + rebase = conf.get_action(f"updates.bootc.stage.{e}.rebase") + reboot = conf.get_action(f"updates.bootc.stage.{e}.reboot") + + steamos = conf.get("updates.bootc.steamos-update", None) + + # Handle steamos polkit + if steamos == "check": + if not conf.get("hhd.settings.bootc_steamui", True): + # Updates are disabled, return that there are none + conf["updates.bootc.steamos-update"] = "ready" + elif e == "ready": + conf["updates.bootc.steamos-update"] = "has-update" + elif e == "ready_rebased": + # Make sure nothing funny happens on the rebase dialog + conf["updates.bootc.steamos-update"] = "ready" + else: + update = True + if steamos == "apply": + update = True + + if update: + if e == "ready_rebased" and self.branch_ref: + self.checked_update = False + self.state = "loading_cancellable" + cmd = [BOOTC_PATH, "switch", self.branch_ref] + if self.bootc_progress: + self.proc, self.progress = run_command_threaded_progress( + cmd, + self.emit, + self.branch_name, + self.progress_lock, + ) + else: + self.proc = run_command_threaded(cmd) + conf["updates.bootc.stage.mode"] = "loading_cancellable" + conf["updates.bootc.stage.loading_cancellable.progress"] = { + "text": _("Updating to latest "), + "unit": self.branches.get( + self.branch_name, self.branch_name + ), + "value": None, + } + elif e == "ready": + self.state = "loading_cancellable" + self.checked_update = False + self.updating = True + if self.bootc_progress: + self.proc, self.progress = run_command_threaded_progress( + BOOTC_UPDATE_CMD, + self.emit, + self.cached_version or self.branch_name or "", + self.progress_lock, + ) + else: + self.proc = run_command_threaded(BOOTC_UPDATE_CMD) + conf["updates.bootc.stage.mode"] = "loading_cancellable" + conf["updates.bootc.stage.loading_cancellable.progress"] = { + "text": _("Updating... "), + "value": None, + "unit": None, + } + else: + self.state = "loading" + self.proc = run_command_threaded(BOOTC_CHECK_CMD) + self.checked_update = True + conf["updates.bootc.stage.mode"] = "loading" + conf["updates.bootc.stage.loading.progress"] = { + "text": _("Checking for updates..."), + "value": None, + "unit": None, + } + elif revert: + self.checked_update = False + self.state = "loading" + self.proc = run_command_threaded(BOOTC_ROLLBACKCMD) + conf["updates.bootc.stage.mode"] = "loading" + if e == "ready_updated": + text = _("Undoing Update...") + elif e == "ready_reverted": + text = _("Undoing Revert...") + else: + text = _("Reverting to Previous version...") + conf["updates.bootc.stage.loading.progress"] = { + "text": text, + "value": None, + "unit": None, + } + elif rebase: + self.checked_update = False + if not self.branches: + self._init(conf) + else: + # Get branch that should be default + curr = ( + (self.status or {}) + .get("spec", {}) + .get("image", {}) + .get("image", "") + ) + default = get_branch(curr, self.branches) + conf["updates.bootc.stage.rebase.branch"] = default + + # Prepare loader + conf["updates.bootc.stage.mode"] = "loading" + conf["updates.bootc.stage.loading.progress"] = { + "text": _("Loading Versions..."), + "value": None, + "unit": None, + } + + # Launch loader thread + self.t_data = {} + self.t = Thread( + target=get_rebase_refs, + args=(curr, self.t_data), + kwargs={"branches": self.branches}, + ) + self.t.start() + self.state = "loading_rebase" + elif reboot: + logger.info("User pressed reboot in updater. Rebooting...") + subprocess.run(["systemctl", "reboot"]) + + # Incompatible + case "incompatible": + if conf.get_action("updates.bootc.stage.incompatible.reset"): + self.state = "loading" + self.proc = run_command_threaded(RPM_OSTREE_RESET) + conf["updates.bootc.stage.mode"] = "loading" + conf["updates.bootc.stage.loading.progress"] = { + "text": _("Removing Customizations..."), + "value": None, + "unit": None, + } + + # Rebase dialog + case "rebase_dialog" | "loading_rebase" as e: + # FIXME: this is the only match statement that + # does early returns. Allows loading the previous + # versions instantly. + + conf["updates.bootc.update"] = None + if e == "loading_rebase": + if self.t is None: + self._init(conf) + return + elif not self.t.is_alive(): + self.t = None + self.state = "rebase_dialog" + conf["updates.bootc.stage.mode"] = "rebase" + else: + return + + apply = conf.get_action("updates.bootc.stage.rebase.apply") + cancel = conf.get_action("updates.bootc.stage.rebase.cancel") + branch = conf.get( + "updates.bootc.stage.rebase.branch", next(iter(self.branches)) + ) + + version = "latest" + if not self.t_data: + conf["updates.bootc.stage.rebase.version_error"] = _( + "Failed to load previous versions" + ) + else: + conf["updates.bootc.stage.rebase.version_error"] = None + if branch in self.t_data: + bdata = {k.replace(".", ""): k for k in self.t_data[branch]} + version = conf.get( + "updates.bootc.stage.rebase.version.value", "latest" + ) + conf["updates.bootc.stage.rebase.version"] = None + conf["updates.bootc.stage.rebase.version"] = { + "options": { + "latest": "Latest", + **bdata, + }, + "value": version if version in bdata else "latest", + } + # Readd . since config system does not support them + version = bdata.get(version, "latest") + + if cancel: + self._init(conf) + elif apply: + if version == "latest": + version = branch + + curr = get_ref_from_status(self.status) + next_ref = ( + (curr[: curr.rindex(":")] if ":" in curr else curr) + + ":" + + version + ) + if next_ref == curr: + self._init(conf) + else: + self.state = "loading_cancellable" + cmd = [BOOTC_PATH, "switch", next_ref] + if self.bootc_progress: + self.proc, self.progress = run_command_threaded_progress( + cmd, self.emit, version, self.progress_lock + ) + else: + self.proc = run_command_threaded(cmd) + conf["updates.bootc.stage.mode"] = "loading_cancellable" + conf["updates.bootc.stage.loading_cancellable.progress"] = { + "text": _("Rebasing to "), + "unit": self.branches.get(version, version), + "value": None, + } + + # Wait for the subcommand to complete + case "loading_cancellable": + cancel = conf.get_action( + f"updates.bootc.stage.loading_cancellable.cancel" + ) + if self.proc is None: + self._init(conf) + elif exit := self.proc.poll() is not None: + if exit and self.updating: + logger.error( + f"Command failed with exit code {exit}. Fallback to rpm-ostree" + ) + self.proc = run_command_threaded(RPM_OSTREE_UPDATE) + conf["updates.bootc.stage.loading_cancellable.progress"] = { + "text": _("Update error. Using alternative method... "), + "value": None, + "unit": None, + } + + # Prevent fallback running forever + self.updating = False + else: + self._init(conf) + self.proc = None + elif cancel: + logger.info("User cancelled update. Stopping...") + self.proc.send_signal(signal.SIGINT) + self.proc.wait() + self.proc = None + self._init(conf) + elif self.progress: + with self.progress_lock: + conf["updates.bootc.stage.loading_cancellable.progress"] = ( + self.progress + ) + val = self.progress.get("value", None) + if val is not None: + try: + val = int(val) + conf["updates.bootc.steamos-update"] = f"{val}%" + except ValueError: + pass + case "loading": + if self.proc is None: + self._init(conf) + elif self.proc.poll() is not None: + self._init(conf) + self.proc = None + + def close(self): + if self.proc: + self.proc.send_signal(signal.SIGINT) + self.proc.wait() + self.proc = None + if self.t: + if self.t.is_alive(): + self.t.join() + self.t = None + + +def autodetect(existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: + if len(existing): + return existing + + if not BOOTC_ENABLED: + return [] + + if not shutil.which(BOOTC_PATH): + logger.warning("Bootc is enabled but not found in path.") + return [] + + return [BootcPlugin()] diff --git a/src/hhd/plugins/bootc/general.yml b/src/hhd/plugins/bootc/general.yml new file mode 100644 index 00000000..c0bf4fab --- /dev/null +++ b/src/hhd/plugins/bootc/general.yml @@ -0,0 +1,9 @@ +type: container +tags: [non-essential] + +children: + bootc_steamui: + type: bool + tags: [non-essential] + title: Show updates in SteamUI + default: True \ No newline at end of file diff --git a/src/hhd/plugins/bootc/settings.yml b/src/hhd/plugins/bootc/settings.yml new file mode 100644 index 00000000..e0b5c6d8 --- /dev/null +++ b/src/hhd/plugins/bootc/settings.yml @@ -0,0 +1,204 @@ +type: container +tags: [system, non-essential] +title: System Image +hint: >- + Manage the currently installed image with bootc. + +children: + steamos-target: + type: display + tags: [hidden] + + steamos-update: + type: display + tags: [hidden] + + image: + tags: [slim, bold] + type: display + title: Image + default: bazzite-deck:stable + + staged: + tags: [slim, bold] + type: display + title: Next + + booted: + tags: [slim] + type: display + title: Current + + rollback: + tags: [slim] + type: display + title: Previous + + update: + tags: [slim, bold] + type: display + title: Update + + stage: + type: mode + tags: [no_dropdown] + title: Update Stage + + modes: + ready: + type: container + children: + update: + type: action + title: Apply Update + + revert: + type: action + title: Revert to Previous + hint: >- + Rollback to the previous image. + + rebase: + type: action + title: Change Version (Rebase) + + ready_rebased: + type: container + children: + update: + type: action + title: Remove Pin and Update + + revert: + type: action + title: Revert to Previous + hint: >- + Rollback to the previous image. + + rebase: + type: action + title: Change Branch (Rebase) + + ready_check: + type: container + children: + update: + type: action + title: Check for Updates + + revert: + type: action + title: Revert to Previous + hint: >- + Rollback to the previous image. + + rebase: + type: action + title: Change Branch (Rebase) + + ready_updated: + type: container + children: + reboot: + type: action + title: Reboot + tags: [verify] + hint: >- + Reboot to apply the update. Are you sure? + + revert: + type: action + title: Undo Update + + update: + type: action + title: Check for Updates + + rebase: + type: action + title: Change Branch (Rebase) + + ready_reverted: + type: container + children: + reboot: + type: action + title: Reboot + tags: [verify] + hint: >- + Reboot to apply the update. Are you sure? + + revert: + type: action + title: Undo Revert + + update: + type: action + title: Check for Updates + + rebase: + type: action + title: Choose Version (Rebase) + + incompatible: + type: container + children: + error: + type: display + tags: [error] + title: Error + default: > + Due to layering or custom initramfs, you cannot update from here. + You can undo those with the button below. + + reset: + type: action + title: Run rpm-ostree reset + tag: [verify] + hint: >- + Disable the custom initramfs and remove layers. Your personal + data will not be affected. + + loading_cancellable: + type: container + children: + progress: + type: custom + tags: [progress] + + cancel: + type: action + title: Cancel + + loading: + type: container + children: + progress: + type: custom + tags: [progress] + + rebase: + type: container + children: + branch: + type: multiple + title: Branch + options: + + version: + type: custom + title: Version Pin + tags: [dropdown] + + version_error: + type: display + title: Error + tags: [error, slim] + + apply: + type: action + title: Apply + + cancel: + type: action + title: Cancel \ No newline at end of file diff --git a/src/hhd/plugins/conf.py b/src/hhd/plugins/conf.py index 02439cfb..68963f3c 100644 --- a/src/hhd/plugins/conf.py +++ b/src/hhd/plugins/conf.py @@ -131,10 +131,15 @@ def __setitem__(self, key: str | tuple[str, ...], val): d[seq[-1]] = val if isinstance(self._conf, MutableMapping): + init = deepcopy(self._conf) parse_conf(cont, self._conf) + # FIXME: verify no regressions + if init != self._conf: + self._updated = True else: self._conf = cont - self.updated = True + if self._conf != cont: + self._updated = True def __contains__(self, key: str | tuple[str, ...]): with self._lock: @@ -170,10 +175,23 @@ def get(self, key, default: A) -> A: return self[key].to(type(default)) except KeyError: return default + except TypeError: + return default + + def get_action(self, key): + if key not in self: + return False + tmp = bool(self[key].conf) + if tmp: + self[key] = False + return tmp def to(self, t: type[A]) -> A: return cast(t, self.conf) + def copy(self): + return Config([self.conf]) + @property def conf(self): with self._lock: diff --git a/src/hhd/plugins/debug/__init__.py b/src/hhd/plugins/debug/__init__.py new file mode 100644 index 00000000..51de7747 --- /dev/null +++ b/src/hhd/plugins/debug/__init__.py @@ -0,0 +1,183 @@ +import logging +import os +import subprocess +import sys +from threading import Event, Thread +from typing import Sequence + +from hhd.i18n import _ +from hhd.plugins import Context, HHDPlugin, HHDSettings, load_relative_yaml +from hhd.plugins.conf import Config +from hhd.utils import GIT_ADJ, GIT_HHD, HHD_DEV_DIR + +from .logs import get_log + +logger = logging.getLogger(__name__) + +FPASTE_SERVICE = os.environ.get("HHD_FPASTE", "fpaste") +BUGREPORTS_ENABLED = os.environ.get("HHD_BUGREPORT", "0") == "1" +USES_BETA = os.environ.get("HHD_SWITCH_ROOT", "0") == "1" + + +def prepare_hhd_dev(ev): + try: + os.makedirs(HHD_DEV_DIR, exist_ok=True) + subprocess.run( + ["python3", "-m", "venv", "--system-site-packages", HHD_DEV_DIR], check=True + ) + subprocess.run( + [ + f"{HHD_DEV_DIR}/bin/pip", + "install", + "--upgrade", + "--cache-dir", + "/tmp/__hhd_update_cache", + GIT_HHD, + GIT_ADJ, + ], + check=True, + ) + except Exception as e: + ev.set() + # Show full stacktrace + raise e + + +def upload_log(boot: str, out: dict): + match boot: + case "current": + bootnum = 0 + case "previous": + bootnum = -1 + case "m2": + bootnum = -2 + case "m3": + bootnum = -3 + case _: + bootnum = 0 + + logs = get_log(bootnum) + try: + res = subprocess.run( + [FPASTE_SERVICE, "-x", str(72 * 60)], + input=logs, + check=True, + capture_output=True, + text=True, + ) + if res.returncode != 0: + raise Exception(f"fpaste failed with code {res.returncode}") + + out["url"] = res.stdout.strip() + out["error"] = None + except Exception as e: + logger.error("Failed to upload logs to fpaste: %s", e) + out["url"] = None + out["error"] = str(e) + + +class DebugPlugin(HHDPlugin): + def __init__(self) -> None: + self.name = f"debug" + self.priority = 80 + self.log = "DDBG" + self.emit = None + self.t = None + self.error = None + self.fpaste_t = None + self.fpaste_data = None + + def settings(self) -> HHDSettings: + sets = {"debug": load_relative_yaml("settings.yml")} + if not USES_BETA: + del sets["debug"]["dev"]["children"]["hhd_dev_exit"] + return sets + + def open( + self, + emit, + context: Context, + ): + self.emit = emit + + def update(self, conf: Config): + self._hhd_dev(conf) + self._fpaste(conf) + + def _fpaste(self, conf: Config): + fpaste = conf.get_action("debug.reports.submit") + + if self.fpaste_t: + if self.fpaste_t.is_alive(): + return + self.fpaste_t.join() + self.fpaste_t = None + if self.fpaste_data and self.fpaste_data.get("url", None): + conf["debug.reports.url"] = self.fpaste_data["url"] + elif self.fpaste_data and self.fpaste_data.get("error", None): + conf["debug.reports.error"] = self.fpaste_data["error"] + conf["debug.reports.progress"] = None + elif fpaste: + conf["debug.reports.progress"] = { + "text": _("Uploading log to fpaste..."), + "value": None, + "unit": None, + } + conf["debug.reports.url"] = None + conf["debug.reports.error"] = None + self.fpaste_data = {} + self.fpaste_t = Thread( + target=upload_log, + args=(conf.get("debug.reports.boot", "current"), self.fpaste_data), + ) + self.fpaste_t.start() + + def _hhd_dev(self, conf: Config): + hhd_dev = conf.get_action("debug.dev.hhd_dev") + + if USES_BETA and self.emit and conf.get_action("debug.dev.hhd_dev_exit"): + conf["debug.dev.progress"] = { + "text": _("Shutting down..."), + "value": None, + "unit": None, + } + self.emit({"type": "special", "event": "shutdown_dev"}) + + if self.t: + if self.t.is_alive(): + return + self.t.join() + self.t = None + if self.error and self.error.is_set(): + conf["debug.dev.progress"] = None + conf["debug.dev.error"] = _("Failed to download Handheld Daemon Beta.") + elif self.emit: + self.emit({"type": "special", "event": "restart_dev"}) + + if hhd_dev: + conf["debug.dev.progress"] = { + "text": _("Downloading Beta and Restarting..."), + "value": None, + "unit": None, + } + self.error = Event() + self.t = Thread(target=prepare_hhd_dev, args=(self.error,)) + self.t.start() + + def close(self): + if self.t: + self.t.join() + self.t = None + if self.fpaste_t: + self.fpaste_t.join() + self.fpaste_t = None + + +def autodetect(existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: + if len(existing): + return existing + + if not BUGREPORTS_ENABLED: + return [] + + return [DebugPlugin()] diff --git a/src/hhd/plugins/debug/logs.py b/src/hhd/plugins/debug/logs.py new file mode 100644 index 00000000..ebdb4eb9 --- /dev/null +++ b/src/hhd/plugins/debug/logs.py @@ -0,0 +1,100 @@ +from hhd.logging import RASTER +import sys +import subprocess + +IMPORTANT_FILES = { + "/sys/class/dmi/id/board_vendor": "Board Vendor", + "/sys/class/dmi/id/board_name": "Board Name", + "/sys/class/dmi/id/board_version": "Board Version", + "/sys/class/dmi/id/product_family": "Product Family", + "/sys/class/dmi/id/product_name": "Product Name", + "/sys/class/dmi/id/sys_vendor": "System Vendor", + "/sys/class/dmi/id/modalias": "DMI Modalias", + "/sys/class/dmi/id/bios_version": "BIOS Version", + "/sys/class/dmi/id/ec_firmware_release": "EC Firmware", +} + +JOURNAL_FIRST_LINES = 1_000 +JOURNAL_MAX_SIZE = 8_000 + +JOURNALCTL_CMD = lambda boot: [ + "journalctl", + "--no-pager", + "--no-hostname", + "-b", + f"{boot}", +] + +JOURNALCTL_BLACKLIST = [ + "vivaldi", +] + + +def get_log(boot: int) -> str: + import datetime + + out = RASTER + + out += f""" +Debug Log created by Handheld Daemon at {datetime.datetime.now().strftime('%d/%m/%Y, %H:%M:%S')}. + +""" + + out += "# Device Information\n" + + for path, name in IMPORTANT_FILES.items(): + try: + with open(path, "r") as f: + out += f'{name} ({path}):\n"{f.read().strip()}"\n' + except Exception as e: + out += f"Error reading {name} ({path}): {e}\n" + + out += "Kernel Version (uname -sr):\n" + try: + out += f"\"{subprocess.run( + ['uname', '-sr'], capture_output=True, text=True + ).stdout.strip()}\"\n" + except Exception as e: + out += f"Error reading kernel version: {e}\n" + + out += "\n# OS Release\n" + try: + with open("/etc/os-release", "r") as f: + out += f"{f.read()}" + except Exception as e: + out += f"Error reading /etc/os-release: {e}\n" + + out += f"\n\n# Journalctl from boot index {boot}\n" + try: + lines = list( + subprocess.run( + JOURNALCTL_CMD(boot), capture_output=True, text=True + ).stdout.splitlines() + ) + + # Write last lines last to allow truncating the middle + written = [] + for line in reversed( + lines[max(JOURNAL_FIRST_LINES, len(lines) - JOURNAL_MAX_SIZE) :] + ): + if not any(bl in line for bl in JOURNALCTL_BLACKLIST): + written.append(line) + if len(written) >= JOURNAL_MAX_SIZE: + written.append("\n... (truncated)\n") + break + + # Write first lines + for line in reversed(lines[:JOURNAL_FIRST_LINES]): + if not any(bl in line for bl in JOURNALCTL_BLACKLIST): + written.append(line) + + out += "\n".join(reversed(written)) + + except Exception as e: + out += f"Error reading journalctl: {e}\n" + + return out + + +if __name__ == "__main__": + print(get_log(0)) diff --git a/src/hhd/plugins/debug/settings.yml b/src/hhd/plugins/debug/settings.yml new file mode 100644 index 00000000..200fcfdb --- /dev/null +++ b/src/hhd/plugins/debug/settings.yml @@ -0,0 +1,65 @@ +reports: + type: container + tags: [system, non-essential] + title: Bug Report + + children: + url: + type: display + title: Bug Report Link + tags: [qr, verify] + + error: + type: display + tags: [error] + title: Upload Error + + progress: + type: custom + tags: [progress] + + submit: + type: action + title: Submit Report + tags: [verify] + hint: 'Upload a bug report to paste.centos.org' + + boot: + type: multiple + title: Logs from + default: current + options: + current: Current Boot + previous: Previous Boot (-1) + m2: Boot -2 + m3: Boot -3 + + status: + type: display + title: + default: 'Create a log that will be uploaded to paste.centos.org for 3 days' + +dev: + type: container + tags: [system, non-essential] + title: Development Tools + + children: + progress: + type: custom + tags: [progress] + + error: + type: display + tags: [error] + + hhd_dev: + type: action + tags: [verify] + title: Use HHD Beta Until Restart + hint: >- + Switch to the HHD beta channel until you restart. + + hhd_dev_exit: + type: action + title: Go Back to Stable \ No newline at end of file diff --git a/src/hhd/plugins/display/__init__.py b/src/hhd/plugins/display/__init__.py new file mode 100644 index 00000000..3a104b1c --- /dev/null +++ b/src/hhd/plugins/display/__init__.py @@ -0,0 +1,131 @@ +from typing import Any, Sequence, TYPE_CHECKING +import os +from hhd.plugins import ( + HHDPlugin, + Context, +) +from time import sleep +from hhd.plugins import HHDSettings, load_relative_yaml +import logging + +from hhd.plugins.conf import Config + +logger = logging.getLogger(__name__) +BACKLIGHT_DIR = "/sys/class/backlight/" + + +def write_sysfs(dir: str, fn: str, val: Any): + with open(os.path.join(dir, fn), "w") as f: + f.write(str(val)) + + +def read_sysfs(dir: str, fn: str, default: str | None = None): + try: + with open(os.path.join(dir, fn), "r") as f: + return f.read().strip() + except Exception as e: + if default is not None: + return default + raise e + + +class DisplayPlugin(HHDPlugin): + def __init__(self) -> None: + self.name = f"displayd" + self.priority = 4 + self.log = "disp" + + self.display = None + self.max_brightness = 255 + + def settings(self) -> HHDSettings: + if self.display: + return {"system": {"display": load_relative_yaml("settings.yml")}} + else: + return {} + + def open( + self, + emit, + context: Context, + ): + self.display = None + self.prev = None + for d in os.listdir(BACKLIGHT_DIR): + ddir = os.path.join(BACKLIGHT_DIR, d) + try: + read_sysfs(ddir, "brightness") + max_bright = int(read_sysfs(ddir, "max_brightness")) + self.display = ddir + self.max_brightness = max_bright + except Exception: + pass + + if self.display is None: + logger.warning(f"Display with variable brightness not found. Exitting.") + + def update(self, conf: Config): + if not self.display: + return + + curr = None + try: + requested = conf["system.display.brightness"].to(int) + + curr = int( + int(read_sysfs(self.display, "brightness", None)) + * 100 + / self.max_brightness + ) + + # Set brightness + if requested is not None and requested != self.prev: + changed = False + # If the change is too low the display might not make the + # change, so while loop and increase requested values + logger.info(f"Setting brightness to {requested}") + while not changed and (requested >= 0 and requested <= 100): + write_sysfs( + self.display, + "brightness", + int(self.max_brightness * requested / 100), + ) + + # Get brightness + new_curr = int( + int(read_sysfs(self.display, "brightness", None)) + * 100 + / self.max_brightness + ) + changed = new_curr != curr + curr = new_curr + + # In case the brightness did not change + # increase request + requested_old = requested + if curr > requested: + requested -= 1 + else: + requested += 1 + + if not changed: + logger.warning( + f"Could not set brightness to {requested_old}. Trying {requested}." + ) + + conf["general.display.brightness"] = curr + self.prev = curr + except Exception as e: + logger.error(f"Error while processing display settings:\n{type(e)}: {e}") + # Set conf to avoid repeated updates + conf["general.display.brightness"] = curr + + def close(self): + pass + + +def autodetect(existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: + if len(existing): + return existing + + return [DisplayPlugin()] diff --git a/src/hhd/plugins/display/settings.yml b/src/hhd/plugins/display/settings.yml new file mode 100644 index 00000000..b2781e44 --- /dev/null +++ b/src/hhd/plugins/display/settings.yml @@ -0,0 +1,17 @@ +type: container +tags: [system, non_steamdeck] +title: System +hint: >- + Basic display settings. Brightness (and framerate TBD). + This pane is meant to replace + +children: + brightness: + type: int + tags: [display_brightness, non_steamdeck] + title: Brightness + hint: >- + Sets the brightness level of a display. Only one display is supported and + it is the one that was read. + min: 0 + max: 100 diff --git a/src/hhd/plugins/gyro.yml b/src/hhd/plugins/gyro.yml new file mode 100644 index 00000000..698e12ed --- /dev/null +++ b/src/hhd/plugins/gyro.yml @@ -0,0 +1,50 @@ +type: mode +title: Motion Axis + +default: default +tags: [ non-essential ] +modes: + default: + type: container + title: Default + hint: >- + The default axis loaded for this device. + remapped: + type: container + title: Override + hint: >- + Remap and invert the axis of your device. If the axis of your device are + wrong, please submit a picture or a text version of the following. + children: + manufacturer: + type: display + title: Manufacturer + product: + type: display + title: Product + x_axis: &axis + type: multiple + options: + x: "X" + y: "Y" + z: "Z" + title: Axis X + default: "x" + x_invert: &invert + type: bool + default: False + title: Invert X + y_axis: + <<: *axis + title: Axis Y + default: "y" + y_invert: + <<: *invert + title: Invert Y + z_axis: + <<: *axis + title: Axis Z + default: "z" + z_invert: + <<: *invert + title: Invert Z diff --git a/src/hhd/plugins/inputs.py b/src/hhd/plugins/inputs.py new file mode 100644 index 00000000..81ff9344 --- /dev/null +++ b/src/hhd/plugins/inputs.py @@ -0,0 +1,160 @@ +from typing import cast, Literal + +from hhd.controller import Axis + +from .conf import Config +from .utils import load_relative_yaml + + +def get_product(): + try: + with open("/sys/devices/virtual/dmi/id/product_name", "r") as f: + return f.read().strip() + except Exception: + return "Uknown" + + +def get_vendor(): + try: + with open("/sys/devices/virtual/dmi/id/board_vendor", "r") as f: + return f.read().strip() + except Exception: + return "Uknown" + + +def get_touchpad_config(): + return load_relative_yaml("touchpad.yml") + + +def get_gyro_config( + mapping: dict[str, tuple[Axis, str | None, float, float | None]] | None +): + g = load_relative_yaml("gyro.yml") + g["modes"]["remapped"]["children"]["manufacturer"]["default"] = f'"{get_vendor()}"' + g["modes"]["remapped"]["children"]["product"]["default"] = f'"{get_product()}"' + if mapping: + for key, (ax, _, scale, _) in mapping.items(): + match key: + case "anglvel_x": + setting = "x" + case "anglvel_y": + setting = "y" + case "anglvel_z": + setting = "z" + case _: + setting = None + match ax: + case "gyro_x": + default = "x" + case "gyro_y": + default = "y" + case "gyro_z": + default = "z" + case _: + default = None + invert = scale < 0 + + if setting and default: + g["modes"]["remapped"]["children"][f"{setting}_axis"][ + "default" + ] = default + g["modes"]["remapped"]["children"][f"{setting}_invert"][ + "default" + ] = invert + return g + + +def get_gyro_state( + conf: Config, + default: dict[str, tuple[Axis, str | None, float, float | None]], +) -> dict[str, tuple[Axis, str | None, float, float | None]]: + if conf["mode"].to(str) == "default": + return default + + rem = conf.get("remapped", {}) + return { + "timestamp": ("imu_ts", None, 1, None), + "accel_x": ( + cast(Axis, f"accel_{rem.get('x_axis', 'x')}"), + "accel", + -1 if rem.get("x_invert", False) else 1, + 3, + ), + "accel_y": ( + cast(Axis, f"accel_{rem.get('y_axis', 'y')}"), + "accel", + -1 if rem.get("y_invert", False) else 1, + 3, + ), + "accel_z": ( + cast(Axis, f"accel_{rem.get('z_axis', 'z')}"), + "accel", + -1 if rem.get("z_invert", False) else 1, + 3, + ), + "anglvel_x": ( + cast(Axis, f"gyro_{rem.get('x_axis', 'x')}"), + "anglvel", + -1 if rem.get("x_invert", False) else 1, + None, + ), + "anglvel_y": ( + cast(Axis, f"gyro_{rem.get('y_axis', 'y')}"), + "anglvel", + -1 if rem.get("y_invert", False) else 1, + None, + ), + "anglvel_z": ( + cast(Axis, f"gyro_{rem.get('z_axis', 'z')}"), + "anglvel", + -1 if rem.get("z_invert", False) else 1, + None, + ), + } + + +AxChoice = Literal["x", "y", "z"] + + +def gen_gyro_state( + x: AxChoice, inv_x: bool, y: AxChoice, inv_y: bool, z: AxChoice, inv_z: bool +): + return { + "timestamp": ("imu_ts", None, 1, None), + "accel_x": ( + cast(Axis, f"accel_{x}"), + "accel", + -1 if inv_x else 1, + None, + ), + "accel_y": ( + cast(Axis, f"accel_{y}"), + "accel", + -1 if inv_y else 1, + None, + ), + "accel_z": ( + cast(Axis, f"accel_{z}"), + "accel", + -1 if inv_z else 1, + None, + ), + "anglvel_x": ( + cast(Axis, f"gyro_{x}"), + "anglvel", + -1 if inv_x else 1, + None, + ), + "anglvel_y": ( + cast(Axis, f"gyro_{y}"), + "anglvel", + -1 if inv_y else 1, + None, + ), + "anglvel_z": ( + cast(Axis, f"gyro_{z}"), + "anglvel", + -1 if inv_z else 1, + None, + ), + } diff --git a/src/hhd/plugins/limits.yml b/src/hhd/plugins/limits.yml new file mode 100644 index 00000000..93ad6ab7 --- /dev/null +++ b/src/hhd/plugins/limits.yml @@ -0,0 +1,77 @@ +type: mode +tags: [limits, non-essential] +title: Deadzones & Vibration +hint: >- + Configure joystick and trigger deadzones, vibration intensity. + +default: default +modes: + default: + type: container + title: Default + hint: >- + Uses reasonable values based on hardware. + manual: + type: container + title: Manual + hint: >- + Allows for manual configuration of deadzones and vibration intensity. + children: + vibration: + type: int + title: Vibration Intensity + hint: >- + Intensity of the vibration. The higher the value, the stronger the vibration. + default: 100 + unit: "%" + min: 0 + max: 100 + step: 20 + + ls_min: &s_min + type: int + title: Left Stick Minimum + hint: >- + Deadzone for the joystick. The higher the value, the more the joystick + needs to be moved before registering. + default: 5 + unit: "%" + min: 0 + max: 100 + step: 2 + ls_max: &s_max + type: int + title: Left Stick Maximum + hint: >- + Maximum value for joystick. The higher the value, the more the joystick + needs to be moved before reaching maximum. + default: 95 + unit: "%" + min: 0 + max: 100 + step: 2 + rs_min: + <<: *s_min + title: Right Stick Minimum + rs_max: + <<: *s_max + title: Right Stick Maximum + + lt_min: + <<: *s_min + title: Left Trigger Minimum + lt_max: + <<: *s_max + title: Left Trigger Maximum + rt_min: + <<: *s_min + title: Right Trigger Minimum + rt_max: + <<: *s_max + title: Right Trigger Maximum + + reset: + type: action + title: Reset to Default + hint: >- + Reset all values to default. diff --git a/src/hhd/plugins/outputs.py b/src/hhd/plugins/outputs.py new file mode 100644 index 00000000..e6887d03 --- /dev/null +++ b/src/hhd/plugins/outputs.py @@ -0,0 +1,349 @@ +import logging +from typing import Any, Literal, Mapping, Sequence + +import os +from ..controller.base import Consumer, Producer, RgbMode, RgbSettings, RgbZones +from ..controller.virtual.dualsense import Dualsense, TouchpadCorrectionType +from ..controller.virtual.uinput import ( + CONTROLLER_THEMES, + GAMEPAD_BUTTON_MAP, + HHD_PID_TOUCHPAD, + HORIPAD_STEAM_BUTTON_MAP, + MOTION_AXIS_MAP, + MOTION_AXIS_MAP_FLIP_Z, + MOTION_CAPABILITIES, + MOTION_INPUT_PROPS, + TOUCHPAD_AXIS_MAP, + TOUCHPAD_BUTTON_MAP, + TOUCHPAD_CAPABILITIES, + XBOX_ELITE_BUTTON_MAP, + UInputDevice, +) +from .plugin import is_steam_gamepad_running, open_steam_kbd +from .utils import load_relative_yaml + +logger = logging.getLogger(__name__) + +HORI_ENABLED = os.environ.get("HHD_HORI_STEAM", "0") == "1" + + +def get_outputs( + conf, + touch_conf, + motion: bool = False, + *, + controller_id: int = 0, + emit=None, + dual_motion: bool = False, + rgb_modes: Mapping[RgbMode, Sequence[RgbSettings]] | None = None, + rgb_zones: RgbZones = "mono", + controller_disabled: bool = False, + touchpad_enable: Literal["disabled", "gamemode", "always"] | None = None, +) -> tuple[Sequence[Producer], Sequence[Consumer], Mapping[str, Any]]: + producers = [] + consumers = [] + nintendo_qam = False + + controller = conf["mode"].to(str) + desktop_disable = False + if touch_conf is not None: + touchpad = touch_conf["mode"].to(str) + correction = touch_conf["controller.correction"].to(TouchpadCorrectionType) + if touchpad in ("emulation", "controller"): + desktop_disable = touch_conf[touchpad]["desktop_disable"].to(bool) + elif touchpad_enable: + touchpad = "disabled" if touchpad_enable == "disabled" else "controller" + correction = "legos" # todo: make generic + desktop_disable = touchpad_enable == "gamemode" + else: + touchpad = "controller" + correction = "stretch" + + # Run steam check for touchpad + steam_check = ( + is_steam_gamepad_running(emit.ctx) if emit and desktop_disable else None + ) + match steam_check: + case True: + logger.info("Gamepadui active. Activating touchpad emulation.") + case False: + logger.info("Gamepadui closed. Disabling touchpad emulation.") + + has_qam = False + uses_touch = False + uses_leds = False + noob_mode = False + flip_z = False + + if controller_disabled: + controller = "hidden" + + match controller: + case "hidden": + # NOOP + UInputDevice.close_cached() + Dualsense.close_cached() + motion = False + noob_mode = conf.get("hidden.noob_mode", False) + case "dualsense": + UInputDevice.close_cached() + flip_z = conf["dualsense.flip_z"].to(bool) + uses_touch = touchpad == "controller" and steam_check is not False + uses_leds = conf.get("dualsense.led_support", False) + paddles_as = conf.get("dualsense.paddles_as", "noob") + noob_mode = paddles_as in ("noob", "both") + edge_mode = False + + if paddles_as == "both": + paddles_to_clicks = "bottom" + elif paddles_as == "touchpad": + paddles_to_clicks = "top" + elif paddles_as == "steam_input": + edge_mode = True + paddles_to_clicks = "disabled" + else: + paddles_to_clicks = "disabled" + + d = Dualsense( + touchpad_method=correction, + edge_mode=edge_mode, + use_bluetooth=conf["dualsense.bluetooth_mode"].to(bool), + enable_touchpad=uses_touch, + enable_rgb=uses_leds, + fake_timestamps=not motion, + sync_gyro=conf["dualsense.sync_gyro"].to(bool) and motion, + paddles_to_clicks=paddles_to_clicks, + flip_z=flip_z, + controller_id=controller_id | (0xF0 if edge_mode else 0), + cache=True, + ) + producers.append(d) + consumers.append(d) + case "uinput" | "xbox_elite" | "joycon_pair" | "hori_steam": + Dualsense.close_cached() + version = 1 + sync_gyro = False + paddles_as = conf.get("uinput.paddles_as", "noob") + if controller == "joycon_pair": + theme = "joycon_pair" + nintendo_qam = conf["joycon_pair.nintendo_qam"].to(bool) + button_map = GAMEPAD_BUTTON_MAP + bus = 0x06 + version = 0 + elif controller == "hori_steam": + theme = "hori_steam" + noob_mode = conf.get("hori_steam.noob_mode", False) + flip_z = conf["hori_steam.flip_z"].to(bool) + button_map = HORIPAD_STEAM_BUTTON_MAP + bus = 0x06 + version = 0 + sync_gyro = conf.get("hori_steam.sync_gyro", True) + has_qam = True + elif controller == "xbox_elite" or ( + controller == "uinput" and paddles_as == "steam_input" + ): + theme = "xbox_one_elite" + button_map = XBOX_ELITE_BUTTON_MAP + bus = 0x03 + else: + noob_mode = paddles_as == "noob" + # theme = conf.get("uinput.theme", "hhd") + theme = "hhd" + nintendo_qam = conf["uinput.nintendo_qam"].to(bool) + # flip_z = conf["uinput.flip_z"].to(bool) + flip_z = False + button_map = GAMEPAD_BUTTON_MAP + bus = 0x03 if theme == "hhd" else 0x06 + vid, pid, name = CONTROLLER_THEMES[theme] + addr = "phys-hhd-main" + if controller_id: + addr = f"phys-hhd-{controller_id:02d}" + d = UInputDevice( + name=name, + vid=vid, + pid=pid, + phys=addr, + uniq=addr, + btn_map=button_map, + bus=bus, + version=version, + cache=True, + sync_gyro=sync_gyro and motion, + ) + producers.append(d) + consumers.append(d) + # Deactivate motion if using an xbox theme + motion = (theme != "hhd" and "xbox" not in theme) and motion + if motion: + d = UInputDevice( + name=f"{name} Motion Sensors", + vid=vid, + pid=pid, + phys=addr, + uniq=addr, + bus=bus, + version=version, + capabilities=MOTION_CAPABILITIES, + btn_map={}, + axis_map=(MOTION_AXIS_MAP_FLIP_Z if flip_z else MOTION_AXIS_MAP), + output_imu_timestamps=True, + input_props=MOTION_INPUT_PROPS, + ignore_cmds=True, + cache=True, + motions_device=True, + ) + producers.append(d) + consumers.append(d) + case _: + raise RuntimeError(f"Invalid controller type: '{controller}'.") + + dual_motion = motion and dual_motion + if dual_motion: + d = Dualsense( + edge_mode=False, + use_bluetooth=True, + enable_touchpad=False, + enable_rgb=False, + fake_timestamps=False, + sync_gyro=True, + paddles_to_clicks="disabled", + flip_z=flip_z, + controller_id=5, + cache=True, + left_motion=True, + ) + producers.append(d) + consumers.append(d) + + if touchpad == "emulation" and steam_check is not False and not controller_disabled: + d = UInputDevice( + name="Handheld Daemon Touchpad", + phys="phys-hhd-main", + capabilities=TOUCHPAD_CAPABILITIES, + pid=HHD_PID_TOUCHPAD, + btn_map=TOUCHPAD_BUTTON_MAP, + axis_map=TOUCHPAD_AXIS_MAP, + output_timestamps=True, + ignore_cmds=True, + ) + producers.append(d) + consumers.append(d) + uses_touch = True + + return ( + producers, + consumers, + { + "uses_touch": uses_touch, + "rgb_used": uses_leds, + "rgb_modes": rgb_modes, + "rgb_zones": rgb_zones, + "is_dual": False, + "steam_check": steam_check, + "steam_check_fn": lambda: emit and is_steam_gamepad_running(emit.ctx), + "steam_kbd": lambda open: open_steam_kbd(emit, open), + "nintendo_qam": nintendo_qam, + "uses_motion": motion, + "uses_dual_motion": dual_motion, + "noob_mode": noob_mode, + "has_qam": has_qam, + "supports_qam": not controller_disabled and controller != "hidden", + }, + ) + + +def get_outputs_config( + can_disable: bool = False, + has_leds: bool = True, + start_disabled: bool = False, + default_device: str | None = None, + extra_buttons: Literal["none", "dual", "quad"] = "dual", +): + s = load_relative_yaml("outputs.yml") + try: + if not can_disable: + del s["modes"]["disabled"] + if not has_leds: + del s["modes"]["dualsense"]["children"]["led_support"] + + if extra_buttons == "none": + del s["modes"]["dualsense"]["children"]["paddles_as"] + del s["modes"]["uinput"]["children"]["paddles_as"] + del s["modes"]["hidden"]["children"]["noob_mode"] + del s["modes"]["xbox_elite"] + elif extra_buttons == "dual": + del s["modes"]["dualsense"]["children"]["paddles_as"]["options"]["both"] + + if HORI_ENABLED: + # Replace xbox elite with hori + try: + del s["modes"]["xbox_elite"] + except Exception: + pass + else: + del s["modes"]["hori_steam"] + + # Set xbox as default for now + s["default"] = "uinput" + + # if default_device: + # s["default"] = default_device + if start_disabled: + s["default"] = "disabled" + except Exception as e: + logger.exception(f"Error fixing outputs:\n{e}") + return s + + +def get_limits_config(defaults: dict[str, int] = {}): + s = load_relative_yaml("limits.yml") + lims = s["modes"]["manual"]["children"] + + lims["ls_min"]["default"] = defaults.get("s_min", 0) + lims["ls_max"]["default"] = defaults.get("s_max", 95) + lims["rs_min"]["default"] = defaults.get("s_min", 0) + lims["rs_max"]["default"] = defaults.get("s_max", 95) + + lims["rt_min"]["default"] = defaults.get("t_min", 0) + lims["rt_max"]["default"] = defaults.get("t_max", 95) + lims["lt_min"]["default"] = defaults.get("t_min", 0) + lims["lt_max"]["default"] = defaults.get("t_max", 95) + + return s + + +def get_limits(conf, defaults={}): + if conf["mode"].to(str) != "manual": + return defaults + + kconf = conf["manual"] + for set in ("ls", "rs", "lt", "rt"): + if kconf[f"{set}_min"].to(int) > kconf[f"{set}_max"].to(int): + kconf[f"{set}_min"] = kconf[f"{set}_max"].to(int) + return kconf.to(dict) + + +def fix_limits(conf, prefix: str, defaults: dict[str, int] = {}): + if conf[f"{prefix}.mode"].to(str) != "manual": + return {} + + # Make sure min < max + for set in ("ls", "rs", "lt", "rt"): + if conf[f"{prefix}.manual.{set}_min"].to(int) > conf[ + f"{prefix}.manual.{set}_max" + ].to(int): + conf[f"{prefix}.manual.{set}_min"] = conf[f"{prefix}.manual.{set}_max"].to( + int + ) + + # Allow reseting limits + if conf[f"{prefix}.manual.reset"].to(bool): + for set in ("l", "r"): + for comp in ("t", "s"): + conf[f"{prefix}.manual.{set}{comp}_min"] = defaults.get( + f"{comp}_min", 0 + ) + conf[f"{prefix}.manual.{set}{comp}_max"] = defaults.get( + f"{comp}_max", 95 + ) + conf[f"{prefix}.manual.reset"] = False diff --git a/src/hhd/plugins/outputs.yml b/src/hhd/plugins/outputs.yml new file mode 100644 index 00000000..0b38c8e8 --- /dev/null +++ b/src/hhd/plugins/outputs.yml @@ -0,0 +1,214 @@ +default: dualsense +modes: + # + # No emulation + # + hidden: + type: container + tags: [lgc_emulation_disabled] + title: Hidden + hint: >- + Disables the controller. Handheld Daemon overlay will still work in gamemode. + children: + info: + type: display + # title: Capabilities + tags: [non-essential] + default: | + Disables the built-in controller. + External controllers will be slot 1. + Overlay will still work. + noob_mode: + type: bool + title: Extra buttons as Keyboard/Overlay + tags: [non-essential] + default: True + hint: >- + Makes the left paddle bring up a keyboard and the right paddle bring + up the overlay. + # + # evdev through uinput + # + uinput: + type: container + tags: [uinput] + title: Xbox + children: + # flip_z: + # type: bool + # title: Invert Roll Axis + # tags: [non-essential] + # default: True + # hint: >- + # Inverts the roll (Z) axis compared to a real Dualsense controller. + # Useful for Steam Input, since you want it to be inverted to look + # left to right, but an issue in emulators. + info: + type: display + # title: Capabilities + tags: [non-essential] + default: | + Simple Xbox controller. + Universal compatibility. + Extra buttons are shortcuts or Steam Input. + + paddles_as: + type: multiple + title: Extra buttons as + tags: [non-essential] + hint: >- + Changes the behavior of the extra buttons. + Left button is Keyboard, right button is Overlay. + Or they can be set for Steam Input. + options: + steam_input: Steam Input (Elite) + noob: Keyboard/Overlay + disabled: Disabled + default: noob + + nintendo_qam: + type: bool + title: Nintendo QAM Fix + tags: [advanced, expert] + default: False + xbox_elite: + type: container + tags: [xbox_elite] + title: Xbox Elite + children: + info: + type: display + # title: Capabilities + tags: [non-essential] + default: | + Extra buttons in Steam Input. + + hori_steam: + type: container + tags: [hori_steam, non-essential] + title: Steam Controller + hint: >- + Allows for gyro, paddles, and has a proper QAM button. + children: + info: + type: display + # title: Capabilities + tags: [non-essential] + default: | + Allows for gyro, paddles, and has a proper QAM button. + noob_mode: + type: bool + title: Extra buttons as Keyboard/Overlay + tags: [non-essential] + default: False + hint: >- + Makes the left paddle bring up a keyboard and the right paddle bring + up the overlay. + sync_gyro: + type: bool + title: Gyro Output Sync + tags: [non-essential] + hint: >- + Steam relies on the IMU timestamp for the touchpad as Mouse and `Gyro to Mouse [BETA]`. + If the same timestamp is sent in 2 reports, this causes a division by 0 and instability. + This option makes it so reports are sent only when there is a new + IMU timestamp, effectively limiting the responsiveness of the + controller to that of the IMU. + This only makes a difference for the Legion Go (125hz), as all the other + handhelds are using 400hz by default. + default: True + flip_z: + type: bool + title: Invert Roll Axis + tags: [non-essential] + default: True + hint: >- + Inverts the roll (Z) axis compared to a real Horipad controller. + Useful for Steam Input, since you want it to be inverted to look + left to right, but an issue in emulators. + # + # Dualsense 5 + # + dualsense: + type: container + tags: [lgc_emulation_dualsense, dualsense, non-essential] + title: Dualsense + + children: + info: + type: display + # title: Capabilities + tags: [non-essential] + default: | + Touchpad, gyro, and RGB support. Extra buttons are remappable. + + paddles_as: + type: multiple + title: Extra buttons as + hint: >- + Changes the behavior of the extra buttons. + Left button is Keyboard, right button is Overlay. + Or they can be left/right touchpad clicks. + For the legion go, top buttons are shortcuts, bottom are + touchpad clicks. + options: + steam_input: Steam Input (Edge) + noob: Keyboard/Overlay + touchpad: Touchpad Clicks + both: Shortcuts + Touchpad Clicks + disabled: Disabled + default: steam_input + + led_support: + type: bool + title: LED Support + hint: >- + Passes through the LEDs to the controller, which allows games + to control them. + default: False + + sync_gyro: + type: bool + title: Gyro Output Sync + hint: >- + Steam relies on the IMU timestamp for the touchpad as Mouse and `Gyro to Mouse [BETA]`. + If the same timestamp is sent in 2 reports, this causes a division by 0 and instability. + This option makes it so reports are sent only when there is a new + IMU timestamp, effectively limiting the responsiveness of the + controller to that of the IMU. + This only makes a difference for the Legion Go (125hz), as all the other + handhelds are using 400hz by default. + default: True + + flip_z: + type: bool + title: Invert Roll Axis + default: True + hint: >- + Inverts the roll (Z) axis compared to a real Dualsense controller. + Useful for Steam Input, since you want it to be inverted to look + left to right, but an issue in emulators. + + bluetooth_mode: + type: bool + title: Bluetooth Mode + tags: [advanced, expert] + hint: >- + Emulates the controller in bluetooth mode instead of USB mode. + This is the default as it causes less issues with how apps + interact with the controller. + However, using USB mode can improve LED support (?) in some games. + Test and report back! + default: True + + disabled: + type: container + tags: [lgc_emulation_disabled, expert, non-essential] + title: Paused + children: + info: + type: display + # title: Capabilities + tags: [non-essential] + default: | + Pauses controller emulation. \ No newline at end of file diff --git a/src/hhd/plugins/overlay/__init__.py b/src/hhd/plugins/overlay/__init__.py new file mode 100644 index 00000000..25676d6d --- /dev/null +++ b/src/hhd/plugins/overlay/__init__.py @@ -0,0 +1,367 @@ +import logging +import os +from threading import Event as TEvent +from threading import Thread +from typing import Sequence + +from hhd.plugins import Config, Context, Event, HHDPlugin, load_relative_yaml +from hhd.utils import expanduser + +from ..plugin import open_steam_kbd +from .const import get_system_info, get_touchscreen_quirk +from .controllers import QamHandlerKeyboard, device_shortcut_loop +from .steam import get_games +from .x11 import is_gamescope_running + +logger = logging.getLogger(__name__) + +SHORTCUT_RELOAD_DELAY = 2 + +HHD_OVERLAY_DISABLE = os.environ.get("HHD_OVERLAY_DISABLE", "0") == "1" +FORCE_GAME = os.environ.get("HHD_FORCE_GAME_ID", None) +SUPPORTS_HALVING = os.environ.get("HHD_GS_STEAMUI_HALFHZ", "0") == "1" +SUPPORTS_DPMS = os.environ.get("HHD_GS_DPMS", "0") == "1" + + +def load_steam_games(ctx: Context, emit, burnt_ids: set): + # Defer loading until we enter a game + info = emit.info + curr = info.get("game.id", None) + # Lump steam into none to avoid loading twice + if info.get("game.is_steam", False): + curr = None + + # If the game changes and we do not have data for it do a reload + if curr in burnt_ids: + return None, None + + if "games" in info and curr in info.get("games", {}): + return None, None + + # Maybe a game is missing from appcache, if it is burn it + # so we dont try to load the library again + burnt_ids.add(curr) + + try: + # Load the games + games, images = get_games(expanduser("~/.local/share/Steam/appcache/", ctx)) + logger.info(f"Loaded info for {len(games)} steam games.") + + # Add correct game data after refreshing the database (e.g., the user + # downloaded a new game) + if curr and curr in games: + emit.info["game.data"] = games[curr] + + return games, images + except Exception as e: + logger.warning(f"Could not load steam games:\n{e}") + return None, None + + +class OverlayPlugin(HHDPlugin): + def __init__(self) -> None: + self.name = f"overlay" + self.priority = 75 + self.log = "ovrl" + self.ovf = None + self.initialized = False + self.old_shortcuts = None + self.short_should_exit = None + self.has_correction = True + self.old_touch = False + self.old_asus_cycle = None + self.short_t = None + self.has_executable = False + self.qam_handler = None + self.qam_handler_fallback = None + self.touch_gestures = True + self.ctx = None + self.emit = None + + self.images = None + self.burnt_ids = set() + + def open( + self, + emit, + context: Context, + ): + try: + from .base import OverlayService + from .overlay import find_overlay_exe + from .x11 import QamHandlerGamescope + + self.ovf = OverlayService(context, emit) + self.ctx = context + self.has_executable = bool(find_overlay_exe(context)) + + if bool(os.environ.get("HHD_QAM_KEYBOARD", None)): + # Sends the events as ctrl+1, ctrl+2 + self.qam_handler = QamHandlerKeyboard() + elif bool(os.environ.get("HHD_QAM_GAMESCOPE", None)): + # Sends X11 events to gamescope. Stopped working after libei + self.qam_handler = QamHandlerGamescope(context) + else: + self.qam_handler = None + + if self.qam_handler: + emit.register_qam(self.qam_handler) + else: + self.qam_handler_fallback = QamHandlerKeyboard() + self.emit = emit + except Exception as e: + logger.warning( + f"Could not init overlay service, is python-xlib installed? Error:\n{e}" + ) + self.ovf = None + + def settings(self): + if not self.ovf: + return {} + + self.initialized = True + set = { + "gamemode": load_relative_yaml("gamemode.yml"), + "shortcuts": load_relative_yaml("shortcuts.yml"), + } + + if not SUPPORTS_HALVING: + del set["gamemode"]["gamescope"]["children"]["steamui_halfhz"] + if not SUPPORTS_DPMS: + del set["gamemode"]["gamescope"]["children"]["dpms"] + + if get_touchscreen_quirk(None, None)[0] and not os.environ.get( + "HHD_ALLOW_CORRECTION", None + ): + # For devices with a dmi match, hide orientation correction + self.has_correction = False + del set["shortcuts"]["touchscreen"]["children"]["orientation"] + else: + self.has_correction = True + set["shortcuts"]["touchscreen"]["children"]["orientation"]["modes"][ + "manual" + ]["children"]["dmi"]["default"] = " - ".join( + map(lambda x: f'"{x}"', get_system_info()) + ) + return set + + def update(self, conf: Config): + if not self.emit: + return + + self.emit.set_simple_qam(not self.has_executable) + + # Load game information + if self.ctx: + games, images = load_steam_games(self.ctx, self.emit, self.burnt_ids) + if games and images: + self.emit.set_gamedata(games, images) + if FORCE_GAME: + self.emit.info["game.id"] = FORCE_GAME + self.emit.info["game.is_steam"] = False + self.emit.info["game.data"] = self.emit.get_gamedata(FORCE_GAME) + if self.ovf: + self.ovf.launch_overlay() + + self.touch_gestures = not bool( + conf.get("gamemode.display.gestures_disable", False) + ) + if SUPPORTS_HALVING and self.ovf: + self.ovf.gsconf["steamui_halfhz"] = conf.get( + "gamemode.gamescope.steamui_halfhz", False + ) + if SUPPORTS_DPMS and self.ovf: + self.ovf.gsconf["dpms"] = conf.get("gamemode.gamescope.dpms", False) + + disable_touch = conf.get("gamemode.display.touchscreen_disable", False) + if disable_touch is None: + # Initialize value since there is no default + disable_touch = False + conf["gamemode.display.touchscreen_disable"] = False + + asus_cycle = conf.get("tdp.asus.cycle_tdp", False) + if self.initialized and ( + not self.old_shortcuts + or self.old_shortcuts != conf["shortcuts"] + or self.old_touch != disable_touch + or self.old_asus_cycle != asus_cycle + ): + self.old_asus_cycle = asus_cycle + self.old_shortcuts = conf["shortcuts"].copy() + self._close_short() + + kbd = False + for v in ("meta_press", "meta_hold", "ctrl_3", "ctrl_4"): + kbd = ( + kbd or conf.get(f"shortcuts.keyboard.{v}", "disabled") != "disabled" + ) + touch = False + for v in ("bottom", "left_top", "left_bottom", "right_top", "right_bottom"): + touch = ( + touch + or conf.get(f"shortcuts.touchscreen.{v}", "disabled") != "disabled" + ) + # ctrl = ( + # conf.get("shortcuts.controller.xbox_b", "disabled") != "disabled" + # or asus_cycle + # ) + # For now always monitor controllers to be able to grab + ctrl = True + # if self.ovf: + # self.ovf.interceptionSupported = True + + if kbd or touch or ctrl or disable_touch: + logger.info( + f"Starting shortcut loop with:\nkbd: {kbd}, touch: {touch}, ctrl: {ctrl}, disable_touch: {disable_touch}" + ) + self.short_should_exit = TEvent() + touch_correction = ( + conf.get("shortcuts.touchscreen.orientation.manual", None) + if self.has_correction + and conf.get("shortcuts.touchscreen.orientation.mode", "auto") + == "manual" + else None + ) + self.short_t = Thread( + target=device_shortcut_loop, + args=( + self.emit, + self.short_should_exit, + False, + kbd, + ctrl, + touch, + disable_touch, + touch_correction, + ), + ) + self.short_t.start() + self.old_touch = disable_touch + else: + logger.info("No shortcuts enabled, not starting shortcut loop.") + + def notify(self, events: Sequence[Event]): + if self.ovf: + self.ovf.notify(events) + + for ev in events: + if ev["type"] != "special": + continue + + side = None + section = None + override_enable = False + match ev["event"]: + case gesture if gesture.startswith("swipe_"): + if self.touch_gestures: + side = gesture[len("swipe_") :] + section = "touchscreen" + cmd = None + case gesture if gesture.startswith("kbd_"): + if is_gamescope_running(): + # Only allow kbd shortcuts while gamescope is open + # Cannot be used in big picture because KDE/GNOME + side = gesture[len("kbd_") :] + section = "keyboard" + cmd = None + case "xbox_b": + side = "xbox_b" + section = "controller" + case "xbox_y": + side = "xbox_y" + section = "controller" + case "qam_hold": + # Open QAM with hold for accessibility + cmd = "open_qam" + case "qam_predouble": + cmd = "open_qam_if_closed" + case "qam_double": + # Preferred bind for QAM is dual press + cmd = "open_qam" + case "overlay": + override_enable = True + cmd = "open_qam" + case "qam_triple": + # Allow opening expanded menu with tripple press + cmd = "open_expanded" + case _: + cmd = None + + if section and side and self.old_shortcuts: + logger.info(f"Gesture: {ev['event']}, section: {section}, key: {side}") + cmd_raw = self.old_shortcuts.get(f"{section}.{side}", "disabled") + cmd = None + match cmd_raw: + case "disconnect": + d = ev.get("data", None) + uniq = d.get("uniq", None) if d else None + import re + + # Make sure uniq is kind of a mac address + # We are a root level daemon + if uniq and re.match(r"([\d:]+)", uniq): + logger.warning( + f"Disconnecting controller with uniq: {uniq}" + ) + os.system("bluetoothctl disconnect " + uniq) + case "hhd_qam": + cmd = "open_qam" + case "hhd_expanded": + cmd = "open_expanded" + case "steam_qam": + logger.info("Opening steam qam.") + if ( + not self.emit.open_steam(False) + and self.qam_handler_fallback + ): + self.qam_handler_fallback(False) + case "steam_expanded": + logger.info("Opening steam expanded.") + if not self.emit.open_steam(True) and self.qam_handler_fallback: + self.qam_handler_fallback(True) + case "keyboard": + if open_steam_kbd(self.emit, True): + logger.info("Opened Steam keyboard.") + else: + logger.warning( + "Could not open Steam keyboard. Is Steam running?" + ) + case "screenshot": + logger.info("Taking screenshot.") + if self.qam_handler and hasattr(self.qam_handler, "screenshot"): + getattr(self.qam_handler, "screenshot")() + elif self.qam_handler_fallback: + self.qam_handler_fallback.screenshot() + + if self.ovf and cmd: + init = "close" not in cmd + if init: + logger.info(f"Executing overlay command: '{cmd}'") + self.ovf.update(cmd, init) + + def _close_short(self): + if self.short_should_exit: + self.short_should_exit.set() + self.short_should_exit = None + if self.short_t: + self.short_t.join() + self.short_t = None + + def close(self): + if self.ovf: + self.ovf.close() + if self.qam_handler: + self.qam_handler.close() + if self.qam_handler_fallback: + self.qam_handler_fallback.close() + self._close_short() + + +def autodetect(existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: + if HHD_OVERLAY_DISABLE: + return [] + + if len(existing): + return existing + + return [OverlayPlugin()] diff --git a/src/hhd/plugins/overlay/base.py b/src/hhd/plugins/overlay/base.py new file mode 100644 index 00000000..22292aef --- /dev/null +++ b/src/hhd/plugins/overlay/base.py @@ -0,0 +1,502 @@ +import logging +import os +import select +import subprocess +import time +from threading import Event as TEvent +from threading import Thread +from typing import Literal, cast + +from Xlib import display + +from hhd.plugins import Config, Context, Emitter + +from .controllers import OverlayWriter +from .overlay import find_overlay_exe, inject_overlay, launch_overlay_de +from .systemd import WakeHandler +from .x11 import ( + HHD_ID, + STEAM_ID, + apply_gamescope_config, + does_steam_exist, + find_focusable_windows, + find_hhd, + find_steam, + find_x11_auth, + find_x11_display, + get_current_game, + get_gamescope_displays, + get_overlay_display, + hide_hhd, + make_hhd_not_focusable, + prepare_hhd, + process_events, + register_changes, + set_dpms, + show_hhd, + update_steam_values, +) + +logger = logging.getLogger(__name__) +Command = Literal[ + "close_now", + "close", + "open_qam", + "open_qam_if_closed", + "open_expanded", + "open_notification", +] +Status = Literal["closed", "qam", "expanded", "notification"] + +GUARD_CHECK = 0.5 +STARTUP_MAX_DELAY = 10 +LOOP_SLEEP = 0.05 +OVERLAY_CHECK_INTERVAL = 5 +GAME_CHECK_INTERVAL = 2 + +SUPPORTS_STANDBY = os.environ.get("HHD_GS_STANDBY", "0") == "1" + +def standby_transition(state: str): + if not SUPPORTS_STANDBY: + return + + try: + if not os.path.exists("/sys/power/standby"): + return + + with open("/sys/power/standby", "w") as f: + logger.info(f"Setting standby state to '{state}'.") + f.write(state) + except Exception as e: + logger.error(f"Failed to set standby state to {state}:\n{e}") + +def loop_manage_desktop( + proc: subprocess.Popen, + emit: Emitter, + writer: OverlayWriter, + should_exit: TEvent, +): + try: + assert proc.stderr and proc.stdout + + fd_out = proc.stdout.fileno() + fd_err = proc.stderr.fileno() + os.set_blocking(fd_out, False) + os.set_blocking(fd_err, False) + + while not should_exit.is_set(): + start = time.perf_counter() + select.select([fd_out, fd_err], [], [], GUARD_CHECK) + + if proc.poll() is not None: + logger.warning(f"Overlay stopped (steam may have restarted). Closing.") + return + + # Process system logs + while True: + l = proc.stderr.readline()[:-1] + if not l: + break + if l.strip(): + logger.info(f"UI: {l}") + + # Update overlay status + while True: + cmd = proc.stdout.readline()[:-1] + if not cmd: + break + elif cmd.startswith("grab:"): + enable = cmd[5:] == "enable" + emit.grab(enable) + if not enable: + writer.reset() + + elapsed = time.perf_counter() - start + if elapsed < LOOP_SLEEP: + time.sleep(LOOP_SLEEP - elapsed) + except Exception as e: + logger.warning(f"The overlay process ended with an exception:\n{e}") + finally: + logger.info(f"Stopping overlay process.") + proc.kill() + proc.wait() + emit.grab(False) + + +def loop_manage_overlay( + disp: display.Display, + proc: subprocess.Popen, + emit: Emitter, + writer: OverlayWriter, + should_exit: TEvent, + requested: bool, + gsconf: Config, +): + wake_handler = None + try: + status: Status = "closed" + + assert proc.stderr and proc.stdout + + fd_out = proc.stdout.fileno() + fd_err = proc.stderr.fileno() + os.set_blocking(fd_out, False) + os.set_blocking(fd_err, False) + fd_disp = disp.fileno() + gsprev = {} + + # Give electron time to warmup + start = time.perf_counter() + curr = start + while ( + curr - start < STARTUP_MAX_DELAY + and (not find_hhd(disp) or not find_focusable_windows(disp)) + and not should_exit.is_set() + ): + time.sleep(GUARD_CHECK) + curr = time.perf_counter() + + hhd = find_hhd(disp) + steam = find_steam(disp) + steam_exists = does_steam_exist(disp) + old_game = None + last_game_check = 0 + old = None + shown = False + wake_handler = None + dpms_time = None + + if hhd: + logger.info(f"UI window found in gamescope, starting handler.") + prepare_hhd(disp, hhd, steam) + if steam: + register_changes(disp, steam) + + while not should_exit.is_set(): + if not hhd: + logger.error(f"UI Window not found, exitting overlay.") + break + if not steam and steam_exists: + logger.error( + f"Steam window not found but steam is active, exitting overlay." + ) + break + + start = time.perf_counter() + fds = [fd_out, fd_err, fd_disp] + if wake_handler and wake_handler.fd != -1: + fds.append(wake_handler.fd) + select.select(fds, [], [], GUARD_CHECK) + + if proc.poll() is not None: + logger.warning(f"Overlay stopped (steam may have restarted). Closing.") + return + + # If steam tries to appear while the overlay is active + # yank its focus + process_events(disp) + apply_gamescope_config(disp, gsconf, gsprev) + + # Handle dpms + dpms_en = gsconf.get("dpms", False) + if dpms_en and not wake_handler: + wake_handler = WakeHandler() + ret = wake_handler.start() + if ret: + logger.info("Started DPMS handler.") + else: + logger.error("Failed to start wake handler, DPMS will not work.") + elif not dpms_en and wake_handler: + wake_handler.close() + wake_handler = None + logger.info("Stopped DPMS handler.") + if dpms_time: + set_dpms(disp, False) + if dpms_en and wake_handler: + s = wake_handler() + if s == "entry": + set_dpms(disp, True) + standby_transition("sleep") + dpms_time = start + logger.info("Enabling gamescope DPMS.") + wake_handler.inhibit(False) + elif s == "exit": + set_dpms(disp, False) + standby_transition("active") + dpms_time = None + logger.info("Disabling gamescope DPMS.") + wake_handler.inhibit(True) + if dpms_time and start - dpms_time > 5: + set_dpms(disp, False) + dpms_time = None + logger.error("DPMS timeout lapsed, disabling.") + + if steam and shown: + old, was_shown = update_steam_values(disp, steam, old) + if was_shown: + show_hhd(disp, hhd, steam) + logger.warning("Steam opened, hiding it.") + + if start - last_game_check > GAME_CHECK_INTERVAL: + game = get_current_game(disp) + if old_game != game: + emit.info["game.id"] = str(game) + is_steam = game in (STEAM_ID, HHD_ID, 7) + emit.info["game.is_steam"] = is_steam + game_data = emit.get_gamedata(str(game)) + name = game_data["name"] if game_data else "Unknown Title" + emit.info["game.data"] = game_data + if is_steam: + logger.info(f"Switched to steam.") + else: + logger.info(f"Switched to game {game}: '{name}'.") + old_game = game + + # If we are running on a headless session + # make sure hhd cant be focused + if not steam and not shown: + make_hhd_not_focusable(disp) + + # Process system logs + while True: + l = proc.stderr.readline()[:-1] + if not l: + break + if l.strip(): + logger.info(f"UI: {l}") + + # Update overlay status + while True: + cmd = proc.stdout.readline()[:-1] + if not cmd: + break + if cmd.startswith("stat:"): + status = cast(Status, cmd[5:]) + if status == "closed": + if shown: + hide_hhd(disp, hhd, steam, old) + old = None + writer.reset() + # Prevent grabbing when the UI is not shown + emit.grab(False) + shown = False + else: + if not shown: + if steam: + old, _ = update_steam_values(disp, steam, None) + show_hhd(disp, hhd, steam) + writer.reset() + shown = True + elif cmd.startswith("grab:"): + enable = cmd[5:] + emit.grab(enable == "enable") + + # Sleep a bit to avoid running too often + # Only do so if the earlier sleep was too short to avoid having + # steam slipping in the UI and flashing the screen + elapsed = time.perf_counter() - start + if elapsed < LOOP_SLEEP: + time.sleep(LOOP_SLEEP - elapsed) + except Exception as e: + logger.warning(f"The overlay process ended with an exception:\n{e}") + finally: + logger.info(f"Stopping overlay process.") + try: + writer.write("cmd:close\n") + time.sleep(0.2) + except Exception as e: + logger.error(f"Error informing overlay:\n{e}") + proc.kill() + proc.wait() + emit.grab(False) + if wake_handler: + wake_handler.close() + # Make sure we leave on the active state + standby_transition("active") + + +class OverlayService: + def __init__(self, ctx: Context, emit: Emitter) -> None: + self.ctx = ctx + self.started = False + self.t = None + self.should_exit = None + self.emit = emit + self.proc = None + self.interceptionSupported = True + self.last_check = None + self.installed = True + self.gsconf = Config() + + def launch_overlay(self): + if not self.installed: + return + curr = time.perf_counter() + if self.last_check and curr - self.last_check < OVERLAY_CHECK_INTERVAL: + return + self.last_check = curr + + launched = self._open_overlay(requested=False) + if launched and not self.is_healthy(): + self.started = False + + def _open_overlay(self, requested=False): + # Should not be called by outsiders + # requires special permissions and error handling by update + if self.started or not self.installed: + return True + + displays = get_gamescope_displays() + if not displays: + if requested: + logger.warning( + "Could not find overlay displays, gamescope is not active." + ) + return False + if requested: + logger.debug(f"Found the following gamescope displays: {displays}") + + res = get_overlay_display(displays, self.ctx) + if not res: + if requested: + logger.error( + f"Could not find overlay display in gamescope displays. This should never happen." + ) + return False + + logger.info("Attempting to launch overlay.") + + exe = find_overlay_exe(self.ctx) + if not exe: + logger.warning("Overlay is not installed, not launching.") + self.installed = False + return False + logger.info(f"Found overlay executable '{exe}'") + disp, name = res + logger.debug(f"Overlay display is the following: DISPLAY={name}") + + self.proc = inject_overlay(exe, name, self.ctx) + self.writer = OverlayWriter(self.proc.stdin, mute=self.interceptionSupported) + self.emit.register_intercept(self.writer) + self.should_exit = TEvent() + self.t = Thread( + target=loop_manage_overlay, + args=( + disp, + self.proc, + self.emit, + self.writer, + self.should_exit, + requested, + self.gsconf, + ), + ) + self.t.start() + + self.started = True + logger.info("Overlay launched.") + return True + + def _open_de(self): + # Allow opening the overlay in desktop + # wayland only, somewhat hardcoded. + if self.started: + return True + + # Launch the overlay + auth = find_x11_auth(self.ctx) + if not auth: + logger.warning("Could not find X11 authority file.") + return False + logger.info(f"Found X11 authority file:\n'{auth}'") + disp = find_x11_display(self.ctx) + if not disp: + logger.warning( + "Tried to find a wayland display to launch the overlay as an application and could not find it." + ) + return False + logger.info(f"Launching hhd-ui in display: {disp}") + exe = find_overlay_exe(self.ctx) + if not exe: + return False + self.proc = launch_overlay_de(exe, disp, auth, self.ctx) + + # Start a managing thread + self.writer = OverlayWriter(self.proc.stdin, mute=self.interceptionSupported) + self.emit.register_intercept(self.writer) + self.should_exit = TEvent() + self.t = Thread( + target=loop_manage_desktop, + args=(self.proc, self.emit, self.writer, self.should_exit), + ) + self.t.start() + self.started = True + self.started_de = True + + return self.proc.poll() is None + + def close(self): + if self.should_exit and self.t: + self.should_exit.set() + self.t.join() + self.should_exit = None + self.t = None + self.started = False + + def is_healthy(self): + if not self.t or not self.should_exit: + logger.error("'is_healthy' called before 'start'") + return False + + if not self.t.is_alive(): + logger.error("Overlay thread died") + return False + + return True + + def update(self, cmd: Command, init: bool): + # Accessing the user's display requires the user's priviledges + if not self.started and not init: + # This function is called with QAM single presses and guide presses + # do not initialize for those. + return + try: + ret = self._open_overlay() + if not ret: + self._open_de() + if not self.is_healthy(): + logger.warning(f"Overlay service died, attempting to restart.") + self.close() + + ret = self._open_overlay() + if not ret: + ret = self._open_de() + if not ret: + logger.error("Failed to start hhd-ui.") + return + + if not self.proc: + logger.error("Overlay subprocess is null. Should never happen.") + return + + self.writer.write(f"\ncmd:{cmd}\n") + except Exception as e: + logger.error(f"Failed launching overlay with error:\n{e}") + self.close() + + def notify(self, events): + if not SUPPORTS_STANDBY: + return + if not self.gsconf or not self.gsconf.get("dpms", False): + return + + for ev in events: + if ev.get("type", None) != "special": + continue + if ev.get("event", None) != "pbtn_short": + continue + + # If steam does not suspend us the following breaks: + # # Fire screen_off while the powerbutton event is happening + # logger.info("Powerbutton event detected, transitioning to standby.") + # standby_transition("screen_off") \ No newline at end of file diff --git a/src/hhd/plugins/overlay/const.py b/src/hhd/plugins/overlay/const.py new file mode 100644 index 00000000..b0564caf --- /dev/null +++ b/src/hhd/plugins/overlay/const.py @@ -0,0 +1,79 @@ +from typing import NamedTuple + + +class TouchScreenQuirk(NamedTuple): + portrait: bool + flip_x: bool # Left <-> Right + flip_y: bool # Top <-> Bottom + + +TQ = TouchScreenQuirk + + +class TouchScreenMatch(NamedTuple): + dmi: str | None = None + vid: int | None = None + pid: int | None = None + name: str | None = None + +DEFAULT_LANDSCAPE = TQ(False, True, False) + +TM = TouchScreenMatch + +TOUCH_SCREEN_QUIRKS = { + # Lenovo + TM("83E1", name="Legion GO"): TQ(True, False, False), + # MinisForum + TM("V3", name="MinisForum V3"): DEFAULT_LANDSCAPE, + # Steam deck + TM("Jupiter", name="Steam Deck LCD"): TQ(True, True, True), + TM("Galileo", name="Steam Deck OLED"): TQ(True, True, True), + # GPD + TM("G1618-04", name="GPD Win 4"): DEFAULT_LANDSCAPE, # 2023: 0x0416:0x038F + TM("G1619-04", name="GPD Win Max 2 (04)"): DEFAULT_LANDSCAPE, # 2023: 27C6:0113 + TM("G1619-05", name="GPD Win Max 2 (05)"): DEFAULT_LANDSCAPE, + # Asus + TM("RC71L", name="ROG Ally"): DEFAULT_LANDSCAPE, + TM("RC72LA", name="ROG Ally X"): DEFAULT_LANDSCAPE, + # Ayaneo + TM("KUN", name="Ayaneo Kun"): TQ(True, False, True), +} + + +def get_touchscreen_quirk(vid=None, pid=None): + try: + with open("/sys/class/dmi/id/product_name") as f: + dmi = f.read().strip() + except Exception: + return None, None + + for match, quirk in TOUCH_SCREEN_QUIRKS.items(): + if match.dmi and match.dmi not in dmi: + continue + if match.vid and match.vid != vid: + continue + if match.pid and match.pid != pid: + continue + + return ( + quirk, + match.name, + ) + + return None, None + + +def get_system_info(): + try: + with open("/sys/class/dmi/id/product_name") as f: + dmi = f.read().strip() + except Exception: + dmi = "" + + try: + with open("/sys/class/dmi/id/sys_vendor") as f: + vendor = f.read().strip() + except Exception: + vendor = "" + + return vendor, dmi diff --git a/src/hhd/plugins/overlay/controllers.py b/src/hhd/plugins/overlay/controllers.py new file mode 100644 index 00000000..af9d641b --- /dev/null +++ b/src/hhd/plugins/overlay/controllers.py @@ -0,0 +1,961 @@ +import ctypes +import logging +import os +import select +import struct +import time +from fcntl import ioctl +from threading import RLock +from typing import Any, Sequence +import stat + +from evdev import InputDevice + +from hhd.controller import Event as ControllerEvent +from hhd.controller.lib.ioctl import EVIOCSMASK, EVIOCGRABCLEAN +from hhd.controller.physical.evdev import B, list_evs, to_map +from hhd.controller.virtual.uinput.monkey import UInput, UInputMonkey + +from .const import get_touchscreen_quirk +from .x11 import is_gamescope_running + +logger = logging.getLogger(__name__) + +ENHANCED_HIDING = bool(os.environ.get("HHD_EVIOC_IOCTL", False)) + +REFRESH_INTERVAL = 0.1 +MONITOR_INTERVAL = 2 +OVERLAY_BUTTON_MAP: dict[int, str] = to_map( + { + "a": [B("BTN_A")], + "b": [B("BTN_B")], + "x": [B("BTN_X")], + "y": [B("BTN_Y")], + "lb": [B("BTN_TL")], + "rb": [B("BTN_TR")], + } +) +OVERLAY_AXIS_MAP: dict[int, str] = to_map( + { + # Values should range from -1 to 1 + "ls_x": [B("ABS_X")], + "ls_y": [B("ABS_Y")], + # Hat, implemented as axis. Either -1, 0, or 1 + "hat_x": [B("ABS_HAT0X")], + "hat_y": [B("ABS_HAT0Y")], + } +) + +CONTROLLER_WAKE_BUTTON: dict[int, str] = to_map( + { + "select": [B("BTN_SELECT")], + "mode": [B("BTN_MODE")], + "b": [B("BTN_B")], + "y": [B("BTN_Y")], + } +) + +TOUCH_WAKE_AXIS: dict[int, str] = to_map( + { + "slot": [B("ABS_MT_SLOT")], + "x": [B("ABS_MT_POSITION_X")], + "y": [B("ABS_MT_POSITION_Y")], + "id": [B("ABS_MT_TRACKING_ID")], + } +) + +KEYBOARD_WAKE_KEY: dict[int, str] = to_map( + { + "meta": [B("KEY_LEFTMETA")], + "ctrl": [B("KEY_LEFTCTRL")], + "3": [B("KEY_3")], + "4": [B("KEY_4")], + } +) + +REPEAT_INITIAL = 0.5 +REPEAT_INTERVAL = 0.2 + +GESTURE_TIME = 0.4 +# Inspired by Ruineka's old +# gamescope gestures (pre 3.14) +GESTURE_END = 0.03 +GESTURE_START = 0.02 +GESTURE_TOP_RATIO = 0.33 + +XBOX_B_MAX_PRESS = 0.3 +KBD_HOLD_DELAY = 0.5 + +# Cached vars +EV_ABS = B("EV_ABS") +EV_KEY = B("EV_KEY") +EV_SYN = B("EV_SYN") +HHD_DEBUG = os.environ.get("HHD_DEBUG", None) + + +class QamHandlerKeyboard: + def __init__(self) -> None: + self.uinput = None + + def _open(self): + if self.uinput: + return True + + args = { + "events": { + B("EV_KEY"): [ + B("KEY_LEFTCTRL"), + B("KEY_1"), + B("KEY_2"), + ] + }, + "name": "Handheld Daemon Steam Events", + "phys": "phys-hhd-qam", + } + try: + self.uinput = UInputMonkey(**args) + return True + except Exception: + try: + self.uinput = UInput(**args) + return True + except Exception as e: + pass + return False + + def __call__(self, expanded=False) -> Any: + if not is_gamescope_running(): + # Ctrl+1/2 do nothing outside gamescope + return False + if not self._open(): + return False + if not self.uinput: + return False + + try: + btn = B("KEY_1") if expanded else B("KEY_2") + self.uinput.write(B("EV_KEY"), B("KEY_LEFTCTRL"), 1) + self.uinput.write(B("EV_KEY"), btn, 1) + self.uinput.syn() + time.sleep(0.3) + self.uinput.write(B("EV_KEY"), btn, 0) + self.uinput.write(B("EV_KEY"), B("KEY_LEFTCTRL"), 0) + self.uinput.syn() + return True + except Exception as e: + logger.error(f"Could not send keyboard event. Error:\n{e}") + return False + + def screenshot(self) -> bool: + if not self._open(): + return False + if not self.uinput: + return False + + try: + btn = B("KEY_F12") + self.uinput.write(B("EV_KEY"), btn, 1) + self.uinput.syn() + time.sleep(0.1) + self.uinput.write(B("EV_KEY"), btn, 0) + self.uinput.syn() + return True + except Exception as e: + logger.error(f"Could not send screenshot event. Error:\n{e}") + return False + + def close(self): + if self.uinput: + self.uinput.close() + self.uinput = None + + +def grab_buttons(fd: int, typ: int, btns: dict[int, str] | None): + if btns: + b_len = max((max(btns) >> 3) + 1, 8) + mask = bytearray(b_len) + for b in btns: + mask[b >> 3] |= 1 << (b & 0x07) + else: + mask = bytes([]) + b_len = 0 + + c_mask = ctypes.create_string_buffer(bytes(mask)) + data = struct.pack("@ I I L", typ, b_len, ctypes.addressof(c_mask)) + # Before + # print(bytes(mask).hex()) + ioctl(fd, EVIOCSMASK, data) + # After + # ioctl(fd, EVIOCGMASK, data) + # print(bytes(mask).hex()) + + +def find_devices( + current: dict[str, Any] = {}, + keyboard: bool = True, + controllers: bool = True, + touchscreens: bool = True, +): + out = {} + for name, dev in list_evs(True).items(): + if name in current: + continue + + # Skip HHD devices + if "hhd" in dev.get("phys", "") or ( + # Allow bluetooth controllers that contain uhid and phys, while + # blocking hhd devices that contain uhid but not phys + "uhid" in dev.get("sysfs", "") + and not dev.get("phys", "") + ): + continue + + # Skip Steam virtual devices + # Vendor=28de Product=11ff + if dev.get("vendor", 0) == 0x28DE and dev.get("product", 0) == 0x11FF: + continue + + abs = dev.get("byte", {}).get("abs", bytes()) + keys = dev.get("byte", {}).get("key", bytes()) + + # Touchscreen is complicated. Should have BTN_TOUCH but not BTN_TOOL_FINGER + is_touchscreen = touchscreens + major = B("BTN_TOUCH") >> 3 + minor = B("BTN_TOUCH") & 0x07 + if len(keys) <= major or not keys[major] & (1 << minor): + is_touchscreen = False + major = B("BTN_TOOL_FINGER") >> 3 + minor = B("BTN_TOOL_FINGER") & 0x07 + if len(keys) > major and keys[major] & (1 << minor): + is_touchscreen = False + + for cap in TOUCH_WAKE_AXIS: + major = cap >> 3 + minor = cap & 0x07 + if len(abs) <= major or not abs[major] & (1 << minor): + is_touchscreen = False + break + + is_controller = controllers + for cap in CONTROLLER_WAKE_BUTTON: + major = cap >> 3 + minor = cap & 0x07 + if len(keys) <= major or not keys[major] & (1 << minor): + is_controller = False + break + + # Avoid laptop keyboards, as they emit left meta on power button hold + # FIXME: will prevent using laptop keyboards to bring up the menu + is_keyboard = keyboard and not dev.get("name", "").startswith("AT Translated") + for cap in KEYBOARD_WAKE_KEY: + major = cap >> 3 + minor = cap & 0x07 + if len(keys) <= major or not keys[major] & (1 << minor): + is_keyboard = False + break + + if is_touchscreen or is_controller or is_keyboard: + out[name] = { + "is_touchscreen": is_touchscreen, + "is_controller": is_controller, + "is_keyboard": is_keyboard, + "pretty": dev.get("name", ""), + "hash": dev.get("hash", ""), + "vid": dev.get("vendor", 0), + "pid": dev.get("product", 0), + } + + return out + + +def process_touch(emit, state, ev, val): + # Check if the gesture should be kept + invalidated = False + if ev == "slot" and val: + # Second finger, remove the gesture + invalidated = True + elif ev == "id" and val == -1: + # Finger removed, remove the gesture + invalidated = True + # This is the only time we remove the + # start_time as well, so gestures can resume. + state["start_time"] = 0 + + if invalidated: + state["start_x"] = 0 + state["start_y"] = 0 + state["last_x"] = 0 + state["last_y"] = 0 + state["grab"] = False + return + + start_time = state.get("start_time", 0) + curr = time.time() + if start_time and curr - start_time > GESTURE_TIME: + # User took too long, stop processing gestures + # until finger is released + return + + # After this point, we only use coordinates + if ev not in ("x", "y"): + return + + # Swap names around to avoid + # Confusion with portrait displays + if state["portrait"]: + if ev == "x": + ev = "y" + max_ev = state["max_x"] + elif ev == "y": + ev = "x" + max_ev = state["max_y"] + else: + max_ev = state[f"max_{ev}"] + + if state["flip_x"] and ev == "x": + val = max_ev - val + if state["flip_y"] and ev == "y": + val = max_ev - val + + if not start_time: + state["start_time"] = curr + + # Save old values + v = val / max_ev + state[f"start_{ev}"] = state[f"start_{ev}"] if state.get(f"start_{ev}", 0) else v + state[f"last_{ev}"] = v + + # Begin handler + start_x = state.get("start_x", 0) + start_y = state.get("start_y", 0) + last_x = state.get("last_x", 0) + last_y = state.get("last_y", 0) + + if not start_x or not start_y: + return + if not last_x or not last_y: + return + + if ( + start_x < GESTURE_START + or start_x > 1 - GESTURE_START + or start_y > 1 - GESTURE_START + ): + state["grab"] = True + + # logger.info( + # f"{start_x:.2f}:{start_y:.2f} -> {last_x:.2f}:{last_y:.2f} = ({dx:5.2f}, {dy:5.2f})" + # ) + + handled = False + if start_x < GESTURE_START and last_x > GESTURE_END: + semi = "top" if start_y < GESTURE_TOP_RATIO else "bottom" + if emit: + emit({"type": "special", "event": f"swipe_right_{semi}"}) + handled = True + elif start_x > 1 - GESTURE_START and last_x < 1 - GESTURE_END: + semi = "top" if start_y < GESTURE_TOP_RATIO else "bottom" + if emit: + emit({"type": "special", "event": f"swipe_left_{semi}"}) + handled = True + elif start_y > 1 - GESTURE_START and last_y < 1 - GESTURE_END: + if emit: + emit({"type": "special", "event": "swipe_bottom"}) + handled = True + elif start_y < GESTURE_START and last_y > GESTURE_END: + if emit: + emit({"type": "special", "event": "swipe_top"}) + handled = True + + if handled: + state["start_x"] = 0 + state["start_y"] = 0 + state["last_x"] = 0 + state["last_y"] = 0 + state["grab"] = False + + +def process_kbd(emit, state, ev, val): + if ev == "ctrl": + state["ctrl"] = val + return + if ev == "3" and val and state.get("ctrl", 0): + if emit: + emit({"type": "special", "event": "kbd_ctrl_3"}) + if ev == "4" and val and state.get("ctrl", 0): + if emit: + emit({"type": "special", "event": "kbd_ctrl_4"}) + + # Skip repeats + if val == 2: + return + + if not ev == "meta": + return + + pressed_n = state.get("pressed_n", 0) + + curr = time.time() + if val: + state["pressed_n"] = pressed_n + 1 + state["last_pressed"] = curr + else: + if pressed_n: + emit({"type": "special", "event": "kbd_meta_press"}) + state["last_pressed"] = 0 + + +def refresh_kbd(emit, state): + pressed_n = state.get("pressed_n", 0) + last_pressed = state.get("last_pressed", 0) + # last_release = state.get("last_release", 0) + curr = time.time() + + if pressed_n and last_pressed and curr - last_pressed > KBD_HOLD_DELAY: + if emit: + emit({"type": "special", "event": "kbd_meta_hold"}) + state["pressed_n"] = 0 + state["last_pressed"] = 0 + + +def process_ctrl(emit, state, ev, val): + # Here, we capture the shortcut xbox+b + # This is a shortcut that is used by steam + # but only when it is held, so we can use + # it if its a shortpress + if ev == "mode": + state["mode"] = val + return + if ev == "select": + state["select"] = val + return + + if ev != "b" and ev != "y": + return + + # Mode needs to be pressed + if not state.get("mode", None) and not state.get("select", None): + return + + if val: + state[ev] = time.time() + else: + if state.get(ev, None) and time.time() - state[ev] < XBOX_B_MAX_PRESS: + logger.info(f"Xbox+{ev} pressed") + if emit: + emit( + { + "type": "special", + "event": f"xbox_{ev}", + "data": {"uniq": state.get("uniq", None)}, + } + ) + state[ev] = None + + +def process_events(emit, dev, evs): + # Some nice logging to make things easier + if HHD_DEBUG: + log = "" + if dev["is_touchscreen"]: + for ev in evs: + if ev.type != EV_ABS: + continue + code = ev.code + if code not in TOUCH_WAKE_AXIS: + continue + log += f"\n - {TOUCH_WAKE_AXIS[code]}: {ev.value}" + elif dev["is_controller"]: + for ev in evs: + if ev.type != EV_KEY: + continue + code = ev.code + if code not in CONTROLLER_WAKE_BUTTON: + continue + log += f"\n - {CONTROLLER_WAKE_BUTTON[code]}: {ev.value}" + elif dev["is_keyboard"]: + for ev in evs: + if ev.type != EV_KEY: + continue + code = ev.code + if code not in KEYBOARD_WAKE_KEY: + continue + if ev.value == 2: + continue + log += f"\n - {KEYBOARD_WAKE_KEY[code]}: {ev.value}" + if log: + logger.info(f"'{dev['pretty']}':{log}") + + for ev in evs: + # The evs list is SYN, however, for gestures do we really care? + # We can also do some ugly prefiltering here, so that the + # inner functions are prettier + if ev.type == EV_SYN: + continue + + if dev["is_touchscreen"] and ev.type == EV_ABS and ev.code in TOUCH_WAKE_AXIS: + process_touch(emit, dev["state_touch"], TOUCH_WAKE_AXIS[ev.code], ev.value) + + if ( + dev["is_controller"] + and ev.type == EV_KEY + and ev.code in CONTROLLER_WAKE_BUTTON + ): + process_ctrl( + emit, dev["state_ctrl"], CONTROLLER_WAKE_BUTTON[ev.code], ev.value + ) + + if dev["is_keyboard"] and ev.type == EV_KEY and ev.code in KEYBOARD_WAKE_KEY: + process_kbd(emit, dev["state_kbd"], KEYBOARD_WAKE_KEY[ev.code], ev.value) + + +def refresh_events(emit, dev): + # if dev["is_touchscreen"]: + # refresh_touch(emit, dev["state_touch"]) + # if dev["is_controller"]: + # refresh_ctrl(emit, dev["state_ctrl"]) + if dev["is_keyboard"]: + refresh_kbd(emit, dev["state_kbd"]) + + +def intercept_devices(devs, activate: bool): + failed = [] + for id, dev in devs.items(): + if not dev["is_controller"]: + continue + d = dev["dev"] + if activate: + try: + grab_buttons( + d.fd, + B("EV_KEY"), + { + **CONTROLLER_WAKE_BUTTON, + **KEYBOARD_WAKE_KEY, + **OVERLAY_BUTTON_MAP, + }, + ) + grab_buttons(d.fd, B("EV_ABS"), OVERLAY_AXIS_MAP) + + if not dev.get("grabbed", False): + fallback = True + if ENHANCED_HIDING: + try: + ioctl(d.fd, EVIOCGRABCLEAN, 1) + fallback = False + except Exception: + pass + if fallback: + d.grab() + dev["grabbed"] = True + logger.info(f" - '{dev['pretty']}'") + except Exception: + logger.warning(f" - Failed: '{dev['pretty']}'") + failed.append((id, dev)) + else: + try: + if dev.get("grabbed", False): + d.ungrab() + dev["grabbed"] = False + + grab_buttons( + d.fd, + B("EV_KEY"), + { + **CONTROLLER_WAKE_BUTTON, + **KEYBOARD_WAKE_KEY, + }, + ) + grab_buttons(d.fd, B("EV_ABS"), {}) + logger.info(f" - '{dev['pretty']}'") + except Exception: + logger.warning(f" - Failed: '{dev['pretty']}'") + failed.append((id, dev)) + return failed + + +def intercept_events(emit, intercept_num, cid, dinput, smax, evs): + if not emit: + return + + out = [] + for ev in evs: + if ev.type == EV_SYN: + continue + + if ev.type == EV_KEY and ev.code in OVERLAY_BUTTON_MAP: + out.append( + { + "type": "button", + "code": OVERLAY_BUTTON_MAP[ev.code], + "value": ev.value, + } + ) + elif ev.type == EV_ABS and ev.code in OVERLAY_AXIS_MAP: + code = OVERLAY_AXIS_MAP[ev.code] + + if "ls" in code: + if dinput: + v = min(1, max(-1, 2 * ev.value / smax - 1)) + else: + v = min(1, max(-1, ev.value / smax)) + else: + v = ev.value + + out.append( + { + "type": "axis", + "code": code, + "value": v, + } + ) + + emit.intercept(cid + intercept_num, out) + + +def device_shortcut_loop( + emit=None, + should_exit=None, + init=True, + keyboard: bool = True, + controllers: bool = True, + touchscreens: bool = True, + disable_touchscreens: bool = False, + touch_correction: dict | None = None, +): + blacklist = set() + last_check = 0 + intercept = False + intercept_num = 0 + devs = {} + while not should_exit or not should_exit.is_set(): + if devs: + # Wait for events + try: + r, _, _ = select.select( + [d["dev"].fd for d in devs.values()], [], [], REFRESH_INTERVAL + ) + except Exception: + pass + elif not init: + # If no devices, wait for a bit + # Except on first run + time.sleep(MONITOR_INTERVAL) + init = False + + # Process events + should_intercept = emit and emit.should_intercept() + if any(dev["is_controller"] for dev in devs.values()): + failed = [] + if not intercept and should_intercept: + intercept = True + intercept_num += 1 + logger.info("Intercepting other controllers:") + failed = intercept_devices(devs, True) + elif intercept and not should_intercept: + intercept = False + logger.info("Stopping intercepting other controllers:") + failed = intercept_devices(devs, False) + for id, f in failed: + blacklist.add(f["hash"]) + try: + del devs[id] + except Exception: + pass + + for name, dev in list(devs.items()): + d = dev["dev"] + refresh_events(emit, dev) + if not d.fd in r: + # Run interception so that holding button repeats work + if should_intercept and dev["is_controller"]: + intercept_events( + emit, + intercept_num, + dev["hash"], + dev["dinput"], + dev["stick_max"], + [], + ) + continue + + try: + if dev["is_controller"] and os.stat(name).st_mode & stat.S_IRGRP == 0: + logger.info(f"Removing hidden device: '{dev['pretty']}'") + blacklist.add(cand["hash"]) + del devs[name] + try: + d.close() + except Exception: + pass + continue + + e = list(d.read()) + # print(e) + process_events(emit, dev, e) + if should_intercept and dev["is_controller"]: + intercept_events( + emit, + intercept_num, + dev["hash"], + dev["dinput"], + dev["stick_max"], + e, + ) + except Exception as e: + logger.error( + f"Device '{dev['pretty']}' has error. Removing. Error:\n{e}" + ) + blacklist.add(dev["hash"]) + del devs[name] + try: + d.close() + except Exception: + pass + + # Avoid spamming proc + curr = time.time() + if curr - last_check < MONITOR_INTERVAL: + continue + + # Add new devices + log = "" + for name, cand in find_devices( + devs, + keyboard=keyboard, + touchscreens=touchscreens or disable_touchscreens, + controllers=controllers, + ).items(): + if cand["hash"] in blacklist: + continue + + try: + if os.stat(name).st_mode & stat.S_IRGRP == 0: + continue + + dev = InputDevice(name) + + if cand["is_touchscreen"] and disable_touchscreens: + # Grab touchscreen if requested + fallback = True + if ENHANCED_HIDING: + try: + ioctl(dev.fd, EVIOCGRABCLEAN, 1) + fallback = False + except Exception: + pass + if fallback: + dev.grab() + + # Add event filters to avoid CPU use + # Do controllers and keyboards together as buttons do not consume much + if cand["is_controller"] or cand["is_keyboard"]: + grab_buttons( + dev.fd, + B("EV_KEY"), + {**CONTROLLER_WAKE_BUTTON, **KEYBOARD_WAKE_KEY}, + ) + else: + grab_buttons(dev.fd, B("EV_KEY"), {}) + + # Abs events + # Touchscreen, joystick, etc. We only care about touchscreens + if cand["is_touchscreen"]: + grab_buttons(dev.fd, B("EV_ABS"), TOUCH_WAKE_AXIS) + else: + grab_buttons(dev.fd, B("EV_ABS"), {}) + + # Rel events are not used + # They contain e.g., scroll events, mouse movements + grab_buttons(dev.fd, B("EV_REL"), {}) + # MSC Events are not used + # They contain e.g., scan codes + grab_buttons(dev.fd, B("EV_MSC"), {}) + + devs[name] = { + "dev": dev, + **cand, + "state_touch": {}, + "state_ctrl": {}, + "state_kbd": {}, + } + caps = [] + if cand["is_touchscreen"]: + max_x = dev.absinfo(B("ABS_MT_POSITION_X")).max + max_y = dev.absinfo(B("ABS_MT_POSITION_Y")).max + + # Default quirks + portrait = max_x < max_y + flip_x = not portrait # just the way it is + flip_y = False + + quirk, pretty = get_touchscreen_quirk( + vid=dev.info.vendor, pid=dev.info.product + ) + if touch_correction: + portrait = touch_correction.get("portrait", False) + flip_x = touch_correction.get("flip_x", False) + flip_y = touch_correction.get("flip_y", False) + caps.append( + f"Touchscreen[manual, portrait={portrait}, x={flip_x}, y={flip_y}]" + ) + elif quirk: + portrait = quirk.portrait + flip_x = quirk.flip_x + flip_y = quirk.flip_y + caps.append(f"Touchscreen[{pretty}]") + else: + caps.append( + f"Touchscreen[auto, portrait={portrait}, x={flip_x}, y={flip_y}]" + ) + + devs[name]["state_touch"].update( + { + "max_x": max_x, + "max_y": max_y, + "portrait": portrait, + "flip_x": flip_x, + "flip_y": flip_y, + } + ) + if cand["is_controller"]: + try: + stick_max = dev.absinfo(B("ABS_X")).max + dinput = not dev.absinfo(B("ABS_X")).min + except Exception: + stick_max = 2**16 + dinput = False + devs[name]["dinput"] = dinput + devs[name]["state_ctrl"].update({"uniq": dev.uniq}) + devs[name]["stick_max"] = stick_max + caps.append(f"Controller[dinput={dinput}, smax={stick_max}]") + if cand["is_keyboard"]: + caps.append("Keyboard") + log += f"\n - '{cand['pretty']}' [{cand['vid']:04x}:{cand['pid']:04x}] ({', '.join(caps)})" + except Exception as e: + logger.error(f"Failed to open device '{cand['pretty']}'. Error:\n{e}") + blacklist.add(cand["hash"]) + + if log: + logger.info(f"Found new shortcut devices:{log}") + + +AXIS_LIMIT = 0.5 + + +class OverlayWriter: + def __init__(self, stdout, mute: bool = True) -> None: + self.state = {} + self.stdout = stdout + self._write_lock = RLock() + + if mute: + # We support intercepting all controllers now + self.write("cmd:mute\n") + + def _call(self, cid: int, evs: Sequence[ControllerEvent]): + if not cid in self.state: + self.state[cid] = {} + + # Debounce changed events + curr = time.perf_counter() + changed = [] + for ev in evs: + match ev["type"]: + case "axis": + code = ev["code"] + act1 = act2 = val1 = val2 = None + if code == "ls_x" or code == "hat_x": + act1 = "right" + act2 = "left" + if ev["value"] > AXIS_LIMIT: + val1 = True + val2 = False + elif ev["value"] < -AXIS_LIMIT: + val1 = False + val2 = True + else: + val1 = False + val2 = False + elif code == "ls_y" or code == "hat_y": + act1 = "down" + act2 = "up" + if ev["value"] > AXIS_LIMIT: + val1 = True + val2 = False + elif ev["value"] < -AXIS_LIMIT: + val1 = False + val2 = True + else: + val1 = False + val2 = False + + if act1 and act2: + for code, val in ((act1, val1), (act2, val2)): + if ( + code not in self.state[cid] + or bool(self.state[cid][code]) != val + ): + changed.append((code, val)) + self.state[cid][code] = ( + curr + REPEAT_INITIAL if val else None + ) + case "button": + code = ev["code"] + if code not in ( + "a", + "b", + "x", + "y", + "rb", + "lb", + "mode", + ): + continue + val = ev["value"] + if ( + code not in self.state[cid] + or bool(self.state[cid][code]) != val + ): + changed.append((code, val)) + self.state[cid][code] = curr + REPEAT_INITIAL if val else None + + # Ignore guide combos + if self.state[cid].get("mode", None): + return + + # Allow holds + for btn, val in list(self.state[cid].items()): + if not val: + continue + if val < curr: + changed.append((btn, True)) + self.state[cid][btn] = curr + REPEAT_INTERVAL + + # Process changed events + cmds = "" + for code, val in changed: + if val: + cmds += f"action:{code}\n" + elif code == "x": + cmds += f"action:x_up\n" + + # Write them out + if cmds: + self.write(cmds) + + def __call__(self, cid: int, evs: Sequence[ControllerEvent]): + with self._write_lock: + return self._call(cid, evs) + + def write(self, cmds: str): + try: + with self._write_lock: + self.stdout.write(cmds) + self.stdout.flush() + except Exception: + pass + + def reset(self): + with self._write_lock: + self.state = {} diff --git a/src/hhd/plugins/overlay/gamemode.yml b/src/hhd/plugins/overlay/gamemode.yml new file mode 100644 index 00000000..aadbe788 --- /dev/null +++ b/src/hhd/plugins/overlay/gamemode.yml @@ -0,0 +1,27 @@ +display: + type: container + title: Display + children: + touchscreen_disable: + type: bool + title: "Disable Touchscreen (Until Restart)" + + gestures_disable: + type: bool + title: "Disable Touch Gestures (Until Restart)" + +gamescope: + type: container + title: Gamescope + children: + steamui_halfhz: + tags: [non-essential] + type: bool + title: "Run Steam at 60/72 Hz" + default: false + + dpms: + tags: [non-essential] + type: bool + title: "Poweroff screen before sleep" + default: true \ No newline at end of file diff --git a/src/hhd/plugins/overlay/overlay.py b/src/hhd/plugins/overlay/overlay.py new file mode 100644 index 00000000..2aba14d1 --- /dev/null +++ b/src/hhd/plugins/overlay/overlay.py @@ -0,0 +1,77 @@ +import os +import shutil +import subprocess + +from hhd.plugins import Context +from hhd.utils import expanduser + + +def find_overlay_exe(ctx: Context): + INSTALLED_PATHS = ["hhd-ui.AppImage", "hhd-ui-dbg", "hhd-ui"] + + usr = os.environ.get("HHD_OVERLAY") + if usr: + if os.path.exists(usr): + return usr + INSTALLED_PATHS.insert(0, usr) + + # FIXME: Potential priviledge escalation attack! + # Runs as the user in `inject_overlay`, so this should + # not be the case. Will still be executed. + for fn in INSTALLED_PATHS: + local = shutil.which(fn, path=expanduser("~/.local/bin", ctx)) + if local: + return local + + for fn in INSTALLED_PATHS: + system = shutil.which(fn) + if system: + return system + + +def inject_overlay(fn: str, display: str, ctx: Context): + out = subprocess.Popen( + [fn], + env={"HOME": expanduser("~", ctx), "DISPLAY": display, "STEAM_OVERLAY": "1"}, + text=True, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + preexec_fn=lambda: os.setpgrp(), # allow closing the overlay smoothly + user=ctx.euid, + group=ctx.egid, + ) + return out + + +def launch_overlay_de(fn: str, display: str, auth: str, ctx: Context): + out = subprocess.Popen( + [fn], + env={ + "HOME": expanduser("~", ctx), + "XAUTHORITY": auth, + "DISPLAY": display, + "HHD_MANAGED": "1", + }, + text=True, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + user=ctx.euid, + group=ctx.egid, + ) + return out + + +def get_overlay_version(fn: str, ctx: Context): + return subprocess.run( + [fn, "--version"], + env={"HOME": expanduser("~", ctx)}, + text=True, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + user=ctx.euid, + group=ctx.egid, + timeout=5, + ).stdout.strip() diff --git a/src/hhd/plugins/overlay/screen_data.txt b/src/hhd/plugins/overlay/screen_data.txt new file mode 100644 index 00000000..e3723db7 --- /dev/null +++ b/src/hhd/plugins/overlay/screen_data.txt @@ -0,0 +1,93 @@ + + +# Legion go +I: Bus=0018 Vendor=0603 Product=f001 Version=0100 +N: Name="NVTK0603:00 0603:F001" +P: Phys=i2c-NVTK0603:00 +S: Sysfs=/devices/platform/AMDI0010:03/i2c-0/i2c-NVTK0603:00/0018:0603:F001.000E/input/input27 +U: Uniq= +H: Handlers=event13 mouse4 +B: PROP=2 +B: EV=1b +B: KEY=400 0 0 0 0 0 +B: ABS=673800001000003 +B: MSC=20 + +# V3 Tablet +/sys/class/dmi/id/bios_date: +04/10/2024 +/sys/class/dmi/id/bios_release: +5.29 +/sys/class/dmi/id/bios_vendor: +American Megatrends International, LLC. +/sys/class/dmi/id/bios_version: +1.05 +/sys/class/dmi/id/board_asset_tag: +Default string +/sys/class/dmi/id/board_name: +HPPAC +/sys/class/dmi/id/board_serial: +FPPAC884019B1051917 +/sys/class/dmi/id/board_vendor: +Shenzhen Meigao Electronic Equipment Co.,Ltd +/sys/class/dmi/id/board_version: +Default string +/sys/class/dmi/id/chassis_asset_tag: +Default string +/sys/class/dmi/id/chassis_serial: +Default string +/sys/class/dmi/id/chassis_type: +32 +/sys/class/dmi/id/chassis_vendor: +Default string +/sys/class/dmi/id/chassis_version: +Default string +/sys/class/dmi/id/ec_firmware_release: +8.8 + +I: Bus=0018 Vendor=222a Product=550d Version=0100 +N: Name="PNP0C50:00 222A:550D" +P: Phys=i2c-PNP0C50:00 +S: Sysfs=/devices/platform/AMDI0010:00/i2c-0/i2c-PNP0C50:00/0018:222A:550D.0004/input/input20 +U: Uniq= +H: Handlers=mouse1 event7 +B: PROP=2 +B: EV=b +B: KEY=400 0 0 0 0 0 +B: ABS=260800000000003 + +I: Bus=0018 Vendor=222a Product=550d Version=0100 +N: Name="PNP0C50:00 222A:550D Stylus" +P: Phys=i2c-PNP0C50:00 +S: Sysfs=/devices/platform/AMDI0010:00/i2c-0/i2c-PNP0C50:00/0018:222A:550D.0004/input/input21 +U: Uniq= +H: Handlers=mouse2 event9 +B: PROP=2 +B: EV=1b +B: KEY=1c03 0 0 0 0 0 +B: ABS=1000d000003 +B: MSC=11 + +I: Bus=0018 Vendor=222a Product=550d Version=0100 +N: Name="PNP0C50:00 222A:550D UNKNOWN" +P: Phys=i2c-PNP0C50:00 +S: Sysfs=/devices/platform/AMDI0010:00/i2c-0/i2c-PNP0C50:00/0018:222A:550D.0004/input/input22 +U: Uniq= +H: Handlers=event11 +B: PROP=0 +B: EV=1b +B: KEY=1 0 0 0 0 +B: ABS=10000000000 +B: MSC=10 + +I: Bus=0018 Vendor=222a Product=550d Version=0100 +N: Name="PNP0C50:00 222A:550D Mouse" +P: Phys=i2c-PNP0C50:00 +S: Sysfs=/devices/platform/AMDI0010:00/i2c-0/i2c-PNP0C50:00/0018:222A:550D.0004/input/input24 +U: Uniq= +H: Handlers=mouse4 event12 js2 +B: PROP=0 +B: EV=1b +B: KEY=1f0000 0 0 0 0 +B: ABS=3 +B: MSC=10 \ No newline at end of file diff --git a/src/hhd/plugins/overlay/shortcuts.yml b/src/hhd/plugins/overlay/shortcuts.yml new file mode 100644 index 00000000..506108e8 --- /dev/null +++ b/src/hhd/plugins/overlay/shortcuts.yml @@ -0,0 +1,117 @@ +controller: + type: container + tags: [non-essential] + title: "All Controllers" + children: + xbox_b: + type: multiple + title: "Xbox or View + B (Press)" + options: &shortcuts + disabled: "Disabled" + keyboard: Steam Keyboard + steam_qam: "Steam Side Menu" + steam_expanded: "Steam Overlay" + hhd_qam: "HHD Side Menu" + hhd_expanded: "HHD Overlay" + # screenshot: "Screenshot" + # default: hhd_qam + default: disabled + xbox_y: + type: multiple + title: "Xbox or View + Y" + options: + <<: *shortcuts + disconnect: Disconnect Controller + # default: disconnect + default: disabled +touchscreen: + type: container + tags: [non-essential] + title: "Touchscreen" + children: + bottom: + type: multiple + title: "↑ Swipe Up" + options: *shortcuts + default: keyboard + right_top: + type: multiple + title: "← Swipe Right Side (Top)" + options: *shortcuts + default: hhd_qam + right_bottom: + type: multiple + title: "← Swipe Right Side (Bottom)" + options: *shortcuts + default: steam_qam + left_top: + type: multiple + title: "→ Swipe Left Side (Top)" + options: *shortcuts + default: hhd_expanded + left_bottom: + type: multiple + title: "→ Swipe Left Side (Bottom)" + options: *shortcuts + default: steam_expanded + top: + type: multiple + title: "↓ Swipe Down" + options: *shortcuts + default: disabled + + orientation: + type: mode + title: "Orientation Correction" + default: auto + modes: + auto: + title: "Auto" + type: container + manual: + title: "Manual" + type: container + children: + dmi: + type: display + title: "Device" + tags: [slim] + portrait: + type: bool + title: "Portrait" + default: false + flip_x: + type: bool + title: "Flip Left-Right" + default: false + flip_y: + type: bool + title: "Flip Top-Bottom" + default: false +keyboard: + type: container + tags: [non-essential] + title: "Keyboard (Gaming Only)" + children: + meta_press: + type: multiple + title: "Start (Meta) Press" + options: *shortcuts + # default: steam_qam + default: disabled + meta_hold: + type: multiple + title: "Start (Meta) Hold" + options: *shortcuts + # default: steam_expanded + default: disabled + ctrl_3: + type: multiple + title: "Ctrl + 3" + options: *shortcuts + default: hhd_expanded + ctrl_4: + type: multiple + title: "Ctrl + 4" + options: *shortcuts + default: hhd_qam \ No newline at end of file diff --git a/src/hhd/plugins/overlay/steam/__init__.py b/src/hhd/plugins/overlay/steam/__init__.py new file mode 100644 index 00000000..5102f055 --- /dev/null +++ b/src/hhd/plugins/overlay/steam/__init__.py @@ -0,0 +1,41 @@ +# import os + +# def get_game_data(appcache: str):from hhd.plugins.overlay.steam import appcache +import os + +from .appcache import parse_appinfo + +def get_games(appdir: str): + with open(os.path.join(appdir, "appinfo.vdf"), 'rb') as f: + games = {} + _, data = parse_appinfo(f) + for d in data: + try: + appid = str(d['appid']) + name = d['data']['appinfo']['common']['name'] + games[appid] = {"name": name, "images": []} + except KeyError: + pass + + images = {} + libdir = os.path.join(appdir, "librarycache") + for fn in os.listdir(libdir): + try: + id_split = fn.index('_') + ext_split = fn.rindex('.') + appid = fn[:id_split] + itype = fn[id_split+1:ext_split] + + if appid not in games: + continue + + games[appid]["images"].append(itype) + + if appid not in images: + images[appid] = {} + + images[appid][itype] = os.path.join(libdir, fn) + except ValueError: + pass + + return games, images \ No newline at end of file diff --git a/src/hhd/plugins/overlay/steam/appcache.py b/src/hhd/plugins/overlay/steam/appcache.py new file mode 100644 index 00000000..2c93f20f --- /dev/null +++ b/src/hhd/plugins/overlay/steam/appcache.py @@ -0,0 +1,208 @@ +""" +SPDX-License-Identifier: MIT +From: https://github.com/ValvePython/steam/ +With fix from: https://github.com/solsticegamestudios/steam/ +""" + +""" +Appache file parsing examples: + +.. code:: python + + >>> from steam.utils.appcache import parse_appinfo, parse_packageinfo + + >>> header, apps = parse_appinfo(open('/d/Steam/appcache/appinfo.vdf', 'rb')) + >>> header + {'magic': b")DV\\x07", 'universe': 1} + >>> next(apps) + {'appid': 5, + 'size': 79, + 'info_state': 1, + 'last_updated': 1484735377, + 'access_token': 0, + 'sha1': b'\\x87\\xfaCg\\x85\\x80\\r\\xb4\\x90Im\\xdc}\\xb4\\x81\\xeeQ\\x8b\\x825', + 'change_number': 4603827, + 'data_sha1': b'\\x87\\xfaCg\\x85\\x80\\r\\xb4\\x90Im\\xdc}\\xb4\\x81\\xeeQ\\x8b\\x825', + 'data': {'appinfo': {'appid': 5, 'public_only': 1}}} + + >>> header, pkgs = parse_packageinfo(open('/d/Steam/appcache/packageinfo.vdf', 'rb')) + >>> header + {'magic': b"'UV\\x06", 'universe': 1} + + >>> next(pkgs) + {'packageid': 7, + 'sha1': b's\\x8b\\xf7n\\t\\xe5 k#\\xb6-\\x82\\xd2 \\x14k@\\xfeDQ', + 'change_number': 7469765, + 'data': {'7': {'packageid': 7, + 'billingtype': 1, + 'licensetype': 1, + 'status': 0, + 'extended': {'requirespreapproval': 'WithRedFlag'}, + 'appids': {'0': 10, '1': 80, '2': 100, '3': 254430}, + 'depotids': {'0': 0, '1': 95, '2': 101, '3': 102, '4': 103, '5': 254431}, + 'appitems': {}}}} + +""" + +import struct +from .vdf import binary_load + +uint32 = struct.Struct('= 41: # b')' + # appinfo.vdf V29 and newer store list of keys in separate table at the + # end of the file to reduce size. Retrieve it and pass it to the VDF + # parser later. + key_table = [] + + key_table_offset = struct.unpack('q', fp.read(8))[0] + offset = fp.tell() + fp.seek(key_table_offset) + key_count = uint32.unpack(fp.read(4))[0] + + # Read all null-terminated strings into a list + for _ in range(0, key_count): + field_name = bytearray() + while True: + field_name += fp.read(1) + + if field_name[-1] == 0: + field_name = field_name[0:-1] + field_name = field_name.decode("utf-8", "replace") + + key_table.append(field_name) + break + + # Rewind to the beginning of the file after the header: + # we can now parse the rest of the file. + fp.seek(offset) + + def apps_iter(): + while True: + appid = uint32.unpack(fp.read(4))[0] + + if appid == 0: + break + + app = { + 'appid': appid, + 'size': uint32.unpack(fp.read(4))[0], + 'info_state': uint32.unpack(fp.read(4))[0], + 'last_updated': uint32.unpack(fp.read(4))[0], + 'access_token': uint64.unpack(fp.read(8))[0], + 'sha1': fp.read(20), + 'change_number': uint32.unpack(fp.read(4))[0], + } + + if magic != b"'DV\x07": + app['data_sha1'] = fp.read(20) + + # 'key_table' will be None for older 'appinfo.vdf' files which + # use self-contained binary VDFs. + app['data'] = binary_load(fp, key_table=key_table, mapper=dict) + + yield app + + + return ({ + 'magic': magic, + 'universe': universe, + }, + apps_iter() + ) + +def parse_packageinfo(fp, mapper=dict): + """Parse packageinfo.vdf from the Steam appcache folder + + :param fp: file-like object + :param mapper: Python object class to return + :raises: SyntaxError + :rtype: (:class:`Generator` returning :class:`dict` by default or mapper class if set) + :return: (header, packages iterator) + """ +# format: +# uint32 - MAGIC: b"'UV\x06" or b"(UV\x06" +# uint32 - UNIVERSE: 1 +# ---- repeated package sections ---- +# uint32 - PackageID +# 20bytes - SHA1 +# uint32 - changeNumber +# uint64 - token (only on b"(UV\x06") +# variable - binary_vdf +# ---- end of section --------- +# uint32 - EOF: 0xFFFFFFFF + + magic = fp.read(4) + if magic not in (b"'UV\x06", b"(UV\x06"): + raise SyntaxError("Invalid magic, got %s" % repr(magic)) + + universe = uint32.unpack(fp.read(4))[0] + + def pkgs_iter(): + while True: + packageid = uint32.unpack(fp.read(4))[0] + + if packageid == 0xFFFFFFFF: + break + + pkg = { + 'packageid': packageid, + 'sha1': fp.read(20), + 'change_number': uint32.unpack(fp.read(4))[0], + } + + if magic == b"(UV\x06": + pkg['token'] = uint64.unpack(fp.read(8))[0] + + pkg['data'] = binary_load(fp, mapper=mapper) + + yield pkg + + + return ({ + 'magic': magic, + 'universe': universe, + }, + pkgs_iter() + ) \ No newline at end of file diff --git a/src/hhd/plugins/overlay/steam/vdf/__init__.py b/src/hhd/plugins/overlay/steam/vdf/__init__.py new file mode 100644 index 00000000..bd8101dd --- /dev/null +++ b/src/hhd/plugins/overlay/steam/vdf/__init__.py @@ -0,0 +1,537 @@ +""" +Module for deserializing/serializing to and from VDF + +SPDX-License-Identifier: MIT +From https://github.com/ValvePython/vdf/ +with https://github.com/ValvePython/vdf/pull/61 from Matoking +""" +__version__ = "3.4" +__author__ = "Rossen Georgiev" + +import re +import struct +import sys +from binascii import crc32 +from collections.abc import Mapping +from io import BytesIO +from io import StringIO as unicodeIO + +from .vdict import VDFDict + +# Py2 & Py3 compatibility +if sys.version_info[0] >= 3: + string_type = str + int_type = int + BOMS = '\ufffe\ufeff' + + def strip_bom(line): + return line.lstrip(BOMS) +else: + from StringIO import StringIO as strIO + string_type = basestring + int_type = long + BOMS = '\xef\xbb\xbf\xff\xfe\xfe\xff' + BOMS_UNICODE = '\\ufffe\\ufeff'.decode('unicode-escape') + + def strip_bom(line): + return line.lstrip(BOMS if isinstance(line, str) else BOMS_UNICODE) + +# string escaping +_unescape_char_map = { + r"\n": "\n", + r"\t": "\t", + r"\v": "\v", + r"\b": "\b", + r"\r": "\r", + r"\f": "\f", + r"\a": "\a", + r"\\": "\\", + r"\?": "?", + r"\"": "\"", + r"\'": "\'", +} +_escape_char_map = {v: k for k, v in _unescape_char_map.items()} + +def _re_escape_match(m): + return _escape_char_map[m.group()] + +def _re_unescape_match(m): + return _unescape_char_map[m.group()] + +def _escape(text): + return re.sub(r"[\n\t\v\b\r\f\a\\\?\"']", _re_escape_match, text) + +def _unescape(text): + return re.sub(r"(\\n|\\t|\\v|\\b|\\r|\\f|\\a|\\\\|\\\?|\\\"|\\')", _re_unescape_match, text) + +# parsing and dumping for KV1 +def parse(fp, mapper=dict, merge_duplicate_keys=True, escaped=True): + """ + Deserialize ``s`` (a ``str`` or ``unicode`` instance containing a VDF) + to a Python object. + + ``mapper`` specifies the Python object used after deserializetion. ``dict` is + used by default. Alternatively, ``collections.OrderedDict`` can be used if you + wish to preserve key order. Or any object that acts like a ``dict``. + + ``merge_duplicate_keys`` when ``True`` will merge multiple KeyValue lists with the + same key into one instead of overwriting. You can se this to ``False`` if you are + using ``VDFDict`` and need to preserve the duplicates. + """ + if not issubclass(mapper, Mapping): + raise TypeError("Expected mapper to be subclass of dict, got %s" % type(mapper)) + if not hasattr(fp, 'readline'): + raise TypeError("Expected fp to be a file-like object supporting line iteration") + + stack = [mapper()] + expect_bracket = False + + re_keyvalue = re.compile(r'^("(?P(?:\\.|[^\\"])*)"|(?P#?[a-z0-9\-\_\\\?$%<>]+))' + r'([ \t]*(' + r'"(?P(?:\\.|[^\\"])*)(?P")?' + r'|(?P(?:(? ])+)' + r'|(?P{[ \t]*)(?P})?' + r'))?', + flags=re.I) + + for lineno, line in enumerate(fp, 1): + if lineno == 1: + line = strip_bom(line) + + line = line.lstrip() + + # skip empty and comment lines + if line == "" or line[0] == '/': + continue + + # one level deeper + if line[0] == "{": + expect_bracket = False + continue + + if expect_bracket: + raise SyntaxError("vdf.parse: expected openning bracket", + (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 1, line)) + + # one level back + if line[0] == "}": + if len(stack) > 1: + stack.pop() + continue + + raise SyntaxError("vdf.parse: one too many closing parenthasis", + (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 0, line)) + + # parse keyvalue pairs + while True: + match = re_keyvalue.match(line) + + if not match: + try: + line += next(fp) + continue + except StopIteration: + raise SyntaxError("vdf.parse: unexpected EOF (open key quote?)", + (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 0, line)) + + key = match.group('key') if match.group('qkey') is None else match.group('qkey') + val = match.group('qval') + if val is None: + val = match.group('val') + if val is not None: + val = val.rstrip() + if val == "": + val = None + + if escaped: + key = _unescape(key) + + # we have a key with value in parenthesis, so we make a new dict obj (level deeper) + if val is None: + if merge_duplicate_keys and key in stack[-1]: + _m = stack[-1][key] + # we've descended a level deeper, if value is str, we have to overwrite it to mapper + if not isinstance(_m, mapper): + _m = stack[-1][key] = mapper() + else: + _m = mapper() + stack[-1][key] = _m + + if match.group('eblock') is None: + # only expect a bracket if it's not already closed or on the same line + stack.append(_m) + if match.group('sblock') is None: + expect_bracket = True + + # we've matched a simple keyvalue pair, map it to the last dict obj in the stack + else: + # if the value is line consume one more line and try to match again, + # until we get the KeyValue pair + if match.group('vq_end') is None and match.group('qval') is not None: + try: + line += next(fp) + continue + except StopIteration: + raise SyntaxError("vdf.parse: unexpected EOF (open quote for value?)", + (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 0, line)) + + stack[-1][key] = _unescape(val) if escaped else val + + # exit the loop + break + + if len(stack) != 1: + raise SyntaxError("vdf.parse: unclosed parenthasis or quotes (EOF)", + (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 0, line)) + + return stack.pop() + + +def loads(s, **kwargs): + """ + Deserialize ``s`` (a ``str`` or ``unicode`` instance containing a JSON + document) to a Python object. + """ + if not isinstance(s, string_type): + raise TypeError("Expected s to be a str, got %s" % type(s)) + + fp = unicodeIO(s) + return parse(fp, **kwargs) + + +def load(fp, **kwargs): + """ + Deserialize ``fp`` (a ``.readline()``-supporting file-like object containing + a JSON document) to a Python object. + """ + return parse(fp, **kwargs) + + +def dumps(obj, pretty=False, escaped=True): + """ + Serialize ``obj`` to a VDF formatted ``str``. + """ + if not isinstance(obj, Mapping): + raise TypeError("Expected data to be an instance of``dict``") + if not isinstance(pretty, bool): + raise TypeError("Expected pretty to be of type bool") + if not isinstance(escaped, bool): + raise TypeError("Expected escaped to be of type bool") + + return ''.join(_dump_gen(obj, pretty, escaped)) + + +def dump(obj, fp, pretty=False, escaped=True): + """ + Serialize ``obj`` as a VDF formatted stream to ``fp`` (a + ``.write()``-supporting file-like object). + """ + if not isinstance(obj, Mapping): + raise TypeError("Expected data to be an instance of``dict``") + if not hasattr(fp, 'write'): + raise TypeError("Expected fp to have write() method") + if not isinstance(pretty, bool): + raise TypeError("Expected pretty to be of type bool") + if not isinstance(escaped, bool): + raise TypeError("Expected escaped to be of type bool") + + for chunk in _dump_gen(obj, pretty, escaped): + fp.write(chunk) + + +def _dump_gen(data, pretty=False, escaped=True, level=0): + indent = "\t" + line_indent = "" + + if pretty: + line_indent = indent * level + + for key, value in data.items(): + if escaped and isinstance(key, string_type): + key = _escape(key) + + if isinstance(value, Mapping): + yield '%s"%s"\n%s{\n' % (line_indent, key, line_indent) + for chunk in _dump_gen(value, pretty, escaped, level+1): + yield chunk + yield "%s}\n" % line_indent + else: + if escaped and isinstance(value, string_type): + value = _escape(value) + + yield '%s"%s" "%s"\n' % (line_indent, key, value) + + +# binary VDF +class BASE_INT(int_type): + def __repr__(self): + return "%s(%d)" % (self.__class__.__name__, self) + +class UINT_64(BASE_INT): + pass + +class INT_64(BASE_INT): + pass + +class POINTER(BASE_INT): + pass + +class COLOR(BASE_INT): + pass + +BIN_NONE = b'\x00' +BIN_STRING = b'\x01' +BIN_INT32 = b'\x02' +BIN_FLOAT32 = b'\x03' +BIN_POINTER = b'\x04' +BIN_WIDESTRING = b'\x05' +BIN_COLOR = b'\x06' +BIN_UINT64 = b'\x07' +BIN_END = b'\x08' +BIN_INT64 = b'\x0A' +BIN_END_ALT = b'\x0B' + +def binary_loads(b, mapper=dict, merge_duplicate_keys=True, alt_format=False, key_table=None, raise_on_remaining=True): + """ + Deserialize ``b`` (``bytes`` containing a VDF in "binary form") + to a Python object. + + ``mapper`` specifies the Python object used after deserializetion. ``dict` is + used by default. Alternatively, ``collections.OrderedDict`` can be used if you + wish to preserve key order. Or any object that acts like a ``dict``. + + ``merge_duplicate_keys`` when ``True`` will merge multiple KeyValue lists with the + same key into one instead of overwriting. You can se this to ``False`` if you are + using ``VDFDict`` and need to preserve the duplicates. + + ``key_table`` will be used to translate keys in binary VDF objects + which do not encode strings directly but instead store them in an out-of-band + table. Newer `appinfo.vdf` format stores this table the end of the file, + and it is needed to deserialize the binary VDF objects in that file. + """ + if not isinstance(b, bytes): + raise TypeError("Expected s to be bytes, got %s" % type(b)) + + return binary_load(BytesIO(b), mapper, merge_duplicate_keys, alt_format, key_table, raise_on_remaining) + +def binary_load(fp, mapper=dict, merge_duplicate_keys=True, alt_format=False, key_table=None, raise_on_remaining=False): + """ + Deserialize ``fp`` (a ``.read()``-supporting file-like object containing + binary VDF) to a Python object. + + ``mapper`` specifies the Python object used after deserializetion. ``dict` is + used by default. Alternatively, ``collections.OrderedDict`` can be used if you + wish to preserve key order. Or any object that acts like a ``dict``. + + ``merge_duplicate_keys`` when ``True`` will merge multiple KeyValue lists with the + same key into one instead of overwriting. You can se this to ``False`` if you are + using ``VDFDict`` and need to preserve the duplicates. + + ``key_table`` will be used to translate keys in binary VDF objects + which do not encode strings directly but instead store them in an out-of-band + table. Newer `appinfo.vdf` format stores this table the end of the file, + and it is needed to deserialize the binary VDF objects in that file. + """ + if not hasattr(fp, 'read') or not hasattr(fp, 'tell') or not hasattr(fp, 'seek'): + raise TypeError("Expected fp to be a file-like object with tell()/seek() and read() returning bytes") + if mapper is None: + mapper = dict + if not issubclass(mapper, Mapping): + raise TypeError("Expected mapper to be subclass of dict, got %s" % type(mapper)) + + # helpers + int32 = struct.Struct(' 1: + stack.pop() + continue + break + + if key_table: + # If 'key_table' was provided, each key is an int32 value that + # needs to be mapped to an actual field name using a key table. + # Newer appinfo.vdf (V29+) stores this table at the end of the file. + index = int32.unpack(fp.read(int32.size))[0] + + key = key_table[index] + else: + key = read_string(fp) + + if t == BIN_NONE: + if merge_duplicate_keys and key in stack[-1]: + _m = stack[-1][key] + else: + _m = mapper() + stack[-1][key] = _m + stack.append(_m) + elif t == BIN_STRING: + stack[-1][key] = read_string(fp) + elif t == BIN_WIDESTRING: + stack[-1][key] = read_string(fp, wide=True) + elif t in (BIN_INT32, BIN_POINTER, BIN_COLOR): + val = int32.unpack(fp.read(int32.size))[0] + + if t == BIN_POINTER: + val = POINTER(val) + elif t == BIN_COLOR: + val = COLOR(val) + + stack[-1][key] = val + elif t == BIN_UINT64: + stack[-1][key] = UINT_64(uint64.unpack(fp.read(int64.size))[0]) + elif t == BIN_INT64: + stack[-1][key] = INT_64(int64.unpack(fp.read(int64.size))[0]) + elif t == BIN_FLOAT32: + stack[-1][key] = float32.unpack(fp.read(float32.size))[0] + else: + raise SyntaxError("Unknown data type at offset %d: %s" % (fp.tell() - 1, repr(t))) + + if len(stack) != 1: + raise SyntaxError("Reached EOF, but Binary VDF is incomplete") + if raise_on_remaining and fp.read(1) != b'': + fp.seek(-1, 1) + raise SyntaxError("Binary VDF ended at offset %d, but there is more data remaining" % (fp.tell() - 1)) + + return stack.pop() + +def binary_dumps(obj, alt_format=False): + """ + Serialize ``obj`` to a binary VDF formatted ``bytes``. + """ + buf = BytesIO() + binary_dump(obj, buf, alt_format) + return buf.getvalue() + +def binary_dump(obj, fp, alt_format=False): + """ + Serialize ``obj`` to a binary VDF formatted ``bytes`` and write it to ``fp`` filelike object + """ + if not isinstance(obj, Mapping): + raise TypeError("Expected obj to be type of Mapping") + if not hasattr(fp, 'write'): + raise TypeError("Expected fp to have write() method") + + for chunk in _binary_dump_gen(obj, alt_format=alt_format): + fp.write(chunk) + +def _binary_dump_gen(obj, level=0, alt_format=False): + if level == 0 and len(obj) == 0: + return + + int32 = struct.Struct('= 3: + _iter_values = 'values' + _range = range + _string_type = str + import collections.abc as _c + class _kView(_c.KeysView): + def __iter__(self): + return self._mapping.iterkeys() # type: ignore + class _vView(_c.ValuesView): + def __iter__(self): + return self._mapping.itervalues() # type: ignore + class _iView(_c.ItemsView): + def __iter__(self): + return self._mapping.iteritems() # type: ignore +else: + _iter_values = 'itervalues' + _range = xrange + _string_type = basestring + _kView = lambda x: list(x.iterkeys()) + _vView = lambda x: list(x.itervalues()) + _iView = lambda x: list(x.iteritems()) + + +class VDFDict(dict): + def __init__(self, data=None): + """ + This is a dictionary that supports duplicate keys and preserves insert order + + ``data`` can be a ``dict``, or a sequence of key-value tuples. (e.g. ``[('key', 'value'),..]``) + The only supported type for key is str. + + Get/set duplicates is done by tuples ``(index, key)``, where index is the duplicate index + for the specified key. (e.g. ``(0, 'key')``, ``(1, 'key')``...) + + When the ``key`` is ``str``, instead of tuple, set will create a duplicate and get will look up ``(0, key)`` + """ + self.__omap = [] + self.__kcount = Counter() + + if data is not None: + if not isinstance(data, (list, dict)): + raise ValueError("Expected data to be list of pairs or dict, got %s" % type(data)) + self.update(data) + + def __repr__(self): + out = "%s(" % self.__class__.__name__ + out += "%s)" % repr(list(self.iteritems())) + return out + + def __len__(self): + return len(self.__omap) + + def _verify_key_tuple(self, key): + if len(key) != 2: + raise ValueError("Expected key tuple length to be 2, got %d" % len(key)) + if not isinstance(key[0], int): + raise TypeError("Key index should be an int") + if not isinstance(key[1], _string_type): + raise TypeError("Key value should be a str") + + def _normalize_key(self, key): + if isinstance(key, _string_type): + key = (0, key) + elif isinstance(key, tuple): + self._verify_key_tuple(key) + else: + raise TypeError("Expected key to be a str or tuple, got %s" % type(key)) + return key + + def __setitem__(self, key, value): + if isinstance(key, _string_type): + key = (self.__kcount[key], key) + self.__omap.append(key) + elif isinstance(key, tuple): + self._verify_key_tuple(key) + if key not in self: + raise KeyError("%s doesn't exist" % repr(key)) + else: + raise TypeError("Expected either a str or tuple for key") + super(VDFDict, self).__setitem__(key, value) + self.__kcount[key[1]] += 1 + + def __getitem__(self, key): + return super(VDFDict, self).__getitem__(self._normalize_key(key)) + + def __delitem__(self, key): + key = self._normalize_key(key) + result = super(VDFDict, self).__delitem__(key) + + start_idx = self.__omap.index(key) + del self.__omap[start_idx] + + dup_idx, skey = key + self.__kcount[skey] -= 1 + tail_count = self.__kcount[skey] - dup_idx + + if tail_count > 0: + for idx in _range(start_idx, len(self.__omap)): + if self.__omap[idx][1] == skey: + oldkey = self.__omap[idx] + newkey = (dup_idx, skey) + super(VDFDict, self).__setitem__(newkey, self[oldkey]) + super(VDFDict, self).__delitem__(oldkey) + self.__omap[idx] = newkey + + dup_idx += 1 + tail_count -= 1 + if tail_count == 0: + break + + if self.__kcount[skey] == 0: + del self.__kcount[skey] + + return result + + def __iter__(self): + return iter(self.iterkeys()) + + def __contains__(self, key): + return super(VDFDict, self).__contains__(self._normalize_key(key)) + + def __eq__(self, other): + if isinstance(other, VDFDict): + return list(self.items()) == list(other.items()) + else: + return False + + def __ne__(self, other): + return not self.__eq__(other) + + def clear(self): + super(VDFDict, self).clear() + self.__kcount.clear() + self.__omap = list() + + def get(self, key, *args): + return super(VDFDict, self).get(self._normalize_key(key), *args) + + def setdefault(self, key, default=None): + if key not in self: + self.__setitem__(key, default) + return self.__getitem__(key) + + def pop(self, key): + key = self._normalize_key(key) + value = self.__getitem__(key) + self.__delitem__(key) + return value + + def popitem(self): + if not self.__omap: + raise KeyError("VDFDict is empty") + key = self.__omap[-1] + return key[1], self.pop(key) + + def update(self, data=None, **kwargs): + if isinstance(data, dict): + data = data.items() + elif not isinstance(data, list): + raise TypeError("Expected data to be a list or dict, got %s" % type(data)) + + for key, value in data: + self.__setitem__(key, value) + + def iterkeys(self): + return (key[1] for key in self.__omap) + + def keys(self): + return _kView(self) + + def itervalues(self): + return (self[key] for key in self.__omap) + + def values(self): + return _vView(self) + + def iteritems(self): + return ((key[1], self[key]) for key in self.__omap) + + def items(self): + return _iView(self) + + def get_all_for(self, key): + """ Returns all values of the given key """ + if not isinstance(key, _string_type): + raise TypeError("Key needs to be a string.") + return [self[(idx, key)] for idx in _range(self.__kcount[key])] + + def remove_all_for(self, key): + """ Removes all items with the given key """ + if not isinstance(key, _string_type): + raise TypeError("Key need to be a string.") + + for idx in _range(self.__kcount[key]): + super(VDFDict, self).__delitem__((idx, key)) + + self.__omap = list(filter(lambda x: x[1] != key, self.__omap)) + + del self.__kcount[key] + + def has_duplicates(self): + """ + Returns ``True`` if the dict contains keys with duplicates. + Recurses through any all keys with value that is ``VDFDict``. + """ + for n in getattr(self.__kcount, _iter_values)(): + if n != 1: + return True + + def dict_recurse(obj): + for v in getattr(obj, _iter_values)(): + if isinstance(v, VDFDict) and v.has_duplicates(): + return True + elif isinstance(v, dict): + return dict_recurse(v) + return False + + return dict_recurse(self) diff --git a/src/hhd/plugins/overlay/systemd.py b/src/hhd/plugins/overlay/systemd.py new file mode 100644 index 00000000..9662f36b --- /dev/null +++ b/src/hhd/plugins/overlay/systemd.py @@ -0,0 +1,105 @@ +import logging +import subprocess +from typing import Literal +import os + +logger = logging.getLogger(__name__) + + +class WakeHandler: + def __init__(self) -> None: + self.proc = None + self.inhibitor = None + self.broken = False + self.fd = -1 + self.got_prepare = False + + def start(self): + try: + self.proc = subprocess.Popen( + [ + "dbus-monitor", + "--system", + "type='signal',interface='org.freedesktop.login1.Manager'", + "--monitor", + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + assert self.proc.stdout is not None + self.fd = self.proc.stdout.fileno() + os.set_blocking(self.fd, False) + + if not self.inhibit(True): + # We need both the inhibitor and reader for this to work + self.close() + return False + + return True + except Exception: + if self.proc: + self.proc.terminate() + self.proc.wait() + self.proc = None + self.broken = True + return False + + def inhibit(self, enable: bool): + if self.inhibitor: + self.inhibitor.terminate() + self.inhibitor.wait() + self.inhibitor = None + + if self.broken: + return False + + if enable: + try: + self.inhibitor = subprocess.Popen( + [ + "systemd-inhibit", + "--what=sleep", + "--mode=delay", + "--who", + "HandheldDaemon", + "--why", + "Handheld Daemon: Turn off display", + "--", + "tail", + "-f", + "/dev/null", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return True + except Exception: + self.inhibitor = None + return False + + return True + + def __call__(self) -> Literal["entry", "exit", None]: + if self.broken or self.fd == -1 or not self.proc or not self.proc.stdout: + return None + try: + while line := self.proc.stdout.readline(): + if "PrepareForSleep" in line: + self.got_prepare = True + elif self.got_prepare and "boolean" in line: + self.got_prepare = False + return "entry" if "true" in line else "exit" + except Exception as e: + logger.error(f"Systemd monitor error:\n{e}") + self.close() + self.broken = True + + return None + + def close(self): + if self.proc: + self.proc.kill() + self.proc.wait() + self.proc = None + self.inhibit(False) diff --git a/src/hhd/plugins/overlay/x11.py b/src/hhd/plugins/overlay/x11.py new file mode 100644 index 00000000..05879eed --- /dev/null +++ b/src/hhd/plugins/overlay/x11.py @@ -0,0 +1,568 @@ +import logging +import os +import subprocess +import time +from select import select +from threading import Event as TEvent +from typing import Any, NamedTuple, Sequence + +import Xlib +from Xlib import XK, X, Xatom, display, error +from Xlib.ext.xtest import fake_input + +from hhd.plugins import Context, Emitter, Config +from hhd.utils import restore_priviledge, switch_priviledge + +logger = logging.getLogger(__name__) + +X11_DIR = b"/tmp/.X11-unix/" +HHD_ID = 5335 +STEAM_ID = 769 + + +class CachedValues(NamedTuple): + overlay: bool + focus: bool + notify: bool + touch: int | None + + +QAM_DELAY = 0.35 + + +class QamHandlerGamescope: + def __init__( + self, ctx=None, force_disp: str | None = None, compat_send: bool = True + ) -> None: + self.disp = None + self.ctx = ctx + self.force_disp = force_disp + self.compat_send = compat_send + + def _register_display(self): + self.close() + try: + if self.force_disp: + res = display.Display(self.force_disp), self.force_disp + else: + res = get_overlay_display(get_gamescope_displays(), self.ctx) + if not res: + logger.info( + f"Could not find gamescope display, sending compatibility QAM." + ) + return False + self.disp, name = res + logger.info(f"Registering display {name} to send QAM events to.") + return True + except Exception as e: + logger.info(f"Error while registering Gamescope display for QAM:\n{e}.") + return False + + def _send_qam(self, expanded=False): + try: + disp = self.disp + if not disp: + return False + get_key = lambda k: disp.keysym_to_keycode(XK.string_to_keysym(k)) + KCTRL = get_key("Control_L") + KEY = get_key("1" if expanded else "2") + + # Checking for steam seemed to work, but blanket sending the command + # with a compatibility QAM at first works the same and this looks + # fragile + # steam = find_steam(disp) + # if not steam: + # logger.info(f"Could not find Steam (?). Sending compatibility QAM.") + # return False + + fake_input(disp, X.KeyPress, KCTRL) # , root=steam) + fake_input(disp, X.KeyPress, KEY) # , root=steam) + disp.sync() + time.sleep(QAM_DELAY) + fake_input(disp, X.KeyRelease, KCTRL) # , root=steam) + fake_input(disp, X.KeyRelease, KEY) # , root=steam) + disp.sync() + logger.info(f"Sent QAM event directly to gamescope.") + return True + except Exception as e: + logger.warning( + f"Could not send QAM to Gamescope with error:\n{e}\nSending compatibility QAM." + ) + return False + + def __call__(self, expanded=False) -> Any: + if self._send_qam(expanded): + return True + # Steam fails to open QAM with ctrl+2 the first time + # So send compatibility QAM if we have to register display + if self._register_display() and self.compat_send: + logger.info( + "Sending compatibility QAM as first QAM, as display was registered now." + ) + if not self.compat_send: + return self._send_qam(expanded) + return False + + def close(self): + if self.disp: + try: + self.disp.close() + self.disp = None + except Exception: + pass + + +def find_x11_auth(ctx: Context): + # TODO: Fix hardcoding runtime dir + LOCATION = f"/run/user/{ctx.euid}" + for fn in sorted(os.listdir(LOCATION)): + if ( + # KDE + fn.startswith("xauth_") + # GNOME + or fn.startswith(".mutter-Xwaylandauth.") + ): + return os.path.join(LOCATION, fn) + + +def find_x11_display(ctx: Context): + for fn in sorted(os.listdir(X11_DIR)): + if fn and os.stat(X11_DIR + fn).st_uid == ctx.euid: + return ":" + fn[1:].decode() + + +def get_gamescope_displays(): + """Returns X11 UNIX sockets from gamescope opened under /tmp""" + files = subprocess.run(["lsof", "-c", "gamescope-wl", "-Fn"], capture_output=True) + out = [] + for ln in files.stdout.splitlines(): + if len(ln) < 1: + continue + ln = ln[1:] + + if not ln.startswith(X11_DIR): + continue + + fn = ln.split(b" ")[0] + disp = ":" + fn[len(X11_DIR) + 1 :].decode() + out.append(disp) + return out + + +def is_gamescope_running(): + return bool(get_gamescope_displays()) + + +def get_overlay_display(displays: Sequence[str], ctx=None): + """Probes the provided gamescope displays to find the overlay one.""" + + # FIXME: Fix authentication without priviledge deescalation + if ctx: + old = switch_priviledge(ctx, False) + else: + old = None + + try: + for disp in displays: + try: + d = display.Display(disp) + + atoms = [d.get_atom_name(v) for v in d.screen().root.list_properties()] + if "GAMESCOPE_FOCUSED_WINDOW" in atoms: + return d, disp + + d.close() + except Exception: + pass + finally: + if old: + restore_priviledge(old) + + +def apply_gamescope_config(display: display.Display, config: Config, prev: dict): + apply = False + + halfhz = config.get("steamui_halfhz", None) + halfhz_rev = prev.get("steamui_halfhz", None) + if halfhz is not None and halfhz != halfhz_rev: + display.screen().root.change_property( + display.get_atom("GAMESCOPE_STEAMUI_HALFHZ"), Xatom.CARDINAL, 32, [int(halfhz)] + ) + logger.info(f"Setting SteamUI halfhz to {halfhz}.") + prev["steamui_halfhz"] = halfhz + apply = True + + if apply: + display.flush() + +def find_wins(display: display.Display, win: list[str], atoms: list[str] = []): + n = display.get_atom("WM_CLASS") + a_ids = [display.get_atom(a, only_if_exists=True) for a in atoms] + + wins = [] + for w in display.screen().root.query_tree().children: + # Check the window has the proper class + v = w.get_property(n, Xatom.STRING, 0, 50) + if not v: + continue + if not v.value: + continue + + # Check the window has all the required atoms + for a_id in a_ids: + if not w.get_property(a_id, Xatom.STRING, 0, 50): + return + + classes = [c.decode() for c in v.value.split(b"\00") if c] + found = True + for val in win: + if val not in classes: + found = False + + if found: + wins.append(w) + return wins + + +def find_win(display: display.Display, win: list[str], atoms: list[str] = []): + out = find_wins(display, win, atoms) + return out[0] if out else None + + +def register_changes(display, win): + win.change_attributes(event_mask=Xlib.X.PropertyChangeMask) + display.flush() + display.sync() + + +def find_hhd(display: display.Display): + return find_win(display, ["dev.hhd.hhd-ui"]) + + +def find_steam(display: display.Display): + return find_win(display, ["steamwebhelper", "steam"]) or find_win( + display, ["steamwebhelper", "SDL Application"] + ) + + +def does_steam_exist(display: display.Display): + return find_win(display, ["steamwebhelper"]) or find_win(display, ["steam"]) + + +def print_data(display: display.Display): + for w in (find_hhd(display), find_steam(display), display.screen().root): + if not w: + continue + for p in w.list_properties(): + req = w.get_property(p, Xatom.CARDINAL, 0, 100) + if req: + v = list(req.value) if req.value else None + else: + v = None + print(f"{p:4d}-{display.get_atom_name(p):>40s}: {v}") + print() + + +def print_debug(display: display.Display, args: list[str] = []): + d = display + r = display.screen().root + + if "noatoms" not in args: + print("ATOMS:") + for v in r.list_properties(): + print(f"{v: 4d}: {d.get_atom_name(v)}") + + if "root" in args: + windows = [r] + else: + windows = [r, *r.query_tree().children] + + print() + print("WINDOWS:") + for i, w in enumerate(windows): + print(f"\n{i:02d}:", end="") + for p in w.list_properties(): + n = d.get_atom_name(p) + if "WM_NAME" == n: + print(f" '{w.get_property(p, Xatom.STRING, 0, 100).value.decode()}'") + break + else: + print(" no name") + + for p in w.list_properties(): + n = d.get_atom_name(p) + if "STEAM" in n or "GAMESCOPE" in n: + print( + f"> {n}: {list(w.get_property(p, Xatom.CARDINAL, 0, 15).value)},", + ) + for p in w.list_properties(): + n = d.get_atom_name(p) + if "STEAM" not in n and "GAMESCOPE" not in n: + print( + f"- {n}: {list(w.get_property(p, Xatom.CARDINAL, 0, 15).value) or w.get_property(p, Xatom.STRING, 0, 15).value},", + ) + + +def prepare_hhd(display, hhd, steam=None): + if not steam: + # If hhd appears a game steam will have issues with per-game profiles + hhd.change_property( + display.get_atom("STEAM_GAME"), Xatom.CARDINAL, 32, [HHD_ID] + ) + # If steam is missing, gamescope disables window controls, so we have + # to play with the opacity to hide. + hhd.change_property( + display.get_atom("_NET_WM_WINDOW_OPACITY"), Xatom.CARDINAL, 32, [0] + ) + + hhd.change_property(display.get_atom("STEAM_NOTIFICATION"), Xatom.CARDINAL, 32, [0]) + hhd.change_property(display.get_atom("STEAM_BIGPICTURE"), Xatom.CARDINAL, 32, [1]) + hhd.change_property(display.get_atom("GAMESCOPE_NO_FOCUS"), Xatom.CARDINAL, 32, [1]) + display.flush() + display.sync() + + +def process_events(disp): + try: + found = False + for _ in range(disp.pending_events()): + ev = disp.next_event() + if ev and hasattr(ev, "atom") and "STEAM" in disp.get_atom_name(ev.atom): + found = True + return found + except Exception as e: + logger.warning(f"Failed to process display events with error:\n{e}") + return True + +def get_current_game(display): + stat_game = display.get_atom("GAMESCOPE_FOCUSED_APP_GFX") + game = display.screen().root.get_property(stat_game, Xatom.CARDINAL, 0, 15) + return game.value[0] if game and game.value else None + + +def set_dpms(display, enable: bool): + stat_dpms = display.get_atom("GAMESCOPE_DPMS") + display.screen().root.change_property( + stat_dpms, Xatom.CARDINAL, 32, [1 if enable else 0] + ) + display.flush() + + +def update_steam_values(display, steam, old: CachedValues | None): + stat_focus = display.get_atom("STEAM_INPUT_FOCUS") + stat_overlay = display.get_atom("STEAM_OVERLAY") + stat_notify = display.get_atom("STEAM_NOTIFICATION") + stat_click = display.get_atom("STEAM_TOUCH_CLICK_MODE") + + def was_set(v): + prop = steam.get_property(v, Xatom.CARDINAL, 0, 15) + return prop and prop.value and bool(prop.value[0]) + + new_focus = was_set(stat_focus) + new_overlay = was_set(stat_overlay) + new_notify = was_set(stat_notify) + + # Use some weird logic to get previous touch value + # Essentially, only remember that value if it was set and different than TARGET_TOUCH + r = display.screen().root + prop = r.get_property(stat_click, Xatom.CARDINAL, 0, 15) + touch_was_set = prop and prop.value and prop.value[0] != TARGET_TOUCH + touch_val = prop.value[0] if touch_was_set else None + if touch_val is None and old and old.touch is not None: + touch_val = old.touch + + out = CachedValues( + focus=new_focus or (old.focus if old else False), + overlay=new_overlay or (old.overlay if old else False), + notify=new_notify or (old.notify if old else False), + touch=touch_val, + ) + return out, new_focus or new_overlay or new_notify + + +TARGET_TOUCH = 4 + + +def show_hhd(display, hhd, steam): + stat_focus = display.get_atom("STEAM_INPUT_FOCUS") + stat_overlay = display.get_atom("STEAM_OVERLAY") + stat_notify = display.get_atom("STEAM_NOTIFICATION") + stat_click = display.get_atom("STEAM_TOUCH_CLICK_MODE") + + # Unfortunately, doing the commented out section breaks steam profiles + # and enables desktop mode steam input on Handheld Daemon, showing a mouse + + # # Here, we do a bit of trickery with steam + # # We pretend to be one of the games that the user has launched to not break + # # steam profiles and to get steam to ignore its input + # stat_game = display.get_atom("STEAM_GAME") + # stat_focusable = display.get_atom("GAMESCOPE_FOCUSABLE_APPS") + + # If steam set the touch value to something else, try to override it with 1 + r = display.screen().root + prop = r.get_property(stat_click, Xatom.CARDINAL, 0, 15) + touch_was_set = prop and prop.value + + hhd.change_property(stat_focus, Xatom.CARDINAL, 32, [1]) + hhd.change_property(stat_overlay, Xatom.CARDINAL, 32, [1]) + if steam: + steam.change_property(stat_focus, Xatom.CARDINAL, 32, [0]) + steam.change_property(stat_overlay, Xatom.CARDINAL, 32, [0]) + steam.change_property(stat_notify, Xatom.CARDINAL, 32, [0]) + + # # Use a game id for hhd so that steam does not leak input + # new_id = HHD_ID + # focusable = display.screen().root.get_property( + # stat_focusable, Xatom.CARDINAL, 0, 50 + # ) + # if focusable and focusable.value: + # for i in focusable.value: + # if i == HHD_ID and i != STEAM_ID: + # new_id = i + # break + # logger.info(f"Setting HHD as game '{new_id}' to disable steam navigation.") + # hhd.change_property(stat_game, Xatom.CARDINAL, 32, [new_id]) + + if not steam: + hhd.change_property( + display.get_atom("_NET_WM_WINDOW_OPACITY"), Xatom.CARDINAL, 32, [0xFFFFFFFF] + ) + + if touch_was_set: + # Give it a bit of time before setting the touch target to avoid steam + # messing with it + display.flush() + display.sync() + time.sleep(0.1) + r.change_property(stat_click, Xatom.CARDINAL, 32, [TARGET_TOUCH]) + + display.flush() + display.sync() + + +def hide_hhd(display, hhd, steam, old: CachedValues | None): + stat_focus = display.get_atom("STEAM_INPUT_FOCUS") + stat_overlay = display.get_atom("STEAM_OVERLAY") + stat_notify = display.get_atom("STEAM_NOTIFICATION") + stat_click = display.get_atom("STEAM_TOUCH_CLICK_MODE") + + # Set values + hhd.change_property(stat_focus, Xatom.CARDINAL, 32, [0]) + hhd.change_property(stat_overlay, Xatom.CARDINAL, 32, [0]) + + # Restore steam + if steam and old: + if old.focus: + steam.change_property(stat_focus, Xatom.CARDINAL, 32, [1]) + if old.overlay: + steam.change_property(stat_overlay, Xatom.CARDINAL, 32, [1]) + if old.notify: + steam.change_property(stat_notify, Xatom.CARDINAL, 32, [1]) + if old.touch is not None: + display.screen().root.change_property( + stat_click, Xatom.CARDINAL, 32, [old.touch] + ) + + if not steam: + hhd.change_property( + display.get_atom("_NET_WM_WINDOW_OPACITY"), Xatom.CARDINAL, 32, [0x0] + ) + + display.flush() + display.sync() + + +def find_focusable_windows(display): + stat_focusable = display.get_atom("GAMESCOPE_FOCUSABLE_APPS") + focusable = display.screen().root.get_property( + stat_focusable, Xatom.CARDINAL, 0, 50 + ) + return focusable.value if focusable and focusable.value else [] + + +def make_hhd_not_focusable(display): + stat_focused = display.get_atom("GAMESCOPECTRL_BASELAYER_APPID") + stat_focusable = display.get_atom("GAMESCOPE_FOCUSABLE_APPS") + + focusable = display.screen().root.get_property( + stat_focusable, Xatom.CARDINAL, 0, 50 + ) + curr = display.screen().root.get_property(stat_focused, Xatom.CARDINAL, 0, 50) + + if not focusable or not focusable.value: + # Cannot print here or the logs will be swarmed + # There should always be something here + return + + # Check whether we should write focusable apps to hide hhd + write_focus = False + if not curr or not curr.value: + write_focus = True + else: + for i in focusable.value: + if i == HHD_ID: + # skip hhd + continue + found = False + for j in curr.value: + if i == j: + found = True + break + if not found: + write_focus = True + break + + # Hide HHD + if write_focus: + new_focus = [v for v in focusable.value if v != HHD_ID] + logger.info( + f"Hiding Handheld Daemon from gamescope. Setting focusable apps to: {new_focus}" + ) + display.screen().root.change_property( + stat_focused, Xatom.CARDINAL, 32, new_focus + ) + display.flush() + display.sync() + + +def monitor_gamescope(emit: Emitter, ctx, should_exit: TEvent): + GAMESCOPE_WAIT = 2 + GAMESCOPE_GUARD = 1 + + should_exit = TEvent() + + while not should_exit.is_set(): + # Wait for gamescope + try: + res = get_overlay_display(get_gamescope_displays(), ctx) + if not res: + time.sleep(GAMESCOPE_WAIT) + continue + + d, name = res + logger.info(f"Found gamescope display {name}") + r = d.screen().root + r.change_attributes(event_mask=X.PropertyChangeMask) + fn = d.fileno() + atom = d.get_atom("GAMESCOPE_FOCUSED_APP_GFX") + old = None + + while not should_exit.is_set(): + rs = select([fn], [], [], GAMESCOPE_GUARD)[0] + if not rs: + continue + + process_events(d) + + val = r.get_property(atom, Xatom.CARDINAL, 0, 5) + if not val or not val.value: + continue + + game = val.value[0] + if old != game: + old = game + logger.warning(game) + + except Exception as e: + logger.warning(f"Lost connection to gamescope. Did steam exit? Error:\n{e}") + time.sleep(GAMESCOPE_WAIT) diff --git a/src/hhd/plugins/plugin.py b/src/hhd/plugins/plugin.py index 7196a961..0e8b4564 100644 --- a/src/hhd/plugins/plugin.py +++ b/src/hhd/plugins/plugin.py @@ -1,18 +1,19 @@ -from typing import ( - Any, - Literal, - Mapping, - NamedTuple, - Protocol, - Sequence, - TypedDict, -) +import getpass +import logging +import os +import subprocess +from typing import Any, Literal, Mapping, NamedTuple, Protocol, Sequence, TypedDict -from hhd.controller import Axis, Button, Configuration +from hhd.controller import Axis, Button, Configuration, ControllerEmitter, SpecialEvent from .conf import Config from .settings import HHDSettings +logger = logging.getLogger(__name__) + +STEAM_PID = "~/.steam/steam.pid" +STEAM_EXE = "~/.steam/root/ubuntu12_32/steam" + class Context(NamedTuple): euid: int = 0 @@ -27,10 +28,20 @@ class SettingsEvent(TypedDict): type: Literal["settings"] +class PowerEvent(TypedDict): + type: Literal["acpi"] + event: Literal["ac", "dc", "tdp", "battery"] + + +class TdpEvent(TypedDict): + type: Literal["tdp"] + tdp: int | None + + class ProfileEvent(TypedDict): type: Literal["profile"] name: str - config: Config + config: Config | None class ApplyEvent(TypedDict): @@ -43,6 +54,11 @@ class ConfigEvent(TypedDict): config: Config +class EnergyEvent(TypedDict): + type: Literal["ppd", "energy"] + status: Literal["power", "balanced", "performance"] + + class InputEvent(TypedDict): type: Literal["input"] controller_id: int @@ -52,13 +68,48 @@ class InputEvent(TypedDict): conf_state: Mapping[Configuration, Any] -Event = ConfigEvent | InputEvent | ProfileEvent | ApplyEvent +Event = ( + ConfigEvent + | InputEvent + | ProfileEvent + | ApplyEvent + | SettingsEvent + | SpecialEvent + | PowerEvent + | TdpEvent + | EnergyEvent +) + +class Emitter(ControllerEmitter): + def __init__(self, ctx=None, info=None) -> None: + if info is None: + info = Config() + self.info = info + self.data = {} + self.images = {} + super().__init__(ctx) -class Emitter(Protocol): def __call__(self, event: Event | Sequence[Event]) -> None: pass + def set_gamedata( + self, data: dict[str, dict[str, str]], images: dict[str, dict[str, str]] + ) -> None: + with self.intercept_lock: + self.data = data + self.images = images + + def get_gamedata(self, game: str | None) -> dict[str, str] | None: + if not game: + return None + with self.intercept_lock: + return self.data.get(game, None) + + def get_image(self, game: str, icon: str) -> str | None: + with self.intercept_lock: + return self.images.get(game, {}).get(icon, None) + class HHDPlugin: name: str @@ -75,12 +126,18 @@ def open( def settings(self) -> HHDSettings: return {} + def validate(self, tags: Sequence[str], config: Any, value: Any): + return False + def prepare(self, conf: Config): pass def update(self, conf: Config): pass + def notify(self, events: Sequence[Event]): + pass + def close(self): pass @@ -88,3 +145,200 @@ def close(self): class HHDAutodetect(Protocol): def __call__(self, existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: raise NotImplementedError() + + +class HHDLocale(TypedDict): + dir: str + domain: str + priority: int + + +class HHDLocaleRegister(Protocol): + def __call__(self) -> Sequence[HHDLocale]: + raise NotImplementedError() + + +def get_context(user: str | None) -> Context | None: + try: + uid = os.getuid() + gid = os.getgid() + + if not user: + if not uid: + print(f"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print( + "Running as root without a specified user (`--user`). Configs will be placed at `/root/.config`." + ) + print(f"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + return Context(uid, gid, uid, gid, getpass.getuser()) + + user = user.replace("\\x2", "-") + + euid = int( + subprocess.run( + ["id", "-u", user], capture_output=True, check=True + ).stdout.decode() + ) + egid = int( + subprocess.run( + ["id", "-g", user], capture_output=True, check=True + ).stdout.decode() + ) + + if (uid or gid) and (uid != euid or gid != egid): + print( + f"The user specified with --user is not the user this process was started with." + ) + return None + + return Context(euid, egid, uid, gid, user) + except subprocess.CalledProcessError as e: + print(f"Getting the user uid/gid returned an error:\n{e.stderr.decode()}") + return None + except Exception as e: + print(f"Failed getting permissions with error:\n{e}") + return None + + +def switch_priviledge(p: Context, escalate=False): + uid = os.geteuid() + gid = os.getegid() + + if escalate: + os.seteuid(p.uid) + os.setegid(p.gid) + else: + os.setegid(p.egid) + os.seteuid(p.euid) + + return uid, gid + + +def restore_priviledge(old: tuple[int, int]): + uid, gid = old + # Try writing group first in case of root + # and fail silently + try: + os.setegid(gid) + except Exception: + pass + os.seteuid(uid) + os.setegid(gid) + pass + + +def expanduser(path: str, user: int | str | Context | None = None): + """Expand ~ and ~user constructions. If user or $HOME is unknown, + do nothing. + + Modified from the python implementation to support using the target userid/user.""" + + path = os.fspath(path) + + if not path.startswith("~"): + return path + + i = path.find("/", 1) + if i < 0: + i = len(path) + if i == 1: + if "HOME" in os.environ and not user: + # Fallback to environ only if user not set + userhome = os.environ["HOME"] + else: + try: + import pwd + except ImportError: + # pwd module unavailable, return path unchanged + return path + try: + if not user: + userhome = pwd.getpwuid(os.getuid()).pw_dir + elif isinstance(user, int): + userhome = pwd.getpwuid(user).pw_dir + elif isinstance(user, Context): + userhome = pwd.getpwuid(user.euid).pw_dir + else: + userhome = pwd.getpwnam(user).pw_dir + except KeyError: + # bpo-10496: if the current user identifier doesn't exist in the + # password database, return the path unchanged + return path + else: + try: + import pwd + except ImportError: + # pwd module unavailable, return path unchanged + return path + name = path[1:i] + try: + pwent = pwd.getpwnam(name) + except KeyError: + # bpo-10496: if the user name from the path doesn't exist in the + # password database, return the path unchanged + return path + userhome = pwent.pw_dir + + root = "/" + userhome = userhome.rstrip(root) + return (userhome + path[i:]) or root + + +def fix_perms(fn: str, ctx: Context): + os.chown(fn, ctx.euid, ctx.egid) + + +def is_steam_gamepad_running(ctx: Context | None, gamepadui: bool = True): + pid = None + try: + with open(expanduser(STEAM_PID, ctx)) as f: + pid = f.read().strip() + + steam_cmd_path = f"/proc/{pid}/cmdline" + if not os.path.exists(steam_cmd_path): + return False + + # The command line is irrelevant if we just want to know if Steam is running. + if not gamepadui: + return True + + # Use this and line to determine if Steam is running in DeckUI mode. + with open(steam_cmd_path, "rb") as f: + steam_cmd = f.read() + is_deck_ui = b"-gamepadui" in steam_cmd + if not is_deck_ui: + return False + except Exception: + return False + return True + + +def run_steam_command(command: str, ctx: Context): + global home_path + try: + if ctx.euid != ctx.uid: + result = subprocess.run( + [ + "su", + ctx.name, + "-c", + f"{expanduser(STEAM_EXE, ctx)} -ifrunning {command}", + ] + ) + else: + result = subprocess.run([expanduser(STEAM_EXE, ctx), "-ifrunning", command]) + + return result.returncode == 0 + except Exception as e: + logger.error(f"Received error when running steam command `{command}`\n{e}") + return False + + +def open_steam_kbd(emit, open: bool = True): + return ( + emit + and is_steam_gamepad_running(emit.ctx, False) + and run_steam_command( + f"steam://{'open' if open else 'close'}/keyboard", emit.ctx + ) + ) diff --git a/src/hhd/plugins/power/__init__.py b/src/hhd/plugins/power/__init__.py new file mode 100644 index 00000000..5d9f62e4 --- /dev/null +++ b/src/hhd/plugins/power/__init__.py @@ -0,0 +1,237 @@ +import logging +from typing import Sequence +import os +import time + +from hhd.plugins import Config, Context, HHDPlugin, load_relative_yaml +from .power import ( + get_windows_bootnum, + boot_windows, + emergency_hibernate, + emergency_shutdown, + delete_temporary_swap, +) + +logger = logging.getLogger(__name__) + +TEMP_CHECK_INTERVAL = 10 +# Chill for the first 10 minutes to avoid bricking installs if a device +# has e.g., a battery bug that trips the condition incorrectly +TEMP_CHECK_INITIALIZE = 300 +BATTERY_LOW_THRESHOLD = 5 +LAST_ATTEMPT_WAIT = 5 +LAST_ATTEMPT_BAIL = 30 + + +def thermal_check(therm: dict[str, int], bat: str | None, last_attempt: float = 0, wakeup: bool = False): + found = False + for path, temp in therm.items(): + with open(path) as f: + curr = int(f.read()) + if curr >= temp: + logger.warning( + f"Thermal zone {path} reached {curr // 1000}C, hibernating." + ) + found = True + + if bat and not found: + with open(bat + "/status") as f: + dc = "discharging" in bat.lower() + + with open(bat + "/capacity") as f: + curr = int(f.read()) + if dc and curr <= BATTERY_LOW_THRESHOLD: + logger.warning(f"Battery level reached {curr}%, hibernating.") + found = True + + if not found: + return False + + if not wakeup and time.time() - last_attempt < LAST_ATTEMPT_WAIT: + # There is a small chance that systemctl returns control too early + # and we run the event loop and fall in the if statement below. + # Therefore, unless this was triggered by a wakeup, we should + # wait a bit. + return False + elif time.time() - last_attempt < LAST_ATTEMPT_BAIL: + # Bail out if we woke up too soon + # This is to avoid a loop of hibernation attempts and wakeup + # Hibernation requires ~20s to complete, then boot another 20 + # so a user should not be able to trigger this by waking up + emergency_shutdown() + return True + else: + emergency_hibernate(shutdown=True) + return True + + +def set_bat_alarm(bat: str | None): + # If we do not do this, systemd will not and the system will wakeup + # randomly. Systemd only uses the alarm in sleep-then-hibernate and + # leaves it dangling otherwise. + # Only touch it if the setting is enabled. + if not bat or not os.path.exists(bat + "/alarm"): + return + + if os.path.exists(bat + "/energy_full"): + with open(bat + "/energy_full") as f: + full = int(f.read()) + else: + with open(bat + "/charge_full") as f: + full = int(f.read()) + + # Go a bit below the threshold to make sure we hibernate when we wake up. + lvl = BATTERY_LOW_THRESHOLD * 85 * full // 100 // 100 + logger.warning( + f"Setting battery alarm to {lvl}/{full} mAh/mWh ({BATTERY_LOW_THRESHOLD}%)" + ) + + with open(bat + "/alarm", "w") as f: + f.write(str(lvl)) + + +class PowerPlugin(HHDPlugin): + def __init__(self) -> None: + self.name = f"power" + self.priority = 50 + self.log = "powr" + self.win_bootnum = None + self.win_bootnum = get_windows_bootnum() + self.therm = {} + self.init = 0 + self.last_check = 0 + self.check_thermal = False + self.bat = None + self.alarm_set = False + self.last_attempt = 0 + + def open( + self, + emit, + context: Context, + ): + self.started = False + self.context = context + self.emit = emit + + self.init = time.time() + self.therm = {} + self.bat = None + + delete_temporary_swap() + + try: + for therm in os.listdir("/sys/class/thermal"): + if not therm.startswith("thermal_zone"): + continue + + with open(f"/sys/class/thermal/{therm}/type") as f: + if "acpitz" not in f.read(): + continue + + for trip in os.listdir(f"/sys/class/thermal/{therm}"): + if not trip.startswith("trip_point_"): + continue + + if not trip.endswith("_type"): + continue + + with open(f"/sys/class/thermal/{therm}/{trip}") as f: + if "hot" not in f.read(): + continue + + with open( + f"/sys/class/thermal/{therm}/{trip.replace("_type", "_temp")}" + ) as f: + self.therm[f"/sys/class/thermal/{therm}/temp"] = int(f.read()) + break + + for bat in os.listdir("/sys/class/power_supply"): + if not bat.startswith("BAT"): + continue + + with open(f"/sys/class/power_supply/{bat}/type") as f: + if "Battery" not in f.read(): + continue + + self.bat = f"/sys/class/power_supply/{bat}" + + except Exception as e: + logger.error(f"Failed to read thermal zones: {e}") + + if self.therm: + logger.info(f"Found thermal zones:") + for path, temp in self.therm.items(): + logger.info(f" {path}: hot @ {temp // 1000}C") + if self.bat: + logger.info(f"Found battery:\n{self.bat}") + + def settings(self): + set = {"gamemode": {"power": load_relative_yaml("power.yml")}} + + if self.win_bootnum is None: + del set["gamemode"]["power"]["children"]["reboot_windows"] + + return set + + def update(self, conf: Config): + if self.win_bootnum is not None and conf.get_action( + "gamemode.power.reboot_windows" + ): + boot_windows() + + if conf.get_action("gamemode.power.hibernate"): + status = emergency_hibernate(shutdown=False) + conf["gamemode.power.status"] = status + + self.check_thermal = conf.get("gamemode.power.hibernate_auto", False) + if self.check_thermal: + if not self.alarm_set: + self.alarm_set = True + if self.bat: + try: + set_bat_alarm(self.bat) + except Exception as e: + logger.error(f"Failed to set battery alarms:\n{e}") + + curr = time.time() + if ( + curr - self.last_check > TEMP_CHECK_INTERVAL + and curr - self.init > TEMP_CHECK_INITIALIZE + ): + self.last_check = curr + try: + if thermal_check(self.therm, self.bat, self.last_attempt): + self.last_attempt = time.time() + except Exception as e: + logger.error(f"Failed to check thermal zones:\n{e}") + self.therm = {} + self.bat = None + + def notify(self, events: Sequence): + for ev in events: + if ev["type"] == "special" and ev.get("event", None) == "wakeup": + delete_temporary_swap() + + if self.check_thermal: + try: + # Battery alarms neeed to be reset after hibernation + set_bat_alarm(self.bat) + except Exception as e: + logger.error(f"Failed to reset battery alarms:\n{e}") + self.bat = None + try: + if thermal_check(self.therm, self.bat, self.last_attempt, wakeup=True): + self.last_attempt = time.time() + except Exception as e: + logger.error(f"Failed to check thermal zones:\n{e}") + self.therm = {} + self.bat = None + return + + +def autodetect(existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: + if len(existing): + return existing + + return [PowerPlugin()] diff --git a/src/hhd/plugins/power/power.py b/src/hhd/plugins/power/power.py new file mode 100644 index 00000000..e4464398 --- /dev/null +++ b/src/hhd/plugins/power/power.py @@ -0,0 +1,302 @@ +import logging +import os +import subprocess + +from hhd.i18n import _ + +# TODO: Flip this to 0 on release +HHD_SWAP_CREATE = os.environ.get("HHD_SWAP_CREATE", "0") == "1" +HHD_SWAP_SUBVOL = os.environ.get("HHD_SWAP_SUBVOL", "/var/swap") +HHD_SWAP_FILE = os.environ.get("HHD_SWAP_FILE", "/var/swap/hhdswap") + +SAFETY_BUFFER = 1.3 +ZRAM_MULTIPLIER = 1.5 + +logger = logging.getLogger(__name__) + + +def get_windows_bootnum() -> int | None: + try: + s = subprocess.check_output("efibootmgr").decode("utf-8") + + for line in s.split("\n"): + if "Windows Boot Manager" in line: + return int(line[: line.index(" ")].replace("*", "").replace("Boot", "")) + + return None + except Exception as e: + return None + + +def boot_windows(): + bootnum = get_windows_bootnum() + + if bootnum is None: + logger.error("Could not find Windows Boot Manager in efibootmgr output") + return + + try: + subprocess.run(["efibootmgr", "-n", str(bootnum)]) + logger.info(f"Booting Windows with bootnum {bootnum}") + subprocess.run(["systemctl", "reboot"]) + except Exception as e: + logger.error(f"Failed to boot Windows: {e}") + + +def is_btrfs(fn): + print() + return ( + subprocess.run( + ["stat", "-f", "-c", "%T", fn], + check=True, + stdout=subprocess.PIPE, + text=True, + ).stdout.strip() + == "btrfs" + ) + + +def create_subvol(): + # First, create subvol dir to make checks + os.makedirs(HHD_SWAP_SUBVOL, exist_ok=True) + + # Check filesystem is btrfs + if not is_btrfs(HHD_SWAP_SUBVOL): + logger.info("Swap filesystem is not btrfs. Skipping subvolume creation.") + return + + # Check if subvolume already exists + if ( + subprocess.run( + ["btrfs", "subvolume", "show", HHD_SWAP_SUBVOL], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode + == 0 + ): + logger.info( + f"Swap subvolume {HHD_SWAP_SUBVOL} already exists. Skipping creation." + ) + return + + # Fixup selinux for swap + subprocess.run( + ["semanage", "fcontext", "-a", "-t", "var_t", HHD_SWAP_SUBVOL], + ) + subprocess.run(["restorecon", HHD_SWAP_SUBVOL]) + + logger.info(f"Creating swap subvolume {HHD_SWAP_SUBVOL}") + os.system(f"btrfs subvolume create {HHD_SWAP_SUBVOL}") + + +def get_meminfo(): + with open("/proc/meminfo") as f: + lines = f.readlines() + + meminfo = {} + for line in lines: + key, value = line.split(":") + meminfo[key.strip()] = int(value.strip().split(" ")[0]) + + return meminfo + + +def create_temporary_swap(): + if not HHD_SWAP_CREATE: + return + + swapdata = subprocess.run( + ["swapon", "--show", "--raw"], capture_output=True, text=True + ).stdout.split("\n")[1:] + + # Check if there is a swap on disk + has_swap = any(["/dev/zram" not in line and line for line in swapdata]) + assert not has_swap, ( + "Found swap partition. We cannot create temporary swap. Bail second attempt. Swap output:\n" + + "\n".join(swapdata) + ) + + if HHD_SWAP_SUBVOL: + create_subvol() + + # Check if there is a ZRAM swap partition + has_zram = any(["/dev/zram" in line for line in swapdata]) + meminfo = get_meminfo() + + required_kb = meminfo["MemTotal"] - meminfo["MemFree"] + required_kb *= SAFETY_BUFFER + # ZRAM can compress a lot, add a safety buffer + if has_zram: + required_kb *= ZRAM_MULTIPLIER + + if os.path.exists(HHD_SWAP_FILE): + os.remove(HHD_SWAP_FILE) + + if is_btrfs(os.path.dirname(HHD_SWAP_FILE)): + logger.info(f"Creating BTRFS swapfile {HHD_SWAP_FILE}") + subprocess.run( + [ + "btrfs", + "filesystem", + "mkswapfile", + HHD_SWAP_FILE, + "--size", + f"{int(required_kb)}k", + ], + check=True, + ) + else: + logger.info(f"Creating swapfile {HHD_SWAP_FILE} (w fallocate/mkswap)") + subprocess.run( + ["fallocate", "-l", f"{int(required_kb)}K", HHD_SWAP_FILE], check=True + ) + subprocess.run(["chmod", "600", HHD_SWAP_FILE], check=True) + subprocess.run(["mkswap", HHD_SWAP_FILE], check=True) + + # Fixup selinux for swap + subprocess.run( + [ + "semanage", + "fcontext", + "-a", + "-t", + "swapfile_t", + HHD_SWAP_FILE, + ], + ) + subprocess.run(["restorecon", HHD_SWAP_FILE]) + + # Enable swap + subprocess.run(["swapon", HHD_SWAP_FILE], check=True) + + # Reset resume device so systemd does not get confused + with open("/sys/power/resume", "w") as f: + f.write("0:0") + with open("/sys/power/resume_offset", "w") as f: + f.write("0") + + # Disable zram to avoid confusing the kernel + # Systemd will re-enable it after hibernation + for zram in swapdata: + if "/dev/zram" not in zram: + continue + + zram = zram.strip().split(" ")[0] + logger.info(f"Disabling ZRAM swap {zram}") + subprocess.run(["swapoff", zram], check=True) + + +def delete_temporary_swap(): + if not HHD_SWAP_CREATE: + return + + if not os.path.exists(HHD_SWAP_FILE): + return + + logger.info(f"Deleting swapfile {HHD_SWAP_FILE}") + try: + subprocess.run( + ["swapoff", HHD_SWAP_FILE], check=True, stdout=subprocess.DEVNULL + ) + except Exception as e: + logger.error(f"Failed to swapoff {HHD_SWAP_FILE}:\n{e}") + try: + os.remove(HHD_SWAP_FILE) + except Exception as e: + logger.error(f"Failed to delete {HHD_SWAP_FILE}:\n{e}") + + +def emergency_shutdown(): + logger.error("HIBERNATION FAILED. INITIATING EMERGENCY SHUTDOWN.") + os.system("systemctl poweroff") + + +def supports_sleep(): + # https://gitlab.freedesktop.org/drm/amd/-/blob/master/scripts/amd_s2idle.py + try: + fn = os.path.join("/", "sys", "power", "mem_sleep") + if not os.path.exists(fn): + logger.error( + "Kernel compiled without sleep support. Sleep button will force hibernate." + ) + return False + + with open(fn) as f: + sleep = f.read().strip() + + if "deep" in sleep: + logger.info("S3 sleep supported, sleep button will work.") + return True + + import struct + + target = os.path.join("/", "sys", "firmware", "acpi", "tables", "FACP") + with open(target, "rb") as r: + r.seek(0x70) + BIT = lambda x: 1 << x + found = struct.unpack("20s}: {wakeup_count:>3s} wakeups, {event_count:>3s} events") + wakeup_counts[entry.path] = wakeup_count + + with open(SMBIOS_FN, "rb") as f: + smbios = f.read() + + wakeup_reason = smbios[24] + # 00h Reserved + # 01h Other + # 02h Unknown + # 03h APM Timer + # 04h Modem Ring + # 05h LAN Remote + # 06h Power Switch + # 07h PCI PME# + # 08h AC Power Restored + match wakeup_reason: + case 0x00: + reason = "Reserved" + case 0x01: + reason = "Other" + case 0x02: + reason = "Unknown" + case 0x03: + reason = "APM Timer" + case 0x04: + reason = "Modem Ring" + case 0x05: + reason = "LAN Remote" + case 0x06: + reason = "Power Switch" + case 0x07: + reason = "PCI PME#" + case 0x08: + reason = "AC Power Restored" + case _: + reason = f"Unknown ({wakeup_reason:02X})" + + if old_reason != reason: + logger.info(f"Wakeup reason: {reason}") + old_reason = reason + + time.sleep(0.2) + + +def main(): + try: + main_loop() + except KeyboardInterrupt: + pass + +if __name__ == "__main__": + main() diff --git a/src/hhd/plugins/powerbutton/__init__.py b/src/hhd/plugins/powerbutton/__init__.py index b2d70b89..c577bd1f 100644 --- a/src/hhd/plugins/powerbutton/__init__.py +++ b/src/hhd/plugins/powerbutton/__init__.py @@ -1,12 +1,13 @@ -from typing import Any, Sequence, TYPE_CHECKING +import logging +from typing import TYPE_CHECKING, Any, Sequence -from hhd.plugins import ( - HHDPlugin, - Context, -) +from hhd.plugins import Config, Context, HHDPlugin, load_relative_yaml + +logger = logging.getLogger(__name__) if TYPE_CHECKING: from .const import PowerButtonConfig + from threading import Event, Thread @@ -19,40 +20,62 @@ def run(**config: Any): class PowerbuttondPlugin(HHDPlugin): def __init__(self, cfg: "PowerButtonConfig") -> None: self.name = f"powerbuttond@'{cfg.device}'" - self.priority = 20 - self.log = 'pbtn' + self.priority = 90 + self.log = "pbtn" self.cfg = cfg self.t = None self.event = None + self.emit = None def open( self, emit, context: Context, ): + self.started = False + self.context = context + self.emit = emit + + def settings(self): + d = {"hhd": load_relative_yaml("settings.yml")} + # if self.cfg.unsupported: + # d["hhd"]["settings"]["children"]["powerbuttond"]["default"] = False + return d + + def update(self, conf: Config): + if conf["hhd.settings.powerbuttond"].to(bool) and not self.started: + self.start() + elif not conf["hhd.settings.powerbuttond"].to(bool) and self.started: + self.stop() + logger.info('Stopping Steam Powerbutton Handler.') + + def start(self): from .base import power_button_run self.event = Event() - self.t = Thread(target=power_button_run, args=(self.cfg, context, self.event)) + self.t = Thread( + target=power_button_run, args=(self.cfg, self.context, self.event, self.emit) + ) self.t.start() + self.started = True - def close(self): + def stop(self): if not self.event or not self.t: return self.event.set() self.t.join() self.event = None self.t = None + self.started = False + + def close(self): + self.stop() def autodetect(existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: if len(existing): return existing - from .base import get_config - - cfg = get_config() - if not cfg: - return [] + from .const import get_config - return [PowerbuttondPlugin(cfg)] + return [PowerbuttondPlugin(get_config())] diff --git a/src/hhd/plugins/powerbutton/base.py b/src/hhd/plugins/powerbutton/base.py index e970a949..c20a4898 100644 --- a/src/hhd/plugins/powerbutton/base.py +++ b/src/hhd/plugins/powerbutton/base.py @@ -7,135 +7,119 @@ from typing import cast import evdev -from evdev import ecodes as e -from hhd.utils import Context, expanduser +from hhd.utils import Context, is_steam_gamepad_running, run_steam_command -from .const import SUPPORTED_DEVICES, PowerButtonConfig +from .const import PowerButtonConfig +from ..power.power import supports_sleep, emergency_hibernate logger = logging.getLogger(__name__) -STEAM_PID = "~/.steam/steam.pid" -STEAM_EXE = "~/.steam/root/ubuntu12_32/steam" STEAM_WAIT_DELAY = 0.5 -LONG_PRESS_DELAY = 2.5 +LONG_PRESS_DELAY = 2.0 +DEBOUNCE_DELAY = 1 def B(b: str): return cast(int, getattr(evdev.ecodes, b)) -def is_steam_gamescope_running(ctx: Context): - pid = None - try: - with open(expanduser(STEAM_PID, ctx)) as f: - pid = f.read().strip() - - steam_cmd_path = f"/proc/{pid}/cmdline" - if not os.path.exists(steam_cmd_path): - return False - - # Use this and line to determine if Steam is running in DeckUI mode. - with open(steam_cmd_path, "rb") as f: - steam_cmd = f.read() - is_deck_ui = b"-gamepadui" in steam_cmd - if not is_deck_ui: - return False - except Exception as e: - return False - return True - - -def run_steam_command(command: str, ctx: Context): - global home_path - try: - result = subprocess.run( - [ - "su", - ctx.name, - "-c", - f"{expanduser(STEAM_EXE, ctx)} -ifrunning {command}", - ] - ) - return result.returncode == 0 - except Exception as e: - logger.error(f"Received error when running steam command `{command}`\n{e}") - return False - - -def register_power_button(b: PowerButtonConfig) -> evdev.InputDevice | None: +def register_power_buttons(b: PowerButtonConfig) -> list[evdev.InputDevice]: + out = [] for device in [evdev.InputDevice(path) for path in evdev.list_devices()]: - if str(device.phys).startswith(b.phys): + capture = False + for phys in b.phys: + if str(device.phys).startswith(phys): + capture = True + if capture: device.grab() logger.info(f"Captured power button '{device.name}': '{device.phys}'") - return device + out.append(device) + return out + + +def pick_closest_button(btns: list[evdev.InputDevice], cfg: PowerButtonConfig): + for phys in cfg.phys: + for b in btns: + if str(b.phys).startswith(phys): + return b + + if btns: + return btns[0] return None def register_hold_button(b: PowerButtonConfig) -> evdev.InputDevice | None: - if not b.hold_phys or not b.hold_events or b.hold_grab is None: + if not b.hold_phys or not b.hold_code: logger.error( f"Device configuration tuple does not contain required parameters:\n{b}" ) return None for device in [evdev.InputDevice(path) for path in evdev.list_devices()]: - if str(device.phys).startswith(b.hold_phys): - if b.hold_grab: - device.grab() - logger.info(f"Captured hold keyboard '{device.name}': '{device.phys}'") - return device + for phys in b.hold_phys: + if str(device.phys).startswith(phys): + if b.hold_grab: + device.grab() + logger.info(f"Captured hold keyboard '{device.name}': '{device.phys}'") + return device return None -def get_config() -> PowerButtonConfig | None: - with open("/sys/devices/virtual/dmi/id/product_name") as f: - prod = f.read().strip() - - for d in SUPPORTED_DEVICES: - if d.prod_name == prod: - return d - - return None +_supports_sleep = None def run_steam_shortpress(perms: Context): - return run_steam_command("steam://shortpowerpress", perms) + global _supports_sleep + # FIXME: This should be patched in systemd instead + if _supports_sleep is None: + _supports_sleep = supports_sleep() + + if _supports_sleep: + return run_steam_command("steam://shortpowerpress", perms) + else: + emergency_hibernate(shutdown=False) + return True def run_steam_longpress(perms: Context): return run_steam_command("steam://longpowerpress", perms) -def power_button_run(cfg: PowerButtonConfig, ctx: Context, should_exit: Event): +def power_button_run(cfg: PowerButtonConfig, ctx: Context, should_exit: Event, emit): match cfg.type: + case "only_press": + logger.info( + f"Starting multi-device powerbutton handler for device '{cfg.device}'." + ) + power_button_multidev(cfg, ctx, should_exit, emit) case "hold_emitted": logger.info( f"Starting timer based powerbutton handler for device '{cfg.device}'." ) - power_button_timer(cfg, ctx, should_exit) + power_button_timer(cfg, ctx, should_exit, emit) case "hold_isa": logger.info( f"Starting isa keyboard powerbutton handler for device '{cfg.device}'." ) - power_button_isa(cfg, ctx, should_exit) + power_button_isa(cfg, ctx, should_exit, emit) case _: logger.error(f"Invalid type in config '{cfg.type}'. Exiting.") -def power_button_isa(cfg: PowerButtonConfig, perms: Context, should_exit: Event): - if not cfg.hold_events: - logger.error(f"Invalid hold events in config. Exiting.\n:{cfg.hold_events}") - return - +def power_button_isa(cfg: PowerButtonConfig, perms: Context, should_exit: Event, emit): press_dev = None + press_devs = [] hold_dev = None try: - hold_state = 0 while not should_exit.is_set(): # Initial check for steam - if not is_steam_gamescope_running(perms): + if not is_steam_gamepad_running(perms): # Close devices + if press_devs: + for d in press_devs: + d.close() + press_devs = [] if press_dev: press_dev.close() press_dev = None @@ -143,22 +127,28 @@ def power_button_isa(cfg: PowerButtonConfig, perms: Context, should_exit: Event) hold_dev.close() hold_dev = None logger.info(f"Waiting for steam to launch.") - while not is_steam_gamescope_running(perms): + while not is_steam_gamepad_running(perms): if should_exit.is_set(): return sleep(STEAM_WAIT_DELAY) if not press_dev or not hold_dev: logger.info(f"Steam is running, hooking power button.") - press_dev = register_power_button(cfg) + press_devs = register_power_buttons(cfg) + press_dev = press_devs[0] if press_devs else None hold_dev = register_hold_button(cfg) - if not press_dev or not hold_dev: + if not press_dev: logger.error(f"Power button interfaces not found, disabling plugin.") return # Add timeout to release the button if steam exits. - r = select.select([press_dev.fd, hold_dev.fd], [], [], STEAM_WAIT_DELAY)[0] - + r = select.select( + [press_dev.fd, hold_dev.fd] if hold_dev else [press_dev.fd], + [], + [], + STEAM_WAIT_DELAY, + )[0] + if not r: continue fd = r[0] # handle one button at a time @@ -170,22 +160,13 @@ def power_button_isa(cfg: PowerButtonConfig, perms: Context, should_exit: Event) if ev.type == B("EV_KEY") and ev.code == B("KEY_POWER") and ev.value: logger.info("Executing short press.") issue_systemctl = not run_steam_shortpress(perms) - elif fd == hold_dev.fd: + emit({"type": "special", "event": "pbtn_short"}) + elif hold_dev and fd == hold_dev.fd: ev = hold_dev.read_one() - chk = (ev.type, ev.code, ev.value) - - if hold_state >= len(cfg.hold_events): - hold_state = 0 - - if chk == cfg.hold_events[hold_state]: - hold_state += 1 - else: - hold_state = 0 - - if hold_state == len(cfg.hold_events): - hold_state = 0 + if ev.type == B("EV_KEY") and ev.code == cfg.hold_code and ev.value: logger.info("Executing long press.") issue_systemctl = not run_steam_longpress(perms) + emit({"type": "special", "event": "pbtn_long"}) if issue_systemctl: logger.error( @@ -198,24 +179,34 @@ def power_button_isa(cfg: PowerButtonConfig, perms: Context, should_exit: Event) logger.error(f"Received exception, exitting:\n{e}") -def power_button_timer(cfg: PowerButtonConfig, perms: Context, should_exit: Event): +def power_button_timer( + cfg: PowerButtonConfig, perms: Context, should_exit: Event, emit +): dev = None + devs = [] try: pressed_time = None while not should_exit.is_set(): # Initial check for steam - if not is_steam_gamescope_running(perms): + if not is_steam_gamepad_running(perms): # Close devices - if dev: - dev.close() - dev = None + if devs: + for d in devs: + d.close() + devs = [] + if dev: + dev.close() + dev = None logger.info(f"Waiting for steam to launch.") - while not is_steam_gamescope_running(perms): + while not is_steam_gamepad_running(perms): + if should_exit.is_set(): + return sleep(STEAM_WAIT_DELAY) if not dev: logger.info(f"Steam is running, hooking power button.") - dev = register_power_button(cfg) + devs = register_power_buttons(cfg) + dev = pick_closest_button(devs, cfg) if not dev: logger.error(f"Power button not found, disabling plugin.") return @@ -247,6 +238,7 @@ def power_button_timer(cfg: PowerButtonConfig, perms: Context, should_exit: Even # Button was pressed but we hit a timeout, that means # it is a long press press_type = "long_press" + pressed_time = None else: # Otherwise, no press press_type = "no_press" @@ -256,9 +248,11 @@ def power_button_timer(cfg: PowerButtonConfig, perms: Context, should_exit: Even case "long_press": logger.info("Executing long press.") issue_systemctl = not run_steam_longpress(perms) + emit({"type": "special", "event": "pbtn_long"}) case "short_press": logger.info("Executing short press.") issue_systemctl = not run_steam_shortpress(perms) + emit({"type": "special", "event": "pbtn_short"}) case "initial_press": logger.info("Power button pressed down.") case "release_without_press": @@ -276,3 +270,73 @@ def power_button_timer(cfg: PowerButtonConfig, perms: Context, should_exit: Even finally: if dev: dev.close() + if devs: + for d in devs: + d.close() + + +def power_button_multidev( + cfg: PowerButtonConfig, perms: Context, should_exit: Event, emit +): + devs = [] + fds = [] + last_pressed = None + try: + while not should_exit.is_set(): + # Initial check for steam + if not is_steam_gamepad_running(perms): + for d in devs: + d.close() + devs = [] + fds = [] + logger.info(f"Waiting for steam to launch.") + while not is_steam_gamepad_running(perms): + if should_exit.is_set(): + return + sleep(STEAM_WAIT_DELAY) + + if not devs: + logger.info(f"Steam is running, hooking power button.") + devs = register_power_buttons(cfg) + fds = {d.fd: d for d in devs} + if not devs: + logger.error(f"Power button(s) not found, disabling plugin.") + return + + # Add timeout to release the button if steam exits. + r = select.select(list(fds), [], [], STEAM_WAIT_DELAY)[0] + + # Handle press logic + issue_power = False + issue_systemctl = False + for fd in r: + # Handle button event + ev = fds[fd].read_one() + if ( + ev.type == B("EV_KEY") + and ev.code == B("KEY_POWER") + and ev.value == 1 + ): + curr_time = perf_counter() + if not last_pressed or curr_time - last_pressed > DEBOUNCE_DELAY: + last_pressed = curr_time + issue_power = True + + if issue_power: + logger.info("Executing short press.") + issue_systemctl = not run_steam_shortpress(perms) + emit({"type": "special", "event": "pbtn_short"}) + + if issue_systemctl: + logger.error( + "Power button action did not work. Calling `systemctl suspend`" + ) + os.system("systemctl suspend") + except KeyboardInterrupt: + pass + except Exception as e: + logger.error(f"Received exception, exitting:\n{e}") + finally: + if devs: + for d in devs: + d.close() diff --git a/src/hhd/plugins/powerbutton/const.py b/src/hhd/plugins/powerbutton/const.py index 4b448fe2..38f13483 100644 --- a/src/hhd/plugins/powerbutton/const.py +++ b/src/hhd/plugins/powerbutton/const.py @@ -3,13 +3,13 @@ class PowerButtonConfig(NamedTuple): device: str - type: Literal["hold_emitted", "hold_isa"] prod_name: str - phys: str - hold_phys: str | None = None - hold_grab: bool | None = None - # ev.type, ev.code, ev.value pairs - hold_events: Sequence[tuple[int, int, int]] | None = None + type: Literal["hold_emitted", "hold_isa", "only_press"] = "hold_isa" + phys: Sequence[str] = ["LNXPWRBN", "PNP0C0C"] + hold_phys: Sequence[str] = ["phys-hhd-powerbutton", "isa0060"] + hold_grab: bool = False + hold_code: int = 125 # left meta + unsupported: bool = False # POWER_BUTTON_NAMES = ["Power Button"] @@ -18,17 +18,91 @@ class PowerButtonConfig(NamedTuple): PBC = PowerButtonConfig SUPPORTED_DEVICES: Sequence[PowerButtonConfig] = [ + PBC("Legion Go", "83E1"), + PBC("Legion Go S Z2 Go", "83L3"), + PBC("Legion Go S Z1E", "83N6"), + PBC("Legion Go S", "83Q2"), + PBC("Legion Go S", "83Q3"), + PBC("ROG Ally", "ROG Ally RC71L_Action"), + PBC("ROG Ally", "ROG Ally RC71L_RC71L"), + PBC("ROG Ally", "ROG Ally RC71L"), + PBC("ROG Ally X", "ROG Ally X RC72LA"), + PBC("GPT Win 4", "G1618-04"), + PBC("GPD Win Mini", "G1617-01"), + PBC("GPD Win Mini", "G1617-02"), + PBC("GPD Win Max 2", "G1619-04"), + PBC("GPD Win Max 2", "G1619-05"), + PBC("OrangePi G1621-02/G1621-02", "G1621-02"), + PBC("OrangePi NEO-01/NEO-01", "NEO-01"), + # breaks volume buttons, use the valve original script and hope steam inhibits systemd + # PBC( + # "Steam Deck LCD", + # "Jupiter", + # type="hold_emitted", + # phys=["isa0060", "PNP0C0C", "LNXPWRBN"], + # ), + # PBC( + # "Steam Deck OLED", + # "Galileo", + # type="hold_emitted", + # phys=["isa0060", "PNP0C0C", "LNXPWRBN"], + # ), PBC( - "Legion Go", - "hold_isa", - "83E1", - "PNP0C0C", - "isa0060", - False, - [(4, 4, 219), (1, 125, 1), (0, 0, 0)], - ) + "AOKZOE A1", + "AOKZOE A1 AR07", + type="only_press", + phys=["LNXPWRBN", "PNP0C0C"], + ), + PBC( + "AOKZOE A1 Pro", + "AOKZOE A1 Pro", + type="only_press", + phys=["LNXPWRBN", "PNP0C0C"], + ), + PBC( + "ONEXPLAYER Mini Pro", + "ONEXPLAYER Mini Pro", + type="only_press", + phys=["LNXPWRBN", "PNP0C0C"], + ), + PBC( + "TECNO (Displayless)", + "Pocket Go", + type="only_press", + ), + PBC( + "MSI Claw 8", + "Claw 8 AI+ A2VM", + type="only_press", + phys=["LNXPWRBN"], + ), ] + +def get_config() -> PowerButtonConfig: + with open("/sys/devices/virtual/dmi/id/product_name") as f: + prod = f.read().strip() + + try: + with open("/sys/devices/virtual/dmi/id/sys_vendor") as f: + sys = f.read().strip() + except Exception: + sys = None + + for d in SUPPORTED_DEVICES: + if d.prod_name in prod: + return d + + if "ONEXPLAYER" in prod or "AOKZOE" in prod: + return PBC(prod, prod, type="only_press") + + if sys == "AYA" or sys == "AYANEO" or sys == "AYN": + # TODO: Fix isa handling to only work when only shift is active + return PBC(prod, prod, type="only_press") + + return PBC("uknown", "NA", "only_press", unsupported=True) + + # Legion go # At device with phys=isa0060/serio0/input0 # diff --git a/src/hhd/plugins/powerbutton/settings.yml b/src/hhd/plugins/powerbutton/settings.yml new file mode 100644 index 00000000..94d519b4 --- /dev/null +++ b/src/hhd/plugins/powerbutton/settings.yml @@ -0,0 +1,9 @@ +settings: + type: container + + children: + powerbuttond: + type: bool + title: Steam Powerbutton Handler + hint: "Enables the Steam Powerbutton handler (responsible for the wink and powerbutton menu)." + default: True \ No newline at end of file diff --git a/src/hhd/plugins/rgb/__init__.py b/src/hhd/plugins/rgb/__init__.py new file mode 100644 index 00000000..c6a3e6c1 --- /dev/null +++ b/src/hhd/plugins/rgb/__init__.py @@ -0,0 +1,413 @@ +import logging +import time +from typing import Literal, Sequence, cast + +from hhd.controller import DEBUG_MODE, Event, RgbMode +from hhd.plugins import Config, Context, HHDPlugin, load_relative_yaml +from hhd.utils import get_distro_color, hsb_to_rgb + +logger = logging.getLogger(__name__) + +RGB_SET_TIMES = 2 +RGB_SET_INTERVAL = 5 +RGB_MIN_INTERVAL = 0.1 +RGB_QUEUE_RGB = 1.5 + + +class RgbPlugin(HHDPlugin): + def __init__(self) -> None: + self.name = f"controller_rgb" + self.priority = 15 + self.log = "LEDS" + + self.modes = None + self.controller = False + self.loaded = False + self.enabled = False + self.last_set = 0 + self.queue_leds = None + + self.init_count = 0 + self.init_last = 0 + self.init = True + self.uniq = None + self.restore = None + self.last_ev = None + + self.prev = None + + def open( + self, + emit, + context: Context, + ): + self.started = False + self.emit = emit + + def notify(self, events): + for ev in events: + # Certain ayaneo devices reset LEDs when being + # plugged in + if ev["type"] == "acpi" and ev["event"] in ("ac", "dc"): + self.init = True + self.init_count = RGB_SET_TIMES - 1 + elif ev["type"] == "special": + match ev["event"]: + case "tdp_cycle_quiet": + color = (0, 0, 255) + case "tdp_cycle_balanced": + color = (255, 255, 255) + case "tdp_cycle_performance": + color = (255, 0, 0) + case "tdp_cycle_custom": + color = (157, 0, 255) + case _: + color = None + + if color: + red, green, blue = color + curr = time.time() + evs: Sequence[tuple[Event, float]] = [] + # Set color based on mode on low brightness + if not self.controller: + evs.append( + ( + { + "type": "led", + "initialize": True, # Always initialize, saves problems on the ally + "code": "main", + "mode": "solid", + "direction": "left", + "brightness": 0.33, + "brightnessd": "low", + "speed": 0, + "speedd": "low", + "red": red, + "green": green, + "blue": blue, + "red2": 0, + "green2": 0, + "blue2": 0, + "oxp": None, + }, + curr, + ), + ) + # Add short vibration + evs.append( + ( + { + "type": "rumble", + "code": "main", + "strong_magnitude": 0.2, + "weak_magnitude": 0.2, + }, + curr, + ), + ) + evs.append( + ( + { + "type": "rumble", + "code": "main", + "strong_magnitude": 0, + "weak_magnitude": 0, + }, + curr + 0.1, + ), + ) + # Restore old color + if not self.controller and self.last_ev: + evs.append((self.last_ev, curr + 3)) + + self.emit.inject_timed(evs) + + def settings(self): + if not self.modes: + self.loaded = False + return {} + self.loaded = True + + # If RGB support is disabled + # return enable option only + base = load_relative_yaml("settings.yml") + if not self.enabled: + del base["rgb"] + return base + + if self.controller: + # Remove RGB settings because the controller has control + del base["rgb"]["handheld"]["children"]["mode"] + else: + # Remove disclaimer + del base["rgb"]["handheld"]["children"]["controller"] + modes = load_relative_yaml("modes.yml") + capabilities = load_relative_yaml("capabilities.yml") + + # Set a sane default color + dc = get_distro_color() + + supported = {} + for mode, caps in self.modes.items(): + if mode in modes: + m = modes[mode] + m["children"] = {} + for cap in caps: + m["children"].update( + {k: dict(v) for k, v in capabilities[cap].items()} + ) + if cap == "color": + m["children"]["hue"]["default"] = dc + for c in m["children"].values(): + c["tags"] = sorted(set(c.get("tags", []) + m.get("tags", []))) + supported[mode] = m + + # Add supported modes + base["rgb"]["handheld"]["children"]["mode"]["modes"] = supported + + # Set a sane default mode + for default in ("solid", "pulse", "disabled"): + if default in supported: + base["rgb"]["handheld"]["children"]["mode"]["default"] = default + break + else: + # fallback to any supported mode to have persistence in the mode + base["rgb"]["handheld"]["children"]["mode"]["default"] = next( + iter(supported) + ) + return base + + def update(self, conf: Config): + cap = self.emit.get_capabilities() + + if DEBUG_MODE: + cap = { + "_dbg": { + "rgb": { + "controller": False, + "modes": { + "disabled": [], + "solid": ["color"], + "pulse": ["color", "speed", "speedd"], + "duality": ["dual", "speedd"], + "rainbow": ["brightness", "speed", "speedd"], + "spiral": ["brightness", "speed", "speedd", "direction"], + }, + } + } + } + + if not cap: + if self.modes: + self.modes = None + self.emit({"type": "settings"}) + return + + # Check controller id and force setting the leds if it changed. + # This will reset the led color after suspend or after exitting + # dualsense emulation. + uniq = next(iter(cap)) + ccap = cap[uniq] + if uniq != self.uniq: + self.prev = None + self.uniq = uniq + + rgb = ccap["rgb"] + refresh_settings = False + if rgb: + # Refresh on initial load + if not self.modes: + refresh_settings = True + + # Refresh if controller takes control of the LEDs + new_controller = rgb["controller"] + if self.controller != new_controller: + refresh_settings = True + + self.controller = new_controller + self.modes = rgb["modes"] + + if self.loaded: + new_enabled = conf["hhd"]["settings"]["rgb"].to(bool) + if new_enabled != self.enabled: + refresh_settings = True + self.enabled = new_enabled + + if refresh_settings: + self.init_count = 0 + self.init_last = 0 + self.emit({"type": "settings"}) + return + + # All checks were ran and settings were updated + # if the controller has control of the LEDs + # or they are not enabled, exit. + if not self.enabled or self.controller: + return + + curr = time.perf_counter() + init = False + + rgb_conf = conf["rgb"]["handheld"]["mode"] + if self.prev and self.prev != rgb_conf: + self.init = False + elif self.init: + # Initialize by setting the LEDs X times + # to avoid early boot having it not set + if self.init_count >= RGB_SET_TIMES: + self.init = False + return + + # Wait inbetween setting the LEDS + curr = time.perf_counter() + if curr - self.init_last < RGB_SET_INTERVAL: + return + + self.init_count += 1 + self.init_last = curr + logger.info( + f"Initializing RGB (repeat {self.init_count}/{RGB_SET_TIMES}, interval: {RGB_SET_INTERVAL})" + ) + init = True + elif self.queue_leds and self.queue_leds < curr: + # Set the LEDs after two seconds with init + logger.info("Running full rgb command.") + init = True + elif self.prev and self.prev == rgb_conf: + return + + self.prev = rgb_conf.copy() + + # Get event info + mode = rgb_conf["mode"].to(str) + if mode in rgb_conf: + info = cast(dict, rgb_conf[mode].conf) + else: + info = {} + ev: Event | None = None + if not self.modes or mode not in self.modes: + return + + brightness = 1 + brightnessd = "high" + speedd = "high" + direction = "left" + speed = 1 + red = 0 + green = 0 + blue = 0 + red2 = 0 + green2 = 0 + blue2 = 0 + color2_set = False + always_init = True + oxp = None + + log = f"Setting RGB to mode '{mode}'" + for cap in self.modes[cast(RgbMode, mode)]: + match cap: + case "color": + red, green, blue = hsb_to_rgb( + info["hue"], + info["saturation"], + info["brightness"], + ) + # Cannot init leds with color slider because it is too fast + always_init = False + log += f" with color: {red:3d}, {green:3d}, {blue:3d}" + case "dual": + red, green, blue = hsb_to_rgb( + info["hue"], + info["saturation"], + info["brightness"], + ) + color2_set = True + red2, green2, blue2 = hsb_to_rgb( + info["hue2"], + info["saturation"], + info["brightness"], + ) + # Cannot init leds with color slider because it is too fast + always_init = False + log += f" with colors: {red:3d}, {green:3d}, {blue:3d} and {red2:3d}, {green2:3d}, {blue2:3d}" + case "brightness": + log += f", brightness: {info['brightness']}" + brightness = info["brightness"] / 100 + case "speed": + log += f", speed: {info['speed']}" + speed = info["speed"] / 100 + case "brightnessd": + log += f", brightness: {info['brightnessd']}" + brightnessd = cast( + Literal["low", "medium", "high"], info["brightnessd"] + ) + case "speedd": + log += f", speed: {info['speedd']}" + speedd = cast(Literal["low", "medium", "high"], info["speedd"]) + case "direction": + log += f", direction: {info['direction']}" + direction = cast(Literal["left", "right"], info["direction"]) + case "oxp": + brightnessd = cast( + Literal["low", "medium", "high"], info["brightnessd"] + ) + log += f", mode: '{info['mode']}', brightness: '{brightnessd}'" + oxp = info["mode"] + case "oxp-secondary": + log += f", center hue: {info['hue']}, enabled: {info['secondary']}" + if info["secondary"]: + red2, green2, blue2 = hsb_to_rgb( + info["hue"], + 100, + 100, + ) + else: + red2 = green2 = blue2 = 0 + color2_set = True + + log += "." + + if not color2_set: + red2 = red + green2 = green + blue2 = blue + + ev = { + "type": "led", + "initialize": init + or always_init, # Always initialize, saves problems on the ally + "code": "main", + "mode": cast(RgbMode, mode), + "direction": direction, + "brightness": brightness, + "brightnessd": brightnessd, + "speed": speed, + "speedd": speedd, + "red": red, + "green": green, + "blue": blue, + "red2": red2, + "green2": green2, + "blue2": blue2, + "oxp": oxp, + } + if not always_init: + self.queue_leds = curr + RGB_QUEUE_RGB + + # Avoid setting the LEDs too fast. + if curr - self.last_set < RGB_MIN_INTERVAL and not init: + return + + logger.info(log) + self.last_set = curr + self.last_ev = ev + self.emit.inject(ev) + if init: + self.queue_leds = None + + +def autodetect(existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: + if len(existing): + return existing + + return [RgbPlugin()] diff --git a/src/hhd/plugins/rgb/capabilities.yml b/src/hhd/plugins/rgb/capabilities.yml new file mode 100644 index 00000000..fe684dfd --- /dev/null +++ b/src/hhd/plugins/rgb/capabilities.yml @@ -0,0 +1,128 @@ +color: + hue: &hue + type: int + tags: [hue, rgb] + title: Hue + min: 0 + max: 360 + step: 5 + unit: "°" + default: 30 + saturation: &saturation + type: int + tags: [saturation, rgb] + title: Saturation + min: 0 + max: 100 + step: 10 + unit: "%" + default: 100 + brightness: &brightness + type: int + tags: [brightness, rgb] + title: Brightness + min: 0 + max: 100 + step: 10 + unit: "%" + default: 50 + +brightnessd: + brightnessd: &brightnessd + type: multiple + title: Brightness + tags: [non-essential, ordinal] + options: + # off: "Off" + low: "Low" + medium: "Medium" + high: "High" + default: medium + +oxp: + mode: + type: multiple + title: Stick Style + tags: [non-essential] + default: classic + options: + monster_woke: "Monster Woke" + flowing: "Flowing Light" + sunset: "Sunset Afterglow" + neon: "Colorful Neon" + dreamy: "Dreamy" + cyberpunk: "Cyberpunk" + colorful: "Colorful" + aurora: "Aurora" + sun: "Warm Sun" + classic: "OXP Classic" + + brightnessd: *brightnessd + +oxp-secondary: + hue: + <<: *hue + title: Secondary + + secondary: + type: bool + title: Enable Secondary + tags: [non-essential] + default: true + +dual: + hue: + <<: *hue + default: 60 + hue2: + <<: *hue + title: Secondary + tags: [hue2, rgb] + saturation: + <<: *saturation + brightness: + <<: *brightness + +speed: + speed: + type: int + tags: [speed] + title: Speed + min: 0 + max: 100 + unit: "%" + step: 10 + default: 50 + +speedd: + speedd: + type: multiple + title: Speed + tags: [non-essential, ordinal] + options: + # off: "Off" + low: "Low" + medium: "Medium" + high: "High" + default: medium + +brightness: + brightness: + type: int + tags: [brightness] + title: Brightness + min: 0 + max: 100 + step: 10 + unit: "%" + default: 50 + +direction: + direction: + type: multiple + title: Direction + tags: [non-essential, ordinal] + options: + left: "Left" + right: "Right" + default: left diff --git a/src/hhd/plugins/rgb/modes.yml b/src/hhd/plugins/rgb/modes.yml new file mode 100644 index 00000000..884e1b6a --- /dev/null +++ b/src/hhd/plugins/rgb/modes.yml @@ -0,0 +1,46 @@ +disabled: + type: container + title: "Off" + tags: [ non-essential, rgb, disabled ] + hint: >- + Turns the LEDs off. + +solid: + type: container + title: Solid + tags: [ non-essential, rgb, solid ] + hint: >- + Maintains the LEDs at a solid color. + +pulse: + type: container + title: Pulse + tags: [ non-essential, rgb, pulse ] + hint: >- + Slowly pulses the LEDs as a prespecified color. + +rainbow: + type: container + title: Rainbow + tags: [ non-essential, rainbow ] + hint: >- + Cycles through the different colors. + +spiral: + type: container + title: Spiral + tags: [ non-essential, spiral ] + hint: >- + Creates an RGB spiral around the stick. + +duality: + type: container + title: Duality + tags: [ non-essential, rgb, duality ] + hint: >- + Alternates between two colors. + +oxp: + type: container + title: OneXPlayer + tags: [ non-essential, rainbow ] \ No newline at end of file diff --git a/src/hhd/plugins/rgb/settings.yml b/src/hhd/plugins/rgb/settings.yml new file mode 100644 index 00000000..13fe649c --- /dev/null +++ b/src/hhd/plugins/rgb/settings.yml @@ -0,0 +1,27 @@ +rgb: + handheld: + type: container + title: RGB Settings + tags: [ hide-title ] + children: + mode: + type: mode + title: RGB Mode + modes: + controller: + title: Controller RGB + type: display + tags: [ non-essential, controller-disclaimer, info ] + default: >- + Dualsense controls the LEDs. + Switch to Xbox or disable Dualsense LEDs. + +hhd: + settings: + type: container + + children: + rgb: + type: bool + title: "Enable RGB support." + default: True \ No newline at end of file diff --git a/src/hhd/plugins/settings.py b/src/hhd/plugins/settings.py index d73efbea..d960cb3f 100644 --- a/src/hhd/plugins/settings.py +++ b/src/hhd/plugins/settings.py @@ -8,7 +8,9 @@ Sequence, TypedDict, cast, + Protocol, ) +import time from copy import copy from .conf import Config @@ -22,21 +24,19 @@ class ButtonSetting(TypedDict): """Just a button, emits an event. Used for resets, etc.""" - type: Literal["event"] - family: Sequence[str] + type: Literal["action"] + tags: Sequence[str] title: str - hint: str - - default: bool | None + hint: str | None class BooleanSetting(TypedDict): """Checkbox container.""" type: Literal["bool"] - family: Sequence[str] + tags: Sequence[str] title: str - hint: str + hint: str | None default: bool | None @@ -45,9 +45,9 @@ class MultipleSetting(TypedDict): """Select one container.""" type: Literal["multiple"] - family: Sequence[str] + tags: Sequence[str] title: str - hint: str + hint: str | None options: Mapping[str, str] default: str | None @@ -57,9 +57,9 @@ class DiscreteSetting(TypedDict): """Ordered and fixed numerical options (etc. tdp).""" type: Literal["discrete"] - family: Sequence[str] + tags: Sequence[str] title: str - hint: str + hint: str | None options: Sequence[int | float] default: int | float | None @@ -69,37 +69,87 @@ class NumericalSetting(TypedDict): """Floating numerical option.""" type: Literal["float"] - family: Sequence[str] + tags: Sequence[str] title: str - hint: str + hint: str | None + unit: str | None min: float | None max: float | None + smin: int | None + smax: int | None + step: int | None + default: float | None class IntegerSetting(TypedDict): """Floating numerical option.""" - type: Literal["integer"] - family: Sequence[str] + type: Literal["int"] + tags: Sequence[str] title: str - hint: str + hint: str | None + unit: str | None min: int | None max: int | None + smin: int | None + smax: int | None + step: int | None + default: int | None +class Color(TypedDict): + red: int + green: int + blue: int + + class ColorSetting(TypedDict): """RGB color setting.""" type: Literal["color"] - family: Sequence[str] + tags: Sequence[str] + title: str + hint: str | None + + default: Color | None + + +class DisplaySetting(TypedDict): + """Shows a text value in the UI.""" + + type: Literal["display"] + tags: Sequence[str] title: str - hint: str + hint: str | None + + config: Any | None + default: Any | None + + +class CustomSetting(TypedDict): + """Custom plugin setting. + + Can be used for any required custom setting that is not covered by the + default ones (e.g., fan curves, deadzones). + + The setting type is defined by tags. + Then, the config variable can be used to supply option specific information + (e.g., for fan curves how many temperature points are available). + + To validate this setting, each loaded plugin's validate function is called, + with the tags, config data, and the supplied value.""" + + type: Literal["custom"] + tags: Sequence[str] + title: str | None + hint: str | None - default: Mapping | None + config: Any | None + default: Any | None Setting = ( @@ -110,6 +160,8 @@ class ColorSetting(TypedDict): | NumericalSetting | IntegerSetting | ColorSetting + | CustomSetting + | DisplaySetting ) # @@ -121,9 +173,9 @@ class Container(TypedDict): """Holds a variety of settings.""" type: Literal["container"] - family: Sequence[str] + tags: Sequence[str] title: str - hint: str + hint: str | None children: MutableMapping[str, "Setting | Container | Mode"] @@ -132,9 +184,9 @@ class Mode(TypedDict): """Holds a number of containers, only one of whih can be active at a time.""" type: Literal["mode"] - family: Sequence[str] + tags: Sequence[str] title: str - hint: str + hint: str | None modes: MutableMapping[str, Container] default: str | None @@ -146,6 +198,7 @@ class Mode(TypedDict): + "#\n" + "# This file contains plugin software-only configuration that will be retained\n" + "# across reboots. You may edit this file in lueu of using a frontend.\n" + + "# This header is on the bottom to make editing easier with e.g., nano.\n" + "#\n" + "# Parameters that are stored in hardware (TDP, RGB colors, etc) and\n" + "# risky parameters that might cause instability and should be reset\n" @@ -169,6 +222,7 @@ class Mode(TypedDict): + "#\n" + "# This file contains the configuration options that will be set when\n" + "# applying the profile which shares this file name.\n" + + "# This header is on the bottom to make editing easier with e.g., nano.\n" + "#\n" + "# Settings are applied once, when applying the profile, and only the ones\n" + "# that are stated change. Therefore, they may drift as the system state changes\n" @@ -191,7 +245,7 @@ class Mode(TypedDict): ) -Section = MutableMapping[str, Container] +Section = Mapping[str, Container] HHDSettings = Mapping[str, Section] @@ -219,84 +273,110 @@ def parse_defaults(sets: HHDSettings): return out -def fill_in_defaults(s: Setting | Container | Mode): - s = copy(s) - s["family"] = s.get("family", []) - s["title"] = s.get("title", "") - s["hint"] = s.get("hint", "") - if s["type"] != "container": - s["default"] = s.get("default", None) - - match s["type"]: - case "container": - s["children"] = s.get("children", []) - case "mode": - s["modes"] = s.get("modes", {}) - case "multiple": - s["options"] = s.get("options", {}) - case "discrete": - s["options"] = s.get("options", []) - case "integer" | "float": - s["min"] = s.get("min", None) - s["max"] = s.get("max", None) - return s +def pick_tag(tag, default, a, b): + if not b: + return a.get(tag, default) + return b.get(tag, a.get(tag, default)) + + +DEFAULT_TAGS = { + "type": None, + "title": "", + "hint": "", + "unit": "", + "tags": [], + "default": None, +} + +TYPE_TAGS = { + "multiple": {"options": {}}, + "discrete": {"options": []}, + "int": { + "min": None, + "max": None, + "step": None, + "unit": None, + "smin": None, + "smax": None, + }, + "float": { + "min": None, + "max": None, + "step": None, + "unit": None, + "smin": None, + "smax": None, + }, + "custom": {"config": None}, +} def merge_reduce( - a: Setting | Container | Mode, b: Setting | Container | Mode -) -> Setting | Container | Mode: - if a["type"] != b["type"]: - return fill_in_defaults(b) - - match a["type"]: - case "container": - out = cast(Container, dict(b)) - new_children = dict(a["children"]) - for k, v in b.items(): - if k in out: - out[k] = merge_reduce(out[k], b[k]) - else: - out[k] = v - out["children"] = new_children - return fill_in_defaults(out) - case "mode": - out = cast(Mode, dict(b)) - new_children = dict(a["modes"]) - for k, v in b.items(): - if k in out: - out[k] = merge_reduce(out[k], b[k]) - else: - out[k] = v - out["modes"] = new_children - return fill_in_defaults(out) - case _: - return fill_in_defaults(b) + a: Setting | Container | Mode, b: Setting | Container | Mode | None = None +): + s = {} + for tag, default in DEFAULT_TAGS.items(): + s[tag] = pick_tag(tag, default, a, b) + + for tag, default in TYPE_TAGS.get(a["type"], {}).items(): + s[tag] = pick_tag(tag, default, a, b) + + if b and b.get("type", None) == a.get("type", None): + match s["type"]: + case "container": + new_children = dict(a.get("children", {})) + for k, v in b.get("children", {}).items(): + if k in new_children: + new_children[k] = merge_reduce(new_children[k], v) # type: ignore + else: + new_children[k] = merge_reduce(v) # type: ignore + s["children"] = new_children + case "mode": + new_children = dict(a.get("modes", {})) + for k, v in b.get("modes", {}).items(): + if k in new_children: + new_children[k] = merge_reduce(new_children[k], v) # type: ignore + else: + new_children[k] = merge_reduce(v) # type: ignore + s["modes"] = new_children + else: + if a.get("type", None) == "container": + s["children"] = { + k: merge_reduce(v) for k, v in (a.get("children", None) or {}).items() + } + + if a.get("type", None) == "mode": + s["modes"] = { + k: merge_reduce(v) for k, v in (a.get("modes", None) or {}).items() + } + return s def merge_reduce_sec(a: Section, b: Section): - out = dict(a) + out = {k: cast(Container, merge_reduce(v)) for k, v in a.items()} for k, v in b.items(): if k in out: - out[k] = cast(Container, merge_reduce(out[k], b[k])) + out[k] = cast(Container, merge_reduce(out[k], v)) else: - out[k] = v + out[k] = cast(Container, merge_reduce(v)) return out def merge_reduce_secs(a: HHDSettings, b: HHDSettings): - out = dict(a) + out = {k: merge_reduce_sec({}, v) for k, v in a.items()} for k, v in b.items(): - if k in out: - out[k] = merge_reduce_sec(out[k], b[k]) - else: - out[k] = v + out[k] = merge_reduce_sec(out.get(k, {}), v) return out def merge_settings(sets: Sequence[HHDSettings]): - return reduce(merge_reduce_secs, sets) + if not sets: + return {} + if len(sets) > 1: + return reduce(merge_reduce_secs, sets) + return merge_reduce_secs({}, sets[0]) def generate_desc(s: Setting | Container | Mode): @@ -322,6 +402,8 @@ def generate_desc(s: Setting | Container | Mode): desc += f"- boolean: [False, True]\n" case "multiple" | "discrete": desc += f"- options: [{', '.join(map(str, s['options']))}]\n" + case "action": + desc += f"- action: Set to True to run.\n" if (d := s.get("default", None)) is not None: desc += f"- default: {d}\n" @@ -374,7 +456,7 @@ def dump_comment(set: HHDSettings, header: str = STATE_HEADER): next_ofs = max(min(next_ofs, ofs), 0) out += f"\n# {'│' * next_ofs}{'ā””' * (ofs - next_ofs)} {lines[-1]}" out += f"\n# {'│' * next_ofs}" - out += "\n\n" + # out += "\n\n" return out @@ -400,7 +482,7 @@ def dump_setting( m = conf.get([*prev, child_name], None) # Skip writing default values default = child.get("default", None) - if default is None: + if default is None and unmark != "unset": out[child_name] = None elif m is None: out[child_name] = unmark @@ -412,7 +494,7 @@ def dump_setting( m = conf.get([*prev, "mode"], None) # Skip writing default values default = set.get("default", None) - if default is None: + if default is None and unmark != "unset": out["mode"] = None elif m is None: out["mode"] = unmark @@ -464,31 +546,74 @@ def dump_settings( return merge_dicts({"version": None, **cast(Mapping, conf.conf)}, out) -def save_state_yaml(fn: str, set: HHDSettings, conf: Config): +def save_state_yaml(fn: str, set: HHDSettings, conf: Config, shash=None): import yaml - if conf.get("version", None) == get_settings_hash(set) and not conf.updated: + if shash is None: + shash = get_settings_hash(set) + if conf.get("version", None) == shash and not conf.updated: return False + conf["version"] = shash with open(fn, "w") as f: + yaml.safe_dump(dump_settings(set, conf, "default"), f, sort_keys=False) + f.write("\n") f.write(dump_comment(set, STATE_HEADER)) - yaml.safe_dump( - dump_settings(set, conf, "default"), f, width=85, sort_keys=False + + return True + + +def save_blacklist_yaml(fn: str, avail: Sequence[str], blacklist: Sequence[str]): + import yaml + + with open(fn, "w") as f: + f.write( + ( + "" + + "# \n" + + "# Plugin blacklist\n" + + "# The plugin providers under blacklist will not run.\n" + + "# \n" + + "# Warning: this file is read only on startup.\n" + + "# `sudo systemctl restart hhd@$(whoami)`\n" + + "# \n" + + "# Available providers:\n" + + f"# [{', '.join(avail)}]\n\n" + ) ) + yaml.safe_dump({"blacklist": blacklist}, f, width=85, sort_keys=False) + return True -def save_profile_yaml(fn: str, set: HHDSettings, conf: Config | None = None): +def load_blacklist_yaml(fn: str): import yaml + try: + with open(fn, "r") as f: + return yaml.safe_load(f)["blacklist"] + except Exception as e: + logger.warning(f"Plugin blacklist not found, using default (empty).") + return ["myplugin1"] + + +def save_profile_yaml( + fn: str, set: HHDSettings, conf: Config | None = None, shash=None +): + import yaml + + if shash is None: + shash = get_settings_hash(set) if conf is None: conf = Config({}) - elif conf.get("version", None) == get_settings_hash(set) and not conf.updated: + elif conf.get("version", None) == shash and not conf.updated: return False + conf["version"] = shash with open(fn, "w") as f: - f.write(dump_comment(set, PROFILE_HEADER)) yaml.safe_dump(dump_settings(set, conf, "unset"), f, width=85, sort_keys=False) + f.write("\n") + f.write(dump_comment(set, PROFILE_HEADER)) return True @@ -551,9 +676,9 @@ def load_profile_yaml(fn: str): def get_settings_hash(set: HHDSettings): - import hashlib + import hashlib, json - return hashlib.md5(dump_comment(set).encode()).hexdigest()[:8] + return hashlib.md5(json.dumps(set).encode()).hexdigest()[:8] def unravel(d: Setting | Container | Mode, prev: Sequence[str], out: MutableMapping): @@ -580,15 +705,92 @@ def unravel_options(settings: HHDSettings): return options -def validate_config(conf: Config, settings: HHDSettings, use_defaults: bool = True): +class Validator(Protocol): + def __call__(self, tags: Sequence[str], config: Any, value: Any) -> bool: + return False + + +def standard_validator(tags, config, value): + return True + + if "progress" in tags: + if not value: + return False + + # Progress contains a dict with + # three values: value, max, unit, and text + if not isinstance(value, Mapping): + return False + + # Value is optional and should be a number + # If it is none, the progress bar should be pulsing + if ( + "value" in value + and value is not None + and not isinstance(value["value"], (int, float)) + ): + return False + + # Max is required and should be a number (if value is present) + if "max" not in value and "value" in value and value["value"] is not None: + return False + + if ( + "max" in value + and value["max"] is not None + and not isinstance(value["max"], (int, float)) + ): + return False + + # Unit is optional, should be text + if ( + "unit" in value + and value["unit"] is not None + and not isinstance(value["unit"], str) + ): + return False + + # Text is optional, should be text + if ( + "text" in value + and value["text"] is not None + and not isinstance(value["text"], str) + ): + return False + + return True + + if "dropdown" in tags: + if not value: + return False + + if not isinstance(value, dict): + return False + + if "options" not in value: + return False + + if "value" in value and value["value"] not in value["options"]: + return False + + return True + + return False + +def validate_config( + conf: Config, settings: HHDSettings, validator: Validator, use_defaults: bool = True +): options = unravel_options(settings) for k, d in options.items(): v = conf.get(k, None) - default = d["default"] + if d["type"] == "action": + default = False + else: + default = d["default"] if v is None: if use_defaults and default is not None: - conf[k] = v + conf[k] = default continue match d["type"]: @@ -598,7 +800,7 @@ def validate_config(conf: Config, settings: HHDSettings, use_defaults: bool = Tr conf[k] = default else: del conf[k] - case "bool" | "event": + case "bool" | "action": if v not in (False, True): conf[k] = bool(v) case "multiple" | "discrete": @@ -607,7 +809,7 @@ def validate_config(conf: Config, settings: HHDSettings, use_defaults: bool = Tr conf[k] = default else: del conf[k] - case "integer": + case "int" | "integer": if not isinstance(v, int): conf[k] = int(v) if v < d["min"]: @@ -622,5 +824,28 @@ def validate_config(conf: Config, settings: HHDSettings, use_defaults: bool = Tr if v > d["max"]: conf[k] = d["max"] case "color": - # TODO - pass + invalid = False + + if not isinstance(v, Mapping): + invalid = True + else: + for c in ("red", "green", "blue"): + if c not in v: + invalid = True + elif not (0 <= v[c] < 256): + invalid = True + + if invalid: + if use_defaults: + conf[k] = default + else: + del conf[k] + case "custom": + if not ( + validator(d["tags"], d["config"], v) + or standard_validator(d["tags"], d["config"], v) + ): + if use_defaults: + conf[k] = default + else: + del conf[k] diff --git a/src/hhd/plugins/touchpad.yml b/src/hhd/plugins/touchpad.yml new file mode 100644 index 00000000..257960bf --- /dev/null +++ b/src/hhd/plugins/touchpad.yml @@ -0,0 +1,140 @@ +type: mode +tags: [touchpad] +title: Touchpad Emulation +hint: >- + Use an emulated touchpad. + Part of the controller if it is supported (e.g., Dualsense) or a virtual + one if not. + +default: emulation +modes: + # + # No emulation + # + disabled: + type: container + title: Disabled + hint: >- + Does not modify the touchpad. Short + holding presses will not work + within gamescope. + # + # Virtual emulation + # + emulation: + type: container + title: Virtual + tags: [non-essential] + hint: >- + Adds an emulated touchpad. This touchpad is meant for use in gamescope + and has left, right click support by default. + However, it causes issues in desktop mode, and it doesnt allow dragging + files. Therefore, it will autodisable in desktop. + + children: + desktop_disable: + type: bool + title: Disable on Desktop + hint: >- + Touchpad emulation will automatically be disabled when not in gamemode. + Specifically, steam will be periodically be checked to be running in + gamepad mode and if not, touchpad emulation will be disabled. + default: True + + short: + type: multiple + title: Short Action + tags: [advanced] + hint: >- + Maps short touches (less than 0.2s) to a virtual touchpad button. + options: + disabled: Disabled + left_click: Left Click + right_click: Right Click + default: "left_click" + hold: + type: multiple + title: Hold Action + tags: [advanced] + hint: >- + Maps long touches (more than 2s) to a virtual touchpad button. + options: + disabled: Disabled + left_click: Left Click + right_click: Right Click + default: "right_click" + # + # Controller emulation + # + controller: + type: container + title: Controller + tags: [non-essential] + hint: >- + Uses the touchpad of the emulated controller (if it exists). + Otherwise, the touchpad remains unmapped (will still show up in the system). + Meant to be used as steam input, so short press is unassigned by + default and long press simulates trackpad click. + children: + desktop_disable: + type: bool + title: Disable on Desktop + hint: >- + Touchpad emulation will automatically be disabled when not in gamemode. + Specifically, steam will be periodically be checked to be running in + gamepad mode and if not, touchpad emulation will be disabled. + default: True + + correction: + type: multiple + title: Location + hint: >- + Controls the placement of the real touchpad to the virtual one, using + what steam expects. In Steam, the "Left" touchpad maps to the left + half, the "Right" touchpad maps to the right half, and "Center" maps + to the whole touchpad. + Therefore, the virtual touchpad is cropped to the left side for left, + the right side for right, and expanded in the center for center. + This means when set to center, half of the left touchpad is left and + half of the right is right. + "Stretch" stretches the touchpad to the whole dualsense surface. + options: + right: "Right" + center: "Center" + left: "Left" + stretch: "Stretch" + # crop_center: "Crop Center (Old)" + # crop_start: "Crop Start (Old)" + # crop_end: "Crop End (Old)" + # contain_start: "Contain Start (Old)" + # contain_end: "Contain End (Old)" + # contain_center: "Contain Center (Old)" + default: right + + short: + type: multiple + title: Short Action + tags: [advanced] + hint: >- + Maps short touches (less than 0.2s) to a touchpad action. + Dualsense uses a physical press for left and a double tap for + right click (support for double tap varies; enable "Tap to Click" + in your desktop's touchpad settings). + options: + disabled: Disabled + left_click: Left Click + right_click: Right Click + default: "disabled" + + hold: + type: multiple + title: Hold Action + hint: >- + Maps long touches (more than 2s) to a touchpad action. + Dualsense uses a physical press for left and a double tap for + right click (support for double tap varies; enable "Tap to Click" + in your desktop's touchpad settings). + options: + disabled: Disabled + left_click: Left Click + right_click: Right Click + default: "left_click" \ No newline at end of file diff --git a/src/hhd/plugins/utils.py b/src/hhd/plugins/utils.py new file mode 100644 index 00000000..d0e2c859 --- /dev/null +++ b/src/hhd/plugins/utils.py @@ -0,0 +1,20 @@ +def get_relative_fn(fn: str): + """Returns the directory of a file relative to the script calling this function.""" + import inspect + import os + + script_fn = inspect.currentframe().f_back.f_globals["__file__"] # type: ignore + dirname = os.path.dirname(script_fn) + return os.path.join(dirname, fn) + + +def load_relative_yaml(fn: str): + """Returns the yaml data of a file in the relative dir provided.""" + import inspect + import os + import yaml + + script_fn = inspect.currentframe().f_back.f_globals["__file__"] # type: ignore + dirname = os.path.dirname(script_fn) + with open(os.path.join(dirname, fn), "r") as f: + return yaml.safe_load(f) diff --git a/src/hhd/sections.yml b/src/hhd/sections.yml new file mode 100644 index 00000000..3590f70a --- /dev/null +++ b/src/hhd/sections.yml @@ -0,0 +1,10 @@ +sections: + tdp: TDP + rgb: RGB + controllers: Controller + wincontrols: WinControls + gamemode: General + updates: Updates + debug: Bugreport + shortcuts: Shortcuts + hhd: Settings diff --git a/src/hhd/settings.yml b/src/hhd/settings.yml index 5360851a..5ec903e2 100644 --- a/src/hhd/settings.yml +++ b/src/hhd/settings.yml @@ -1,34 +1,119 @@ +settings: + type: container + title: Core Settings + tags: [non-essential] + + children: + language: + type: multiple + tags: [i18n, non-essential, language] + title: Language + default: system + options: + system: System + C: English + zh_CN: Simplified Chinese + zh_TW: Traditional Chinese + pt: Portugese + + theme: + type: multiple + tags: [theme-selector, advanced] + title: "Theme" + hint: >- + Allows changing the theme in the UI. + Default is either Diavolo or your distribution's theme. + default: default + options: + default: Default + diavolo: Diavolo + ocean: Atlantis + vapor: Vapor + blood_orange: Blood Orange + + reset: + type: action + tags: [ reset-button, verify ] + title: Reset Settings + hint: >- + Resets all Handheld Daemon settings to their default values. + + decky_deprecation: + type: display + tags: [hhd-version-display-decky, text] + title: "It is no longer possible to update Decky from here. If you see this, update the Decky plugin manually." + default: " " + + version: + type: display + tags: [hhd-version-display, text] + title: Handheld Daemon Version + hint: "Displays the Handheld Daemon version." + + version_ui: + type: display + tags: [hhd-version-display, text] + title: Handheld Daemon UI Version + hint: "Displays the Handheld Daemon version." + + version_adj: + type: display + tags: [hhd-version-display, text] + title: Adjustor (TDP) Version + hint: "Displays the Handheld Daemon version." + + update_stable: + type: action + tags: [hhd-update-stable, verify] + title: Update (Stable) + hint: "Updates to the latest version from PyPi (local install only)." + + update_beta: + type: action + tags: [hhd-update-beta, verify] + title: Update (Unstable) + hint: "Updates to the master branch from git (local install only)." + + update_error: + title: Update Error + type: display + tags: [ error, non-essential ] + http: type: container - tags: [hhd-http] - title: REST API Configuration + tags: [hhd-http, advanced, expert] + title: API Configuration hint: >- Settings for configuring the http endpoint of HHD. children: enable: type: bool - title: Enable REST API. + title: Enable the API + tags: [advanced] hint: >- - Enables the rest API of Handheld Daemon - default: False + Enables the API of Handheld Daemon (required for decky and ui). + default: True port: - type: integer - title: REST API Port + type: int + title: API Port + tags: [advanced, hhd-port, port, dropdown] hint: >- - Which port should the REST API be on? + Which port should the API be on? min: 1024 max: 49151 default: 5335 localhost: type: bool title: Limit Access to localhost + tags: [advanced] hint: >- Sets the API target to '127.0.0.1' instead '0.0.0.0'. default: True token: type: bool title: Use Security token + tags: [advanced] hint: >- Generates a security token in `~/.config/hhd/token` that is required for authentication. diff --git a/src/hhd/utils.py b/src/hhd/utils.py index 5cfb6c58..36698a3b 100644 --- a/src/hhd/utils.py +++ b/src/hhd/utils.py @@ -1,136 +1,157 @@ import logging import os -import subprocess -from typing import NamedTuple -from hhd.plugins import Context +from hhd.plugins.plugin import ( + Context, + expanduser, + fix_perms, + get_context, + is_steam_gamepad_running, + restore_priviledge, + run_steam_command, + switch_priviledge, +) logger = logging.getLogger(__name__) +DISTRO_NAMES = ("manjaro", "bazzite", "ubuntu", "arch") +GIT_HHD = "git+https://github.com/hhd-dev/hhd" +GIT_ADJ = "git+https://github.com/hhd-dev/adjustor" +HHD_DEV_DIR = "/run/hhd/dev" + + +def get_distro_color(): + match get_os(): + case "manjaro": + return 115 + case "bazzite": + return 265 + case "arch": + return 195 + case "ubuntu": + return 340 + case "red_gold" | "red_gold_ba": + return 28 + case "blood_orange" | "blood_orange_ba": + return 18 + case _: + return 30 + + +def hsb_to_rgb(h: int, s: int | float, v: int | float): + # https://www.rapidtables.com/convert/color/hsv-to-rgb.html + if h >= 360: + h = 359 + s = s / 100 + v = v / 100 + + c = v * s + x = c * (1 - abs((h / 60) % 2 - 1)) + m = v - c + + if h < 60: + rgb = (c, x, 0) + elif h < 120: + rgb = (x, c, 0) + elif h < 180: + rgb = (0, c, x) + elif h < 240: + rgb = (0, x, c) + elif h < 300: + rgb = (x, 0, c) + else: + rgb = (c, 0, x) + + return [int((v + m) * 255) for v in rgb] + + +def get_os() -> str: + if name := os.environ.get("HHD_DISTRO", None): + logger.warning(f"Distro override using an environment variable to '{name}'.") + return name -def get_context(user: str | None) -> Context | None: try: - uid = os.getuid() - gid = os.getgid() - - if not user: - if not uid or not gid: - print(f"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") - print( - "Running as root without a specified user (`--user`). Configs will be placed at `/root/.config`." - ) - print(f"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") - return Context(uid, gid, uid, gid, "root") - - euid = int( - subprocess.run( - ["id", "-u", user], capture_output=True, check=True - ).stdout.decode() - ) - egid = int( - subprocess.run( - ["id", "-g", user], capture_output=True, check=True - ).stdout.decode() - ) - - if (uid or gid) and (uid != euid or gid != egid): - print( - f"The user specified with --user is not the user this process was started with." - ) - return None + with open("/etc/os-release") as f: + os_release = f.read().strip().lower() + except Exception as e: + logger.error(f"Could not read os information, error:\n{e}") + return "ukn" - return Context(euid, egid, uid, gid, user) - except subprocess.CalledProcessError as e: - print(f"Getting the user uid/gid returned an error:\n{e.stderr.decode()}") - return None + distro = None + for name in DISTRO_NAMES: + if name in os_release: + logger.info(f"Running under Linux distro '{name}'.") + distro = name + + try: + # Match just product name + # if a device exists here its officially supported + with open("/sys/devices/virtual/dmi/id/product_name") as f: + dmi = f.read().strip() + + # if "jupiter" in dmi.lower() or "onexplayer" in dmi.lower(): + # if distro == "bazzite": + # distro = "blood_orange_ba" + # else: + # distro = "blood_orange" + + if "ONEXPLAYER F1 EVA-02" in dmi: + if distro == "bazzite": + distro = "red_gold_ba" + else: + distro = "red_gold" except Exception as e: - print(f"Failed getting permissions with error:\n{e}") - return None + logger.error(f"Could not read product name, error:\n{e}") + if distro is not None: + return distro -def switch_priviledge(p: Context, escalate=False): - uid = os.geteuid() - gid = os.getegid() + logger.info(f"Running under an unknown Linux distro.") + return "ukn" - if escalate: - os.seteuid(p.uid) - os.setegid(p.gid) - else: - os.setegid(p.egid) - os.seteuid(p.euid) - return uid, gid +def get_ac_status_fn() -> str | None: + BASE_DIR = "/sys/class/power_supply" + fn = None + try: + for name in os.listdir(BASE_DIR): + if name.startswith("AC") or name.startswith("ADP"): + fn = name + break + if fn is None: + logger.error( + f"Could not find AC status file. Power supply directory:\n{os.listdir(BASE_DIR)}" + ) + return None + + return os.path.join(BASE_DIR, fn, "online") + except Exception as e: + logger.error(f"Could not read power supply directory, error:\n{e}") + return None -def restore_priviledge(old: tuple[int, int]): - uid, gid = old - # Try writing group first in case of root - # and fail silently +def get_ac_status(fn: str | None) -> bool | None: + if fn is None: + return None + if not os.path.exists(fn): + return None try: - os.setegid(gid) - except Exception: - pass - os.seteuid(uid) - os.setegid(gid) - pass - - -def expanduser(path: str, user: int | str | Context | None = None): - """Expand ~ and ~user constructions. If user or $HOME is unknown, - do nothing. - - Modified from the python implementation to support using the target userid/user.""" - - path = os.fspath(path) - - if not path.startswith("~"): - return path - - i = path.find("/", 1) - if i < 0: - i = len(path) - if i == 1: - if "HOME" in os.environ and not user: - # Fallback to environ only if user not set - userhome = os.environ["HOME"] - else: - try: - import pwd - except ImportError: - # pwd module unavailable, return path unchanged - return path - try: - if not user: - userhome = pwd.getpwuid(os.getuid()).pw_dir - elif isinstance(user, int): - userhome = pwd.getpwuid(user).pw_dir - elif isinstance(user, Context): - userhome = pwd.getpwuid(user.euid).pw_dir - else: - userhome = pwd.getpwnam(user).pw_dir - except KeyError: - # bpo-10496: if the current user identifier doesn't exist in the - # password database, return the path unchanged - return path - else: - try: - import pwd - except ImportError: - # pwd module unavailable, return path unchanged - return path - name = path[1:i] - try: - pwent = pwd.getpwnam(name) - except KeyError: - # bpo-10496: if the user name from the path doesn't exist in the - # password database, return the path unchanged - return path - userhome = pwent.pw_dir - - root = "/" - userhome = userhome.rstrip(root) - return (userhome + path[i:]) or root - - -def fix_perms(fn: str, ctx: Context): - os.chown(fn, ctx.euid, ctx.gid) + with open(fn) as f: + return f.read().strip() != "Discharging" + except Exception as e: + return None + + +__all__ = [ + "get_os", + "is_steam_gamepad_running", + "fix_perms", + "expanduser", + "restore_priviledge", + "switch_priviledge", + "get_context", + "Context", + "run_steam_command", + "get_ac_status", + "get_ac_status_fn", +] diff --git a/sync.sh b/sync.sh new file mode 100755 index 00000000..a344ee18 --- /dev/null +++ b/sync.sh @@ -0,0 +1,52 @@ +if [ -z "$1" ]; then + echo "Usage: $0 " + exit 1 +fi + +HOST=$1 +RSYNC="rsync -rv --exclude .git --exclude venv --exclude __pycache__'" +USER=${2:-bazzite} + +# python -m venv --system-site-packages ~/hhd-dev/hhd/venv +# ~/hhd-dev/hhd/venv/bin/pip install -e ~/hhd-dev/adjustor +# ~/hhd-dev/hhd/venv/bin/pip install -e ~/hhd-dev/hhd +# sudo chcon -R -u system_u -r object_r --type=bin_t /var/home/$USER/hhd-dev/hhd/venv/bin +# sudo systemctl disable --now hhd@$(whoami) +# sudo systemctl mask hhd@$(whoami) +# sudo systemctl enable --now hhdl + +# sudo nano /etc/systemd/system/hhdl.service +# [Unit] +# Description=Handheld Daemon Service + +# [Service] +# ExecStart=/home/bazzite/hhd-dev/hhd/venv/bin/hhd --user bazzite +# Nice=-12 +# Restart=on-failure +# RestartSec=10 +# #Environment="HHD_QAM_KEYBOARD=1" +# Environment="HHD_ALLY_POWERSAVE=1" +# Environment="HHD_HORI_STEAM=1" +# Environment="HHD_PPD_MASK=1" +# Environment="HHD_HIDE_ALL=1" +# Environment="HHD_GS_STEAMUI_HALFHZ=1" +# Environment="HHD_GS_DPMS=1" +# Environment="HHD_GS_STANDBY=1" +# Environment="HHD_BOOTC=1" +# Environment="HHD_BUGREPORT=1" +# Environment="HHD_SWAP_CREATE=1" + +# [Install] +# WantedBy=multi-user.target + +# set -e +$RSYNC . $HOST:hhd-dev/hhd +$RSYNC ../adjustor/ $HOST:hhd-dev/adjustor +$RSYNC ../hhd-bazzite/ $HOST:hhd-dev/hhd-bazzite + +ssh $HOST /bin/bash << EOF + sudo systemctl restart hhdl + # sudo systemctl stop hhdl +EOF + +# ssh -t $HOST "sudo HHD_HORI_STEAM=1 HHD_HIDE_ALL=1 ~/hhd-dev/hhd/venv/bin/hhd --user bazzite" \ No newline at end of file diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 00000000..bb3aee59 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,28 @@ +#!/usr/bin/bash +# Removes handheld daemon from ~/.local/share/hhd + +if [ "$EUID" -eq 0 ] + then echo "You should run this script as your user, not root (sudo)." + exit +fi + +# Disable Service +sudo systemctl disable --now hhd_local@$(whoami) + +# Remove Binary +rm -rf ~/.local/share/hhd + +# Remove bin link/overlay +rm -f ~/.local/bin/hhd +rm -f ~/.local/bin/hhd.contrib +rm -f ~/.local/bin/hhd-ui + +# Remove /etc files +sudo rm -f /etc/udev/rules.d/83-hhd.rules +sudo rm -f /etc/udev/hwdb.d/83-hhd.hwdb +sudo rm -f /etc/systemd/system/hhd_local@.service + +# # Delete your configuration +# rm -rf ~/.config/hhd + +echo "Handheld Daemon Uninstalled. Reboot!" \ No newline at end of file diff --git a/usr/lib/modprobe.d/hhd.conf b/usr/lib/modprobe.d/hhd.conf deleted file mode 100644 index f56f5c4b..00000000 --- a/usr/lib/modprobe.d/hhd.conf +++ /dev/null @@ -1,3 +0,0 @@ -# Blacklist playstation driver to avoid issues with steam -# seeing the touchpad -blacklist hid_playstation \ No newline at end of file diff --git a/usr/lib/systemd/system/hhd@.service b/usr/lib/systemd/system/hhd@.service index 9f9b1686..56b4d920 100644 --- a/usr/lib/systemd/system/hhd@.service +++ b/usr/lib/systemd/system/hhd@.service @@ -4,6 +4,13 @@ Description=Handheld Daemon Service [Service] ExecStart=/usr/bin/hhd --user %i Nice=-12 +Restart=on-failure +RestartSec=5 + +# Required for bootc to work correctly, otherwise +# the default SELinux context is enough +# FIXME: lower this in the future +SELinuxContext=system_u:unconfined_r:unconfined_t:s0 [Install] WantedBy=multi-user.target \ No newline at end of file diff --git a/usr/lib/systemd/system/hhd_local@.service b/usr/lib/systemd/system/hhd_local@.service index 32cf4bdd..8e4da15a 100644 --- a/usr/lib/systemd/system/hhd_local@.service +++ b/usr/lib/systemd/system/hhd_local@.service @@ -4,6 +4,8 @@ Description=Handheld Daemon Service [Service] ExecStart=/home/%i/.local/share/hhd/venv/bin/hhd --user %i Nice=-12 +Restart=on-failure +RestartSec=5 [Install] WantedBy=multi-user.target \ No newline at end of file diff --git a/usr/lib/systemd/system/product_name@.service b/usr/lib/systemd/system/product_name@.service new file mode 100644 index 00000000..1e7eff67 --- /dev/null +++ b/usr/lib/systemd/system/product_name@.service @@ -0,0 +1,11 @@ +[Unit] +Description=Change Product Name + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/bin/sh -c "echo %i > /tmp/product_name && mount --bind /tmp/product_name /sys/devices/virtual/dmi/id/product_name" +ExecStop=/bin/sh -c "umount /sys/devices/virtual/dmi/id/product_name" + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/usr/lib/systemd/user/hhd-user.service b/usr/lib/systemd/user/hhd-user.service new file mode 100644 index 00000000..70c29765 --- /dev/null +++ b/usr/lib/systemd/user/hhd-user.service @@ -0,0 +1,10 @@ +[Unit] +Description=Handheld Daemon User Service + +[Service] +ExecStart=hhd +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/usr/lib/udev/hwdb.d/83-hhd.hwdb b/usr/lib/udev/hwdb.d/83-hhd.hwdb new file mode 100644 index 00000000..47572902 --- /dev/null +++ b/usr/lib/udev/hwdb.d/83-hhd.hwdb @@ -0,0 +1,28 @@ +# Add f buttons to Ayaneo devices +evdev:name:AT Translated Set 2 keyboard:dmi:*:svnAYANEO:* + FORCE_INPUT_UACCESS=1 + KEYBOARD_KEY_66=f15 + KEYBOARD_KEY_67=f16 + KEYBOARD_KEY_68=f17 + KEYBOARD_KEY_69=f18 + +# OrangePi G1621-02/NEO-01 (OrangePi Neo) +evdev:name:AT Translated Set 2 keyboard:dmi:*:svnOrangePi:pnG1621-02:* + KEYBOARD_KEY_64=f15 + KEYBOARD_KEY_65=f16 + KEYBOARD_KEY_66=f15 + KEYBOARD_KEY_67=f16 + KEYBOARD_KEY_68=f17 + KEYBOARD_KEY_69=f18 + +evdev:name:AT Translated Set 2 keyboard:dmi:*:svnOrangePi:pnNEO-01:* + KEYBOARD_KEY_66=f15 + KEYBOARD_KEY_67=f16 + +# From systemd +# MSI Claw A1M, MSI Claw 7 AI+ A2VM, MSI Claw 8 AI+ A2VM +evdev:name:AT Translated Set 2 keyboard:dmi:*:svnMicro-StarInternationalCo.,Ltd.:pnClawA1M:* +evdev:name:AT Translated Set 2 keyboard:dmi:*:svnMicro-StarInternationalCo.,Ltd.:pnClaw7AI+A2VM:* +evdev:name:AT Translated Set 2 keyboard:dmi:*:svnMicro-StarInternationalCo.,Ltd.:pnClaw8AI+A2VM:* + KEYBOARD_KEY_b9=f15 # Right Face Button + KEYBOARD_KEY_ba=f16 # Left Face Button \ No newline at end of file diff --git a/usr/lib/udev/rules.d/83-hhd-user.rules b/usr/lib/udev/rules.d/83-hhd-user.rules index 250bd3ce..c194007a 100644 --- a/usr/lib/udev/rules.d/83-hhd-user.rules +++ b/usr/lib/udev/rules.d/83-hhd-user.rules @@ -17,4 +17,7 @@ KERNELS=="input[0-9]*", SUBSYSTEMS=="input", ATTRS{phys}=="isa0060*", MODE="0666 # Enable access to uhid and uinput KERNEL=="uinput", MODE="0666", TAG+="uaccess" -KERNEL=="uhid", MODE="0666", TAG+="uaccess" \ No newline at end of file +KERNEL=="uhid", MODE="0666", TAG+="uaccess" + +# Allow access to brightness controls +ACTION=="add", SUBSYSTEM=="backlight", RUN+="/bin/chgrp video $sys$devpath/brightness", RUN+="/bin/chmod g+w $sys$devpath/brightness" \ No newline at end of file diff --git a/usr/lib/udev/rules.d/83-hhd.rules b/usr/lib/udev/rules.d/83-hhd.rules index c9722362..83bbb291 100644 --- a/usr/lib/udev/rules.d/83-hhd.rules +++ b/usr/lib/udev/rules.d/83-hhd.rules @@ -1,13 +1,40 @@ + +# NOT NEEDED ANYMORE # Legion go specific quirk # Disable accelerometer use from iio-sensor-proxy to avoid messing up the driver # https://askubuntu.com/questions/803845/what-process-is-responsible-for-auto-screen-rotation-i-want-to-disable-it-on-1 # overrides /lib/udev/rules.d/80-iio-sensor-proxy.rules to disable using the accelerometer for orientation -KERNELS=="0020:1022:*", SUBSYSTEM=="iio", TEST=="in_accel_x_raw", TEST=="in_accel_y_raw", TEST=="in_accel_z_raw", ENV{IIO_SENSOR_PROXY_TYPE}="" +# KERNELS=="0020:1022:*", SUBSYSTEM=="iio", TEST=="in_accel_x_raw", TEST=="in_accel_y_raw", TEST=="in_accel_z_raw", ENV{IIO_SENSOR_PROXY_TYPE}="" + +# Remove buffer polling from iio to prevent it messing with the controllers +SUBSYSTEM=="iio", TEST=="in_accel_x_raw", TEST=="in_accel_y_raw", TEST=="in_accel_z_raw", TEST=="scan_elements/in_accel_x_en", TEST=="scan_elements/in_accel_y_en", TEST=="scan_elements/in_accel_z_en", ENV{IIO_SENSOR_PROXY_TYPE}="iio-poll-accel" # # Allow steam to access the raw controllers # +# Sony DualSense Wireless-Controller; Bluetooth; USB +KERNEL=="hidraw*", KERNELS=="*054C:0CE6*", MODE="0666", TAG+="uaccess" +KERNEL=="hidraw*", ATTRS{idVendor}=="054c", ATTRS{idProduct}=="0ce6", MODE="0666", TAG+="uaccess" + # Sony DualSense Edge Wireless-Controller; Bluetooth; USB KERNEL=="hidraw*", KERNELS=="*054C:0DF2*", MODE="0666", TAG+="uaccess" -KERNEL=="hidraw*", ATTRS{idVendor}=="054c", ATTRS{idProduct}=="0df2", MODE="0666", TAG+="uaccess" \ No newline at end of file +KERNEL=="hidraw*", ATTRS{idVendor}=="054c", ATTRS{idProduct}=="0df2", MODE="0666", TAG+="uaccess" + +# Handheld Daemon Emulated Controllers +# VID is not reserved at the time of this writing +ATTRS{idVendor}=="5335", MODE="0666", TAG+="uaccess" +ATTRS{id/vendor}=="5335", MODE="0666", TAG+="uaccess" + +# The following rule is required for the Dualsense evdev to be used from userspace +# (e.g., Dolphin) +SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="*Motion Sensors", MODE="0666", TAG+="uaccess" + +# Enable xpad for the MSI Claw +ATTRS{idVendor}=="0db0", ATTRS{idProduct}=="1901", RUN+="/sbin/modprobe xpad" RUN+="/bin/sh -c 'echo 0db0 1901 > /sys/bus/usb/drivers/xpad/new_id'" + +# Enable xpad for the TECNO Pocket Go +ATTRS{idVendor}=="2993", ATTRS{idProduct}=="2001", RUN+="/sbin/modprobe xpad" RUN+="/bin/sh -c 'echo 2993 2001 > /sys/bus/usb/drivers/xpad/new_id'" + +# Enable xpad for the Legion Go S +ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="e310", RUN+="/sbin/modprobe xpad" RUN+="/bin/sh -c 'echo 1a86 e310 > /sys/bus/usb/drivers/xpad/new_id'" \ No newline at end of file diff --git a/usr/share/hhd/controller_db.txt b/usr/share/hhd/controller_db.txt new file mode 100644 index 00000000..cc127db7 --- /dev/null +++ b/usr/share/hhd/controller_db.txt @@ -0,0 +1,15 @@ +050000004c050000f20d000000810000,Sony Interactive Entertainment DualSense Edge Wireless Controller,platform:Linux,a:b0,b:b1,x:b3,y:b2,dpleft:h0.8,dpright:h0.2,dpup:h0.1,dpdown:h0.4,leftx:a0,lefty:a1,leftstick:b11,rightx:a3,righty:a4,rightstick:b12,leftshoulder:b4,lefttrigger:a2,rightshoulder:b5,righttrigger:a5,back:b8,start:b9,guide:b10,steam:2, +03000000355300000100000001000000,Handheld Daemon Controller,a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +060000005e040000e302000001000000,Xbox One Elite Controller (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +060000004c050000f20d000001000000,DualSense Edge (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +060000007e0500000920000001000000,Switch Pro (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +060000004c050000e60c000001000000,PS5 Controller (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +060000004c0500006802000001000000,PS3 Controller (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +060000004c050000c405000001000000,PS4 Controller (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +060000005e0400008f02000001000000,Xbox 360 Controller (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +060000005e040000d102000001000000,Xbox One Controller (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +060000005e040000120b000001000000,Xbox Series X Controller (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +060000007e0500000820000001000000,JoyCon Pair (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +060000007e0500000620000001000000,JoyCon Left (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +060000007e0500000720000001000000,JoyCon Right (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 +060000007e0500000e20000001000000,JoyCon Grip (HHD),a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,crc:a107,platform:Linux,paddle1:b12,paddle2:b11,paddle3:b14,paddle4:b13,misc1:b16 diff --git a/xbox-emulator-web/README.md b/xbox-emulator-web/README.md new file mode 100644 index 00000000..869f87b9 --- /dev/null +++ b/xbox-emulator-web/README.md @@ -0,0 +1,32 @@ +# Xbox Emulator Web App + +## Overview +The Xbox Emulator Web App is a modern web application that allows users to play Xbox games on their Android devices. It provides a user-friendly interface for importing game ROMs, managing a game library, and configuring emulator settings. + +## Project Structure +The project consists of the following files: + +- **index.html**: The main HTML document that structures the web page, including navigation and sections for home, library, settings, and help. +- **styles.css**: The stylesheet that defines the layout, colors, fonts, and responsive design for the web app. +- **main.js**: The JavaScript file that contains the logic for navigation, file uploads, controller detection, and toggling special modes. +- **xbox-logo.svg**: The logo for the Xbox Emulator. +- **xbox360-logo.svg**: The Xbox 360 logo used in the SX mode banner and emulator bar. +- **controller.svg**: An image representing a game controller, used in the features section. +- **library.svg**: An image representing a game library, used in the features section. +- **settings.svg**: An image representing settings, used in the features section. +- **halo-cover.jpg**: The cover art for the game "Halo: Combat Evolved" in the library section. + +## Setup Instructions +1. Clone the repository or download the project files. +2. Ensure all asset files are in the project directory. +3. Open `index.html` in a web browser to run the application. + +## Usage +- Navigate through the app using the menu at the top. +- Import game ROMs by clicking the "Import Game ROM" button on the home page. +- Manage your game library and launch games from the library section. +- Adjust settings in the settings section, including graphics and audio options. +- Access help and FAQs for guidance on using the emulator. + +## License +This project is for educational use only and is not affiliated with Microsoft. \ No newline at end of file diff --git a/xbox-emulator-web/controller.svg b/xbox-emulator-web/controller.svg new file mode 100644 index 00000000..86941c69 --- /dev/null +++ b/xbox-emulator-web/controller.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/xbox-emulator-web/halo-cover.jpg b/xbox-emulator-web/halo-cover.jpg new file mode 100644 index 00000000..aa46a7cf --- /dev/null +++ b/xbox-emulator-web/halo-cover.jpg @@ -0,0 +1 @@ +This file is intentionally left blank. \ No newline at end of file diff --git a/xbox-emulator-web/index.html b/xbox-emulator-web/index.html new file mode 100644 index 00000000..da28cf36 --- /dev/null +++ b/xbox-emulator-web/index.html @@ -0,0 +1,189 @@ + + + + + + Xbox Emulator for Android + + + + + + +
+ + +
+ +
+ +
+
+

Play Xbox Games on Android

+

Experience classic and modern Xbox games right on your Android device. Import ROMs, save your progress, and personalize your gaming experience.

+ + +
+
+
+ Controller +

Intuitive Controls

+

On-screen or Bluetooth gamepad support, customizable layouts.

+
+
+ Library +

Game Library

+

Organize and launch your favorite games with cover art and details.

+
+
+ Settings +

Advanced Settings

+

Adjust graphics, audio, and performance options for the best gameplay.

+
+
+ + +
+

Play Xbox Game Pass Games

+

Want to play Game Pass games on your device? Launch Xbox Cloud Gaming with full controller support.

+ Launch Xbox Cloud Gaming +
+ + +
+ Controller: Not Connected + +
+
+ + +
+

Your Game Library

+
+ +
+ Halo Cover +
+

Halo: Combat Evolved

+

Action / Shooter

+ +
+
+ +
+ +
+
+
+ + + + + +
+

Settings

+
+

Graphics

+ + +
+
+

Audio

+ + +
+
+

Controller

+ + +
+ +
+

Special Modes

+ + This mode gives the UI an Xbox One & 360-inspired look! +
+ +
+ + +
+

Help & FAQ

+

How to Dump Your Original Xbox Discs for Emulation

+
    +
  1. You must own the original Xbox game disc.
  2. +
  3. Use a compatible DVD drive with your PC (not all drives work with Xbox discs).
  4. +
  5. Download the extract-xiso tool or similar to create an .iso or .xiso file.
  6. +
  7. Follow the instructions to dump the disc and save the ISO to your PC.
  8. +
  9. Transfer the ISO to your Android device and load it in the emulator.
  10. +
  11. Never download ISOs you do not own. This is illegal and against emulator policy.
  12. +
+

For more detailed guides, see xemu's official documentation.

+

Can I play Xbox Game Pass games in this emulator?

+

No. Xbox Game Pass games are protected by DRM and cannot be run in any emulator. Use the Cloud Gaming launcher above to play Game Pass games via streaming with controller support.

+

Controller Troubleshooting

+
    +
  • Make sure your Bluetooth controller is paired with your device before launching the emulator.
  • +
  • Supported controllers include Xbox, PlayStation, and most Android-compatible Bluetooth gamepads.
  • +
  • If your controller is not detected, try reconnecting or refreshing the page/app.
  • +
+
+
+
+

© 2025 Xbox Emulator. Not affiliated with Microsoft. For educational use only.

+
+ + + \ No newline at end of file diff --git a/xbox-emulator-web/library.svg b/xbox-emulator-web/library.svg new file mode 100644 index 00000000..d6c831ed --- /dev/null +++ b/xbox-emulator-web/library.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/xbox-emulator-web/main.js b/xbox-emulator-web/main.js new file mode 100644 index 00000000..40be257b --- /dev/null +++ b/xbox-emulator-web/main.js @@ -0,0 +1,170 @@ +// Simple navigation logic for sections +document.querySelectorAll('nav a').forEach(link => { + link.addEventListener('click', function (e) { + e.preventDefault(); + document.querySelectorAll('nav a').forEach(a => a.classList.remove('active')); + this.classList.add('active'); + document.querySelectorAll('.section').forEach(s => s.classList.remove('active')); + document.querySelector(this.getAttribute('href')).classList.add('active'); + // Hide emulator overlay if navigating + document.getElementById('emulator-controls').style.display = 'none'; + }); +}); + +// Placeholder: file upload (ROM import) +document.getElementById('file-upload').addEventListener('change', function () { + alert('ROM upload feature coming soon!'); +}); + +// Emulator controls overlay toggling (demo) +document.querySelectorAll('.play-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.getElementById('emulator-controls').style.display = 'block'; + // Show SX emulator bar if SX mode is enabled + if(document.body.classList.contains('sx-mode')) { + document.getElementById('sx-emulator-bar').style.display = 'flex'; + } else { + document.getElementById('sx-emulator-bar').style.display = 'none'; + } + }); +}); +document.getElementById('close-emulator').addEventListener('click', () => { + document.getElementById('emulator-controls').style.display = 'none'; +}); + +// Controller detection and testing +const controllerConnection = document.getElementById('controller-connection'); +const controllerDetails = document.getElementById('controller-details'); +const controllerList = document.getElementById('controller-list'); +const buttonGrid = document.getElementById('button-grid'); +const axisGrid = document.getElementById('axis-grid'); + +let lastGamepadStates = {}; + +function updateControllerStatus() { + const gamepads = navigator.getGamepads ? navigator.getGamepads() : []; + const connectedGamepads = []; + for (let i = 0; i < gamepads.length; i++) { + let gp = gamepads[i]; + if (gp && gp.connected) connectedGamepads.push(gp); + } + // Update status text + if (connectedGamepads.length > 0) { + controllerConnection.textContent = "Connected"; + controllerDetails.style.display = "block"; + controllerList.innerHTML = ''; + connectedGamepads.forEach((gp, idx) => { + let li = document.createElement('li'); + li.textContent = `${gp.id} (Index: ${gp.index})`; + controllerList.appendChild(li); + }); + // Show button and axis testers for first controller + showButtonTester(connectedGamepads[0]); + showAxisTester(connectedGamepads[0]); + } else { + controllerConnection.textContent = "Not Connected"; + controllerDetails.style.display = "none"; + buttonGrid.innerHTML = ''; + axisGrid.innerHTML = ''; + } +} + +function showButtonTester(gp) { + buttonGrid.innerHTML = ''; + gp.buttons.forEach((btn, idx) => { + const btnEl = document.createElement('button'); + btnEl.className = "controller-btn"; + btnEl.textContent = idx; + if (btn.pressed) btnEl.classList.add('active'); + btnEl.title = `Button ${idx}`; + buttonGrid.appendChild(btnEl); + }); +} + +function showAxisTester(gp) { + axisGrid.innerHTML = ''; + gp.axes.forEach((val, idx) => { + const axisRow = document.createElement('div'); + axisRow.className = 'axis-bar'; + axisRow.title = `Axis ${idx}: ${val.toFixed(2)}`; + // Axis indicator (centered, -1 to +1) + const indicator = document.createElement('div'); + indicator.className = 'axis-indicator'; + indicator.style.left = `${((val + 1) / 2) * 100}%`; + indicator.style.width = '8px'; + axisRow.appendChild(indicator); + // Label + const label = document.createElement('span'); + label.style.position = 'absolute'; + label.style.left = '8px'; + label.style.top = '14px'; + label.style.fontSize = '0.95em'; + label.style.color = '#fff9'; + label.textContent = `Axis ${idx}: ${val.toFixed(2)}`; + axisRow.appendChild(label); + axisGrid.appendChild(axisRow); + }); +} + +// Poll for gamepad status and button/axis +function pollGamepads() { + const gamepads = navigator.getGamepads ? navigator.getGamepads() : []; + let changed = false; + for (let i = 0; i < gamepads.length; i++) { + let gp = gamepads[i]; + if (gp && gp.connected) { + // Check if button or axis state changed + let last = lastGamepadStates[gp.index]; + if (!last || JSON.stringify(last.buttons) !== JSON.stringify(gp.buttons.map(b => b.pressed)) + || JSON.stringify(last.axes) !== JSON.stringify(gp.axes)) { + changed = true; + lastGamepadStates[gp.index] = { + buttons: gp.buttons.map(b => b.pressed), + axes: Array.from(gp.axes) + }; + } + } + } + if (changed) updateControllerStatus(); +} + +window.addEventListener("gamepadconnected", updateControllerStatus); +window.addEventListener("gamepaddisconnected", updateControllerStatus); +setInterval(() => { + pollGamepads(); +}, 150); + +// Initial call +updateControllerStatus(); + +// SX MODE TOGGLE LOGIC +const sxToggle = document.getElementById('sx-mode-toggle'); +const sxBanner = document.getElementById('sx-banner'); +const mainLogo = document.getElementById('main-logo'); + +if (sxToggle) { + // Restore from localStorage if previously set + if (localStorage.getItem('sxMode') === 'on') { + document.body.classList.add('sx-mode'); + if(sxBanner) sxBanner.style.display = 'flex'; + sxToggle.checked = true; + if (mainLogo) mainLogo.src = "xbox360-logo.svg"; + } + sxToggle.addEventListener('change', function() { + if (sxToggle.checked) { + document.body.classList.add('sx-mode'); + if(sxBanner) sxBanner.style.display = 'flex'; + localStorage.setItem('sxMode', 'on'); + if (mainLogo) mainLogo.src = "xbox360-logo.svg"; + if(document.getElementById('emulator-controls').style.display === 'block') { + document.getElementById('sx-emulator-bar').style.display = 'flex'; + } + } else { + document.body.classList.remove('sx-mode'); + if(sxBanner) sxBanner.style.display = 'none'; + localStorage.setItem('sxMode', 'off'); + if (mainLogo) mainLogo.src = "xbox-logo.svg"; + document.getElementById('sx-emulator-bar').style.display = 'none'; + } + }); +} \ No newline at end of file diff --git a/xbox-emulator-web/settings.svg b/xbox-emulator-web/settings.svg new file mode 100644 index 00000000..5f2e0c26 --- /dev/null +++ b/xbox-emulator-web/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/xbox-emulator-web/styles.css b/xbox-emulator-web/styles.css new file mode 100644 index 00000000..65da8a05 --- /dev/null +++ b/xbox-emulator-web/styles.css @@ -0,0 +1,318 @@ +:root { + --xbox-green: #107C10; + --xbox-dark: #23272a; + --xbox-light: #f0f4f8; + --xbox-grey: #333940; + --primary-font: 'Segoe UI', 'Arial', sans-serif; + --controller-btn: #2e8b57; + --controller-btn-active: #38d948; + --axis-bg: #13381a; + --axis-fg: #38d948; +} +html, body { + margin: 0; + padding: 0; + background: var(--xbox-dark); + color: var(--xbox-light); + font-family: var(--primary-font); + min-height: 100vh; +} +header { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--xbox-green); + padding: 0.5em 2em; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); +} +.logo { display: flex; align-items: center; } +.logo img { width: 40px; margin-right: 10px; } +.logo span { font-size: 1.5em; font-weight: bold; letter-spacing: 1px; } +nav a { + color: var(--xbox-light); + text-decoration: none; + margin-left: 2em; + font-weight: 500; + transition: color 0.2s; +} +nav a.active, nav a:hover { color: #fff; text-shadow: 0 0 5px #fff9; } +main { + max-width: 1200px; + margin: 2em auto; + padding: 0 1em; +} +.section { display: none; } +.section.active { display: block; } +.hero { + text-align: center; + margin-bottom: 3em; +} +.hero h1 { font-size: 2.5em; margin-bottom: 0.4em; } +.hero p { font-size: 1.2em; margin-bottom: 1.4em; } +.primary-btn { + background: var(--xbox-green); + color: #fff; + padding: 0.8em 2.2em; + border: none; + border-radius: 30px; + font-size: 1em; + font-weight: bold; + cursor: pointer; + box-shadow: 0 2px 8px #0006; + transition: background 0.2s; +} +.primary-btn:hover { background: #0e6a0e; } +.features { + display: flex; + justify-content: center; + gap: 2em; +} +.feature { + background: var(--xbox-grey); + padding: 1.5em; + border-radius: 12px; + text-align: center; + max-width: 300px; + box-shadow: 0 2px 8px #0004; +} +.feature img { width: 54px; margin-bottom: 0.5em; } +.feature h3 { margin: 0.3em 0 0.4em; } +.game-grid { + display: flex; + flex-wrap: wrap; + gap: 2em; +} +.game-card { + background: var(--xbox-grey); + border-radius: 12px; + overflow: hidden; + width: 220px; + box-shadow: 0 2px 8px #0003; + display: flex; + flex-direction: column; +} +.game-card img { width: 100%; height: 140px; object-fit: cover; } +.game-info { padding: 1em; } +.game-info h4 { margin: 0 0 0.4em; font-size: 1.1em; } +.play-btn { + background: var(--xbox-green); + color: #fff; + border: none; + border-radius: 18px; + padding: 0.5em 1.4em; + cursor: pointer; + margin-top: 0.6em; + font-weight: 600; +} +.add-game { + display: flex; + align-items: center; + justify-content: center; + min-width: 220px; + height: 180px; + background: #1e252b88; + border-radius: 12px; +} +.add-game button { + background: none; + border: 2px dashed var(--xbox-green); + color: var(--xbox-green); + font-size: 1.4em; + border-radius: 12px; + padding: 1em 2em; + cursor: pointer; +} +.settings-group { + margin-bottom: 2em; + background: var(--xbox-grey); + border-radius: 10px; + padding: 1.5em; +} +.settings-group h3 { margin-top: 0; } +.settings-group label { + display: block; + margin-bottom: 1em; + font-size: 1.1em; +} +input[type="range"] { width: 150px; } +footer { + text-align: center; + padding: 2em 0 1em 0; + color: #bbb; + font-size: 0.95em; +} +.cloud-gaming { + background: #232a34; + border-radius: 14px; + padding: 2em; + margin: 2em 0; + text-align: center; + box-shadow: 0 2px 8px #0004; +} +.cloud-btn { + display: inline-block; + background: var(--xbox-green); + color: #fff; + padding: 0.8em 2em; + font-size: 1.2em; + border-radius: 30px; + font-weight: bold; + margin-top: 1em; + text-decoration: none; + box-shadow: 0 2px 6px #0006; + transition: background 0.2s; +} +.cloud-btn:hover { background: #0e6a0e; } +.controller-status { + margin: 1.5em 0; + background: #222d2f; + color: #e0f7d4; + padding: 1em 2em; + border-radius: 10px; + text-align: center; + font-size: 1.1em; +} +.controller-details { + margin-top: 1em; + background: #293940; + color: #b2ffc3; + border-radius: 8px; + padding: 1em; + text-align: left; + display: inline-block; + min-width: 300px; +} +.controller-details h4 { + margin-top: 0; +} +#controller-list { + list-style: none; + padding: 0; + margin-bottom: 0.5em; +} +#controller-list li { + margin-bottom: 0.2em; + font-size: 1em; +} +.button-tester, .axis-tester { + margin-top: 0.7em; +} +#button-grid { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 0.7em; +} +.controller-btn { + background: var(--controller-btn); + border: none; + color: #fff; + width: 38px; + height: 38px; + border-radius: 50%; + margin: 0 3px 3px 0; + font-weight: bold; + font-size: 1em; + cursor: pointer; + outline: none; + transition: background 0.15s; + pointer-events: none; + opacity: 0.7; +} +.controller-btn.active { + background: var(--controller-btn-active); + color: #222; + opacity: 1; + box-shadow: 0 0 6px #38d948cc; +} +#axis-grid { + display: flex; + flex-direction: column; + gap: 3px; + margin-top: 0.6em; +} +.axis-bar { + background: var(--axis-bg); + height: 12px; + border-radius: 6px; + margin: 2px 0; + position: relative; + width: 160px; + box-shadow: 0 0 2px #111b; +} +.axis-indicator { + background: var(--axis-fg); + height: 12px; + border-radius: 6px; + position: absolute; + top: 0; + left: 50%; + width: 8px; + transition: left 0.1s; +} + +body.sx-mode { + background: linear-gradient(135deg, #08250a 0%, #222 100%); +} +body.sx-mode header { + background: #107c10; + border-bottom: 4px solid #77e300; +} +body.sx-mode .primary-btn, +body.sx-mode .cloud-btn, +body.sx-mode .play-btn { + background: #77e300; + color: #232; +} +body.sx-mode .primary-btn:hover, +body.sx-mode .cloud-btn:hover, +body.sx-mode .play-btn:hover { + background: #b2ff59; +} +body.sx-mode .controller-status, +body.sx-mode .settings-group, +body.sx-mode .feature, +body.sx-mode .game-card { + background: #162d1a; + color: #b2ffc3; +} + +.sx-banner { + position: fixed; + top: 0; + left: 50%; + transform: translateX(-50%); + background: #101e0e; + color: #b2ff59; + border: 2px solid #b2ff59; + border-radius: 0 0 16px 16px; + padding: 0.5em 2em; + z-index: 1000; + font-size: 1.18em; + font-weight: bold; + letter-spacing: 1px; + box-shadow: 0 6px 24px #000b; + display: flex; + align-items: center; + gap: 1em; +} +.sx-emulator-bar { + background: #101e0e; + color: #b2ff59; + border: 2px solid #b2ff59; + border-radius: 0 0 12px 12px; + padding: 0.3em 1em; + margin-bottom: 8px; + font-size: 1.05em; + font-weight: bold; + letter-spacing: 1px; + display: flex; + align-items: center; + gap: 0.8em; + justify-content: center; +} +@media (max-width: 900px) { + .features { flex-direction: column; align-items: center; } + .game-grid { flex-direction: column; align-items: center; } + .controller-details { min-width: 0; width: 98vw; } +} \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/README.md b/xbox-emulator-web/xbox-emulator-web/README.md new file mode 100644 index 00000000..534c3139 --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/README.md @@ -0,0 +1,33 @@ +# Xbox Emulator Web App + +## Overview +The Xbox Emulator Web App is a modern web application that allows users to play Xbox games on their Android devices. It provides a user-friendly interface for importing game ROMs, managing a game library, and configuring emulator settings. + +## Project Structure +The project consists of the following files: + +- **index.html**: The main HTML document that structures the web page, including navigation and sections for home, library, settings, and help. +- **styles.css**: The stylesheet that defines the layout, colors, fonts, and responsive design for the web app. +- **main.js**: The JavaScript file that contains the logic for navigation, file uploads, controller detection, and toggling special modes. +- **xbox-logo.svg**: The logo for the Xbox Emulator. +- **xbox360-logo.svg**: The Xbox 360 logo used in the SX mode banner and emulator bar. +- **controller.svg**: An image representing a game controller, used in the features section. +- **library.svg**: An image representing a game library, used in the features section. +- **settings.svg**: An image representing settings, used in the features section. +- **halo-cover.jpg**: The cover art for the game "Halo: Combat Evolved" in the library section. + +## Setup Instructions +1. Clone the repository or download the project files. +2. Ensure all asset files are in the project directory. +3. Open `index.html` in a web browser to run the application. + +## Usage +- Navigate through the app using the menu at the top. +- Import game ROMs by clicking the "Import Game ROM" button on the home page. +- Manage your game library and launch games from the library section. +- Adjust settings in the settings section, including graphics and audio options. +- Access help and FAQs for guidance on using the emulator. + +## License +This project is for educational use only and is not affiliated with Microsoft. + diff --git a/xbox-emulator-web/xbox-emulator-web/controller.svg b/xbox-emulator-web/xbox-emulator-web/controller.svg new file mode 100644 index 00000000..86941c69 --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/controller.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/halo-cover.jpg b/xbox-emulator-web/xbox-emulator-web/halo-cover.jpg new file mode 100644 index 00000000..aa46a7cf --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/halo-cover.jpg @@ -0,0 +1 @@ +This file is intentionally left blank. \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/index.html b/xbox-emulator-web/xbox-emulator-web/index.html new file mode 100644 index 00000000..da28cf36 --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/index.html @@ -0,0 +1,189 @@ + + + + + + Xbox Emulator for Android + + + + + + +
+ + +
+ +
+ +
+
+

Play Xbox Games on Android

+

Experience classic and modern Xbox games right on your Android device. Import ROMs, save your progress, and personalize your gaming experience.

+ + +
+
+
+ Controller +

Intuitive Controls

+

On-screen or Bluetooth gamepad support, customizable layouts.

+
+
+ Library +

Game Library

+

Organize and launch your favorite games with cover art and details.

+
+
+ Settings +

Advanced Settings

+

Adjust graphics, audio, and performance options for the best gameplay.

+
+
+ + +
+

Play Xbox Game Pass Games

+

Want to play Game Pass games on your device? Launch Xbox Cloud Gaming with full controller support.

+ Launch Xbox Cloud Gaming +
+ + +
+ Controller: Not Connected + +
+
+ + +
+

Your Game Library

+
+ +
+ Halo Cover +
+

Halo: Combat Evolved

+

Action / Shooter

+ +
+
+ +
+ +
+
+
+ + + + + +
+

Settings

+
+

Graphics

+ + +
+
+

Audio

+ + +
+
+

Controller

+ + +
+ +
+

Special Modes

+ + This mode gives the UI an Xbox One & 360-inspired look! +
+ +
+ + +
+

Help & FAQ

+

How to Dump Your Original Xbox Discs for Emulation

+
    +
  1. You must own the original Xbox game disc.
  2. +
  3. Use a compatible DVD drive with your PC (not all drives work with Xbox discs).
  4. +
  5. Download the extract-xiso tool or similar to create an .iso or .xiso file.
  6. +
  7. Follow the instructions to dump the disc and save the ISO to your PC.
  8. +
  9. Transfer the ISO to your Android device and load it in the emulator.
  10. +
  11. Never download ISOs you do not own. This is illegal and against emulator policy.
  12. +
+

For more detailed guides, see xemu's official documentation.

+

Can I play Xbox Game Pass games in this emulator?

+

No. Xbox Game Pass games are protected by DRM and cannot be run in any emulator. Use the Cloud Gaming launcher above to play Game Pass games via streaming with controller support.

+

Controller Troubleshooting

+
    +
  • Make sure your Bluetooth controller is paired with your device before launching the emulator.
  • +
  • Supported controllers include Xbox, PlayStation, and most Android-compatible Bluetooth gamepads.
  • +
  • If your controller is not detected, try reconnecting or refreshing the page/app.
  • +
+
+
+
+

© 2025 Xbox Emulator. Not affiliated with Microsoft. For educational use only.

+
+ + + \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/library.svg b/xbox-emulator-web/xbox-emulator-web/library.svg new file mode 100644 index 00000000..d6c831ed --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/library.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/main.js b/xbox-emulator-web/xbox-emulator-web/main.js new file mode 100644 index 00000000..40be257b --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/main.js @@ -0,0 +1,170 @@ +// Simple navigation logic for sections +document.querySelectorAll('nav a').forEach(link => { + link.addEventListener('click', function (e) { + e.preventDefault(); + document.querySelectorAll('nav a').forEach(a => a.classList.remove('active')); + this.classList.add('active'); + document.querySelectorAll('.section').forEach(s => s.classList.remove('active')); + document.querySelector(this.getAttribute('href')).classList.add('active'); + // Hide emulator overlay if navigating + document.getElementById('emulator-controls').style.display = 'none'; + }); +}); + +// Placeholder: file upload (ROM import) +document.getElementById('file-upload').addEventListener('change', function () { + alert('ROM upload feature coming soon!'); +}); + +// Emulator controls overlay toggling (demo) +document.querySelectorAll('.play-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.getElementById('emulator-controls').style.display = 'block'; + // Show SX emulator bar if SX mode is enabled + if(document.body.classList.contains('sx-mode')) { + document.getElementById('sx-emulator-bar').style.display = 'flex'; + } else { + document.getElementById('sx-emulator-bar').style.display = 'none'; + } + }); +}); +document.getElementById('close-emulator').addEventListener('click', () => { + document.getElementById('emulator-controls').style.display = 'none'; +}); + +// Controller detection and testing +const controllerConnection = document.getElementById('controller-connection'); +const controllerDetails = document.getElementById('controller-details'); +const controllerList = document.getElementById('controller-list'); +const buttonGrid = document.getElementById('button-grid'); +const axisGrid = document.getElementById('axis-grid'); + +let lastGamepadStates = {}; + +function updateControllerStatus() { + const gamepads = navigator.getGamepads ? navigator.getGamepads() : []; + const connectedGamepads = []; + for (let i = 0; i < gamepads.length; i++) { + let gp = gamepads[i]; + if (gp && gp.connected) connectedGamepads.push(gp); + } + // Update status text + if (connectedGamepads.length > 0) { + controllerConnection.textContent = "Connected"; + controllerDetails.style.display = "block"; + controllerList.innerHTML = ''; + connectedGamepads.forEach((gp, idx) => { + let li = document.createElement('li'); + li.textContent = `${gp.id} (Index: ${gp.index})`; + controllerList.appendChild(li); + }); + // Show button and axis testers for first controller + showButtonTester(connectedGamepads[0]); + showAxisTester(connectedGamepads[0]); + } else { + controllerConnection.textContent = "Not Connected"; + controllerDetails.style.display = "none"; + buttonGrid.innerHTML = ''; + axisGrid.innerHTML = ''; + } +} + +function showButtonTester(gp) { + buttonGrid.innerHTML = ''; + gp.buttons.forEach((btn, idx) => { + const btnEl = document.createElement('button'); + btnEl.className = "controller-btn"; + btnEl.textContent = idx; + if (btn.pressed) btnEl.classList.add('active'); + btnEl.title = `Button ${idx}`; + buttonGrid.appendChild(btnEl); + }); +} + +function showAxisTester(gp) { + axisGrid.innerHTML = ''; + gp.axes.forEach((val, idx) => { + const axisRow = document.createElement('div'); + axisRow.className = 'axis-bar'; + axisRow.title = `Axis ${idx}: ${val.toFixed(2)}`; + // Axis indicator (centered, -1 to +1) + const indicator = document.createElement('div'); + indicator.className = 'axis-indicator'; + indicator.style.left = `${((val + 1) / 2) * 100}%`; + indicator.style.width = '8px'; + axisRow.appendChild(indicator); + // Label + const label = document.createElement('span'); + label.style.position = 'absolute'; + label.style.left = '8px'; + label.style.top = '14px'; + label.style.fontSize = '0.95em'; + label.style.color = '#fff9'; + label.textContent = `Axis ${idx}: ${val.toFixed(2)}`; + axisRow.appendChild(label); + axisGrid.appendChild(axisRow); + }); +} + +// Poll for gamepad status and button/axis +function pollGamepads() { + const gamepads = navigator.getGamepads ? navigator.getGamepads() : []; + let changed = false; + for (let i = 0; i < gamepads.length; i++) { + let gp = gamepads[i]; + if (gp && gp.connected) { + // Check if button or axis state changed + let last = lastGamepadStates[gp.index]; + if (!last || JSON.stringify(last.buttons) !== JSON.stringify(gp.buttons.map(b => b.pressed)) + || JSON.stringify(last.axes) !== JSON.stringify(gp.axes)) { + changed = true; + lastGamepadStates[gp.index] = { + buttons: gp.buttons.map(b => b.pressed), + axes: Array.from(gp.axes) + }; + } + } + } + if (changed) updateControllerStatus(); +} + +window.addEventListener("gamepadconnected", updateControllerStatus); +window.addEventListener("gamepaddisconnected", updateControllerStatus); +setInterval(() => { + pollGamepads(); +}, 150); + +// Initial call +updateControllerStatus(); + +// SX MODE TOGGLE LOGIC +const sxToggle = document.getElementById('sx-mode-toggle'); +const sxBanner = document.getElementById('sx-banner'); +const mainLogo = document.getElementById('main-logo'); + +if (sxToggle) { + // Restore from localStorage if previously set + if (localStorage.getItem('sxMode') === 'on') { + document.body.classList.add('sx-mode'); + if(sxBanner) sxBanner.style.display = 'flex'; + sxToggle.checked = true; + if (mainLogo) mainLogo.src = "xbox360-logo.svg"; + } + sxToggle.addEventListener('change', function() { + if (sxToggle.checked) { + document.body.classList.add('sx-mode'); + if(sxBanner) sxBanner.style.display = 'flex'; + localStorage.setItem('sxMode', 'on'); + if (mainLogo) mainLogo.src = "xbox360-logo.svg"; + if(document.getElementById('emulator-controls').style.display === 'block') { + document.getElementById('sx-emulator-bar').style.display = 'flex'; + } + } else { + document.body.classList.remove('sx-mode'); + if(sxBanner) sxBanner.style.display = 'none'; + localStorage.setItem('sxMode', 'off'); + if (mainLogo) mainLogo.src = "xbox-logo.svg"; + document.getElementById('sx-emulator-bar').style.display = 'none'; + } + }); +} \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/settings.svg b/xbox-emulator-web/xbox-emulator-web/settings.svg new file mode 100644 index 00000000..5f2e0c26 --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/styles.css b/xbox-emulator-web/xbox-emulator-web/styles.css new file mode 100644 index 00000000..65da8a05 --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/styles.css @@ -0,0 +1,318 @@ +:root { + --xbox-green: #107C10; + --xbox-dark: #23272a; + --xbox-light: #f0f4f8; + --xbox-grey: #333940; + --primary-font: 'Segoe UI', 'Arial', sans-serif; + --controller-btn: #2e8b57; + --controller-btn-active: #38d948; + --axis-bg: #13381a; + --axis-fg: #38d948; +} +html, body { + margin: 0; + padding: 0; + background: var(--xbox-dark); + color: var(--xbox-light); + font-family: var(--primary-font); + min-height: 100vh; +} +header { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--xbox-green); + padding: 0.5em 2em; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); +} +.logo { display: flex; align-items: center; } +.logo img { width: 40px; margin-right: 10px; } +.logo span { font-size: 1.5em; font-weight: bold; letter-spacing: 1px; } +nav a { + color: var(--xbox-light); + text-decoration: none; + margin-left: 2em; + font-weight: 500; + transition: color 0.2s; +} +nav a.active, nav a:hover { color: #fff; text-shadow: 0 0 5px #fff9; } +main { + max-width: 1200px; + margin: 2em auto; + padding: 0 1em; +} +.section { display: none; } +.section.active { display: block; } +.hero { + text-align: center; + margin-bottom: 3em; +} +.hero h1 { font-size: 2.5em; margin-bottom: 0.4em; } +.hero p { font-size: 1.2em; margin-bottom: 1.4em; } +.primary-btn { + background: var(--xbox-green); + color: #fff; + padding: 0.8em 2.2em; + border: none; + border-radius: 30px; + font-size: 1em; + font-weight: bold; + cursor: pointer; + box-shadow: 0 2px 8px #0006; + transition: background 0.2s; +} +.primary-btn:hover { background: #0e6a0e; } +.features { + display: flex; + justify-content: center; + gap: 2em; +} +.feature { + background: var(--xbox-grey); + padding: 1.5em; + border-radius: 12px; + text-align: center; + max-width: 300px; + box-shadow: 0 2px 8px #0004; +} +.feature img { width: 54px; margin-bottom: 0.5em; } +.feature h3 { margin: 0.3em 0 0.4em; } +.game-grid { + display: flex; + flex-wrap: wrap; + gap: 2em; +} +.game-card { + background: var(--xbox-grey); + border-radius: 12px; + overflow: hidden; + width: 220px; + box-shadow: 0 2px 8px #0003; + display: flex; + flex-direction: column; +} +.game-card img { width: 100%; height: 140px; object-fit: cover; } +.game-info { padding: 1em; } +.game-info h4 { margin: 0 0 0.4em; font-size: 1.1em; } +.play-btn { + background: var(--xbox-green); + color: #fff; + border: none; + border-radius: 18px; + padding: 0.5em 1.4em; + cursor: pointer; + margin-top: 0.6em; + font-weight: 600; +} +.add-game { + display: flex; + align-items: center; + justify-content: center; + min-width: 220px; + height: 180px; + background: #1e252b88; + border-radius: 12px; +} +.add-game button { + background: none; + border: 2px dashed var(--xbox-green); + color: var(--xbox-green); + font-size: 1.4em; + border-radius: 12px; + padding: 1em 2em; + cursor: pointer; +} +.settings-group { + margin-bottom: 2em; + background: var(--xbox-grey); + border-radius: 10px; + padding: 1.5em; +} +.settings-group h3 { margin-top: 0; } +.settings-group label { + display: block; + margin-bottom: 1em; + font-size: 1.1em; +} +input[type="range"] { width: 150px; } +footer { + text-align: center; + padding: 2em 0 1em 0; + color: #bbb; + font-size: 0.95em; +} +.cloud-gaming { + background: #232a34; + border-radius: 14px; + padding: 2em; + margin: 2em 0; + text-align: center; + box-shadow: 0 2px 8px #0004; +} +.cloud-btn { + display: inline-block; + background: var(--xbox-green); + color: #fff; + padding: 0.8em 2em; + font-size: 1.2em; + border-radius: 30px; + font-weight: bold; + margin-top: 1em; + text-decoration: none; + box-shadow: 0 2px 6px #0006; + transition: background 0.2s; +} +.cloud-btn:hover { background: #0e6a0e; } +.controller-status { + margin: 1.5em 0; + background: #222d2f; + color: #e0f7d4; + padding: 1em 2em; + border-radius: 10px; + text-align: center; + font-size: 1.1em; +} +.controller-details { + margin-top: 1em; + background: #293940; + color: #b2ffc3; + border-radius: 8px; + padding: 1em; + text-align: left; + display: inline-block; + min-width: 300px; +} +.controller-details h4 { + margin-top: 0; +} +#controller-list { + list-style: none; + padding: 0; + margin-bottom: 0.5em; +} +#controller-list li { + margin-bottom: 0.2em; + font-size: 1em; +} +.button-tester, .axis-tester { + margin-top: 0.7em; +} +#button-grid { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 0.7em; +} +.controller-btn { + background: var(--controller-btn); + border: none; + color: #fff; + width: 38px; + height: 38px; + border-radius: 50%; + margin: 0 3px 3px 0; + font-weight: bold; + font-size: 1em; + cursor: pointer; + outline: none; + transition: background 0.15s; + pointer-events: none; + opacity: 0.7; +} +.controller-btn.active { + background: var(--controller-btn-active); + color: #222; + opacity: 1; + box-shadow: 0 0 6px #38d948cc; +} +#axis-grid { + display: flex; + flex-direction: column; + gap: 3px; + margin-top: 0.6em; +} +.axis-bar { + background: var(--axis-bg); + height: 12px; + border-radius: 6px; + margin: 2px 0; + position: relative; + width: 160px; + box-shadow: 0 0 2px #111b; +} +.axis-indicator { + background: var(--axis-fg); + height: 12px; + border-radius: 6px; + position: absolute; + top: 0; + left: 50%; + width: 8px; + transition: left 0.1s; +} + +body.sx-mode { + background: linear-gradient(135deg, #08250a 0%, #222 100%); +} +body.sx-mode header { + background: #107c10; + border-bottom: 4px solid #77e300; +} +body.sx-mode .primary-btn, +body.sx-mode .cloud-btn, +body.sx-mode .play-btn { + background: #77e300; + color: #232; +} +body.sx-mode .primary-btn:hover, +body.sx-mode .cloud-btn:hover, +body.sx-mode .play-btn:hover { + background: #b2ff59; +} +body.sx-mode .controller-status, +body.sx-mode .settings-group, +body.sx-mode .feature, +body.sx-mode .game-card { + background: #162d1a; + color: #b2ffc3; +} + +.sx-banner { + position: fixed; + top: 0; + left: 50%; + transform: translateX(-50%); + background: #101e0e; + color: #b2ff59; + border: 2px solid #b2ff59; + border-radius: 0 0 16px 16px; + padding: 0.5em 2em; + z-index: 1000; + font-size: 1.18em; + font-weight: bold; + letter-spacing: 1px; + box-shadow: 0 6px 24px #000b; + display: flex; + align-items: center; + gap: 1em; +} +.sx-emulator-bar { + background: #101e0e; + color: #b2ff59; + border: 2px solid #b2ff59; + border-radius: 0 0 12px 12px; + padding: 0.3em 1em; + margin-bottom: 8px; + font-size: 1.05em; + font-weight: bold; + letter-spacing: 1px; + display: flex; + align-items: center; + gap: 0.8em; + justify-content: center; +} +@media (max-width: 900px) { + .features { flex-direction: column; align-items: center; } + .game-grid { flex-direction: column; align-items: center; } + .controller-details { min-width: 0; width: 98vw; } +} \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/README.md b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/README.md new file mode 100644 index 00000000..50dd3bc8 --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/README.md @@ -0,0 +1,40 @@ +# Xbox Emulator Web App + +## Overview +The Xbox Emulator Web App is a modern web application that allows users to play Xbox games on their Android devices. It features an intuitive interface, controller support, and various settings to enhance the gaming experience. + +## Project Structure +The project consists of the following files: + +- **index.html**: The main HTML document that structures the web page, including sections for home, library, settings, and help. +- **styles.css**: Contains the CSS styles for the web app, defining layout, colors, fonts, and responsive design. +- **main.js**: JavaScript logic for handling navigation, file uploads, controller detection, and toggling special modes. +- **xbox-logo.svg**: SVG image of the Xbox logo used in the header. +- **xbox360-logo.svg**: SVG image of the Xbox 360 logo used in SX mode. +- **controller.svg**: SVG image representing a game controller, featured in the app. +- **library.svg**: SVG image representing a game library, used in the features section. +- **settings.svg**: SVG image representing settings, used in the features section. +- **halo-cover.jpg**: JPEG image of the cover art for the game Halo, displayed in the game library. + +## Setup Instructions +1. Clone the repository: + ``` + git clone + ``` +2. Navigate to the project directory: + ``` + cd xbox-emulator-web + ``` +3. Open `index.html` in a web browser to view the application. + +## Usage +- Import game ROMs by clicking the "Import Game ROM" button on the home page. +- Navigate through the sections using the navigation links in the header. +- Adjust settings in the settings section, including graphics and audio options. +- Test controller functionality in the controller status section. + +## Contributing +Contributions are welcome! Please fork the repository and submit a pull request for any enhancements or bug fixes. + +## License +This project is for educational use only and is not affiliated with Microsoft. \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/controller.svg b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/controller.svg new file mode 100644 index 00000000..5f2e0c26 --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/controller.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/halo-cover.jpg b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/halo-cover.jpg new file mode 100644 index 00000000..aa46a7cf --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/halo-cover.jpg @@ -0,0 +1 @@ +This file is intentionally left blank. \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/index.html b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/index.html new file mode 100644 index 00000000..da28cf36 --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/index.html @@ -0,0 +1,189 @@ + + + + + + Xbox Emulator for Android + + + + + + +
+ + +
+ +
+ +
+
+

Play Xbox Games on Android

+

Experience classic and modern Xbox games right on your Android device. Import ROMs, save your progress, and personalize your gaming experience.

+ + +
+
+
+ Controller +

Intuitive Controls

+

On-screen or Bluetooth gamepad support, customizable layouts.

+
+
+ Library +

Game Library

+

Organize and launch your favorite games with cover art and details.

+
+
+ Settings +

Advanced Settings

+

Adjust graphics, audio, and performance options for the best gameplay.

+
+
+ + +
+

Play Xbox Game Pass Games

+

Want to play Game Pass games on your device? Launch Xbox Cloud Gaming with full controller support.

+ Launch Xbox Cloud Gaming +
+ + +
+ Controller: Not Connected + +
+
+ + +
+

Your Game Library

+
+ +
+ Halo Cover +
+

Halo: Combat Evolved

+

Action / Shooter

+ +
+
+ +
+ +
+
+
+ + + + + +
+

Settings

+
+

Graphics

+ + +
+
+

Audio

+ + +
+
+

Controller

+ + +
+ +
+

Special Modes

+ + This mode gives the UI an Xbox One & 360-inspired look! +
+ +
+ + +
+

Help & FAQ

+

How to Dump Your Original Xbox Discs for Emulation

+
    +
  1. You must own the original Xbox game disc.
  2. +
  3. Use a compatible DVD drive with your PC (not all drives work with Xbox discs).
  4. +
  5. Download the extract-xiso tool or similar to create an .iso or .xiso file.
  6. +
  7. Follow the instructions to dump the disc and save the ISO to your PC.
  8. +
  9. Transfer the ISO to your Android device and load it in the emulator.
  10. +
  11. Never download ISOs you do not own. This is illegal and against emulator policy.
  12. +
+

For more detailed guides, see xemu's official documentation.

+

Can I play Xbox Game Pass games in this emulator?

+

No. Xbox Game Pass games are protected by DRM and cannot be run in any emulator. Use the Cloud Gaming launcher above to play Game Pass games via streaming with controller support.

+

Controller Troubleshooting

+
    +
  • Make sure your Bluetooth controller is paired with your device before launching the emulator.
  • +
  • Supported controllers include Xbox, PlayStation, and most Android-compatible Bluetooth gamepads.
  • +
  • If your controller is not detected, try reconnecting or refreshing the page/app.
  • +
+
+
+
+

© 2025 Xbox Emulator. Not affiliated with Microsoft. For educational use only.

+
+ + + \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/library.svg b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/library.svg new file mode 100644 index 00000000..d6aa0721 --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/library.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/main.js b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/main.js new file mode 100644 index 00000000..40be257b --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/main.js @@ -0,0 +1,170 @@ +// Simple navigation logic for sections +document.querySelectorAll('nav a').forEach(link => { + link.addEventListener('click', function (e) { + e.preventDefault(); + document.querySelectorAll('nav a').forEach(a => a.classList.remove('active')); + this.classList.add('active'); + document.querySelectorAll('.section').forEach(s => s.classList.remove('active')); + document.querySelector(this.getAttribute('href')).classList.add('active'); + // Hide emulator overlay if navigating + document.getElementById('emulator-controls').style.display = 'none'; + }); +}); + +// Placeholder: file upload (ROM import) +document.getElementById('file-upload').addEventListener('change', function () { + alert('ROM upload feature coming soon!'); +}); + +// Emulator controls overlay toggling (demo) +document.querySelectorAll('.play-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.getElementById('emulator-controls').style.display = 'block'; + // Show SX emulator bar if SX mode is enabled + if(document.body.classList.contains('sx-mode')) { + document.getElementById('sx-emulator-bar').style.display = 'flex'; + } else { + document.getElementById('sx-emulator-bar').style.display = 'none'; + } + }); +}); +document.getElementById('close-emulator').addEventListener('click', () => { + document.getElementById('emulator-controls').style.display = 'none'; +}); + +// Controller detection and testing +const controllerConnection = document.getElementById('controller-connection'); +const controllerDetails = document.getElementById('controller-details'); +const controllerList = document.getElementById('controller-list'); +const buttonGrid = document.getElementById('button-grid'); +const axisGrid = document.getElementById('axis-grid'); + +let lastGamepadStates = {}; + +function updateControllerStatus() { + const gamepads = navigator.getGamepads ? navigator.getGamepads() : []; + const connectedGamepads = []; + for (let i = 0; i < gamepads.length; i++) { + let gp = gamepads[i]; + if (gp && gp.connected) connectedGamepads.push(gp); + } + // Update status text + if (connectedGamepads.length > 0) { + controllerConnection.textContent = "Connected"; + controllerDetails.style.display = "block"; + controllerList.innerHTML = ''; + connectedGamepads.forEach((gp, idx) => { + let li = document.createElement('li'); + li.textContent = `${gp.id} (Index: ${gp.index})`; + controllerList.appendChild(li); + }); + // Show button and axis testers for first controller + showButtonTester(connectedGamepads[0]); + showAxisTester(connectedGamepads[0]); + } else { + controllerConnection.textContent = "Not Connected"; + controllerDetails.style.display = "none"; + buttonGrid.innerHTML = ''; + axisGrid.innerHTML = ''; + } +} + +function showButtonTester(gp) { + buttonGrid.innerHTML = ''; + gp.buttons.forEach((btn, idx) => { + const btnEl = document.createElement('button'); + btnEl.className = "controller-btn"; + btnEl.textContent = idx; + if (btn.pressed) btnEl.classList.add('active'); + btnEl.title = `Button ${idx}`; + buttonGrid.appendChild(btnEl); + }); +} + +function showAxisTester(gp) { + axisGrid.innerHTML = ''; + gp.axes.forEach((val, idx) => { + const axisRow = document.createElement('div'); + axisRow.className = 'axis-bar'; + axisRow.title = `Axis ${idx}: ${val.toFixed(2)}`; + // Axis indicator (centered, -1 to +1) + const indicator = document.createElement('div'); + indicator.className = 'axis-indicator'; + indicator.style.left = `${((val + 1) / 2) * 100}%`; + indicator.style.width = '8px'; + axisRow.appendChild(indicator); + // Label + const label = document.createElement('span'); + label.style.position = 'absolute'; + label.style.left = '8px'; + label.style.top = '14px'; + label.style.fontSize = '0.95em'; + label.style.color = '#fff9'; + label.textContent = `Axis ${idx}: ${val.toFixed(2)}`; + axisRow.appendChild(label); + axisGrid.appendChild(axisRow); + }); +} + +// Poll for gamepad status and button/axis +function pollGamepads() { + const gamepads = navigator.getGamepads ? navigator.getGamepads() : []; + let changed = false; + for (let i = 0; i < gamepads.length; i++) { + let gp = gamepads[i]; + if (gp && gp.connected) { + // Check if button or axis state changed + let last = lastGamepadStates[gp.index]; + if (!last || JSON.stringify(last.buttons) !== JSON.stringify(gp.buttons.map(b => b.pressed)) + || JSON.stringify(last.axes) !== JSON.stringify(gp.axes)) { + changed = true; + lastGamepadStates[gp.index] = { + buttons: gp.buttons.map(b => b.pressed), + axes: Array.from(gp.axes) + }; + } + } + } + if (changed) updateControllerStatus(); +} + +window.addEventListener("gamepadconnected", updateControllerStatus); +window.addEventListener("gamepaddisconnected", updateControllerStatus); +setInterval(() => { + pollGamepads(); +}, 150); + +// Initial call +updateControllerStatus(); + +// SX MODE TOGGLE LOGIC +const sxToggle = document.getElementById('sx-mode-toggle'); +const sxBanner = document.getElementById('sx-banner'); +const mainLogo = document.getElementById('main-logo'); + +if (sxToggle) { + // Restore from localStorage if previously set + if (localStorage.getItem('sxMode') === 'on') { + document.body.classList.add('sx-mode'); + if(sxBanner) sxBanner.style.display = 'flex'; + sxToggle.checked = true; + if (mainLogo) mainLogo.src = "xbox360-logo.svg"; + } + sxToggle.addEventListener('change', function() { + if (sxToggle.checked) { + document.body.classList.add('sx-mode'); + if(sxBanner) sxBanner.style.display = 'flex'; + localStorage.setItem('sxMode', 'on'); + if (mainLogo) mainLogo.src = "xbox360-logo.svg"; + if(document.getElementById('emulator-controls').style.display === 'block') { + document.getElementById('sx-emulator-bar').style.display = 'flex'; + } + } else { + document.body.classList.remove('sx-mode'); + if(sxBanner) sxBanner.style.display = 'none'; + localStorage.setItem('sxMode', 'off'); + if (mainLogo) mainLogo.src = "xbox-logo.svg"; + document.getElementById('sx-emulator-bar').style.display = 'none'; + } + }); +} \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/settings.svg b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/settings.svg new file mode 100644 index 00000000..5f2e0c26 --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/styles.css b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/styles.css new file mode 100644 index 00000000..65da8a05 --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/styles.css @@ -0,0 +1,318 @@ +:root { + --xbox-green: #107C10; + --xbox-dark: #23272a; + --xbox-light: #f0f4f8; + --xbox-grey: #333940; + --primary-font: 'Segoe UI', 'Arial', sans-serif; + --controller-btn: #2e8b57; + --controller-btn-active: #38d948; + --axis-bg: #13381a; + --axis-fg: #38d948; +} +html, body { + margin: 0; + padding: 0; + background: var(--xbox-dark); + color: var(--xbox-light); + font-family: var(--primary-font); + min-height: 100vh; +} +header { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--xbox-green); + padding: 0.5em 2em; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); +} +.logo { display: flex; align-items: center; } +.logo img { width: 40px; margin-right: 10px; } +.logo span { font-size: 1.5em; font-weight: bold; letter-spacing: 1px; } +nav a { + color: var(--xbox-light); + text-decoration: none; + margin-left: 2em; + font-weight: 500; + transition: color 0.2s; +} +nav a.active, nav a:hover { color: #fff; text-shadow: 0 0 5px #fff9; } +main { + max-width: 1200px; + margin: 2em auto; + padding: 0 1em; +} +.section { display: none; } +.section.active { display: block; } +.hero { + text-align: center; + margin-bottom: 3em; +} +.hero h1 { font-size: 2.5em; margin-bottom: 0.4em; } +.hero p { font-size: 1.2em; margin-bottom: 1.4em; } +.primary-btn { + background: var(--xbox-green); + color: #fff; + padding: 0.8em 2.2em; + border: none; + border-radius: 30px; + font-size: 1em; + font-weight: bold; + cursor: pointer; + box-shadow: 0 2px 8px #0006; + transition: background 0.2s; +} +.primary-btn:hover { background: #0e6a0e; } +.features { + display: flex; + justify-content: center; + gap: 2em; +} +.feature { + background: var(--xbox-grey); + padding: 1.5em; + border-radius: 12px; + text-align: center; + max-width: 300px; + box-shadow: 0 2px 8px #0004; +} +.feature img { width: 54px; margin-bottom: 0.5em; } +.feature h3 { margin: 0.3em 0 0.4em; } +.game-grid { + display: flex; + flex-wrap: wrap; + gap: 2em; +} +.game-card { + background: var(--xbox-grey); + border-radius: 12px; + overflow: hidden; + width: 220px; + box-shadow: 0 2px 8px #0003; + display: flex; + flex-direction: column; +} +.game-card img { width: 100%; height: 140px; object-fit: cover; } +.game-info { padding: 1em; } +.game-info h4 { margin: 0 0 0.4em; font-size: 1.1em; } +.play-btn { + background: var(--xbox-green); + color: #fff; + border: none; + border-radius: 18px; + padding: 0.5em 1.4em; + cursor: pointer; + margin-top: 0.6em; + font-weight: 600; +} +.add-game { + display: flex; + align-items: center; + justify-content: center; + min-width: 220px; + height: 180px; + background: #1e252b88; + border-radius: 12px; +} +.add-game button { + background: none; + border: 2px dashed var(--xbox-green); + color: var(--xbox-green); + font-size: 1.4em; + border-radius: 12px; + padding: 1em 2em; + cursor: pointer; +} +.settings-group { + margin-bottom: 2em; + background: var(--xbox-grey); + border-radius: 10px; + padding: 1.5em; +} +.settings-group h3 { margin-top: 0; } +.settings-group label { + display: block; + margin-bottom: 1em; + font-size: 1.1em; +} +input[type="range"] { width: 150px; } +footer { + text-align: center; + padding: 2em 0 1em 0; + color: #bbb; + font-size: 0.95em; +} +.cloud-gaming { + background: #232a34; + border-radius: 14px; + padding: 2em; + margin: 2em 0; + text-align: center; + box-shadow: 0 2px 8px #0004; +} +.cloud-btn { + display: inline-block; + background: var(--xbox-green); + color: #fff; + padding: 0.8em 2em; + font-size: 1.2em; + border-radius: 30px; + font-weight: bold; + margin-top: 1em; + text-decoration: none; + box-shadow: 0 2px 6px #0006; + transition: background 0.2s; +} +.cloud-btn:hover { background: #0e6a0e; } +.controller-status { + margin: 1.5em 0; + background: #222d2f; + color: #e0f7d4; + padding: 1em 2em; + border-radius: 10px; + text-align: center; + font-size: 1.1em; +} +.controller-details { + margin-top: 1em; + background: #293940; + color: #b2ffc3; + border-radius: 8px; + padding: 1em; + text-align: left; + display: inline-block; + min-width: 300px; +} +.controller-details h4 { + margin-top: 0; +} +#controller-list { + list-style: none; + padding: 0; + margin-bottom: 0.5em; +} +#controller-list li { + margin-bottom: 0.2em; + font-size: 1em; +} +.button-tester, .axis-tester { + margin-top: 0.7em; +} +#button-grid { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 0.7em; +} +.controller-btn { + background: var(--controller-btn); + border: none; + color: #fff; + width: 38px; + height: 38px; + border-radius: 50%; + margin: 0 3px 3px 0; + font-weight: bold; + font-size: 1em; + cursor: pointer; + outline: none; + transition: background 0.15s; + pointer-events: none; + opacity: 0.7; +} +.controller-btn.active { + background: var(--controller-btn-active); + color: #222; + opacity: 1; + box-shadow: 0 0 6px #38d948cc; +} +#axis-grid { + display: flex; + flex-direction: column; + gap: 3px; + margin-top: 0.6em; +} +.axis-bar { + background: var(--axis-bg); + height: 12px; + border-radius: 6px; + margin: 2px 0; + position: relative; + width: 160px; + box-shadow: 0 0 2px #111b; +} +.axis-indicator { + background: var(--axis-fg); + height: 12px; + border-radius: 6px; + position: absolute; + top: 0; + left: 50%; + width: 8px; + transition: left 0.1s; +} + +body.sx-mode { + background: linear-gradient(135deg, #08250a 0%, #222 100%); +} +body.sx-mode header { + background: #107c10; + border-bottom: 4px solid #77e300; +} +body.sx-mode .primary-btn, +body.sx-mode .cloud-btn, +body.sx-mode .play-btn { + background: #77e300; + color: #232; +} +body.sx-mode .primary-btn:hover, +body.sx-mode .cloud-btn:hover, +body.sx-mode .play-btn:hover { + background: #b2ff59; +} +body.sx-mode .controller-status, +body.sx-mode .settings-group, +body.sx-mode .feature, +body.sx-mode .game-card { + background: #162d1a; + color: #b2ffc3; +} + +.sx-banner { + position: fixed; + top: 0; + left: 50%; + transform: translateX(-50%); + background: #101e0e; + color: #b2ff59; + border: 2px solid #b2ff59; + border-radius: 0 0 16px 16px; + padding: 0.5em 2em; + z-index: 1000; + font-size: 1.18em; + font-weight: bold; + letter-spacing: 1px; + box-shadow: 0 6px 24px #000b; + display: flex; + align-items: center; + gap: 1em; +} +.sx-emulator-bar { + background: #101e0e; + color: #b2ff59; + border: 2px solid #b2ff59; + border-radius: 0 0 12px 12px; + padding: 0.3em 1em; + margin-bottom: 8px; + font-size: 1.05em; + font-weight: bold; + letter-spacing: 1px; + display: flex; + align-items: center; + gap: 0.8em; + justify-content: center; +} +@media (max-width: 900px) { + .features { flex-direction: column; align-items: center; } + .game-grid { flex-direction: column; align-items: center; } + .controller-details { min-width: 0; width: 98vw; } +} \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/xbox-logo.svg b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/xbox-logo.svg new file mode 100644 index 00000000..5f2e0c26 --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/xbox-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/xbox360-logo.svg b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/xbox360-logo.svg new file mode 100644 index 00000000..1c5303f7 --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/xbox-emulator-web/xbox360-logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/xbox-logo.svg b/xbox-emulator-web/xbox-emulator-web/xbox-logo.svg new file mode 100644 index 00000000..0ed9ff99 --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/xbox-logo.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/xbox-emulator-web/xbox-emulator-web/xbox360-logo.svg b/xbox-emulator-web/xbox-emulator-web/xbox360-logo.svg new file mode 100644 index 00000000..ab172b4e --- /dev/null +++ b/xbox-emulator-web/xbox-emulator-web/xbox360-logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/xbox-emulator-web/xbox-logo.svg b/xbox-emulator-web/xbox-logo.svg new file mode 100644 index 00000000..0ed9ff99 --- /dev/null +++ b/xbox-emulator-web/xbox-logo.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/xbox-emulator-web/xbox360-logo.svg b/xbox-emulator-web/xbox360-logo.svg new file mode 100644 index 00000000..ab172b4e --- /dev/null +++ b/xbox-emulator-web/xbox360-logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file