forked from andrius/neohubapi
Compare commits
82 Commits
Author | SHA1 | Date |
---|---|---|
Andrius Štikonas | 1121045275 | |
Mehmet Can | 91c95149a3 | |
Andrius Štikonas | 2305ac05bd | |
Vlad Firoiu | 5ebf6c8aa1 | |
Andrius Štikonas | d881f73fde | |
Andrius Štikonas | e0c591f6f8 | |
MindrustUK | 7d57078906 | |
MindrustUK | 3ad3a73bb8 | |
Lawrence Akka | 54ca38d4d6 | |
Andrius Štikonas | b68c294a25 | |
Andrius Štikonas | 204bff812c | |
Andrius Štikonas | 268a27ac55 | |
Lawrence Akka | 81a11dfa43 | |
Lawrence Akka | 879e43789d | |
Lawrence Akka | 92df4f9722 | |
ribbal | 2e5cc5ecf4 | |
Andrius Štikonas | 4d6e2441db | |
Balbir Boughan | dfc4382f01 | |
Balbir Boughan | 97b5a395db | |
Roberto Cosenza | 14c5271ecd | |
Andrius Štikonas | d811131f8a | |
Roberto Cosenza | f8803a7b15 | |
Roberto Cosenza | dbda9b4157 | |
Andrius Štikonas | add4dfea10 | |
Roberto Cosenza | 43dbc27c0f | |
Andrius Štikonas | ee252edc18 | |
Dave O'Connor | e138bd95cd | |
Andrius Štikonas | ec43960560 | |
Anton Tolchanov | 18292bf629 | |
Dave O'Connor | e769da03f7 | |
Dave O'Connor | 90d5969e53 | |
Anton Tolchanov | 7117c890f0 | |
Dave O'Connor | 7bbc4d3f96 | |
Dave O'Connor | b55ea393cb | |
Dave O'Connor | 4792fc8249 | |
Dave O'Connor | 53a3d0b5a2 | |
Dave O'Connor | d21c54b4a6 | |
Dave O'Connor | e10b1de9ae | |
Dave O'Connor | 1f44cde9a3 | |
Dave O'Connor | 22d3be4131 | |
Dave O'Connor | a1248e219e | |
Dave O'Connor | 1756eb86d4 | |
Dave O'Connor | 5c590b3ce0 | |
Dave O'Connor | 69819d81b2 | |
Dave O'Connor | 2430b7d5cd | |
Andrius Štikonas | f2224fbb7c | |
Andrius Štikonas | 54362b229c | |
Dave O'Connor | aa6020c621 | |
Andrius Štikonas | 5ef21a23fd | |
Dave O'Connor | b54e00fbe3 | |
Andrius Štikonas | c6d62b5852 | |
Andrius Štikonas | 27b997df37 | |
Andrius Štikonas | 278867c4fc | |
Andrius Štikonas | 5fc952110e | |
Andrius Štikonas | 2fc8544a34 | |
Andrius Štikonas | ec92300199 | |
Andrius Štikonas | 7893ed66fd | |
Andrius Štikonas | 65b9667d99 | |
Andrius Štikonas | bea16520f5 | |
Andrius Štikonas | 105abc9339 | |
Andrius Štikonas | db381d04e4 | |
Andrius Štikonas | da10de2d7a | |
Andrius Štikonas | 9e59d5254b | |
Andrius Štikonas | 0d5074fd4e | |
Andrius Štikonas | 3d839aeaf9 | |
Andrius Štikonas | fed09bdaff | |
Andrius Štikonas | c9041e7caf | |
Andrius Štikonas | 3487ac3962 | |
Andrius Štikonas | 1e4a60e642 | |
Andrius Štikonas | cb5645fcf0 | |
Andrius Štikonas | 67b502a861 | |
Andrius Štikonas | 5afe389234 | |
Andrius Štikonas | 464aa195f9 | |
Andrius Štikonas | 980ec9a62e | |
Andrius Štikonas | c45fb963c9 | |
Andrius Štikonas | a3eb2699c8 | |
Andrius Štikonas | e099fe4741 | |
Andrius Štikonas | 87c9516172 | |
Andrius Štikonas | 24127f9df5 | |
Andrius Štikonas | c1a609f927 | |
Andrius Štikonas | b70cc611e1 | |
Andrius Štikonas | 3718cb64d1 |
|
@ -3,5 +3,9 @@
|
|||
|
||||
__pycache__
|
||||
*~
|
||||
*#*#
|
||||
.kdev4
|
||||
*.kdev4
|
||||
build/
|
||||
dist/
|
||||
neohubapi.egg-info/
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
image: "python:3.8"
|
||||
|
||||
before_script:
|
||||
- python --version
|
||||
- pip3 install flake8 pytest-asyncio async_property websockets
|
||||
|
||||
stages:
|
||||
- test
|
||||
|
||||
test:
|
||||
stage: test
|
||||
script:
|
||||
- flake8 --max-line-length=120 neohubapi/*.py
|
||||
- flake8 --max-line-length=120 tests/*.py
|
||||
- flake8 --max-line-length=120 scripts/*.py
|
||||
- python -m pytest
|
|
@ -0,0 +1,324 @@
|
|||
Creative Commons Attribution 4.0 International Creative Commons Corporation
|
||||
("Creative Commons") is not a law firm and does not provide legal services
|
||||
or legal advice. Distribution of Creative Commons public licenses does not
|
||||
create a lawyer-client or other relationship. Creative Commons makes its licenses
|
||||
and related information available on an "as-is" basis. Creative Commons gives
|
||||
no warranties regarding its licenses, any material licensed under their terms
|
||||
and conditions, or any related information. Creative Commons disclaims all
|
||||
liability for damages resulting from their use to the fullest extent possible.
|
||||
|
||||
Using Creative Commons Public Licenses
|
||||
|
||||
Creative Commons public licenses provide a standard set of terms and conditions
|
||||
that creators and other rights holders may use to share original works of
|
||||
authorship and other material subject to copyright and certain other rights
|
||||
specified in the public license below. The following considerations are for
|
||||
informational purposes only, are not exhaustive, and do not form part of our
|
||||
licenses.
|
||||
|
||||
Considerations for licensors: Our public licenses are intended for use by
|
||||
those authorized to give the public permission to use material in ways otherwise
|
||||
restricted by copyright and certain other rights. Our licenses are irrevocable.
|
||||
Licensors should read and understand the terms and conditions of the license
|
||||
they choose before applying it. Licensors should also secure all rights necessary
|
||||
before applying our licenses so that the public can reuse the material as
|
||||
expected. Licensors should clearly mark any material not subject to the license.
|
||||
This includes other CC-licensed material, or material used under an exception
|
||||
or limitation to copyright. More considerations for licensors : wiki.creativecommons.org/Considerations_for_licensors
|
||||
|
||||
Considerations for the public: By using one of our public licenses, a licensor
|
||||
grants the public permission to use the licensed material under specified
|
||||
terms and conditions. If the licensor's permission is not necessary for any
|
||||
reason–for example, because of any applicable exception or limitation to copyright–then
|
||||
that use is not regulated by the license. Our licenses grant only permissions
|
||||
under copyright and certain other rights that a licensor has authority to
|
||||
grant. Use of the licensed material may still be restricted for other reasons,
|
||||
including because others have copyright or other rights in the material. A
|
||||
licensor may make special requests, such as asking that all changes be marked
|
||||
or described. Although not required by our licenses, you are encouraged to
|
||||
respect those requests where reasonable. More considerations for the public
|
||||
: wiki.creativecommons.org/Considerations_for_licensees Creative Commons Attribution
|
||||
4.0 International Public License
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree to
|
||||
be bound by the terms and conditions of this Creative Commons Attribution
|
||||
4.0 International Public License ("Public License"). To the extent this Public
|
||||
License may be interpreted as a contract, You are granted the Licensed Rights
|
||||
in consideration of Your acceptance of these terms and conditions, and the
|
||||
Licensor grants You such rights in consideration of benefits the Licensor
|
||||
receives from making the Licensed Material available under these terms and
|
||||
conditions.
|
||||
|
||||
Section 1 – Definitions.
|
||||
|
||||
a. Adapted Material means material subject to Copyright and Similar Rights
|
||||
that is derived from or based upon the Licensed Material and in which the
|
||||
Licensed Material is translated, altered, arranged, transformed, or otherwise
|
||||
modified in a manner requiring permission under the Copyright and Similar
|
||||
Rights held by the Licensor. For purposes of this Public License, where the
|
||||
Licensed Material is a musical work, performance, or sound recording, Adapted
|
||||
Material is always produced where the Licensed Material is synched in timed
|
||||
relation with a moving image.
|
||||
|
||||
b. Adapter's License means the license You apply to Your Copyright and Similar
|
||||
Rights in Your contributions to Adapted Material in accordance with the terms
|
||||
and conditions of this Public License.
|
||||
|
||||
c. Copyright and Similar Rights means copyright and/or similar rights closely
|
||||
related to copyright including, without limitation, performance, broadcast,
|
||||
sound recording, and Sui Generis Database Rights, without regard to how the
|
||||
rights are labeled or categorized. For purposes of this Public License, the
|
||||
rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
|
||||
|
||||
d. Effective Technological Measures means those measures that, in the absence
|
||||
of proper authority, may not be circumvented under laws fulfilling obligations
|
||||
under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996,
|
||||
and/or similar international agreements.
|
||||
|
||||
e. Exceptions and Limitations means fair use, fair dealing, and/or any other
|
||||
exception or limitation to Copyright and Similar Rights that applies to Your
|
||||
use of the Licensed Material.
|
||||
|
||||
f. Licensed Material means the artistic or literary work, database, or other
|
||||
material to which the Licensor applied this Public License.
|
||||
|
||||
g. Licensed Rights means the rights granted to You subject to the terms and
|
||||
conditions of this Public License, which are limited to all Copyright and
|
||||
Similar Rights that apply to Your use of the Licensed Material and that the
|
||||
Licensor has authority to license.
|
||||
|
||||
h. Licensor means the individual(s) or entity(ies) granting rights under this
|
||||
Public License.
|
||||
|
||||
i. Share means to provide material to the public by any means or process that
|
||||
requires permission under the Licensed Rights, such as reproduction, public
|
||||
display, public performance, distribution, dissemination, communication, or
|
||||
importation, and to make material available to the public including in ways
|
||||
that members of the public may access the material from a place and at a time
|
||||
individually chosen by them.
|
||||
|
||||
j. Sui Generis Database Rights means rights other than copyright resulting
|
||||
from Directive 96/9/EC of the European Parliament and of the Council of 11
|
||||
March 1996 on the legal protection of databases, as amended and/or succeeded,
|
||||
as well as other essentially equivalent rights anywhere in the world.
|
||||
|
||||
k. You means the individual or entity exercising the Licensed Rights under
|
||||
this Public License. Your has a corresponding meaning.
|
||||
|
||||
Section 2 – Scope.
|
||||
|
||||
a. License grant.
|
||||
|
||||
1. Subject to the terms and conditions of this Public License, the Licensor
|
||||
hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive,
|
||||
irrevocable license to exercise the Licensed Rights in the Licensed Material
|
||||
to:
|
||||
|
||||
A. reproduce and Share the Licensed Material, in whole or in part; and
|
||||
|
||||
B. produce, reproduce, and Share Adapted Material.
|
||||
|
||||
2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions
|
||||
and Limitations apply to Your use, this Public License does not apply, and
|
||||
You do not need to comply with its terms and conditions.
|
||||
|
||||
3. Term. The term of this Public License is specified in Section 6(a).
|
||||
|
||||
4. Media and formats; technical modifications allowed. The Licensor authorizes
|
||||
You to exercise the Licensed Rights in all media and formats whether now known
|
||||
or hereafter created, and to make technical modifications necessary to do
|
||||
so. The Licensor waives and/or agrees not to assert any right or authority
|
||||
to forbid You from making technical modifications necessary to exercise the
|
||||
Licensed Rights, including technical modifications necessary to circumvent
|
||||
Effective Technological Measures. For purposes of this Public License, simply
|
||||
making modifications authorized by this Section 2(a)(4) never produces Adapted
|
||||
Material.
|
||||
|
||||
5. Downstream recipients.
|
||||
|
||||
A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed
|
||||
Material automatically receives an offer from the Licensor to exercise the
|
||||
Licensed Rights under the terms and conditions of this Public License.
|
||||
|
||||
B. No downstream restrictions. You may not offer or impose any additional
|
||||
or different terms or conditions on, or apply any Effective Technological
|
||||
Measures to, the Licensed Material if doing so restricts exercise of the Licensed
|
||||
Rights by any recipient of the Licensed Material.
|
||||
|
||||
6. No endorsement. Nothing in this Public License constitutes or may be construed
|
||||
as permission to assert or imply that You are, or that Your use of the Licensed
|
||||
Material is, connected with, or sponsored, endorsed, or granted official status
|
||||
by, the Licensor or others designated to receive attribution as provided in
|
||||
Section 3(a)(1)(A)(i).
|
||||
|
||||
b. Other rights.
|
||||
|
||||
1. Moral rights, such as the right of integrity, are not licensed under this
|
||||
Public License, nor are publicity, privacy, and/or other similar personality
|
||||
rights; however, to the extent possible, the Licensor waives and/or agrees
|
||||
not to assert any such rights held by the Licensor to the limited extent necessary
|
||||
to allow You to exercise the Licensed Rights, but not otherwise.
|
||||
|
||||
2. Patent and trademark rights are not licensed under this Public License.
|
||||
|
||||
3. To the extent possible, the Licensor waives any right to collect royalties
|
||||
from You for the exercise of the Licensed Rights, whether directly or through
|
||||
a collecting society under any voluntary or waivable statutory or compulsory
|
||||
licensing scheme. In all other cases the Licensor expressly reserves any right
|
||||
to collect such royalties.
|
||||
|
||||
Section 3 – License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the following
|
||||
conditions.
|
||||
|
||||
a. Attribution.
|
||||
|
||||
1. If You Share the Licensed Material (including in modified form), You must:
|
||||
|
||||
A. retain the following if it is supplied by the Licensor with the Licensed
|
||||
Material:
|
||||
|
||||
i. identification of the creator(s) of the Licensed Material and any others
|
||||
designated to receive attribution, in any reasonable manner requested by the
|
||||
Licensor (including by pseudonym if designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
|
||||
|
||||
B. indicate if You modified the Licensed Material and retain an indication
|
||||
of any previous modifications; and
|
||||
|
||||
C. indicate the Licensed Material is licensed under this Public License, and
|
||||
include the text of, or the URI or hyperlink to, this Public License.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner
|
||||
based on the medium, means, and context in which You Share the Licensed Material.
|
||||
For example, it may be reasonable to satisfy the conditions by providing a
|
||||
URI or hyperlink to a resource that includes the required information.
|
||||
|
||||
3. If requested by the Licensor, You must remove any of the information required
|
||||
by Section 3(a)(1)(A) to the extent reasonably practicable.
|
||||
|
||||
4. If You Share Adapted Material You produce, the Adapter's License You apply
|
||||
must not prevent recipients of the Adapted Material from complying with this
|
||||
Public License.
|
||||
|
||||
Section 4 – Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that apply to
|
||||
Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract,
|
||||
reuse, reproduce, and Share all or a substantial portion of the contents of
|
||||
the database;
|
||||
|
||||
b. if You include all or a substantial portion of the database contents in
|
||||
a database in which You have Sui Generis Database Rights, then the database
|
||||
in which You have Sui Generis Database Rights (but not its individual contents)
|
||||
is Adapted Material; and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share all or
|
||||
a substantial portion of the contents of the database.
|
||||
|
||||
For the avoidance of doubt, this Section 4 supplements and does not replace
|
||||
Your obligations under this Public License where the Licensed Rights include
|
||||
other Copyright and Similar Rights.
|
||||
|
||||
Section 5 – Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. Unless otherwise separately undertaken by the Licensor, to the extent possible,
|
||||
the Licensor offers the Licensed Material as-is and as-available, and makes
|
||||
no representations or warranties of any kind concerning the Licensed Material,
|
||||
whether express, implied, statutory, or other. This includes, without limitation,
|
||||
warranties of title, merchantability, fitness for a particular purpose, non-infringement,
|
||||
absence of latent or other defects, accuracy, or the presence or absence of
|
||||
errors, whether or not known or discoverable. Where disclaimers of warranties
|
||||
are not allowed in full or in part, this disclaimer may not apply to You.
|
||||
|
||||
b. To the extent possible, in no event will the Licensor be liable to You
|
||||
on any legal theory (including, without limitation, negligence) or otherwise
|
||||
for any direct, special, indirect, incidental, consequential, punitive, exemplary,
|
||||
or other losses, costs, expenses, or damages arising out of this Public License
|
||||
or use of the Licensed Material, even if the Licensor has been advised of
|
||||
the possibility of such losses, costs, expenses, or damages. Where a limitation
|
||||
of liability is not allowed in full or in part, this limitation may not apply
|
||||
to You.
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided above
|
||||
shall be interpreted in a manner that, to the extent possible, most closely
|
||||
approximates an absolute disclaimer and waiver of all liability.
|
||||
|
||||
Section 6 – Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and Similar Rights
|
||||
licensed here. However, if You fail to comply with this Public License, then
|
||||
Your rights under this Public License terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated under Section
|
||||
6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured, provided it is cured
|
||||
within 30 days of Your discovery of the violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
c. For the avoidance of doubt, this Section 6(b) does not affect any right
|
||||
the Licensor may have to seek remedies for Your violations of this Public
|
||||
License.
|
||||
|
||||
d. For the avoidance of doubt, the Licensor may also offer the Licensed Material
|
||||
under separate terms or conditions or stop distributing the Licensed Material
|
||||
at any time; however, doing so will not terminate this Public License.
|
||||
|
||||
e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
|
||||
|
||||
Section 7 – Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different terms or
|
||||
conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the Licensed
|
||||
Material not stated herein are separate from and independent of the terms
|
||||
and conditions of this Public License.
|
||||
|
||||
Section 8 – Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and shall not
|
||||
be interpreted to, reduce, limit, restrict, or impose conditions on any use
|
||||
of the Licensed Material that could lawfully be made without permission under
|
||||
this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License is deemed
|
||||
unenforceable, it shall be automatically reformed to the minimum extent necessary
|
||||
to make it enforceable. If the provision cannot be reformed, it shall be severed
|
||||
from this Public License without affecting the enforceability of the remaining
|
||||
terms and conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and no failure
|
||||
to comply consented to unless expressly agreed to by the Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted as a limitation
|
||||
upon, or waiver of, any privileges and immunities that apply to the Licensor
|
||||
or You, including from the legal processes of any jurisdiction or authority.
|
||||
|
||||
Creative Commons is not a party to its public licenses. Notwithstanding, Creative
|
||||
Commons may elect to apply one of its public licenses to material it publishes
|
||||
and in those instances will be considered the "Licensor." The text of the
|
||||
Creative Commons public licenses is dedicated to the public domain under the
|
||||
CC0 Public Domain Dedication. Except for the limited purpose of indicating
|
||||
that material is shared under a Creative Commons public license or as otherwise
|
||||
permitted by the Creative Commons policies published at creativecommons.org/policies,
|
||||
Creative Commons does not authorize the use of the trademark "Creative Commons"
|
||||
or any other trademark or logo of Creative Commons without its prior written
|
||||
consent including, without limitation, in connection with any unauthorized
|
||||
modifications to any of its public licenses or any other arrangements, understandings,
|
||||
or agreements concerning use of licensed material. For the avoidance of doubt,
|
||||
this paragraph does not form part of the public licenses.
|
||||
|
||||
Creative Commons may be contacted at creativecommons.org.
|
|
@ -0,0 +1,19 @@
|
|||
MIT License Copyright (c) <year> <copyright holders>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished
|
||||
to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice (including the next
|
||||
paragraph) shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
|
||||
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||
OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@ -0,0 +1,59 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: 2020 Andrius Štikonas <andrius@stikonas.eu>
|
||||
SPDX-License-Identifier: CC-BY-4.0
|
||||
-->
|
||||
|
||||
# NeoHubAPI
|
||||
|
||||
This is a simple python wrapper around Heatmiser's Neohub API. Up-to-date
|
||||
documentation for the API can be obtained from the [Heatmiser Developer
|
||||
Portal](https://dev.heatmiser.com). You will need to sign up for a free account.
|
||||
|
||||
The primary purpose of this module is to help with [Home
|
||||
Assistant](https://www.home-assistant.io) integration but it can also be used as
|
||||
a standalone library for other projects.
|
||||
|
||||
## Connection methods
|
||||
|
||||
The API provides two connection methods. The so-called "legacy" method is by way of an unencrypted connection to port 4242 of the Neohub. The newer method uses an encrypted websocket on port 4243, but only works on a second generation hub (look for the sticker on the back).
|
||||
|
||||
To use the websocket connection, you need to obtain a token from the Heatmiser Neo app. Go to `Settings > API > +` in the app and create one.
|
||||
|
||||
On newer hubs, the legacy connection may be disabled by default. If you want to use it, go to `Settings > API` in the app, and enable it from there.
|
||||
|
||||
## Usage example
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import neohubapi.neohub as neohub
|
||||
|
||||
|
||||
async def run():
|
||||
# Legacy connection
|
||||
hub = neohub.NeoHub()
|
||||
# Or, for a websocket connection:
|
||||
# hub = neohub.Neohub(port=4243, token='xxx-xxxxxxx')
|
||||
system = await hub.get_system()
|
||||
hub_data = await hub.get_devices_data()
|
||||
devices = hub_data['neo_devices']
|
||||
for device in devices:
|
||||
print(f"Temperature in zone {device.name}: {device.temperature}")
|
||||
await device.identify()
|
||||
|
||||
|
||||
asyncio.run(run())
|
||||
```
|
||||
|
||||
|
||||
|
||||
## neohub_cli.py
|
||||
|
||||
This package includes a CLI for performing common tasks.
|
||||
|
||||
```
|
||||
$ neohub_cli.py help # Shows all commands
|
||||
$ neohub_cli.py help set_time # Displays help for the set_time function
|
||||
$ neohub_cli.py --hub_ip=myneohub set_time "2021-01-31 15:43:00" # Specify times like this
|
||||
$ neohub_cli.py --hub_ip=myneohub set_lock 1234 "Living Room" # Name NeoStats like this.
|
||||
$ neohub_cli.py --hub_ip=myneohub --hub_token=XXX get_system # Get system variables with websocket connection
|
||||
```
|
22
enums.py
22
enums.py
|
@ -1,22 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# SPDX-FileCopyrightText: 2020 Andrius Štikonas <andrius@stikonas.eu>
|
||||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class ScheduleFormat(str, enum.Enum):
|
||||
"""
|
||||
Enum to specify Schedule Format
|
||||
|
||||
ZERO - non programmable (time clocks cannot be non programmable)
|
||||
ONE - same format every day of the week
|
||||
TWO - 5 day / 2 day
|
||||
SEVEN - 7 day (every day different)
|
||||
"""
|
||||
|
||||
ZERO = "NONPROGRAMMABLE"
|
||||
ONE = "24HOURSFIXED"
|
||||
TWO = "5DAY/2DAY"
|
||||
SEVEN = "7DAY"
|
|
@ -0,0 +1,37 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# SPDX-FileCopyrightText: 2020-2021 Andrius Štikonas <andrius@stikonas.eu>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import neohubapi.neohub as neohub
|
||||
|
||||
from neohubapi.enums import ScheduleFormat
|
||||
|
||||
|
||||
async def run():
|
||||
hub = neohub.NeoHub()
|
||||
system = await hub.get_system()
|
||||
hub_data, devices = await hub.get_live_data()
|
||||
print("Thermostats:")
|
||||
for device in devices['thermostats']:
|
||||
print(f"Target temperature of {device.name}: {device.target_temperature}")
|
||||
await device.identify()
|
||||
|
||||
print("Timeclocks:")
|
||||
for device in devices['timeclocks']:
|
||||
print(f"Timeclock {device.name}: {device.target_temperature}")
|
||||
print(await hub.set_timer_hold(False, 1, [device]))
|
||||
|
||||
print(await hub.target_temperature_step)
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
asyncio.run(run())
|
||||
|
||||
if (__name__ == '__main__'):
|
||||
main()
|
46
holiday.py
46
holiday.py
|
@ -1,46 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# SPDX-FileCopyrightText: 2020 Andrius Štikonas <andrius@stikonas.eu>
|
||||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
import datetime
|
||||
import enum
|
||||
|
||||
|
||||
class Holiday:
|
||||
"""
|
||||
Class representing Holidays
|
||||
|
||||
start, end: holiday datetime objects or None
|
||||
ids
|
||||
"""
|
||||
|
||||
def __init__(self, reply: str):
|
||||
start = reply["start"]
|
||||
if start:
|
||||
self._start = datetime.datetime.strptime(start.strip(), "%a %b %d %H:%M:%S %Y")
|
||||
else:
|
||||
self._start = None
|
||||
|
||||
end = reply["end"]
|
||||
if end:
|
||||
self.end = datetime.datetime.strptime(end.strip(), "%a %b %d %H:%M:%S %Y")
|
||||
else:
|
||||
self._end = None
|
||||
|
||||
self._ids = reply["ids"]
|
||||
|
||||
@property
|
||||
def start(self):
|
||||
""" Beginning of holiday. """
|
||||
return self._start
|
||||
|
||||
@property
|
||||
def end(self):
|
||||
""" End of holiday. """
|
||||
return self._end
|
||||
|
||||
@property
|
||||
def ids(self):
|
||||
""" Devices that have holiday set up. """
|
||||
return self._ids
|
435
neohub.py
435
neohub.py
|
@ -1,435 +0,0 @@
|
|||
# SPDX-FileCopyrightText: 2020 Andrius Štikonas <andrius@stikonas.eu>
|
||||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from enums import ScheduleFormat
|
||||
from system import System
|
||||
from holiday import Holiday
|
||||
from neostat import NeoStat
|
||||
|
||||
|
||||
class NeoHub:
|
||||
def __init__(self):
|
||||
self._logger = logging.getLogger('neohub')
|
||||
pass
|
||||
|
||||
async def connect(self, host='Neo-Hub', port='4242'):
|
||||
self._host = host
|
||||
self._port = port
|
||||
|
||||
async def _send(self, message, expected_reply=None):
|
||||
reader, writer = await asyncio.open_connection(self._host, self._port)
|
||||
encoded_message = bytearray(json.dumps(message) + "\0\r", "utf-8")
|
||||
self._logger.debug(f"Sending message: {encoded_message}")
|
||||
writer.write(encoded_message)
|
||||
await writer.drain()
|
||||
|
||||
data = await reader.readuntil(b'\0')
|
||||
data = data.strip(b'\0')
|
||||
json_string = data.decode('utf-8')
|
||||
self._logger.debug(f"Received message: {json_string}")
|
||||
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
try:
|
||||
reply = json.loads(json_string)
|
||||
except json.decoder.JSONDecodeError as e:
|
||||
if expected_reply is None:
|
||||
raise(e)
|
||||
else:
|
||||
return False
|
||||
|
||||
if expected_reply is None:
|
||||
return reply
|
||||
else:
|
||||
if reply == expected_reply:
|
||||
return True
|
||||
else:
|
||||
self._logger.error(f"Unexpected reply: {reply}")
|
||||
return False
|
||||
|
||||
async def firmware(self):
|
||||
"""
|
||||
NeoHub firmware version
|
||||
"""
|
||||
|
||||
message = {"FIRMWARE": 0}
|
||||
|
||||
result = await self._send(message)
|
||||
firmware_version = int(result['firmware version'])
|
||||
return firmware_version
|
||||
|
||||
async def get_system(self):
|
||||
"""
|
||||
Get system wide variables
|
||||
|
||||
Returns System object
|
||||
"""
|
||||
message = {"GET_SYSTEM": 0}
|
||||
|
||||
data = await self._send(message)
|
||||
system_data = System(data)
|
||||
return system_data
|
||||
|
||||
async def reset(self):
|
||||
"""
|
||||
Reboot neohub
|
||||
|
||||
Returns True if Restart is initiated
|
||||
"""
|
||||
|
||||
message = {"RESET": 0}
|
||||
reply = {"Restarting": 1}
|
||||
|
||||
firmware_version = await self.firmware()
|
||||
result = ""
|
||||
if firmware_version >= 2027:
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
else:
|
||||
return False
|
||||
|
||||
async def set_channel(self, channel: int):
|
||||
"""
|
||||
Set ZigBee channel.
|
||||
|
||||
Only channels 11, 14, 15, 19, 20, 24, 25 are allowed.
|
||||
"""
|
||||
|
||||
message = {"SET_CHANNEL": channel}
|
||||
reply = {"result": "Trying to change channel"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_temp_format(self, temp_format: str):
|
||||
"""
|
||||
Set temperature format to C or F
|
||||
"""
|
||||
|
||||
message = {"SET_TEMP_FORMAT": temp_format}
|
||||
reply = {"result": f"Temperature format set to {temp_format}"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_format(self, format: ScheduleFormat):
|
||||
"""
|
||||
Sets schedule format
|
||||
|
||||
Format is specified using ScheduleFormat enum:
|
||||
"""
|
||||
|
||||
message = {"SET_FORMAT": format}
|
||||
reply = {"result": "Format was set"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_away(self, state: bool):
|
||||
"""
|
||||
Enables away mode for all devices.
|
||||
|
||||
Puts thermostats into frost mode and timeclocks are set to off.
|
||||
Instead of this function it is recommended to use frost on/off commands
|
||||
|
||||
List of affected devices can be restricted using GLOBAL_DEV_LIST command
|
||||
"""
|
||||
|
||||
message = {"AWAY_ON" if state else "AWAY_OFF": 0}
|
||||
reply = {"result": "away on" if state else "away off"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def holiday(self, start: datetime.datetime, end: datetime.datetime):
|
||||
"""
|
||||
Sets holiday mode.
|
||||
|
||||
start: beginning of holiday
|
||||
end: end of holiday
|
||||
"""
|
||||
|
||||
message = {"HOLIDAY": [start.strftime("%H%M%S%d%m%Y"), end.strftime("%H%M%S%d%m%Y")]}
|
||||
|
||||
result = await self._send(message)
|
||||
return result
|
||||
|
||||
async def get_holiday(self):
|
||||
"""
|
||||
Get list of holidays
|
||||
|
||||
Returns Holiday object
|
||||
"""
|
||||
message = {"GET_HOLIDAY": 0}
|
||||
|
||||
result = await self._send(message)
|
||||
return Holiday(result)
|
||||
|
||||
async def cancel_holiday(self):
|
||||
"""
|
||||
Cancels holidays and returns to normal schedule
|
||||
"""
|
||||
|
||||
message = {"CANCEL_HOLIDAY": 0}
|
||||
reply = {"result": "holiday cancelled"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def get_zones(self):
|
||||
"""
|
||||
Get list of all thermostats
|
||||
|
||||
Returns a list of NeoStat objects
|
||||
"""
|
||||
|
||||
message = {"GET_ZONES": 0}
|
||||
|
||||
zones = await self._send(message)
|
||||
result = []
|
||||
for name, zone_id in zones.items():
|
||||
result.append(NeoStat(self, name, zone_id))
|
||||
|
||||
return result
|
||||
|
||||
async def get_devices(self):
|
||||
"""
|
||||
Returns list of devices
|
||||
|
||||
{"result": ["device1"]}
|
||||
"""
|
||||
|
||||
message = {"GET_DEVICES": 0}
|
||||
|
||||
result = await self._send(message)
|
||||
return result
|
||||
|
||||
async def get_device_list(self, zone: str):
|
||||
"""
|
||||
Returns list of devices associated with zone
|
||||
"""
|
||||
|
||||
message = {"GET_DEVICE_LIST": zone}
|
||||
|
||||
result = await self._send(message)
|
||||
if 'error' in result:
|
||||
return False
|
||||
else:
|
||||
return result[zone]
|
||||
|
||||
async def devices_sn(self):
|
||||
"""
|
||||
Returns serial numbers of attached devices
|
||||
|
||||
{'name': [id, 'serial', 1], ...}
|
||||
"""
|
||||
|
||||
message = {"DEVICES_SN": 0}
|
||||
|
||||
result = await self._send(message)
|
||||
return result
|
||||
|
||||
async def set_ntp(self, state: bool):
|
||||
"""
|
||||
Enables NTP client on Neohub
|
||||
"""
|
||||
|
||||
message = {"NTP_ON" if state else "NTP_OFF": 0}
|
||||
reply = {"result": "ntp client started" if state else "ntp client stopped"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_date(self, date=None):
|
||||
"""
|
||||
Sets current date
|
||||
|
||||
By default, set to current date. Can be optionally passed datetime.datetime object
|
||||
"""
|
||||
|
||||
if date is None:
|
||||
date = datetime.datetime.today()
|
||||
|
||||
message = {"SET_DATE": [date.year, date.month, date.day]}
|
||||
reply = {"result": "Date is set"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_time(self, time=None):
|
||||
"""
|
||||
Sets current time
|
||||
|
||||
By default, set to current time. Can be optionally passed datetime.datetime object
|
||||
"""
|
||||
|
||||
if time is None:
|
||||
time = datetime.datetime.now()
|
||||
|
||||
message = {"SET_TIME": [time.hour, time.minute]}
|
||||
reply = {"result": "time set"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_datetime(self, date_time=None):
|
||||
"""
|
||||
Convenience method to set both date and time
|
||||
"""
|
||||
|
||||
result = await self.set_date(date_time)
|
||||
if result:
|
||||
result = await self.set_time(date_time)
|
||||
return result
|
||||
|
||||
async def manual_dst(self, state: bool):
|
||||
"""
|
||||
Manually enables/disables daylight saving time
|
||||
"""
|
||||
|
||||
message = {"MANUAL_DST": int(state)}
|
||||
reply = {"result": "Updated time"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_dst(self, state: bool, region=None):
|
||||
"""
|
||||
Enables/disables automatic DST handling.
|
||||
|
||||
By default it uses UK dates for turning DST on/off.
|
||||
Available options for region are UK, EU, NZ.
|
||||
"""
|
||||
|
||||
message = {"DST_ON" if state else "DST_OFF": 0 if region is None else region}
|
||||
reply = {"result": "dst on" if state else "dst off"}
|
||||
|
||||
valid_timezones = ["UK", "EU", "NZ"]
|
||||
if region not in valid_timezones:
|
||||
return False
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def identify(self):
|
||||
"""
|
||||
Flashes red LED light
|
||||
"""
|
||||
|
||||
message = {"IDENTIFY": 0}
|
||||
reply = {"result": "flashing led"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def get_live_data(self):
|
||||
"""
|
||||
Returns unstructured live data from all devices
|
||||
"""
|
||||
|
||||
message = {"GET_LIVE_DATA": 0}
|
||||
|
||||
result = await self._send(message)
|
||||
return result
|
||||
|
||||
async def permit_join(self, name, timeout_s=120):
|
||||
"""
|
||||
Permit new thermostat to join network
|
||||
|
||||
name: new zone will be added with this name
|
||||
timeout: duration of discovery mode in seconds
|
||||
|
||||
To actually join network you need to select 01
|
||||
from the thermostat's setup menu.
|
||||
"""
|
||||
|
||||
message = {"PERMIT_JOIN": [timeout_s, name]}
|
||||
reply = {"result": "network allows joining"}
|
||||
|
||||
result = await self._send(message)
|
||||
return result
|
||||
|
||||
async def lock(self, pin: int, devices: [NeoStat]):
|
||||
"""
|
||||
PIN locks thermostats
|
||||
|
||||
PIN is a four digit number
|
||||
"""
|
||||
|
||||
if pin < 0 or pin > 9999:
|
||||
return False
|
||||
|
||||
pins = []
|
||||
for x in range(4):
|
||||
pins.append(pin % 10)
|
||||
pin = pin // 10
|
||||
pins.reverse()
|
||||
|
||||
names = [x.name for x in devices]
|
||||
message = {"LOCK": [pins, names]}
|
||||
reply = {"result": "locked"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def unlock(self, devices: [NeoStat]):
|
||||
"""
|
||||
Unlocks PIN locked thermostats
|
||||
"""
|
||||
|
||||
names = [x.name for x in devices]
|
||||
message = {"UNLOCK": names}
|
||||
reply = {"result": "unlocked"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def frost(self, state: bool, devices: [NeoStat]):
|
||||
"""
|
||||
Enables or disables Frost mode
|
||||
"""
|
||||
|
||||
names = [x.name for x in devices]
|
||||
message = {"FROST_ON" if state else "FROST_OFF": names}
|
||||
reply = {"result": "frost on" if state else "frost off"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_temp(self, temperature: int, devices: [NeoStat]):
|
||||
"""
|
||||
Sets the thermostat's temperature
|
||||
|
||||
The temperature will be reset once next comfort level is reached
|
||||
"""
|
||||
|
||||
names = [x.name for x in devices]
|
||||
message = {"SET_TEMP": [temperature, names]}
|
||||
reply = {"result": "temperature was set"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_diff(self, switching_differential: int, devices: [NeoStat]):
|
||||
"""
|
||||
Sets the thermostat's switching differential
|
||||
|
||||
-1: Undocumented option. Seems to set differential to 204.
|
||||
0: 0.5 degrees
|
||||
1: 1 degree
|
||||
2: 2 degrees
|
||||
3: 3 degrees
|
||||
"""
|
||||
|
||||
names = [x.name for x in devices]
|
||||
message = {"SET_DIFF": [switching_differential, names]}
|
||||
reply = {"result": "switching differential was set"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2020 Andrius Štikonas <andrius@stikonas.eu>
|
||||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
from . import neohub
|
||||
from . import neohub # noqa: F401 # flake8 should ignore this.
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# SPDX-FileCopyrightText: 2020 Andrius Štikonas <andrius@stikonas.eu>
|
||||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class ScheduleFormat(enum.Enum):
|
||||
"""
|
||||
Enum to specify Schedule Format
|
||||
|
||||
ZERO - non programmable (time clocks cannot be non programmable)
|
||||
ONE - same format every day of the week
|
||||
TWO - 5 day / 2 day
|
||||
SEVEN - 7 day (every day different)
|
||||
"""
|
||||
|
||||
ZERO = "NONPROGRAMMABLE"
|
||||
ONE = "24HOURSFIXED"
|
||||
TWO = "5DAY/2DAY"
|
||||
SEVEN = "7DAY"
|
||||
|
||||
|
||||
def schedule_format_int_to_enum(int_format):
|
||||
if int_format is None:
|
||||
return None
|
||||
if int_format == 0:
|
||||
return ScheduleFormat.ZERO
|
||||
elif int_format == 1:
|
||||
return ScheduleFormat.ONE
|
||||
elif int_format == 2:
|
||||
return ScheduleFormat.TWO
|
||||
elif int_format == 4:
|
||||
return ScheduleFormat.SEVEN
|
||||
else:
|
||||
raise ValueError('Unrecognized ScheduleFormat')
|
||||
|
||||
|
||||
class Weekday(enum.Enum):
|
||||
MONDAY = "monday"
|
||||
TUESDAY = "tuesday"
|
||||
WEDNESDAY = "wednesday"
|
||||
THURSDAY = "thursday"
|
||||
FRIDAY = "friday"
|
||||
SATURDAY = "saturday"
|
||||
SUNDAY = "sunday"
|
||||
|
||||
|
||||
class HCMode(enum.Enum):
|
||||
AUTO = "AUTO"
|
||||
COOLING = "COOLING"
|
||||
HEATING = "HEATING"
|
||||
VENT = "VENT"
|
|
@ -0,0 +1,679 @@
|
|||
# SPDX-FileCopyrightText: 2020-2021 Andrius Štikonas <andrius@stikonas.eu>
|
||||
# SPDX-FileCopyrightText: 2021 Dave O'Connor <daveoc@google.com>
|
||||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import ssl
|
||||
from async_property import async_cached_property
|
||||
from types import SimpleNamespace
|
||||
import websockets
|
||||
|
||||
from neohubapi.enums import HCMode
|
||||
from neohubapi.enums import ScheduleFormat
|
||||
from neohubapi.enums import schedule_format_int_to_enum
|
||||
from neohubapi.neostat import NeoStat
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NeoHubUsageError(Error):
|
||||
pass
|
||||
|
||||
|
||||
class NeoHubConnectionError(Error):
|
||||
pass
|
||||
|
||||
|
||||
class NeoHub:
|
||||
def __init__(self, host='Neo-Hub', port=4242, request_timeout=60, request_attempts=1, token=None):
|
||||
self._logger = logging.getLogger('neohub')
|
||||
self._host = host
|
||||
self._port = int(port)
|
||||
self._request_timeout = request_timeout
|
||||
self._request_attempts = request_attempts
|
||||
self._token = token
|
||||
# Sanity checks.
|
||||
if port not in (4242, 4243):
|
||||
raise NeoHubConnectionError(
|
||||
f'Invalid port number ({port}): use 4242 or 4243 instead')
|
||||
if port == 4243 and token is None:
|
||||
raise NeoHubConnectionError(
|
||||
'You must provide a token for a connection on port 4243, or use a legacy connection on port 4242')
|
||||
if port == 4242 and token is not None:
|
||||
raise NeoHubConnectionError(
|
||||
'You have provided a token, so you must use port=4243')
|
||||
self._websocket = None
|
||||
|
||||
async def _send_message(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, message: str):
|
||||
encoded_message = bytearray(json.dumps(message) + "\0\r", "utf-8")
|
||||
self._logger.debug(f"Sending message: {encoded_message}")
|
||||
writer.write(encoded_message)
|
||||
await writer.drain()
|
||||
|
||||
data = await reader.readuntil(b'\0')
|
||||
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
data = data.strip(b'\0')
|
||||
return data
|
||||
|
||||
async def _send(self, message, expected_reply=None):
|
||||
last_exception = None
|
||||
writer = None
|
||||
for attempt in range(1, self._request_attempts + 1):
|
||||
try:
|
||||
if self._token is not None:
|
||||
# Websocket connection on port 4243, introduced in v3 of the API on 12/12/21
|
||||
if self._websocket is None or self._websocket.closed:
|
||||
# The hub uses a locally generated certificate, so
|
||||
# we must diable hostname checking
|
||||
context = ssl.SSLContext(check_hostname=False)
|
||||
uri = f"wss://{self._host}:{self._port}"
|
||||
self._websocket = await websockets.connect(
|
||||
uri, ssl=context, open_timeout=self._request_timeout)
|
||||
self._logger.debug("Websocket connected")
|
||||
# The message format includes json nested within json nested within json!
|
||||
# All that appears to matter is the toker and the command
|
||||
encoded_message = \
|
||||
r"""{"message_type":"hm_get_command_queue","message":"{\"token\":\"""" + \
|
||||
self._token + r"""\",\"COMMANDS\":[{\"COMMAND\":\"""" + \
|
||||
str(message) + r"""\",\"COMMANDID\":1}]}"}"""
|
||||
self._logger.debug(f"Sending message: {encoded_message}")
|
||||
await self._websocket.send(encoded_message)
|
||||
# There appears to be no obvious way to detect an invalid token.
|
||||
# At present, the hub just seems to forcibly close the connection
|
||||
# with a 1002 error
|
||||
result = await asyncio.wait_for(
|
||||
self._websocket.recv(), timeout=self._request_timeout)
|
||||
json_string = json.loads(result)['response']
|
||||
else:
|
||||
# Legacy connection, on port 4242
|
||||
self._logger.debug("Using legacy connection")
|
||||
reader, writer = await asyncio.open_connection(self._host, self._port)
|
||||
data = await asyncio.wait_for(
|
||||
self._send_message(reader, writer, message), timeout=self._request_timeout)
|
||||
json_string = data.decode('utf-8')
|
||||
|
||||
self._logger.debug(f"Received message: {json_string}")
|
||||
reply = json.loads(json_string, object_hook=lambda d: SimpleNamespace(**d))
|
||||
|
||||
if expected_reply is None:
|
||||
return reply
|
||||
if reply.__dict__ == expected_reply:
|
||||
return True
|
||||
self._logger.error(f"[{attempt}] Unexpected reply: {reply} for message: {message}")
|
||||
except (socket.gaierror, ConnectionRefusedError, websockets.InvalidHandshake) as e:
|
||||
last_exception = NeoHubConnectionError(e)
|
||||
self._logger.error(f"[{attempt}] Could not connect to NeoHub at {self._host}: {e}")
|
||||
except asyncio.TimeoutError as e:
|
||||
last_exception = e
|
||||
self._logger.error(f"[{attempt}] Timed out while sending a message to {self._host}")
|
||||
if self._websocket is not None:
|
||||
self._websocket.close()
|
||||
if writer is not None:
|
||||
writer.close()
|
||||
except websockets.exceptions.ConnectionClosedError as e:
|
||||
last_exception = NeoHubConnectionError(e)
|
||||
self._logger.error(f"[{attempt}] Connection forcibly closed - maybe a bad token?: {e}")
|
||||
except json.decoder.JSONDecodeError as e:
|
||||
last_exception = e
|
||||
self._logger.error(f"[{attempt}] Could not decode JSON: {e}")
|
||||
# Wait for 1/2 of the timeout value before retrying.
|
||||
if self._request_attempts > 1 and attempt < self._request_attempts:
|
||||
await asyncio.sleep(self._request_timeout / 2)
|
||||
|
||||
if expected_reply is None and last_exception is not None:
|
||||
raise last_exception
|
||||
return False
|
||||
|
||||
def _devices_to_device_ids(self, devices: [NeoStat]):
|
||||
"""
|
||||
Returns the list of device ids
|
||||
"""
|
||||
try:
|
||||
return [x.device_id for x in devices]
|
||||
except (TypeError, AttributeError):
|
||||
raise NeoHubUsageError('devices must be a list of NeoStat objects')
|
||||
|
||||
def _devices_to_names(self, devices: [NeoStat]):
|
||||
"""
|
||||
Returns the list of device names
|
||||
"""
|
||||
try:
|
||||
return [x.name for x in devices]
|
||||
except (TypeError, AttributeError):
|
||||
raise NeoHubUsageError('devices must be a list of NeoStat objects')
|
||||
|
||||
async def firmware(self):
|
||||
"""
|
||||
NeoHub firmware version
|
||||
"""
|
||||
|
||||
message = {"FIRMWARE": 0}
|
||||
|
||||
result = await self._send(message)
|
||||
firmware_version = int(getattr(result, 'firmware version'))
|
||||
return firmware_version
|
||||
|
||||
async def get_system(self):
|
||||
"""
|
||||
Get system wide variables
|
||||
"""
|
||||
message = {"GET_SYSTEM": 0}
|
||||
|
||||
data = await self._send(message)
|
||||
data.FORMAT = schedule_format_int_to_enum(data.FORMAT)
|
||||
data.ALT_TIMER_FORMAT = schedule_format_int_to_enum(data.ALT_TIMER_FORMAT)
|
||||
return data
|
||||
|
||||
@async_cached_property
|
||||
async def target_temperature_step(self):
|
||||
"""
|
||||
Returns Neohub's target temperature step
|
||||
"""
|
||||
|
||||
firmware_version = await self.firmware()
|
||||
if firmware_version >= 2135:
|
||||
return 0.5
|
||||
else:
|
||||
return 1
|
||||
|
||||
async def reset(self):
|
||||
"""
|
||||
Reboot neohub
|
||||
|
||||
Returns True if Restart is initiated
|
||||
"""
|
||||
|
||||
message = {"RESET": 0}
|
||||
reply = {"Restarting": 1}
|
||||
|
||||
firmware_version = await self.firmware()
|
||||
result = ""
|
||||
if firmware_version >= 2027:
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
else:
|
||||
return False
|
||||
|
||||
async def set_channel(self, channel: int):
|
||||
"""
|
||||
Set ZigBee channel.
|
||||
|
||||
Only channels 11, 14, 15, 19, 20, 24, 25 are allowed.
|
||||
"""
|
||||
|
||||
try:
|
||||
message = {"SET_CHANNEL": int(channel)}
|
||||
except ValueError:
|
||||
raise NeoHubUsageError('channel must be a number')
|
||||
|
||||
reply = {"result": "Trying to change channel"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_temp_format(self, temp_format: str):
|
||||
"""
|
||||
Set temperature format to C or F
|
||||
"""
|
||||
|
||||
message = {"SET_TEMP_FORMAT": temp_format}
|
||||
reply = {"result": f"Temperature format set to {temp_format}"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_hc_mode(self, hc_mode: HCMode, devices: [NeoStat]):
|
||||
"""
|
||||
Set hc_mode to AUTO or...
|
||||
"""
|
||||
names = self._devices_to_names(devices)
|
||||
message = {"SET_HC_MODE": [hc_mode.value, names]}
|
||||
reply = {"result": "HC_MODE was set"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_format(self, sched_format: ScheduleFormat):
|
||||
"""
|
||||
Sets schedule format
|
||||
|
||||
Format is specified using ScheduleFormat enum:
|
||||
"""
|
||||
if not isinstance(sched_format, ScheduleFormat):
|
||||
raise NeoHubUsageError('sched_format must be a ScheduleFormat')
|
||||
|
||||
message = {"SET_FORMAT": sched_format.value}
|
||||
reply = {"result": "Format was set"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_away(self, state: bool):
|
||||
"""
|
||||
Enables away mode for all devices.
|
||||
|
||||
Puts thermostats into frost mode and timeclocks are set to off.
|
||||
Instead of this function it is recommended to use frost on/off commands
|
||||
|
||||
List of affected devices can be restricted using GLOBAL_DEV_LIST command
|
||||
"""
|
||||
|
||||
message = {"AWAY_ON" if state else "AWAY_OFF": 0}
|
||||
reply = {"result": "away on" if state else "away off"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_holiday(self, start: datetime.datetime, end: datetime.datetime):
|
||||
"""
|
||||
Sets holiday mode.
|
||||
|
||||
start: beginning of holiday
|
||||
end: end of holiday
|
||||
"""
|
||||
for datetime_arg in (start, end):
|
||||
if not isinstance(datetime_arg, datetime.datetime):
|
||||
raise NeoHubUsageError('start and end must be datetime.datetime objects')
|
||||
|
||||
message = {"HOLIDAY": [start.strftime("%H%M%S%d%m%Y"), end.strftime("%H%M%S%d%m%Y")]}
|
||||
|
||||
result = await self._send(message)
|
||||
return result
|
||||
|
||||
async def get_holiday(self):
|
||||
"""
|
||||
Get list of holidays
|
||||
|
||||
start end end times are converted to datetimes
|
||||
"""
|
||||
message = {"GET_HOLIDAY": 0}
|
||||
|
||||
result = await self._send(message)
|
||||
result.start = datetime.datetime.strptime(
|
||||
result.start.strip(), "%a %b %d %H:%M:%S %Y") if result.start else None
|
||||
result.end = datetime.datetime.strptime(
|
||||
result.end.strip(), "%a %b %d %H:%M:%S %Y") if result.end else None
|
||||
return result
|
||||
|
||||
async def cancel_holiday(self):
|
||||
"""
|
||||
Cancels holidays and returns to normal schedule
|
||||
"""
|
||||
|
||||
message = {"CANCEL_HOLIDAY": 0}
|
||||
reply = {"result": "holiday cancelled"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def get_devices(self):
|
||||
"""
|
||||
Returns list of devices
|
||||
|
||||
{"result": ["device1"]}
|
||||
"""
|
||||
|
||||
message = {"GET_DEVICES": 0}
|
||||
|
||||
result = await self._send(message)
|
||||
return result
|
||||
|
||||
async def get_device_list(self, zone: str):
|
||||
"""
|
||||
Returns list of devices associated with zone
|
||||
"""
|
||||
|
||||
message = {"GET_DEVICE_LIST": zone}
|
||||
|
||||
result = await self._send(message)
|
||||
if 'error' in result:
|
||||
return False
|
||||
else:
|
||||
return result[zone]
|
||||
|
||||
async def devices_sn(self):
|
||||
"""
|
||||
Returns serial numbers of attached devices
|
||||
|
||||
{'name': [id, 'serial', 1], ...}
|
||||
"""
|
||||
|
||||
message = {"DEVICES_SN": 0}
|
||||
|
||||
result = await self._send(message)
|
||||
return result
|
||||
|
||||
async def set_ntp(self, state: bool):
|
||||
"""
|
||||
Enables NTP client on Neohub
|
||||
"""
|
||||
|
||||
message = {"NTP_ON" if state else "NTP_OFF": 0}
|
||||
reply = {"result": "ntp client started" if state else "ntp client stopped"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_date(self, date: datetime.datetime = datetime.datetime.today()):
|
||||
"""
|
||||
Sets current date
|
||||
|
||||
By default, set to current date. Can be optionally passed datetime.datetime object
|
||||
"""
|
||||
|
||||
message = {"SET_DATE": [date.year, date.month, date.day]}
|
||||
reply = {"result": "Date is set"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_time(self, time: datetime.datetime = datetime.datetime.now()):
|
||||
"""
|
||||
Sets current time
|
||||
|
||||
By default, set to current time. Can be optionally passed datetime.datetime object
|
||||
"""
|
||||
message = {"SET_TIME": [time.hour, time.minute]}
|
||||
reply = {"result": "time set"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_datetime(self, date_time: datetime.datetime = datetime.datetime.now()):
|
||||
"""
|
||||
Convenience method to set both date and time
|
||||
"""
|
||||
|
||||
result = await self.set_date(date_time)
|
||||
if result:
|
||||
result = await self.set_time(date_time)
|
||||
return result
|
||||
|
||||
async def manual_dst(self, state: bool):
|
||||
"""
|
||||
Manually enables/disables daylight saving time
|
||||
"""
|
||||
|
||||
message = {"MANUAL_DST": int(state)}
|
||||
reply = {"result": "Updated time"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_dst(self, state: bool, region: str = None):
|
||||
"""
|
||||
Enables/disables automatic DST handling.
|
||||
|
||||
By default it uses UK dates for turning DST on/off.
|
||||
Available options for region are UK, EU, NZ.
|
||||
"""
|
||||
|
||||
message = {"DST_ON" if state else "DST_OFF": 0 if region is None else region}
|
||||
reply = {"result": "dst on" if state else "dst off"}
|
||||
|
||||
valid_timezones = ["UK", "EU", "NZ"]
|
||||
if region not in valid_timezones:
|
||||
raise NeoHubUsageError(f'region must be in {valid_timezones}')
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def identify(self):
|
||||
"""
|
||||
Flashes red LED light
|
||||
"""
|
||||
|
||||
message = {"IDENTIFY": 0}
|
||||
reply = {"result": "flashing led"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def get_live_data(self):
|
||||
"""
|
||||
Returns live data from hub and all devices
|
||||
"""
|
||||
|
||||
message = {"GET_LIVE_DATA": 0}
|
||||
live_data = await self._send(message)
|
||||
|
||||
return live_data
|
||||
|
||||
async def permit_join(self, name, timeout_s: int = 120):
|
||||
"""
|
||||
Permit new thermostat to join network
|
||||
|
||||
name: new zone will be added with this name
|
||||
timeout: duration of discovery mode in seconds
|
||||
|
||||
To actually join network you need to select 01
|
||||
from the thermostat's setup menu.
|
||||
"""
|
||||
|
||||
message = {"PERMIT_JOIN": [timeout_s, name]}
|
||||
reply = {"result": "network allows joining"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_lock(self, pin: int, devices: [NeoStat]):
|
||||
"""
|
||||
PIN locks thermostats
|
||||
|
||||
PIN is a four digit number
|
||||
"""
|
||||
|
||||
try:
|
||||
if pin < 0 or pin > 9999:
|
||||
return False
|
||||
except TypeError:
|
||||
raise NeoHubUsageError('pin must be a number')
|
||||
|
||||
pins = []
|
||||
for x in range(4):
|
||||
pins.append(pin % 10)
|
||||
pin = pin // 10
|
||||
pins.reverse()
|
||||
|
||||
names = self._devices_to_names(devices)
|
||||
message = {"LOCK": [pins, names]}
|
||||
reply = {"result": "locked"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def unlock(self, devices: [NeoStat]):
|
||||
"""
|
||||
Unlocks PIN locked thermostats
|
||||
"""
|
||||
|
||||
names = self._devices_to_names(devices)
|
||||
message = {"UNLOCK": names}
|
||||
reply = {"result": "unlocked"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_frost(self, state: bool, devices: [NeoStat]):
|
||||
"""
|
||||
Enables or disables Frost mode
|
||||
"""
|
||||
|
||||
names = self._devices_to_names(devices)
|
||||
message = {"FROST_ON" if state else "FROST_OFF": names}
|
||||
reply = {"result": "frost on" if state else "frost off"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_cool_temp(self, temperature: int, devices: [NeoStat]):
|
||||
"""
|
||||
Sets the thermostat's cooling temperature i.e. the temperature that will
|
||||
trigger the thermostat if exceeded. Note that this is only supported on
|
||||
the HC (Heating/Cooling) thermostats.
|
||||
|
||||
The temperature will be reset once next comfort level is reached
|
||||
"""
|
||||
|
||||
names = self._devices_to_names(devices)
|
||||
message = {"SET_COOL_TEMP": [temperature, names]}
|
||||
reply = {"result": "temperature was set"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_target_temperature(self, temperature: int, devices: [NeoStat]):
|
||||
"""
|
||||
Sets the thermostat's temperature
|
||||
|
||||
The temperature will be reset once next comfort level is reached
|
||||
"""
|
||||
|
||||
names = self._devices_to_names(devices)
|
||||
message = {"SET_TEMP": [temperature, names]}
|
||||
reply = {"result": "temperature was set"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_diff(self, switching_differential: int, devices: [NeoStat]):
|
||||
"""
|
||||
Sets the thermostat's switching differential
|
||||
|
||||
-1: Undocumented option. Seems to set differential to 204.
|
||||
0: 0.5 degrees
|
||||
1: 1 degree
|
||||
2: 2 degrees
|
||||
3: 3 degrees
|
||||
"""
|
||||
|
||||
names = self._devices_to_names(devices)
|
||||
message = {"SET_DIFF": [switching_differential, names]}
|
||||
reply = {"result": "switching differential was set"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def rate_of_change(self, devices: [NeoStat]):
|
||||
"""
|
||||
Returns time in minutes required to change temperature by 1 degree
|
||||
"""
|
||||
|
||||
names = self._devices_to_names(devices)
|
||||
message = {"VIEW_ROC": names}
|
||||
|
||||
result = await self._send(message)
|
||||
return result.__dict__
|
||||
|
||||
async def set_timer(self, state: bool, devices: [NeoStat]):
|
||||
"""
|
||||
Turns the output of timeclock on or off
|
||||
|
||||
This function works only with NeoPlugs and does not work on
|
||||
NeoStats that are in timeclock mode.
|
||||
"""
|
||||
|
||||
names = self._devices_to_names(devices)
|
||||
message = {"TIMER_ON" if state else "TIMER_OFF": names}
|
||||
reply = {"result": "timers on" if state else "timers off"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_manual(self, state: bool, devices: [NeoStat]):
|
||||
"""
|
||||
Controls the timeclock built into the neoplug, can be enabled and disabled to allow for manual operation.
|
||||
This function works only with NeoPlugs and does not work on NeoStats that are in timeclock mode.
|
||||
"""
|
||||
|
||||
names = self._devices_to_names(devices)
|
||||
message = {"MANUAL_ON" if state else "MANUAL_OFF": names}
|
||||
reply = {"result": "manual on" if state else "manual off"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_hold(self, temperature: int, hours: int, minutes: int, devices: [NeoStat]):
|
||||
"""
|
||||
Tells a thermostat to maintain the current temperature for a fixed time.
|
||||
"""
|
||||
|
||||
names = self._devices_to_names(devices)
|
||||
ids = self._devices_to_device_ids(devices)
|
||||
message = {"HOLD": [{"temp": temperature, "hours": hours, "minutes": minutes, "id": f"{ids}"}, names]}
|
||||
reply = {"result": "temperature on hold"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_timer_hold(self, state: bool, minutes: int, devices: [NeoStat]):
|
||||
"""
|
||||
Turns the output of timeclock on or off for certain duration
|
||||
|
||||
This function works with NeoStats in timeclock mode
|
||||
"""
|
||||
|
||||
names = self._devices_to_names(devices)
|
||||
message = {"TIMER_HOLD_ON" if state else "TIMER_HOLD_OFF": [minutes, names]}
|
||||
reply = {"result": "timer hold on" if state else "timer hold off"}
|
||||
|
||||
result = await self._send(message, reply)
|
||||
return result
|
||||
|
||||
async def get_engineers(self):
|
||||
"""
|
||||
Get engineers data
|
||||
"""
|
||||
message = {"GET_ENGINEERS": 0}
|
||||
|
||||
data = await self._send(message)
|
||||
return data
|
||||
|
||||
async def get_devices_data(self):
|
||||
"""
|
||||
Returns live data from hub and all devices
|
||||
"""
|
||||
|
||||
# Get live data from Neohub
|
||||
live_hub_data = await self.get_live_data()
|
||||
|
||||
# Get the engineers data from the Neohub for things like Device Type, Sensor Mode etc.
|
||||
eng_hub_data = await self.get_engineers()
|
||||
|
||||
# Remove the devices node from the hub_data.
|
||||
devices = live_hub_data.devices
|
||||
delattr(live_hub_data, "devices")
|
||||
|
||||
neo_devices = []
|
||||
|
||||
# Iterate through all devices in list returned by the get_live_data and enrich this object with the attributes
|
||||
# from engineers data.
|
||||
for device in devices:
|
||||
# Find matching device ID in Engineers data
|
||||
for engineers_data_key, engineers_data_value in eng_hub_data.__dict__.items():
|
||||
# If we found a matching device ID combine the engineering data into the live data
|
||||
if device.DEVICE_ID == engineers_data_value.DEVICE_ID:
|
||||
for x in engineers_data_value.__dict__.items():
|
||||
# FLOOR_LIMIT is a duplicate key, do not overwrite this.
|
||||
if x[0] == "FLOOR_LIMIT":
|
||||
setattr(device, "ENG_FLOOR_LIMIT", x[1])
|
||||
else:
|
||||
setattr(device, x[0], x[1])
|
||||
|
||||
for neo_device in devices:
|
||||
neo_devices.append(NeoStat(self, neo_device))
|
||||
|
||||
devices = {
|
||||
'neo_devices': neo_devices
|
||||
}
|
||||
|
||||
return devices
|
|
@ -0,0 +1,217 @@
|
|||
# SPDX-FileCopyrightText: 2020-2021 Andrius Štikonas <andrius@stikonas.eu>
|
||||
# SPDX-FileCopyrightText: 2021 Dave O'Connor <daveoc@google.com>
|
||||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from async_property import async_property
|
||||
|
||||
from neohubapi.enums import HCMode
|
||||
from neohubapi.enums import Weekday
|
||||
|
||||
|
||||
class NeoStat(SimpleNamespace):
|
||||
"""
|
||||
Class representing NeoStat theormostat
|
||||
"""
|
||||
|
||||
def __init__(self, hub, thermostat):
|
||||
self._logger = logging.getLogger('neohub')
|
||||
self._data_ = thermostat
|
||||
self._hub = hub
|
||||
|
||||
self._simple_attrs = (
|
||||
'active_level',
|
||||
'active_profile',
|
||||
'available_modes',
|
||||
'away',
|
||||
'cool_on',
|
||||
'cool_temp',
|
||||
'current_floor_temperature',
|
||||
'date',
|
||||
'device_id',
|
||||
'device_type',
|
||||
'fan_control',
|
||||
'fan_speed',
|
||||
'floor_limit',
|
||||
'hc_mode',
|
||||
'heat_mode',
|
||||
'heat_on',
|
||||
'fan_control',
|
||||
'fan_speed',
|
||||
'hc_mode',
|
||||
'heat_mode',
|
||||
'heat_on',
|
||||
'hold_cool',
|
||||
'hold_hours',
|
||||
'hold_mins',
|
||||
'hold_off',
|
||||
'hold_on',
|
||||
'hold_state',
|
||||
'hold_temp',
|
||||
'hold_time', # This is updated below.
|
||||
'holiday',
|
||||
'lock',
|
||||
'low_battery',
|
||||
'manual_off',
|
||||
'modelock',
|
||||
'modulation_level',
|
||||
'offline',
|
||||
'pin_number',
|
||||
'preheat_active',
|
||||
'prg_temp',
|
||||
'prg_timer',
|
||||
'sensor_mode',
|
||||
'standby',
|
||||
'stat_version',
|
||||
'switch_delay_left', # This is updated below.
|
||||
'temporary_set_flag',
|
||||
'time', # This is updated below.
|
||||
'timer_on',
|
||||
'window_open',
|
||||
'write_count'
|
||||
)
|
||||
|
||||
for a in self._simple_attrs:
|
||||
data_attr = a.upper()
|
||||
if not hasattr(self._data_, data_attr):
|
||||
self._logger.debug(f"Thermostat object has no attribute {data_attr}")
|
||||
self.__dict__[a] = getattr(self._data_, data_attr, None)
|
||||
|
||||
# Renamed attrs
|
||||
self.name = getattr(self._data_, 'ZONE_NAME', getattr(self._data_, 'device', None))
|
||||
self.target_temperature = getattr(self._data_, 'SET_TEMP', None)
|
||||
self.temperature = getattr(self._data_, 'ACTUAL_TEMP', None)
|
||||
|
||||
# must be ints
|
||||
self.pin_number = int(self.pin_number)
|
||||
|
||||
# HOLD_TIME can be up to 99:99
|
||||
_hold_time = list(map(int, self.hold_time.split(':')))
|
||||
_hold_time_minutes = _hold_time[0] * 60 + _hold_time[1]
|
||||
self.hold_time = timedelta(minutes=_hold_time_minutes)
|
||||
|
||||
self.weekday = Weekday(self.date)
|
||||
|
||||
_switch_delay_left = datetime.strptime(self.switch_delay_left, "%H:%M")
|
||||
self.switch_delay_left = timedelta(
|
||||
hours=_switch_delay_left.hour,
|
||||
minutes=_switch_delay_left.minute)
|
||||
_time = datetime.strptime(self.time, "%H:%M")
|
||||
self.time = timedelta(hours=_time.hour, minutes=_time.minute)
|
||||
|
||||
# 35 Degrees appears to be the hard limit in app, 5 degrees appears to be the hard lower limit.
|
||||
self.max_temperature_limit = 35
|
||||
self.min_temperature_limit = 5
|
||||
|
||||
# Ensure that TIMECLOCKS are correctly reported
|
||||
if hasattr(self._data_, 'TIMECLOCK'):
|
||||
self.time_clock_mode = True
|
||||
else:
|
||||
self.time_clock_mode = False
|
||||
|
||||
# Adding an attribute to deal with battery powered or not.
|
||||
if self.device_type in [2, 5, 13, 14]:
|
||||
self.battery_powered = True
|
||||
else:
|
||||
self.battery_powered = False
|
||||
|
||||
if self.device_type == 1:
|
||||
# We're dealing with a NeoStat V1 there's a known bug in the API, so we must patch the hc_mode
|
||||
self.hc_mode = "HEATING"
|
||||
|
||||
def __str__(self):
|
||||
"""
|
||||
String representation.
|
||||
"""
|
||||
data_elem = []
|
||||
for elem in dir(self):
|
||||
if not callable(getattr(self, elem)) and not elem.startswith('_'):
|
||||
data_elem.append(elem)
|
||||
out = f'HeatMiser NeoStat {self.name}:\n'
|
||||
for elem in data_elem:
|
||||
out += f' - {elem}: {getattr(self, elem)}\n'
|
||||
return out
|
||||
|
||||
async def identify(self):
|
||||
"""
|
||||
Flashes Devices LED light
|
||||
"""
|
||||
|
||||
message = {"IDENTIFY_DEV": self.name}
|
||||
reply = {"result": "Device identifying"}
|
||||
|
||||
result = await self._hub._send(message, reply)
|
||||
return result
|
||||
|
||||
async def rename(self, new_name):
|
||||
"""
|
||||
Renames this zone
|
||||
"""
|
||||
|
||||
message = {"ZONE_TITLE": [self.name, new_name]}
|
||||
reply = {"result": "zone renamed"}
|
||||
|
||||
result = await self._hub._send(message, reply)
|
||||
return result
|
||||
|
||||
async def remove(self):
|
||||
"""
|
||||
Removes this zone
|
||||
|
||||
If successful, thermostat will be disconnected from the hub
|
||||
Note that it takes a few seconds to remove thermostat
|
||||
New get_zones call will still return the original list
|
||||
during that period.
|
||||
"""
|
||||
|
||||
message = {"REMOVE_ZONE": self.name}
|
||||
reply = {"result": "zone removed"}
|
||||
|
||||
result = await self._hub._send(message, reply)
|
||||
return result
|
||||
|
||||
async def set_lock(self, pin: int):
|
||||
result = await self._hub.set_lock(pin, [self])
|
||||
return result
|
||||
|
||||
async def unlock(self):
|
||||
result = await self._hub.unlock([self])
|
||||
return result
|
||||
|
||||
async def set_frost(self, state: bool):
|
||||
result = await self._hub.set_frost(state, [self])
|
||||
return result
|
||||
|
||||
async def set_target_temperature(self, temperature: int):
|
||||
result = await self._hub.set_target_temperature(temperature, [self])
|
||||
return result
|
||||
|
||||
async def set_hc_mode(self, hc_mode: HCMode):
|
||||
result = await self._hub.set_hc_mode(hc_mode, [self])
|
||||
return result
|
||||
|
||||
async def set_cool_temp(self, temperature: int):
|
||||
result = await self._hub.set_cool_temp(temperature, [self])
|
||||
return result
|
||||
|
||||
async def set_diff(self, switching_differential: int):
|
||||
result = await self._hub.set_diff(switching_differential, [self])
|
||||
return result
|
||||
|
||||
@async_property
|
||||
async def rate_of_change(self):
|
||||
result = await self._hub.rate_of_change([self])
|
||||
roc = result[self.name]
|
||||
return roc
|
||||
|
||||
async def set_timer_hold(self, state: bool, minutes: int):
|
||||
"""
|
||||
Turns the output of timeclock on or off for certain duration
|
||||
|
||||
Works only with NeoStats in timeclock mode
|
||||
"""
|
||||
result = await self._hub.set_timer_hold(state, minutes, [self])
|
||||
return result
|
85
neostat.py
85
neostat.py
|
@ -1,85 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# SPDX-FileCopyrightText: 2020 Andrius Štikonas <andrius@stikonas.eu>
|
||||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
|
||||
class NeoStat:
|
||||
"""
|
||||
Class representing NeoStat theormostat
|
||||
"""
|
||||
|
||||
def __init__(self, hub, name: str, zone_id: int):
|
||||
self._hub = hub
|
||||
self._name = name
|
||||
self._zone_id = zone_id
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Zone name. """
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def zone_id(self):
|
||||
""" End of holiday. """
|
||||
return self._zone_id
|
||||
|
||||
async def identify(self):
|
||||
"""
|
||||
Flashes red LED light
|
||||
"""
|
||||
|
||||
message = {"IDENTIFY_DEV": self.zone_id}
|
||||
reply = {"result": "Device identifying"}
|
||||
|
||||
result = await self._hub._send(message, reply)
|
||||
return result
|
||||
|
||||
async def rename(self, new_name):
|
||||
"""
|
||||
Renames this zone
|
||||
"""
|
||||
|
||||
message = {"ZONE_TITLE": [self.name, new_name]}
|
||||
reply = {"result": "flashing led"}
|
||||
|
||||
result = await self._hub._send(message, reply)
|
||||
if result:
|
||||
self.name = new_name
|
||||
return result
|
||||
|
||||
async def remove(self):
|
||||
"""
|
||||
Removes this zone
|
||||
|
||||
If successful, thermostat will be disconnected from the hub
|
||||
Note that it takes a few seconds to remove thermostat
|
||||
New get_zones call will still return the original list
|
||||
during that period.
|
||||
"""
|
||||
|
||||
message = {"REMOVE_ZONE": self.name}
|
||||
reply = {"result": "zone removed"}
|
||||
|
||||
result = await self._hub._send(message, reply)
|
||||
return result
|
||||
|
||||
async def lock(self, pin: int):
|
||||
result = await self._hub.lock(pin, [self])
|
||||
return result
|
||||
|
||||
async def unlock(self):
|
||||
result = await self._hub.unlock([self])
|
||||
return result
|
||||
|
||||
async def frost(self, state: bool):
|
||||
result = await self._hub.frost(state, [self])
|
||||
return result
|
||||
|
||||
async def set_temp(self, temperature: int):
|
||||
result = await self._hub.set_temp(temperature, [self])
|
||||
return result
|
||||
|
||||
async def set_diff(self, switching_differential: int):
|
||||
result = await self._hub.set_diff(switching_differential, [self])
|
||||
return result
|
|
@ -0,0 +1,2 @@
|
|||
[pytest]
|
||||
asyncio_mode = auto
|
|
@ -0,0 +1,308 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# SPDX-FileCopyrightText: 2020-2021 Andrius Štikonas <andrius@stikonas.eu>
|
||||
# SPDX-FileCopyrightText: 2021 Dave O'Connor <daveoc@google.com>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import types
|
||||
import asyncio
|
||||
import inspect
|
||||
import argparse
|
||||
import datetime
|
||||
from functools import partial
|
||||
from neohubapi.neohub import NeoHub
|
||||
from neohubapi.neohub import NeoHubUsageError
|
||||
from neohubapi.neostat import NeoStat
|
||||
from neohubapi.enums import ScheduleFormat, Weekday
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NeoHubCLIUsageError(Error):
|
||||
pass
|
||||
|
||||
|
||||
class NeoHubCLIInternalError(Error):
|
||||
pass
|
||||
|
||||
|
||||
class NeoHubCLIArgumentError(Error):
|
||||
pass
|
||||
|
||||
|
||||
class NeoHubCLI(object):
|
||||
"""A runner for neohub_cli operations."""
|
||||
|
||||
def __init__(self, command, args, hub_ip=None, hub_port=4242, token=None):
|
||||
self._hub = NeoHub(host=hub_ip, port=hub_port, token=token)
|
||||
self._command = command
|
||||
self._args = args
|
||||
# live data cached from the neohub. We assume this data will remain current
|
||||
# throughout the execution of the script.
|
||||
self._live_data = None
|
||||
|
||||
if command == 'help':
|
||||
return
|
||||
|
||||
if command not in self._hub_command_methods():
|
||||
raise NeoHubCLIUsageError(f'Unknown command {command}')
|
||||
|
||||
def _hub_command_methods(self):
|
||||
"""Return a list of NeoHub functions.
|
||||
|
||||
Right now this is just all methods not starting with _
|
||||
|
||||
"""
|
||||
all_methods = [
|
||||
m for m in dir(self._hub) if inspect.ismethod(getattr(self._hub, m))]
|
||||
|
||||
return [m for m in all_methods if not m.startswith('_')]
|
||||
|
||||
async def callable(self):
|
||||
"""Return a bound callable for the method we want, or None."""
|
||||
if self._command == 'help':
|
||||
print(self.get_help(self._args))
|
||||
return None
|
||||
|
||||
# Firstly, see if we have a separately-implemented exception below.
|
||||
special_method = getattr(self, f'_callable_{self._command}', None)
|
||||
if special_method:
|
||||
return await special_method()
|
||||
|
||||
hubmethod = getattr(self._hub, self._command)
|
||||
sig = inspect.signature(hubmethod)
|
||||
if len(sig.parameters) == 0:
|
||||
# No arguments
|
||||
return hubmethod
|
||||
|
||||
# See if we have the right numer of command line arguments.
|
||||
if len(sig.parameters) != len(self._args):
|
||||
print(f'Expecting {len(sig.parameters)} args for {self._command}, got {len(self._args)}')
|
||||
print(self.get_help([self._command]))
|
||||
return None
|
||||
|
||||
# Next, see if all our arguments are annotated correctly.
|
||||
# Several methods ask for [NeoStat] (i.e. a list of NeoStat objects).
|
||||
# Cover for this case, without getting into expanding lists of other things.
|
||||
# (Yes, this means we only support passing one neostat name per CLI command, for now).
|
||||
arg_types = []
|
||||
for p in sig.parameters:
|
||||
a = sig.parameters[p].annotation
|
||||
if isinstance(a, type):
|
||||
arg_types.append(a)
|
||||
elif isinstance(a, list):
|
||||
if a[0] == NeoStat:
|
||||
arg_types.append(a[0])
|
||||
else:
|
||||
raise NeoHubCLIInternalError(f'Command {self._command} argument not bindable: {a[0]}')
|
||||
else:
|
||||
raise NeoHubCLIInternalError(f'Unexpected para annotation in {self._command}: {a}')
|
||||
|
||||
if inspect._empty not in arg_types:
|
||||
# build a list comprising the 'real' objects represented.
|
||||
real_args = []
|
||||
for i in range(len(self._args)):
|
||||
try:
|
||||
real_args.append(await self._parse_arg(self._args[i], arg_types[i]))
|
||||
except NeoHubCLIArgumentError:
|
||||
raise
|
||||
except NeoHubCLIInternalError:
|
||||
print('Internal Error:')
|
||||
raise
|
||||
return partial(getattr(self._hub, self._command), *real_args)
|
||||
|
||||
# No special method and un-annotated params.
|
||||
print(f'Cannot do {self._command} (yet)')
|
||||
return None
|
||||
|
||||
async def _parse_arg(self, arg, argtype):
|
||||
"""Return the desired type, given the string version of arg.
|
||||
|
||||
This also does things liks parsing dates, etc.
|
||||
"""
|
||||
if argtype == int:
|
||||
if not arg.isnumeric():
|
||||
raise NeoHubCLIArgumentError(f'argument to {self._command} must be numeric')
|
||||
return int(arg)
|
||||
elif argtype == str:
|
||||
return str(arg)
|
||||
elif argtype == bool:
|
||||
if arg in ('1', 'True', 'true', 'on', 'y'):
|
||||
away = True
|
||||
elif arg in ('0', 'False', 'false', 'off', 'n'):
|
||||
away = False
|
||||
else:
|
||||
raise NeoHubCLIArgumentError(f'\'{arg}\' not recognised as boolean')
|
||||
return away
|
||||
elif argtype == ScheduleFormat:
|
||||
sf = getattr(ScheduleFormat, arg, None)
|
||||
if not sf:
|
||||
raise NeoHubCLIArgumentError(
|
||||
f'argument must be in {[x.name for x in ScheduleFormat]}')
|
||||
return sf
|
||||
elif argtype == datetime.datetime:
|
||||
try:
|
||||
dt = datetime.datetime.strptime(arg, '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
raise NeoHubCLIArgumentError('dates must be in format "YYYY-MM-DD HH:MM:SS"')
|
||||
return dt
|
||||
elif argtype == NeoStat:
|
||||
# We assume the cmdline argument is the thermostat name.
|
||||
if not self._live_data:
|
||||
# (hub_data, devices)
|
||||
self._live_data = await self._hub.get_live_data()
|
||||
found = [x for x in self._live_data[1]['thermostats'] if x.name == arg]
|
||||
if found:
|
||||
# methods always expect a list of these.
|
||||
return [found[0]]
|
||||
else:
|
||||
raise NeoHubCLIArgumentError(f'No such thermostat: {arg}')
|
||||
else:
|
||||
raise NeoHubCLIInternalError(f'Unknown type {type(argtype)} {argtype} for {self._command}')
|
||||
|
||||
async def _optional_datetime(self):
|
||||
# If we got an arg, assume it's a date, otherwise use today.
|
||||
if len(self._args) > 1:
|
||||
print(f'{self._command} takes zero or one argument')
|
||||
return None
|
||||
elif len(self._args) == 1:
|
||||
real_arg = await self._parse_arg(self._args[0], datetime.datetime)
|
||||
return partial(getattr(self._hub, self._command), *[real_arg])
|
||||
else:
|
||||
return getattr(self._hub, self._command)
|
||||
|
||||
# These take a single datetime (optional) argument.
|
||||
_callable_set_date = _optional_datetime
|
||||
_callable_set_time = _optional_datetime
|
||||
_callable_set_datetime = _optional_datetime
|
||||
|
||||
async def _callable_permit_join(self):
|
||||
if len(self._args) > 2 or len(self._args) == 0:
|
||||
print(f'{self._command} takes either 1 or 2 arguments')
|
||||
return None
|
||||
|
||||
real_name = self._args[0]
|
||||
args = [real_name]
|
||||
|
||||
if len(self._args) == 2:
|
||||
timeout_s = await self._parse_arg(self._args[1], int)
|
||||
args.append(timeout_s)
|
||||
|
||||
return partial(getattr(self._hub, 'permit_join'), *args)
|
||||
|
||||
def output(self, raw_result, output_format='json'):
|
||||
"""Produce output in a desired format."""
|
||||
if output_format == 'raw':
|
||||
return raw_result
|
||||
|
||||
# Right now, everything just returns hub data or a single outcome
|
||||
# except get_live_data. Handle special cases.
|
||||
special_case = getattr(self, f'_output_{self._command}', None)
|
||||
if special_case:
|
||||
return special_case(raw_result, output_format)
|
||||
|
||||
if isinstance(raw_result, bool):
|
||||
return 'Command Succeeded' if raw_result else 'Command Failed'
|
||||
|
||||
if type(raw_result) in (int, str):
|
||||
return f'{raw_result}'
|
||||
|
||||
if not isinstance(raw_result, types.SimpleNamespace):
|
||||
if isinstance(raw_result, dict):
|
||||
raw_result = types.SimpleNamespace(**raw_result)
|
||||
else:
|
||||
raise NeoHubCLIInternalError(
|
||||
f'Unexpected type {type(raw_result)} in output()')
|
||||
|
||||
return self._output_simplenamespace(raw_result, output_format)
|
||||
|
||||
def _resolve_output_val(self, val):
|
||||
"""Return a readable str version of a value.
|
||||
This is mainly so our own enums and objects look readable.
|
||||
"""
|
||||
if type(val) in (ScheduleFormat, Weekday):
|
||||
return val.value
|
||||
elif isinstance(val, NeoStat):
|
||||
return f'[NeoStat: {val.name}]'
|
||||
else:
|
||||
return val
|
||||
|
||||
def _output_simplenamespace(self, obj, output_format):
|
||||
"""Output a types.Simplenamespace object."""
|
||||
if output_format == 'list':
|
||||
attrs = dict(
|
||||
[(a, getattr(obj, a)) for a in dir(obj)
|
||||
if not a.startswith('_')])
|
||||
return '\n'.join([f'{a}: {self._resolve_output_val(attrs[a])}' for a in attrs])
|
||||
else:
|
||||
raise NeoHubCLIUsageError(f'Unknown output format {output_format}')
|
||||
|
||||
def _output_get_live_data(self, raw_result, output_format):
|
||||
"""Return special case output for get_live_data."""
|
||||
out = self._output_simplenamespace(raw_result[0], output_format)
|
||||
out += '\n\n'
|
||||
for device_type in raw_result[1]:
|
||||
out += f'{device_type}:\n'
|
||||
devices = raw_result[1][device_type]
|
||||
for d in devices:
|
||||
out += str(d) + '\n'
|
||||
return out
|
||||
|
||||
def get_help(self, args):
|
||||
"""Print help on what commands do"""
|
||||
if len(args) == 0:
|
||||
ret = 'Valid commands:\n\n'
|
||||
for cmd in self._hub_command_methods():
|
||||
ret += f' - {cmd}\n'
|
||||
return ret
|
||||
|
||||
# handle 'help <blah>'
|
||||
if args[0] not in self._hub_command_methods():
|
||||
return f'Command {args[0]} not known'
|
||||
|
||||
docstr = getattr(self._hub, args[0]).__doc__ or 'No help for {args[0]}'
|
||||
sig = inspect.signature(getattr(self._hub, args[0]))
|
||||
|
||||
ret = f'{args[0]}:\n'
|
||||
|
||||
if len(sig.parameters) == 0:
|
||||
ret += ' - No Arguments\n'
|
||||
else:
|
||||
for s in sig.parameters:
|
||||
ret += f' - {sig.parameters[s]}\n'
|
||||
|
||||
return f'{ret}\n{docstr}\n'
|
||||
|
||||
|
||||
async def main():
|
||||
argp = argparse.ArgumentParser(description='CLI to neohub devices')
|
||||
argp.add_argument('--hub_ip', help='IP address of NeoHub', default=None)
|
||||
argp.add_argument(
|
||||
'--hub_port', help='Port number of NeoHub to talk to', default=4242)
|
||||
argp.add_argument('--token', help='Token', default=None)
|
||||
argp.add_argument('--format', help='Output format', default='list')
|
||||
argp.add_argument('command', help='Command to issue')
|
||||
argp.add_argument('arg', help='Arguments to command', nargs='*')
|
||||
args = argp.parse_args()
|
||||
|
||||
try:
|
||||
nhc = NeoHubCLI(
|
||||
args.command,
|
||||
args.arg,
|
||||
hub_ip=args.hub_ip,
|
||||
hub_port=int(args.hub_port),
|
||||
token=args.token)
|
||||
m = await nhc.callable()
|
||||
if m:
|
||||
result = await m()
|
||||
print(nhc.output(result, output_format=args.format))
|
||||
except NeoHubUsageError as e:
|
||||
print(f'Invalid API usage: {e}')
|
||||
except Error as e:
|
||||
print(e)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
|
@ -0,0 +1,34 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# SPDX-FileCopyrightText: 2020 Andrius Štikonas <andrius@stikonas.eu>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import setuptools
|
||||
|
||||
|
||||
with open("README.md", "r", encoding="utf-8") as fh:
|
||||
long_description = fh.read()
|
||||
|
||||
setuptools.setup(
|
||||
name="neohubapi",
|
||||
version="2.0",
|
||||
description="Async library to communicate with Heatmiser NeoHub 2 API.",
|
||||
url="https://gitlab.com/neohubapi/neohubapi/",
|
||||
author="Andrius Štikonas",
|
||||
author_email="andrius@stikonas.eu",
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
|
||||
"Operating System :: OS Independent",
|
||||
],
|
||||
install_requires=[
|
||||
'async_property',
|
||||
'websockets',
|
||||
],
|
||||
long_description=long_description,
|
||||
long_description_content_type='text/markdown',
|
||||
packages=setuptools.find_packages(),
|
||||
scripts=['scripts/neohub_cli.py'],
|
||||
keywords=['neohub', 'heatmiser'],
|
||||
zip_safe=True,
|
||||
)
|
35
system.py
35
system.py
|
@ -1,35 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# SPDX-FileCopyrightText: 2020 Andrius Štikonas <andrius@stikonas.eu>
|
||||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
from enums import ScheduleFormat
|
||||
|
||||
|
||||
def schedule_format(int_format):
|
||||
if int_format == 0:
|
||||
return ScheduleFormat.ZERO
|
||||
elif int_format == 1:
|
||||
return ScheduleFormat.ONE
|
||||
elif int_format == 2:
|
||||
return ScheduleFormat.TWO
|
||||
elif int_format == 4:
|
||||
return ScheduleFormat.SEVEN
|
||||
else:
|
||||
raise ValueError('Unrecognized ScheduleFormat')
|
||||
|
||||
|
||||
class System:
|
||||
def __init__(self, system_info):
|
||||
self.dst_auto = system_info['DST_AUTO']
|
||||
self.dst_on = system_info['DST_ON']
|
||||
self.timer_format = schedule_format(system_info['FORMAT'])
|
||||
# If system timer format is set to non programmable, then any time clock remain
|
||||
# in the previous setting which is stored in ALT_TIMER_FORMAT.
|
||||
self.alt_timer_format = schedule_format(system_info['ALT_TIMER_FORMAT'])
|
||||
self.ntp = system_info['NTP_ON'] == "Running"
|
||||
self.hub_type = system_info['HUB_TYPE']
|
||||
self.hub_version = system_info['HUB_VERSION']
|
||||
self.temperature_unit = system_info["CORF"]
|
||||
self.timezone = system_info['TIME_ZONE']
|
||||
self.time = system_info['UTC']
|
24
test.py
24
test.py
|
@ -1,24 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# SPDX-FileCopyrightText: 2020 Andrius Štikonas <andrius@stikonas.eu>
|
||||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import neohub
|
||||
|
||||
from enums import ScheduleFormat
|
||||
|
||||
|
||||
async def run():
|
||||
hub = neohub.NeoHub()
|
||||
await hub.connect()
|
||||
system = await hub.get_system()
|
||||
result = await hub.get_zones()
|
||||
print(result)
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
asyncio.run(run())
|
|
@ -0,0 +1,109 @@
|
|||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import neohubapi
|
||||
|
||||
HOST = 'localhost'
|
||||
|
||||
|
||||
class FakeProtocol(asyncio.Protocol):
|
||||
"""A simple asyncio protocol that returns a given message."""
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def data_received(self, data):
|
||||
input = data.decode()
|
||||
# self.server and self.handler are set by create_protocol below.
|
||||
self.server.inputs.append(input)
|
||||
output = self.handler(input).encode() + b'\0'
|
||||
self.transport.write(output)
|
||||
self.transport.close()
|
||||
|
||||
|
||||
class FakeServer:
|
||||
def __init__(self, loop, port):
|
||||
self.port = port
|
||||
self.loop = loop
|
||||
self.inputs = []
|
||||
|
||||
async def start(self, handler):
|
||||
def create_protocol():
|
||||
fake_protocol = FakeProtocol()
|
||||
fake_protocol.handler = handler
|
||||
fake_protocol.server = self
|
||||
return fake_protocol
|
||||
self.server = await self.loop.create_server(create_protocol, HOST, self.port)
|
||||
|
||||
async def close(self):
|
||||
server, self.server = self.server, None
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
async def fakeserver(event_loop):
|
||||
"""Create a fakeserver pytest fixture."""
|
||||
server = FakeServer(event_loop, 4242)
|
||||
yield server
|
||||
await server.close()
|
||||
|
||||
|
||||
async def test_send_valid(fakeserver):
|
||||
def handler(input):
|
||||
return '{"message": "ok"}'
|
||||
await fakeserver.start(handler)
|
||||
|
||||
hub = neohubapi.neohub.NeoHub(host=HOST, port=fakeserver.port)
|
||||
|
||||
# expected_reply is not set: function returns the message.
|
||||
assert SimpleNamespace(message='ok') == await hub._send('test')
|
||||
|
||||
# Response equals to expected_reply: function returns True.
|
||||
assert await hub._send('test', {'message': 'ok'}) is True
|
||||
|
||||
# Response not equal to expected_reply: function returns False.
|
||||
assert await hub._send('test', {'message': 'not ok'}) is False
|
||||
|
||||
|
||||
async def test_send_invalid_json(fakeserver):
|
||||
def handler(input):
|
||||
return '{"message": not valid json"}'
|
||||
await fakeserver.start(handler)
|
||||
|
||||
hub = neohubapi.neohub.NeoHub(host=HOST, port=fakeserver.port)
|
||||
|
||||
# expected_reply is set, function returns False.
|
||||
assert await hub._send('test', {'message': 'ok'}) is False
|
||||
assert len(fakeserver.inputs) == 1 # by default there are no retries.
|
||||
|
||||
# expected_reply is not set, function raises exception.
|
||||
with pytest.raises(json.decoder.JSONDecodeError):
|
||||
await hub._send('test')
|
||||
|
||||
|
||||
async def test_send_timeout(fakeserver):
|
||||
def handler(input):
|
||||
time.sleep(0.2)
|
||||
return '{"message": "ok"}'
|
||||
await fakeserver.start(handler)
|
||||
|
||||
hub = neohubapi.neohub.NeoHub(host=HOST, port=fakeserver.port, request_timeout=0.1)
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await hub._send('test')
|
||||
|
||||
|
||||
async def test_send_retries(fakeserver):
|
||||
def handler(input):
|
||||
return '{"message": "error"}'
|
||||
await fakeserver.start(handler)
|
||||
|
||||
hub = neohubapi.neohub.NeoHub(
|
||||
host=HOST, port=fakeserver.port, request_attempts=3, request_timeout=0.1)
|
||||
|
||||
# after 3 attempts the result is still incorrect.
|
||||
assert await hub._send('test', {'message': 'ok'}) is False
|
||||
assert len(fakeserver.inputs) == 3
|
Loading…
Reference in New Issue