├── .gitignore ├── LICENSE ├── README.md ├── example.py ├── poetry.lock ├── pyproject.toml ├── pytest.ini ├── tests ├── __init__.py └── test_schema.py └── thinq2 ├── __init__.py ├── client ├── __init__.py ├── base.py ├── common.py ├── gateway.py ├── oauth.py ├── objectstore.py └── thinq.py ├── controller ├── __init__.py ├── auth.py ├── device.py ├── mqtt.py └── thinq.py ├── model ├── __init__.py ├── auth.py ├── common.py ├── config.py ├── device │ ├── __init__.py │ ├── base.py │ └── laundry.py ├── gateway.py ├── mqtt.py └── thinq.py ├── schema.py └── util ├── __init__.py ├── filesystem.py └── uuid.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.pyc 3 | .*.sw? 4 | *.egg-info/ 5 | .venv/ 6 | .vscode/ 7 | .pytest_cache 8 | # output by example script 9 | state.json 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # thinq2-python 2 | 3 | This is a reverse-engineered client for the LG ThinQ v2 IoT protocol. 4 | 5 | If you are working with v1 devices, try [wideq](https://github.com/sampsyo/wideq), 6 | which inspired this project. 7 | 8 | ## Work in progress! 9 | 10 | This is very much a **work in progress**. 11 | 12 | There are no unit tests, there is no documentation, there is no defined API and 13 | breaking changes are almost guaranteed to happen. Fun times! 14 | 15 | ## Development 16 | 17 | This project uses [poetry](https://python-poetry.org/) for dependency management. 18 | 19 | To configure a development environment, run `poetry install`. 20 | 21 | ## Running the Example 22 | 23 | There is currently no documentation, but you can use `example.py` to demo the 24 | codebase, and its code shows basic usage. The `COUNTRY_CODE` and `LANGUAGE_CODE` 25 | environment variables should be set appropriately on first invocation in order 26 | to bootstrap the client. These will be stored in state for future invocations. 27 | 28 | Example: 29 | 30 | poetry install 31 | COUNTRY_CODE=US LANGUAGE_CODE=en-US poetry run python example.py 32 | 33 | Example (Windows Powershell): 34 | 35 | $env:COUNTRY_CODE=US 36 | $env:LANGUAGE_CODE=en-US 37 | poetry run python example.py 38 | 39 | The example script will bootstrap the application state on first run, walking 40 | you through the OAuth flow. If authentication succeeds, you should see a 41 | display of basic account information and a list of your ThinQv2 devices. The 42 | script will then begin dumping device events received via MQTT. Try turning 43 | devices on/off or otherwise changing their state, and you should see raw events 44 | being sent. 45 | 46 | 47 | ## Contributing 48 | 49 | As this is an early stage prototype, no contribution guidelines are available, 50 | but help is always welcome! 51 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import json 4 | import signal 5 | 6 | from thinq2.controller.auth import ThinQAuth 7 | from thinq2.controller.thinq import ThinQ 8 | 9 | LANGUAGE_CODE = os.environ.get("LANGUAGE_CODE", "ko-KR") 10 | COUNTRY_CODE = os.environ.get("COUNTRY_CODE", "KR") 11 | STATE_FILE = os.environ.get("STATE_FILE", "state.json") 12 | 13 | ############################################################################# 14 | # load from existing state or create a new client # 15 | ############################################################################# 16 | if os.path.exists(STATE_FILE): 17 | with open(STATE_FILE, "r") as f: 18 | thinq = ThinQ(json.load(f)) 19 | else: 20 | auth = ThinQAuth(language_code=LANGUAGE_CODE, country_code=COUNTRY_CODE) 21 | 22 | print("No state file found, starting new client session.\n") 23 | print( 24 | "Please set the following environment variables if the default is not correct:\n" 25 | ) 26 | print("LANGUAGE_CODE={} COUNTRY_CODE={}\n".format(LANGUAGE_CODE, COUNTRY_CODE)) 27 | print("Log in here:\n") 28 | print(auth.oauth_login_url) 29 | print("\nThen paste the URL to which the browser is redirected:\n") 30 | 31 | callback_url = input() 32 | auth.set_token_from_url(callback_url) 33 | thinq = ThinQ(auth=auth) 34 | 35 | print("\n") 36 | 37 | 38 | def save_state(): 39 | with open(STATE_FILE, "w") as f: 40 | json.dump(vars(thinq), f) 41 | 42 | 43 | save_state() 44 | 45 | ############################################################################# 46 | # state is easily serialized in dict form, as in this shutdown handler # 47 | ############################################################################# 48 | def shutdown(sig, frame): 49 | print("\nCaught SIGINT, saving application state.") 50 | exit(0) 51 | 52 | 53 | signal.signal(signal.SIGINT, shutdown) 54 | 55 | ############################################################################# 56 | # display some information about the user's account/devices # 57 | ############################################################################# 58 | devices = thinq.mqtt.thinq_client.get_devices() 59 | 60 | if len(devices.items) == 0: 61 | print("No devices found!") 62 | print("If you are using ThinQ v1 devices, try https://github.com/sampsyo/wideq") 63 | exit(1) 64 | 65 | print("UserID: {}".format(thinq.auth.profile.user_id)) 66 | print("User #: {}\n".format(thinq.auth.profile.user_no)) 67 | print("Devices:\n") 68 | 69 | 70 | for device in devices.items: 71 | print("{}: {} (model {})".format(device.device_id, device.alias, device.model_name)) 72 | 73 | ############################################################################# 74 | # example of raw MQTT access # 75 | ############################################################################# 76 | 77 | print("\nListening for device events. Use Ctrl-C/SIGINT to quit.\n") 78 | 79 | thinq.mqtt.on_message = lambda client, userdata, msg: print(msg.payload) 80 | thinq.mqtt.connect() 81 | thinq.mqtt.loop_forever() 82 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | category = "dev" 3 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 4 | name = "appdirs" 5 | optional = false 6 | python-versions = "*" 7 | version = "1.4.4" 8 | 9 | [[package]] 10 | category = "dev" 11 | description = "An abstract syntax tree for Python with inference support." 12 | name = "astroid" 13 | optional = false 14 | python-versions = ">=3.5.*" 15 | version = "2.3.3" 16 | 17 | [package.dependencies] 18 | lazy-object-proxy = ">=1.4.0,<1.5.0" 19 | six = ">=1.12,<2.0" 20 | wrapt = ">=1.11.0,<1.12.0" 21 | 22 | [package.dependencies.typed-ast] 23 | python = "<3.8" 24 | version = ">=1.4.0,<1.5" 25 | 26 | [[package]] 27 | category = "dev" 28 | description = "Atomic file writes." 29 | marker = "sys_platform == \"win32\"" 30 | name = "atomicwrites" 31 | optional = false 32 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 33 | version = "1.3.0" 34 | 35 | [[package]] 36 | category = "main" 37 | description = "A dict with attribute-style access" 38 | name = "attrdict" 39 | optional = false 40 | python-versions = "*" 41 | version = "2.0.1" 42 | 43 | [package.dependencies] 44 | six = "*" 45 | 46 | [[package]] 47 | category = "dev" 48 | description = "Classes Without Boilerplate" 49 | name = "attrs" 50 | optional = false 51 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 52 | version = "19.3.0" 53 | 54 | [package.extras] 55 | azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] 56 | dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] 57 | docs = ["sphinx", "zope.interface"] 58 | tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] 59 | 60 | [[package]] 61 | category = "dev" 62 | description = "The uncompromising code formatter." 63 | name = "black" 64 | optional = false 65 | python-versions = ">=3.6" 66 | version = "19.10b0" 67 | 68 | [package.dependencies] 69 | appdirs = "*" 70 | attrs = ">=18.1.0" 71 | click = ">=6.5" 72 | pathspec = ">=0.6,<1" 73 | regex = "*" 74 | toml = ">=0.9.4" 75 | typed-ast = ">=1.4.0" 76 | 77 | [package.extras] 78 | d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] 79 | 80 | [[package]] 81 | category = "main" 82 | description = "Python package for providing Mozilla's CA Bundle." 83 | name = "certifi" 84 | optional = false 85 | python-versions = "*" 86 | version = "2020.4.5.1" 87 | 88 | [[package]] 89 | category = "main" 90 | description = "Foreign Function Interface for Python calling C code." 91 | name = "cffi" 92 | optional = false 93 | python-versions = "*" 94 | version = "1.14.0" 95 | 96 | [package.dependencies] 97 | pycparser = "*" 98 | 99 | [[package]] 100 | category = "main" 101 | description = "Universal encoding detector for Python 2 and 3" 102 | name = "chardet" 103 | optional = false 104 | python-versions = "*" 105 | version = "3.0.4" 106 | 107 | [[package]] 108 | category = "dev" 109 | description = "Composable command line interface toolkit" 110 | name = "click" 111 | optional = false 112 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 113 | version = "7.1.2" 114 | 115 | [[package]] 116 | category = "dev" 117 | description = "Cross-platform colored terminal text." 118 | marker = "sys_platform == \"win32\"" 119 | name = "colorama" 120 | optional = false 121 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 122 | version = "0.4.3" 123 | 124 | [[package]] 125 | category = "main" 126 | description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." 127 | name = "cryptography" 128 | optional = false 129 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" 130 | version = "2.9.2" 131 | 132 | [package.dependencies] 133 | cffi = ">=1.8,<1.11.3 || >1.11.3" 134 | six = ">=1.4.1" 135 | 136 | [package.extras] 137 | docs = ["sphinx (>=1.6.5,<1.8.0 || >1.8.0)", "sphinx-rtd-theme"] 138 | docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] 139 | idna = ["idna (>=2.1)"] 140 | pep8test = ["flake8", "flake8-import-order", "pep8-naming"] 141 | test = ["pytest (>=3.6.0,<3.9.0 || >3.9.0,<3.9.1 || >3.9.1,<3.9.2 || >3.9.2)", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,<3.79.2 || >3.79.2)"] 142 | 143 | [[package]] 144 | category = "main" 145 | description = "a toolset to deeply merge python dictionaries." 146 | name = "deepmerge" 147 | optional = false 148 | python-versions = "*" 149 | version = "0.1.0" 150 | 151 | [[package]] 152 | category = "dev" 153 | description = "the modular source code checker: pep8 pyflakes and co" 154 | name = "flake8" 155 | optional = false 156 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 157 | version = "3.8.1" 158 | 159 | [package.dependencies] 160 | mccabe = ">=0.6.0,<0.7.0" 161 | pycodestyle = ">=2.6.0a1,<2.7.0" 162 | pyflakes = ">=2.2.0,<2.3.0" 163 | 164 | [package.dependencies.importlib-metadata] 165 | python = "<3.8" 166 | version = "*" 167 | 168 | [[package]] 169 | category = "main" 170 | description = "Internationalized Domain Names in Applications (IDNA)" 171 | name = "idna" 172 | optional = false 173 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 174 | version = "2.9" 175 | 176 | [[package]] 177 | category = "dev" 178 | description = "Read metadata from Python packages" 179 | marker = "python_version < \"3.8\"" 180 | name = "importlib-metadata" 181 | optional = false 182 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 183 | version = "1.6.0" 184 | 185 | [package.dependencies] 186 | zipp = ">=0.5" 187 | 188 | [package.extras] 189 | docs = ["sphinx", "rst.linker"] 190 | testing = ["packaging", "importlib-resources"] 191 | 192 | [[package]] 193 | category = "main" 194 | description = "A port of Ruby on Rails inflector to Python" 195 | name = "inflection" 196 | optional = false 197 | python-versions = ">=3.5" 198 | version = "0.4.0" 199 | 200 | [[package]] 201 | category = "dev" 202 | description = "A Python utility / library to sort Python imports." 203 | name = "isort" 204 | optional = false 205 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 206 | version = "4.3.21" 207 | 208 | [package.extras] 209 | pipfile = ["pipreqs", "requirementslib"] 210 | pyproject = ["toml"] 211 | requirements = ["pipreqs", "pip-api"] 212 | xdg_home = ["appdirs (>=1.4.0)"] 213 | 214 | [[package]] 215 | category = "dev" 216 | description = "A fast and thorough lazy object proxy." 217 | name = "lazy-object-proxy" 218 | optional = false 219 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 220 | version = "1.4.3" 221 | 222 | [[package]] 223 | category = "main" 224 | description = "A lightweight library for converting complex datatypes to and from native Python datatypes." 225 | name = "marshmallow" 226 | optional = false 227 | python-versions = ">=3.5" 228 | version = "3.5.1" 229 | 230 | [package.extras] 231 | dev = ["pytest", "pytz", "simplejson", "mypy (0.761)", "flake8 (3.7.9)", "flake8-bugbear (20.1.4)", "pre-commit (>=1.20,<3.0)", "tox"] 232 | docs = ["sphinx (2.4.3)", "sphinx-issues (1.2.0)", "alabaster (0.7.12)", "sphinx-version-warning (1.1.2)"] 233 | lint = ["mypy (0.761)", "flake8 (3.7.9)", "flake8-bugbear (20.1.4)", "pre-commit (>=1.20,<3.0)"] 234 | tests = ["pytest", "pytz", "simplejson"] 235 | 236 | [[package]] 237 | category = "main" 238 | description = "Python library to convert dataclasses into marshmallow schemas." 239 | name = "marshmallow-dataclass" 240 | optional = false 241 | python-versions = ">=3.6" 242 | version = "7.5.0" 243 | 244 | [package.dependencies] 245 | marshmallow = ">=3.0.0,<4.0" 246 | typing-inspect = "*" 247 | 248 | [package.extras] 249 | dev = ["marshmallow-enum", "marshmallow-union", "pre-commit (>=1.18,<2.0)", "sphinx", "pytest", "pytest-mypy-plugins (>=1.2.0)"] 250 | docs = ["sphinx"] 251 | enum = ["marshmallow-enum"] 252 | lint = ["pre-commit (>=1.18,<2.0)"] 253 | tests = ["pytest", "pytest-mypy-plugins (>=1.2.0)"] 254 | union = ["marshmallow-union"] 255 | 256 | [[package]] 257 | category = "main" 258 | description = "Enum field for Marshmallow" 259 | name = "marshmallow-enum" 260 | optional = false 261 | python-versions = "*" 262 | version = "1.5.1" 263 | 264 | [package.dependencies] 265 | marshmallow = ">=2.0.0" 266 | 267 | [[package]] 268 | category = "dev" 269 | description = "McCabe checker, plugin for flake8" 270 | name = "mccabe" 271 | optional = false 272 | python-versions = "*" 273 | version = "0.6.1" 274 | 275 | [[package]] 276 | category = "dev" 277 | description = "More routines for operating on iterables, beyond itertools" 278 | name = "more-itertools" 279 | optional = false 280 | python-versions = ">=3.5" 281 | version = "8.2.0" 282 | 283 | [[package]] 284 | category = "main" 285 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 286 | name = "mypy-extensions" 287 | optional = false 288 | python-versions = "*" 289 | version = "0.4.3" 290 | 291 | [[package]] 292 | category = "dev" 293 | description = "Core utilities for Python packages" 294 | name = "packaging" 295 | optional = false 296 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 297 | version = "20.3" 298 | 299 | [package.dependencies] 300 | pyparsing = ">=2.0.2" 301 | six = "*" 302 | 303 | [[package]] 304 | category = "main" 305 | description = "MQTT version 3.1.1 client class" 306 | name = "paho-mqtt" 307 | optional = false 308 | python-versions = "*" 309 | version = "1.5.0" 310 | 311 | [package.extras] 312 | proxy = ["pysocks"] 313 | 314 | [[package]] 315 | category = "dev" 316 | description = "Utility library for gitignore style pattern matching of file paths." 317 | name = "pathspec" 318 | optional = false 319 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 320 | version = "0.8.0" 321 | 322 | [[package]] 323 | category = "dev" 324 | description = "plugin and hook calling mechanisms for python" 325 | name = "pluggy" 326 | optional = false 327 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 328 | version = "0.13.1" 329 | 330 | [package.dependencies] 331 | [package.dependencies.importlib-metadata] 332 | python = "<3.8" 333 | version = ">=0.12" 334 | 335 | [package.extras] 336 | dev = ["pre-commit", "tox"] 337 | 338 | [[package]] 339 | category = "dev" 340 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 341 | name = "py" 342 | optional = false 343 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 344 | version = "1.8.1" 345 | 346 | [[package]] 347 | category = "dev" 348 | description = "Python style guide checker" 349 | name = "pycodestyle" 350 | optional = false 351 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 352 | version = "2.6.0" 353 | 354 | [[package]] 355 | category = "main" 356 | description = "C parser in Python" 357 | name = "pycparser" 358 | optional = false 359 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 360 | version = "2.20" 361 | 362 | [[package]] 363 | category = "dev" 364 | description = "passive checker of Python programs" 365 | name = "pyflakes" 366 | optional = false 367 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 368 | version = "2.2.0" 369 | 370 | [[package]] 371 | category = "dev" 372 | description = "python code static checker" 373 | name = "pylint" 374 | optional = false 375 | python-versions = ">=3.5.*" 376 | version = "2.4.4" 377 | 378 | [package.dependencies] 379 | astroid = ">=2.3.0,<2.4" 380 | colorama = "*" 381 | isort = ">=4.2.5,<5" 382 | mccabe = ">=0.6,<0.7" 383 | 384 | [[package]] 385 | category = "main" 386 | description = "Python wrapper module around the OpenSSL library" 387 | name = "pyopenssl" 388 | optional = false 389 | python-versions = "*" 390 | version = "19.1.0" 391 | 392 | [package.dependencies] 393 | cryptography = ">=2.8" 394 | six = ">=1.5.2" 395 | 396 | [package.extras] 397 | docs = ["sphinx", "sphinx-rtd-theme"] 398 | test = ["flaky", "pretend", "pytest (>=3.0.1)"] 399 | 400 | [[package]] 401 | category = "dev" 402 | description = "Python parsing module" 403 | name = "pyparsing" 404 | optional = false 405 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 406 | version = "2.4.7" 407 | 408 | [[package]] 409 | category = "dev" 410 | description = "pytest: simple powerful testing with Python" 411 | name = "pytest" 412 | optional = false 413 | python-versions = ">=3.5" 414 | version = "5.4.1" 415 | 416 | [package.dependencies] 417 | atomicwrites = ">=1.0" 418 | attrs = ">=17.4.0" 419 | colorama = "*" 420 | more-itertools = ">=4.0.0" 421 | packaging = "*" 422 | pluggy = ">=0.12,<1.0" 423 | py = ">=1.5.0" 424 | wcwidth = "*" 425 | 426 | [package.dependencies.importlib-metadata] 427 | python = "<3.8" 428 | version = ">=0.12" 429 | 430 | [package.extras] 431 | checkqa-mypy = ["mypy (v0.761)"] 432 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 433 | 434 | [[package]] 435 | category = "dev" 436 | description = "Alternative regular expression module, to replace re." 437 | name = "regex" 438 | optional = false 439 | python-versions = "*" 440 | version = "2020.5.14" 441 | 442 | [[package]] 443 | category = "main" 444 | description = "Python HTTP for Humans." 445 | name = "requests" 446 | optional = false 447 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 448 | version = "2.23.0" 449 | 450 | [package.dependencies] 451 | certifi = ">=2017.4.17" 452 | chardet = ">=3.0.2,<4" 453 | idna = ">=2.5,<3" 454 | urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" 455 | 456 | [package.extras] 457 | security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] 458 | socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] 459 | 460 | [[package]] 461 | category = "dev" 462 | description = "a python refactoring library..." 463 | name = "rope" 464 | optional = false 465 | python-versions = "*" 466 | version = "0.17.0" 467 | 468 | [package.extras] 469 | dev = ["pytest"] 470 | 471 | [[package]] 472 | category = "main" 473 | description = "Python 2 and 3 compatibility utilities" 474 | name = "six" 475 | optional = false 476 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 477 | version = "1.14.0" 478 | 479 | [[package]] 480 | category = "dev" 481 | description = "Python Library for Tom's Obvious, Minimal Language" 482 | name = "toml" 483 | optional = false 484 | python-versions = "*" 485 | version = "0.10.1" 486 | 487 | [[package]] 488 | category = "dev" 489 | description = "a fork of Python 2 and 3 ast modules with type comment support" 490 | name = "typed-ast" 491 | optional = false 492 | python-versions = "*" 493 | version = "1.4.1" 494 | 495 | [[package]] 496 | category = "main" 497 | description = "Backported and Experimental Type Hints for Python 3.5+" 498 | name = "typing-extensions" 499 | optional = false 500 | python-versions = "*" 501 | version = "3.7.4.2" 502 | 503 | [[package]] 504 | category = "main" 505 | description = "Runtime inspection utilities for typing module." 506 | name = "typing-inspect" 507 | optional = false 508 | python-versions = "*" 509 | version = "0.5.0" 510 | 511 | [package.dependencies] 512 | mypy-extensions = ">=0.3.0" 513 | typing-extensions = ">=3.7.4" 514 | 515 | [[package]] 516 | category = "main" 517 | description = "A Declarative HTTP Client for Python." 518 | name = "uplink" 519 | optional = false 520 | python-versions = "*" 521 | version = "0.9.1" 522 | 523 | [package.dependencies] 524 | requests = ">=2.18.0" 525 | six = ">=1.12.0" 526 | uritemplate = ">=3.0.0" 527 | 528 | [package.extras] 529 | aiohttp = ["aiohttp (>=2.3.0)"] 530 | marshmallow = ["marshmallow (>=2.15.0)"] 531 | tests = ["pytest (4.6.5)", "pytest-mock", "pytest-cov", "pytest-twisted"] 532 | twisted = ["twisted (>=17.1.0)", "twisted (<=17.9.0)", "twisted (<=19.2.1)"] 533 | typing = ["typing (>=3.6.4)"] 534 | 535 | [[package]] 536 | category = "main" 537 | description = "URI templates" 538 | name = "uritemplate" 539 | optional = false 540 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 541 | version = "3.0.1" 542 | 543 | [[package]] 544 | category = "main" 545 | description = "HTTP library with thread-safe connection pooling, file post, and more." 546 | name = "urllib3" 547 | optional = false 548 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 549 | version = "1.25.8" 550 | 551 | [package.extras] 552 | brotli = ["brotlipy (>=0.6.0)"] 553 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] 554 | socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] 555 | 556 | [[package]] 557 | category = "dev" 558 | description = "Measures number of Terminal column cells of wide-character codes" 559 | name = "wcwidth" 560 | optional = false 561 | python-versions = "*" 562 | version = "0.1.9" 563 | 564 | [[package]] 565 | category = "dev" 566 | description = "Module for decorators, wrappers and monkey patching." 567 | name = "wrapt" 568 | optional = false 569 | python-versions = "*" 570 | version = "1.11.2" 571 | 572 | [[package]] 573 | category = "dev" 574 | description = "Backport of pathlib-compatible object wrapper for zip files" 575 | marker = "python_version < \"3.8\"" 576 | name = "zipp" 577 | optional = false 578 | python-versions = ">=3.6" 579 | version = "3.1.0" 580 | 581 | [package.extras] 582 | docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] 583 | testing = ["jaraco.itertools", "func-timeout"] 584 | 585 | [metadata] 586 | content-hash = "45b93b5b0b890d59981e3b3b8db5960c8f1ec9be70c01fc0277c6eaeabb810ed" 587 | python-versions = "^3.7" 588 | 589 | [metadata.files] 590 | appdirs = [ 591 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, 592 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, 593 | ] 594 | astroid = [ 595 | {file = "astroid-2.3.3-py3-none-any.whl", hash = "sha256:840947ebfa8b58f318d42301cf8c0a20fd794a33b61cc4638e28e9e61ba32f42"}, 596 | {file = "astroid-2.3.3.tar.gz", hash = "sha256:71ea07f44df9568a75d0f354c49143a4575d90645e9fead6dfb52c26a85ed13a"}, 597 | ] 598 | atomicwrites = [ 599 | {file = "atomicwrites-1.3.0-py2.py3-none-any.whl", hash = "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4"}, 600 | {file = "atomicwrites-1.3.0.tar.gz", hash = "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"}, 601 | ] 602 | attrdict = [ 603 | {file = "attrdict-2.0.1-py2.py3-none-any.whl", hash = "sha256:9432e3498c74ff7e1b20b3d93b45d766b71cbffa90923496f82c4ae38b92be34"}, 604 | {file = "attrdict-2.0.1.tar.gz", hash = "sha256:35c90698b55c683946091177177a9e9c0713a0860f0e049febd72649ccd77b70"}, 605 | ] 606 | attrs = [ 607 | {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, 608 | {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, 609 | ] 610 | black = [ 611 | {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, 612 | {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, 613 | ] 614 | certifi = [ 615 | {file = "certifi-2020.4.5.1-py2.py3-none-any.whl", hash = "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304"}, 616 | {file = "certifi-2020.4.5.1.tar.gz", hash = "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"}, 617 | ] 618 | cffi = [ 619 | {file = "cffi-1.14.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384"}, 620 | {file = "cffi-1.14.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30"}, 621 | {file = "cffi-1.14.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c"}, 622 | {file = "cffi-1.14.0-cp27-cp27m-win32.whl", hash = "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78"}, 623 | {file = "cffi-1.14.0-cp27-cp27m-win_amd64.whl", hash = "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793"}, 624 | {file = "cffi-1.14.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e"}, 625 | {file = "cffi-1.14.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a"}, 626 | {file = "cffi-1.14.0-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff"}, 627 | {file = "cffi-1.14.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f"}, 628 | {file = "cffi-1.14.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa"}, 629 | {file = "cffi-1.14.0-cp35-cp35m-win32.whl", hash = "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5"}, 630 | {file = "cffi-1.14.0-cp35-cp35m-win_amd64.whl", hash = "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4"}, 631 | {file = "cffi-1.14.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d"}, 632 | {file = "cffi-1.14.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc"}, 633 | {file = "cffi-1.14.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac"}, 634 | {file = "cffi-1.14.0-cp36-cp36m-win32.whl", hash = "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f"}, 635 | {file = "cffi-1.14.0-cp36-cp36m-win_amd64.whl", hash = "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b"}, 636 | {file = "cffi-1.14.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3"}, 637 | {file = "cffi-1.14.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66"}, 638 | {file = "cffi-1.14.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0"}, 639 | {file = "cffi-1.14.0-cp37-cp37m-win32.whl", hash = "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f"}, 640 | {file = "cffi-1.14.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26"}, 641 | {file = "cffi-1.14.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd"}, 642 | {file = "cffi-1.14.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55"}, 643 | {file = "cffi-1.14.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2"}, 644 | {file = "cffi-1.14.0-cp38-cp38-win32.whl", hash = "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8"}, 645 | {file = "cffi-1.14.0-cp38-cp38-win_amd64.whl", hash = "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b"}, 646 | {file = "cffi-1.14.0.tar.gz", hash = "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6"}, 647 | ] 648 | chardet = [ 649 | {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, 650 | {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, 651 | ] 652 | click = [ 653 | {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, 654 | {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, 655 | ] 656 | colorama = [ 657 | {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, 658 | {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, 659 | ] 660 | cryptography = [ 661 | {file = "cryptography-2.9.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e"}, 662 | {file = "cryptography-2.9.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b"}, 663 | {file = "cryptography-2.9.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:c447cf087cf2dbddc1add6987bbe2f767ed5317adb2d08af940db517dd704365"}, 664 | {file = "cryptography-2.9.2-cp27-cp27m-win32.whl", hash = "sha256:f118a95c7480f5be0df8afeb9a11bd199aa20afab7a96bcf20409b411a3a85f0"}, 665 | {file = "cryptography-2.9.2-cp27-cp27m-win_amd64.whl", hash = "sha256:c4fd17d92e9d55b84707f4fd09992081ba872d1a0c610c109c18e062e06a2e55"}, 666 | {file = "cryptography-2.9.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d0d5aeaedd29be304848f1c5059074a740fa9f6f26b84c5b63e8b29e73dfc270"}, 667 | {file = "cryptography-2.9.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:1e4014639d3d73fbc5ceff206049c5a9a849cefd106a49fa7aaaa25cc0ce35cf"}, 668 | {file = "cryptography-2.9.2-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:96c080ae7118c10fcbe6229ab43eb8b090fccd31a09ef55f83f690d1ef619a1d"}, 669 | {file = "cryptography-2.9.2-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:e993468c859d084d5579e2ebee101de8f5a27ce8e2159959b6673b418fd8c785"}, 670 | {file = "cryptography-2.9.2-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:88c881dd5a147e08d1bdcf2315c04972381d026cdb803325c03fe2b4a8ed858b"}, 671 | {file = "cryptography-2.9.2-cp35-cp35m-win32.whl", hash = "sha256:651448cd2e3a6bc2bb76c3663785133c40d5e1a8c1a9c5429e4354201c6024ae"}, 672 | {file = "cryptography-2.9.2-cp35-cp35m-win_amd64.whl", hash = "sha256:726086c17f94747cedbee6efa77e99ae170caebeb1116353c6cf0ab67ea6829b"}, 673 | {file = "cryptography-2.9.2-cp36-cp36m-win32.whl", hash = "sha256:091d31c42f444c6f519485ed528d8b451d1a0c7bf30e8ca583a0cac44b8a0df6"}, 674 | {file = "cryptography-2.9.2-cp36-cp36m-win_amd64.whl", hash = "sha256:bb1f0281887d89617b4c68e8db9a2c42b9efebf2702a3c5bf70599421a8623e3"}, 675 | {file = "cryptography-2.9.2-cp37-cp37m-win32.whl", hash = "sha256:18452582a3c85b96014b45686af264563e3e5d99d226589f057ace56196ec78b"}, 676 | {file = "cryptography-2.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:22e91636a51170df0ae4dcbd250d318fd28c9f491c4e50b625a49964b24fe46e"}, 677 | {file = "cryptography-2.9.2-cp38-cp38-win32.whl", hash = "sha256:844a76bc04472e5135b909da6aed84360f522ff5dfa47f93e3dd2a0b84a89fa0"}, 678 | {file = "cryptography-2.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:1dfa985f62b137909496e7fc182dac687206d8d089dd03eaeb28ae16eec8e7d5"}, 679 | {file = "cryptography-2.9.2.tar.gz", hash = "sha256:a0c30272fb4ddda5f5ffc1089d7405b7a71b0b0f51993cb4e5dbb4590b2fc229"}, 680 | ] 681 | deepmerge = [ 682 | {file = "deepmerge-0.1.0-py2.py3-none-any.whl", hash = "sha256:ae23dd76d3c0d22d33a3fd3980c92d3f0773e4affb48d9b341847d0b0a24e8f8"}, 683 | {file = "deepmerge-0.1.0.tar.gz", hash = "sha256:3d37f739e74e8a284ee0bd683daaef88acc8438ba048545aefb87ade695a2a34"}, 684 | ] 685 | flake8 = [ 686 | {file = "flake8-3.8.1-py2.py3-none-any.whl", hash = "sha256:6c1193b0c3f853ef763969238f6c81e9e63ace9d024518edc020d5f1d6d93195"}, 687 | {file = "flake8-3.8.1.tar.gz", hash = "sha256:ea6623797bf9a52f4c9577d780da0bb17d65f870213f7b5bcc9fca82540c31d5"}, 688 | ] 689 | idna = [ 690 | {file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"}, 691 | {file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"}, 692 | ] 693 | importlib-metadata = [ 694 | {file = "importlib_metadata-1.6.0-py2.py3-none-any.whl", hash = "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f"}, 695 | {file = "importlib_metadata-1.6.0.tar.gz", hash = "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e"}, 696 | ] 697 | inflection = [ 698 | {file = "inflection-0.4.0-py2.py3-none-any.whl", hash = "sha256:9a15d3598f01220e93f2207c432cfede50daff53137ce660fb8be838ef1ca6cc"}, 699 | {file = "inflection-0.4.0.tar.gz", hash = "sha256:32a5c3341d9583ec319548b9015b7fbdf8c429cbcb575d326c33ae3a0e90d52c"}, 700 | ] 701 | isort = [ 702 | {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"}, 703 | {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"}, 704 | ] 705 | lazy-object-proxy = [ 706 | {file = "lazy-object-proxy-1.4.3.tar.gz", hash = "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"}, 707 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-macosx_10_13_x86_64.whl", hash = "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442"}, 708 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-win32.whl", hash = "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4"}, 709 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-win_amd64.whl", hash = "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a"}, 710 | {file = "lazy_object_proxy-1.4.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d"}, 711 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a"}, 712 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-win32.whl", hash = "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e"}, 713 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-win_amd64.whl", hash = "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357"}, 714 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50"}, 715 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db"}, 716 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449"}, 717 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156"}, 718 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531"}, 719 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb"}, 720 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08"}, 721 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383"}, 722 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142"}, 723 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea"}, 724 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62"}, 725 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-win32.whl", hash = "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd"}, 726 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239"}, 727 | ] 728 | marshmallow = [ 729 | {file = "marshmallow-3.5.1-py2.py3-none-any.whl", hash = "sha256:ac2e13b30165501b7d41fc0371b8df35944f5849769d136f20e2c5f6cdc6e665"}, 730 | {file = "marshmallow-3.5.1.tar.gz", hash = "sha256:90854221bbb1498d003a0c3cc9d8390259137551917961c8b5258c64026b2f85"}, 731 | ] 732 | marshmallow-dataclass = [ 733 | {file = "marshmallow_dataclass-7.5.0.tar.gz", hash = "sha256:1bf04647541ab7c5fd4184984391c56b1613ff7e0ef9dbd63acfb5f509f43951"}, 734 | ] 735 | marshmallow-enum = [ 736 | {file = "marshmallow-enum-1.5.1.tar.gz", hash = "sha256:38e697e11f45a8e64b4a1e664000897c659b60aa57bfa18d44e226a9920b6e58"}, 737 | {file = "marshmallow_enum-1.5.1-py2.py3-none-any.whl", hash = "sha256:57161ab3dbfde4f57adeb12090f39592e992b9c86d206d02f6bd03ebec60f072"}, 738 | ] 739 | mccabe = [ 740 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 741 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 742 | ] 743 | more-itertools = [ 744 | {file = "more-itertools-8.2.0.tar.gz", hash = "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"}, 745 | {file = "more_itertools-8.2.0-py3-none-any.whl", hash = "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c"}, 746 | ] 747 | mypy-extensions = [ 748 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 749 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 750 | ] 751 | packaging = [ 752 | {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, 753 | {file = "packaging-20.3.tar.gz", hash = "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3"}, 754 | ] 755 | paho-mqtt = [ 756 | {file = "paho-mqtt-1.5.0.tar.gz", hash = "sha256:e3d286198baaea195c8b3bc221941d25a3ab0e1507fc1779bdb7473806394be4"}, 757 | ] 758 | pathspec = [ 759 | {file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"}, 760 | {file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"}, 761 | ] 762 | pluggy = [ 763 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 764 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 765 | ] 766 | py = [ 767 | {file = "py-1.8.1-py2.py3-none-any.whl", hash = "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"}, 768 | {file = "py-1.8.1.tar.gz", hash = "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa"}, 769 | ] 770 | pycodestyle = [ 771 | {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, 772 | {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, 773 | ] 774 | pycparser = [ 775 | {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, 776 | {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, 777 | ] 778 | pyflakes = [ 779 | {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, 780 | {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, 781 | ] 782 | pylint = [ 783 | {file = "pylint-2.4.4-py3-none-any.whl", hash = "sha256:886e6afc935ea2590b462664b161ca9a5e40168ea99e5300935f6591ad467df4"}, 784 | {file = "pylint-2.4.4.tar.gz", hash = "sha256:3db5468ad013380e987410a8d6956226963aed94ecb5f9d3a28acca6d9ac36cd"}, 785 | ] 786 | pyopenssl = [ 787 | {file = "pyOpenSSL-19.1.0-py2.py3-none-any.whl", hash = "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504"}, 788 | {file = "pyOpenSSL-19.1.0.tar.gz", hash = "sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507"}, 789 | ] 790 | pyparsing = [ 791 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 792 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 793 | ] 794 | pytest = [ 795 | {file = "pytest-5.4.1-py3-none-any.whl", hash = "sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172"}, 796 | {file = "pytest-5.4.1.tar.gz", hash = "sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970"}, 797 | ] 798 | regex = [ 799 | {file = "regex-2020.5.14-cp27-cp27m-win32.whl", hash = "sha256:e565569fc28e3ba3e475ec344d87ed3cd8ba2d575335359749298a0899fe122e"}, 800 | {file = "regex-2020.5.14-cp27-cp27m-win_amd64.whl", hash = "sha256:d466967ac8e45244b9dfe302bbe5e3337f8dc4dec8d7d10f5e950d83b140d33a"}, 801 | {file = "regex-2020.5.14-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:27ff7325b297fb6e5ebb70d10437592433601c423f5acf86e5bc1ee2919b9561"}, 802 | {file = "regex-2020.5.14-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ea55b80eb0d1c3f1d8d784264a6764f931e172480a2f1868f2536444c5f01e01"}, 803 | {file = "regex-2020.5.14-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:c9bce6e006fbe771a02bda468ec40ffccbf954803b470a0345ad39c603402577"}, 804 | {file = "regex-2020.5.14-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:d881c2e657c51d89f02ae4c21d9adbef76b8325fe4d5cf0e9ad62f850f3a98fd"}, 805 | {file = "regex-2020.5.14-cp36-cp36m-win32.whl", hash = "sha256:99568f00f7bf820c620f01721485cad230f3fb28f57d8fbf4a7967ec2e446994"}, 806 | {file = "regex-2020.5.14-cp36-cp36m-win_amd64.whl", hash = "sha256:70c14743320a68c5dac7fc5a0f685be63bc2024b062fe2aaccc4acc3d01b14a1"}, 807 | {file = "regex-2020.5.14-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:a7c37f048ec3920783abab99f8f4036561a174f1314302ccfa4e9ad31cb00eb4"}, 808 | {file = "regex-2020.5.14-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:89d76ce33d3266173f5be80bd4efcbd5196cafc34100fdab814f9b228dee0fa4"}, 809 | {file = "regex-2020.5.14-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:51f17abbe973c7673a61863516bdc9c0ef467407a940f39501e786a07406699c"}, 810 | {file = "regex-2020.5.14-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:ce5cc53aa9fbbf6712e92c7cf268274eaff30f6bd12a0754e8133d85a8fb0f5f"}, 811 | {file = "regex-2020.5.14-cp37-cp37m-win32.whl", hash = "sha256:8044d1c085d49673aadb3d7dc20ef5cb5b030c7a4fa253a593dda2eab3059929"}, 812 | {file = "regex-2020.5.14-cp37-cp37m-win_amd64.whl", hash = "sha256:c2062c7d470751b648f1cacc3f54460aebfc261285f14bc6da49c6943bd48bdd"}, 813 | {file = "regex-2020.5.14-cp38-cp38-manylinux1_i686.whl", hash = "sha256:329ba35d711e3428db6b45a53b1b13a0a8ba07cbbcf10bbed291a7da45f106c3"}, 814 | {file = "regex-2020.5.14-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:579ea215c81d18da550b62ff97ee187b99f1b135fd894a13451e00986a080cad"}, 815 | {file = "regex-2020.5.14-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:3a9394197664e35566242686d84dfd264c07b20f93514e2e09d3c2b3ffdf78fe"}, 816 | {file = "regex-2020.5.14-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ce367d21f33e23a84fb83a641b3834dd7dd8e9318ad8ff677fbfae5915a239f7"}, 817 | {file = "regex-2020.5.14-cp38-cp38-win32.whl", hash = "sha256:1386e75c9d1574f6aa2e4eb5355374c8e55f9aac97e224a8a5a6abded0f9c927"}, 818 | {file = "regex-2020.5.14-cp38-cp38-win_amd64.whl", hash = "sha256:7e61be8a2900897803c293247ef87366d5df86bf701083b6c43119c7c6c99108"}, 819 | {file = "regex-2020.5.14.tar.gz", hash = "sha256:ce450ffbfec93821ab1fea94779a8440e10cf63819be6e176eb1973a6017aff5"}, 820 | ] 821 | requests = [ 822 | {file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"}, 823 | {file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"}, 824 | ] 825 | rope = [ 826 | {file = "rope-0.17.0.tar.gz", hash = "sha256:658ad6705f43dcf3d6df379da9486529cf30e02d9ea14c5682aa80eb33b649e1"}, 827 | ] 828 | six = [ 829 | {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, 830 | {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, 831 | ] 832 | toml = [ 833 | {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, 834 | {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, 835 | ] 836 | typed-ast = [ 837 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, 838 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, 839 | {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, 840 | {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, 841 | {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, 842 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, 843 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, 844 | {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, 845 | {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, 846 | {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, 847 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, 848 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, 849 | {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, 850 | {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, 851 | {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, 852 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, 853 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, 854 | {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, 855 | {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, 856 | {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, 857 | {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, 858 | ] 859 | typing-extensions = [ 860 | {file = "typing_extensions-3.7.4.2-py2-none-any.whl", hash = "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"}, 861 | {file = "typing_extensions-3.7.4.2-py3-none-any.whl", hash = "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5"}, 862 | {file = "typing_extensions-3.7.4.2.tar.gz", hash = "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae"}, 863 | ] 864 | typing-inspect = [ 865 | {file = "typing_inspect-0.5.0-py2-none-any.whl", hash = "sha256:75c97b7854426a129f3184c68588db29091ff58e6908ed520add1d52fc44df6e"}, 866 | {file = "typing_inspect-0.5.0-py3-none-any.whl", hash = "sha256:c6ed1cd34860857c53c146a6704a96da12e1661087828ce350f34addc6e5eee3"}, 867 | {file = "typing_inspect-0.5.0.tar.gz", hash = "sha256:811b44f92e780b90cfe7bac94249a4fae87cfaa9b40312765489255045231d9c"}, 868 | ] 869 | uplink = [ 870 | {file = "uplink-0.9.1-py2.py3-none-any.whl", hash = "sha256:b01b35cb1174d4006d901210b15700447ac2c576b4063fdd3b45f4e7c271c759"}, 871 | {file = "uplink-0.9.1.tar.gz", hash = "sha256:17355ed8219078bfa9c2b0419f757a9010f7b71f91a8ca1f460b5cd206266ba1"}, 872 | ] 873 | uritemplate = [ 874 | {file = "uritemplate-3.0.1-py2.py3-none-any.whl", hash = "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f"}, 875 | {file = "uritemplate-3.0.1.tar.gz", hash = "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae"}, 876 | ] 877 | urllib3 = [ 878 | {file = "urllib3-1.25.8-py2.py3-none-any.whl", hash = "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc"}, 879 | {file = "urllib3-1.25.8.tar.gz", hash = "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"}, 880 | ] 881 | wcwidth = [ 882 | {file = "wcwidth-0.1.9-py2.py3-none-any.whl", hash = "sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1"}, 883 | {file = "wcwidth-0.1.9.tar.gz", hash = "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1"}, 884 | ] 885 | wrapt = [ 886 | {file = "wrapt-1.11.2.tar.gz", hash = "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1"}, 887 | ] 888 | zipp = [ 889 | {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, 890 | {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, 891 | ] 892 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "thinq2" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["tinkerborg "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.7" 9 | uplink = "^0.9.1" 10 | marshmallow = "^3.5.1" 11 | marshmallow-dataclass = "^7.5.0" 12 | inflection = "^0.4.0" 13 | marshmallow_enum = "^1.5.1" 14 | PyOpenSSL = "^19.1.0" 15 | paho-mqtt = "^1.5.0" 16 | attrdict = "^2.0.1" 17 | deepmerge = "^0.1.0" 18 | 19 | [tool.poetry.dev-dependencies] 20 | pytest = "^5.2" 21 | pylint = "^2.4.4" 22 | black = "^19.10b0" 23 | flake8 = "^3.8.1" 24 | rope = "^0.17.0" 25 | 26 | [build-system] 27 | requires = ["poetry>=0.12"] 28 | build-backend = "poetry.masonry.api" 29 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | junit_family = xunit1 3 | 4 | filterwarnings = 5 | # deprecation warning from attrdict dependency 6 | ignore:Using or importing the ABCs:DeprecationWarning 7 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinkerborg/thinq2-python/9775de9fa1a775533e15fb734c319949f9a48f05/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_schema.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from marshmallow_dataclass import dataclass 4 | 5 | from thinq2 import schema 6 | 7 | 8 | def test_base_schema_ignores_unknown_fields(): 9 | model = schema.BaseSchema().load(dict(foo="bar")) 10 | assert model == {} 11 | 12 | 13 | def test_controller_can_be_constructed_from_dict(Controller, valid_data): 14 | controller = Controller(valid_data) 15 | assert isinstance(controller, Controller) 16 | 17 | 18 | def test_controller_can_be_constructed_from_kwargs(Controller, valid_data): 19 | controller = Controller(**valid_data) 20 | assert isinstance(controller, Controller) 21 | 22 | 23 | def test_controller_can_be_constructed_from_model(Controller, Model, valid_data): 24 | model = Model(**valid_data) 25 | controller = Controller(model) 26 | assert isinstance(controller, Controller) 27 | 28 | 29 | def test_controller_as_dict_equals_model_data(Controller, valid_data): 30 | controller = Controller(valid_data) 31 | assert vars(controller) == valid_data 32 | 33 | 34 | def test_controller_constructor_styles_are_equivalent(Controller, Model, valid_data): 35 | controller_from_dict = Controller(valid_data) 36 | controller_from_kwargs = Controller(**valid_data) 37 | controller_from_model = Controller(Model(**valid_data)) 38 | assert vars(controller_from_dict) == vars(controller_from_kwargs) 39 | assert vars(controller_from_dict) == vars(controller_from_model) 40 | 41 | 42 | def test_controller_instances_with_identical_data_are_not_equal(Controller, valid_data): 43 | controller_a = Controller(valid_data) 44 | controller_b = Controller(valid_data) 45 | assert controller_a != controller_b 46 | 47 | 48 | def test_initialzer_decorator_sets_default_value(Controller, data_with_missing_quux): 49 | quux = 43 50 | 51 | class QuuxedController(Controller): 52 | @schema.initializer 53 | def quux(self): 54 | return quux 55 | 56 | controller = QuuxedController(**data_with_missing_quux) 57 | assert controller.quux == quux 58 | 59 | 60 | def test_initialzer_ignored_when_value_supplied(Controller, data_with_missing_quux): 61 | quux = 43 62 | data = {**data_with_missing_quux, **dict(quux=quux + 1)} 63 | 64 | class QuuxedController(Controller): 65 | @schema.initializer 66 | def quux(self): 67 | return quux 68 | 69 | controller = QuuxedController(data) 70 | assert controller.quux != quux 71 | 72 | 73 | def test_initialize_nested_controller(ParentController, Controller, nested_data): 74 | controller = ParentController(nested_data) 75 | assert isinstance(controller.child, Controller) 76 | 77 | 78 | def test_nested_controller_as_dict_matches_data(ParentController, nested_data): 79 | controller = ParentController(nested_data) 80 | assert nested_data == vars(controller) 81 | 82 | 83 | @pytest.fixture 84 | def Model(): 85 | @dataclass 86 | class Model: 87 | foo: str 88 | quux: int 89 | 90 | return Model 91 | 92 | 93 | @pytest.fixture 94 | def Controller(Model): 95 | @schema.controller(Model) 96 | class Controller: 97 | pass 98 | 99 | return Controller 100 | 101 | 102 | @pytest.fixture 103 | def ParentModel(Model): 104 | @dataclass 105 | class ParentModel: 106 | baz: str 107 | child: Model 108 | 109 | return ParentModel 110 | 111 | 112 | @pytest.fixture 113 | def ParentController(ParentModel, Controller): 114 | @schema.controller(ParentModel) 115 | class ParentController: 116 | @schema.controller 117 | def child(self, child): 118 | return Controller(child) 119 | 120 | return ParentController 121 | 122 | 123 | @pytest.fixture 124 | def valid_data(): 125 | return dict(foo="bar", quux=42) 126 | 127 | 128 | @pytest.fixture 129 | def data_with_missing_quux(): 130 | return dict(foo="bar") 131 | 132 | 133 | @pytest.fixture 134 | def nested_data(valid_data): 135 | return dict(baz="xyzzy", child=valid_data) 136 | -------------------------------------------------------------------------------- /thinq2/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.0" 2 | 3 | 4 | API_KEY = "VGhpblEyLjAgU0VSVklDRQ==" 5 | SERVICE_CODE = "SVC202" 6 | SERVICE_PHASE = "OP" 7 | APP_LEVEL = "PRD" 8 | APP_OS = "ANDROID" 9 | APP_TYPE = "NUTS" 10 | APP_VERSION = "3.0.1700" 11 | DIVISION = "ha" 12 | OAUTH_REDIRECT_URI = "https://kr.m.lgaccount.com/login/iabClose" 13 | OAUTH_TIMESTAMP_FORMAT = "%a, %d %b %Y %H:%M:%S +0000" 14 | OAUTH_SECRET = "c053c2a6ddeb7ad97cb0eed0dcb31cf8" 15 | LGE_APP_KEY = "LGAO221A02" 16 | THIRD_PARTY_LOGINS = "GGL,AMZ,FBK" 17 | 18 | AWS_IOTT_CA_CERT_URL = "https://www.websecurity.digicert.com/content/dam/websitesecurity/digitalassets/desktop/pdfs/roots/VeriSign-Class%203-Public-Primary-Certification-Authority-G5.pem" 19 | AWS_IOTT_ALPN_PROTOCOL = "x-amzn-mqtt-ca" 20 | -------------------------------------------------------------------------------- /thinq2/client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinkerborg/thinq2-python/9775de9fa1a775533e15fb734c319949f9a48f05/thinq2/client/__init__.py -------------------------------------------------------------------------------- /thinq2/client/base.py: -------------------------------------------------------------------------------- 1 | from uplink import Consumer 2 | 3 | from thinq2.util import end_with 4 | 5 | 6 | class BaseClient(Consumer): 7 | """ Base client class """ 8 | 9 | def __init__(self, base_url=None, headers={}, **kwargs): 10 | super().__init__(end_with(base_url or self.base_url, "/"), **kwargs) 11 | self.session.headers.update(headers) 12 | -------------------------------------------------------------------------------- /thinq2/client/common.py: -------------------------------------------------------------------------------- 1 | from uplink import get 2 | 3 | from thinq2.client.base import BaseClient 4 | from thinq2.model.common import Route 5 | from thinq2.model.thinq import ThinQResult 6 | 7 | 8 | class CommonClient(BaseClient): 9 | """LG ThinQ Common API client""" 10 | 11 | base_url = "https://common.lgthinq.com/" 12 | 13 | @get("route") 14 | def get_route(self) -> ThinQResult(Route): 15 | """Retrieves route definition for current country/language (MQTT url, etc)""" 16 | -------------------------------------------------------------------------------- /thinq2/client/gateway.py: -------------------------------------------------------------------------------- 1 | from uplink import get 2 | 3 | from thinq2.client.base import BaseClient 4 | from thinq2.model.gateway import Gateway 5 | from thinq2.model.thinq import ThinQResult 6 | 7 | 8 | class GatewayClient(BaseClient): 9 | """LG ThinQ Gateway API client""" 10 | 11 | base_url = "https://route.lgthinq.com:46030/v1/" 12 | 13 | @get("service/application/gateway-uri") 14 | def get_gateway(self) -> ThinQResult(Gateway): 15 | """Retrieves Gateway information for current country/language""" 16 | -------------------------------------------------------------------------------- /thinq2/client/oauth.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import hmac 3 | import base64 4 | import hashlib 5 | 6 | from urllib.parse import urlencode 7 | 8 | from uplink import Consumer, Field 9 | from uplink import headers, form_url_encoded, get, post, response_handler 10 | from uplink.arguments import Header 11 | from uplink.decorators import inject 12 | from uplink.hooks import RequestAuditor 13 | 14 | import thinq2 15 | from thinq2.model.auth import OAuthToken, UserProfile 16 | 17 | REDIRECT_URI = "https://kr.m.lgaccount.com/login/iabClose" 18 | 19 | 20 | def lg_oauth_signer(request_builder): 21 | url = "/{}".format(request_builder.relative_url) 22 | 23 | if request_builder.info["data"]: 24 | # LG expects the form vars to be sorted alphabetically before signing 25 | form = urlencode(sorted(request_builder.info["data"].items())) 26 | url = "{}?{}".format(url, form) 27 | 28 | timestamp = datetime.datetime.utcnow().strftime(thinq2.OAUTH_TIMESTAMP_FORMAT) 29 | message = "{}\n{}".format(url, timestamp).encode("utf8") 30 | secret = thinq2.OAUTH_SECRET.encode("utf8") 31 | digest = hmac.new(secret, message, hashlib.sha1).digest() 32 | signature = base64.b64encode(digest) 33 | 34 | request_builder.info["headers"].update( 35 | { 36 | "x-lge-oauth-signature": signature, 37 | "x-lge-oauth-date": timestamp, 38 | "x-lge-appkey": thinq2.LGE_APP_KEY, 39 | } 40 | ) 41 | 42 | 43 | class BearerToken(Header): 44 | def _modify_request(self, request_builder, value): 45 | """Updates request header contents.""" 46 | request_builder.info["headers"]["Authorization"] = "Bearer {}".format(value) 47 | 48 | 49 | @inject(RequestAuditor(lg_oauth_signer)) 50 | @headers({"Accept": "application/json"}) 51 | class OAuthClient(Consumer): 52 | """LG ThinQ OAuth Client""" 53 | 54 | auth = {} 55 | 56 | @form_url_encoded 57 | @post("oauth/1.0/oauth2/token") 58 | def get_token( 59 | self, 60 | code: Field, 61 | grant_type: Field = "authorization_code", 62 | redirect_uri: Field = REDIRECT_URI, 63 | ) -> OAuthToken.Schema(): 64 | 65 | """Retrieves initial OAuth token from authorization code""" 66 | 67 | @form_url_encoded 68 | @post("oauth/1.0/oauth2/token") 69 | def refresh_token( 70 | self, refresh_token: Field, grant_type: Field = "refresh_token" 71 | ) -> OAuthToken.Schema(): 72 | 73 | """Retrieves updated OAuth token from refresh token""" 74 | 75 | @response_handler(lambda response: response.json().get("account")) 76 | @get("oauth/1.0/users/profile") 77 | def get_profile(self, access_code: BearerToken) -> UserProfile.Schema: 78 | 79 | """Retrieves current user's OAuth profile""" 80 | -------------------------------------------------------------------------------- /thinq2/client/objectstore.py: -------------------------------------------------------------------------------- 1 | from uplink import Url, get, returns 2 | 3 | from thinq2.client.base import BaseClient 4 | 5 | 6 | class ObjectStoreClient(BaseClient): 7 | """LG ThinQ API client""" 8 | 9 | base_url = "https://objectstore.lgthinq.com" 10 | 11 | @returns.json 12 | @get 13 | def get_json_url(self, url: Url): 14 | """Retrieves an arbitrary JSON object""" 15 | -------------------------------------------------------------------------------- /thinq2/client/thinq.py: -------------------------------------------------------------------------------- 1 | from uplink import Field, Path, Query 2 | from uplink import get, post, delete, json 3 | 4 | from thinq2.client.base import BaseClient 5 | from thinq2.model.thinq import ( 6 | DeviceCollection, 7 | DeviceDescriptor, 8 | ThinQResult, 9 | ThinQResultSuccess, 10 | IOTRegistration, 11 | ModelJsonDescriptor, 12 | ) 13 | 14 | 15 | class ThinQClient(BaseClient): 16 | """LG ThinQ API client""" 17 | 18 | @get("service/application/dashboard") 19 | def get_devices(self) -> ThinQResult(DeviceCollection): 20 | """Retrieves collection of user's registered devices with dashboard data.""" 21 | 22 | @get("service/devices/{device_id}") 23 | def get_device(self, device_id: Path) -> ThinQResult(DeviceDescriptor): 24 | """Retrieves an individual device""" 25 | 26 | @get("service/application/modeljson") 27 | def get_model_json_descriptor( 28 | self, device_id: Query("deviceId"), model_name: Query("modelName") 29 | ) -> ThinQResult(ModelJsonDescriptor): 30 | """Retrieves ModelJson descriptor for a device""" 31 | 32 | @get("service/users/client") 33 | def get_registered(self) -> ThinQResultSuccess(): 34 | """Get client registration status""" 35 | 36 | @post("service/users/client") 37 | def register(self) -> ThinQResultSuccess(): 38 | """Register client ID""" 39 | 40 | @delete("service/users/client") 41 | def deregister(self) -> ThinQResultSuccess(): 42 | """Deregister client ID""" 43 | 44 | @json 45 | @post("service/users/client/certificate") 46 | def register_iot(self, csr: Field) -> ThinQResult(IOTRegistration): 47 | """Register an IoT/MQTT session, given a csr""" 48 | -------------------------------------------------------------------------------- /thinq2/controller/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinkerborg/thinq2-python/9775de9fa1a775533e15fb734c319949f9a48f05/thinq2/controller/__init__.py -------------------------------------------------------------------------------- /thinq2/controller/auth.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | import base64 3 | import uuid 4 | import re 5 | import thinq2 6 | 7 | from urllib.parse import urlencode, urljoin, urlparse, parse_qs 8 | 9 | from uplink.clients.io import RequestTemplate, transitions 10 | from thinq2.client.oauth import OAuthClient 11 | from thinq2.client.gateway import GatewayClient 12 | from thinq2.model.auth import ThinQSession 13 | from thinq2.schema import controller, initializer 14 | 15 | 16 | @controller(ThinQSession) 17 | class ThinQAuth(RequestTemplate): 18 | def __call__(self, request_builder): 19 | self._ret = request_builder.return_type 20 | request_builder.add_request_template(self) 21 | 22 | def before_request(self, request): 23 | self.add_headers(*request) 24 | 25 | def add_headers(self, method, url, extras): 26 | extras["headers"].update(self.base_headers) 27 | extras["headers"].update(self.auth_headers) 28 | 29 | def after_response(self, request, response): 30 | if response.status_code == 400: 31 | # XXX - thinq auth error - find a cleaner way of handling this 32 | # this gets raised when the oauth code is expired/invalid 33 | # XXX - this should also die on repeated 400s for the same request 34 | # (after token refresh) 35 | try: 36 | content = response.json() 37 | if content.get("resultCode") != "0102": 38 | return 39 | except ValueError: 40 | pass 41 | 42 | self.refresh_token() 43 | self.add_headers(*request) 44 | return transitions.sleep(1) 45 | 46 | # XXX - this should throw exceptions if they fail 47 | def set_token(self, authorization_code): 48 | self.token = self.oauth_client.get_token(authorization_code) 49 | 50 | def set_token_from_url(self, url): 51 | params = parse_qs(urlparse(url).query) 52 | ## XXX - throw error if no code 53 | self.set_token(params["code"][0]) 54 | 55 | def refresh_token(self): 56 | self.token.update(self.oauth_client.refresh_token(self.token.refresh_token)) 57 | 58 | @property 59 | def auth_headers(self): 60 | return { 61 | "x-emp-token": self.token.access_token, 62 | "x-user-no": self.profile.user_no, 63 | } 64 | 65 | @property 66 | def base_headers(self): 67 | return { 68 | "x-client-id": self.client_id, 69 | "x-country-code": self.country_code, 70 | "x-language-code": self.language_code, 71 | "x-message-id": self.message_id, 72 | "x-api-key": thinq2.API_KEY, 73 | "x-service-code": thinq2.SERVICE_CODE, 74 | "x-service-phase": thinq2.SERVICE_PHASE, 75 | "x-thinq-app-level": thinq2.APP_LEVEL, 76 | "x-thinq-app-os": thinq2.APP_OS, 77 | "x-thinq-app-type": thinq2.APP_TYPE, 78 | "x-thinq-app-ver": thinq2.APP_VERSION, 79 | } 80 | 81 | @property 82 | def oauth_login_url(self): 83 | """ Returns a URL to start the OAuth flow """ 84 | 85 | url = urljoin(self.gateway.emp_uri, "spx/login/signIn") 86 | query = urlencode( 87 | { 88 | "country": self.country_code, 89 | "language": self.language_code, 90 | "client_id": thinq2.LGE_APP_KEY, 91 | "svc_list": thinq2.SERVICE_CODE, 92 | "division": thinq2.DIVISION, 93 | "show_thirdparty_login": thinq2.THIRD_PARTY_LOGINS, 94 | "redirect_uri": thinq2.OAUTH_REDIRECT_URI, 95 | "state": uuid.uuid1().hex, 96 | } 97 | ) 98 | return "{}?{}".format(url, query) 99 | 100 | @property 101 | def oauth_backend_url(self): 102 | return "https://{}.lgeapi.com".format(self.country_code) 103 | 104 | @property 105 | def oauth_client(self): 106 | return OAuthClient(base_url=self.oauth_backend_url) 107 | 108 | @property 109 | def gateway_client(self): 110 | return GatewayClient( 111 | # XXX technically if this was reused the messageid would not change 112 | headers=self.base_headers, 113 | client_id=self.client_id, 114 | country_code=self.country_code, 115 | language_code=self.language_code, 116 | ) 117 | 118 | @property 119 | def message_id(self): 120 | id = base64.urlsafe_b64encode(uuid.uuid4().bytes).decode("UTF-8") 121 | return re.sub("=*$", "", id) 122 | 123 | @initializer 124 | def client_id(self): 125 | return secrets.token_hex(32) 126 | 127 | @initializer 128 | def gateway(self): 129 | return self.gateway_client.get_gateway() 130 | 131 | @initializer 132 | def profile(self): 133 | return self.oauth_client.get_profile(self.token.access_token) 134 | -------------------------------------------------------------------------------- /thinq2/controller/device.py: -------------------------------------------------------------------------------- 1 | from deepmerge import Merger 2 | 3 | from thinq2.schema import controller 4 | from thinq2.util import memoize 5 | from thinq2.client.thinq import ThinQClient 6 | from thinq2.client.objectstore import ObjectStoreClient 7 | from thinq2.model.thinq import DeviceDescriptor, ModelJsonDataclass 8 | 9 | 10 | @controller(DeviceDescriptor) 11 | class ThinQDevice: 12 | def __init__(self, auth): 13 | self._auth = auth 14 | self._on_update = None 15 | 16 | def update(self, state): 17 | schema = self.snapshot.Schema() 18 | snapshot = schema.dump(self.snapshot) 19 | update = self._merger.merge(snapshot, state) 20 | self.snapshot = schema.load(update) 21 | if self._on_update: 22 | self._on_update(self) 23 | 24 | def on_update(self, func): 25 | self._on_update = func 26 | 27 | @property 28 | def _merger(self): 29 | return Merger([(dict, ["merge"])], ["override"], ["override"]) 30 | 31 | @property 32 | def state(self): 33 | return self._model.Schema().load(self.snapshot.state) 34 | 35 | @property 36 | @memoize 37 | def model_json(self): 38 | return self._object_store_client.get_json_url(self.model_json_uri) 39 | 40 | @property 41 | @memoize 42 | def model_json_uri(self): 43 | descriptor = self._thinq_client.get_model_json_descriptor( 44 | device_id=self.device_id, model_name=self.model_name 45 | ) 46 | return descriptor.model_json_uri 47 | 48 | @property 49 | @memoize 50 | def _model(self): 51 | return ModelJsonDataclass(self.model_json).build(self.alias) 52 | 53 | @property 54 | def _thinq_client(self): 55 | return ThinQClient(base_url=self._auth.gateway.thinq2_uri, auth=self._auth) 56 | 57 | @property 58 | def _object_store_client(self): 59 | return ObjectStoreClient() 60 | -------------------------------------------------------------------------------- /thinq2/controller/mqtt.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import ssl 3 | from urllib.parse import urlparse 4 | 5 | from OpenSSL import crypto 6 | from OpenSSL.SSL import FILETYPE_PEM 7 | from paho.mqtt.client import Client 8 | 9 | from thinq2.model.mqtt import MQTTConfiguration, MQTTMessage 10 | from thinq2.schema import controller, initializer 11 | from thinq2.client.thinq import ThinQClient 12 | from thinq2.client.common import CommonClient 13 | from thinq2.util import memoize 14 | from thinq2.util.filesystem import TempDir 15 | 16 | from thinq2 import AWS_IOTT_CA_CERT_URL, AWS_IOTT_ALPN_PROTOCOL 17 | 18 | 19 | @controller(MQTTConfiguration) 20 | class ThinQMQTT: 21 | def __init__(self, auth): 22 | self._auth = auth 23 | 24 | def connect(self): 25 | if not self.client.is_connected(): 26 | endpoint = urlparse(self.route.mqtt_server) 27 | self.client.connect(endpoint.hostname, endpoint.port) 28 | 29 | def loop_start(self): 30 | self.connect() 31 | self.client.loop_start() 32 | 33 | def loop_forever(self): 34 | self.connect() 35 | self.client.loop_forever() 36 | 37 | def on_message(self, client, userdata, msg): 38 | self._on_message(client, userdata, msg) 39 | 40 | def on_connect(self, client, userdata, flags, rc): 41 | for topic in self.registration.subscriptions: 42 | client.subscribe(topic, 1) 43 | 44 | def on_device_message(self, message): 45 | pass 46 | 47 | def _on_message(self, client, userdata, msg): 48 | # XXX - nastiness 49 | message = None 50 | try: 51 | message = MQTTMessage.Schema().loads(msg.payload) 52 | except Exception as e: 53 | print("Can't parse MQTT message:", e) 54 | self.on_device_message(message) 55 | 56 | @property 57 | @memoize 58 | def client(self): 59 | client = Client(client_id=self._auth.client_id) 60 | client.tls_set_context(self.ssl_context) 61 | client.on_connect = self.on_connect 62 | client.on_message = self.on_message 63 | return client 64 | 65 | @property 66 | @memoize 67 | def thinq_client(self): 68 | return ThinQClient(base_url=self._auth.gateway.thinq2_uri, auth=self._auth) 69 | 70 | @property 71 | @memoize 72 | def common_client(self): 73 | return CommonClient(auth=self._auth) 74 | 75 | @property 76 | def ssl_context(self): 77 | temp_dir = TempDir() 78 | ca_cert_path = temp_dir.file(self.ca_cert) 79 | private_key_path = temp_dir.file(self.private_key) 80 | client_cert_path = temp_dir.file(self.registration.certificate_pem) 81 | 82 | context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 83 | context.set_alpn_protocols([AWS_IOTT_ALPN_PROTOCOL]) 84 | context.load_verify_locations(cafile=ca_cert_path) 85 | context.load_cert_chain(certfile=client_cert_path, keyfile=private_key_path) 86 | 87 | return context 88 | 89 | @initializer 90 | def ca_cert(self): 91 | return requests.get(AWS_IOTT_CA_CERT_URL).text 92 | 93 | @initializer 94 | def private_key(self): 95 | key = crypto.PKey() 96 | key.generate_key(crypto.TYPE_RSA, 2048) 97 | return str(crypto.dump_privatekey(FILETYPE_PEM, key), "utf8") 98 | 99 | @initializer 100 | def csr(self): 101 | key = crypto.load_privatekey(FILETYPE_PEM, self.private_key) 102 | csr = crypto.X509Req() 103 | csr.get_subject().CN = "AWS IoT Certificate" 104 | csr.get_subject().O = "Amazon" 105 | csr.set_pubkey(key) 106 | csr.sign(key, "sha256") 107 | return str(crypto.dump_certificate_request(FILETYPE_PEM, csr), "utf8") 108 | 109 | @initializer 110 | def registration(self): 111 | if self.thinq_client.get_registered() is False: 112 | self.thinq_client.register() 113 | return self.thinq_client.register_iot(csr=self.csr) 114 | 115 | @initializer 116 | def route(self): 117 | return self.common_client.get_route() 118 | -------------------------------------------------------------------------------- /thinq2/controller/thinq.py: -------------------------------------------------------------------------------- 1 | import gc 2 | 3 | from thinq2.schema import controller 4 | from thinq2.util import memoize 5 | from thinq2.client.thinq import ThinQClient 6 | from thinq2.controller.mqtt import ThinQMQTT 7 | from thinq2.controller.auth import ThinQAuth 8 | from thinq2.controller.device import ThinQDevice 9 | from thinq2.model.config import ThinQConfiguration 10 | from thinq2.model.mqtt import MQTTMessage 11 | 12 | 13 | @controller(ThinQConfiguration) 14 | class ThinQ: 15 | 16 | _devices = [] 17 | 18 | def get_device(self, device_id): 19 | device = ThinQDevice(self.thinq_client.get_device(device_id), auth=self.auth) 20 | self._devices.append(device) 21 | return device 22 | 23 | # XXX - temporary? 24 | def start(self): 25 | self.mqtt.on_device_message = self._notify_device 26 | self.mqtt.loop_forever() 27 | 28 | def _notify_device(self, message: MQTTMessage): 29 | # XXX - ugly temporary PoC 30 | for device in self._devices: 31 | if len(gc.get_referrers(device)) <= 1: 32 | self._devices.remove(device) 33 | elif device.device_id == message.device_id: 34 | device.update(message.data.state.reported) 35 | 36 | @property 37 | @memoize 38 | def thinq_client(self): 39 | return ThinQClient(base_url=self.auth.gateway.thinq2_uri, auth=self.auth) 40 | 41 | @controller 42 | def auth(self, auth): 43 | return ThinQAuth(auth) 44 | 45 | @controller 46 | def mqtt(self, mqtt): 47 | return ThinQMQTT(mqtt, auth=self.auth) 48 | -------------------------------------------------------------------------------- /thinq2/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinkerborg/thinq2-python/9775de9fa1a775533e15fb734c319949f9a48f05/thinq2/model/__init__.py -------------------------------------------------------------------------------- /thinq2/model/auth.py: -------------------------------------------------------------------------------- 1 | from marshmallow_dataclass import dataclass 2 | 3 | from thinq2.model.gateway import Gateway 4 | from thinq2.schema import CamelIDSchema 5 | 6 | 7 | @dataclass 8 | class OAuthToken: 9 | access_token: str 10 | expires_in: str 11 | oauth2_backend_url: str = None 12 | refresh_token: str = None 13 | 14 | def update(self, token: "OAuthToken"): 15 | self.access_token = token.access_token 16 | self.expires_in = token.expires_in 17 | 18 | 19 | @dataclass(base_schema=CamelIDSchema) 20 | class UserProfile: 21 | user_id: str 22 | user_no: str 23 | 24 | 25 | @dataclass 26 | class ThinQSession: 27 | country_code: str 28 | language_code: str 29 | client_id: str 30 | gateway: Gateway 31 | profile: UserProfile = None 32 | token: OAuthToken = None 33 | -------------------------------------------------------------------------------- /thinq2/model/common.py: -------------------------------------------------------------------------------- 1 | from marshmallow_dataclass import dataclass 2 | 3 | from thinq2.schema import CamelCaseSchema 4 | 5 | 6 | @dataclass(base_schema=CamelCaseSchema) 7 | class Route: 8 | api_server: str 9 | mqtt_server: str 10 | -------------------------------------------------------------------------------- /thinq2/model/config.py: -------------------------------------------------------------------------------- 1 | from marshmallow_dataclass import dataclass 2 | 3 | from thinq2.model.auth import ThinQSession 4 | from thinq2.model.mqtt import MQTTConfiguration 5 | 6 | 7 | @dataclass 8 | class ThinQConfiguration: 9 | auth: ThinQSession 10 | mqtt: MQTTConfiguration 11 | -------------------------------------------------------------------------------- /thinq2/model/device/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Device 2 | from .laundry import LaundryDevice 3 | 4 | device_types = { 5 | 201: LaundryDevice, 6 | 202: LaundryDevice, 7 | } 8 | -------------------------------------------------------------------------------- /thinq2/model/device/base.py: -------------------------------------------------------------------------------- 1 | from marshmallow_dataclass import dataclass 2 | 3 | from thinq2.schema import CamelCaseSchema 4 | 5 | 6 | @dataclass(base_schema=CamelCaseSchema) 7 | class DeviceStatic: 8 | device_type: int 9 | country_code: str 10 | 11 | 12 | @dataclass(base_schema=CamelCaseSchema) 13 | class Device: 14 | timestamp: float 15 | static: DeviceStatic 16 | -------------------------------------------------------------------------------- /thinq2/model/device/laundry.py: -------------------------------------------------------------------------------- 1 | from dataclasses import field 2 | from marshmallow_dataclass import dataclass 3 | 4 | from thinq2.schema import CamelCaseSchema 5 | 6 | from .base import Device 7 | 8 | 9 | @dataclass(base_schema=CamelCaseSchema) 10 | class LaundryDevice(Device): 11 | state: dict = field(metadata=dict(data_key="washerDryer")) 12 | -------------------------------------------------------------------------------- /thinq2/model/gateway.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import thinq2 3 | from urllib import parse 4 | from marshmallow_dataclass import dataclass 5 | 6 | from thinq2.schema import CamelCaseSchema 7 | 8 | 9 | @dataclass(base_schema=CamelCaseSchema) 10 | class Gateway: 11 | country_code: str 12 | language_code: str 13 | thinq1_uri: str 14 | thinq2_uri: str 15 | emp_uri: str 16 | 17 | @property 18 | def oauth_url(self) -> str: 19 | query = parse.urlencode( 20 | { 21 | "country": self.country_code, 22 | "language": self.language_code, 23 | "svc_list": thinq2.SERVICE_CODE, 24 | "client_id": thinq2.OAUTH_CLIENT_ID, 25 | "division": thinq2.DIVISION, 26 | "redirect_uri": thinq2.OAUTH_REDIRECT_URI, 27 | "state": uuid.uuid1().hex, 28 | "show_thirdparty_login": thinq2.THIRD_PARTY_LOGINS, 29 | } 30 | ) 31 | return parse.urljoin( 32 | self.emp_uri, "spx/login/signIn?{query}".format(query=query) 33 | ) 34 | -------------------------------------------------------------------------------- /thinq2/model/mqtt.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from dataclasses import field 3 | 4 | from marshmallow_dataclass import dataclass 5 | 6 | from thinq2.model.common import Route 7 | from thinq2.model.thinq import IOTRegistration 8 | from thinq2.schema import CamelCaseSchema 9 | 10 | 11 | @dataclass 12 | class MQTTConfiguration: 13 | route: Route 14 | registration: IOTRegistration 15 | ca_cert: str 16 | private_key: str 17 | csr: str 18 | 19 | 20 | @dataclass 21 | class MQTTMessageDeviceState: 22 | desired: dict = field(default_factory=dict) 23 | reported: dict = field(default_factory=dict) 24 | 25 | 26 | @dataclass(base_schema=CamelCaseSchema) 27 | class MQTTMessageDeviceData: 28 | state: MQTTMessageDeviceState 29 | 30 | 31 | @dataclass(base_schema=CamelCaseSchema) 32 | class MQTTMessage: 33 | device_id: str 34 | message_type: str = field(metadata=dict(data_key="type")) 35 | data: MQTTMessageDeviceData 36 | timestamp: datetime = None 37 | -------------------------------------------------------------------------------- /thinq2/model/thinq.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from dataclasses import field, make_dataclass 3 | from typing import List 4 | 5 | import marshmallow_dataclass 6 | 7 | from marshmallow import Schema, fields, post_load, pre_load 8 | from marshmallow_dataclass import dataclass 9 | from marshmallow_enum import EnumField 10 | 11 | from inflection import underscore, camelize 12 | 13 | from thinq2.schema import CamelCaseSchema, BaseSchema 14 | from thinq2.model.device import Device, device_types 15 | from thinq2.util import memoize 16 | 17 | 18 | @dataclass(base_schema=CamelCaseSchema) 19 | class DeviceDescriptor: 20 | device_id: str 21 | model_name: str 22 | device_type: int 23 | alias: str 24 | model_country_code: str 25 | country_code: str 26 | fw_ver: str 27 | # image_file_name: str 28 | # image_url: str 29 | # small_image_url: str 30 | ssid: str 31 | mac_address: str 32 | network_type: str 33 | timezone_code: str 34 | timezone_code_alias: str 35 | utc_offset: int 36 | utc_offset_display: str 37 | dst_offset: int 38 | dst_offset_display: str 39 | cur_offset: int 40 | cur_offset_display: str 41 | new_reg_yn: str 42 | remote_control_type: str 43 | user_no: str 44 | # model_json_ver: float 45 | # model_json_uri: str 46 | # app_module_ver: float 47 | # app_module_uri: str 48 | # app_restart_yn: str 49 | # app_module_size: float 50 | # lang_pack_product_type_ver: float 51 | # lang_pack_product_type_uri: str 52 | device_state: str 53 | online: bool 54 | area: int 55 | reg_dt: float 56 | blackbox_yn: bool 57 | model_protocol: str 58 | order: int 59 | dr_service_yn: str 60 | # guide_type_yn: str 61 | # guide_type: str 62 | reg_dt_utc: str 63 | groupable_yn: str 64 | controllable_yn: str 65 | combined_product_yn: str 66 | master_yn: str 67 | tclcount: int 68 | snapshot: Device 69 | 70 | @post_load(pass_original=True) 71 | def polymorphism(self, item, data, **kwargs): 72 | device_schema = device_types.get(item.device_type, Device).Schema() 73 | item.snapshot = device_schema.load(data.get("snapshot", {})) 74 | return item 75 | 76 | 77 | @dataclass(base_schema=CamelCaseSchema) 78 | class DeviceCollection: 79 | items: List[DeviceDescriptor] = field(metadata=dict(data_key="item")) 80 | 81 | @pre_load 82 | def filter_items(self, data, **kwargs): 83 | """ Filter thinq1 devices as we don't currently support them """ 84 | try: 85 | items = [i for i in data["item"] if i["platformType"] == "thinq2"] 86 | return {**data, **dict(item=items)} 87 | except KeyError as e: 88 | return data 89 | 90 | 91 | @dataclass(base_schema=CamelCaseSchema) 92 | class IOTRegistration: 93 | certificate_pem: str 94 | subscriptions: List[str] 95 | 96 | 97 | class ThinQResultCode(Enum): 98 | OK = "0000" 99 | PARTIAL_OK = "0001" 100 | OPERATION_IN_PROGRESS_DEVICE = "0103" 101 | PORTAL_INTERWORKING_ERROR = "0007" 102 | PROCESSING_REFRIGERATOR = "0104" 103 | RESPONSE_DELAY_DEVICE = "0111" 104 | SERVICE_SERVER_ERROR = "8107" 105 | SSP_ERROR = "8102" 106 | TIME_OUT = "9020" 107 | WRONG_XML_OR_URI = "9000" 108 | 109 | AWS_IOT_ERROR = "8104" 110 | AWS_S3_ERROR = "8105" 111 | AWS_SQS_ERROR = "8106" 112 | BASE64_DECODING_ERROR = "9002" 113 | BASE64_ENCODING_ERROR = "9001" 114 | CLIP_ERROR = "8103" 115 | CONTROL_ERROR_REFRIGERATOR = "0105" 116 | CREATE_SESSION_FAIL = "9003" 117 | DB_PROCESSING_FAIL = "9004" 118 | DM_ERROR = "8101" 119 | DUPLICATED_ALIAS = "0013" 120 | DUPLICATED_DATA = "0008" 121 | DUPLICATED_LOGIN = "0004" 122 | EMP_AUTHENTICATION_FAILED = "0102" 123 | ETC_COMMUNICATION_ERROR = "8900" 124 | ETC_ERROR = "9999" 125 | EXCEEDING_LIMIT = "0112" 126 | EXPIRED_CUSTOMER_NUMBER = "0119" 127 | EXPIRES_SESSION_BY_WITHDRAWAL = "9005" 128 | FAIL = "0100" 129 | INACTIVE_API = "8001" 130 | INSUFFICIENT_STORAGE_SPACE = "0107" 131 | INVAILD_CSR = "9010" 132 | INVALID_BODY = "0002" 133 | INVALID_CUSTOMER_NUMBER = "0118" 134 | INVALID_HEADER = "0003" 135 | INVALID_PUSH_TOKEN = "0301" 136 | INVALID_REQUEST_DATA_FOR_DIAGNOSIS = "0116" 137 | MISMATCH_DEVICE_GROUP = "0014" 138 | MISMATCH_LOGIN_SESSION = "0114" 139 | MISMATCH_NONCE = "0006" 140 | MISMATCH_REGISTRED_DEVICE = "0115" 141 | MISSING_SERVER_SETTING_INFORMATION = "9005" 142 | NOT_AGREED_TERMS = "0110" 143 | NOT_CONNECTED_DEVICE = "0106" 144 | NOT_CONTRACT_CUSTOMER_NUMBER = "0120" 145 | NOT_EXIST_DATA = "0010" 146 | NOT_EXIST_DEVICE = "0009" 147 | NOT_EXIST_MODEL_JSON = "0117" 148 | NOT_REGISTERED_SMART_CARE = "0121" 149 | NOT_SUPPORTED_COMMAND = "0012" 150 | NOT_SUPPORTED_COUNTRY = "8000" 151 | NOT_SUPPORTED_SERVICE = "0005" 152 | NO_INFORMATION_DR = "0109" 153 | NO_INFORMATION_SLEEP_MODE = "0108" 154 | NO_PERMISSION = "0011" 155 | NO_PERMMISION_MODIFY_RECIPE = "0113" 156 | NO_REGISTERED_DEVICE = "0101" 157 | NO_USER_INFORMATION = "9006" 158 | 159 | 160 | class ThinQException(Exception): 161 | pass 162 | 163 | 164 | class BaseThinQResult(Schema): 165 | result_code = EnumField( 166 | ThinQResultCode, data_key="resultCode", load_by=EnumField.VALUE 167 | ) 168 | result = fields.Nested(Schema) 169 | 170 | 171 | class ThinQResultSuccess(BaseThinQResult): 172 | result = fields.Raw() 173 | 174 | @post_load 175 | def is_successful(self, data, **kwargs): 176 | return data["result_code"] == ThinQResultCode.OK 177 | 178 | 179 | class ThinQResult(BaseThinQResult): 180 | def __init__(self, result_class): 181 | self._result_schema = result_class.Schema() 182 | super().__init__() 183 | 184 | def on_bind_field(self, field_name, field): 185 | if isinstance(field, fields.Nested): 186 | # field = fields.Str() 187 | field.nested = self._result_schema 188 | 189 | @post_load 190 | def unwrap_result(self, data, **kwargs): 191 | if data["result_code"] != ThinQResultCode.OK: 192 | raise ThinQException(ThinQResultCode(data["result_code"])) 193 | return data["result"] 194 | 195 | 196 | @dataclass(base_schema=CamelCaseSchema) 197 | class ModelJsonDescriptor: 198 | """ ModelJSON metadata """ 199 | 200 | model_json_ver: str 201 | model_json_uri: str 202 | timestamp: int 203 | 204 | 205 | class ModelJsonDataclass: 206 | """ Builds a marshmallow-enabled dataclass from an LG "modeljson" object """ 207 | 208 | def __init__(self, model): 209 | self.model = model 210 | 211 | def build(self, dataclass_name=None): 212 | DeviceModel = make_dataclass(dataclass_name or self.model_type, self.fields) 213 | marshmallow_dataclass.add_schema(DeviceModel, base_schema=BaseSchema) 214 | DeviceModel.Enum = self.enums 215 | return DeviceModel 216 | 217 | def _field_definition(self, name, spec): 218 | key = underscore(name) 219 | field_type = self._field_type(key, spec) 220 | return (key, field_type, field(metadata=self._field_meta(name, field_type))) 221 | 222 | def _field_meta(self, name, field_type): 223 | return dict(data_key=name) 224 | 225 | def _field_type(self, name, spec): 226 | if "dataType" in spec: 227 | if spec["dataType"].lower() == "enum": 228 | return self._enum_field(name, self._map_values(spec["valueMapping"])) 229 | 230 | elif spec["dataType"].lower() == "range": 231 | # XXX - validation check 232 | return int 233 | 234 | elif "ref" in spec: 235 | return self._enum_field(name, self._ref_values(spec["ref"])) 236 | 237 | # XXX - non generic exceptions 238 | raise Exception("Unknown modelJson field type in {}".format(name)) 239 | 240 | def _ref_values(self, ref): 241 | return list(self.model[ref].keys()) + ["NOT_SELECTED"] 242 | 243 | def _map_values(self, mappings): 244 | return {key: mapping["index"] for key, mapping in mappings.items()} 245 | 246 | def _enum_field(self, name, values): 247 | return Enum(camelize(name), values) 248 | 249 | @property 250 | @memoize 251 | def fields(self): 252 | return [ 253 | self._field_definition(data_key, spec) 254 | for data_key, spec in self.model["MonitoringValue"].items() 255 | ] 256 | 257 | @property 258 | @memoize 259 | def enums(self): 260 | return { 261 | name: field for name, field, _ in self.fields if issubclass(field, Enum) 262 | } 263 | 264 | @property 265 | def model_type(self): 266 | return self.model.get("Info", {"modelType": "UnknownDevice"}).get("modelType") 267 | -------------------------------------------------------------------------------- /thinq2/schema.py: -------------------------------------------------------------------------------- 1 | import re 2 | import inspect 3 | 4 | from dataclasses import is_dataclass 5 | 6 | from attrdict import AttrDict 7 | from marshmallow import EXCLUDE, Schema 8 | from inflection import camelize 9 | 10 | from thinq2.util import memoize 11 | 12 | 13 | class BaseSchema(Schema): 14 | class Meta: 15 | unknown = EXCLUDE 16 | 17 | def on_bind_field(self, field_name, field): 18 | field.data_key = self.transform(field.data_key or field_name) 19 | 20 | def transform(self, field_name): 21 | return field_name 22 | 23 | 24 | class CamelCaseSchema(BaseSchema): 25 | def transform(self, field_name): 26 | return camelize(field_name, uppercase_first_letter=False) 27 | 28 | 29 | # XXX - bad pun 30 | class CamelIDSchema(CamelCaseSchema): 31 | def transform(self, field_name): 32 | return re.sub(r"(?<=[a-z])Id(?=[A-Z]|$)", "ID", super().transform(field_name)) 33 | 34 | 35 | class AbstractController: 36 | pass 37 | 38 | 39 | def controller_class(data_type, **children): 40 | schema = data_type.Schema() 41 | 42 | def merge_args(self, kwargs): 43 | attrs = { 44 | k: getattr(self, k, None) for k in schema.fields.keys() if not k in kwargs 45 | } 46 | defaults = schema.dump({k: v for k, v in attrs.items() if v is not None}) 47 | return {**defaults, **kwargs} 48 | 49 | class Controller(AbstractController): 50 | _data: data_type = None 51 | 52 | # XXX - fix infinite recursion error if called w/ no args 53 | def __init__(self, data=None, *args, **kwargs): 54 | init = inspect.signature(super().__init__) 55 | pass_keys = AttrDict( 56 | filter(self._param_filter, init.parameters.items()) 57 | ).keys() 58 | pass_params = {k: v for k, v in kwargs.items() if k in pass_keys} 59 | 60 | super().__init__(**pass_params) 61 | 62 | if data is None: 63 | self._data = AttrDict(kwargs) 64 | args = AttrDict(filter(self._args_filter, kwargs.items())) 65 | self._data = schema.load(merge_args(self, args)) 66 | else: 67 | if is_dataclass(data): 68 | self._data = data 69 | else: 70 | self._data = schema.load(data) 71 | 72 | def _args_filter(self, item): 73 | key, value = item 74 | return not isinstance(value, AbstractController) 75 | 76 | def _param_filter(self, item): 77 | name, param = item 78 | return param.kind in [param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY] 79 | 80 | @classmethod 81 | def load(cls, data): 82 | return cls(schema.load(data)) 83 | 84 | @property 85 | def __dict__(self): 86 | return schema.dump(self._data) 87 | 88 | 89 | # XXX - should throw attribute exception if attr not in schema 90 | def __getattr__(self, attr): 91 | if not attr.startswith("_"): 92 | if hasattr(self._data, attr): 93 | return getattr(self._data, attr) 94 | 95 | # XXX - fail if super doesn't havegetattr? 96 | return super().__getattr__(attr) 97 | 98 | def __setattr__(self, attr, value): 99 | if isinstance(self._data, data_type) and hasattr(self._data, attr): 100 | setattr(self._data, attr, value) 101 | else: 102 | super().__setattr__(attr, value) 103 | 104 | def class_wrapper(base_class): 105 | return type(base_class.__name__, (Controller, base_class), {}) 106 | 107 | return class_wrapper 108 | 109 | 110 | def controller_factory(func): 111 | field_name = func.__name__ 112 | 113 | @property 114 | @memoize 115 | def inner(self, *args, **kwargs): 116 | if isinstance(self._data, dict): 117 | existing = self._data.get(field_name, None) 118 | else: 119 | existing = getattr(self._data, field_name, None) 120 | 121 | if isinstance(existing, AbstractController): 122 | """ If it's already a controller, return it """ 123 | return existing 124 | 125 | return func(self, existing) 126 | 127 | return inner 128 | 129 | 130 | def controller(class_or_func, **children): 131 | if isinstance(class_or_func, type): 132 | return controller_class(class_or_func, **children) 133 | if callable(class_or_func): 134 | return controller_factory(class_or_func) 135 | 136 | 137 | def initializer(obj, attr="_data"): 138 | @property 139 | def inner(self): 140 | data = getattr(self, attr) 141 | if data is None: 142 | return obj(self) 143 | 144 | value = getattr(data, obj.__name__, None) or obj(self) 145 | setattr(data, obj.__name__, value) 146 | return value 147 | 148 | return inner 149 | -------------------------------------------------------------------------------- /thinq2/util/__init__.py: -------------------------------------------------------------------------------- 1 | def memoize(func): 2 | memo = {} 3 | 4 | def inner(*args, **kwargs): 5 | key = str(dict(args=args, kwargs=kwargs)) 6 | if key not in memo: 7 | memo[key] = func(*args, **kwargs) 8 | return memo[key] 9 | 10 | return inner 11 | 12 | 13 | def end_with(string, end): 14 | if string.endswith(end): 15 | return string 16 | return string + end 17 | -------------------------------------------------------------------------------- /thinq2/util/filesystem.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from tempfile import TemporaryDirectory, mkstemp 4 | 5 | 6 | class TempDir: 7 | def __init__(self): 8 | self._dir = TemporaryDirectory() 9 | 10 | def file(self, content: str = None): 11 | fh, path = mkstemp(dir=self._dir.name) 12 | if content is not None: 13 | os.write(fh, str.encode(content)) 14 | os.close(fh) 15 | return path 16 | -------------------------------------------------------------------------------- /thinq2/util/uuid.py: -------------------------------------------------------------------------------- 1 | import re 2 | import uuid 3 | import base64 4 | 5 | 6 | class ThinQMessageID(object): 7 | """Object that returns a new random ThinQ message ID each time it's referenced as a string""" 8 | 9 | def __str__(self): 10 | return re.sub( 11 | "=*$", "", base64.urlsafe_b64encode(uuid.uuid4().bytes).decode("UTF-8") 12 | ) 13 | --------------------------------------------------------------------------------