├── python ├── shared │ ├── __init__.py │ └── NavienSmartControl.py ├── credentials.sample.json ├── PoC.py └── NavienSmartControl-CLI.py ├── .gitattributes ├── .gitignore ├── setup.py ├── README.md └── LICENSE /python/shared/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /python/credentials.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "Username": "MyUsername", 3 | "Password": "MyPassword" 4 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.pyc 4 | 5 | # Secret credentials. 6 | /python/credentials.json -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r", encoding="utf-8") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="PyNavienSmartControl", 8 | version="1.0.0", 9 | author="Brian Rudy", 10 | author_email="brudy@praecogito.com", 11 | description="A Python module and tools for getting information about and controlling your Navien tankless water heater, combi-boiler or boiler connected via NaviLink.", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/rudybrian/PyNavienSmartControl", 15 | project_urls={ 16 | "Bug Tracker": "https://github.com/rudybrian/PyNavienSmartControl/issues" 17 | }, 18 | classifiers=[ 19 | "Programming Language :: Python :: 2.7", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3.6", 22 | "Programming Language :: Python :: 3.7", 23 | "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", 24 | "Operating System :: OS Independent", 25 | "Topic :: Internet", 26 | "Topic :: System :: Hardware :: Hardware Drivers", 27 | "Development Status :: 3 - Alpha", 28 | ], 29 | package_dir={"": "python/shared"}, 30 | packages=setuptools.find_packages(where="python/shared"), 31 | install_requires=[ 32 | "requests", 33 | "socket", 34 | "struct", 35 | "collections", 36 | "binascii", 37 | "enum", 38 | "json", 39 | "argparse", 40 | ], 41 | python_requires=">=2.7", 42 | ) 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyNavienSmartControl 2 | 3 | ## Note that this project is now archived. 4 | 5 | > Hi folks, 6 | > 7 | > After some consideration, I think it's time to archive this project as it has served it's intended purpose. With Navien's new v2 protocol requiring a total refactor > and @nikshriv having already fully integrated the changes into Home Assistant with his [actively maintained HASS integration] > (https://github.com/nikshriv/hass_navien_water_heater), it's time has come. 8 | > 9 | > I am happy that my minor contribution along the way has led to what is now a usable IoT/Smart Home product. 10 | 11 | 12 | Python library for getting information about and controlling your Navien tankless water heater, combi-boiler or boiler connected via NaviLink. 13 | 14 | Originally based on [matthew1471/Navien-API](https://github.com/matthew1471/Navien-API/), this module and tools have been completely rewritten to support the current protocols used by mobile applications. 15 | 16 | This updated implementation supports individual NPE, NCB, NHB, NFB, NFC, NPN, NPE2. NCB-H, NVW as well as cascaded NPE, NHB, NFB, NPN, NPE2 and NVW device types. 17 | 18 | Included with the Python library are two tools: CLI.py and PoC.py. 19 | 20 | CLI.py is a commandline tool that can be used interactively or scripted via automation to read and print specific information and provide full control for a specific device. 21 | ``` 22 | usage: NavienSmartControl-CLI.py [-h] [-gatewayid GATEWAYID] 23 | [-channel CHANNEL] 24 | [-devicenumber DEVICENUMBER] 25 | [-recirctemp RECIRCTEMP] 26 | [-heatingtemp HEATINGTEMP] 27 | [-hotwatertemp HOTWATERTEMP] 28 | [-power {on,off}] [-heat {on,off}] 29 | [-ondemand] [-schedule {on,off}] [-summary] 30 | [-trendsample] [-trendmonth] [-trendyear] 31 | [-modifyschedule {add,delete}] 32 | [-scheduletime SCHEDULETIME] 33 | [-scheduleday {wed,sun,thu,tue,mon,fri,sat}] 34 | [-schedulestate {on,off}] 35 | 36 | Control a Navien tankless water heater, combi-boiler or boiler connected via 37 | NaviLink. 38 | 39 | optional arguments: 40 | -h, --help show this help message and exit 41 | -gatewayid GATEWAYID Specify gatewayID (required when multiple gateways are 42 | used) 43 | -channel CHANNEL Specify channel (required when multiple channels are 44 | used) 45 | -devicenumber DEVICENUMBER 46 | Specify device number (required when multiple devices 47 | on a common channel are used) 48 | -recirctemp RECIRCTEMP 49 | Set the recirculation temperature to this value. 50 | -heatingtemp HEATINGTEMP 51 | Set the central heating temperature to this value. 52 | -hotwatertemp HOTWATERTEMP 53 | Set the hot water temperature to this value. 54 | -power {on,off} Turn the power on or off. 55 | -heat {on,off} Turn the heat on or off. 56 | -ondemand Trigger On Demand. 57 | -schedule {on,off} Turn the weekly recirculation schedule on or off. 58 | -summary Show the device's extended status. 59 | -trendsample Show the device's trend sample report. 60 | -trendmonth Show the device's trend month report. 61 | -trendyear Show the device's trend year report. 62 | -modifyschedule {add,delete} 63 | Modify recirculation schedule. Requires scheduletime, 64 | scheduleday and schedulestate 65 | -scheduletime SCHEDULETIME 66 | Modify schedule for given time in format HH:MM. 67 | -scheduleday {wed,sun,thu,tue,mon,fri,sat} 68 | Modify schedule for given day of week. 69 | -schedulestate {on,off} 70 | Modify schedule with given state. 71 | ``` 72 | PoC.py is a test framework that can iterate through all detected gateways and connected devices and demonstrates how to use each function in the module. 73 | 74 | Details on the protocol used by the Python module can be found in the [Wiki](https://github.com/rudybrian/PyNavienSmartControl/wiki/Protocol-Decoding) 75 | -------------------------------------------------------------------------------- /python/PoC.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Support Python3 in Python2. 4 | from __future__ import print_function 5 | 6 | # The NavienSmartControl code is in a library. 7 | from shared.NavienSmartControl import NavienSmartControl 8 | 9 | # Import select enums from the NavienSmartControl library 10 | from shared.NavienSmartControl import DeviceSorting 11 | from shared.NavienSmartControl import OnOFFFlag 12 | from shared.NavienSmartControl import DayOfWeek 13 | 14 | # The credentials are loaded from a separate file. 15 | import json 16 | 17 | import binascii 18 | 19 | # Load credentials. 20 | with open("credentials.json", "r") as in_file: 21 | credentials = json.load(in_file) 22 | 23 | # Create a reference to the NavienSmartControl library. 24 | navienSmartControl = NavienSmartControl( 25 | credentials["Username"], credentials["Password"] 26 | ) 27 | 28 | # Perform the login and get the list of gateways 29 | gateways = navienSmartControl.login() 30 | 31 | for i in range(len(gateways)): 32 | # Print out the gateway list information. 33 | print("Gateway List") 34 | print("---------------------------") 35 | print("Device ID: " + gateways[i]["GID"]) 36 | print("Nickname: " + gateways[i]["NickName"]) 37 | print("State: " + gateways[i]["State"]) 38 | print("Connected: " + gateways[i]["ConnectionTime"]) 39 | print("Server IP Address: " + gateways[i]["ServerIP"]) 40 | print("Server TCP Port Number: " + gateways[i]["ServerPort"]) 41 | print("---------------------------\n") 42 | 43 | # Connect to the socket. 44 | channelInfo = navienSmartControl.connect(gateways[i]["GID"]) 45 | 46 | # Print the channel info 47 | print("Channel Info") 48 | print("---------------------------") 49 | navienSmartControl.printResponseHandler(channelInfo, 0) 50 | print("---------------------------\n") 51 | 52 | print() 53 | # Request the info for each connected device 54 | for chan in channelInfo["channel"]: 55 | if ( 56 | DeviceSorting(channelInfo["channel"][chan]["deviceSorting"]).name 57 | != DeviceSorting.NO_DEVICE.name 58 | ): 59 | print("Channel " + chan + " Info:") 60 | for deviceNumber in range( 61 | 1, channelInfo["channel"][chan]["deviceCount"] + 1 62 | ): 63 | # Request the current state 64 | print("Device: " + str(deviceNumber)) 65 | state = navienSmartControl.sendStateRequest( 66 | binascii.unhexlify(gateways[i]["GID"]), int(chan), deviceNumber 67 | ) 68 | 69 | # Print out the current state 70 | print("State") 71 | print("---------------------------") 72 | navienSmartControl.printResponseHandler( 73 | state, channelInfo["channel"][chan]["deviceTempFlag"] 74 | ) 75 | print("---------------------------\n") 76 | 77 | # Request the trend sample data 78 | trendSample = navienSmartControl.sendTrendSampleRequest( 79 | binascii.unhexlify(gateways[i]["GID"]), int(chan), deviceNumber 80 | ) 81 | 82 | # Print out the trend sample data 83 | print("Trend Sample") 84 | print("---------------------------") 85 | navienSmartControl.printResponseHandler( 86 | trendSample, channelInfo["channel"][chan]["deviceTempFlag"] 87 | ) 88 | print("---------------------------\n") 89 | 90 | # Request the trend month data 91 | trendMonth = navienSmartControl.sendTrendMonthRequest( 92 | binascii.unhexlify(gateways[i]["GID"]), int(chan), deviceNumber 93 | ) 94 | 95 | # Print out the trend month data 96 | print("Trend Month") 97 | print("---------------------------") 98 | navienSmartControl.printResponseHandler( 99 | trendMonth, channelInfo["channel"][chan]["deviceTempFlag"] 100 | ) 101 | print("---------------------------\n") 102 | 103 | # Request the trend year data 104 | trendYear = navienSmartControl.sendTrendYearRequest( 105 | binascii.unhexlify(gateways[i]["GID"]), int(chan), deviceNumber 106 | ) 107 | 108 | # Print out the trend year data 109 | print("Trend Year") 110 | print("---------------------------") 111 | navienSmartControl.printResponseHandler( 112 | trendYear, channelInfo["channel"][chan]["deviceTempFlag"] 113 | ) 114 | print("---------------------------\n") 115 | 116 | ## Turn the power off 117 | # print("Turn the power off") 118 | # state = navienSmartControl.sendPowerControlRequest( 119 | # binascii.unhexlify(gateways[i]["GID"]), 120 | # int(chan), 121 | # deviceNumber, 122 | # OnOFFFlag.OFF.value 123 | # ) 124 | 125 | ## Turn the power on 126 | # print("Turn the power on") 127 | # state = navienSmartControl.sendPowerControlRequest( 128 | # binascii.unhexlify(gateways[i]["GID"]), 129 | # int(chan), 130 | # deviceNumber, 131 | # OnOFFFlag.ON.value, 132 | # ) 133 | 134 | ## Turn heat on 135 | # print("Turn heat on") 136 | # state = navienSmartControl.sendHeatControlRequest( 137 | # binascii.unhexlify(gateways[i]["GID"]), 138 | # int(chan), 139 | # deviceNumber, 140 | # channelInfo, 141 | # OnOFFFlag.ON.value, 142 | # ) 143 | 144 | ## Turn on on demand (equivalent of pressing HotButton) 145 | # print("Turn on on-demand") 146 | # state = navienSmartControl.sendOnDemandControlRequest( 147 | # binascii.unhexlify(gateways[i]["GID"]), 148 | # int(chan), 149 | # deviceNumber, 150 | # channelInfo, 151 | # ) 152 | 153 | ## Turn weekly schedule on 154 | # print("Turn weekly schedule on") 155 | # state = navienSmartControl.sendDeviceWeeklyControlRequest( 156 | # binascii.unhexlify(gateways[i]["GID"]), 157 | # int(chan), 158 | # deviceNumber, 159 | # OnOFFFlag.ON.value, 160 | # ) 161 | 162 | ## Set the water temperature to 125 163 | # tempToSet = 125 164 | # print("Set the water temperature to " + str(tempToSet)) 165 | # state = navienSmartControl.sendWaterTempControlRequest( 166 | # binascii.unhexlify(gateways[i]["GID"]), 167 | # int(chan), 168 | # deviceNumber, 169 | # channelInfo, 170 | # tempToSet, 171 | # ) 172 | 173 | ## Set the device heating water temperature to 125 174 | # tempToSet = 125 175 | # print("Set the water temperature to " + str(tempToSet)) 176 | # state = navienSmartControl.sendHeatingWaterTempControlRequest( 177 | # binascii.unhexlify(gateways[i]["GID"]), 178 | # int(chan), 179 | # deviceNumber, 180 | # channelInfo, 181 | # tempToSet, 182 | # ) 183 | 184 | ## Set the recirculation temperature to 125 185 | # tempToSet = 125 186 | # print("Set the water temperature to " + str(tempToSet)) 187 | # state = navienSmartControl.sendRecirculationTempControlRequest( 188 | # binascii.unhexlify(gateways[i]["GID"]), 189 | # int(chan), 190 | # deviceNumber, 191 | # channelInfo, 192 | # tempToSet, 193 | # ) 194 | 195 | WeeklyDay = { 196 | "dayOfWeek": DayOfWeek.SUN.value, 197 | "hour": 1, # 1AM 198 | "minute": 20, # 20 minutes past the hour (01:20) 199 | "isOnOFF": OnOFFFlag.OFF.value, # turn off 200 | } 201 | 202 | ## Add an entry to the weekly schedule 203 | # print("Add an entry to the weekly schedule") 204 | # state = navienSmartControl.sendDeviceControlWeeklyScheduleRequest( 205 | # state, 206 | # WeeklyDay, 207 | # "add" 208 | # ) 209 | 210 | ## Delete an entry from the weekly schedule 211 | # print("Delete an entry from the weekly schedule") 212 | # state = navienSmartControl.sendDeviceControlWeeklyScheduleRequest( 213 | # state, 214 | # WeeklyDay, 215 | # "delete" 216 | # ) 217 | 218 | ## Print out the current state 219 | # print("State") 220 | # print("---------------------------") 221 | # navienSmartControl.printResponseHandler(state, channelInfo["channel"][chan]["deviceTempFlag"]) 222 | # print("---------------------------\n") 223 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /python/NavienSmartControl-CLI.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Support Python3 in Python2. 4 | from __future__ import print_function 5 | 6 | # The NavienSmartControl code is in a library. 7 | from shared.NavienSmartControl import ( 8 | NavienSmartControl, 9 | DeviceSorting, 10 | OnOFFFlag, 11 | DayOfWeek, 12 | ControlType, 13 | TemperatureType, 14 | ) 15 | 16 | # The credentials are loaded from a separate file. 17 | import json 18 | 19 | # We support command line arguments. 20 | import argparse 21 | 22 | # We use the system package for interaction with the OS. 23 | import sys 24 | 25 | # We need to convert between human readable and raw hex forms of the ID 26 | import binascii 27 | 28 | # This script's version. 29 | version = 1.0 30 | 31 | # Check the user is invoking us directly rather than from a module. 32 | if __name__ == "__main__": 33 | 34 | # Output program banner. 35 | print("--------------") 36 | print("Navien-API V" + str(version)) 37 | print("--------------") 38 | print() 39 | 40 | # Get an initialised parser object. 41 | parser = argparse.ArgumentParser( 42 | description="Control a Navien tankless water heater, combi-boiler or boiler connected via NaviLink.", 43 | prefix_chars="-/", 44 | ) 45 | parser.add_argument( 46 | "-gatewayid", 47 | help="Specify gatewayID (required when multiple gateways are used)", 48 | ) 49 | parser.add_argument( 50 | "-channel", 51 | type=int, 52 | help="Specify channel (required when multiple channels are used)", 53 | ) 54 | parser.add_argument( 55 | "-devicenumber", 56 | type=int, 57 | help="Specify device number (required when multiple devices on a common channel are used)", 58 | ) 59 | parser.add_argument( 60 | "-recirctemp", type=int, help="Set the recirculation temperature to this value." 61 | ) 62 | parser.add_argument( 63 | "-heatingtemp", 64 | type=int, 65 | help="Set the central heating temperature to this value.", 66 | ) 67 | parser.add_argument( 68 | "-hotwatertemp", type=int, help="Set the hot water temperature to this value." 69 | ) 70 | parser.add_argument( 71 | "-power", choices={"on", "off"}, help="Turn the power on or off." 72 | ) 73 | parser.add_argument("-heat", choices={"on", "off"}, help="Turn the heat on or off.") 74 | parser.add_argument("-ondemand", action="store_true", help="Trigger On Demand.") 75 | parser.add_argument( 76 | "-schedule", 77 | choices={"on", "off"}, 78 | help="Turn the weekly recirculation schedule on or off.", 79 | ) 80 | parser.add_argument( 81 | "-summary", action="store_true", help="Show the device's extended status." 82 | ) 83 | parser.add_argument( 84 | "-trendsample", 85 | action="store_true", 86 | help="Show the device's trend sample report.", 87 | ) 88 | parser.add_argument( 89 | "-trendmonth", action="store_true", help="Show the device's trend month report." 90 | ) 91 | parser.add_argument( 92 | "-trendyear", action="store_true", help="Show the device's trend year report." 93 | ) 94 | parser.add_argument( 95 | "-modifyschedule", 96 | choices={"add", "delete"}, 97 | help="Modify recirculation schedule. Requires scheduletime, scheduleday and schedulestate", 98 | ) 99 | parser.add_argument( 100 | "-scheduletime", help="Modify schedule for given time in format HH:MM." 101 | ) 102 | parser.add_argument( 103 | "-scheduleday", 104 | choices={"sun", "mon", "tue", "wed", "thu", "fri", "sat"}, 105 | help="Modify schedule for given day of week.", 106 | ) 107 | parser.add_argument( 108 | "-schedulestate", 109 | choices={"on", "off"}, 110 | help="Modify schedule with given state.", 111 | ) 112 | 113 | # The following function provides arguments for calling functions when command line switches are used. 114 | args = parser.parse_args() 115 | 116 | # Were arguments specified? 117 | if len(sys.argv) == 1: 118 | parser.print_help(sys.stderr) 119 | 120 | # Yes, there was. 121 | else: 122 | 123 | # Load credentials. 124 | with open("credentials.json", "r") as in_file: 125 | credentials = json.load(in_file) 126 | 127 | # Create a reference to the NavienSmartControl library. 128 | navienSmartControl = NavienSmartControl( 129 | credentials["Username"], credentials["Password"] 130 | ) 131 | 132 | # Perform the login. 133 | gateways = navienSmartControl.login() 134 | 135 | myGatewayID = 0 136 | # If a gateway is specified, make sure it is in the list 137 | if args.gatewayid: 138 | foundGateway = False 139 | for i in range(len(gateways)): 140 | if args.gatewayid == gateways[i]["GID"]: 141 | myGatewayID = i 142 | foundGateway = True 143 | break 144 | if not foundGateway: 145 | raise ValueError("No such gatewayID " + args.gatewayid) 146 | elif len(gateways) > 1: 147 | if not args.summary: 148 | raise ValueError( 149 | "Must specify gatewayID when more than one is available. View summary to see list of gatewayIDs." 150 | ) 151 | 152 | myChannel = str(1) 153 | # If a channel is specified, ensure that it has a device connected 154 | channelInfo = navienSmartControl.connect(gateways[myGatewayID]["GID"]) 155 | channels = 0 156 | if args.channel: 157 | if ( 158 | DeviceSorting( 159 | channelInfo["channel"][str(args.channel)]["deviceSorting"] 160 | ).name 161 | == DeviceSorting.NO_DEVICE.name 162 | ): 163 | raise ValueError( 164 | "No device detected on channel " 165 | + str(args.channel) 166 | + " on gatewayID " 167 | + gateways[myGatewayID]["GID"] 168 | ) 169 | else: 170 | myChannel = str(args.channel) 171 | channels = 1 172 | else: 173 | # No channel is specified, so find the one that has a device connected if any 174 | foundChannel = False 175 | for chan in channelInfo["channel"]: 176 | if ( 177 | DeviceSorting(channelInfo["channel"][chan]["deviceSorting"]).name 178 | != DeviceSorting.NO_DEVICE.name 179 | ): 180 | myChannel = chan 181 | if foundChannel: 182 | if not args.summary: 183 | raise ValueError( 184 | "Must specify channel when more than one device is connected. View summary to see list of devicenumbers." 185 | ) 186 | foundChannel = True 187 | channels += channels 188 | if not foundChannel: 189 | raise ValueError( 190 | "No device detected on any channel on gatewayID " 191 | + gateways[myGatewayID]["GID"] 192 | ) 193 | 194 | myDeviceNumber = 1 195 | # If a devicenumber is specified, make sure it is present 196 | if args.devicenumber: 197 | if args.devicenumber > channelInfo["channel"][myChannel]["deviceCount"]: 198 | raise ValueError( 199 | "Devicenumber " 200 | + str(args.devicenumber) 201 | + " not found on channel " 202 | + str(myChannel) 203 | + " on gatewayID " 204 | + gateways[myGatewayID]["GID"] 205 | ) 206 | else: 207 | myDeviceNumber = args.devicenumber 208 | elif channelInfo["channel"][myChannel]["deviceCount"] > 1: 209 | if not args.summary: 210 | raise ValueError( 211 | "Must specify devicenumber when more than one is available. View summary to see list of devicenumbers." 212 | ) 213 | 214 | # print( 215 | # "GatewayID: " 216 | # + str(myGatewayID) 217 | # + ", channel: " 218 | # + str(myChannel) 219 | # + ", deviceNumber:" 220 | # + str(myDeviceNumber) 221 | # ) 222 | 223 | # We can provide a full summary. 224 | if args.summary: 225 | if (len(gateways) > 1) and (not args.gatewayid): 226 | # There is more than one gateway, and no gateway was specified, print all the gateways 227 | for i in range(len(gateways)): 228 | # Print out the gateway list information. 229 | print("---------------------------") 230 | print("Device ID: " + gateways[i]["GID"]) 231 | print("Nickname: " + gateways[i]["NickName"]) 232 | print("State: " + gateways[i]["State"]) 233 | print("Connected: " + gateways[i]["ConnectionTime"]) 234 | print("Server IP Address: " + gateways[i]["ServerIP"]) 235 | print("Server TCP Port Number: " + gateways[i]["ServerPort"]) 236 | print("---------------------------\n") 237 | print("Specify a gateway to view channel details.") 238 | 239 | else: 240 | print("---------------------------") 241 | print("Device ID: " + gateways[myGatewayID]["GID"]) 242 | print("Nickname: " + gateways[myGatewayID]["NickName"]) 243 | print("State: " + gateways[myGatewayID]["State"]) 244 | print("Connected: " + gateways[myGatewayID]["ConnectionTime"]) 245 | print("Server IP Address: " + gateways[myGatewayID]["ServerIP"]) 246 | print("Server TCP Port Number: " + gateways[myGatewayID]["ServerPort"]) 247 | print("---------------------------\n") 248 | 249 | # Print the channel info 250 | print("Channel Info") 251 | print("---------------------------") 252 | navienSmartControl.printResponseHandler(channelInfo, 0) 253 | print("---------------------------\n") 254 | 255 | print() 256 | if (channels > 1) and (not args.channel): 257 | print("Specify a channel to view device details.") 258 | else: 259 | if ( 260 | DeviceSorting( 261 | channelInfo["channel"][str(myChannel)]["deviceSorting"] 262 | ).name 263 | != DeviceSorting.NO_DEVICE.name 264 | ): 265 | print("Channel " + str(myChannel) + " Info:") 266 | for deviceNumber in range( 267 | 1, channelInfo["channel"][str(myChannel)]["deviceCount"] + 1 268 | ): 269 | # Request the current state 270 | print("Device: " + str(deviceNumber)) 271 | state = navienSmartControl.sendStateRequest( 272 | binascii.unhexlify(gateways[myGatewayID]["GID"]), 273 | int(myChannel), 274 | deviceNumber, 275 | ) 276 | 277 | # Print out the current state 278 | print("State") 279 | print("---------------------------") 280 | navienSmartControl.printResponseHandler( 281 | state, 282 | channelInfo["channel"][str(myChannel)][ 283 | "deviceTempFlag" 284 | ], 285 | ) 286 | print("---------------------------\n") 287 | if ( 288 | channelInfo["channel"][str(myChannel)]["deviceCount"] 289 | > 1 290 | ) and (not args.devicenumber): 291 | print( 292 | "Specify a devicenumber to select a specific device." 293 | ) 294 | else: 295 | raise ValueError( 296 | "No device detected on channel " + str(myChannel) + "." 297 | ) 298 | print() 299 | # We need to exit to ensure no other CLI args are processed when requesting 300 | # summary as we cannot be sure that the appropriate device identifiers have 301 | # been specified. 302 | sys.exit("Done") 303 | 304 | # Change the recirculation temperature. 305 | if args.recirctemp: 306 | # Send the request 307 | stateData = navienSmartControl.sendRecirculationTempControlRequest( 308 | binascii.unhexlify(gateways[myGatewayID]["GID"]), 309 | int(myChannel), 310 | myDeviceNumber, 311 | channelInfo, 312 | args.recirctemp, 313 | ) 314 | if ControlType(stateData["controlType"]) == ControlType.STATE: 315 | if "recirculationSettingTemperature" in stateData: 316 | if ( 317 | TemperatureType( 318 | channelInfo["channel"][str(myChannel)]["deviceTempFlag"] 319 | ) 320 | == TemperatureType.CELSIUS 321 | ): 322 | print( 323 | "Recirculation temperature now set to " 324 | + str( 325 | round( 326 | stateData["recirculationSettingTemperature"] / 2.0, 327 | 1, 328 | ) 329 | ) 330 | + " " 331 | + u"\u00b0" 332 | + "C" 333 | ) 334 | elif ( 335 | TemperatureType( 336 | channelInfo["channel"][str(myChannel)]["deviceTempFlag"] 337 | ) 338 | == TemperatureType.FAHRENHEIT 339 | ): 340 | print( 341 | "Recirculation temperature now set to " 342 | + str(stateData["recirculationSettingTemperature"]) 343 | + " " 344 | + u"\u00b0" 345 | + "F" 346 | ) 347 | else: 348 | raise ValueError( 349 | "Recirculation temperature does not appear to be supported." 350 | ) 351 | else: 352 | # We didn't receive the expected response, it's probably an error. Let the print handler deal with it. 353 | navienSmartControl.printResponseHandler( 354 | stateData, channelInfo["channel"][str(myChannel)]["deviceTempFlag"] 355 | ) 356 | 357 | # Set the central heating temperature. 358 | if args.heatingtemp: 359 | # Send the request 360 | stateData = navienSmartControl.sendHeatingWaterTempControlRequest( 361 | binascii.unhexlify(gateways[myGatewayID]["GID"]), 362 | int(myChannel), 363 | myDeviceNumber, 364 | channelInfo, 365 | args.heatingtemp, 366 | ) 367 | if ControlType(stateData["controlType"]) == ControlType.STATE: 368 | if ( 369 | TemperatureType( 370 | channelInfo["channel"][str(myChannel)]["deviceTempFlag"] 371 | ) 372 | == TemperatureType.CELSIUS 373 | ): 374 | print( 375 | "Heating setting temperature now set to " 376 | + str(round(stateData["heatSettingTemperature"] / 2.0, 1)) 377 | + " " 378 | + u"\u00b0" 379 | + "C" 380 | ) 381 | elif ( 382 | TemperatureType( 383 | channelInfo["channel"][str(myChannel)]["deviceTempFlag"] 384 | ) 385 | == TemperatureType.FAHRENHEIT 386 | ): 387 | print( 388 | "Heating setting temperature now set to " 389 | + str(stateData["heatSettingTemperature"]) 390 | + " " 391 | + u"\u00b0" 392 | + "F" 393 | ) 394 | else: 395 | # We didn't receive the expected response, it's probably an error. Let the print handler deal with it. 396 | navienSmartControl.printResponseHandler( 397 | stateData, channelInfo["channel"][str(myChannel)]["deviceTempFlag"] 398 | ) 399 | 400 | # Set the hot water temperature. 401 | if args.hotwatertemp: 402 | # Send the request 403 | stateData = navienSmartControl.sendWaterTempControlRequest( 404 | binascii.unhexlify(gateways[myGatewayID]["GID"]), 405 | int(myChannel), 406 | myDeviceNumber, 407 | channelInfo, 408 | args.hotwatertemp, 409 | ) 410 | if ControlType(stateData["controlType"]) == ControlType.STATE: 411 | if ( 412 | TemperatureType( 413 | channelInfo["channel"][str(myChannel)]["deviceTempFlag"] 414 | ) 415 | == TemperatureType.CELSIUS 416 | ): 417 | print( 418 | "Hot water setting temperature now set to " 419 | + str(round(stateData["hotWaterSettingTemperature"] / 2.0, 1)) 420 | + " " 421 | + u"\u00b0" 422 | + "C" 423 | ) 424 | elif ( 425 | TemperatureType( 426 | channelInfo["channel"][str(myChannel)]["deviceTempFlag"] 427 | ) 428 | == TemperatureType.FAHRENHEIT 429 | ): 430 | print( 431 | "Hot water setting temperature now set to " 432 | + str(stateData["hotWaterSettingTemperature"]) 433 | + " " 434 | + u"\u00b0" 435 | + "F" 436 | ) 437 | else: 438 | # We didn't receive the expected response, it's probably an error. Let the print handler deal with it. 439 | navienSmartControl.printResponseHandler( 440 | stateData, channelInfo["channel"][str(myChannel)]["deviceTempFlag"] 441 | ) 442 | 443 | # Set the power on or off 444 | if args.power: 445 | stateData = navienSmartControl.sendPowerControlRequest( 446 | binascii.unhexlify(gateways[myGatewayID]["GID"]), 447 | int(myChannel), 448 | myDeviceNumber, 449 | OnOFFFlag[(args.power).upper()].value, 450 | ) 451 | if "powerStatus" in stateData: 452 | print( 453 | "Power status is now " 454 | + (OnOFFFlag(stateData["powerStatus"]).name).lower() 455 | ) 456 | else: 457 | # We didn't receive the expected response, it's probably an error. Let the print handler deal with it. 458 | navienSmartControl.printResponseHandler( 459 | stateData, channelInfo["channel"][str(myChannel)]["deviceTempFlag"] 460 | ) 461 | 462 | # Set the heat on or off 463 | if args.heat: 464 | stateData = navienSmartControl.sendHeatControlRequest( 465 | binascii.unhexlify(gateways[myGatewayID]["GID"]), 466 | int(myChannel), 467 | myDeviceNumber, 468 | channelInfo, 469 | OnOFFFlag[(args.heat).upper()].value, 470 | ) 471 | if "heatStatus" in stateData: 472 | print( 473 | "Heat status is now " 474 | + (OnOFFFlag(stateData["heatStatus"]).name).lower() 475 | ) 476 | else: 477 | # We didn't receive the expected response, it's probably an error. Let the print handler deal with it. 478 | navienSmartControl.printResponseHandler( 479 | stateData, channelInfo["channel"][str(myChannel)]["deviceTempFlag"] 480 | ) 481 | 482 | # Set on demand on or off 483 | if args.ondemand: 484 | stateData = navienSmartControl.sendOnDemandControlRequest( 485 | binascii.unhexlify(gateways[myGatewayID]["GID"]), 486 | int(myChannel), 487 | myDeviceNumber, 488 | channelInfo, 489 | ) 490 | if "useOnDemand" in stateData: 491 | print( 492 | "On Demand status is now " 493 | + (OnOFFFlag(stateData["useOnDemand"]).name).lower() 494 | ) 495 | else: 496 | # We didn't receive the expected response, it's probably an error. Let the print handler deal with it. 497 | navienSmartControl.printResponseHandler( 498 | stateData, channelInfo["channel"][str(myChannel)]["deviceTempFlag"] 499 | ) 500 | 501 | # Set the weekly recirculation schedule on or off 502 | if args.schedule: 503 | stateData = navienSmartControl.sendDeviceWeeklyControlRequest( 504 | binascii.unhexlify(gateways[myGatewayID]["GID"]), 505 | int(myChannel), 506 | myDeviceNumber, 507 | OnOFFFlag[(args.schedule).upper()].value, 508 | ) 509 | if "weeklyControl" in stateData: 510 | print( 511 | "Weekly schedule control is now " 512 | + (OnOFFFlag(stateData["weeklyControl"]).name).lower() 513 | ) 514 | else: 515 | # We didn't receive the expected response, it's probably an error. Let the print handler deal with it. 516 | navienSmartControl.printResponseHandler( 517 | stateData, channelInfo["channel"][str(myChannel)]["deviceTempFlag"] 518 | ) 519 | 520 | # Print the trend sample info 521 | if args.trendsample: 522 | trendSample = navienSmartControl.sendTrendSampleRequest( 523 | binascii.unhexlify(gateways[myGatewayID]["GID"]), 524 | int(myChannel), 525 | myDeviceNumber, 526 | ) 527 | navienSmartControl.printResponseHandler( 528 | trendSample, channelInfo["channel"][str(myChannel)]["deviceTempFlag"] 529 | ) 530 | 531 | # Print the trend month info 532 | if args.trendmonth: 533 | trendMonth = navienSmartControl.sendTrendMonthRequest( 534 | binascii.unhexlify(gateways[myGatewayID]["GID"]), 535 | int(myChannel), 536 | myDeviceNumber, 537 | ) 538 | navienSmartControl.printResponseHandler( 539 | trendMonth, channelInfo["channel"][str(myChannel)]["deviceTempFlag"] 540 | ) 541 | 542 | # Print the trend year info 543 | if args.trendyear: 544 | trendYear = navienSmartControl.sendTrendYearRequest( 545 | binascii.unhexlify(gateways[myGatewayID]["GID"]), 546 | int(myChannel), 547 | myDeviceNumber, 548 | ) 549 | navienSmartControl.printResponseHandler( 550 | trendYear, channelInfo["channel"][str(myChannel)]["deviceTempFlag"] 551 | ) 552 | 553 | # Update recirculation schedule 554 | if args.modifyschedule: 555 | if args.scheduletime and args.scheduleday and args.schedulestate: 556 | hourMin = (args.scheduletime).split(":", 1) 557 | if (int(hourMin[0]) < 24) and (int(hourMin[1]) < 60): 558 | weeklyDay = { 559 | "dayOfWeek": DayOfWeek[(args.scheduleday).upper()].value, 560 | "hour": int(hourMin[0]), 561 | "minute": int(hourMin[1]), 562 | "isOnOFF": OnOFFFlag[(args.schedulestate).upper()].value, 563 | } 564 | currentState = navienSmartControl.sendStateRequest( 565 | binascii.unhexlify(gateways[myGatewayID]["GID"]), 566 | int(myChannel), 567 | myDeviceNumber, 568 | ) 569 | stateData = navienSmartControl.sendDeviceControlWeeklyScheduleRequest( 570 | currentState, weeklyDay, args.modifyschedule 571 | ) 572 | navienSmartControl.printResponseHandler( 573 | stateData, 574 | channelInfo["channel"][str(myChannel)]["deviceTempFlag"], 575 | ) 576 | else: 577 | raise ValueError( 578 | "Invalid time specified: " + args.scheduletime + "." 579 | ) 580 | else: 581 | raise ValueError( 582 | "Must supply values for modifyschedule, scheduletime, scheduleday and schedulestate." 583 | ) 584 | 585 | # finished parsing everything, just exit 586 | sys.exit("Done") 587 | -------------------------------------------------------------------------------- /python/shared/NavienSmartControl.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module makes it possible to interact with Navien tankless water heater, 3 | combi-boiler or boiler connected via NaviLink. 4 | 5 | Please refer to the documentation provided in the README.md, 6 | which can be found at https://github.com/rudybrian/PyNavienSmartControl/ 7 | 8 | Note: "pip install requests" if getting import errors. 9 | """ 10 | 11 | __version__ = "1.0" 12 | __author__ = "Brian Rudy" 13 | __email__ = "brudy@praecogito.com" 14 | __credits__ = ["matthew1471", "Gary T. Giesen"] 15 | __date__ = "3/15/2022" 16 | __license__ = "GPL" 17 | 18 | 19 | # Third party library 20 | import requests 21 | 22 | # We use raw sockets. 23 | import socket 24 | 25 | # We unpack structures. 26 | import struct 27 | 28 | # We use namedtuple to reduce index errors. 29 | import collections 30 | 31 | # We use binascii to convert some consts from hex. 32 | import binascii 33 | 34 | # We use Python enums. 35 | import enum 36 | 37 | # We need json support for parsing the REST API response 38 | import json 39 | 40 | 41 | class ControlType(enum.Enum): 42 | UNKNOWN = 0 43 | CHANNEL_INFORMATION = 1 44 | STATE = 2 45 | TREND_SAMPLE = 3 46 | TREND_MONTH = 4 47 | TREND_YEAR = 5 48 | ERROR_CODE = 6 49 | 50 | 51 | class ChannelUse(enum.Enum): 52 | UNKNOWN = 0 53 | CHANNEL_1_USE = 1 54 | CHANNEL_2_USE = 2 55 | CHANNEL_1_2_USE = 3 56 | CHANNEL_3_USE = 4 57 | CHANNEL_1_3_USE = 5 58 | CHANNEL_2_3_USE = 6 59 | CHANNEL_1_2_3_USE = 7 60 | 61 | 62 | class DeviceSorting(enum.Enum): 63 | NO_DEVICE = 0 64 | NPE = 1 65 | NCB = 2 66 | NHB = 3 67 | CAS_NPE = 4 68 | CAS_NHB = 5 69 | NFB = 6 70 | CAS_NFB = 7 71 | NFC = 8 72 | NPN = 9 73 | CAS_NPN = 10 74 | NPE2 = 11 75 | CAS_NPE2 = 12 76 | NCB_H = 13 77 | NVW = 14 78 | CAS_NVW = 15 79 | 80 | 81 | class TemperatureType(enum.Enum): 82 | UNKNOWN = 0 83 | CELSIUS = 1 84 | FAHRENHEIT = 2 85 | 86 | 87 | class OnDemandFlag(enum.Enum): 88 | UNKNOWN = 0 89 | ON = 1 90 | OFF = 2 91 | WARMUP = 3 92 | 93 | 94 | class HeatingControl(enum.Enum): 95 | UNKNOWN = 0 96 | SUPPLY = 1 97 | RETURN = 2 98 | OUTSIDE_CONTROL = 3 99 | 100 | 101 | class WWSDFlag(enum.Enum): 102 | OK = False 103 | FAIL = True 104 | 105 | 106 | class WWSDMask(enum.Enum): 107 | WWSDFLAG = 0x01 108 | COMMERCIAL_LOCK = 0x02 109 | HOTWATER_POSSIBILITY = 0x04 110 | RECIRCULATION_POSSIBILITY = 0x08 111 | 112 | 113 | class CommercialLockFlag(enum.Enum): 114 | OK = False 115 | LOCK = True 116 | 117 | 118 | class NFBWaterFlag(enum.Enum): 119 | OFF = False 120 | ON = True 121 | 122 | 123 | class RecirculationFlag(enum.Enum): 124 | OFF = False 125 | ON = True 126 | 127 | 128 | class HighTemperature(enum.Enum): 129 | TEMPERATURE_60 = 0 130 | TEMPERATURE_83 = 1 131 | 132 | 133 | class OnOFFFlag(enum.Enum): 134 | UNKNOWN = 0 135 | ON = 1 136 | OFF = 2 137 | 138 | 139 | class DayOfWeek(enum.Enum): 140 | UN_KNOWN = 0 141 | SUN = 1 142 | MON = 2 143 | TUE = 3 144 | WED = 4 145 | THU = 5 146 | FRI = 6 147 | SAT = 7 148 | 149 | 150 | class ControlSorting(enum.Enum): 151 | INFO = 1 152 | CONTROL = 2 153 | 154 | 155 | class DeviceControl(enum.Enum): 156 | POWER = 1 157 | HEAT = 2 158 | WATER_TEMPERATURE = 3 159 | HEATING_WATER_TEMPERATURE = 4 160 | ON_DEMAND = 5 161 | WEEKLY = 6 162 | RECIRCULATION_TEMPERATURE = 7 163 | 164 | 165 | class AutoVivification(dict): 166 | """Implementation of perl's autovivification feature.""" 167 | 168 | def __getitem__(self, item): 169 | try: 170 | return dict.__getitem__(self, item) 171 | except KeyError: 172 | value = self[item] = type(self)() 173 | return value 174 | 175 | 176 | class NavienSmartControl: 177 | """The main NavienSmartControl class""" 178 | 179 | # This prevents the requests module from creating its own user-agent. 180 | stealthyHeaders = {"User-Agent": None} 181 | 182 | # The Navien server. 183 | navienServer = "uscv2.naviensmartcontrol.com" 184 | navienWebServer = "https://" + navienServer 185 | navienServerSocketPort = 6001 186 | 187 | def __init__(self, userID, passwd): 188 | """ 189 | Construct a new 'NavienSmartControl' object. 190 | 191 | :param userID: The user ID used to log in to the mobile application 192 | :param passwd: The corresponding user's password 193 | :return: returns nothing 194 | """ 195 | self.userID = userID 196 | self.passwd = passwd 197 | self.connection = None 198 | 199 | def login(self): 200 | """ 201 | Login to the REST API 202 | 203 | :return: The REST API response 204 | """ 205 | response = requests.post( 206 | NavienSmartControl.navienWebServer + "/api/requestDeviceList", 207 | headers=NavienSmartControl.stealthyHeaders, 208 | data={"userID": self.userID, "password": self.passwd}, 209 | ) 210 | 211 | # If an error occurs this will raise it, otherwise it returns the gateway list. 212 | return self.handleResponse(response) 213 | 214 | def handleResponse(self, response): 215 | """ 216 | HTTP response handler 217 | 218 | :param response: The response returned by the REST API request 219 | :return: The gateway list JSON in dictionary form 220 | """ 221 | # We need to check for the HTTP response code before attempting to parse the data 222 | if response.status_code != 200: 223 | print(response.text) 224 | response_data = json.loads(response.text) 225 | if response_data["msg"] == "DB_ERROR": 226 | # Credentials invalid or some other error 227 | raise Exception( 228 | "Error: " 229 | + response_data["msg"] 230 | + ": Login details incorrect. Please note, these are case-sensitive." 231 | ) 232 | else: 233 | raise Exception("Error: " + response_data["msg"]) 234 | 235 | response_data = json.loads(response.text) 236 | 237 | try: 238 | response_data["data"] 239 | gateway_data = json.loads(response_data["data"]) 240 | except NameError: 241 | raise Exception("Error: Unexpected JSON response to gateway list request.") 242 | 243 | return gateway_data 244 | 245 | def connect(self, gatewayID): 246 | """ 247 | Connect to the binary API service 248 | 249 | :param gatewayID: The gatewayID that we want to connect to 250 | :return: The response data (normally a channel information response) 251 | """ 252 | 253 | # Construct a socket object. 254 | self.connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 255 | 256 | # Connect to the socket server. 257 | self.connection.connect( 258 | (NavienSmartControl.navienServer, NavienSmartControl.navienServerSocketPort) 259 | ) 260 | 261 | # Send the initial connection details 262 | self.connection.sendall( 263 | (self.userID + "$" + "iPhone1.0" + "$" + gatewayID).encode() 264 | ) 265 | 266 | # Receive the status. 267 | data = self.connection.recv(1024) 268 | 269 | # Return the parsed data. 270 | return self.parseResponse(data) 271 | 272 | def parseResponse(self, data): 273 | """ 274 | Main handler for handling responses from the binary protocol. 275 | This function passes on the response data to the appopriate response-specific parsing function. 276 | 277 | :param data: Data received from a response 278 | :return: The parsed response data from the corresponding response-specific parser. 279 | """ 280 | # The response is returned with a fixed header for the first 12 bytes 281 | commonResponseColumns = collections.namedtuple( 282 | "response", 283 | [ 284 | "deviceID", 285 | "countryCD", 286 | "controlType", 287 | "swVersionMajor", 288 | "swVersionMinor", 289 | ], 290 | ) 291 | commonResponseData = commonResponseColumns._make( 292 | struct.unpack("8s B B B B", data[:12]) 293 | ) 294 | 295 | # print("Device ID: " + "".join("%02x" % b for b in commonResponseData.deviceID)) 296 | 297 | # Based on the controlType, parse the response accordingly 298 | if commonResponseData.controlType == ControlType.CHANNEL_INFORMATION.value: 299 | retval = self.parseChannelInformationResponse(commonResponseData, data) 300 | elif commonResponseData.controlType == ControlType.STATE.value: 301 | retval = self.parseStateResponse(commonResponseData, data) 302 | elif commonResponseData.controlType == ControlType.TREND_SAMPLE.value: 303 | retval = self.parseTrendSampleResponse(commonResponseData, data) 304 | elif commonResponseData.controlType == ControlType.TREND_MONTH.value: 305 | retval = self.parseTrendMYResponse(commonResponseData, data) 306 | elif commonResponseData.controlType == ControlType.TREND_YEAR.value: 307 | retval = self.parseTrendMYResponse(commonResponseData, data) 308 | elif commonResponseData.controlType == ControlType.ERROR_CODE.value: 309 | retval = self.parseErrorCodeResponse(commonResponseData, data) 310 | elif commonResponseData.controlType == ControlType.UNKNOWN.value: 311 | raise Exception("Error: Unknown controlType. Please restart to retry.") 312 | else: 313 | raise Exception( 314 | "An error occurred in the process of retrieving data; please restart to retry." 315 | ) 316 | 317 | return retval 318 | 319 | def parseChannelInformationResponse(self, commonResponseData, data): 320 | """ 321 | Parse channel information response 322 | 323 | :param commonResponseData: The common response data from the response header 324 | :param data: The full channel information response data 325 | :return: The parsed channel information response data 326 | """ 327 | # This tells us which serial channels are in use 328 | chanUse = data[12] 329 | fwVersion = int( 330 | commonResponseData.swVersionMajor * 100 + commonResponseData.swVersionMinor 331 | ) 332 | channelResponseData = {} 333 | if fwVersion > 1500: 334 | chanOffset = 15 335 | else: 336 | chanOffset = 13 337 | 338 | if chanUse != ChannelUse.UNKNOWN.value: 339 | if fwVersion < 1500: 340 | channelResponseColumns = collections.namedtuple( 341 | "response", 342 | [ 343 | "channel", 344 | "deviceSorting", 345 | "deviceCount", 346 | "deviceTempFlag", 347 | "minimumSettingWaterTemperature", 348 | "maximumSettingWaterTemperature", 349 | "heatingMinimumSettingWaterTemperature", 350 | "heatingMaximumSettingWaterTemperature", 351 | "useOnDemand", 352 | "heatingControl", 353 | "wwsdFlag", 354 | "highTemperature", 355 | "useWarmWater", 356 | ], 357 | ) 358 | for x in range(3): 359 | tmpChannelResponseData = channelResponseColumns._make( 360 | struct.unpack( 361 | "B B B B B B B B B B B B B", 362 | data[ 363 | (13 + chanOffset * x) : (13 + chanOffset * x) 364 | + chanOffset 365 | ], 366 | ) 367 | ) 368 | channelResponseData[str(x + 1)] = tmpChannelResponseData._asdict() 369 | else: 370 | channelResponseColumns = collections.namedtuple( 371 | "response", 372 | [ 373 | "channel", 374 | "deviceSorting", 375 | "deviceCount", 376 | "deviceTempFlag", 377 | "minimumSettingWaterTemperature", 378 | "maximumSettingWaterTemperature", 379 | "heatingMinimumSettingWaterTemperature", 380 | "heatingMaximumSettingWaterTemperature", 381 | "useOnDemand", 382 | "heatingControl", 383 | "wwsdFlag", 384 | "highTemperature", 385 | "useWarmWater", 386 | "minimumSettingRecirculationTemperature", 387 | "maximumSettingRecirculationTemperature", 388 | ], 389 | ) 390 | for x in range(3): 391 | tmpChannelResponseData = channelResponseColumns._make( 392 | struct.unpack( 393 | "B B B B B B B B B B B B B B B", 394 | data[ 395 | (13 + chanOffset * x) : (13 + chanOffset * x) 396 | + chanOffset 397 | ], 398 | ) 399 | ) 400 | channelResponseData[str(x + 1)] = tmpChannelResponseData._asdict() 401 | tmpChannelResponseData = {"channel": channelResponseData} 402 | result = dict(commonResponseData._asdict(), **tmpChannelResponseData) 403 | return result 404 | else: 405 | raise Exception( 406 | "Error: Unknown Channel: An error occurred in the process of parsing channel information; please restart to retry." 407 | ) 408 | 409 | def parseStateResponse(self, commonResponseData, data): 410 | """ 411 | Parse state response 412 | 413 | :param commonResponseData: The common response data from the response header 414 | :param data: The full state response data 415 | :return: The parsed state response data 416 | """ 417 | stateResponseColumns = collections.namedtuple( 418 | "response", 419 | [ 420 | "controllerVersion", 421 | "pannelVersion", 422 | "deviceSorting", 423 | "deviceCount", 424 | "currentChannel", 425 | "deviceNumber", 426 | "errorCD", 427 | "operationDeviceNumber", 428 | "averageCalorimeter", 429 | "gasInstantUse", 430 | "gasAccumulatedUse", 431 | "hotWaterSettingTemperature", 432 | "hotWaterCurrentTemperature", 433 | "hotWaterFlowRate", 434 | "hotWaterTemperature", 435 | "heatSettingTemperature", 436 | "currentWorkingFluidTemperature", 437 | "currentReturnWaterTemperature", 438 | "powerStatus", 439 | "heatStatus", 440 | "useOnDemand", 441 | "weeklyControl", 442 | "totalDaySequence", 443 | ], 444 | ) 445 | stateResponseData = stateResponseColumns._make( 446 | struct.unpack( 447 | "2s 2s B B B B 2s B B 2s 4s B B 2s B B B B B B B B B", data[12:43] 448 | ) 449 | ) 450 | 451 | # Load each of the 7 daily sets of day sequences 452 | daySequenceResponseColumns = collections.namedtuple( 453 | "response", ["hour", "minute", "isOnOFF"] 454 | ) 455 | 456 | daySequences = AutoVivification() 457 | for i in range(7): 458 | i2 = i * 32 459 | i3 = i2 + 43 460 | # Note Python 2.x doesn't convert these properly, so need to explicitly unpack them 461 | daySequences[i]["dayOfWeek"] = self.bigHexToInt(data[i3]) 462 | weeklyTotalCount = self.bigHexToInt(data[i2 + 44]) 463 | for i4 in range(weeklyTotalCount): 464 | i5 = i4 * 3 465 | daySequence = daySequenceResponseColumns._make( 466 | struct.unpack("B B B", data[i2 + 45 + i5 : i2 + 45 + i5 + 3]) 467 | ) 468 | daySequences[i]["daySequence"][str(i4)] = daySequence._asdict() 469 | if len(data) > 271: 470 | stateResponseColumns2 = collections.namedtuple( 471 | "response", 472 | [ 473 | "hotWaterAverageTemperature", 474 | "inletAverageTemperature", 475 | "supplyAverageTemperature", 476 | "returnAverageTemperature", 477 | "recirculationSettingTemperature", 478 | "recirculationCurrentTemperature", 479 | ], 480 | ) 481 | stateResponseData2 = stateResponseColumns2._make( 482 | struct.unpack("B B B B B B", data[267:274]) 483 | ) 484 | else: 485 | stateResponseColumns2 = collections.namedtuple( 486 | "response", 487 | [ 488 | "hotWaterAverageTemperature", 489 | "inletAverageTemperature", 490 | "supplyAverageTemperature", 491 | "returnAverageTemperature", 492 | ], 493 | ) 494 | stateResponseData2 = stateResponseColumns2._make( 495 | struct.unpack("B B B B", data[267:272]) 496 | ) 497 | tmpDaySequences = {"daySequences": daySequences} 498 | result = dict(stateResponseData._asdict(), **tmpDaySequences) 499 | result.update(stateResponseData2._asdict()) 500 | result.update(commonResponseData._asdict()) 501 | return result 502 | 503 | def parseTrendSampleResponse(self, commonResponseData, data): 504 | """ 505 | Parse trend sample response 506 | 507 | :param commonResponseData: The common response data from the response header 508 | :param data: The full trend sample response data 509 | :return: The parsed trend sample response data 510 | """ 511 | if len(data) > 39: 512 | trendSampleResponseColumns = collections.namedtuple( 513 | "response", 514 | [ 515 | "controllerVersion", 516 | "pannelVersion", 517 | "deviceSorting", 518 | "deviceCount", 519 | "currentChannel", 520 | "deviceNumber", 521 | "modelInfo", 522 | "totalOperatedTime", 523 | "totalGasAccumulateSum", 524 | "totalHotWaterAccumulateSum", 525 | "totalCHOperatedTime", 526 | "totalDHWUsageTime", 527 | ], 528 | ) 529 | trendSampleResponseData = trendSampleResponseColumns._make( 530 | struct.unpack("2s 2s B B B B 3s 4s 4s 4s 4s 4s", data[12:43]) 531 | ) 532 | else: 533 | trendSampleResponseColumns = collections.namedtuple( 534 | "response", 535 | [ 536 | "controllerVersion", 537 | "pannelVersion", 538 | "deviceSorting", 539 | "deviceCount", 540 | "currentChannel", 541 | "deviceNumber", 542 | "modelInfo", 543 | "totalOperatedTime", 544 | "totalGasAccumulateSum", 545 | "totalHotWaterAccumulateSum", 546 | "totalCHOperatedTime", 547 | ], 548 | ) 549 | trendSampleResponseData = trendSampleResponseColumns._make( 550 | struct.unpack("2s 2s B B B B 3s 4s 4s 4s 4s", data[12:39]) 551 | ) 552 | result = trendSampleResponseData._asdict() 553 | result.update(commonResponseData._asdict()) 554 | return result 555 | 556 | def parseTrendMYResponse(self, commonResponseData, data): 557 | """ 558 | Parse trend month or year response 559 | 560 | :param commonResponseData: The common response data from the response header 561 | :param data: The full trend (month or year) response data 562 | :return: The parsed trend (month or year) response data 563 | """ 564 | trendSampleMYResponseColumns = collections.namedtuple( 565 | "response", 566 | [ 567 | "controllerVersion", 568 | "pannelVersion", 569 | "deviceSorting", 570 | "deviceCount", 571 | "currentChannel", 572 | "deviceNumber", 573 | "totalDaySequence", 574 | ], 575 | ) 576 | trendSampleMYResponseData = trendSampleMYResponseColumns._make( 577 | struct.unpack("2s 2s B B B B B", data[12:21]) 578 | ) 579 | 580 | # Read the trend sequence data 581 | trendSequenceColumns = collections.namedtuple( 582 | "response", 583 | [ 584 | "modelInfo", 585 | "gasAccumulatedUse", 586 | "hotWaterAccumulatedUse", 587 | "hotWaterOperatedCount", 588 | "onDemandUseCount", 589 | "heatAccumulatedUse", 590 | "outdoorAirMaxTemperature", 591 | "outdoorAirMinTemperature", 592 | "dHWAccumulatedUse", 593 | ], 594 | ) 595 | 596 | trendSequences = AutoVivification() 597 | # loops 31 times for month and 24 times for year 598 | for i in range(trendSampleMYResponseData.totalDaySequence): 599 | i2 = i * 22 600 | trendSequences[i]["dMIndex"] = data[i2 + 21] 601 | trendData = trendSequenceColumns._make( 602 | struct.unpack("3s 4s 4s 2s 2s 2s B B 2s", data[i2 + 22 : i2 + 43]) 603 | ) 604 | trendSequences[i]["trendData"] = trendData._asdict() 605 | 606 | tmpTrendSequences = {"trendSequences": trendSequences} 607 | result = dict(trendSampleMYResponseData._asdict(), **tmpTrendSequences) 608 | result.update(commonResponseData._asdict()) 609 | return result 610 | 611 | def parseErrorCodeResponse(self, commonResponseData, data): 612 | """ 613 | Parse error code response 614 | 615 | :param commonResponseData: The common response data from the response header 616 | :param data: The full error response data 617 | :return: The parsed error response data 618 | """ 619 | errorResponseColumns = collections.namedtuple( 620 | "response", 621 | [ 622 | "controllerVersion", 623 | "pannelVersion", 624 | "deviceSorting", 625 | "deviceCount", 626 | "currentChannel", 627 | "deviceNumber", 628 | "errorFlag", 629 | "errorCD", 630 | ], 631 | ) 632 | errorResponseData = trendSampleMYResponseColumns._make( 633 | struct.unpack("2s 2s B B B B B 2s", data[12:23]) 634 | ) 635 | result = errorResponseData._asdict() 636 | result.update(commonResponseData._asdict()) 637 | return result 638 | 639 | # ----- Convenience methods for printing response data in human readable form ----- 640 | 641 | def printResponseHandler(self, responseData, temperatureType): 642 | """ 643 | Master handler for printing specific response data 644 | 645 | :param responseData: The parsed response data 646 | :param temperatureType: The temperature type is used to determine if responses should be in metric or imperial units. 647 | """ 648 | if ControlType(responseData["controlType"]) == ControlType.CHANNEL_INFORMATION: 649 | self.printChannelInformation(responseData) 650 | elif ControlType(responseData["controlType"]) == ControlType.STATE: 651 | self.printState(responseData, temperatureType) 652 | elif ControlType(responseData["controlType"]) == ControlType.TREND_SAMPLE: 653 | self.printTrendSample(responseData, temperatureType) 654 | elif ControlType(responseData["controlType"]) == ControlType.TREND_MONTH: 655 | self.printTrendMY(responseData, temperatureType) 656 | elif ControlType(responseData["controlType"]) == ControlType.TREND_YEAR: 657 | self.printTrendMY(responseData, temperatureType) 658 | elif ControlType(responseData["controlType"]) == ControlType.ERROR_CODE: 659 | self.printError(responseData, temperatureType) 660 | else: 661 | raise Exception("Error: unknown controlType in response") 662 | 663 | def printChannelInformation(self, channelInformation): 664 | """ 665 | Print Channel Information response data 666 | 667 | :param responseData: The parsed channel information response data 668 | """ 669 | for chan in range(1, len(channelInformation["channel"]) + 1): 670 | print("Channel:" + str(chan)) 671 | print( 672 | "\tDevice Model Type: " 673 | + DeviceSorting( 674 | channelInformation["channel"][str(chan)]["deviceSorting"] 675 | ).name 676 | ) 677 | print( 678 | "\tDevice Count: " 679 | + str(channelInformation["channel"][str(chan)]["deviceCount"]) 680 | ) 681 | print( 682 | "\tTemp Flag: " 683 | + TemperatureType( 684 | channelInformation["channel"][str(chan)]["deviceTempFlag"] 685 | ).name 686 | ) 687 | print( 688 | "\tMinimum Setting Water Temperature: " 689 | + str( 690 | channelInformation["channel"][str(chan)][ 691 | "minimumSettingWaterTemperature" 692 | ] 693 | ) 694 | ) 695 | print( 696 | "\tMaximum Setting Water Temperature: " 697 | + str( 698 | channelInformation["channel"][str(chan)][ 699 | "maximumSettingWaterTemperature" 700 | ] 701 | ) 702 | ) 703 | print( 704 | "\tHeating Minimum Setting Water Temperature: " 705 | + str( 706 | channelInformation["channel"][str(chan)][ 707 | "heatingMinimumSettingWaterTemperature" 708 | ] 709 | ) 710 | ) 711 | print( 712 | "\tHeating Maximum Setting Water Temperature: " 713 | + str( 714 | channelInformation["channel"][str(chan)][ 715 | "heatingMaximumSettingWaterTemperature" 716 | ] 717 | ) 718 | ) 719 | print( 720 | "\tUse On Demand: " 721 | + OnDemandFlag( 722 | channelInformation["channel"][str(chan)]["useOnDemand"] 723 | ).name 724 | ) 725 | print( 726 | "\tHeating Control: " 727 | + HeatingControl( 728 | channelInformation["channel"][str(chan)]["heatingControl"] 729 | ).name 730 | ) 731 | # Do some different stuff with the wwsdFlag value 732 | print( 733 | "\twwsdFlag: " 734 | + WWSDFlag( 735 | ( 736 | channelInformation["channel"][str(chan)]["wwsdFlag"] 737 | & WWSDMask.WWSDFLAG.value 738 | ) 739 | > 0 740 | ).name 741 | ) 742 | print( 743 | "\tcommercialLock: " 744 | + CommercialLockFlag( 745 | ( 746 | channelInformation["channel"][str(chan)]["wwsdFlag"] 747 | & WWSDMask.COMMERCIAL_LOCK.value 748 | ) 749 | > 0 750 | ).name 751 | ) 752 | print( 753 | "\thotwaterPossibility: " 754 | + NFBWaterFlag( 755 | ( 756 | channelInformation["channel"][str(chan)]["wwsdFlag"] 757 | & WWSDMask.HOTWATER_POSSIBILITY.value 758 | ) 759 | > 0 760 | ).name 761 | ) 762 | print( 763 | "\trecirculationPossibility: " 764 | + RecirculationFlag( 765 | ( 766 | channelInformation["channel"][str(chan)]["wwsdFlag"] 767 | & WWSDMask.RECIRCULATION_POSSIBILITY.value 768 | ) 769 | > 0 770 | ).name 771 | ) 772 | print( 773 | "\tHigh Temperature: " 774 | + HighTemperature( 775 | channelInformation["channel"][str(chan)]["highTemperature"] 776 | ).name 777 | ) 778 | print( 779 | "\tUse Warm Water: " 780 | + OnOFFFlag( 781 | channelInformation["channel"][str(chan)]["useWarmWater"] 782 | ).name 783 | ) 784 | # These values are ony populated with firmware version > 1500 785 | if ( 786 | "minimumSettingRecirculationTemperature" 787 | in channelInformation["channel"][str(chan)] 788 | ): 789 | print( 790 | "\tMinimum Recirculation Temperature: " 791 | + channelInformation["channel"][str(chan)][ 792 | "minimumSettingRecirculationTemperature" 793 | ] 794 | ) 795 | print( 796 | "\tMaximum Recirculation Temperature: " 797 | + channelInformation["channel"][str(chan)][ 798 | "maximumSettingRecirculationTemperature" 799 | ] 800 | ) 801 | 802 | def printState(self, stateData, temperatureType): 803 | """ 804 | Print State response data 805 | 806 | :param responseData: The parsed state response data 807 | :param temperatureType: The temperature type is used to determine if responses should be in metric or imperial units. 808 | """ 809 | # print(json.dumps(stateData, indent=2, default=str)) 810 | print( 811 | "Controller Version: " 812 | + str(self.bigHexToInt(stateData["controllerVersion"])) 813 | ) 814 | print("Panel Version: " + str(self.bigHexToInt(stateData["pannelVersion"]))) 815 | print("Device Model Type: " + DeviceSorting(stateData["deviceSorting"]).name) 816 | print("Device Count: " + str(stateData["deviceCount"])) 817 | print("Current Channel: " + str(stateData["currentChannel"])) 818 | print("Device Number: " + str(stateData["deviceNumber"])) 819 | errorCD = self.bigHexToInt(stateData["errorCD"]) 820 | if errorCD == 0: 821 | errorCD = "Normal" 822 | print("Error Code: " + str(errorCD)) 823 | print("Operation Device Number: " + str(stateData["operationDeviceNumber"])) 824 | print( 825 | "Average Calorimeter: " 826 | + str(round(stateData["averageCalorimeter"] / 2.0, 1)) 827 | + " %" 828 | ) 829 | if temperatureType == TemperatureType.CELSIUS.value: 830 | if stateData["deviceSorting"] in [ 831 | DeviceSorting.NFC.value, 832 | DeviceSorting.NCB_H.value, 833 | DeviceSorting.NFB.value, 834 | DeviceSorting.NVW.value, 835 | ]: 836 | GIUFactor = 100 837 | else: 838 | GIUFactor = 10 839 | # This needs to be summed for cascaded units 840 | print( 841 | "Current Gas Usage: " 842 | + str( 843 | round( 844 | (self.bigHexToInt(stateData["gasInstantUse"]) * GIUFactor) 845 | / 10.0, 846 | 1, 847 | ) 848 | ) 849 | + " kcal" 850 | ) 851 | # This needs to be summed for cascaded units 852 | print( 853 | "Total Gas Usage: " 854 | + str(round(self.bigHexToInt(stateData["gasAccumulatedUse"]) / 10.0, 1)) 855 | + " m" 856 | + u"\u00b3" 857 | ) 858 | # only print these if DHW is in use 859 | if stateData["deviceSorting"] in [ 860 | DeviceSorting.NPE.value, 861 | DeviceSorting.NPN.value, 862 | DeviceSorting.NPE2.value, 863 | DeviceSorting.NCB.value, 864 | DeviceSorting.NFC.value, 865 | DeviceSorting.NCB_H.value, 866 | DeviceSorting.CAS_NPE.value, 867 | DeviceSorting.CAS_NPN.value, 868 | DeviceSorting.CAS_NPE2.value, 869 | DeviceSorting.NFB.value, 870 | DeviceSorting.NVW.value, 871 | DeviceSorting.CAS_NFB.value, 872 | DeviceSorting.CAS_NVW.value, 873 | ]: 874 | print( 875 | "Hot Water Setting Temperature: " 876 | + str(round(stateData["hotWaterSettingTemperature"] / 2.0, 1)) 877 | + " " 878 | + u"\u00b0" 879 | + "C" 880 | ) 881 | if str(DeviceSorting(stateData["deviceSorting"]).name).startswith( 882 | "CAS_" 883 | ): 884 | print( 885 | "Hot Water Average Temperature: " 886 | + str(round(stateData["hotWaterAverageTemperature"] / 2.0, 1)) 887 | + " " 888 | + u"\u00b0" 889 | + "C" 890 | ) 891 | print( 892 | "Inlet Average Temperature: " 893 | + str(round(stateData["inletAverageTemperature"] / 2.0, 1)) 894 | + " " 895 | + u"\u00b0" 896 | + "C" 897 | ) 898 | print( 899 | "Hot Water Current Temperature: " 900 | + str(round(stateData["hotWaterCurrentTemperature"] / 2.0, 1)) 901 | + " " 902 | + u"\u00b0" 903 | + "C" 904 | ) 905 | print( 906 | "Hot Water Flow Rate: " 907 | + str( 908 | round(self.bigHexToInt(stateData["hotWaterFlowRate"]) / 10.0, 1) 909 | ) 910 | + " LPM" 911 | ) 912 | print( 913 | "Inlet Temperature: " 914 | + str(round(stateData["hotWaterTemperature"] / 2.0, 1)) 915 | + " " 916 | + u"\u00b0" 917 | + "C" 918 | ) 919 | if "recirculationSettingTemperature" in stateData: 920 | print( 921 | "Recirculation Setting Temperature: " 922 | + str( 923 | round(stateData["recirculationSettingTemperature"] / 2.0, 1) 924 | ) 925 | + " " 926 | + u"\u00b0" 927 | + "C" 928 | ) 929 | print( 930 | "Recirculation Current Temperature: " 931 | + str( 932 | round(stateData["recirculationCurrentTemperature"] / 2.0, 1) 933 | ) 934 | + " " 935 | + u"\u00b0" 936 | + "C" 937 | ) 938 | # Only print these if CH is in use 939 | if stateData["deviceSorting"] in [ 940 | DeviceSorting.NHB.value, 941 | DeviceSorting.CAS_NHB.value, 942 | DeviceSorting.NFB.value, 943 | DeviceSorting.NVW.value, 944 | DeviceSorting.CAS_NFB.value, 945 | DeviceSorting.CAS_NVW.value, 946 | DeviceSorting.NCB.value, 947 | DeviceSorting.NFC.value, 948 | DeviceSorting.NCB_H.value, 949 | ]: 950 | # Don't show the setting for cascaded devices, as it isn't applicable 951 | print( 952 | "Heat Setting Temperature: " 953 | + str(round(stateData["heatSettingTemperature"] / 2.0, 1)) 954 | + " " 955 | + u"\u00b0" 956 | + "C" 957 | ) 958 | if str(DeviceSorting(stateData["deviceSorting"]).name).startswith( 959 | "CAS_" 960 | ): 961 | print( 962 | "Supply Average Temperature: " 963 | + str(round(stateData["supplyAverageTemperature"] / 2.0, 1)) 964 | + " " 965 | + u"\u00b0" 966 | + "C" 967 | ) 968 | print( 969 | "Return Average Temperature: " 970 | + str(round(stateData["returnAverageTemperature"] / 2.0, 1)) 971 | + " " 972 | + u"\u00b0" 973 | + "C" 974 | ) 975 | print( 976 | "Current Supply Water Temperature: " 977 | + str(round(stateData["currentWorkingFluidTemperature"] / 2.0, 1)) 978 | + " " 979 | + u"\u00b0" 980 | + "C" 981 | ) 982 | print( 983 | "Current Return Water Temperature: " 984 | + str(round(stateData["currentReturnWaterTemperature"] / 2.0), 1) 985 | + " " 986 | + u"\u00b0" 987 | + "C" 988 | ) 989 | elif temperatureType == TemperatureType.FAHRENHEIT.value: 990 | if stateData["deviceSorting"] in [ 991 | DeviceSorting.NFC.value, 992 | DeviceSorting.NCB_H.value, 993 | DeviceSorting.NFB.value, 994 | DeviceSorting.NVW.value, 995 | ]: 996 | GIUFactor = 10 997 | else: 998 | GIUFactor = 1 999 | # This needs to be summed for cascaded units 1000 | print( 1001 | "Current Gas Usage: " 1002 | + str( 1003 | round( 1004 | self.bigHexToInt(stateData["gasInstantUse"]) 1005 | * GIUFactor 1006 | * 3.968, 1007 | 1, 1008 | ) 1009 | ) 1010 | + " BTU" 1011 | ) 1012 | # This needs to be summed for cascaded units 1013 | print( 1014 | "Total Gas Usage: " 1015 | + str( 1016 | round( 1017 | (self.bigHexToInt(stateData["gasAccumulatedUse"]) * 35.314667) 1018 | / 10.0, 1019 | 1, 1020 | ) 1021 | ) 1022 | + " ft" 1023 | + u"\u00b3" 1024 | ) 1025 | # only print these if DHW is in use 1026 | if stateData["deviceSorting"] in [ 1027 | DeviceSorting.NPE.value, 1028 | DeviceSorting.NPN.value, 1029 | DeviceSorting.NPE2.value, 1030 | DeviceSorting.NCB.value, 1031 | DeviceSorting.NFC.value, 1032 | DeviceSorting.NCB_H.value, 1033 | DeviceSorting.CAS_NPE.value, 1034 | DeviceSorting.CAS_NPN.value, 1035 | DeviceSorting.CAS_NPE2.value, 1036 | DeviceSorting.NFB.value, 1037 | DeviceSorting.NVW.value, 1038 | DeviceSorting.CAS_NFB.value, 1039 | DeviceSorting.CAS_NVW.value, 1040 | ]: 1041 | print( 1042 | "Hot Water Setting Temperature: " 1043 | + str(stateData["hotWaterSettingTemperature"]) 1044 | + " " 1045 | + u"\u00b0" 1046 | + "F" 1047 | ) 1048 | if str(DeviceSorting(stateData["deviceSorting"]).name).startswith( 1049 | "CAS_" 1050 | ): 1051 | print( 1052 | "Hot Water Average Temperature: " 1053 | + str(stateData["hotWaterAverageTemperature"]) 1054 | + " " 1055 | + u"\u00b0" 1056 | + "F" 1057 | ) 1058 | print( 1059 | "Inlet Average Temperature: " 1060 | + str(stateData["inletAverageTemperature"]) 1061 | + " " 1062 | + u"\u00b0" 1063 | + "F" 1064 | ) 1065 | print( 1066 | "Hot Water Current Temperature: " 1067 | + str(stateData["hotWaterCurrentTemperature"]) 1068 | + " " 1069 | + u"\u00b0" 1070 | + "F" 1071 | ) 1072 | print( 1073 | "Hot Water Flow Rate: " 1074 | + str( 1075 | round( 1076 | (self.bigHexToInt(stateData["hotWaterFlowRate"]) / 3.785) 1077 | / 10.0, 1078 | 1, 1079 | ) 1080 | ) 1081 | + " GPM" 1082 | ) 1083 | print( 1084 | "Inlet Temperature: " 1085 | + str(stateData["hotWaterTemperature"]) 1086 | + " " 1087 | + u"\u00b0" 1088 | + "F" 1089 | ) 1090 | if "recirculationSettingTemperature" in stateData: 1091 | print( 1092 | "Recirculation Setting Temperature: " 1093 | + str(stateData["recirculationSettingTemperature"]) 1094 | + " " 1095 | + u"\u00b0" 1096 | + "F" 1097 | ) 1098 | print( 1099 | "Recirculation Current Temperature: " 1100 | + str(stateData["recirculationCurrentTemperature"]) 1101 | + " " 1102 | + u"\u00b0" 1103 | + "F" 1104 | ) 1105 | # Only print these if CH is in use 1106 | if stateData["deviceSorting"] in [ 1107 | DeviceSorting.NHB.value, 1108 | DeviceSorting.CAS_NHB.value, 1109 | DeviceSorting.NFB.value, 1110 | DeviceSorting.NVW.value, 1111 | DeviceSorting.CAS_NFB.value, 1112 | DeviceSorting.CAS_NVW.value, 1113 | DeviceSorting.NCB.value, 1114 | DeviceSorting.NFC.value, 1115 | DeviceSorting.NCB_H.value, 1116 | ]: 1117 | # Don't show the setting for cascaded devices, as it isn't applicable 1118 | print( 1119 | "Heat Setting Temperature: " 1120 | + str(stateData["heatSettingTemperature"]) 1121 | + " " 1122 | + u"\u00b0" 1123 | + "F" 1124 | ) 1125 | if str(DeviceSorting(stateData["deviceSorting"]).name).startswith( 1126 | "CAS_" 1127 | ): 1128 | print( 1129 | "Supply Average Temperature: " 1130 | + str(stateData["supplyAverageTemperature"]) 1131 | + " " 1132 | + u"\u00b0" 1133 | + "F" 1134 | ) 1135 | print( 1136 | "Return Average Temperature: " 1137 | + str(stateData["returnAverageTemperature"]) 1138 | + " " 1139 | + u"\u00b0" 1140 | + "F" 1141 | ) 1142 | print( 1143 | "Current Supply Water Temperature: " 1144 | + str(stateData["currentWorkingFluidTemperature"]) 1145 | + " " 1146 | + u"\u00b0" 1147 | + "F" 1148 | ) 1149 | print( 1150 | "Current Return Water Temperature: " 1151 | + str(stateData["currentReturnWaterTemperature"]) 1152 | + " " 1153 | + u"\u00b0" 1154 | + "F" 1155 | ) 1156 | else: 1157 | raise Exception("Error: Invalid temperatureType") 1158 | 1159 | print("Power Status: " + OnOFFFlag(stateData["powerStatus"]).name) 1160 | print("Heat Status: " + OnOFFFlag(stateData["heatStatus"]).name) 1161 | print("Use On Demand: " + OnDemandFlag(stateData["useOnDemand"]).name) 1162 | print("Weekly Control: " + OnOFFFlag(stateData["weeklyControl"]).name) 1163 | # Print the daySequences 1164 | print("Day Sequences") 1165 | for i in range(7): 1166 | print("\t" + DayOfWeek(stateData["daySequences"][i]["dayOfWeek"]).name) 1167 | if "daySequence" in stateData["daySequences"][i]: 1168 | for j in stateData["daySequences"][i]["daySequence"]: 1169 | print( 1170 | "\t\tHour: " 1171 | + str(stateData["daySequences"][i]["daySequence"][j]["hour"]) 1172 | + ", Minute: " 1173 | + str(stateData["daySequences"][i]["daySequence"][j]["minute"]) 1174 | + ", " 1175 | + OnOFFFlag( 1176 | stateData["daySequences"][i]["daySequence"][j]["isOnOFF"] 1177 | ).name 1178 | ) 1179 | else: 1180 | print("\t\tNone") 1181 | 1182 | def printTrendSample(self, trendSampleData, temperatureType): 1183 | """ 1184 | Print the trend sample response data 1185 | 1186 | :param responseData: The parsed trend sample response data 1187 | :param temperatureType: The temperature type is used to determine if responses should be in metric or imperial units. 1188 | """ 1189 | # print(json.dumps(trendSampleData, indent=2, default=str)) 1190 | print( 1191 | "Controller Version: " 1192 | + str(self.bigHexToInt(trendSampleData["controllerVersion"])) 1193 | ) 1194 | print( 1195 | "Panel Version: " + str(self.bigHexToInt(trendSampleData["pannelVersion"])) 1196 | ) 1197 | print( 1198 | "Device Model Type: " + DeviceSorting(trendSampleData["deviceSorting"]).name 1199 | ) 1200 | print("Device Count: " + str(trendSampleData["deviceCount"])) 1201 | print("Current Channel: " + str(trendSampleData["currentChannel"])) 1202 | print("Device Number: " + str(trendSampleData["deviceNumber"])) 1203 | print("Model Info: " + str(self.bigHexToInt(trendSampleData["modelInfo"]))) 1204 | print( 1205 | "Total Operated Time: " 1206 | + str(self.bigHexToInt(trendSampleData["totalOperatedTime"])) 1207 | ) 1208 | # totalGasAccumulateSum needs to be converted based on the metric or imperial setting 1209 | if temperatureType == TemperatureType.CELSIUS.value: 1210 | print( 1211 | "Total Gas Accumulated Sum: " 1212 | + str( 1213 | round( 1214 | self.bigHexToInt(trendSampleData["totalGasAccumulateSum"]) 1215 | / 10.0, 1216 | 1, 1217 | ) 1218 | ) 1219 | + " m" 1220 | + u"\u00b3" 1221 | ) 1222 | else: 1223 | print( 1224 | "Total Gas Accumulated Sum: " 1225 | + str( 1226 | round( 1227 | ( 1228 | self.bigHexToInt(trendSampleData["totalGasAccumulateSum"]) 1229 | * 35.314667 1230 | ) 1231 | / 10.0, 1232 | 1, 1233 | ) 1234 | ) 1235 | + " ft" 1236 | + u"\u00b3" 1237 | ) 1238 | print( 1239 | "Total Hot Water Accumulated Sum: " 1240 | + str(self.bigHexToInt(trendSampleData["totalHotWaterAccumulateSum"])) 1241 | ) 1242 | print( 1243 | "Total Central Heating Operated Time: " 1244 | + str(self.bigHexToInt(trendSampleData["totalCHOperatedTime"])) 1245 | ) 1246 | if "totalDHWUsageTime" in trendSampleData: 1247 | print( 1248 | "Total Domestic Hot Water Usage Time: " 1249 | + str(self.bigHexToInt(trendSampleData["totalDHWUsageTime"])) 1250 | ) 1251 | 1252 | def printTrendMY(self, trendMYData, temperatureType): 1253 | """ 1254 | Print the trend month or year response data 1255 | 1256 | :param responseData: The parsed trend month or year response data 1257 | :param temperatureType: The temperature type is used to determine if responses should be in metric or imperial units. 1258 | """ 1259 | # print(json.dumps(trendMYData, indent=2, default=str)) 1260 | print( 1261 | "Controller Version: " 1262 | + str(self.bigHexToInt(trendMYData["controllerVersion"])) 1263 | ) 1264 | print("Panel Version: " + str(self.bigHexToInt(trendMYData["pannelVersion"]))) 1265 | print("Device Model Type: " + DeviceSorting(trendMYData["deviceSorting"]).name) 1266 | print("Device Count: " + str(trendMYData["deviceCount"])) 1267 | print("Current Channel: " + str(trendMYData["currentChannel"])) 1268 | print("Device Number: " + str(trendMYData["deviceNumber"])) 1269 | # Print the trend data 1270 | for i in range(trendMYData["totalDaySequence"]): 1271 | print( 1272 | "\tIndex: " 1273 | + str(self.bigHexToInt(trendMYData["trendSequences"][i]["dMIndex"])) 1274 | ) 1275 | print( 1276 | "\t\tModel Info: " 1277 | + str( 1278 | self.bigHexToInt( 1279 | trendMYData["trendSequences"][i]["trendData"]["modelInfo"] 1280 | ) 1281 | ) 1282 | ) 1283 | print( 1284 | "\t\tHot Water Operated Count: " 1285 | + str( 1286 | self.bigHexToInt( 1287 | trendMYData["trendSequences"][i]["trendData"][ 1288 | "hotWaterOperatedCount" 1289 | ] 1290 | ) 1291 | ) 1292 | ) 1293 | print( 1294 | "\t\tOn Demand Use Count: " 1295 | + str( 1296 | self.bigHexToInt( 1297 | trendMYData["trendSequences"][i]["trendData"][ 1298 | "onDemandUseCount" 1299 | ] 1300 | ) 1301 | ) 1302 | ) 1303 | print( 1304 | "\t\tHeat Accumulated Use: " 1305 | + str( 1306 | self.bigHexToInt( 1307 | trendMYData["trendSequences"][i]["trendData"][ 1308 | "heatAccumulatedUse" 1309 | ] 1310 | ) 1311 | ) 1312 | ) 1313 | print( 1314 | "\t\tDomestic Hot Water Accumulated Use: " 1315 | + str( 1316 | self.bigHexToInt( 1317 | trendMYData["trendSequences"][i]["trendData"][ 1318 | "dHWAccumulatedUse" 1319 | ] 1320 | ) 1321 | ) 1322 | ) 1323 | if temperatureType == TemperatureType.CELSIUS.value: 1324 | print( 1325 | "\t\tTotal Gas Usage: " 1326 | + str( 1327 | round( 1328 | self.bigHexToInt( 1329 | trendMYData["trendSequences"][i]["trendData"][ 1330 | "gasAccumulatedUse" 1331 | ] 1332 | ) 1333 | / 10.0, 1334 | 1, 1335 | ) 1336 | ) 1337 | + " m" 1338 | + u"\u00b3" 1339 | ) 1340 | print( 1341 | "\t\tHot water Accumulated Use: " 1342 | + str( 1343 | round( 1344 | self.bigHexToInt( 1345 | trendMYData["trendSequences"][i]["trendData"][ 1346 | "hotWaterAccumulatedUse" 1347 | ] 1348 | ) 1349 | / 10.0, 1350 | 1, 1351 | ) 1352 | ) 1353 | + " L" 1354 | ) 1355 | print( 1356 | "\t\tOutdoor Air Max Temperature: " 1357 | + str( 1358 | round( 1359 | trendMYData["trendSequences"][i]["trendData"][ 1360 | "outdoorAirMaxTemperature" 1361 | ] 1362 | / 2.0, 1363 | 1, 1364 | ) 1365 | ) 1366 | + " " 1367 | + u"\u00b0" 1368 | + "C" 1369 | ) 1370 | print( 1371 | "\t\tOutdoor Air Min Temperature: " 1372 | + str( 1373 | round( 1374 | trendMYData["trendSequences"][i]["trendData"][ 1375 | "outdoorAirMinTemperature" 1376 | ] 1377 | / 2.0, 1378 | 1, 1379 | ) 1380 | ) 1381 | + " " 1382 | + u"\u00b0" 1383 | + "C" 1384 | ) 1385 | elif temperatureType == TemperatureType.FAHRENHEIT.value: 1386 | print( 1387 | "\t\tTotal Gas Usage: " 1388 | + str( 1389 | round( 1390 | ( 1391 | self.bigHexToInt( 1392 | trendMYData["trendSequences"][i]["trendData"][ 1393 | "gasAccumulatedUse" 1394 | ] 1395 | ) 1396 | * 35.314667 1397 | ) 1398 | / 10.0, 1399 | 1, 1400 | ) 1401 | ) 1402 | + " ft" 1403 | + u"\u00b3" 1404 | ) 1405 | print( 1406 | "\t\tHot water Accumulated Use: " 1407 | + str( 1408 | round( 1409 | ( 1410 | self.bigHexToInt( 1411 | trendMYData["trendSequences"][i]["trendData"][ 1412 | "hotWaterAccumulatedUse" 1413 | ] 1414 | ) 1415 | / 3.785 1416 | ) 1417 | / 10.0, 1418 | 1, 1419 | ) 1420 | ) 1421 | + " G" 1422 | ) 1423 | print( 1424 | "\t\tOutdoor Air Max Temperature: " 1425 | + str( 1426 | trendMYData["trendSequences"][i]["trendData"][ 1427 | "outdoorAirMaxTemperature" 1428 | ] 1429 | ) 1430 | + " " 1431 | + u"\u00b0" 1432 | + "F" 1433 | ) 1434 | print( 1435 | "\t\tOutdoor Air Min Temperature: " 1436 | + str( 1437 | trendMYData["trendSequences"][i]["trendData"][ 1438 | "outdoorAirMinTemperature" 1439 | ] 1440 | ) 1441 | + " " 1442 | + u"\u00b0" 1443 | + "F" 1444 | ) 1445 | else: 1446 | raise Exception("Error: Invalid temperatureType") 1447 | 1448 | def printError(self, errorData, temperatureType): 1449 | """ 1450 | Print an error response 1451 | 1452 | :param responseData: The parsed error presponse data 1453 | :param temperatureType: The temperature type is used to determine if responses should be in metric or imperial units. 1454 | """ 1455 | print( 1456 | "Controller Version: " 1457 | + str(self.bigHexToInt(errorData["controllerVersion"])) 1458 | ) 1459 | print("Panel Version: " + str(self.bigHexToInt(errorData["pannelVersion"]))) 1460 | print("Device Model Type: " + DeviceSorting(errorData["deviceSorting"]).name) 1461 | print("Device Count: " + str(errorData["deviceCount"])) 1462 | print("Current Channel: " + str(errorData["currentChannel"])) 1463 | print("Device Number: " + str(errorData["deviceNumber"])) 1464 | # not sure how to parse these, so just print them as numbers 1465 | print("Error Flag: " + str(errorData["errorFlag"])) 1466 | print("Error Code: " + str(self.bigHexToInt(errorData["errorCD"]))) 1467 | 1468 | def bigHexToInt(self, hex): 1469 | """ 1470 | Convert from a list of big endian hex byte array or string to an integer 1471 | 1472 | :param hex: Big-endian string, int or byte array to be converted 1473 | :return: Integer after little-endian conversion 1474 | """ 1475 | if isinstance(hex, str): 1476 | hex = bytearray(hex) 1477 | if isinstance(hex, int): 1478 | # This is already an int, just return it 1479 | return hex 1480 | bigEndianStr = "".join("%02x" % b for b in hex) 1481 | littleHex = bytearray.fromhex(bigEndianStr) 1482 | littleHex.reverse() 1483 | littleHexStr = "".join("%02x" % b for b in littleHex) 1484 | return int(littleHexStr, 16) 1485 | 1486 | def sendRequest( 1487 | self, 1488 | gatewayID, 1489 | currentControlChannel, 1490 | deviceNumber, 1491 | controlSorting, 1492 | infoItem, 1493 | controlItem, 1494 | controlValue, 1495 | WeeklyDay, 1496 | ): 1497 | """ 1498 | Main handler for sending a request to the binary API 1499 | 1500 | :param gatewayID: The gatewayID (NaviLink) the device is connected to 1501 | :param currentControlChannel: The serial port channel on the Navilink that the device is connected to 1502 | :param deviceNumber: The device number on the serial bus corresponding with the device 1503 | :param controlSorting: Corresponds with the ControlSorting enum (info or control) 1504 | :param infoItem: Corresponds with the ControlType enum 1505 | :param controlItem: Corresponds with the ControlType enum when controlSorting is control 1506 | :param controlValue: Value being changed when controlling 1507 | :param WeeklyDay: WeeklyDay dictionary (values are ignored when not changing schedule, but must be present) 1508 | :return: Parsed response data 1509 | 1510 | """ 1511 | requestHeader = { 1512 | "stx": 0x07, 1513 | "did": 0x99, 1514 | "reserve": 0x00, 1515 | "cmd": 0xA6, 1516 | "dataLength": 0x37, 1517 | "dSid": 0x00, 1518 | } 1519 | sendData = bytearray( 1520 | [ 1521 | requestHeader["stx"], 1522 | requestHeader["did"], 1523 | requestHeader["reserve"], 1524 | requestHeader["cmd"], 1525 | requestHeader["dataLength"], 1526 | requestHeader["dSid"], 1527 | ] 1528 | ) 1529 | sendData.extend(gatewayID) 1530 | sendData.extend( 1531 | [ 1532 | 0x01, # commandCount 1533 | currentControlChannel, 1534 | deviceNumber, 1535 | controlSorting, 1536 | infoItem, 1537 | controlItem, 1538 | controlValue, 1539 | ] 1540 | ) 1541 | sendData.extend( 1542 | [ 1543 | WeeklyDay["WeeklyDay"], 1544 | WeeklyDay["WeeklyCount"], 1545 | WeeklyDay["1_Hour"], 1546 | WeeklyDay["1_Minute"], 1547 | WeeklyDay["1_Flag"], 1548 | WeeklyDay["2_Hour"], 1549 | WeeklyDay["2_Minute"], 1550 | WeeklyDay["2_Flag"], 1551 | WeeklyDay["3_Hour"], 1552 | WeeklyDay["3_Minute"], 1553 | WeeklyDay["3_Flag"], 1554 | WeeklyDay["4_Hour"], 1555 | WeeklyDay["4_Minute"], 1556 | WeeklyDay["4_Flag"], 1557 | WeeklyDay["5_Hour"], 1558 | WeeklyDay["5_Minute"], 1559 | WeeklyDay["5_Flag"], 1560 | WeeklyDay["6_Hour"], 1561 | WeeklyDay["6_Minute"], 1562 | WeeklyDay["6_Flag"], 1563 | WeeklyDay["7_Hour"], 1564 | WeeklyDay["7_Minute"], 1565 | WeeklyDay["7_Flag"], 1566 | WeeklyDay["8_Hour"], 1567 | WeeklyDay["8_Minute"], 1568 | WeeklyDay["8_Flag"], 1569 | WeeklyDay["9_Hour"], 1570 | WeeklyDay["9_Minute"], 1571 | WeeklyDay["9_Flag"], 1572 | WeeklyDay["10_Hour"], 1573 | WeeklyDay["10_Minute"], 1574 | WeeklyDay["10_Flag"], 1575 | ] 1576 | ) 1577 | 1578 | # We should ensure that the socket is still connected, and abort if not 1579 | self.connection.sendall(sendData) 1580 | 1581 | # Receive the status. 1582 | data = self.connection.recv(1024) 1583 | return self.parseResponse(data) 1584 | 1585 | def initWeeklyDay(self): 1586 | """ 1587 | Helper function to initialize and populate the WeeklyDay dict 1588 | 1589 | :return: An initialized but empty weeklyDay dict 1590 | """ 1591 | weeklyDay = {} 1592 | weeklyDay["WeeklyDay"] = 0x00 1593 | weeklyDay["WeeklyCount"] = 0x00 1594 | for i in range(1, 11): 1595 | weeklyDay[str(i) + "_Hour"] = 0x00 1596 | weeklyDay[str(i) + "_Minute"] = 0x00 1597 | weeklyDay[str(i) + "_Flag"] = 0x00 1598 | return weeklyDay 1599 | 1600 | # ----- Convenience methods for sending requests ----- # 1601 | 1602 | def sendStateRequest(self, gatewayID, currentControlChannel, deviceNumber): 1603 | """ 1604 | Send state request 1605 | 1606 | :param gatewayID: The gatewayID (NaviLink) the device is connected to 1607 | :param currentControlChannel: The serial port channel on the Navilink that the device is connected to 1608 | :param deviceNumber: The device number on the serial bus corresponding with the device 1609 | :return: Parsed response data 1610 | """ 1611 | return self.sendRequest( 1612 | gatewayID, 1613 | currentControlChannel, 1614 | deviceNumber, 1615 | ControlSorting.INFO.value, 1616 | ControlType.STATE.value, 1617 | 0x00, 1618 | 0x00, 1619 | self.initWeeklyDay(), 1620 | ) 1621 | 1622 | def sendChannelInfoRequest(self, gatewayID, currentControlChannel, deviceNumber): 1623 | """ 1624 | Send channel information request (we already get this when we log in) 1625 | 1626 | :param gatewayID: The gatewayID (NaviLink) the device is connected to 1627 | :param currentControlChannel: The serial port channel on the Navilink that the device is connected to 1628 | :param deviceNumber: The device number on the serial bus corresponding with the device 1629 | :return: Parsed response data 1630 | """ 1631 | return self.sendRequest( 1632 | gatewayID, 1633 | currentControlChannel, 1634 | deviceNumber, 1635 | ControlSorting.INFO.value, 1636 | ControlType.CHANNEL_INFORMATION.value, 1637 | 0x00, 1638 | 0x00, 1639 | self.initWeeklyDay(), 1640 | ) 1641 | 1642 | def sendTrendSampleRequest(self, gatewayID, currentControlChannel, deviceNumber): 1643 | """ 1644 | Send trend sample request 1645 | 1646 | :param gatewayID: The gatewayID (NaviLink) the device is connected to 1647 | :param currentControlChannel: The serial port channel on the Navilink that the device is connected to 1648 | :param deviceNumber: The device number on the serial bus corresponding with the device 1649 | :return: Parsed response data 1650 | """ 1651 | return self.sendRequest( 1652 | gatewayID, 1653 | currentControlChannel, 1654 | deviceNumber, 1655 | ControlSorting.INFO.value, 1656 | ControlType.TREND_SAMPLE.value, 1657 | 0x00, 1658 | 0x00, 1659 | self.initWeeklyDay(), 1660 | ) 1661 | 1662 | def sendTrendMonthRequest(self, gatewayID, currentControlChannel, deviceNumber): 1663 | """ 1664 | Send trend month request 1665 | 1666 | :param gatewayID: The gatewayID (NaviLink) the device is connected to 1667 | :param currentControlChannel: The serial port channel on the Navilink that the device is connected to 1668 | :param deviceNumber: The device number on the serial bus corresponding with the device 1669 | :return: Parsed response data 1670 | """ 1671 | return self.sendRequest( 1672 | gatewayID, 1673 | currentControlChannel, 1674 | deviceNumber, 1675 | ControlSorting.INFO.value, 1676 | ControlType.TREND_MONTH.value, 1677 | 0x00, 1678 | 0x00, 1679 | self.initWeeklyDay(), 1680 | ) 1681 | 1682 | def sendTrendYearRequest(self, gatewayID, currentControlChannel, deviceNumber): 1683 | """ 1684 | Send trend year request 1685 | 1686 | :param gatewayID: The gatewayID (NaviLink) the device is connected to 1687 | :param currentControlChannel: The serial port channel on the Navilink that the device is connected to 1688 | :param deviceNumber: The device number on the serial bus corresponding with the device 1689 | :return: Parsed response data 1690 | """ 1691 | return self.sendRequest( 1692 | gatewayID, 1693 | currentControlChannel, 1694 | deviceNumber, 1695 | ControlSorting.INFO.value, 1696 | ControlType.TREND_YEAR.value, 1697 | 0x00, 1698 | 0x00, 1699 | self.initWeeklyDay(), 1700 | ) 1701 | 1702 | def sendPowerControlRequest( 1703 | self, gatewayID, currentControlChannel, deviceNumber, powerState 1704 | ): 1705 | """ 1706 | Send device power control request 1707 | 1708 | :param gatewayID: The gatewayID (NaviLink) the device is connected to 1709 | :param currentControlChannel: The serial port channel on the Navilink that the device is connected to 1710 | :param deviceNumber: The device number on the serial bus corresponding with the device 1711 | :param powerState: The power state as identified in the OnOFFFlag enum 1712 | :return: Parsed response data 1713 | """ 1714 | return self.sendRequest( 1715 | gatewayID, 1716 | currentControlChannel, 1717 | deviceNumber, 1718 | ControlSorting.CONTROL.value, 1719 | ControlType.UNKNOWN.value, 1720 | DeviceControl.POWER.value, 1721 | OnOFFFlag(powerState).value, 1722 | self.initWeeklyDay(), 1723 | ) 1724 | 1725 | def sendHeatControlRequest( 1726 | self, gatewayID, currentControlChannel, deviceNumber, channelData, heatState 1727 | ): 1728 | """ 1729 | Send device heat control request 1730 | 1731 | :param gatewayID: The gatewayID (NaviLink) the device is connected to 1732 | :param currentControlChannel: The serial port channel on the Navilink that the device is connected to 1733 | :param deviceNumber: The device number on the serial bus corresponding with the device 1734 | :param heatState: The heat state as identified in the OnOFFFlag enum 1735 | :return: Parsed response data 1736 | """ 1737 | if ( 1738 | NFBWaterFlag( 1739 | ( 1740 | channelData["channel"][str(currentControlChannel)]["wwsdFlag"] 1741 | & WWSDMask.HOTWATER_POSSIBILITY.value 1742 | ) 1743 | > 0 1744 | ) 1745 | == NFBWaterFlag.OFF 1746 | ): 1747 | raise Exception("Error: Heat is disabled.") 1748 | else: 1749 | return self.sendRequest( 1750 | gatewayID, 1751 | currentControlChannel, 1752 | deviceNumber, 1753 | ControlSorting.CONTROL.value, 1754 | ControlType.UNKNOWN.value, 1755 | DeviceControl.HEAT.value, 1756 | OnOFFFlag(heatState).value, 1757 | self.initWeeklyDay(), 1758 | ) 1759 | 1760 | def sendOnDemandControlRequest( 1761 | self, gatewayID, currentControlChannel, deviceNumber, channelData 1762 | ): 1763 | """ 1764 | Send device on demand control request 1765 | 1766 | Note that no additional state parameter is required as this is the equivalent of pressing the HotButton. 1767 | 1768 | :param gatewayID: The gatewayID (NaviLink) the device is connected to 1769 | :param currentControlChannel: The serial port channel on the Navilink that the device is connected to 1770 | :param deviceNumber: The device number on the serial bus corresponding with the device 1771 | :return: Parsed response data 1772 | """ 1773 | return self.sendRequest( 1774 | gatewayID, 1775 | currentControlChannel, 1776 | deviceNumber, 1777 | ControlSorting.CONTROL.value, 1778 | ControlType.UNKNOWN.value, 1779 | DeviceControl.ON_DEMAND.value, 1780 | OnOFFFlag.ON.value, 1781 | self.initWeeklyDay(), 1782 | ) 1783 | 1784 | def sendDeviceWeeklyControlRequest( 1785 | self, gatewayID, currentControlChannel, deviceNumber, weeklyState 1786 | ): 1787 | """ 1788 | Send device weekly control (enable or disable weekly schedule) 1789 | 1790 | :param gatewayID: The gatewayID (NaviLink) the device is connected to 1791 | :param currentControlChannel: The serial port channel on the Navilink that the device is connected to 1792 | :param deviceNumber: The device number on the serial bus corresponding with the device 1793 | :param weeklyState: The weekly control state as identified in the OnOFFFlag enum 1794 | :return: Parsed response data 1795 | 1796 | """ 1797 | return self.sendRequest( 1798 | gatewayID, 1799 | currentControlChannel, 1800 | deviceNumber, 1801 | ControlSorting.CONTROL.value, 1802 | ControlType.UNKNOWN.value, 1803 | DeviceControl.WEEKLY.value, 1804 | OnOFFFlag(weeklyState).value, 1805 | self.initWeeklyDay(), 1806 | ) 1807 | 1808 | def sendWaterTempControlRequest( 1809 | self, gatewayID, currentControlChannel, deviceNumber, channelData, tempVal 1810 | ): 1811 | """ 1812 | Send device water temperature control request 1813 | 1814 | :param gatewayID: The gatewayID (NaviLink) the device is connected to 1815 | :param currentControlChannel: The serial port channel on the Navilink that the device is connected to 1816 | :param deviceNumber: The device number on the serial bus corresponding with the device 1817 | :param channelData: The parsed channel information data used to determine limits and units 1818 | :param tempVal: The temperature to set 1819 | :return: Parsed response data 1820 | """ 1821 | if ( 1822 | tempVal 1823 | > channelData["channel"][str(currentControlChannel)][ 1824 | "maximumSettingWaterTemperature" 1825 | ] 1826 | ) or ( 1827 | tempVal 1828 | < channelData["channel"][str(currentControlChannel)][ 1829 | "minimumSettingWaterTemperature" 1830 | ] 1831 | ): 1832 | raise Exception("Error: Invalid tempVal requested.") 1833 | else: 1834 | return self.sendRequest( 1835 | gatewayID, 1836 | currentControlChannel, 1837 | deviceNumber, 1838 | ControlSorting.CONTROL.value, 1839 | ControlType.UNKNOWN.value, 1840 | DeviceControl.WATER_TEMPERATURE.value, 1841 | tempVal, 1842 | self.initWeeklyDay(), 1843 | ) 1844 | 1845 | def sendHeatingWaterTempControlRequest( 1846 | self, gatewayID, currentControlChannel, deviceNumber, channelData, tempVal 1847 | ): 1848 | """ 1849 | Send device heating water temperature control request 1850 | 1851 | :param gatewayID: The gatewayID (NaviLink) the device is connected to 1852 | :param currentControlChannel: The serial port channel on the Navilink that the device is connected to 1853 | :param deviceNumber: The device number on the serial bus corresponding with the device 1854 | :param channelData: The parsed channel information data used to determine limits and units 1855 | :param tempVal: The temperature to set 1856 | :return: Parsed response data 1857 | """ 1858 | if ( 1859 | NFBWaterFlag( 1860 | ( 1861 | channelData["channel"][str(currentControlChannel)]["wwsdFlag"] 1862 | & WWSDMask.HOTWATER_POSSIBILITY.value 1863 | ) 1864 | > 0 1865 | ) 1866 | == NFBWaterFlag.OFF 1867 | ): 1868 | raise Exception("Error: Heat is disabled. Unable to set temperature") 1869 | elif ( 1870 | tempVal 1871 | > channelData["channel"][str(currentControlChannel)][ 1872 | "heatingMaximumSettingWaterTemperature" 1873 | ] 1874 | ) or ( 1875 | tempVal 1876 | < channelData["channel"][str(currentControlChannel)][ 1877 | "heatingMinimumSettingWaterTemperature" 1878 | ] 1879 | ): 1880 | raise Exception("Error: Invalid tempVal requested.") 1881 | else: 1882 | return self.sendRequest( 1883 | gatewayID, 1884 | currentControlChannel, 1885 | deviceNumber, 1886 | ControlSorting.CONTROL.value, 1887 | ControlType.UNKNOWN.value, 1888 | DeviceControl.HEATING_WATER_TEMPERATURE.value, 1889 | tempVal, 1890 | self.initWeeklyDay(), 1891 | ) 1892 | 1893 | def sendRecirculationTempControlRequest( 1894 | self, gatewayID, currentControlChannel, deviceNumber, channelData, tempVal 1895 | ): 1896 | """ 1897 | Send recirculation temperature control request 1898 | 1899 | :param gatewayID: The gatewayID (NaviLink) the device is connected to 1900 | :param currentControlChannel: The serial port channel on the Navilink that the device is connected to 1901 | :param deviceNumber: The device number on the serial bus corresponding with the device 1902 | :param channelData: The parsed channel information data used to determine limits and units 1903 | :param tempVal: The temperature to set 1904 | :return: Parsed response data 1905 | """ 1906 | if ( 1907 | RecirculationFlag( 1908 | ( 1909 | channelData["channel"][str(currentControlChannel)]["wwsdFlag"] 1910 | & WWSDMask.RECIRCULATION_POSSIBILITY.value 1911 | ) 1912 | > 0 1913 | ) 1914 | == RecirculationFlag.OFF 1915 | ): 1916 | raise Exception( 1917 | "Error: Recirculation is disabled. Unable to set temperature" 1918 | ) 1919 | elif ( 1920 | tempVal 1921 | > channelData["channel"][str(currentControlChannel)][ 1922 | "maximumSettingWaterTemperature" 1923 | ] 1924 | ) or ( 1925 | tempVal 1926 | < channelData["channel"][str(currentControlChannel)][ 1927 | "minimumSettingWaterTemperature" 1928 | ] 1929 | ): 1930 | raise Exception("Error: Invalid tempVal requested.") 1931 | else: 1932 | return self.sendRequest( 1933 | gatewayID, 1934 | currentControlChannel, 1935 | deviceNumber, 1936 | ControlSorting.CONTROL.value, 1937 | ControlType.UNKNOWN.value, 1938 | DeviceControl.RECIRCULATION_TEMPERATURE.value, 1939 | tempVal, 1940 | self.initWeeklyDay(), 1941 | ) 1942 | 1943 | # Send request to set weekly schedule 1944 | def sendDeviceControlWeeklyScheduleRequest(self, stateData, WeeklyDay, action): 1945 | """ 1946 | Send request to set weekly schedule 1947 | 1948 | The state information contains the gatewayID, currentControlChannel, deviceNumber and all current WeeklyDay schedules. We need to compare current WeeklyDay schedule with requested modifications and apply as needed. 1949 | 1950 | Note: Only one schedule entry can be modified at a time. 1951 | 1952 | :param stateData: The state information contains the gatewayID, currentControlChannel, deviceNumber and all current WeeklyDay schedules. 1953 | :param WeeklyDay: We need to compare current schedule in the stateData with requested WeeklyDay and apply as needed. 1954 | :param action: add or delete the requested WeeklyDay. 1955 | :return: Parsed response data 1956 | """ 1957 | 1958 | if (WeeklyDay["hour"] > 23) or (WeeklyDay["minute"] > 59): 1959 | raise Exception("Error: Invalid weeklyday schedule time requested") 1960 | 1961 | # Check if the entry already exists and set a flag 1962 | foundScheduleEntry = False 1963 | if "daySequence" in stateData["daySequences"][WeeklyDay["dayOfWeek"] - 1]: 1964 | for j in stateData["daySequences"][WeeklyDay["dayOfWeek"] - 1][ 1965 | "daySequence" 1966 | ]: 1967 | if ( 1968 | ( 1969 | stateData["daySequences"][WeeklyDay["dayOfWeek"] - 1][ 1970 | "daySequence" 1971 | ][j]["hour"] 1972 | == WeeklyDay["hour"] 1973 | ) 1974 | and ( 1975 | stateData["daySequences"][WeeklyDay["dayOfWeek"] - 1][ 1976 | "daySequence" 1977 | ][j]["minute"] 1978 | == WeeklyDay["minute"] 1979 | ) 1980 | and ( 1981 | stateData["daySequences"][WeeklyDay["dayOfWeek"] - 1][ 1982 | "daySequence" 1983 | ][j]["isOnOFF"] 1984 | == WeeklyDay["isOnOFF"] 1985 | ) 1986 | ): 1987 | foundScheduleEntry = True 1988 | foundIndex = j 1989 | 1990 | tmpWeeklyDay = self.initWeeklyDay() 1991 | tmpWeeklyDay["WeeklyDay"] = WeeklyDay["dayOfWeek"] 1992 | 1993 | if action == "add": 1994 | if foundScheduleEntry: 1995 | raise Exception( 1996 | "Error: unable to add. Already have matching schedule entry." 1997 | ) 1998 | else: 1999 | if ( 2000 | "daySequence" 2001 | in stateData["daySequences"][WeeklyDay["dayOfWeek"] - 1] 2002 | ): 2003 | currentWDCount = len( 2004 | stateData["daySequences"][WeeklyDay["dayOfWeek"] - 1][ 2005 | "daySequence" 2006 | ] 2007 | ) 2008 | for i in stateData["daySequences"][WeeklyDay["dayOfWeek"] - 1][ 2009 | "daySequence" 2010 | ]: 2011 | tmpWeeklyDay[str(int(i) + 1) + "_Hour"] = stateData[ 2012 | "daySequences" 2013 | ][WeeklyDay["dayOfWeek"] - 1]["daySequence"][i]["hour"] 2014 | tmpWeeklyDay[str(int(i) + 1) + "_Minute"] = stateData[ 2015 | "daySequences" 2016 | ][WeeklyDay["dayOfWeek"] - 1]["daySequence"][i]["minute"] 2017 | tmpWeeklyDay[str(int(i) + 1) + "_Flag"] = stateData[ 2018 | "daySequences" 2019 | ][WeeklyDay["dayOfWeek"] - 1]["daySequence"][i]["isOnOFF"] 2020 | else: 2021 | currentWDCount = 0 2022 | tmpWeeklyDay["WeeklyCount"] = currentWDCount + 1 2023 | tmpWeeklyDay[str(currentWDCount + 1) + "_Hour"] = WeeklyDay["hour"] 2024 | tmpWeeklyDay[str(currentWDCount + 1) + "_Minute"] = WeeklyDay["minute"] 2025 | tmpWeeklyDay[str(currentWDCount + 1) + "_Flag"] = WeeklyDay["isOnOFF"] 2026 | elif action == "delete": 2027 | if not foundScheduleEntry: 2028 | raise Exception("Error: unable to delete. No matching schedule entry.") 2029 | else: 2030 | dSIndex = 0 2031 | for c in stateData["daySequences"][WeeklyDay["dayOfWeek"] - 1][ 2032 | "daySequence" 2033 | ]: 2034 | if c != foundIndex: 2035 | dSIndex += 1 2036 | tmpWeeklyDay[str(dSIndex) + "_Hour"] = stateData[ 2037 | "daySequences" 2038 | ][WeeklyDay["dayOfWeek"] - 1]["daySequence"][c]["hour"] 2039 | tmpWeeklyDay[str(dSIndex) + "_Minute"] = stateData[ 2040 | "daySequences" 2041 | ][WeeklyDay["dayOfWeek"] - 1]["daySequence"][c]["minute"] 2042 | tmpWeeklyDay[str(dSIndex) + "_Flag"] = stateData[ 2043 | "daySequences" 2044 | ][WeeklyDay["dayOfWeek"] - 1]["daySequence"][c]["isOnOFF"] 2045 | tmpWeeklyDay["WeeklyCount"] = dSIndex 2046 | else: 2047 | raise Exception("Error: unsupported action " + action) 2048 | 2049 | # print(json.dumps(tmpWeeklyDay, indent=2, default=str)) 2050 | return self.sendRequest( 2051 | stateData["deviceID"], 2052 | stateData["currentChannel"], 2053 | stateData["deviceNumber"], 2054 | ControlSorting.CONTROL.value, 2055 | ControlType.UNKNOWN.value, 2056 | DeviceControl.WEEKLY.value, 2057 | OnOFFFlag(stateData["weeklyControl"]).value, 2058 | tmpWeeklyDay, 2059 | ) 2060 | --------------------------------------------------------------------------------