├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── PKGBUILD ├── README.md ├── docs ├── images │ └── dumang-logo.png └── index.md ├── dumang_ctrl ├── __init__.py ├── dumang │ ├── __init__.py │ ├── common.py │ └── gui.py └── tools │ ├── __init__.py │ ├── config.py │ └── sync.py ├── mkdocs.yml ├── poetry.toml ├── pyproject.toml ├── systemd ├── dumang-sync-python.service ├── dumang-sync.install └── dumang-sync.service └── udev └── 51-dumang.rules /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: "v4.4.0" 4 | hooks: 5 | - id: check-case-conflict 6 | - id: check-merge-conflict 7 | - id: check-toml 8 | - id: check-yaml 9 | - id: end-of-file-fixer 10 | - id: trailing-whitespace 11 | 12 | - repo: https://github.com/pre-commit/mirrors-prettier 13 | rev: "v3.0.3" 14 | hooks: 15 | - id: prettier 16 | 17 | - repo: https://github.com/google/yapf 18 | rev: "v0.40.2" 19 | hooks: 20 | - id: yapf 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: setup 2 | setup: ## Install the poetry environment and install the pre-commit hooks 3 | @echo "🚀 Creating virtual environment using pyenv and poetry" 4 | @poetry install 5 | @poetry run pre-commit install 6 | 7 | .PHONY: shell 8 | shell: ## Activate the poetry environment 9 | @echo "🚀 Activating virtual environment using pyenv and poetry" 10 | @poetry shell 11 | 12 | .PHONY: check 13 | check: ## Run pre-commit checks. 14 | @echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry lock --check" 15 | @poetry check --lock 16 | @echo "🚀 Running pre-commit" 17 | @poetry run pre-commit run -a 18 | 19 | .PHONY: check-strict 20 | check-strict: ## Run code quality tools 21 | @echo "🚀 Static type checking: Running mypy" 22 | @poetry run mypy 23 | @echo "🚀 Linting code: unning ruff" 24 | @poetry run ruff dumang_ctrl 25 | 26 | .PHONY: build 27 | build: clean-build ## Build wheel file using poetry 28 | @echo "🚀 Creating wheel file" 29 | @poetry version $(shell git describe --tags --abbrev=0) 30 | @poetry build 31 | 32 | .PHONY: clean-build 33 | clean-build: ## clean build artifacts 34 | @rm -rf dist 35 | 36 | .PHONY: publish 37 | publish: ## publish a release to pypi. 38 | @echo "🚀 Publishing: Dry run." 39 | @poetry config pypi-token.pypi $(PYPI_TOKEN) 40 | @poetry publish --dry-run 41 | @echo "🚀 Publishing." 42 | @poetry publish 43 | 44 | .PHONY: publish-test 45 | publish-test: ## publish a release to pypi. 46 | @echo "🚀 Publishing: Dry run." 47 | @poetry publish -r test-pypi --dry-run 48 | @echo "🚀 Publishing." 49 | @poetry publish -r test-pypi 50 | 51 | .PHONY: build-and-publish 52 | build-and-publish: build publish ## Build and publish. 53 | 54 | .PHONY: docs 55 | docs: ## Build and serve the documentation 56 | @poetry run mkdocs serve 57 | 58 | .PHONY: help 59 | help: 60 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' 61 | 62 | .DEFAULT_GOAL := help 63 | -------------------------------------------------------------------------------- /PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: maarroyo 2 | _pkgname=dumang-ctrl 3 | pkgname=$_pkgname-git 4 | pkgver=VERSION 5 | pkgrel=1 6 | pkgdesc="DuMang DK6 Keyboard Programming Tools" 7 | arch=('any') 8 | url="https://github.com/mayanez/dumang-keyboard-ctrl" 9 | license=('GPL-3.0-or-later') 10 | depends=('python' 'libusb' 'qt6-base' 'hidapi' 'python-hidapi' 'python-pyqt6' 'python-pyyaml' 'python-libusb1' 'python-click') 11 | makedepends=( 12 | git 13 | make 14 | python-poetry 15 | python-installer 16 | ) 17 | provides=($_pkgname) 18 | conflicts=($_pkgname) 19 | source=($pkgname::git+https://github.com/mayanez/dumang-keyboard-ctrl.git) 20 | md5sums=('SKIP') 21 | 22 | pkgver() { 23 | cd "$pkgname" 24 | ( set -o pipefail 25 | git describe --long --abbrev=7 2>/dev/null | sed 's/\([^-]*-g\)/r\1/;s/-/./g' || 26 | printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short=7 HEAD)" 27 | ) 28 | } 29 | 30 | build() { 31 | cd $pkgname 32 | make setup 33 | make build 34 | } 35 | 36 | package() { 37 | cd $pkgname 38 | python -m installer --destdir="$pkgdir" dist/*.whl 39 | install -Dm644 udev/51-dumang.rules "$pkgdir/usr/lib/udev/rules.d/51-dumang.rules" || return 1 40 | install -Dm644 systemd/dumang-sync-python.service "$pkgdir/usr/lib/systemd/system/dumang-sync-python.service" || return 1 41 | install -Dm644 systemd/dumang-sync.service "$pkgdir/usr/lib/systemd/system/dumang-sync.service" || return 1 42 | install -vDm 644 LICENSE -t "$pkgdir/usr/share/licenses/$pkgname/" 43 | install=systemd/dumang-sync.install 44 | } 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Logo](docs/images/dumang-logo.png) 2 | 3 | # BeyondQ DuMang Keyboard Programming Tools 4 | 5 | ![GitHub](https://img.shields.io/github/license/mayanez/dumang-keyboard-ctrl) 6 | ![PyPI](https://img.shields.io/pypi/v/dumang-ctrl) 7 | ![MadeWithPython](https://img.shields.io/badge/Made%20with-Python-1f425f.svg) 8 | ![Maintained](https://img.shields.io/badge/Maintained%3F-yes-green.svg) 9 | 10 | This is an open-source toolset for use with the DuMang line of keyboards from [Beyond Q](http://www.beyondq.com/). 11 | 12 | These keyboards are fully programmable and support multiple layers. 13 | 14 | Supported OSes: 15 | 16 | ![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black) 17 | 18 | ![Mac OS X](https://img.shields.io/badge/mac%20os-000000?style=for-the-badge&logo=apple&logoColor=white) 19 | 20 | _NOTE:_ For correct functionality under Linux, you need to copy the udev file provided in this repo into the appropriate directory for you distro. You might then need to call `udevadm control --reload-rules` to reload the rules. 21 | 22 | Should you run into any problems please open an `Issue`. Hopefully I can help 😸 23 | 24 | ## Install 25 | 26 | ### Using PyPI 27 | 28 | $ pip install dumang-ctrl 29 | 30 | The PyPI package can be found at https://pypi.org/project/dumang-ctrl/ 31 | 32 | ### Using Arch Linux 33 | 34 | ![ArchLinux](https://img.shields.io/badge/Arch_Linux-1793D1?style=for-the-badge&logo=arch-linux&logoColor=white) 35 | 36 | Using your AUR helper of choice install the `dumang-ctrl-git` [package](https://aur.archlinux.org/packages/dumang-ctrl-git). 37 | 38 | ## Usage 39 | 40 | Please refer to the [Github Pages](https://mayanez.github.io/dumang-keyboard-ctrl/) or `docs/`. 41 | 42 | ## Build (for Development) 43 | 44 | $ make setup 45 | $ make build 46 | 47 | You may activate the virtualenv by running `make shell`. 48 | -------------------------------------------------------------------------------- /docs/images/dumang-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayanez/dumang-keyboard-ctrl/f754a925bacde42bc5b96b2bbc5be984cea84d95/docs/images/dumang-logo.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ![Logo](images/dumang-logo.png) 2 | 3 | # BeyondQ DuMang Keyboard Programming Tools 4 | 5 | ![GitHub](https://img.shields.io/github/license/mayanez/dumang-keyboard-ctrl) 6 | ![PyPI](https://img.shields.io/pypi/v/dumang-ctrl) 7 | ![MadeWithPython](https://img.shields.io/badge/Made%20with-Python-1f425f.svg) 8 | ![Maintained](https://img.shields.io/badge/Maintained%3F-yes-green.svg) 9 | 10 | ## Installation 11 | 12 | Please refer to the [README.md](https://github.com/mayanez/dumang-keyboard-ctrl/) in the repo for more details. 13 | 14 | ## Sync Tool 15 | 16 | This tool is a daemon process that can be used to sync the two keyboard halves for layer functionality. 17 | 18 | You may want to setup this tool to run at startup. Depending on your distribution `systemd` is a likely solution. See for an example. If you installed via the AUR package this is setup automatically. 19 | 20 | _NOTE:_ libusb does NOT raise a `HOTPLUG_EVENT_DEVICE_LEFT` event on suspend (at least on Linux). This means the sync script doesn't know the keyboard handles are invalid upon resuming. To address this, two `systemd` scripts can be used. See the `systemd/` directory in the repo. The AUR package takes this approach. 21 | 22 | ### Run 23 | 24 | $ dumang-sync 25 | 26 | or 27 | 28 | $ python -m dumang_ctrl.tools.sync 29 | 30 | ## Programming Tool 31 | 32 | This tool provides the ability to configure the keys on your keyboard. 33 | 34 | ### Run 35 | 36 | $ dumang-config --help 37 | 38 | or 39 | 40 | $ python -m dumang_ctrl.tools.config --help 41 | 42 | ### Usage 43 | 44 | #### dump 45 | 46 | The first thing you'll want to do is `dump` your current configuration: 47 | 48 | $ dumang-config dump --format=yaml > config.yml 49 | $ dumang-config dump --format=json > config.json 50 | 51 | The configuration is a file describing each _Board_ half, the attached _Key Modules_, and the keycodes associated with each _Layer_ or _Macro_. Each _Board_ and _Key Module_ will have an associated `serial` that is embedded in the hardware. Each _Key Module_ can be assigned up to four layers (eg. `layer_0` - `layer_3`), one macro, and one color. 52 | 53 | The following is an example of a YAML configuration file: 54 | 55 | ```yml 56 | - board: 57 | serial: "DEADBEEF" # as hex string 58 | nkro: true # or false. When false at most 6 keys can be pressed at a given time. 59 | report_rate: 1000 # Allowed values [100, 125, 200, 250, 333, 500, 1000] 60 | keys: 61 | - key: 62 | serial: "28287602" # as hex string 63 | layer_0: BACKSLASH # Refer to HID Keycodes in dumang_ctrl/dumang/common.py 64 | layer_1: MACRO 65 | layer_2: TRANSPARENT 66 | layer_3: TRANSPARENT 67 | color: "FF0000" # as hex string. Note that the LED can only represet 4-bits for each color channel [0x00 - 0x0F]. 68 | macro: 69 | # A maximum of 69 entries are allowed. 70 | - type: KEYDOWN # Valid types: [KEYDOWN, KEYUP, WAIT_KEYUP] 71 | key: A 72 | delay_ms: 10 # Delay Range: [10 - 65280] 73 | - type: KEYUP 74 | key: A 75 | delay_ms: 10 76 | ``` 77 | 78 | #### inspect 79 | 80 | Because of the unique reconfigurability of this keyboard, it can be difficult to associated a given `serial` with a _Board_ or _Key Module_. To make this process convenient you can use the `inspect` command to launch a GUI to identify this information. 81 | 82 | $ dumang-config inspect 83 | 84 | The GUI will allow you to inspect your current configuration, but most importantly, when your mouse hovers over a particular _Key Module_ on a given _Board_ the LED on the _Key Module_ will begin to flash. This allows you to identify which `serial` corresponds to a given _Key Module_ & _Board_. 85 | 86 | #### load 87 | 88 | The `load` command does the opposite of the `dump` command and allows one to program _Key Modules_. 89 | 90 | $ dumang-config load --format=yaml 91 | $ dumang-config load --format=json 92 | 93 | This will only load the configuration onto _Key Modules_ that are specified in the file, all other keys will be unaffected. 94 | -------------------------------------------------------------------------------- /dumang_ctrl/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | import logging 3 | import sys 4 | 5 | pkgmetadata = importlib.metadata.metadata(__package__) 6 | version = pkgmetadata['Version'] 7 | description = pkgmetadata['Summary'] 8 | url = pkgmetadata['Home-page'] 9 | 10 | logging.basicConfig(stream=sys.stderr, level=logging.INFO) 11 | -------------------------------------------------------------------------------- /dumang_ctrl/dumang/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayanez/dumang-keyboard-ctrl/f754a925bacde42bc5b96b2bbc5be984cea84d95/dumang_ctrl/dumang/__init__.py -------------------------------------------------------------------------------- /dumang_ctrl/dumang/common.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import queue 3 | import sys 4 | import threading 5 | from collections import deque 6 | 7 | import hid 8 | import usb1 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | BOARD_INFO_REQUEST_CMD = 0x30 13 | BOARD_INFO_RESPONSE_CMD = 0x31 14 | DKM_INFO_REQUEST_CMD = 0x06 15 | DKM_INFO_RESPONSE_CMD = 0x07 16 | BOARD_SYNC_CMD = 0x46 17 | KEY_UP_CMD = 0x3E 18 | KEY_DOWN_CMD = 0x3C 19 | LIGHT_PULSE_CMD = 0x2A 20 | DKM_REPORT_REQUEST_CMD = 0x04 21 | DKM_REPORT_RESPONSE_CMD = 0x05 22 | DKM_ADDED_CMD = 0x18 23 | DKM_REMOVED_CMD = 0x1A 24 | DKM_CONFIGURE_CMD = 0x09 25 | MACRO_REPORT_REQUEST_CMD = 0x42 26 | MACRO_REPORT_RESPONSE_CMD = 0x43 27 | MACRO_CONFIGURE_CMD = 0x40 28 | NKRO_CONFIGURE_CMD = 0x44 29 | DKM_COLOR_REQUEST_CMD = 0x2C 30 | DKM_COLOR_RESPONSE_CMD = 0x2D 31 | DKM_COLOR_CONFIGURE_CMD = 0x2E 32 | REPORT_RATE_CONFIGURE_CMD = 0x34 33 | 34 | MACRO_MIN_IDX = 0x05 35 | MACRO_MAX_IDX = 0x45 36 | MACRO_MIN_DELAY_MS = 10 37 | MACRO_MAX_DELAY_MS = 255 * 255 + 255 38 | 39 | DEFAULT_NKRO_VALUE = True 40 | DEFAULT_REPORT_RATE = 1000 41 | 42 | VENDOR_ID = 0x0483 43 | PRODUCT_ID = 0x5710 44 | KBD_1_ID = 0x25 45 | KBD_2_ID = 0x0D 46 | 47 | # NOTE: Unless configured in "overload" mode (which is considered unstable), 48 | # according to documentation, max amount of DKM per board is 44. 49 | MAX_KEYS = 44 50 | MAX_LAYERS = 4 51 | UNKNOWN_MACROTYPE_STR = "UNKNOWN" 52 | 53 | NOTIFY_STATUS_READY = "ready" 54 | NOTIFY_STATUS_WAIT = "wait" 55 | NOTIFY_STATUS_STOP = "stop" 56 | 57 | 58 | class Keycode: 59 | """These are HID Keycodes and can be found here: https://usb.org/sites/default/files/hut1_3_0.pdf""" 60 | 61 | MACRO = 0x03 62 | """Macro""" 63 | 64 | A = 0x04 65 | """``a`` and ``A``""" 66 | B = 0x05 67 | """``b`` and ``B``""" 68 | C = 0x06 69 | """``c`` and ``C``""" 70 | D = 0x07 71 | """``d`` and ``D``""" 72 | E = 0x08 73 | """``e`` and ``E``""" 74 | F = 0x09 75 | """``f`` and ``F``""" 76 | G = 0x0A 77 | """``g`` and ``G``""" 78 | H = 0x0B 79 | """``h`` and ``H``""" 80 | I = 0x0C 81 | """``i`` and ``I``""" 82 | J = 0x0D 83 | """``j`` and ``J``""" 84 | K = 0x0E 85 | """``k`` and ``K``""" 86 | L = 0x0F 87 | """``l`` and ``L``""" 88 | M = 0x10 89 | """``m`` and ``M``""" 90 | N = 0x11 91 | """``n`` and ``N``""" 92 | O = 0x12 93 | """``o`` and ``O``""" 94 | P = 0x13 95 | """``p`` and ``P``""" 96 | Q = 0x14 97 | """``q`` and ``Q``""" 98 | R = 0x15 99 | """``r`` and ``R``""" 100 | S = 0x16 101 | """``s`` and ``S``""" 102 | T = 0x17 103 | """``t`` and ``T``""" 104 | U = 0x18 105 | """``u`` and ``U``""" 106 | V = 0x19 107 | """``v`` and ``V``""" 108 | W = 0x1A 109 | """``w`` and ``W``""" 110 | X = 0x1B 111 | """``x`` and ``X``""" 112 | Y = 0x1C 113 | """``y`` and ``Y``""" 114 | Z = 0x1D 115 | """``z`` and ``Z``""" 116 | 117 | ONE = 0x1E 118 | """``1`` and ``!``""" 119 | TWO = 0x1F 120 | """``2`` and ``@``""" 121 | THREE = 0x20 122 | """``3`` and ``#``""" 123 | FOUR = 0x21 124 | """``4`` and ``$``""" 125 | FIVE = 0x22 126 | """``5`` and ``%``""" 127 | SIX = 0x23 128 | """``6`` and ``^``""" 129 | SEVEN = 0x24 130 | """``7`` and ``&``""" 131 | EIGHT = 0x25 132 | """``8`` and ``*``""" 133 | NINE = 0x26 134 | """``9`` and ``(``""" 135 | ZERO = 0x27 136 | """``0`` and ``)``""" 137 | ENTER = 0x28 138 | """Enter (Return)""" 139 | RETURN = ENTER 140 | """Alias for ``ENTER``""" 141 | ESCAPE = 0x29 142 | """Escape""" 143 | BACKSPACE = 0x2A 144 | """Delete backward (Backspace)""" 145 | TAB = 0x2B 146 | """Tab and Backtab""" 147 | SPACEBAR = 0x2C 148 | """Spacebar""" 149 | SPACE = SPACEBAR 150 | """Alias for SPACEBAR""" 151 | MINUS = 0x2D 152 | """``-` and ``_``""" 153 | EQUALS = 0x2E 154 | """``=` and ``+``""" 155 | LEFT_BRACKET = 0x2F 156 | """``[`` and ``{``""" 157 | RIGHT_BRACKET = 0x30 158 | """``]`` and ``}``""" 159 | BACKSLASH = 0x31 160 | r"""``\`` and ``|``""" 161 | POUND = 0x32 162 | """``#`` and ``~`` (Non-US keyboard)""" 163 | SEMICOLON = 0x33 164 | """``;`` and ``:``""" 165 | QUOTE = 0x34 166 | """``'`` and ``"``""" 167 | GRAVE_ACCENT = 0x35 168 | r""":literal:`\`` and ``~``""" 169 | COMMA = 0x36 170 | """``,`` and ``<``""" 171 | PERIOD = 0x37 172 | """``.`` and ``>``""" 173 | FORWARD_SLASH = 0x38 174 | """``/`` and ``?``""" 175 | 176 | CAPS_LOCK = 0x39 177 | """Caps Lock""" 178 | 179 | F1 = 0x3A 180 | """Function key F1""" 181 | F2 = 0x3B 182 | """Function key F2""" 183 | F3 = 0x3C 184 | """Function key F3""" 185 | F4 = 0x3D 186 | """Function key F4""" 187 | F5 = 0x3E 188 | """Function key F5""" 189 | F6 = 0x3F 190 | """Function key F6""" 191 | F7 = 0x40 192 | """Function key F7""" 193 | F8 = 0x41 194 | """Function key F8""" 195 | F9 = 0x42 196 | """Function key F9""" 197 | F10 = 0x43 198 | """Function key F10""" 199 | F11 = 0x44 200 | """Function key F11""" 201 | F12 = 0x45 202 | """Function key F12""" 203 | 204 | PRINT_SCREEN = 0x46 205 | """Print Screen (SysRq)""" 206 | SCROLL_LOCK = 0x47 207 | """Scroll Lock""" 208 | PAUSE = 0x48 209 | """Pause (Break)""" 210 | 211 | INSERT = 0x49 212 | """Insert""" 213 | HOME = 0x4A 214 | """Home (often moves to beginning of line)""" 215 | PAGE_UP = 0x4B 216 | """Go back one page""" 217 | DELETE = 0x4C 218 | """Delete forward""" 219 | END = 0x4D 220 | """End (often moves to end of line)""" 221 | PAGE_DOWN = 0x4E 222 | """Go forward one page""" 223 | 224 | RIGHT_ARROW = 0x4F 225 | """Move the cursor right""" 226 | LEFT_ARROW = 0x50 227 | """Move the cursor left""" 228 | DOWN_ARROW = 0x51 229 | """Move the cursor down""" 230 | UP_ARROW = 0x52 231 | """Move the cursor up""" 232 | 233 | KEYPAD_NUMLOCK = 0x53 234 | """Num Lock (Clear on Mac)""" 235 | KEYPAD_FORWARD_SLASH = 0x54 236 | """Keypad ``/``""" 237 | KEYPAD_ASTERISK = 0x55 238 | """Keypad ``*``""" 239 | KEYPAD_MINUS = 0x56 240 | """Keyapd ``-``""" 241 | KEYPAD_PLUS = 0x57 242 | """Keypad ``+``""" 243 | KEYPAD_ENTER = 0x58 244 | """Keypad Enter""" 245 | KEYPAD_ONE = 0x59 246 | """Keypad ``1`` and End""" 247 | KEYPAD_TWO = 0x5A 248 | """Keypad ``2`` and Down Arrow""" 249 | KEYPAD_THREE = 0x5B 250 | """Keypad ``3`` and PgDn""" 251 | KEYPAD_FOUR = 0x5C 252 | """Keypad ``4`` and Left Arrow""" 253 | KEYPAD_FIVE = 0x5D 254 | """Keypad ``5``""" 255 | KEYPAD_SIX = 0x5E 256 | """Keypad ``6`` and Right Arrow""" 257 | KEYPAD_SEVEN = 0x5F 258 | """Keypad ``7`` and Home""" 259 | KEYPAD_EIGHT = 0x60 260 | """Keypad ``8`` and Up Arrow""" 261 | KEYPAD_NINE = 0x61 262 | """Keypad ``9`` and PgUp""" 263 | KEYPAD_ZERO = 0x62 264 | """Keypad ``0`` and Ins""" 265 | KEYPAD_PERIOD = 0x63 266 | """Keypad ``.`` and Del""" 267 | KEYPAD_BACKSLASH = 0x64 268 | """Keypad ``\\`` and ``|`` (Non-US)""" 269 | 270 | APPLICATION = 0x65 271 | """Application: also known as the Menu key (Windows)""" 272 | POWER = 0x66 273 | """Power (Mac)""" 274 | KEYPAD_EQUALS = 0x67 275 | """Keypad ``=`` (Mac)""" 276 | F13 = 0x68 277 | """Function key F13 (Mac)""" 278 | F14 = 0x69 279 | """Function key F14 (Mac)""" 280 | F15 = 0x6A 281 | """Function key F15 (Mac)""" 282 | F16 = 0x6B 283 | """Function key F16 (Mac)""" 284 | F17 = 0x6C 285 | """Function key F17 (Mac)""" 286 | F18 = 0x6D 287 | """Function key F18 (Mac)""" 288 | F19 = 0x6E 289 | """Function key F19 (Mac)""" 290 | 291 | LEFT_CONTROL = 0xE0 292 | """Control modifier left of the spacebar""" 293 | CONTROL = LEFT_CONTROL 294 | """Alias for LEFT_CONTROL""" 295 | LEFT_SHIFT = 0xE1 296 | """Shift modifier left of the spacebar""" 297 | SHIFT = LEFT_SHIFT 298 | """Alias for LEFT_SHIFT""" 299 | LEFT_ALT = 0xE2 300 | """Alt modifier left of the spacebar""" 301 | ALT = LEFT_ALT 302 | """Alias for LEFT_ALT; Alt is also known as Option (Mac)""" 303 | OPTION = ALT 304 | """Labeled as Option on some Mac keyboards""" 305 | LEFT_GUI = 0xE3 306 | """GUI modifier left of the spacebar""" 307 | GUI = LEFT_GUI 308 | """Alias for LEFT_GUI; GUI is also known as the Windows key, Command (Mac), or Meta""" 309 | WINDOWS = GUI 310 | """Labeled with a Windows logo on Windows keyboards""" 311 | COMMAND = GUI 312 | """Labeled as Command on Mac keyboards, with a clover glyph""" 313 | RIGHT_CONTROL = 0xE4 314 | """Control modifier right of the spacebar""" 315 | RIGHT_SHIFT = 0xE5 316 | """Shift modifier right of the spacebar""" 317 | RIGHT_ALT = 0xE6 318 | """Alt modifier right of the spacebar""" 319 | RIGHT_GUI = 0xE7 320 | """GUI modifier right of the spacebar""" 321 | 322 | LAYER_0 = 0xD0 323 | """Change to keyboard Layer 0""" 324 | LAYER_1 = 0xD1 325 | """Change to keyboard Layer 1""" 326 | LAYER_2 = 0xD2 327 | """Change to keyboard Layer 2""" 328 | LAYER_3 = 0xD3 329 | """Change to keyboard Layer 3""" 330 | 331 | LAYER_TOGGLE_0 = 0xD4 332 | """Change while pressing to keyboard Layer 0""" 333 | LAYER_TOGGLE_1 = 0xD5 334 | """Change while pressing to keyboard Layer 1""" 335 | LAYER_TOGGLE_2 = 0xD6 336 | """Change while pressing to keyboard Layer 2""" 337 | LAYER_TOGGLE_3 = 0xD7 338 | """Change while pressing to keyboard Layer 3""" 339 | 340 | LAYER_KEY_0 = 0xDC 341 | """Change when long pressing to keyboard Layer 0""" 342 | LAYER_KEY_1 = 0xDD 343 | """Change when long pressing to keyboard Layer 1""" 344 | LAYER_KEY_2 = 0xDE 345 | """Change when long pressing to keyboard Layer 2""" 346 | LAYER_KEY_3 = 0xDF 347 | """Change when long pressing to keyboard Layer 3""" 348 | 349 | LEFT_MOUSE_CLICK = 0xF6 350 | """Left mouse click""" 351 | RIGHT_MOUSE_CLICK = 0xF7 352 | """Right mouse click""" 353 | MIDDLE_MOUSE_CLICK = 0xF8 354 | """Middle mouse click""" 355 | 356 | TRANSPARENT = 0xFF 357 | """Transparent""" 358 | 359 | DISABLED = 0xFE 360 | """Disabled""" 361 | 362 | def __init__(self, keycode): 363 | self.keycode = keycode 364 | self.keystr = [] 365 | 366 | for attribute in Keycode.__dict__: 367 | if attribute[:2] != "__": 368 | value = getattr(Keycode, attribute) 369 | if not callable(value) and self.keycode == value: 370 | self.keystr.append(attribute) 371 | if not self.keystr: 372 | self.keystr = [f"UNKNOWN_{self.keycode:02X}"] 373 | 374 | def __lt__(self, other): 375 | return self.keycode < other.keycode 376 | 377 | def __eq__(self, other): 378 | return self.keycode == other.keycode 379 | 380 | def __hash__(self): 381 | return hash(self.keycode) 382 | 383 | @classmethod 384 | def fromstr(cls, keystr): 385 | for attribute in Keycode.__dict__: 386 | if attribute[:2] != "__": 387 | value = getattr(Keycode, attribute) 388 | if not callable(value) and keystr == attribute: 389 | return cls(value) 390 | 391 | @classmethod 392 | def keys(cls): 393 | result = [] 394 | for attribute in Keycode.__dict__: 395 | if attribute[:2] != "__": 396 | value = getattr(Keycode, attribute) 397 | if not callable(value): 398 | result.append(attribute) 399 | return result 400 | 401 | def encode(self): 402 | return self.keycode 403 | 404 | def __str__(self): 405 | # NOTE: The first item in the list 406 | # will be used. This is determined by the definition 407 | # order in this class. 408 | return self.keystr[0] 409 | 410 | def __repr__(self): 411 | return "/".join(self.keystr) 412 | 413 | 414 | class MacroType: 415 | KEYDOWN = 0x01 416 | KEYUP = 0x02 417 | WAIT_KEYUP = 0x04 418 | """Used to define Split Section Macros (ie. execute part 1 on key down and part 2 on key up)""" 419 | 420 | def __init__(self, type_): 421 | self.type = type_ 422 | self.typestr = UNKNOWN_MACROTYPE_STR 423 | for attribute in MacroType.__dict__: 424 | if attribute[:2] != "__": 425 | value = getattr(MacroType, attribute) 426 | if not callable(value) and self.type == value: 427 | self.typestr = attribute 428 | if self.typestr is UNKNOWN_MACROTYPE_STR: 429 | self.typestr = f"UNKNOWN_{self.type:02X}" 430 | 431 | @classmethod 432 | def fromstr(cls, typestr): 433 | for attribute in MacroType.__dict__: 434 | if attribute[:2] != "__": 435 | value = getattr(MacroType, attribute) 436 | if not callable(value) and typestr == attribute: 437 | return cls(value) 438 | 439 | def __eq__(self, other): 440 | return self.type == other.type 441 | 442 | def __repr__(self): 443 | return self.typestr 444 | 445 | 446 | class Macro: 447 | 448 | def __init__(self, keycode, idx, type, delay): 449 | self.keycode = Keycode.fromstr(keycode) if not isinstance( 450 | keycode, Keycode) else keycode 451 | self.idx = idx 452 | self.type = MacroType.fromstr(type) if not isinstance( 453 | type, MacroType) else type 454 | if delay < MACRO_MIN_DELAY_MS: 455 | self.delay = MACRO_MIN_DELAY_MS 456 | logger.warning( 457 | f"Cannot set macro delay less than {MACRO_MIN_DELAY_MS}. Setting to {MACRO_MIN_DELAY_MS}." 458 | ) 459 | if delay > MACRO_MAX_DELAY_MS: 460 | self.delay = MACRO_MAX_DELAY_MS 461 | logger.warning( 462 | f"Cannot set macro delay less than {MACRO_MAX_DELAY_MS}. Setting to {MACRO_MAX_DELAY_MS}." 463 | ) 464 | else: 465 | self.delay = delay 466 | 467 | def topacket(self, key): 468 | return MacroConfigurePacket(key, self.idx, self.type, self.keycode, 469 | self.delay) 470 | 471 | @classmethod 472 | def frompacket(cls, packet): 473 | if not isinstance(packet, MacroReportResponsePacket): 474 | logger.error("Cannot create class from this packet type!") 475 | return None 476 | return cls(packet.keycode, packet.idx, packet.type, packet.delay) 477 | 478 | def __eq__(self, other): 479 | return (self.keycode, self.idx, self.type, 480 | self.delay) == (other.keycode, other.idx, other.type, 481 | other.delay) 482 | 483 | def __repr__(self): 484 | return f"{self.keycode}/{self.idx}/{self.type}/{self.delay}" 485 | 486 | 487 | class Job(threading.Thread): 488 | 489 | def __init__(self, **kwargs): 490 | try: 491 | args = kwargs["args"] 492 | except KeyError: 493 | args = [] 494 | 495 | super().__init__( 496 | target=kwargs["target"], args=args, daemon=kwargs["daemon"]) 497 | self.shutdown_flag = threading.Event() 498 | self.started = False 499 | 500 | def run(self): 501 | while not self.shutdown_flag.is_set(): 502 | self._target(*self._args, **self._kwargs) 503 | logger.debug("Thread Killed") 504 | 505 | def start(self): 506 | if not self.started: 507 | self.started = True 508 | super().start() 509 | else: 510 | logger.debug("Thread previously started") 511 | 512 | def stop(self): 513 | self.shutdown_flag.set() 514 | 515 | 516 | class JobKiller: 517 | 518 | def __init__(self): 519 | self.init = True 520 | 521 | 522 | class DuMangKeyModule: 523 | 524 | def __init__(self, 525 | key, 526 | layer_keycodes=None, 527 | serial=None, 528 | color=None, 529 | version=None): 530 | assert isinstance(key, int) 531 | self.key = key 532 | self.layer_keycodes = layer_keycodes 533 | self.serial = serial 534 | self.macro = [] 535 | self.color = color 536 | self.version = version 537 | 538 | def __lt__(self, other): 539 | return self.key < other.key 540 | 541 | def __eq__(self, other): 542 | return self.key == other.key 543 | 544 | def __hash__(self): 545 | return hash(self.key) 546 | 547 | def encode(self): 548 | return self.key 549 | 550 | def __repr__(self): 551 | return f"{self.key:02X}" if isinstance(self.key, int) else f"{self.key}" 552 | 553 | 554 | class DuMangBoard: 555 | READ_TIMEOUT_MS = 50 556 | 557 | def __init__(self, serial, handle): 558 | self.serial = serial 559 | self.handle = handle 560 | self._keys_initialized = False 561 | self._configured_keys = {} 562 | self.send_q = queue.Queue() 563 | self.recv_q = queue.Queue() 564 | self.should_stop = False 565 | self._initialize() 566 | 567 | def _initialize(self): 568 | # NOTE: Because threads aren't started yet, it is important, 569 | # to use write/read_packet(). 570 | self.write_packet(BoardInfoRequestPacket()) 571 | p = self.read_packet() 572 | 573 | if isinstance(p, BoardInfoResponsePacket): 574 | self.nkro = p.nkro 575 | self.report_rate = p.report_rate 576 | self.version = p.version 577 | else: 578 | self.nkro = DEFAULT_NKRO_VALUE 579 | self.report_rate = DEFAULT_REPORT_RATE 580 | 581 | def write(self, rawbytes): 582 | self.handle.write(rawbytes) 583 | 584 | def read(self): 585 | try: 586 | # NOTE: Needs to be non-blocking so thread can be killed atm. 587 | # Ideally I prefer it to be blocking. 588 | return self.handle.read(64, timeout_ms=DuMangBoard.READ_TIMEOUT_MS) 589 | except: 590 | return None 591 | 592 | def put(self, v): 593 | self.send_q.put(v) 594 | 595 | def get(self): 596 | return self.recv_q.get() 597 | 598 | def close(self): 599 | # NOTE: A hacky way of verifying if the handle is still valid. 600 | # I wonder if there is a cleaner way to do this. 601 | valid = True 602 | 603 | try: 604 | self.handle.read(64, timeout_ms=DuMangBoard.READ_TIMEOUT_MS) 605 | except OSError: 606 | valid = False 607 | except ValueError: 608 | valid = False 609 | 610 | if valid: 611 | self.handle.close() 612 | 613 | def read_packet(self): 614 | d = self.read() 615 | return DuMangPacket.parse(d) 616 | 617 | def write_packet(self, p): 618 | self.write(p.encode()) 619 | 620 | def kill_threads(self): 621 | self.send_q.put(JobKiller()) 622 | self.recv_q.put(JobKiller()) 623 | self.should_stop = True 624 | 625 | def receive_thread(self): 626 | p = self.read_packet() 627 | 628 | if p: 629 | logger.debug(p) 630 | self.recv_q.put(p) 631 | 632 | if self.should_stop: 633 | sys.exit(0) 634 | 635 | def send_thread(self): 636 | p = self.send_q.get() 637 | 638 | # NOTE: Allows for thread to be killed with blocking queue 639 | if isinstance(p, JobKiller): 640 | sys.exit(0) 641 | 642 | self.write_packet(p) 643 | 644 | def _handle_dkm_reports(self): 645 | pending = 0 646 | for k in range(MAX_KEYS): 647 | self.put(DKMReportRequestPacket(k)) 648 | pending += 1 649 | 650 | while pending > 0: 651 | p = self.get() 652 | if isinstance(p, DKMReportResponsePacket): 653 | pending -= 1 654 | if any([kc.keycode != 0 for kc in p.layer_keycodes.values()]): 655 | # NOTE: We add the layer_keycodes to the DKM 656 | p.key.layer_keycodes = p.layer_keycodes 657 | self._configured_keys[p.serial] = DuMangKeyModule( 658 | p.key.key, p.layer_keycodes, p.serial) 659 | 660 | def _handle_dkm_macros(self): 661 | for _, dkm in self._configured_keys.items(): 662 | pending = 0 663 | if any([ 664 | kc.keycode == Keycode.MACRO 665 | for kc in dkm.layer_keycodes.values() 666 | ]): 667 | self.put(MacroReportRequestPacket(dkm, 0)) 668 | pending += 1 669 | 670 | while pending > 0: 671 | p = self.get() 672 | if isinstance(p, MacroReportResponsePacket): 673 | pending -= 1 674 | if p.type.type not in [0, 0xFF]: 675 | dkm.macro.append(Macro.frompacket(p)) 676 | self.put(MacroReportRequestPacket(dkm, p.idx + 1)) 677 | pending += 1 678 | 679 | def _handle_dkm_colors(self): 680 | for _, dkm in self._configured_keys.items(): 681 | self.put(DKMColorRequestPacket(dkm)) 682 | 683 | p = self.get() 684 | if isinstance(p, DKMColorResponsePacket): 685 | dkm.color = (p.red, p.green, p.blue) 686 | 687 | def _handle_dkm_info(self): 688 | for _, dkm in self._configured_keys.items(): 689 | self.put(DKMInfoRequestPacket(dkm)) 690 | 691 | p = self.get() 692 | if isinstance(p, DKMInfoResponsePacket): 693 | dkm.version = p.version 694 | 695 | @property 696 | def configured_keys(self): 697 | if not self._keys_initialized: 698 | self._handle_dkm_reports() 699 | self._handle_dkm_macros() 700 | self._handle_dkm_colors() 701 | self._handle_dkm_info() 702 | 703 | self._keys_initialized = True 704 | 705 | return self._configured_keys 706 | 707 | 708 | class DuMangPacket: 709 | 710 | def __init__(self, cmd, rawbytes): 711 | self.cmd = cmd 712 | self.rawbytes = rawbytes 713 | 714 | @classmethod 715 | def parse(cls, rawbytes): 716 | c = None 717 | if rawbytes: 718 | cmd = rawbytes[0] 719 | 720 | if cmd == KEY_UP_CMD: 721 | c = KeyDownPacket.fromrawbytes(rawbytes) 722 | elif cmd == KEY_DOWN_CMD: 723 | c = KeyUpPacket.fromrawbytes(rawbytes) 724 | elif cmd == BOARD_INFO_RESPONSE_CMD: 725 | c = BoardInfoResponsePacket.fromrawbytes(rawbytes) 726 | elif cmd == DKM_INFO_RESPONSE_CMD: 727 | c = DKMInfoResponsePacket.fromrawbytes(rawbytes) 728 | elif cmd == DKM_COLOR_RESPONSE_CMD: 729 | c = DKMColorResponsePacket.fromrawbytes(rawbytes) 730 | elif cmd == LIGHT_PULSE_CMD: 731 | c = LightPulsePacket.fromrawbytes(rawbytes) 732 | elif cmd == DKM_REPORT_RESPONSE_CMD: 733 | c = DKMReportResponsePacket.fromrawbytes(rawbytes) 734 | elif cmd == DKM_ADDED_CMD: 735 | c = DKMAddedPacket.fromrawbytes(rawbytes) 736 | elif cmd == DKM_REMOVED_CMD: 737 | c = DKMRemovedPacket.fromrawbytes(rawbytes) 738 | elif cmd == MACRO_REPORT_RESPONSE_CMD: 739 | c = MacroReportResponsePacket.fromrawbytes(rawbytes) 740 | else: 741 | c = cls(cmd, rawbytes[1:]) 742 | 743 | return c 744 | 745 | def encode(self): 746 | pass 747 | 748 | def __repr__(self): 749 | return "{} - CMD:{:02X} raw:[{}]".format( 750 | self.__class__.__name__, self.cmd, 751 | ", ".join(hex(x) for x in self.rawbytes)) 752 | 753 | 754 | class BoardInfoRequestPacket(DuMangPacket): 755 | 756 | def __init__(self): 757 | super().__init__(BOARD_INFO_REQUEST_CMD, None) 758 | 759 | def encode(self): 760 | return [self.cmd, 0x00, 0x00, 0x00, 0x00] 761 | 762 | 763 | class BoardInfoResponsePacket(DuMangPacket): 764 | REPORT_RATES = { 765 | 0x0A: 100, 766 | 0x08: 125, 767 | 0x05: 200, 768 | 0x04: 250, 769 | 0x03: 333, 770 | 0x02: 500, 771 | 0x01: 1000 772 | } 773 | 774 | def __init__(self, report_rate, nkro_enabled, version): 775 | super().__init__(BOARD_INFO_RESPONSE_CMD, None) 776 | self.report_rate = self.REPORT_RATES[report_rate] 777 | self.nkro = True if nkro_enabled == 1 else False 778 | self.version = version 779 | 780 | @classmethod 781 | def fromrawbytes(cls, rawbytes): 782 | # NOTE: rawbytes[7] is a bit-vector. We currently only know 783 | # that bit 3 corresponds to NKRO. 784 | return cls(rawbytes[5], rawbytes[7] & 0b100, (rawbytes[3], rawbytes[4])) 785 | 786 | def __repr__(self): 787 | return "{} - CMD:{:02X} NKRO:{} Report Rate:{}".format( 788 | self.__class__.__name__, self.cmd, self.nkro, self.report_rate) 789 | 790 | 791 | class DKMInfoRequestPacket(DuMangPacket): 792 | 793 | def __init__(self, key): 794 | super().__init__(DKM_INFO_REQUEST_CMD, None) 795 | self.key = DuMangKeyModule(key) if isinstance(key, int) else key 796 | 797 | def encode(self): 798 | return [self.cmd, self.key.encode(), 0x00, 0x00, 0x00] 799 | 800 | 801 | class DKMInfoResponsePacket(DuMangPacket): 802 | 803 | def __init__(self, version): 804 | super().__init__(DKM_INFO_RESPONSE_CMD, None) 805 | self.version = version 806 | 807 | @classmethod 808 | def fromrawbytes(cls, rawbytes): 809 | return cls((rawbytes[3], rawbytes[4])) 810 | 811 | def __repr__(self): 812 | return "{} - CMD:{:02X} Version:{}".format(self.__class__.__name__, 813 | self.cmd, self.version) 814 | 815 | 816 | class KeyDownPacket(DuMangPacket): 817 | 818 | def __init__(self, ID, flag, layer_info): 819 | super().__init__(KEY_UP_CMD, None) 820 | self.ID = ID 821 | self.flag = flag 822 | self.layer_info = layer_info 823 | 824 | @classmethod 825 | def fromrawbytes(cls, rawbytes): 826 | return cls(rawbytes[1], rawbytes[2], rawbytes[3]) 827 | 828 | def __repr__(self): 829 | return "{} - CMD:{:02X} ID:{:02X} Flag:{:02X} LayerInfo:{:02X}".format( 830 | self.__class__.__name__, self.cmd, self.ID, self.flag, 831 | self.layer_info) 832 | 833 | 834 | class KeyUpPacket(DuMangPacket): 835 | 836 | def __init__(self, ID, flag, layer_info): 837 | super().__init__(KEY_DOWN_CMD, None) 838 | self.ID = ID 839 | self.flag = flag 840 | self.layer_info = layer_info 841 | 842 | @classmethod 843 | def fromrawbytes(cls, rawbytes): 844 | return cls(rawbytes[1], rawbytes[2], rawbytes[3]) 845 | 846 | def __repr__(self): 847 | return "{} - CMD:{:02X} ID:{:02X} Flag:{:02X} LayerInfo:{:02X}".format( 848 | self.__class__.__name__, self.cmd, self.ID, self.flag, 849 | self.layer_info) 850 | 851 | 852 | class BoardSyncPacket(DuMangPacket): 853 | 854 | def __init__(self, ID, layer_active, layer_info): 855 | super().__init__(BOARD_SYNC_CMD, None) 856 | self.ID = ID 857 | self.layer_active = 0x03 if layer_active == True else 0x02 858 | self.layer_info = layer_info 859 | 860 | def encode(self): 861 | return [ 862 | self.cmd, 0x01, self.layer_active, 0x00, self.ID, self.layer_info, 863 | self.layer_info & 0x03, 0x00 864 | ] 865 | 866 | 867 | class LightPulsePacket(DuMangPacket): 868 | 869 | def __init__(self, onoff, key): 870 | super().__init__(LIGHT_PULSE_CMD, None) 871 | self.onoff = 0x03 if onoff else 0x02 872 | self.key = DuMangKeyModule(key) if isinstance(key, int) else key 873 | 874 | @classmethod 875 | def fromrawbytes(cls, rawbytes): 876 | return cls(bool(rawbytes[2] == 3), rawbytes[1]) 877 | 878 | def encode(self): 879 | return [self.cmd, self.key.encode(), self.onoff, 0x0F, 0x0F, 0x0F] 880 | 881 | 882 | class DKMReportRequestPacket(DuMangPacket): 883 | 884 | def __init__(self, key): 885 | super().__init__(DKM_REPORT_REQUEST_CMD, None) 886 | self.key = DuMangKeyModule(key) if isinstance(key, int) else key 887 | 888 | def encode(self): 889 | return [self.cmd, self.key.encode(), 0x00, 0x00, 0x00] 890 | 891 | 892 | class KeyReportBasePacket(DuMangPacket): 893 | 894 | def __init__(self, cmd, key, layer_keycodes, serial): 895 | super().__init__(cmd, None) 896 | self.key = DuMangKeyModule(key) if isinstance(key, int) else key 897 | self.layer_keycodes = layer_keycodes 898 | self.serial = serial 899 | 900 | @classmethod 901 | def fromrawbytes(cls, rawbytes): 902 | serial = (rawbytes[2] << 24) + (rawbytes[3] << 16) + ( 903 | rawbytes[4] << 8) + rawbytes[5] 904 | return cls( 905 | rawbytes[1], 906 | { 907 | 0: Keycode(rawbytes[7]), 908 | 1: Keycode(rawbytes[8]), 909 | 2: Keycode(rawbytes[9]), 910 | 3: Keycode(rawbytes[10]) 911 | }, 912 | f"{serial:08X}", 913 | ) 914 | 915 | def __repr__(self): 916 | return "{} - CMD:{:02X} Key:{} Serial:{} LayerKeycodes:{}".format( 917 | self.__class__.__name__, self.cmd, self.key, self.serial, 918 | self.layer_keycodes) 919 | 920 | 921 | class DKMReportResponsePacket(KeyReportBasePacket): 922 | 923 | def __init__(self, key, layer_keycodes, serial): 924 | super().__init__(DKM_REPORT_RESPONSE_CMD, key, layer_keycodes, serial) 925 | 926 | @classmethod 927 | def fromrawbytes(cls, rawbytes): 928 | return super().fromrawbytes(rawbytes) 929 | 930 | 931 | class DKMAddedPacket(KeyReportBasePacket): 932 | 933 | def __init__(self, key, layer_keycodes, serial): 934 | super().__init__(DKM_ADDED_CMD, key, layer_keycodes, serial) 935 | 936 | @classmethod 937 | def fromrawbytes(cls, rawbytes): 938 | return super().fromrawbytes(rawbytes) 939 | 940 | 941 | class DKMRemovedPacket(KeyReportBasePacket): 942 | 943 | def __init__(self, key, layer_keycodes, serial): 944 | super().__init__(DKM_REMOVED_CMD, key, layer_keycodes, serial) 945 | 946 | @classmethod 947 | def fromrawbytes(cls, rawbytes): 948 | return super().fromrawbytes(rawbytes) 949 | 950 | 951 | class DKMConfigurePacket(DuMangPacket): 952 | 953 | def __init__(self, key, layer_keycodes): 954 | super().__init__(DKM_CONFIGURE_CMD, None) 955 | self.key = DuMangKeyModule(key) if isinstance(key, int) else key 956 | self.layer_keycodes = { 957 | k: v.encode() if isinstance(v, Keycode) else v 958 | for k, v in layer_keycodes.items() 959 | } 960 | 961 | def encode(self): 962 | return [ 963 | self.cmd, 964 | self.key.encode() + 1, 965 | self.layer_keycodes[1], 966 | self.layer_keycodes[2], 967 | self.layer_keycodes[3], 968 | 0xFF, 969 | self.layer_keycodes[0], 970 | ] 971 | 972 | def __repr__(self): 973 | return f"{self.__class__.__name__} - CMD:{self.cmd:02X} Key:{self.key} LayerKeycodes:{self.layer_keycodes}" 974 | 975 | 976 | class MacroReportRequestPacket(DuMangPacket): 977 | 978 | def __init__(self, key, idx): 979 | super().__init__(MACRO_REPORT_REQUEST_CMD, None) 980 | self.key = DuMangKeyModule(key) if isinstance(key, int) else key 981 | self.idx = idx 982 | 983 | def encode(self): 984 | return [ 985 | self.cmd, 986 | self.key.encode(), self.idx + MACRO_MIN_IDX, 0x00, 0x00 987 | ] 988 | 989 | 990 | class MacroReportResponsePacket(DuMangPacket): 991 | 992 | def __init__(self, key, idx, type_, keycode, delay): 993 | super().__init__(MACRO_REPORT_RESPONSE_CMD, None) 994 | self.key = DuMangKeyModule(key) if isinstance(key, int) else key 995 | self.idx = idx 996 | self.type = type_ 997 | self.keycode = keycode 998 | self.delay = delay 999 | 1000 | @classmethod 1001 | def fromrawbytes(cls, rawbytes): 1002 | return cls( 1003 | rawbytes[1], 1004 | rawbytes[2] - MACRO_MIN_IDX, 1005 | MacroType(rawbytes[3]), 1006 | Keycode(rawbytes[4]), 1007 | rawbytes[5] * 256 + rawbytes[6], 1008 | ) 1009 | 1010 | def __repr__(self): 1011 | return "{} - CMD:{:02X} Key:{} Idx:{} Type:{} Keycode:{} Delay:{}".format( 1012 | self.__class__.__name__, self.cmd, self.key, self.idx, self.type, 1013 | self.keycode, self.delay) 1014 | 1015 | 1016 | class MacroConfigurePacket(DuMangPacket): 1017 | 1018 | def __init__(self, key, idx, type_, keycode, delay): 1019 | super().__init__(MACRO_CONFIGURE_CMD, None) 1020 | self.key = DuMangKeyModule(key) if isinstance(key, int) else key 1021 | self.idx = idx 1022 | self.type = type_ 1023 | self.keycode = keycode 1024 | self.delay = delay 1025 | 1026 | def encode(self): 1027 | return [ 1028 | self.cmd, 1029 | self.key.encode(), 1030 | self.idx + MACRO_MIN_IDX, 1031 | self.type.type, 1032 | self.keycode.keycode, 1033 | self.delay // 256, # MAX: 255*255 + 255 1034 | self.delay % 256, 1035 | ] 1036 | 1037 | def __repr__(self): 1038 | return "{} - CMD:{:02X} Key:{} Idx:{} Type:{} Keycode:{} Delay:{}".format( 1039 | self.__class__.__name__, self.cmd, self.key, self.idx, self.type, 1040 | self.keycode, self.delay) 1041 | 1042 | 1043 | class NKROConfigurePacket(DuMangPacket): 1044 | 1045 | def __init__(self, onoff): 1046 | super().__init__(NKRO_CONFIGURE_CMD, None) 1047 | self.onoff = 0x01 if onoff else 0x00 1048 | 1049 | def encode(self): 1050 | return [self.cmd, 0x01, 0x04, self.onoff, 0x17, 0x20, 0x00] 1051 | 1052 | 1053 | class ReportRateConfigurePacket(DuMangPacket): 1054 | 1055 | REPORT_RATES = { 1056 | 100: 0x0A, 1057 | 125: 0x08, 1058 | 200: 0x05, 1059 | 250: 0x04, 1060 | 333: 0x03, 1061 | 500: 0x02, 1062 | 1000: 0x01 1063 | } 1064 | 1065 | def __init__(self, report_rate): 1066 | super().__init__(REPORT_RATE_CONFIGURE_CMD, None) 1067 | if not report_rate in self.REPORT_RATES: 1068 | logger.warning( 1069 | f"Invalid report rate. Supported values: {self.REPORT_RATES}.") 1070 | logger.info(f"Enabling default rate: {DEFAULT_REPORT_RATE}") 1071 | self.report_rate = DEFAULT_REPORT_RATE 1072 | else: 1073 | self.report_rate = report_rate 1074 | 1075 | def encode(self): 1076 | return [ 1077 | self.cmd, 0x00, self.REPORT_RATES[self.report_rate], 0x00, 0x00, 1078 | 0x00 1079 | ] 1080 | 1081 | 1082 | class DKMColorRequestPacket(DuMangPacket): 1083 | 1084 | def __init__(self, key): 1085 | super().__init__(DKM_COLOR_REQUEST_CMD, None) 1086 | self.key = DuMangKeyModule(key) if isinstance(key, int) else key 1087 | 1088 | def encode(self): 1089 | return [self.cmd, self.key.encode(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] 1090 | 1091 | 1092 | class DKMColorResponsePacket(DuMangPacket): 1093 | 1094 | def __init__(self, red, green, blue): 1095 | super().__init__(DKM_COLOR_RESPONSE_CMD, None) 1096 | self.red = red 1097 | self.green = green 1098 | self.blue = blue 1099 | 1100 | @classmethod 1101 | def fromrawbytes(cls, rawbytes): 1102 | # TODO: It's possible the key is encoded in this packet type. 1103 | return cls(rawbytes[3], rawbytes[4], rawbytes[5]) 1104 | 1105 | def __repr__(self): 1106 | return "{} - CMD:{:02X} Color: ({}, {}, {})".format( 1107 | self.__class__.__name__, self.cmd, self.red, self.green, self.blue) 1108 | 1109 | 1110 | class DKMColorConfigurePacket(DuMangPacket): 1111 | 1112 | def __init__(self, key, red, green, blue): 1113 | super().__init__(DKM_COLOR_CONFIGURE_CMD, None) 1114 | self.key = DuMangKeyModule(key) if isinstance(key, int) else key 1115 | # NOTE: LEDs can only encode 4bits of color per channel. 1116 | self.red = red % 16 1117 | self.green = green % 16 1118 | self.blue = blue % 16 1119 | 1120 | def encode(self): 1121 | return [ 1122 | self.cmd, 1123 | self.key.encode(), 0x00, self.red, self.green, self.blue 1124 | ] 1125 | 1126 | 1127 | def signal_handler(signal, frame): 1128 | sys.exit(0) 1129 | 1130 | 1131 | def initialize_devices(): 1132 | init_devices = [] 1133 | 1134 | device_list = hid.enumerate(VENDOR_ID, PRODUCT_ID) 1135 | 1136 | ctrl_device = [] 1137 | for d in device_list: 1138 | if d["interface_number"] == 1: 1139 | ctrl_device.append(d) 1140 | 1141 | for d in ctrl_device: 1142 | try: 1143 | h = hid.device() 1144 | h.open_path(d["path"]) 1145 | b = DuMangBoard(d["serial_number"], h) 1146 | init_devices.append(b) 1147 | 1148 | except OSError as ex: 1149 | logger.error(ex, exc_info=True) 1150 | logger.error("Likely permissions error.") 1151 | sys.exit(1) 1152 | 1153 | return init_devices 1154 | 1155 | 1156 | class NoHotplugSupport(Exception): 1157 | pass 1158 | 1159 | 1160 | class TooManyBoards(Exception): 1161 | pass 1162 | 1163 | 1164 | class DetectedDevice: 1165 | 1166 | def __init__(self, handle, on_close): 1167 | self._handle = handle 1168 | self._on_close = on_close 1169 | 1170 | def __str__(self): 1171 | return "USB Detected Device at " + str(self._handle.getDevice()) 1172 | 1173 | def close(self): 1174 | # Note: device may have already left when this method is called, 1175 | # so catch USBErrorNoDevice around cleanup steps involving the device. 1176 | try: 1177 | self.on_close(self) 1178 | except usb1.USBErrorNoDevice: 1179 | pass 1180 | self._handle.close() 1181 | 1182 | 1183 | class USBConnectionMonitor: 1184 | """ 1185 | Manages the hotplug events. 1186 | Monitors the arrival and departure of USB devices. 1187 | """ 1188 | 1189 | def __init__(self, vendor_id, product_id): 1190 | self.context = usb1.USBContext() 1191 | if not self.context.hasCapability(usb1.CAP_HAS_HOTPLUG): 1192 | raise NoHotplugSupport( 1193 | "Hotplug support is missing. Please update your libusb version." 1194 | ) 1195 | self._device_dict = {} 1196 | self.vendor_id = vendor_id 1197 | self.product_id = product_id 1198 | self.notify_q = queue.Queue() 1199 | self._notify_threshold = 2 1200 | self._has_started = False 1201 | 1202 | def _on_device_left(self, detected_device): 1203 | logger.debug(f"Device left: {detected_device!s}") 1204 | 1205 | def _on_device_arrived(self, handle): 1206 | detected_device = DetectedDevice(handle, self._on_device_left) 1207 | logger.debug(f"Device arrived: {detected_device!s}") 1208 | return detected_device 1209 | 1210 | def _register_callback(self): 1211 | self._callback_handle = self.context.hotplugRegisterCallback( 1212 | self._on_hotplug_event, 1213 | events=usb1.HOTPLUG_EVENT_DEVICE_ARRIVED 1214 | | usb1.HOTPLUG_EVENT_DEVICE_LEFT, 1215 | vendor_id=self.vendor_id, 1216 | product_id=self.product_id, 1217 | ) 1218 | 1219 | def _deregister_callback(self): 1220 | self.context.hotplugDeregisterCallback(self._callback_handle) 1221 | 1222 | def _on_hotplug_event(self, context, device, event): 1223 | if event == usb1.HOTPLUG_EVENT_DEVICE_LEFT: 1224 | device_from_event = self._device_dict.pop(device, None) 1225 | if device_from_event is not None: 1226 | device_from_event.close() 1227 | self._update_status() 1228 | return 1229 | try: 1230 | handle = device.open() 1231 | except usb1.USBError as ex: 1232 | logger.error(ex, exc_info=True) 1233 | return 1234 | self._device_dict[device] = self._on_device_arrived(handle) 1235 | self._update_status() 1236 | 1237 | def _update_status(self): 1238 | total_connected = len(self._device_dict) 1239 | if total_connected == self._notify_threshold: 1240 | self._has_started = True 1241 | self.ready() 1242 | elif total_connected < self._notify_threshold: 1243 | if self._has_started: 1244 | self.wait() 1245 | else: 1246 | raise TooManyBoards( 1247 | "Too many boards connected. Not sure how to handle it.") 1248 | 1249 | def ready(self): 1250 | self.notify_q.put(NOTIFY_STATUS_READY) 1251 | 1252 | def wait(self): 1253 | self.notify_q.put(NOTIFY_STATUS_WAIT) 1254 | 1255 | def get_status(self): 1256 | return self.notify_q.get() 1257 | 1258 | def stop(self): 1259 | self._deregister_callback() 1260 | self.notify_q.put(NOTIFY_STATUS_STOP) 1261 | 1262 | def join(self): 1263 | pass 1264 | 1265 | 1266 | class USBConnectionMonitorRunner(USBConnectionMonitor): 1267 | """ 1268 | API: USB-event-centric application. 1269 | Simplest API, for userland drivers which only react to USB events. 1270 | """ 1271 | 1272 | def __init__(self, vendor_id, product_id): 1273 | super().__init__(vendor_id, product_id) 1274 | self._observer = threading.Thread(target=self._run, daemon=True) 1275 | self._shutdown_flag = threading.Event() 1276 | 1277 | def _run(self): 1278 | with self.context: 1279 | logger.debug("Registering hotplug callback...") 1280 | self._register_callback() 1281 | while not self._shutdown_flag.is_set(): 1282 | # NOTE: This call will block until callback is deregistered. 1283 | self.context.handleEvents() 1284 | 1285 | def start(self): 1286 | self._observer.start() 1287 | 1288 | def join(self): 1289 | if self._observer: 1290 | self._observer.join() 1291 | 1292 | def stop(self): 1293 | if self._observer: 1294 | # NOTE: Set shutdown_flag before stopping. 1295 | self._shutdown_flag.set() 1296 | super().stop() 1297 | -------------------------------------------------------------------------------- /dumang_ctrl/dumang/gui.py: -------------------------------------------------------------------------------- 1 | from PyQt6 import QtGui 2 | from PyQt6.QtCore import * 3 | from PyQt6.QtWidgets import * 4 | 5 | from .common import * 6 | 7 | 8 | class KBDTableView(QTableWidget): 9 | itemLeave = pyqtSignal() 10 | 11 | HEADERS = ["Key Module Serial", "Layer 0", "Layer 1", "Layer 2", "Layer 3"] 12 | 13 | def __init__(self, keys, *args): 14 | super().__init__(len(keys), len(KBDTableView.HEADERS), *args) 15 | self.keys = keys 16 | self.setData() 17 | self.resizeColumnsToContents() 18 | self.resizeRowsToContents() 19 | self._last_item = None 20 | self.viewport().installEventFilter(self) 21 | self.horizontalHeader().setStretchLastSection(True) 22 | self.horizontalHeader().setStretchLastSection(True) 23 | self.horizontalHeader().setSectionResizeMode( 24 | QHeaderView.ResizeMode.Fixed) 25 | self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) 26 | 27 | def setData(self): 28 | for n, p in enumerate(self.keys.items()): 29 | serial, dkm = p 30 | 31 | for m, layer in enumerate(dkm.layer_keycodes.values()): 32 | # NOTE: Use repr() here to show the Aliases 33 | # for keycodes should they exist. 34 | newitem = QTableWidgetItem(repr(layer)) 35 | newitem.setFlags(Qt.ItemFlag.ItemIsEnabled) 36 | self.setItem(n, m + 1, newitem) 37 | 38 | keycodeitem = QTableWidgetItem(dkm.serial) 39 | keycodeitem.setFlags(Qt.ItemFlag.ItemIsEnabled) 40 | self.setItem(n, 0, keycodeitem) 41 | 42 | self.setHorizontalHeaderLabels(KBDTableView.HEADERS) 43 | 44 | def eventFilter(self, widget, event): 45 | if widget is self.viewport() and event.type() == QEvent.Type.Leave: 46 | QModelIndex() 47 | self.itemLeave.emit() 48 | return True 49 | 50 | return QTableWidget.eventFilter(self, widget, event) 51 | 52 | def _on_itemEntered(self, kbd, item): 53 | # NOTE: The lack of itemLeave/Exited requires us to track last item. 54 | # Previous solution as descriped in https://stackoverflow.com/questions/20064975 55 | # proved problematic when scrolling as it would trigger Enter events, but no Leave events. 56 | if item != self._last_item and self._last_item is not None: 57 | p = LightPulsePacket( 58 | False, self.keys[self.item(self._last_item.row(), 59 | 0).data(0)].key) 60 | kbd.put(p) 61 | 62 | p = LightPulsePacket(True, self.keys[self.item(item.row(), 63 | 0).data(0)].key) 64 | kbd.put(p) 65 | self._last_item = item 66 | 67 | def _on_itemLeave(self, kbd): 68 | p = LightPulsePacket( 69 | False, self.keys[self.item(self._last_item.row(), 0).data(0)].key) 70 | kbd.put(p) 71 | 72 | 73 | class KBDWidget(QWidget): 74 | 75 | def __init__(self, parent, kbd): 76 | super(QWidget, self).__init__(parent) 77 | self.layout = QVBoxLayout(self) 78 | self.tableWidget = self._add_table_widget(kbd) 79 | self.layout.addWidget(self.tableWidget) 80 | self.setLayout(self.layout) 81 | 82 | def _add_table_widget(self, kbd): 83 | kbd_widget = KBDTableView(kbd.configured_keys) 84 | kbd_widget.setMouseTracking(True) 85 | kbd_widget.itemEntered.connect( 86 | lambda item: kbd_widget._on_itemEntered(kbd, item)) 87 | kbd_widget.itemLeave.connect(lambda: kbd_widget._on_itemLeave(kbd)) 88 | return kbd_widget 89 | 90 | def hideLayout(self, n): 91 | self.tableWidget.hideColumn(n + 1) 92 | 93 | def showLayout(self, n): 94 | self.tableWidget.showColumn(n + 1) 95 | 96 | 97 | class KBDTab(QWidget): 98 | 99 | def __init__(self, parent, kbd): 100 | super(QWidget, self).__init__(parent) 101 | self.layout = QVBoxLayout(self) 102 | self.layout.setAlignment(Qt.AlignmentFlag.AlignTop) 103 | self.kbd_serial_label = QLabel(f"Board Serial: {kbd.serial}") 104 | self.layout.addWidget(self.kbd_serial_label) 105 | self.kbd_firmware_label = QLabel( 106 | f"Firmware: v{kbd.version[0]}.{kbd.version[1]}") 107 | self.layout.addWidget(self.kbd_firmware_label) 108 | 109 | # TODO: Refresh Button & Display DKM Firmware Versions & Color 110 | 111 | # Add dropdown/combo box 112 | headers = [f"Layer {i}" for i in range(MAX_LAYERS)] 113 | self.comboLayouts = QComboBox() 114 | self.comboLayouts.addItems(headers) 115 | self.label = QLabel("&L:") 116 | self.label.setBuddy(self.comboLayouts) 117 | self.layout.addWidget(self.comboLayouts) 118 | 119 | self.comboLayouts.currentIndexChanged.connect(self.changeKBDLayout) 120 | self.kbdWidget = KBDWidget(self, kbd) 121 | self.layout.addWidget(self.kbdWidget) 122 | self.comboLayouts.setCurrentIndex(0) 123 | self.changeKBDLayout() 124 | 125 | def changeKBDLayout(self): 126 | show = self.comboLayouts.currentIndex() 127 | hide = [x for x in range(5) if x != show] 128 | list(map(lambda x: self.kbdWidget.hideLayout(x), hide)) 129 | self.kbdWidget.showLayout(show) 130 | 131 | 132 | class KBDTabs(QWidget): 133 | 134 | def __init__(self, parent, kbds): 135 | super(QWidget, self).__init__(parent) 136 | self.layout = QVBoxLayout(self) 137 | 138 | # Initialize tab screen 139 | self.tabs = QTabWidget() 140 | # Add tabs 141 | for i, kbd in enumerate(kbds): 142 | self.tabs.addTab(KBDTab(self, kbd), f"Board {i}") 143 | self.tabs.resize(300, 200) 144 | 145 | # Add tabs to widget 146 | self.layout.addWidget(self.tabs) 147 | self.setLayout(self.layout) 148 | 149 | @pyqtSlot() 150 | def on_click(self): 151 | for currentQTableWidgetItem in self.tableWidget.selectedItems(): 152 | print(currentQTableWidgetItem.row(), 153 | currentQTableWidgetItem.column(), 154 | currentQTableWidgetItem.text()) 155 | 156 | 157 | class App(QMainWindow): 158 | 159 | def __init__(self): 160 | super().__init__() 161 | self.title = "Dumang Board Configuration Inspection Tool" 162 | self.left = 0 163 | self.top = 0 164 | self.width = 500 165 | self.height = 500 166 | self.setWindowTitle(self.title) 167 | self.setGeometry(self.left, self.top, self.width, self.height) 168 | self.center() 169 | 170 | def center(self): 171 | centerPoint = QtGui.QGuiApplication.primaryScreen().availableGeometry( 172 | ).center() 173 | self.move(centerPoint - self.frameGeometry().center()) 174 | 175 | 176 | def inspect_gui(kbd1, kbd2=None): 177 | init = QApplication([]) 178 | app = App() 179 | kbds = [kbd for kbd in [kbd1, kbd2] if kbd is not None] 180 | app.setCentralWidget(KBDTabs(app, kbds)) 181 | app.show() 182 | return init.exec() 183 | -------------------------------------------------------------------------------- /dumang_ctrl/tools/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayanez/dumang-keyboard-ctrl/f754a925bacde42bc5b96b2bbc5be984cea84d95/dumang_ctrl/tools/__init__.py -------------------------------------------------------------------------------- /dumang_ctrl/tools/config.py: -------------------------------------------------------------------------------- 1 | import click 2 | import logging 3 | import signal 4 | import sys 5 | import json 6 | import yaml 7 | from collections import OrderedDict 8 | 9 | import dumang_ctrl as pkginfo 10 | from dumang_ctrl.dumang.common import * 11 | 12 | logger = logging.getLogger("DuMang Config") 13 | logger.setLevel(logging.INFO) 14 | 15 | LABEL_LAYER_PREFIX = "layer_" 16 | LABEL_SERIAL = "serial" 17 | LABEL_KEY = "key" 18 | LABEL_KEYS = "keys" 19 | LABEL_BOARD = "board" 20 | LABEL_MACRO = "macro" 21 | LABEL_COLOR = "color" 22 | LABEL_TYPE = "type" 23 | LABEL_DELAY_MS = "delay_ms" 24 | LABEL_NKRO = "nkro" 25 | LABEL_REPORT_RATE = "report_rate" 26 | 27 | CFG_YAML_FORMAT = "yaml" 28 | CFG_JSON_FORMAT = "json" 29 | CFG_FORMATS = [CFG_YAML_FORMAT, CFG_JSON_FORMAT] 30 | DEFAULT_CFG_FORMAT = CFG_YAML_FORMAT 31 | 32 | CTX_KEYBOARDS_KEY = "KEYBOARDS" 33 | CTX_THREADS_KEY = "THREADS" 34 | 35 | 36 | class NestedDict(OrderedDict): 37 | 38 | def __missing__(self, key): 39 | self[key] = NestedDict() 40 | return self[key] 41 | 42 | 43 | # NOTE: The following is required due to a bug in PyYAML when 44 | # it comes to outputting ints with leading zeros. This is problematic 45 | # when outputting DKM serials since they are written as hex strings. 46 | # REF: https://github.com/yaml/pyyaml/issues/98#issuecomment-436814271 47 | def string_representer(dumper, value): 48 | TAG_STR = "tag:yaml.org,2002:str" 49 | if value.startswith("0"): 50 | return dumper.represent_scalar(TAG_STR, value, style="'") 51 | return dumper.represent_scalar(TAG_STR, value) 52 | 53 | 54 | yaml.add_representer(str, string_representer) 55 | yaml.add_representer(NestedDict, yaml.representer.Representer.represent_dict) 56 | 57 | 58 | def init_send_threads(kbds): 59 | return [Job(target=kbd.send_thread, daemon=True) for kbd in kbds] 60 | 61 | 62 | def init_receive_threads(kbds): 63 | return [Job(target=kbd.receive_thread, daemon=True) for kbd in kbds] 64 | 65 | 66 | def find_kbd_by_serial(kbds, serial): 67 | for kbd in kbds: 68 | if kbd.serial == serial: 69 | return kbd 70 | return None 71 | 72 | 73 | def find_key_by_serial(kbd, serial): 74 | return kbd.configured_keys.get(serial, None) 75 | 76 | 77 | def configure_layers(board, key, cfg_key): 78 | layer_keycodes = {} 79 | for layer in cfg_key: 80 | if not layer.startswith(LABEL_LAYER_PREFIX): 81 | continue 82 | layer_int = int(layer.split(LABEL_LAYER_PREFIX[-1])[1]) 83 | layer_keycodes[layer_int] = Keycode.fromstr(cfg_key[layer]) 84 | 85 | if (key.layer_keycodes != layer_keycodes): 86 | logger.debug( 87 | f"Configuring DKM serial {cfg_key[LABEL_SERIAL]} to {layer_keycodes}" 88 | ) 89 | board.put(DKMConfigurePacket(key, layer_keycodes)) 90 | return True 91 | 92 | return False 93 | 94 | 95 | def configure_macro(board, key, cfg_key): 96 | cfg_macro = cfg_key.get(LABEL_MACRO) 97 | if cfg_macro: 98 | cfg_macro_obj_list = [ 99 | Macro(m[LABEL_KEY], idx, m[LABEL_TYPE], int(m[LABEL_DELAY_MS])) 100 | for idx, m in enumerate(cfg_macro) 101 | ] 102 | 103 | if key.macro != cfg_macro_obj_list: 104 | logger.debug( 105 | f"Configuring DKM serial {cfg_key[LABEL_SERIAL]} macro") 106 | 107 | for m in cfg_macro_obj_list: 108 | board.put(m.topacket(key.key)) 109 | board.put( 110 | MacroConfigurePacket(key.key, 111 | len(cfg_macro_obj_list) + 1, MacroType(0), 112 | Keycode(0), 0)) 113 | return True 114 | 115 | return False 116 | 117 | 118 | def configure_color(board, key, cfg_key): 119 | cfg_color = cfg_key.get(LABEL_COLOR, None) 120 | if cfg_color and isinstance(cfg_color, str): 121 | red, green, blue = tuple(int(cfg_color[i:i + 2], 16) for i in (0, 2, 4)) 122 | if key.color != (red, green, blue): 123 | board.put(DKMColorConfigurePacket(key.key, red, green, blue)) 124 | logger.debug( 125 | f"Configuring DKM serial {cfg_key[LABEL_SERIAL]} color to #{cfg_color}" 126 | ) 127 | return True 128 | 129 | return False 130 | 131 | 132 | def configure_key(board, key, cfg_key): 133 | did_configure = configure_layers(board, key, cfg_key) 134 | did_configure |= configure_macro(board, key, cfg_key) 135 | did_configure |= configure_color(board, key, cfg_key) 136 | 137 | if not did_configure: 138 | logger.debug( 139 | f"DKM serial {cfg_key[LABEL_SERIAL]} already properly configured") 140 | 141 | return did_configure 142 | 143 | 144 | def configure_keys(cfg_kbd, kbds): 145 | n = 0 146 | for k in cfg_kbd[LABEL_KEYS]: 147 | cfg_key = k[LABEL_KEY] 148 | 149 | key_serial = cfg_key.get(LABEL_SERIAL, None) 150 | if key_serial is None: 151 | logger.error(f"DKM config without serial {k}") 152 | sys.exit(1) 153 | 154 | board = None 155 | for kbd in kbds: 156 | key = find_key_by_serial(kbd, key_serial) 157 | if key is not None: 158 | board = kbd 159 | break 160 | if key is None: 161 | logger.error(f"DKM with serial {key_serial} not found") 162 | sys.exit(1) 163 | 164 | cfg_kbd_serial = cfg_kbd[LABEL_SERIAL] 165 | if board.serial != cfg_kbd_serial: 166 | logger.warning( 167 | f"DKM with serial {key_serial} found on a different board. Maybe moved from board {cfg_kbd_serial} to {board.serial} ?" 168 | ) 169 | 170 | if configure_key(board, key, cfg_key): 171 | n += 1 172 | 173 | return n 174 | 175 | 176 | def configure_boards(cfg, kbds): 177 | n = 0 178 | for cfg_kbd in cfg: 179 | cfg_board = cfg_kbd[LABEL_BOARD] 180 | n += configure_keys(cfg_board, kbds) 181 | board = find_kbd_by_serial(kbds, cfg_board[LABEL_SERIAL]) 182 | if board: 183 | cfg_nkro = cfg_board.get(LABEL_NKRO, None) 184 | if cfg_nkro: 185 | if board.nkro != cfg_nkro: 186 | logger.info(f"Configuring NKRO to: {cfg_nkro}") 187 | board.put(NKROConfigurePacket(cfg_nkro)) 188 | 189 | cfg_report_rate = cfg_board.get(LABEL_REPORT_RATE, None) 190 | if cfg_report_rate: 191 | if board.report_rate != cfg_report_rate: 192 | logger.info( 193 | f"Configuring Report Rate to: {cfg_report_rate}") 194 | board.put(ReportRateConfigurePacket(cfg_report_rate)) 195 | 196 | return n 197 | 198 | 199 | @click.group(help="Configuration Tool", invoke_without_command=True) 200 | @click.option("--verbose", help="Enable Verbose Logging", is_flag=True) 201 | @click.option( 202 | "--very-verbose", help="Enable Very Verbose Logging", is_flag=True) 203 | @click.option("--version", help="Print Version", is_flag=True) 204 | @click.pass_context 205 | def cli(ctx, verbose, very_verbose, version): 206 | signal.signal(signal.SIGINT, signal_handler) 207 | 208 | if very_verbose: 209 | logging.getLogger().setLevel(logging.DEBUG) 210 | elif verbose: 211 | logger.setLevel(logging.DEBUG) 212 | 213 | if version: 214 | click.echo(f"{pkginfo.description}") 215 | click.echo(f"Version: {pkginfo.version}") 216 | click.echo(f"Report issues to: {pkginfo.url}") 217 | return 218 | 219 | # TODO: Can both config and sync tools run at the same time? 220 | kbds = initialize_devices() 221 | if not kbds: 222 | logger.error("Keyboard not detected") 223 | sys.exit(1) 224 | 225 | ctx.ensure_object(dict) 226 | ctx.obj[CTX_KEYBOARDS_KEY] = kbds 227 | ctx.obj[CTX_THREADS_KEY] = [] 228 | 229 | ctx.obj[CTX_THREADS_KEY].extend(init_send_threads(kbds)) 230 | ctx.obj[CTX_THREADS_KEY].extend(init_receive_threads(kbds)) 231 | 232 | for t in ctx.obj[CTX_THREADS_KEY]: 233 | t.start() 234 | 235 | 236 | @cli.command(help="Dump the current configuration") 237 | @click.option( 238 | "--format", type=click.Choice(CFG_FORMATS), default=DEFAULT_CFG_FORMAT) 239 | @click.pass_context 240 | def dump(ctx, format): 241 | n = 0 242 | cfg_dict = [] 243 | for _, kbd in enumerate(ctx.obj[CTX_KEYBOARDS_KEY]): 244 | cfg_board = { 245 | LABEL_BOARD: { 246 | LABEL_SERIAL: kbd.serial, 247 | LABEL_NKRO: kbd.nkro, 248 | LABEL_REPORT_RATE: kbd.report_rate, 249 | LABEL_KEYS: [] 250 | } 251 | } 252 | 253 | cfg_keys = cfg_board[LABEL_BOARD][LABEL_KEYS] 254 | for _, dkm in kbd.configured_keys.items(): 255 | cfg_key = {LABEL_KEY: NestedDict()} 256 | if dkm.serial is not None: 257 | cfg_key[LABEL_KEY][LABEL_SERIAL] = dkm.serial 258 | for l, kc in dkm.layer_keycodes.items(): 259 | # NOTE: Use str() here to get ANY of the valid 260 | # aliases should a keycode have them. 261 | cfg_key[LABEL_KEY][f"{LABEL_LAYER_PREFIX}{l}"] = str(kc) 262 | if dkm.macro: 263 | cfg_key[LABEL_KEY][LABEL_MACRO] = [{ 264 | LABEL_TYPE: str(m.type), 265 | LABEL_KEY: str(m.keycode), 266 | LABEL_DELAY_MS: m.delay, 267 | } for m in dkm.macro] 268 | if dkm.color: 269 | cfg_key[LABEL_KEY][ 270 | LABEL_COLOR] = "{0:02x}{1:02x}{2:02x}".format(*dkm.color) 271 | cfg_keys.append(cfg_key) 272 | n += 1 273 | 274 | cfg_dict.append(cfg_board) 275 | kbd.kill_threads() 276 | 277 | if format == CFG_YAML_FORMAT: 278 | yaml.dump( 279 | cfg_dict, 280 | sys.stdout, 281 | allow_unicode=True, 282 | default_flow_style=False, 283 | sort_keys=False) 284 | logger.info(f"Dumped {n} keys.") 285 | elif format == CFG_JSON_FORMAT: 286 | json.dump(cfg_dict, sys.stdout, indent=2) 287 | 288 | for t in ctx.obj[CTX_THREADS_KEY]: 289 | t.join() 290 | 291 | 292 | @cli.command(help="Load the current configuration") 293 | @click.option( 294 | "--format", type=click.Choice(CFG_FORMATS), default=DEFAULT_CFG_FORMAT) 295 | @click.argument("filename") 296 | @click.pass_context 297 | def load(ctx, format, filename): 298 | cfgfile = open(filename) 299 | if format == CFG_YAML_FORMAT: 300 | cfg = yaml.safe_load(cfgfile) 301 | elif format == CFG_JSON_FORMAT: 302 | cfg = json.load(cfgfile) 303 | 304 | n = configure_boards(cfg, ctx.obj[CTX_KEYBOARDS_KEY]) 305 | for kbd in ctx.obj[CTX_KEYBOARDS_KEY]: 306 | kbd.kill_threads() 307 | logger.info(f"Configured {n} keys.") 308 | 309 | for t in ctx.obj[CTX_THREADS_KEY]: 310 | t.join() 311 | 312 | 313 | @cli.command(help="Inspect the current configuration via a GUI") 314 | @click.pass_context 315 | def inspect(ctx): 316 | logger.info("Launching GUI") 317 | from dumang_ctrl.dumang.gui import inspect_gui 318 | kbds = ctx.obj[CTX_KEYBOARDS_KEY] 319 | threads = ctx.obj[CTX_THREADS_KEY] 320 | 321 | gui = inspect_gui(*kbds) 322 | 323 | for kbd in kbds: 324 | kbd.kill_threads() 325 | 326 | for t in threads: 327 | t.join() 328 | 329 | for kbd in kbds: 330 | kbd.close() 331 | 332 | sys.exit(gui) 333 | 334 | 335 | if __name__ == "__main__": 336 | cli(obj={}) 337 | -------------------------------------------------------------------------------- /dumang_ctrl/tools/sync.py: -------------------------------------------------------------------------------- 1 | import click 2 | import logging 3 | import threading 4 | import signal 5 | 6 | import dumang_ctrl as pkginfo 7 | from dumang_ctrl.dumang.common import * 8 | 9 | logger = logging.getLogger("DuMang Sync") 10 | logger.setLevel(logging.INFO) 11 | 12 | 13 | def layer_toggle_process(p): 14 | if isinstance(p, KeyUpPacket): 15 | layer_active = False 16 | elif isinstance(p, KeyDownPacket): 17 | layer_active = True 18 | 19 | return BoardSyncPacket(p.ID, layer_active, p.layer_info) 20 | 21 | 22 | def send_response(p, q): 23 | response = None 24 | 25 | if isinstance(p, (KeyDownPacket, KeyUpPacket)): 26 | response = layer_toggle_process(p) 27 | 28 | if response: 29 | q.put(response) 30 | 31 | 32 | def sync_thread(kbd1, kbd2): 33 | p = kbd1.recv_q.get() 34 | if isinstance(p, JobKiller): 35 | logger.debug("Kill Sync Thread") 36 | return 37 | send_response(p, kbd2.send_q) 38 | kbd1.recv_q.task_done() 39 | 40 | 41 | def init_synchronization_threads(kbd1, kbd2): 42 | s1 = Job( 43 | target=sync_thread, 44 | args=( 45 | kbd1, 46 | kbd2, 47 | ), 48 | daemon=True, 49 | ) 50 | s2 = Job( 51 | target=sync_thread, 52 | args=( 53 | kbd2, 54 | kbd1, 55 | ), 56 | daemon=True, 57 | ) 58 | return [s1, s2] 59 | 60 | 61 | def init_send_threads(kbd1, kbd2): 62 | s1 = Job(target=kbd1.send_thread, daemon=True) 63 | s2 = Job(target=kbd2.send_thread, daemon=True) 64 | return [s1, s2] 65 | 66 | 67 | def init_receive_threads(kbd1, kbd2): 68 | r1 = Job(target=kbd1.receive_thread, daemon=True) 69 | r2 = Job(target=kbd2.receive_thread, daemon=True) 70 | return [r1, r2] 71 | 72 | 73 | def kill_and_join_threads(kbd1, kbd2, threads): 74 | if kbd1: 75 | kbd1.kill_threads() 76 | if kbd2: 77 | kbd2.kill_threads() 78 | 79 | for t in threads: 80 | t.stop() 81 | 82 | for t in threads: 83 | t.join() 84 | 85 | threads = [] 86 | 87 | if kbd1: 88 | kbd1.close() 89 | if kbd2: 90 | kbd2.close() 91 | 92 | 93 | def device_init_thread(monitor): 94 | threads = [] 95 | kbd1 = None 96 | kbd2 = None 97 | 98 | while True: 99 | status = monitor.get_status() 100 | if status == NOTIFY_STATUS_READY: 101 | logger.debug("Keyboard Detected!") 102 | logger.debug("Initializing...") 103 | # If both boards aren't connected, one of these may be `None`. 104 | # In which case we will enter this state twice. 105 | # We only want to begin the sync threads once we have 106 | # two devices. 107 | kbd1, kbd2 = initialize_devices() 108 | 109 | if not (kbd1 and kbd2): 110 | logger.info("Waiting for other Keyboard...") 111 | continue 112 | 113 | logger.debug("Both keyboards detected.") 114 | logger.debug("Starting sync threads...") 115 | threads.extend(init_send_threads(kbd1, kbd2)) 116 | threads.extend(init_receive_threads(kbd1, kbd2)) 117 | threads.extend(init_synchronization_threads(kbd1, kbd2)) 118 | 119 | for t in threads: 120 | t.start() 121 | 122 | elif status == NOTIFY_STATUS_WAIT: 123 | logger.debug("Keyboard Disconnected!") 124 | logger.debug("Stopping sync threads...") 125 | 126 | # NOTE: Kill threads and wait for devices to be reconnected. 127 | kill_and_join_threads(kbd1, kbd2, threads) 128 | 129 | kbd1 = None 130 | kbd2 = None 131 | elif status == NOTIFY_STATUS_STOP: 132 | logger.debug("Stopping sync threads...") 133 | 134 | if not (kbd1 or kbd2): 135 | logger.info( 136 | "Could not find devices. Make sure you've setup udev rules!" 137 | ) 138 | return 139 | 140 | # NOTE: Same as the NOTIFY_STATUS_WAIT case above. However, 141 | # here we want to return from the loop. 142 | kill_and_join_threads(kbd1, kbd2, threads) 143 | 144 | kbd1 = None 145 | kbd2 = None 146 | 147 | return 148 | 149 | 150 | monitor = USBConnectionMonitorRunner(VENDOR_ID, PRODUCT_ID) 151 | device_thread = threading.Thread( 152 | target=device_init_thread, args=(monitor,), daemon=True) 153 | 154 | 155 | def sync_terminate_handler(signal, frame): 156 | monitor.stop() 157 | device_thread.join() 158 | monitor.join() 159 | 160 | 161 | def sync(): 162 | logger.info("Staring DuMang Layer Sync...") 163 | signal.signal(signal.SIGINT, sync_terminate_handler) 164 | 165 | monitor.start() 166 | device_thread.start() 167 | 168 | device_thread.join() 169 | monitor.join() 170 | 171 | 172 | @click.command(help="Enable Layer Sync between two keyboard halves") 173 | @click.option("--verbose", help="Enable Verbose Logging", is_flag=True) 174 | @click.option( 175 | "--very-verbose", help="Enable Very Verbose Logging", is_flag=True) 176 | @click.option("--version", help="Print Version", is_flag=True) 177 | def cli(verbose, very_verbose, version): 178 | if very_verbose: 179 | logging.getLogger().setLevel(logging.DEBUG) 180 | elif verbose: 181 | logger.setLevel(logging.DEBUG) 182 | 183 | if version: 184 | click.echo(f"{pkginfo.description}") 185 | click.echo(f"Version: {pkginfo.version}") 186 | click.echo(f"Report issues to: {pkginfo.url}") 187 | return 188 | 189 | sync() 190 | 191 | 192 | if __name__ == "__main__": 193 | cli() 194 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: dumang-keyboard-ctrl 2 | repo_url: https://github.com/mayanez/dumang-keyboard-ctrl 3 | site_url: https://mayanez.github.io/dumang-keyboard-ctrl 4 | site_description: Dumang DK6 Tools 5 | site_author: Miguel A. Arroyo 6 | edit_uri: edit/main/docs/ 7 | repo_name: mayanez/dumang-keyboard-ctrl 8 | copyright: Maintained by Miguel A. Arroyo. 9 | 10 | nav: 11 | - Home: index.md 12 | plugins: 13 | - search 14 | - mkdocstrings: 15 | handlers: 16 | python: 17 | setup_commands: 18 | - import sys 19 | - sys.path.append('../') 20 | theme: 21 | name: material 22 | feature: 23 | tabs: true 24 | palette: 25 | - media: "(prefers-color-scheme: light)" 26 | scheme: default 27 | primary: white 28 | accent: deep orange 29 | toggle: 30 | icon: material/brightness-7 31 | name: Switch to dark mode 32 | - media: "(prefers-color-scheme: dark)" 33 | scheme: slate 34 | primary: black 35 | accent: deep orange 36 | toggle: 37 | icon: material/brightness-4 38 | name: Switch to light mode 39 | icon: 40 | repo: fontawesome/brands/github 41 | 42 | extra: 43 | social: 44 | - icon: fontawesome/brands/github 45 | link: https://github.com/mayanez/dumang-keyboard-ctrl 46 | - icon: fontawesome/brands/python 47 | link: https://pypi.org/project/dumang-keyboard-ctrl 48 | 49 | markdown_extensions: 50 | - toc: 51 | permalink: true 52 | - pymdownx.arithmatex: 53 | generic: true 54 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "dumang-ctrl" 3 | version = "1.0.6" 4 | description = "Dumang DK6 Tools" 5 | authors = ["Miguel A. Arroyo "] 6 | repository = "https://github.com/mayanez/dumang-keyboard-ctrl" 7 | documentation = "https://mayanez.github.io/dumang-keyboard-ctrl/" 8 | readme = "README.md" 9 | license = "LICENSE" 10 | packages = [ 11 | {include = "dumang_ctrl"} 12 | ] 13 | 14 | [tool.poetry.dependencies] 15 | python = ">=3.11,<4.0" 16 | hidapi = "*" 17 | PyQt6 = "*" 18 | PyYAML = "*" 19 | libusb1 = "*" 20 | click = "*" 21 | 22 | [tool.poetry.group.dev.dependencies] 23 | mypy = "^1.5.1" 24 | yapf = "*" 25 | pre-commit = "^3.4.0" 26 | 27 | [tool.poetry.group.docs.dependencies] 28 | mkdocs = "^1.4.2" 29 | mkdocs-material = "^9.2.7" 30 | mkdocstrings = {extras = ["python"], version = "^0.23.0"} 31 | 32 | [tool.poetry.scripts] 33 | dumang-sync = 'dumang_ctrl.tools.sync:cli' 34 | dumang-config = 'dumang_ctrl.tools.config:cli' 35 | 36 | [build-system] 37 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] 38 | build-backend = "poetry.core.masonry.api" 39 | 40 | [tool.yapf] 41 | based_on_style = "yapf" 42 | indent_width = 4 43 | 44 | [tool.mypy] 45 | files = ["dumang_ctrl"] 46 | disallow_untyped_defs = "True" 47 | disallow_any_unimported = "True" 48 | no_implicit_optional = "True" 49 | check_untyped_defs = "True" 50 | warn_return_any = "True" 51 | warn_unused_ignores = "True" 52 | show_error_codes = "True" 53 | 54 | [tool.ruff] 55 | target-version = "py311" 56 | line-length = 120 57 | fix = true 58 | select = [ 59 | # flake8-2020 60 | "YTT", 61 | # flake8-bandit 62 | "S", 63 | # flake8-bugbear 64 | "B", 65 | # flake8-builtins 66 | "A", 67 | # flake8-comprehensions 68 | "C4", 69 | # flake8-debugger 70 | "T10", 71 | # flake8-simplify 72 | "SIM", 73 | # isort 74 | "I", 75 | # mccabe 76 | "C90", 77 | # pycodestyle 78 | "E", "W", 79 | # pyflakes 80 | "F", 81 | # pygrep-hooks 82 | "PGH", 83 | # pyupgrade 84 | "UP", 85 | # ruff 86 | "RUF", 87 | # tryceratops 88 | "TRY", 89 | ] 90 | ignore = [ 91 | # LineTooLong 92 | "E501", 93 | # DoNotAssignLambda 94 | "E731", 95 | ] 96 | 97 | [tool.ruff.format] 98 | preview = true 99 | -------------------------------------------------------------------------------- /systemd/dumang-sync-python.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=DuMang DK6 Layer Sync (Python Invoke) 3 | 4 | [Service] 5 | Type=Simple 6 | ExecStart=dumang-sync 7 | 8 | [Install] 9 | WantedBy=multi-user.target 10 | -------------------------------------------------------------------------------- /systemd/dumang-sync.install: -------------------------------------------------------------------------------- 1 | post_install() { 2 | echo '==> You might want to enable the sync service with the command "systemctl enable dumang-sync.service".' 3 | } 4 | -------------------------------------------------------------------------------- /systemd/dumang-sync.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=DuMang DK6 Layer Sync 3 | # NOTE: Two systemd services are required since on suspend, libusb does NOT raise a HOTPLUG_EVENT_DEVICE_LEFT 4 | # event. This means the sync script doesn't know the keyboard handles are invalid upon resuming. 5 | # TODO: Is this expected behavior on part of libusb or a bug? 6 | Before=sleep.target 7 | StopWhenUnneeded=yes 8 | 9 | [Service] 10 | Type=oneshot 11 | RemainAfterExit=yes 12 | ExecStart=-/bin/systemctl stop dumang-sync-python.service 13 | ExecStop=-/bin/systemctl start dumang-sync-python.service 14 | 15 | [Install] 16 | WantedBy=sleep.target 17 | -------------------------------------------------------------------------------- /udev/51-dumang.rules: -------------------------------------------------------------------------------- 1 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5710", MODE:="0666" 2 | --------------------------------------------------------------------------------