├── .github └── FUNDING.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── bin ├── README.md ├── flash_esp32_inetbox2mqtt_v265_4M.bin.zip └── flash_womolin_inetbox2mqtt_v264_4M.bin.zip ├── bootloader └── main.py ├── doc ├── ELECTRIC.md ├── README.md ├── aventa.II ├── aventa.yaml ├── image.png ├── truma.ll └── truma.yaml ├── lib ├── connect.py ├── crypto_keys.py ├── gen_html.py ├── kalman.py ├── logging.py ├── mqtt_async2.py ├── nanoweb.py ├── web_os.py └── web_os_main.py └── src ├── args.dat ├── args.py ├── boot.py ├── conversions.py ├── cred.py ├── crypto_keys.py ├── duocontrol.py ├── imu.py ├── inetboxapp.py ├── kalman.py ├── lin.py ├── main.py ├── main1.py ├── release.py ├── spiritlevel.py ├── tools.py ├── update.py └── vector.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [mc0110] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | ./lib/DS_Store 3 | ./bin/DS_Store 4 | ./doc/DS_Store 5 | ./src/DS_Store 6 | ./bin/DS_Store 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.yaml": "home-assistant" 4 | } 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Dr. Magnus Christ (mc0110) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bin/README.md: -------------------------------------------------------------------------------- 1 | # Image V2.6.5 for ESP32 and V2.6.4 for WOMOLIN ESP32 Interface V1 and V2 2 | 3 | The flash_esp32_xxxx.bin file contains both the python and the .py files. This allows the whole project to be flashed onto the ESP32 in one go. For this, you can use the esptool. In my case, it finds the serial port of the ESP32 automatically, but the port can also be specified. The ESP32 must be in programming mode (GPIO0 to GND at startup). The command to flash the complete .bin file to the ESP32 is: 4 | 5 | esptool.py write_flash 0 flash_esp32_inetbox2mqtt_v265_4M.bin 6 | 7 | The address 0 is not a typo. 8 | 9 | This image contains a first version that supports both the LAN port and WLAN use. Furthermore, static IP addresses or DHCP can now be set. There is also a version for both WOMOLIN variants, version 1 and version 2. The yellow network led informs about mqtt-activities and also about Lin-interface-communication. In version 2 the normal setup supports LIN1. If you want to support LIN2, the second LIN-decoder, you need to change in args.dat the entry hw=WOMOLIN to hw=WOMOLIN_LIN2: 10 | 11 | esptool.py write_flash 0 flash_womolin_inetbox2mqtt_v264_4M.bin 12 | 13 | 14 | ***The development of this software and also just the maintenance in the different variants has already cost many hours of time. This is only possible with your support. So if you use this software, I deserve more than a beer. Many thanks for this in advance. For this purpose, you will find the Sponsorship button on the right side of the page.*** 15 | 16 | After flashing, please reboot the ESP32. It will start with an access point on IP 192.168.4.1. After connecting with wifi you can start a browser to http://192.168.4.1. Now you should input the credentials. 17 | See the details in the README.md. After you have done everything, press the button for "normal run" and restart the chip. It will than start without web frontend. 18 | 19 | Note: *Since the ESP32 does not have enough memory, the micropython version used is already pre-compiled with several modules, which reduces the memory requirement. The micropython version has the release date of Juli 2023.* 20 | 21 | -------------------------------------------------------------------------------- /bin/flash_esp32_inetbox2mqtt_v265_4M.bin.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mc0110/inetbox2mqtt/5bf5b223fd7d45e245b87536b38d7edc63b4981d/bin/flash_esp32_inetbox2mqtt_v265_4M.bin.zip -------------------------------------------------------------------------------- /bin/flash_womolin_inetbox2mqtt_v264_4M.bin.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mc0110/inetbox2mqtt/5bf5b223fd7d45e245b87536b38d7edc63b4981d/bin/flash_womolin_inetbox2mqtt_v264_4M.bin.zip -------------------------------------------------------------------------------- /bootloader/main.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2022 Dr. Magnus Christ (mc0110) 4 | # 5 | # This is part of the inetbox2mqtt package 6 | # 7 | # 8 | # After reboot the port starts with boot.py and main.py 9 | # 10 | # This code-segment needs to be named "main.py" 11 | # 12 | # 13 | # 14 | # 15 | # Use this commands to connect to the network 16 | # 17 | # import network 18 | # s = network.WLAN(network.STA_IF) 19 | # s.active(True) 20 | # s.connect("","") 21 | # print('network config:', s.ifconfig()) 22 | # import mip 23 | # mip.install("github:mc0110/inetbox2mqtt/bootloader/main.py", target = "/") 24 | # 25 | # import main 26 | # 27 | # The last command starts the download-process of the whole suite 28 | # The download overwrites the main-program, so you see this process only once 29 | 30 | 31 | 32 | import time, machine 33 | import mip 34 | #sleep to give some boards time to initialize, for example Rpi Pico W 35 | time.sleep(3) 36 | 37 | # bootloader for the whole suite 38 | tree = "github:mc0110/inetbox2mqtt" 39 | 40 | # mip.install(tree) 41 | 42 | env = [ 43 | ["/lib/", "crypto_keys.py", "/lib"], 44 | ["/lib/", "logging.py", "/lib"], 45 | ["/lib/", "mqtt_async2.py", "/lib"], 46 | ["/lib/", "nanoweb.py", "/lib"], 47 | ["/src/", "update.py", "/"], 48 | ] 49 | 50 | for i in range(len(env)): 51 | mip.install(tree+env[i][0]+env[i][1], target= env[i][2]) 52 | 53 | 54 | import update 55 | #cred.set_cred_json() 56 | for i,j in update.update_repo(): 57 | print(i, j) 58 | 59 | machine.reset() -------------------------------------------------------------------------------- /doc/ELECTRIC.md: -------------------------------------------------------------------------------- 1 | ## Electrics 2 | A LIN-UART converter is necessary for communication with the TRUMA CPplus. Since there are now several boards available for purchase that already have the converter integrated, I have moved the level converter topic to the documentation for all those who want to build the level converter themselves. 3 | 4 | There is no 12V potential at the RJ12 (LIN connector). Therefore, the supply voltage must be obtained separately from the car electrical system. 5 | 6 | The electrical connection via the TJA1020 to the UART of the ESP32/RP2 pico is made according to the circuit diagram shown. 7 | 8 |
9 | 10 | ![grafik](https://user-images.githubusercontent.com/10268240/206511684-806cda73-a47d-4070-86ac-6de7d999c5d6.png) 11 | 12 |
13 | 14 | Examples for the implementation of the concrete connection can be found under [Connection](https://github.com/mc0110/inetbox2mqtt/issues/20). 15 | 16 | On the **ESP32** we recommend the use of UART2 (**Tx - GPIO17, Rx - GPIO16**): 17 | 18 |
19 | 20 | ![1](https://user-images.githubusercontent.com/65889763/200187420-7c787a62-4b06-4b8d-a50c-1ccb71626118.png) 21 | 22 |
23 | 24 | On the **RP2 pico w** we recommend the use of UART1 (**Tx - GPIO04, Rx - GPIO05**): 25 | 26 |
27 | 28 | ![grafik](https://user-images.githubusercontent.com/10268240/201338579-29c815ca-e5ef-4f25-b015-1749a59b3e99.png) 29 |
30 | 31 | These are to be connected to the TJA1020. No level shift is needed (thanks to the internal construction of the TJA1020). It also works on 3.3V levels, even if the TJA1020 is operated at 12V. 32 | 33 | **It is important to connect not only the signal level but also the ground connection.** 34 | 35 | ![Alt text](image.png) 36 | 37 | Here you see an example with a missing ground connection. It cannot work like that. -------------------------------------------------------------------------------- /doc/README.md: -------------------------------------------------------------------------------- 1 | # Example of a front-end realisation 2 | 3 | First of all, I would like to ask you to send me **examples of your smarthome systems** so that we can publish them. It would be great if we could also show examples of e.g. openhab or node-red implementations here also. But alternatives to realisation in Home Assistant are of course also welcome. 4 | 5 | 6 | ## TRUMA heater and Aventa AirCon - example for frontends in Home Assistant 7 | 8 | Since I use a Home Assistant system myself, you can currently only find the implementation of the mqtt-protocol-transfer in HA. The files now are modified (in November 2024) to the changed HA yaml-syntax. 9 | 10 | The given truma.yaml is a package in the HA logic, so please note the explanations about [packages](https://www.home-assistant.io/docs/configuration/packages/) in the HA documentation.An example of fully control from a smart home solution as example of bidirectional operation from Home Assistant. 11 | 12 | Bidirectional means that the values can be set both in the CPplus display and in the home assistant frontend and are passed through in each case. 13 | 14 | ## Lovelance frontend cards in home assistant 15 | ### TRUMA heater 16 | 17 | ![grafik](https://github.com/mc0110/inetbox2mqtt/assets/10268240/d53bb678-4d01-48fa-a6ce-1a7ffec84303) 18 | 19 | 20 | ### TRUMA aircon 21 | 22 | ![grafik](https://github.com/mc0110/inetbox2mqtt/assets/10268240/55b823fe-1d2f-42d5-9986-6dc8de31b01e) 23 | 24 | 25 | You find both lovelance-cards also as file in this directory. 26 | 27 | About the function of the card: 28 | 29 | New!: There is a central switch function integrated. So you can switch off and block all functions with one switch. 30 | 31 | The upper part shows the hot water (a climate entity is also generated for this) can be selected via the buttons. In the function, the hot water production is automatically switched off when the target temperature is reached. 32 | 33 | The room thermostat lets the TRUMA take over the temperature control. If the room temperature is changed, the changed temperature is communicated to the TRUMA. Since the TRUMA can only set the temperature to within one degree (Celsius), the target temperature is rounded off accordingly by the thermostat element. The heating can be switched off via the thermostat. 34 | 35 | The full control can also be made via the CPplus, so communication is bidirectional. The entries made there are then also transferred to the HA entities. Pls keep in mind, that the communication needs sometimes a few seconds, so keep calm. 36 | 37 | Furthermore, the operating modes of the TRUMA (gas, mixed, electric) can be preselected. In addition to the PRESETS, the heating can also be controlled very easily via automations using the climate.set_temperature service. 38 | 39 | 40 | ## Celsius vs. Fahrenheit 41 | This example works only if CPplus and Home Assistant are set to Celsius. Initial tests with systems set to Fahrenheit show problems. If someone could send their approaches to solving this, that would be great. 42 | 43 | ## ESPHOME version 44 | For all those who are looking for an ESPHome version, I would like to refer to the great work of Fabian [esphome-truma_inetbox](https://github.com/Fabian-Schmidt/esphome-truma_inetbox), who has managed the realisation in this framework. Here, the MQTT protocol is no longer necessary, but it works via the HA-internal protocol. 45 | 46 | 47 | ## Example of a simple GAUGE implementation of the MPU6050 Leveling Wizard - also in Home Assistant 48 | 49 | ![grafik](https://user-images.githubusercontent.com/10268240/202903478-bbf7741f-cc21-48a2-918b-e94c15f7c373.png) 50 | 51 | title: test-imu 52 | - type: gauge 53 | entity: sensor.pan1pitch 54 | min: -20 55 | max: 20 56 | needle: true 57 | name: Pitch 58 | unit: ° 59 | - type: gauge 60 | entity: sensor.pan1roll 61 | name: Roll 62 | unit: ° 63 | min: -20 64 | max: 20 65 | needle: true 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /doc/aventa.II: -------------------------------------------------------------------------------- 1 | type: vertical-stack 2 | cards: 3 | - show_name: false 4 | show_icon: true 5 | type: button 6 | tap_action: 7 | action: toggle 8 | entity: input_boolean.truma_aventa_ctrl 9 | icon: mdi:air-conditioner 10 | icon_height: 40px 11 | - type: thermostat 12 | entity: climate.aventa 13 | - type: horizontal-stack 14 | cards: 15 | - show_name: true 16 | show_icon: true 17 | type: button 18 | tap_action: 19 | action: toggle 20 | entity: input_boolean.truma_aventa_om_vent 21 | name: Vent 22 | icon: mdi:fan 23 | - show_name: true 24 | show_icon: true 25 | type: button 26 | name: Auto 27 | tap_action: 28 | action: toggle 29 | entity: input_boolean.truma_aventa_om_auto 30 | icon: mdi:auto-mode 31 | - show_name: true 32 | show_icon: true 33 | type: button 34 | name: Cool 35 | tap_action: 36 | action: toggle 37 | entity: input_boolean.truma_aventa_om_cool 38 | icon: mdi:air-conditioner 39 | - show_name: true 40 | show_icon: true 41 | type: button 42 | name: Hot 43 | tap_action: 44 | action: toggle 45 | entity: input_boolean.truma_aventa_om_hot 46 | icon: mdi:heat-pump 47 | title: Modus 48 | - type: horizontal-stack 49 | cards: 50 | - show_name: true 51 | show_icon: true 52 | type: button 53 | tap_action: 54 | action: toggle 55 | entity: input_boolean.truma_aventa_vm_night 56 | name: night 57 | icon: mdi:sleep 58 | - show_name: true 59 | show_icon: true 60 | type: button 61 | name: auto 62 | tap_action: 63 | action: toggle 64 | entity: input_boolean.truma_aventa_vm_auto 65 | icon: mdi:auto-mode 66 | - show_name: true 67 | show_icon: true 68 | type: button 69 | name: low 70 | tap_action: 71 | action: toggle 72 | entity: input_boolean.truma_aventa_vm_low 73 | icon: mdi:fan-chevron-down 74 | - show_name: true 75 | show_icon: true 76 | type: button 77 | name: mid 78 | tap_action: 79 | action: toggle 80 | entity: input_boolean.truma_aventa_vm_mid 81 | icon: mdi:fan 82 | - show_name: true 83 | show_icon: true 84 | type: button 85 | name: high 86 | tap_action: 87 | action: toggle 88 | entity: input_boolean.truma_aventa_vm_high 89 | icon: mdi:fan-chevron-up 90 | title: Ventilator 91 | - type: entities 92 | entities: 93 | - entity: sensor.truma_aircon_operating_mode 94 | - entity: sensor.truma_aircon_vent_mode 95 | - entity: sensor.truma_target_temp_aircon 96 | - entity: sensor.truma_aventa_op_mode 97 | - entity: sensor.truma_aventa_vm_mode 98 | show_header_toggle: false 99 | title: Status 100 | -------------------------------------------------------------------------------- /doc/aventa.yaml: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # AVENTA Control 4 | # 5 | # Copyright (c) 2023 Dr. Magnus Christ (mc0110) 6 | # modified in 2024, using the new yaml-syntax 7 | # 8 | # This YAML is part of the inetbox2mqtt project and is based on the automatically generated entities. 9 | # 10 | # The library enables bidirectional interaction with the CPplus. 11 | # Thus, inputs can be made on the CPplus as well as on the HA. 12 | # This applies both to temperature changes and to switching on/off. 13 | # 14 | # Observe the associated frontend-card, which is optimally matched to it 15 | # 16 | input_boolean: 17 | truma_aventa_ctrl: 18 | name: "AVENTA central control" 19 | truma_aventa_switch: 20 | name: "AVENTA control" 21 | truma_aventa_button: 22 | name: "AVENTA Cooler State" 23 | truma_aventa_om_vent: 24 | name: "AVENTA Vent Mode" 25 | truma_aventa_om_auto: 26 | name: "AVENTA Auto Mode" 27 | truma_aventa_om_cool: 28 | name: "AVENTA Cool Mode" 29 | truma_aventa_om_hot: 30 | name: "AVENTA Hot Mode" 31 | truma_aventa_vm_night: 32 | name: "AVENTA Night Mode" 33 | truma_aventa_vm_auto: 34 | name: "AVENTA Vent Auto Mode" 35 | truma_aventa_vm_low: 36 | name: "AVENTA Vent Low Mode" 37 | truma_aventa_vm_mid: 38 | name: "AVENTA Vent Mid Mode" 39 | truma_aventa_vm_high: 40 | name: "AVENTA Vent High Mode" 41 | 42 | template: 43 | - sensor: 44 | - name: "TRUMA Aventa op mode" 45 | state: > 46 | {% if states('input_boolean.truma_aventa_om_vent')=="on" %} 47 | vent 48 | {% elif states('input_boolean.truma_aventa_om_auto')=="on" %} 49 | auto 50 | {% elif states('input_boolean.truma_aventa_om_cool')=="on" %} 51 | cool 52 | {% elif states('input_boolean.truma_aventa_om_hot')=="on" %} 53 | hot 54 | {% endif %} 55 | 56 | - name: "TRUMA Aventa vm mode" 57 | state: > 58 | {% if states('input_boolean.truma_aventa_vm_night')=="on" %} 59 | night 60 | {% elif states('input_boolean.truma_aventa_vm_auto')=="on" %} 61 | auto 62 | {% elif states('input_boolean.truma_aventa_vm_low')=="on" %} 63 | low 64 | {% elif states('input_boolean.truma_aventa_vm_mid')=="on" %} 65 | mid 66 | {% elif states('input_boolean.truma_aventa_vm_high')=="on" %} 67 | high 68 | {% endif %} 69 | 70 | climate: 71 | - platform: generic_thermostat 72 | name: Aventa 73 | heater: input_boolean.aventa_button 74 | target_sensor: sensor.truma_current_temp_room 75 | min_temp: 18 76 | max_temp: 30 77 | ac_mode: true 78 | cold_tolerance: 0.3 79 | hot_tolerance: 0.3 80 | min_cycle_duration: 81 | seconds: 120 82 | precision: 0.1 83 | 84 | automation: 85 | - alias: "TRUMA switch Aventa OM Vent" 86 | id: "d01" 87 | triggers: 88 | - trigger: state 89 | entity_id: input_boolean.truma_aventa_om_vent 90 | to: "on" 91 | - trigger: state 92 | entity_id: sensor.truma_aircon_operating_mode 93 | to: "vent" 94 | for: 10 95 | actions: 96 | - action: homeassistant.turn_on 97 | entity_id: input_boolean.truma_aventa_om_vent 98 | - action: homeassistant.turn_off 99 | entity_id: input_boolean.truma_aventa_om_auto 100 | - action: homeassistant.turn_off 101 | entity_id: input_boolean.truma_aventa_om_cool 102 | - action: homeassistant.turn_off 103 | entity_id: input_boolean.truma_aventa_om_hot 104 | - choose: 105 | - conditions: 106 | condition: state 107 | entity_id: input_boolean.truma_aventa_switch 108 | state: "on" 109 | sequence: 110 | - action: mqtt.publish 111 | data: 112 | topic: "service/truma/set/aircon_operating_mode" 113 | payload: "vent" 114 | - action: homeassistant.turn_on 115 | entity_id: input_boolean.truma_aventa_vm_low 116 | 117 | - alias: "TRUMA switch Aventa OM auto" 118 | id: "d02" 119 | triggers: 120 | - trigger: state 121 | entity_id: input_boolean.truma_aventa_om_auto 122 | to: "on" 123 | - trigger: state 124 | entity_id: sensor.truma_aircon_operating_mode 125 | to: "auto" 126 | for: 10 127 | actions: 128 | - action: homeassistant.turn_on 129 | entity_id: input_boolean.truma_aventa_om_auto 130 | - action: homeassistant.turn_off 131 | entity_id: input_boolean.truma_aventa_om_vent 132 | - action: homeassistant.turn_off 133 | entity_id: input_boolean.truma_aventa_om_cool 134 | - action: homeassistant.turn_off 135 | entity_id: input_boolean.truma_aventa_om_hot 136 | - choose: 137 | - conditions: 138 | condition: state 139 | entity_id: input_boolean.truma_aventa_switch 140 | state: "on" 141 | sequence: 142 | - action: mqtt.publish 143 | data: 144 | topic: "service/truma/set/aircon_operating_mode" 145 | payload: "auto" 146 | - action: homeassistant.turn_on 147 | entity_id: input_boolean.truma_aventa_vm_auto 148 | 149 | - alias: "TRUMA switch Aventa OM cool" 150 | id: "d03" 151 | triggers: 152 | - trigger: state 153 | entity_id: input_boolean.truma_aventa_om_cool 154 | to: "on" 155 | - trigger: state 156 | entity_id: sensor.truma_aircon_operating_mode 157 | to: "cool" 158 | for: 10 159 | actions: 160 | - action: homeassistant.turn_on 161 | entity_id: input_boolean.truma_aventa_om_cool 162 | - action: homeassistant.turn_off 163 | entity_id: input_boolean.truma_aventa_om_auto 164 | - action: homeassistant.turn_off 165 | entity_id: input_boolean.truma_aventa_om_vent 166 | - action: homeassistant.turn_off 167 | entity_id: input_boolean.truma_aventa_om_hot 168 | - choose: 169 | - conditions: 170 | condition: state 171 | entity_id: input_boolean.truma_aventa_switch 172 | state: "on" 173 | sequence: 174 | - action: mqtt.publish 175 | data: 176 | topic: "service/truma/set/aircon_operating_mode" 177 | payload: "cool" 178 | - action: homeassistant.turn_on 179 | entity_id: input_boolean.truma_aventa_vm_low 180 | 181 | - alias: "TRUMA switch Aventa OM hot" 182 | id: "d04" 183 | triggers: 184 | - trigger: state 185 | entity_id: input_boolean.truma_aventa_om_hot 186 | to: "on" 187 | - trigger: state 188 | entity_id: sensor.truma_aircon_operating_mode 189 | to: "hot" 190 | for: 10 191 | actions: 192 | - action: homeassistant.turn_on 193 | entity_id: input_boolean.truma_aventa_om_hot 194 | - action: homeassistant.turn_off 195 | entity_id: input_boolean.truma_aventa_om_auto 196 | - action: homeassistant.turn_off 197 | entity_id: input_boolean.truma_aventa_om_cool 198 | - action: homeassistant.turn_off 199 | entity_id: input_boolean.truma_aventa_om_vent 200 | - choose: 201 | - conditions: 202 | condition: state 203 | entity_id: input_boolean.truma_aventa_switch 204 | state: "on" 205 | sequence: 206 | - action: mqtt.publish 207 | data: 208 | topic: "service/truma/set/aircon_operating_mode" 209 | payload: "hot" 210 | - action: homeassistant.turn_on 211 | entity_id: input_boolean.truma_aventa_vm_low 212 | 213 | - alias: "TRUMA switch Aventa VM Night" 214 | id: "d001" 215 | triggers: 216 | - trigger: state 217 | entity_id: input_boolean.truma_aventa_vm_night 218 | to: "on" 219 | - trigger: state 220 | entity_id: sensor.truma_aircon_vent_mode 221 | to: "night" 222 | actions: 223 | - action: homeassistant.turn_on 224 | entity_id: input_boolean.truma_aventa_vm_night 225 | - action: homeassistant.turn_off 226 | entity_id: input_boolean.truma_aventa_vm_auto 227 | - action: homeassistant.turn_off 228 | entity_id: input_boolean.truma_aventa_vm_low 229 | - action: homeassistant.turn_off 230 | entity_id: input_boolean.truma_aventa_vm_mid 231 | - action: homeassistant.turn_off 232 | entity_id: input_boolean.truma_aventa_vm_high 233 | - choose: 234 | - conditions: 235 | condition: state 236 | entity_id: input_boolean.truma_aventa_switch 237 | state: "on" 238 | sequence: 239 | - action: mqtt.publish 240 | data: 241 | topic: "service/truma/set/aircon_vent_mode" 242 | payload: "night" 243 | 244 | - alias: "TRUMA switch Aventa VM Auto" 245 | id: "d002" 246 | triggers: 247 | - trigger: state 248 | entity_id: input_boolean.truma_aventa_vm_auto 249 | to: "on" 250 | - trigger: state 251 | entity_id: sensor.truma_aircon_vent_mode 252 | to: "auto" 253 | actions: 254 | - action: homeassistant.turn_on 255 | entity_id: input_boolean.truma_aventa_vm_auto 256 | - action: homeassistant.turn_off 257 | entity_id: input_boolean.truma_aventa_vm_night 258 | - action: homeassistant.turn_off 259 | entity_id: input_boolean.truma_aventa_vm_low 260 | - action: homeassistant.turn_off 261 | entity_id: input_boolean.truma_aventa_vm_mid 262 | - action: homeassistant.turn_off 263 | entity_id: input_boolean.truma_aventa_vm_high 264 | - choose: 265 | - conditions: 266 | condition: state 267 | entity_id: input_boolean.truma_aventa_switch 268 | state: "on" 269 | sequence: 270 | - action: mqtt.publish 271 | data: 272 | topic: "service/truma/set/aircon_vent_mode" 273 | payload: "auto" 274 | 275 | - alias: "TRUMA switch Aventa VM low" 276 | id: "d003" 277 | triggers: 278 | - trigger: state 279 | entity_id: input_boolean.truma_aventa_vm_low 280 | to: "on" 281 | - trigger: state 282 | entity_id: sensor.truma_aircon_vent_mode 283 | to: "low" 284 | actions: 285 | - action: homeassistant.turn_on 286 | entity_id: input_boolean.truma_aventa_vm_low 287 | - action: homeassistant.turn_off 288 | entity_id: input_boolean.truma_aventa_vm_night 289 | - action: homeassistant.turn_off 290 | entity_id: input_boolean.truma_aventa_vm_auto 291 | - action: homeassistant.turn_off 292 | entity_id: input_boolean.truma_aventa_vm_mid 293 | - action: homeassistant.turn_off 294 | entity_id: input_boolean.truma_aventa_vm_high 295 | - choose: 296 | - conditions: 297 | condition: state 298 | entity_id: input_boolean.truma_aventa_switch 299 | state: "on" 300 | sequence: 301 | - action: mqtt.publish 302 | data: 303 | topic: "service/truma/set/aircon_vent_mode" 304 | payload: "low" 305 | 306 | - alias: "TRUMA switch Aventa VM mid" 307 | id: "d004" 308 | triggers: 309 | - trigger: state 310 | entity_id: input_boolean.truma_aventa_vm_mid 311 | to: "on" 312 | - trigger: state 313 | entity_id: sensor.truma_aircon_vent_mode 314 | to: "mid" 315 | actions: 316 | - action: homeassistant.turn_on 317 | entity_id: input_boolean.truma_aventa_vm_mid 318 | - action: homeassistant.turn_off 319 | entity_id: input_boolean.truma_aventa_vm_night 320 | - action: homeassistant.turn_off 321 | entity_id: input_boolean.truma_aventa_vm_auto 322 | - action: homeassistant.turn_off 323 | entity_id: input_boolean.truma_aventa_vm_low 324 | - action: homeassistant.turn_off 325 | entity_id: input_boolean.truma_aventa_vm_high 326 | - choose: 327 | - conditions: 328 | condition: state 329 | entity_id: input_boolean.truma_aventa_switch 330 | state: "on" 331 | sequence: 332 | - action: mqtt.publish 333 | data: 334 | topic: "service/truma/set/aircon_vent_mode" 335 | payload: "mid" 336 | 337 | - alias: "TRUMA switch Aventa VM high" 338 | id: "d005" 339 | triggers: 340 | - trigger: state 341 | entity_id: input_boolean.truma_aventa_vm_high 342 | to: "on" 343 | - trigger: state 344 | entity_id: sensor.truma_aircon_vent_mode 345 | to: "high" 346 | actions: 347 | - action: homeassistant.turn_on 348 | entity_id: input_boolean.truma_aventa_vm_high 349 | - action: homeassistant.turn_off 350 | entity_id: input_boolean.truma_aventa_vm_night 351 | - action: homeassistant.turn_off 352 | entity_id: input_boolean.truma_aventa_vm_auto 353 | - action: homeassistant.turn_off 354 | entity_id: input_boolean.truma_aventa_vm_mid 355 | - action: homeassistant.turn_off 356 | entity_id: input_boolean.truma_aventa_vm_low 357 | - choose: 358 | - conditions: 359 | condition: state 360 | entity_id: input_boolean.truma_aventa_switch 361 | state: "on" 362 | sequence: 363 | - action: mqtt.publish 364 | data: 365 | topic: "service/truma/set/aircon_vent_mode" 366 | payload: "high" 367 | 368 | - alias: "Aventa climate change" 369 | id: "d06" 370 | triggers: 371 | - trigger: state 372 | entity_id: sensor.truma_target_temp_aircon 373 | actions: 374 | - choose: 375 | - conditions: 376 | condition: numeric_state 377 | entity_id: sensor.truma_target_temp_aircon 378 | above: 15 379 | sequence: 380 | - action: homeassistant.turn_on 381 | entity_id: input_boolean.truma_aventa_switch 382 | - action: climate.set_temperature 383 | entity_id: climate.aventa 384 | data: 385 | temperature: | 386 | {{ states('sensor.truma_target_temp_aircon')| float }} 387 | - action: climate.turn_on 388 | entity_id: climate.aventa 389 | - action: mqtt.publish 390 | data: 391 | topic: "service/truma/set/target_temp_aircon" 392 | payload: > 393 | {{ (state_attr('climate.aventa', 'temperature'))|round(1) }} 394 | - action: mqtt.publish 395 | data: 396 | topic: "service/truma/set/aircon_operating_mode" 397 | payload: > 398 | {{ states("sensor.truma_aventa_op_mode") }} 399 | - action: mqtt.publish 400 | data: 401 | topic: "service/truma/set/aircon_vent_mode" 402 | payload: > 403 | {{ states("sensor.truma_aventa_vm_mode") }} 404 | 405 | - alias: "Aventa climate change" 406 | id: "d0601" 407 | triggers: 408 | - trigger: state 409 | entity_id: climate.aventa 410 | attribute: temperature 411 | actions: 412 | - action: mqtt.publish 413 | data: 414 | topic: "service/truma/set/target_temp_aircon" 415 | payload: > 416 | {{ (state_attr('climate.aventa', 'temperature'))|round(1) }} 417 | 418 | - alias: "Aventa set off" 419 | id: "d07" 420 | triggers: 421 | - trigger: state 422 | entity_id: climate.aventa 423 | attribute: hvac_action 424 | to: "off" 425 | - trigger: state 426 | entity_id: input_boolean.truma_aventa_ctrl 427 | to: "off" 428 | - trigger: state 429 | entity_id: sensor.truma_aircon_operating_mode 430 | to: "off" 431 | actions: 432 | # - action: mqtt.publish 433 | # data: 434 | # topic: "service/truma/set/aircon_operating_mode" 435 | # payload: "auto" 436 | # - action: mqtt.publish 437 | # data: 438 | # topic: "service/truma/set/aircon_vent_mode" 439 | # payload: "auto" 440 | # - delay: 20 441 | - action: mqtt.publish 442 | data: 443 | topic: "service/truma/set/aircon_operating_mode" 444 | payload: "off" 445 | - action: mqtt.publish 446 | data: 447 | topic: "service/truma/set/aircon_vent_mode" 448 | payload: "low" 449 | - action: homeassistant.turn_off 450 | entity_id: input_boolean.truma_aventa_switch 451 | - action: climate.turn_off 452 | entity_id: climate.aventa 453 | 454 | - alias: "Aventa switch on" 455 | id: "d09" 456 | triggers: 457 | - trigger: state 458 | entity_id: climate.aventa 459 | attribute: hvac_action 460 | from: "off" 461 | - trigger: state 462 | entity_id: sensor.truma_aircon_operating_mode 463 | from: "off" 464 | actions: 465 | - choose: 466 | - conditions: 467 | condition: state 468 | entity_id: input_boolean.truma_aventa_ctrl 469 | state: "on" 470 | sequence: 471 | - action: climate.turn_on 472 | entity_id: climate.aventa 473 | - action: homeassistant.turn_on 474 | entity_id: input_boolean.truma_aventa_switch 475 | - action: mqtt.publish 476 | data: 477 | topic: "service/truma/set/aircon_operating_mode" 478 | payload: > 479 | {{ states("sensor.truma_aventa_op_mode") }} 480 | - action: mqtt.publish 481 | data: 482 | topic: "service/truma/set/aircon_vent_mode" 483 | payload: > 484 | {{ states("sensor.truma_aventa_vm_mode") }} 485 | - action: mqtt.publish 486 | data: 487 | topic: "service/truma/set/target_temp_aircon" 488 | payload: > 489 | {{ (state_attr('climate.aventa', 'temperature'))|round() }} 490 | - choose: 491 | - conditions: 492 | condition: state 493 | entity_id: input_boolean.truma_aventa_ctrl 494 | state: "off" 495 | sequence: 496 | - action: homeassistant.turn_off 497 | entity_id: input_boolean.truma_aventa_switch 498 | - action: climate.set_hvac_mode 499 | entity_id: climate.aventa 500 | data: 501 | hvac_mode: "off" 502 | - action: mqtt.publish 503 | data: 504 | topic: "service/truma/set/aircon_operating_mode" 505 | payload: "off" 506 | -------------------------------------------------------------------------------- /doc/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mc0110/inetbox2mqtt/5bf5b223fd7d45e245b87536b38d7edc63b4981d/doc/image.png -------------------------------------------------------------------------------- /doc/truma.ll: -------------------------------------------------------------------------------- 1 | type: vertical-stack 2 | cards: 3 | - show_name: true 4 | show_icon: true 5 | type: button 6 | tap_action: 7 | action: toggle 8 | entity: input_boolean.truma_ctrl 9 | icon: mdi:radiator 10 | icon_height: 40px 11 | - type: horizontal-stack 12 | cards: 13 | - show_name: true 14 | show_icon: true 15 | type: button 16 | tap_action: 17 | action: toggle 18 | entity: input_boolean.truma_heat_gas 19 | name: Gas 20 | icon: mdi:gas-burner 21 | - show_name: true 22 | show_icon: true 23 | type: button 24 | name: Mix 1kW 25 | tap_action: 26 | action: toggle 27 | entity: input_boolean.truma_heat_mix1 28 | icon: mdi:home-lightning-bolt-outline 29 | - show_name: true 30 | show_icon: true 31 | type: button 32 | name: Mix 2kW 33 | tap_action: 34 | action: toggle 35 | entity: input_boolean.truma_heat_mix2 36 | icon: mdi:home-lightning-bolt 37 | - show_name: true 38 | show_icon: true 39 | type: button 40 | name: El 1kW 41 | tap_action: 42 | action: toggle 43 | entity: input_boolean.truma_heat_elec1 44 | icon: mdi:home-lightning-bolt 45 | - show_name: true 46 | show_icon: true 47 | type: button 48 | name: El 2kW 49 | tap_action: 50 | action: toggle 51 | entity: input_boolean.truma_heat_elec2 52 | icon: mdi:home-lightning-bolt 53 | title: Energie 54 | - type: vertical-stack 55 | cards: 56 | - type: horizontal-stack 57 | cards: 58 | - show_name: true 59 | show_icon: false 60 | type: button 61 | name: Hotwater 62 | tap_action: 63 | action: toggle 64 | entity: input_boolean.truma_water_switch 65 | show_state: true 66 | - type: button 67 | name: eco 68 | tap_action: 69 | action: toggle 70 | entity: input_boolean.truma_water_eco 71 | show_name: true 72 | show_icon: true 73 | - type: button 74 | name: hot 75 | tap_action: 76 | action: toggle 77 | entity: input_boolean.truma_water_hot 78 | show_name: true 79 | show_icon: true 80 | - type: button 81 | name: boost 82 | tap_action: 83 | action: toggle 84 | entity: input_boolean.truma_water_boost 85 | show_name: true 86 | show_icon: true 87 | title: Wasser 88 | - type: horizontal-stack 89 | cards: 90 | - type: entity 91 | entity: sensor.truma_current_temp_water 92 | name: Momentan 93 | - show_name: true 94 | show_icon: true 95 | type: button 96 | tap_action: 97 | action: toggle 98 | entity: input_boolean.truma_water_autooff 99 | icon: mdi:thermometer-auto 100 | icon_height: 40px 101 | name: AutoOff 102 | - type: horizontal-stack 103 | cards: 104 | - type: thermostat 105 | entity: climate.truma 106 | title: Raumklima 107 | - type: horizontal-stack 108 | cards: 109 | - show_name: false 110 | show_icon: true 111 | type: button 112 | name: FAN 113 | tap_action: 114 | action: toggle 115 | entity: input_boolean.truma_fan_mode 116 | show_state: true 117 | icon: mdi:fan 118 | icon_height: 60px 119 | - type: horizontal-stack 120 | cards: 121 | - show_name: true 122 | show_icon: true 123 | show_state: true 124 | type: glance 125 | entities: 126 | - entity: sensor.truma_clock 127 | icon: m 128 | name: TRUMA 129 | - entity: binary_sensor.truma_alive 130 | name: Status 131 | - entity: sensor.truma_release 132 | icon: m 133 | name: Release 134 | columns: 3 135 | title: Status 136 | -------------------------------------------------------------------------------- /doc/truma.yaml: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2023 Dr. Magnus Christ (mc0110) 4 | # modified in 2024, now using the new yaml syntax 5 | # 6 | # This YAML is part of the inetbox2mqtt project and is based on the automatically generated entities. 7 | # 8 | # The library enables bidirectional interaction with the CPplus. 9 | # Thus, inputs can be made on the CPplus as well as on the HA. 10 | # This applies both to temperature changes and to switching on/off. 11 | # 12 | # Observe the associated frontend-card, which is optimally matched to it 13 | # 14 | input_boolean: 15 | truma_ctrl: 16 | name: "TRUMA Zentralschalter" 17 | truma_button: 18 | name: "TRUMA Heater State" 19 | truma_water_autooff: 20 | name: "TRUMA Water State" 21 | 22 | # Energie States 23 | truma_switch: 24 | name: "TRUMA State" 25 | truma_fan_mode: 26 | name: "TRUMA Fan high" 27 | truma_heat_elec2: 28 | name: "TRUMA El2" 29 | truma_heat_elec1: 30 | name: "TRUMA El1" 31 | truma_heat_mix1: 32 | name: "TRUMA Mix1" 33 | truma_heat_mix2: 34 | name: "TRUMA Mix2" 35 | truma_heat_gas: 36 | name: "TRUMA Gas" 37 | 38 | # Water-Temp-Level 39 | truma_water_eco: 40 | name: "eco" 41 | truma_water_hot: 42 | name: "hot" 43 | truma_water_boost: 44 | name: "boost" 45 | truma_water_switch: 46 | name: "TRUMA Water Heater" 47 | 48 | template: 49 | - sensor: 50 | - name: "TRUMA target temp water mode" 51 | state: > 52 | {% set temp = states('sensor.truma_target_temp_water')| float %} 53 | {% if temp == 0 %} 54 | off 55 | {% elif temp == 40 %} 56 | eco 57 | {% elif temp == 60 %} 58 | hot 59 | {% else %} 60 | boost 61 | {% endif %} 62 | 63 | - name: "TRUMA energy mode" 64 | state: > 65 | {% if (states("sensor.truma_energy_mix")=='electricity') %} 66 | {{ "elec"+((states("sensor.truma_el_power_level")|float/900.0))|round(0) |string}} 67 | {% elif states("sensor.truma_energy_mix")=='mix' %} 68 | {{ "mix"+((states("sensor.truma_el_power_level")|float/900.0))|round(0) |string}} 69 | {% elif states("sensor.truma_energy_mix")=='gas' %} 70 | {{ "gas" }} 71 | {% endif %} 72 | 73 | - binary_sensor: 74 | # Sensor, wenn charger on/off 75 | - name: "TRUMA Status" 76 | state: > 77 | {{ (states('input_boolean.truma_switch')) }} 78 | # Sensor, wenn charger on/off 79 | - name: "TRUMA Water Status" 80 | state: > 81 | {{ (states('input_boolean.truma_water_switch')) }} 82 | 83 | climate: 84 | - platform: generic_thermostat 85 | name: Truma 86 | heater: input_boolean.truma_button 87 | target_sensor: sensor.truma_current_temp_room 88 | min_temp: 5 89 | max_temp: 28 90 | ac_mode: false 91 | cold_tolerance: 0.3 92 | hot_tolerance: 0 93 | min_cycle_duration: 94 | seconds: 120 95 | away_temp: 10 96 | sleep_temp: 17 97 | comfort_temp: 22 98 | home_temp: 20 99 | precision: 0.1 100 | 101 | - platform: generic_thermostat 102 | name: Truma Water 103 | heater: input_boolean.truma_button 104 | target_sensor: sensor.truma_current_temp_water 105 | min_temp: 20 106 | max_temp: 200 107 | ac_mode: false 108 | cold_tolerance: 0.3 109 | hot_tolerance: 0 110 | min_cycle_duration: 111 | seconds: 120 112 | precision: 0.1 113 | 114 | automation: 115 | - alias: "TRUMA Fan" 116 | id: "c21" 117 | triggers: 118 | - trigger: state 119 | entity_id: sensor.truma_heating_mode 120 | for: 121 | seconds: 10 122 | actions: 123 | - choose: 124 | - conditions: 125 | condition: state 126 | entity_id: sensor.truma_heating_mode 127 | state: "high" 128 | sequence: 129 | - action: homeassistant.turn_on 130 | entity_id: input_boolean.truma_fan_mode 131 | - choose: 132 | - conditions: 133 | condition: state 134 | entity_id: sensor.truma_heating_mode 135 | state: "eco" 136 | sequence: 137 | - action: homeassistant.turn_off 138 | entity_id: input_boolean.truma_fan_mode 139 | - choose: 140 | - conditions: 141 | condition: state 142 | entity_id: sensor.truma_heating_mode 143 | state: "off" 144 | sequence: 145 | - action: homeassistant.turn_off 146 | entity_id: input_boolean.truma_fan_mode 147 | 148 | - alias: "TRUMA Bidirectional Change Room Temperature" 149 | id: "c22" 150 | triggers: 151 | - trigger: state 152 | entity_id: sensor.truma_target_temp_room 153 | actions: 154 | - choose: 155 | - conditions: 156 | condition: numeric_state 157 | entity_id: sensor.truma_target_temp_room 158 | above: 4 159 | sequence: 160 | - action: climate.set_temperature 161 | entity_id: climate.truma 162 | data: 163 | temperature: | 164 | {{ states('sensor.truma_target_temp_room')| float }} 165 | - action: homeassistant.turn_on 166 | entity_id: input_boolean.truma_switch 167 | - action: climate.turn_on 168 | entity_id: climate.truma 169 | - choose: 170 | - conditions: 171 | condition: numeric_state 172 | entity_id: sensor.truma_target_temp_room 173 | below: 5 174 | sequence: 175 | - action: homeassistant.turn_off 176 | entity_id: input_boolean.truma_switch 177 | - action: climate.turn_off 178 | entity_id: climate.truma 179 | 180 | - alias: "TRUMA climate change" 181 | id: "c23" 182 | triggers: 183 | - trigger: state 184 | entity_id: climate.truma 185 | attribute: temperature 186 | actions: 187 | - action: homeassistant.turn_on 188 | entity_id: input_boolean.truma_switch 189 | - action: mqtt.publish 190 | data: 191 | topic: "service/truma/set/target_temp_room" 192 | payload: > 193 | {{ (state_attr('climate.truma', 'temperature'))|round() }} 194 | - action: mqtt.publish 195 | data: 196 | topic: "service/truma/set/heating_mode" 197 | payload: "eco" 198 | 199 | - alias: "TRUMA set off" 200 | id: "c24" 201 | triggers: 202 | - trigger: state 203 | entity_id: climate.truma 204 | attribute: hvac_action 205 | to: "off" 206 | - trigger: state 207 | entity_id: input_boolean.truma_ctrl 208 | to: "off" 209 | actions: 210 | - action: climate.turn_off 211 | entity_id: climate.truma 212 | - action: mqtt.publish 213 | data: 214 | topic: "service/truma/set/target_temp_room" 215 | payload: "0" 216 | - action: mqtt.publish 217 | data: 218 | topic: "service/truma/set/heating_mode" 219 | payload: "off" 220 | 221 | - alias: "TRUMA switch on" 222 | id: "c26" 223 | triggers: 224 | - trigger: state 225 | entity_id: climate.truma 226 | attribute: hvac_action 227 | from: "off" 228 | actions: 229 | - choose: 230 | - conditions: 231 | condition: state 232 | entity_id: input_boolean.truma_ctrl 233 | state: "on" 234 | sequence: 235 | - action: climate.turn_on 236 | entity_id: climate.truma 237 | - action: mqtt.publish 238 | data: 239 | topic: "service/truma/set/target_temp_room" 240 | payload: > 241 | {{ (state_attr('climate.truma', 'temperature'))|round() }} 242 | - action: mqtt.publish 243 | data: 244 | topic: "service/truma/set/heating_mode" 245 | payload: "eco" 246 | - choose: 247 | - conditions: 248 | condition: state 249 | entity_id: input_boolean.truma_ctrl 250 | state: "off" 251 | sequence: 252 | - action: climate.set_hvac_mode 253 | entity_id: climate.truma 254 | data: 255 | hvac_mode: "off" 256 | 257 | - alias: "TRUMA set fan to high" 258 | id: "c27" 259 | triggers: 260 | trigger: state 261 | entity_id: input_boolean.truma_fan_mode 262 | to: "on" 263 | actions: 264 | - action: mqtt.publish 265 | data: 266 | topic: "service/truma/set/heating_mode" 267 | payload: "high" 268 | 269 | - alias: "TRUMA set fan to eco" 270 | id: "c28" 271 | triggers: 272 | trigger: state 273 | entity_id: input_boolean.truma_fan_mode 274 | to: "off" 275 | actions: 276 | - action: mqtt.publish 277 | data: 278 | topic: "service/truma/set/heating_mode" 279 | payload: "eco" 280 | 281 | # TRUMA Water Control 282 | - alias: "TRUMA Bidirectional Change Water Temperature" 283 | id: "c30" 284 | triggers: 285 | - trigger: state 286 | entity_id: sensor.truma_target_temp_water 287 | actions: 288 | - choose: 289 | - conditions: 290 | condition: numeric_state 291 | entity_id: sensor.truma_target_temp_water 292 | above: 0 293 | sequence: 294 | - action: climate.set_temperature 295 | entity_id: climate.truma_water 296 | data: 297 | temperature: | 298 | {{ states('sensor.truma_target_temp_water')| float }} 299 | - action: homeassistant.turn_on 300 | entity_id: input_boolean.truma_water_switch 301 | - action: climate.turn_on 302 | entity_id: climate.truma_water 303 | - choose: 304 | - conditions: 305 | condition: numeric_state 306 | entity_id: sensor.truma_target_temp_water 307 | below: 1 308 | sequence: 309 | - action: homeassistant.turn_off 310 | entity_id: input_boolean.truma_water_switch 311 | - action: climate.turn_off 312 | entity_id: climate.truma_water 313 | 314 | - alias: "TRUMA water set mode" 315 | id: "c30_1" 316 | triggers: 317 | - trigger: state 318 | entity_id: input_boolean.truma_water_eco 319 | to: "on" 320 | - trigger: state 321 | entity_id: sensor.truma_target_temp_water_mode 322 | to: "eco" 323 | actions: 324 | - action: homeassistant.turn_on 325 | entity_id: input_boolean.truma_water_eco 326 | - action: homeassistant.turn_off 327 | entity_id: input_boolean.truma_water_hot 328 | - action: homeassistant.turn_off 329 | entity_id: input_boolean.truma_water_boost 330 | - action: climate.set_temperature 331 | entity_id: climate.truma_water 332 | data: 333 | temperature: 40 334 | 335 | - alias: "TRUMA water set mode2" 336 | id: "c30_2" 337 | triggers: 338 | - trigger: state 339 | entity_id: input_boolean.truma_water_hot 340 | to: "on" 341 | - trigger: state 342 | entity_id: sensor.truma_target_temp_water_mode 343 | to: "hot" 344 | actions: 345 | - action: homeassistant.turn_on 346 | entity_id: input_boolean.truma_water_hot 347 | - action: homeassistant.turn_off 348 | entity_id: input_boolean.truma_water_eco 349 | - action: homeassistant.turn_off 350 | entity_id: input_boolean.truma_water_boost 351 | - action: climate.set_temperature 352 | entity_id: climate.truma_water 353 | data: 354 | temperature: 60 355 | 356 | - alias: "TRUMA water set mode3" 357 | id: "c30_3" 358 | triggers: 359 | - trigger: state 360 | entity_id: input_boolean.truma_water_boost 361 | to: "on" 362 | - trigger: state 363 | entity_id: sensor.truma_target_temp_water_mode 364 | to: "boost" 365 | actions: 366 | - action: homeassistant.turn_on 367 | entity_id: input_boolean.truma_water_boost 368 | - action: homeassistant.turn_off 369 | entity_id: input_boolean.truma_water_hot 370 | - action: homeassistant.turn_off 371 | entity_id: input_boolean.truma_water_eco 372 | - action: climate.set_temperature 373 | entity_id: climate.truma_water 374 | data: 375 | temperature: 65 376 | 377 | - alias: "TRUMA water set off" 378 | id: "c31" 379 | triggers: 380 | - trigger: state 381 | entity_id: climate.truma_water 382 | attribute: hvac_action 383 | to: "off" 384 | - trigger: state 385 | entity_id: input_boolean.truma_water_switch 386 | to: "off" 387 | - trigger: state 388 | entity_id: input_boolean.truma_ctrl 389 | to: "off" 390 | - trigger: state 391 | entity_id: sensor.truma_target_temp_water_mode 392 | to: "off" 393 | actions: 394 | - action: climate.turn_off 395 | entity_id: climate.truma_water 396 | - action: homeassistant.turn_off 397 | entity_id: input_boolean.truma_water_switch 398 | - action: mqtt.publish 399 | data: 400 | topic: "service/truma/set/target_temp_water" 401 | payload: "0" 402 | 403 | - alias: "TRUMA Water autoswitch off" 404 | id: "c34" 405 | triggers: 406 | - trigger: numeric_state 407 | entity_id: sensor.truma_current_temp_water 408 | above: 54 409 | for: "00:10:00" 410 | actions: 411 | - choose: 412 | conditions: 413 | condition: state 414 | entity_id: input_boolean.truma_water_autooff 415 | state: "on" 416 | sequence: 417 | - action: climate.turn_off 418 | entity_id: climate.truma_water 419 | - action: homeassistant.turn_off 420 | entity_id: input_boolean.truma_water_switch 421 | - action: notify.whatsapp 422 | data: 423 | message: "TRUMA-Water is warm" 424 | 425 | - alias: "TRUMA water switch on" 426 | id: "c32" 427 | triggers: 428 | - trigger: state 429 | entity_id: climate.truma_water 430 | attribute: hvac_action 431 | from: "off" 432 | - trigger: state 433 | entity_id: binary_sensor.truma_water_status 434 | from: "off" 435 | - trigger: state 436 | entity_id: climate.truma_water 437 | attribute: temperature 438 | - trigger: state 439 | entity_id: sensor.truma_target_temp_water_mode 440 | from: "off" 441 | actions: 442 | - choose: 443 | conditions: 444 | condition: state 445 | entity_id: input_boolean.truma_ctrl 446 | state: "on" 447 | sequence: 448 | - action: homeassistant.turn_on 449 | entity_id: input_boolean.truma_water_switch 450 | - action: climate.turn_on 451 | entity_id: climate.truma_water 452 | - action: mqtt.publish 453 | data: 454 | topic: "service/truma/set/target_temp_water" 455 | payload: > 456 | {% if states('input_boolean.truma_water_eco')=='on' %} 457 | 40 458 | {% elif states('input_boolean.truma_water_hot')=='on' %} 459 | 60 460 | {% else %} 461 | 200 462 | {% endif %} 463 | - choose: 464 | conditions: 465 | condition: state 466 | entity_id: input_boolean.truma_ctrl 467 | state: "off" 468 | sequence: 469 | - action: climate.turn_off 470 | entity_id: climate.truma_water 471 | - action: homeassistant.turn_off 472 | entity_id: input_boolean.truma_water_switch 473 | 474 | # - alias: "TRUMA water temperature change" 475 | # id: "c33" 476 | # triggers: 477 | # - trigger: state 478 | # entity_id: climate.truma_water 479 | # attribute: temperature 480 | # for: 481 | # seconds: 3 482 | # actions: 483 | # - action: homeassistant.turn_on 484 | # entity_id: input_boolean.truma_water_switch 485 | # - action: mqtt.publish 486 | # data: 487 | # topic: "service/truma/set/target_temp_water" 488 | # payload: > 489 | # {% set temp = state_attr('climate.truma_water', 'temperature') %} 490 | # {% if temp < 20 %} 491 | # 0 492 | # {% elif temp < 41 %} 493 | # 40 494 | # {% elif temp < 61 %} 495 | # 60 496 | # {% else %} 497 | # 200 498 | # {% endif %} 499 | 500 | - alias: "Switchoff TRUMA without shore-power" 501 | id: "c35" 502 | triggers: 503 | - trigger: state 504 | entity_id: binary_sensor.shore_power_in 505 | to: "off" 506 | actions: 507 | - action: homeassistant.turn_off 508 | entity_id: input_boolean.truma_ctrl 509 | 510 | - alias: "TRUMA switch elec2" 511 | id: "c40" 512 | triggers: 513 | - trigger: state 514 | entity_id: input_boolean.truma_heat_elec2 515 | to: "on" 516 | - trigger: state 517 | entity_id: sensor.truma_energy_mode 518 | to: "elec2" 519 | actions: 520 | - action: homeassistant.turn_on 521 | entity_id: input_boolean.truma_heat_elec2 522 | - action: homeassistant.turn_off 523 | entity_id: input_boolean.truma_heat_elec1 524 | - action: homeassistant.turn_off 525 | entity_id: input_boolean.truma_heat_mix1 526 | - action: homeassistant.turn_off 527 | entity_id: input_boolean.truma_heat_mix2 528 | - action: homeassistant.turn_off 529 | entity_id: input_boolean.truma_heat_gas 530 | - action: mqtt.publish 531 | data: 532 | topic: "service/truma/set/energy_mix" 533 | payload: "electricity" 534 | - action: mqtt.publish 535 | data: 536 | topic: "service/truma/set/el_power_level" 537 | payload: "1800" 538 | - alias: "TRUMA switch elec1" 539 | id: "c401" 540 | triggers: 541 | - trigger: state 542 | entity_id: input_boolean.truma_heat_elec1 543 | to: "on" 544 | - trigger: state 545 | entity_id: sensor.truma_energy_mode 546 | to: "elec1" 547 | actions: 548 | - action: homeassistant.turn_on 549 | entity_id: input_boolean.truma_heat_elec1 550 | - action: homeassistant.turn_off 551 | entity_id: input_boolean.truma_heat_elec2 552 | - action: homeassistant.turn_off 553 | entity_id: input_boolean.truma_heat_mix1 554 | - action: homeassistant.turn_off 555 | entity_id: input_boolean.truma_heat_mix2 556 | - action: homeassistant.turn_off 557 | entity_id: input_boolean.truma_heat_gas 558 | - action: mqtt.publish 559 | data: 560 | topic: "service/truma/set/energy_mix" 561 | payload: "electricity" 562 | - action: mqtt.publish 563 | data: 564 | topic: "service/truma/set/el_power_level" 565 | payload: "900" 566 | 567 | - alias: "TRUMA switch mix1" 568 | id: "c41" 569 | triggers: 570 | - trigger: state 571 | entity_id: input_boolean.truma_heat_mix1 572 | to: "on" 573 | - trigger: state 574 | entity_id: sensor.truma_energy_mode 575 | to: "mix1" 576 | actions: 577 | - action: homeassistant.turn_on 578 | entity_id: input_boolean.truma_heat_mix1 579 | - action: homeassistant.turn_off 580 | entity_id: input_boolean.truma_heat_elec2 581 | - action: homeassistant.turn_off 582 | entity_id: input_boolean.truma_heat_elec1 583 | - action: homeassistant.turn_off 584 | entity_id: input_boolean.truma_heat_mix2 585 | - action: homeassistant.turn_off 586 | entity_id: input_boolean.truma_heat_gas 587 | - action: mqtt.publish 588 | data: 589 | topic: "service/truma/set/energy_mix" 590 | payload: "mix" 591 | - action: mqtt.publish 592 | data: 593 | topic: "service/truma/set/el_power_level" 594 | payload: "900" 595 | 596 | - alias: "TRUMA switch mix2" 597 | id: "c42" 598 | triggers: 599 | - trigger: state 600 | entity_id: input_boolean.truma_heat_mix2 601 | to: "on" 602 | - trigger: state 603 | entity_id: sensor.truma_energy_mode 604 | to: "mix2" 605 | actions: 606 | - action: homeassistant.turn_on 607 | entity_id: input_boolean.truma_heat_mix2 608 | - action: homeassistant.turn_off 609 | entity_id: input_boolean.truma_heat_elec2 610 | - action: homeassistant.turn_off 611 | entity_id: input_boolean.truma_heat_elec1 612 | - action: homeassistant.turn_off 613 | entity_id: input_boolean.truma_heat_mix1 614 | - action: homeassistant.turn_off 615 | entity_id: input_boolean.truma_heat_gas 616 | - action: mqtt.publish 617 | data: 618 | topic: "service/truma/set/energy_mix" 619 | payload: "mix" 620 | - action: mqtt.publish 621 | data: 622 | topic: "service/truma/set/el_power_level" 623 | payload: "1800" 624 | 625 | - alias: "TRUMA switch gas" 626 | id: "c43" 627 | triggers: 628 | - trigger: state 629 | entity_id: input_boolean.truma_heat_gas 630 | to: "on" 631 | - trigger: state 632 | entity_id: sensor.truma_energy_mode 633 | to: "gas" 634 | actions: 635 | - action: homeassistant.turn_on 636 | entity_id: input_boolean.truma_heat_gas 637 | - action: homeassistant.turn_off 638 | entity_id: input_boolean.truma_heat_elec2 639 | - action: homeassistant.turn_off 640 | entity_id: input_boolean.truma_heat_elec1 641 | - action: homeassistant.turn_off 642 | entity_id: input_boolean.truma_heat_mix1 643 | - action: homeassistant.turn_off 644 | entity_id: input_boolean.truma_heat_mix2 645 | - action: mqtt.publish 646 | data: 647 | topic: "service/truma/set/energy_mix" 648 | payload: "gas" 649 | - action: mqtt.publish 650 | data: 651 | topic: "service/truma/set/el_power_level" 652 | payload: "0" 653 | -------------------------------------------------------------------------------- /lib/connect.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2022 Dr. Magnus Christ (mc0110) 4 | # 5 | # This is part of the wifimanager package 6 | # 7 | # 8 | # Functionalities: The module establishes a Wifi connection. 9 | # This is done either via an STA connection or if no credentials are available, 10 | # via an AP connection on 192.168.4.1. 11 | # It is possible to establish both connections in parallel. 12 | # 13 | # Further functionalities: 14 | # Reading / writing a json file for the credentials 15 | # Wifi-network scan 16 | # Reading / writing encrypted credentials from / to file 17 | 18 | 19 | import logging 20 | import network, os, sys, time, json 21 | from crypto_keys import fn_crypto as crypt 22 | from machine import reset, soft_reset, Pin 23 | from mqtt_async2 import MQTTClient, config 24 | import uasyncio as asyncio 25 | from tools import PIN_MAPS, PIN_MAP 26 | 27 | 28 | class Connect(): 29 | 30 | CRED_FN = "credentials.dat" 31 | CRED_JSON = "cred.json" 32 | BOOT_CNT = "boot.dat" 33 | RUN_MODE = "run_mode.dat" 34 | client = None 35 | 36 | ap_if = None 37 | con_if = None 38 | sta_if = None 39 | lan_if = None 40 | cred_fn = None 41 | config = None 42 | fixIP = None 43 | dhcp = True 44 | hostname = "" 45 | scanliste = [] 46 | platform = "" 47 | python = "" 48 | appname = "undefined" 49 | # mqtt-blink-time in ms 50 | blink_t = 100 51 | 52 | 53 | def __init__(self, hw, fn=None, debuglog=False): 54 | if hw == None: hw = "RP2" 55 | print("HW: ", hw) 56 | self.log = logging.getLogger(__name__) 57 | # self.c_coro = self.c_callback 58 | # self.w_coro = self.w_state 59 | if debuglog: 60 | self.log.setLevel(logging.DEBUG) 61 | else: 62 | self.log.setLevel(logging.INFO) 63 | self.pin_map = hw 64 | self.client = None 65 | self.p = PIN_MAP(PIN_MAPS[hw]) 66 | self.wifi_flg = False 67 | self.mqtt_flg = False 68 | self.connect_log = "" 69 | if fn != None: 70 | self.cred_fn = fn 71 | else: 72 | self.cred_fn = self.CRED_FN 73 | 74 | if not(self.CRED_JSON in os.listdir("/")): 75 | self.gen_cred_json() 76 | 77 | self.platform = str(sys.platform) 78 | self.platform_name = str(self.pin_map) + " " + str(sys.platform) 79 | self.python = '{} {} {}'.format(sys.implementation.name,'.'.join(str(s) for s in sys.implementation.version), sys.implementation._mpy) 80 | 81 | self.log.info("Detected " + self.python + " on port: " + self.platform) 82 | 83 | self.config = config 84 | self.set_proc() 85 | 86 | self.config.subs_cb = self.c_subscripted 87 | self.config.connect_coro = self.c_connected 88 | self.config.wifi_coro = self.w_state 89 | 90 | asyncio.create_task(self.mqtt_blink()) 91 | 92 | self.p.set_led("lin_led",0) 93 | # self.p.set_led("mqtt_led",0) 94 | if self.platform == 'rp2': 95 | import rp2 96 | rp2.country('DE') 97 | 98 | def mqtt_blink_ok(self): 99 | self.blink_t = 2000 100 | 101 | def mqtt_blink_search(self): 102 | self.blink_t = 100 103 | 104 | def mqtt_blink_err(self): 105 | self.blink_t = 300 106 | 107 | async def mqtt_blink(self): 108 | while 1: 109 | if self.blink_t != None: 110 | self.p.set_led("mqtt_led", True) 111 | await asyncio.sleep_ms(self.blink_t) 112 | self.p.set_led("mqtt_led", False) 113 | await asyncio.sleep_ms(self.blink_t) 114 | else: 115 | self.p.set_led("mqtt_led", False) 116 | 117 | 118 | async def c_subscripted(self, topic, msg, retained, qos): 119 | self.log.debug("Received topic:" + str(topic) + " > payload: " + str(msg) + "qos: " + str(qos)) 120 | if self.subscripted != None: await self.subscripted(topic, msg, retained, qos) 121 | 122 | # Initialze the connect-funct 123 | # define subscriptions 124 | async def c_connected(self, client): 125 | self.log.info("MQTT connected") 126 | self.mqtt_blink_ok() 127 | self.mqtt_flg = True 128 | if self.connected != None: 129 | await self.connected(client) 130 | 131 | # Wifi and MQTT status 132 | async def w_state(self, stat): 133 | if stat: 134 | self.log.info("Interface connected: " + str(self.con_if.ifconfig()[0])) 135 | self.mqtt_blink_ok() 136 | self.wifi_flg = True 137 | if self.wifi_state != None: await self.wifi_state(stat) 138 | else: 139 | self.log.info("Wifi connection lost") 140 | self.sta_if = None 141 | #self.p.set_led("mqtt_led", False) 142 | self.wifi_flg = False 143 | self.mqtt_flg = False 144 | self.mqtt_blink_search() 145 | self.connect(False) 146 | self.mqtt_blink_err() 147 | if self.wifi_state != None: await self.wifi_state(stat) 148 | 149 | def set_proc(self, wifi = None, connect = None, subscript = None): 150 | self.wifi_state = wifi 151 | self.connected = connect 152 | self.subscripted = subscript 153 | 154 | def gen_cred_json(self): 155 | 156 | j = { 157 | "SSID": ["text", "SSID:", "1"], 158 | "WIFIPW": ["password", "Wifi passcode:", "2"], 159 | "MQTT": ["text", "Broker name/IP:", "3"], 160 | "PORT": ["text", "Broker port (1883):", "4"], 161 | "UN": ["text", "Broker User:", "5"], 162 | "UPW": ["text", "Broker password:", "6"], 163 | "HOSTNAME": ["text", "Hostname:", "7"], 164 | "LAN": ["checkbox", "LAN Support :", "8"], 165 | "STATIC": ["checkbox", "Static IP :", "9"], 166 | "IP": ["text", "IP (static):", "A"], 167 | "TOPIC": ["text", "Topic prefix (instead of truma):", "B"], 168 | "ADC": ["checkbox", "Addon DuoControl :", "C"], 169 | "ASL": ["checkbox", "Addon SpiritLevel:", "D"], 170 | } 171 | with open(self.CRED_JSON, "w") as f: json.dump(j, f) 172 | 173 | def read_cred_json(self): 174 | with open(self.CRED_JSON, "r") as f: j=json.load(f) 175 | return j 176 | 177 | 178 | def set_appname(self, an): 179 | self.appname = an 180 | 181 | 182 | # run-modes (0: OS-run, 1: normal-run 2,3: ota-upload) 183 | def run_mode(self, set=-1): 184 | if set == -1: 185 | if (self.RUN_MODE in os.listdir("/")): 186 | with open(self.RUN_MODE, "r") as f: a = f.read() 187 | self.log.debug(f"RUN-Mode: {a}") 188 | return int(str(a)) 189 | else: return 0 190 | if set > 0: 191 | if self.creds(): 192 | with open(self.RUN_MODE, "w") as f: f.write(str(set)) 193 | self.boot_count(10) 194 | self.log.info("Set RUN-Mode: " + str(set)) 195 | return set 196 | else: 197 | return 0 198 | if set == 0: 199 | self.log.info("Set RUN-Mode: 0 = OS-RUN") 200 | try: 201 | os.remove(self.RUN_MODE) 202 | except: 203 | pass 204 | return 0 205 | 206 | # boot-counts 207 | # ask and decrease with -1 or empty 208 | # 209 | def boot_count(self, set=-1): 210 | if set == -1: 211 | if (self.BOOT_CNT in os.listdir("/")): 212 | with open(self.BOOT_CNT, "r") as f: a = f.read() 213 | self.log.debug("Boot tries left: " + str(a)) 214 | a = int(str(a)) 215 | self.boot_count(a - 1) 216 | return a 217 | else: return 0 218 | if set > 0: 219 | if self.creds(): 220 | with open(self.BOOT_CNT, "w") as f: f.write(str(set)) 221 | return set 222 | else: 223 | return 0 224 | if set == 0: 225 | try: 226 | os.remove(self.BOOT_CNT) 227 | except: 228 | pass 229 | return 0 230 | 231 | def get_state(self): 232 | def get_ap(ap, id): 233 | if ap == None: 234 | return {} 235 | conf_s = ["mac", "ssid", "channel", "hidden", "security", "key", "hostname", "reconnects", "txpower"] 236 | q = {} 237 | for i in conf_s: 238 | try: 239 | q.update({id + i: ap.config(i)}) 240 | except: 241 | pass 242 | x = ap.ifconfig() 243 | q.update({id + "ip": x[0]}) 244 | q.update({id + "mask": x[1]}) 245 | q.update({id + "gateway": x[2]}) 246 | q.update({id + "dns": x[3]}) 247 | return q 248 | 249 | r = {"port": self.platform, "python": self.python} 250 | if self.ap_if != None: 251 | r["ap_state"] = "on" 252 | r.update(get_ap(self.ap_if,"ap_")) 253 | else: 254 | r["ap_state"] = "off" 255 | if self.sta_if != None: 256 | r["sta_state"] = "on" 257 | r.update(get_ap(self.sta_if,"sta_")) 258 | else: 259 | r["sta_state"] = "off" 260 | r["cred_fn"] = self.creds() 261 | r["cred_bak"] = self.creds_bak() 262 | r["run_mode"] = self.run_mode() 263 | r["run_mqtt"] = self.set_mqtt() 264 | return r 265 | 266 | 267 | def creds(self): 268 | return self.cred_fn in os.listdir("/") 269 | 270 | 271 | def creds_bak(self): 272 | a = self.cred_fn.split(".") 273 | return a[0]+".bak" in os.listdir("/") 274 | 275 | 276 | def swap_creds(self): 277 | if self.creds() and self.creds_bak(): 278 | a = self.cred_fn.split(".") 279 | os.rename(self.cred_fn, a[0] + ".bal") 280 | os.rename(a[0] + ".bak", self.cred_fn) 281 | os.rename(a[0] + ".bal", a[0] + ".bak") 282 | 283 | 284 | def restore_creds(self): 285 | if self.creds_bak(): 286 | a = self.cred_fn.split(".") 287 | os.rename(a[0] + ".bak", self.cred_fn) 288 | 289 | 290 | def scan(self): 291 | a = network.WLAN(network.STA_IF) 292 | time.sleep(2) 293 | q = a.scan() 294 | self.log.debug(str(q)) 295 | return q 296 | 297 | 298 | def scan_html(self): # use the bootstrap-css-definitions 299 | a = self.scan() 300 | authmodes = ['Open','WEP', 'WPA-PSK', 'WPA2-PSK4', 'WPA/WPA2-PSK'] 301 | tmp = "" 302 | tmp += "
" 303 | tmp += " \n" 304 | for (ssid, bssid, channel, RSSI, authmode, hidden) in a: 305 | tmp += "" 306 | tmp += "" 307 | tmp += "" 308 | tmp += "" 309 | tmp += " \n" 310 | tmp += "
SSIDAuthChannelRSSISBSSID
{:s}".format(ssid) + "{} {}".format(authmodes[authmode-1], hidden) + "{}".format(channel) + "{}".format(RSSI) + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}".format(*bssid) + "
" 311 | return tmp 312 | 313 | 314 | def set_ap(self, sta=-1): 315 | if sta == -1: # default value returns current state 316 | return int((self.ap_if != None)) 317 | self.log.info(f"set ap to: {sta}") 318 | self.ap_if = network.WLAN(network.AP_IF) 319 | # Access point definitions 320 | if self.platform == 'rp2': 321 | self.ap_if.config(ssid="Pico") 322 | self.ap_if.config(password="password") 323 | if self.platform == 'esp32': 324 | pass 325 | # self.ap_if.config(ssid="ESP32") 326 | # self.ap_if.config(password="password") 327 | 328 | self.ap_if.active(sta) # activate the interface 329 | time.sleep(1) 330 | if not(sta): 331 | self.log.debug("AP_WLAN switched off") 332 | self.ap_if = None 333 | return 0 334 | else: 335 | self.log.info("AP enabled: " + str(self.ap_if.ifconfig()[0])) 336 | # print(self.get_state()) 337 | return 1 338 | 339 | def connect(self, t=True): 340 | self.dhcp = True 341 | self.lan = False 342 | if self.creds(): 343 | cred = self.read_json_creds() 344 | self.dhcp = (cred["STATIC"] != "1") 345 | self.lan = (cred["LAN"] == "1") 346 | if cred["IP"] == "": 347 | self.dhcp = True 348 | else: 349 | self.fixIP = cred["IP"] 350 | else: 351 | return 0 352 | self.log.info(f"DHCP: {self.dhcp}") 353 | self.log.info(f"fixedIP: {self.fixIP}") 354 | self.log.info(f"LAN: {self.lan}") 355 | # set LAN connection 356 | if self.lan: 357 | self.log.debug(f"LAN Interface starting") 358 | if self.set_lan(1) == 1: 359 | self.log.debug(f"LAN Interface started") 360 | if t: 361 | s = self.set_mqtt(1) 362 | return s 363 | else: 364 | return 0 365 | else: 366 | return 0 367 | # set wifi connection 368 | else: 369 | self.log.debug(f"WLAN Interface starting") 370 | if self.set_sta(1) == 1: 371 | self.log.debug(f"WLAN Interface started") 372 | self.log.debug(f"MQTT connection starting") 373 | if t: 374 | s = self.set_mqtt(1) 375 | return s 376 | else: 377 | return 0 378 | else: 379 | return 0 380 | 381 | 382 | def set_lan(self, sta=-1): 383 | if sta == -1: 384 | return int((self.lan_if != None)) 385 | 386 | if sta==1: 387 | if not(self.p.get_data("lan")): 388 | self.log.info("LAN device not defined") 389 | return 0 390 | else: 391 | if not(self.lan): 392 | return 0 393 | self.log.debug("LAN device configured") 394 | try: 395 | self.log.debug("LAN-Connection") 396 | self.lan_if = network.LAN(mdc=Pin(self.p.get_pin("mdc")), mdio=Pin(self.p.get_pin("mdio")), ref_clk=Pin(self.p.get_pin("ref_clk")), 397 | ref_clk_mode=False, power=None, id=None, phy_addr=0, phy_type=network.PHY_KSZ8081) 398 | except: 399 | if self.boot_count(): 400 | reset() 401 | else: 402 | soft_reset() 403 | try: 404 | time.sleep(0.5) 405 | self.lan_if.active(False) 406 | except: 407 | pass 408 | time.sleep(0.1) 409 | self.lan_if.active(True) 410 | self.log.debug(f"isconnected: {self.lan_if.isconnected()}") # check if the station is connected to an AP 411 | 412 | self.log.debug(f"Mac: {self.lan_if.config('mac')}") # get the interface's MAC address 413 | time.sleep(0.1) 414 | ipconfig = self.lan_if.ifconfig() 415 | self.log.info("Waiting for DHCP...") 416 | 417 | while (ipconfig[0]=="0.0.0.0"): 418 | time.sleep(0.1) 419 | # lan.active(True) 420 | self.log.debug(f"lan.status: {self.lan_if.status()}") 421 | ipconfig = self.lan_if.ifconfig() 422 | 423 | if not(self.dhcp): 424 | ipconfig = self.lan_if.ifconfig() 425 | # self.lan_if.ifconfig([self.fixIP]) 426 | self.lan_if.ifconfig([self.fixIP, ipconfig[1], ipconfig[2],ipconfig[3]]) 427 | self.con_if = self.lan_if 428 | self.boot_count(10) 429 | return 1 430 | 431 | 432 | def set_sta(self, sta=-1): 433 | if sta == -1: # default value returns current state 434 | return int((self.sta_if != None)) 435 | self.sta_if = network.WLAN(network.STA_IF) 436 | time.sleep(0.2) # without delay we see on an ESP32 a system fault and reboot 437 | self.sta_if.active(sta) # activate the interface 438 | time.sleep(0.2) # without delay we see on an ESP32 a system fault and reboot 439 | if not(sta): 440 | self.log.debug("STA_WLAN switched off") 441 | self.sta_if = None 442 | return 0 443 | # sta==True / check for Creds 444 | cred = self.read_json_creds() 445 | 446 | if not(self.creds()): 447 | self.log.debug('No credentials found ...') 448 | self.sta_if.active(False) 449 | self.sta_if = None 450 | return 0 451 | if not(cred["SSID"] != ""): 452 | self.log.debug('No credentials found ...') 453 | self.sta_if.active(False) 454 | self.sta_if = None 455 | return 0 456 | c = crypt() 457 | ssid = cred["SSID"] 458 | wifipw = cred["WIFIPW"] 459 | self.hostname = cred["HOSTNAME"] 460 | self.log.debug('Connecting with credentials to network...') 461 | self.sta_if.active(False) 462 | time.sleep(0.2) 463 | err = 0 464 | try: 465 | self.sta_if.active(True) 466 | self.sta_if.connect(ssid, wifipw) 467 | except: 468 | err = 1 469 | # only running on ESP32 470 | #if self.platform == 'esp32': 471 | # self.sta_if.config(hostname = self.hostname) 472 | i = 0 473 | while not(self.sta_if.isconnected()) and not(err): 474 | print(".",end='') 475 | i += 1 476 | time.sleep(1) 477 | #self.p.toggle_led("mqtt_led") 478 | if i>60: 479 | self.log.debug("Connection couldn't be established - aborted") 480 | self.sta_if.active(False) 481 | self.sta_if = None 482 | if self.run_mode() == 1: 483 | # boot_count() > 0, decreased 484 | if self.boot_count(): 485 | soft_reset() 486 | else: 487 | self.run_mode(0) 488 | 489 | elif self.run_mode() > 1: 490 | soft_reset() 491 | 492 | # self.p.set_led("mqtt_led", 0) 493 | self.set_ap(1) # sta-cred wrong, established ap-connection 494 | return 0 # sta-cred wrong, established ap-connection 495 | if err: 496 | self.set_ap(1) 497 | self.log.debug("STA connection error - couldn't be established") 498 | return 0 499 | if not(self.dhcp): 500 | ipconfig = self.sta_if.ifconfig() 501 | self.sta_if.ifconfig((self.fixIP, ipconfig[1], ipconfig[2], ipconfig[3])) 502 | 503 | self.log.debug("STA connection connected successful") 504 | self.log.debug(f"Mac: {self.sta_if.config('mac')}") # get the interface's MAC address 505 | self.log.info("Wifi connected: " + str(self.sta_if.ifconfig()[0])) 506 | # self.p.set_led("mqtt_led", 1) 507 | self.log.debug(self.get_state()) 508 | self.con_if = self.sta_if 509 | # set boot_count back, connection is realized 510 | self.boot_count(10) 511 | return 1 512 | 513 | def set_mqtt(self, sta=-1): 514 | if sta == -1: 515 | return (self.mqtt_flg and self.wifi_flg) 516 | 517 | if sta: 518 | self.log.debug("Try to open mqtt connection") 519 | # Decrypt your encrypted credentials 520 | # c = crypt() 521 | if self.creds(): 522 | cred = self.read_json_creds() 523 | self.log.info("Found Credentials") 524 | self.config.server = cred["MQTT"] 525 | self.config.user = cred["UN"] 526 | self.config.password = cred["UPW"] 527 | port = 1883 528 | if cred["PORT"] != "": 529 | port = int(cred["PORT"]) 530 | self.log.info(f"MQTT Port is switched to port: {port}") 531 | self.config.interface = self.con_if 532 | self.config.clean = True 533 | self.config.keepalive = 60 # last will after 60sek off 534 | # self.config.set_last_will("test/alive", "OFF", retain=True, qos=0) # last will is important for clean connect 535 | self.config.set_last_will("service/truma/control_status/alive", "OFF", retain=True, qos=0) # last will is important 536 | self.client = MQTTClient(self.config) 537 | self.log.info("Start mqtt connect task") 538 | asyncio.create_task(self.client.connect()) 539 | return 1 540 | else: 541 | self.log.debug("no Credentials found") 542 | return 0 543 | else: 544 | self.log.info("Reset Wifi and MQTT-client") 545 | s = network.WLAN(network.STA_IF) 546 | s.disconnect() 547 | self.mqtt_flg = False 548 | self.wifi_flg = False 549 | self.client = None 550 | 551 | # write values from the given dict (l) to crypt-file (needs crypto_keys_lib) 552 | def store_creds(self, l): 553 | self.delete_creds() 554 | with open(self.cred_fn, "wb") as fn: 555 | c = crypt() 556 | for key, val in l.items(): 557 | c.fn_write_encrypt(fn, key + ":" + val) 558 | c.fn_write_eof_encrypt(fn) 559 | fn.close() 560 | 561 | # read json-file and move the credentials into a dict {key: value} 562 | def read_json_creds(self): 563 | JSON = self.read_cred_json() 564 | json = {} 565 | # convert JSON to json_result = {key: value} 566 | for i in JSON.keys(): 567 | json[i] = "0" 568 | # take results from cred-file {key: value} 569 | a = self.read_creds(json) 570 | return a 571 | 572 | # take and decrypt the values from encrypted file into the given dict (l) -> return 573 | def read_creds(self, l): 574 | ro = {} 575 | if self.creds(): 576 | ro = l 577 | c = crypt() 578 | for key in ro.keys(): 579 | ro[key] = c.get_decrypt_key(self.cred_fn, key) 580 | return ro 581 | 582 | def delete_creds(self): 583 | if self.creds(): 584 | a = self.cred_fn.split(".") 585 | os.rename(self.cred_fn, a[0]+".bak") 586 | return 0 587 | return 1 588 | 589 | 590 | 591 | -------------------------------------------------------------------------------- /lib/crypto_keys.py: -------------------------------------------------------------------------------- 1 | # Crypto-Class for Strings with bonding the key with the machine.unique_id 2 | # 3 | # 4 | # This modules are using the machine_unique_number for encrypt / decrypt 5 | # So the data is only on the target-system usable 6 | # based on ESP32 Micropython implementation of cryptographic 7 | # 8 | # reference: 9 | # https://pycryptodome.readthedocs.io/en/latest/src/cipher/classic.html#cbc-mode 10 | # https://docs.micropython.org/en/latest/library/ucryptolib.html 11 | 12 | import os 13 | from ucryptolib import aes 14 | import machine 15 | 16 | class crypto(): 17 | def encrypt(self, text): 18 | BLOCK_SIZE = 32 19 | IV_SIZE = 16 20 | MODE_CBC = 2 21 | # Padding plain text with space 22 | text = text.encode("utf-8") 23 | pad = BLOCK_SIZE - len(text) % BLOCK_SIZE 24 | text = text + " ".encode("utf-8")*pad 25 | key1 = machine.unique_id() 26 | key = bytearray(b'I_am_32bytes=256bits_key_padding') 27 | for i in range(len(key1)-1): 28 | key[i] = key1[i] 29 | key[len(key)-i-1] = key1[i] 30 | # Generate iv with HW random generator 31 | iv = os.urandom(IV_SIZE) 32 | cipher = aes(key, MODE_CBC, iv) 33 | ct_bytes = iv + cipher.encrypt(text) 34 | return ct_bytes 35 | 36 | # you need only one of this modules 37 | def decrypt(self, enc_bytes): 38 | BLOCK_SIZE = 32 39 | IV_SIZE = 16 40 | MODE_CBC = 2 41 | key1 = machine.unique_id() 42 | key = bytearray(b'I_am_32bytes=256bits_key_padding') 43 | for i in range(len(key1)-1): 44 | key[i] = key1[i] 45 | key[len(key)-i-1] = key1[i] 46 | # Generate iv with HW random generator 47 | iv = enc_bytes[:IV_SIZE] 48 | cipher = aes(key, MODE_CBC, iv) 49 | return cipher.decrypt(enc_bytes)[IV_SIZE:].strip().decode("utf-8") 50 | 51 | 52 | 53 | class fn_crypto(): 54 | def __init__(self): 55 | pass 56 | 57 | def fn_write_encrypt(self, f, x): 58 | cip = crypto() 59 | x = cip.encrypt(x) 60 | f.write(len(x).to_bytes(2, 'little')) 61 | f.write(x) 62 | 63 | 64 | def fn_write_eof_encrypt(self, f): 65 | x=0 66 | f.write(x.to_bytes(2, 'little')) 67 | 68 | 69 | def fn_read_decrypt(self, f): 70 | cip = crypto() 71 | x = int.from_bytes(f.read(2), "little") 72 | if x > 0: 73 | return str(cip.decrypt(f.read(x)), 'utf-8') 74 | else: return "" 75 | 76 | 77 | def fn_read_str_decrypt(self, f, x): 78 | cip = crypto() 79 | return str(cip.decrypt(f.read(x)), 'utf-8') 80 | 81 | 82 | def get_decrypt_key(self, fn, key): 83 | with open(fn, "rb") as f: 84 | s = self.fn_read_decrypt(f) 85 | while s != "": 86 | if s.find(key) > -1: 87 | f.close() 88 | return str(s[s.find(":")+1:], 'utf-8') 89 | s = self.fn_read_decrypt(f) 90 | f.close() 91 | print('Err in crypto_keys: key not found') 92 | return '' 93 | -------------------------------------------------------------------------------- /lib/gen_html.py: -------------------------------------------------------------------------------- 1 | import os, machine 2 | #import connect 3 | import gc 4 | #from lin import Lin 5 | 6 | class Gen_Html(): 7 | CR_M = "(c) MIT licence  ­inetbox2mqtt (2023) " 8 | 9 | CONNECT_STATE = "" 10 | 11 | HLP_TXT = { 12 | "root": '''This manager allows the administration of microPython devices via Wifi connection. 13 | ''', 14 | "files": 'Filemanager with full access to the ports filesystem.', 15 | "": 'No help description available', 16 | } 17 | 18 | # w-parameter is the connect-object 19 | def __init__(self, w, lin): 20 | self.connect = w 21 | self.lin = lin 22 | # generate the json-definition for credentials 23 | self.JSON = self.connect.read_cred_json() 24 | # connection will be established 25 | #self.connect.connect() 26 | self.refresh_connect_state() 27 | 28 | def refresh_connect_state(self): 29 | # collect state information 30 | # Wifi-class information 31 | self.CONNECT_STATE = self.connect.get_state() 32 | gc.collect() 33 | # add mem state 34 | self.CONNECT_STATE["mem_free"] = str(gc.mem_free()) 35 | # add cred-file, with existing 36 | s =self.lin.app.get_all(False) 37 | for key, val in s.items(): 38 | self.CONNECT_STATE["lin_" + key] = val 39 | if self.connect.creds(): 40 | json = {} 41 | # convert JSON to json_result = {key: value} 42 | for i in self.JSON.keys(): 43 | json[i] = "0" 44 | # take results from cred-file {key: value} 45 | a = self.connect.read_creds(json) 46 | for key, val in a.items(): 47 | self.CONNECT_STATE["cred_" + key] = val 48 | 49 | 50 | def head(self, refresh=None): 51 | tmp = ''' 52 | 53 | 54 | 55 | 56 | 57 | ''' 58 | if refresh != None: 59 | tmp += '' 60 | tmp += ''' 61 | 62 | 63 | 76 | 77 | ''' 78 | return tmp 79 | 80 | def handleHeader(self, title = "", hlpkey = None, refresh = None, status = False): 81 | def str_keys(pre): 82 | s = pre.strip("_")+": " 83 | ap_k = [] 84 | for key in self.CONNECT_STATE.keys(): 85 | if key.startswith(pre): 86 | ap_k.append(key) 87 | ap_k.sort() 88 | for key in ap_k: 89 | s += " (" + key[len(pre):] + " = " + str(self.CONNECT_STATE[key]) + ") " 90 | return s 91 | 92 | tmp = self.head(refresh) 93 | tmp += "
" 94 | tmp += "

" + self.connect.appname + " " + title + "

" 95 | tmp += "
" 96 | if hlpkey != None: 97 | tmp += "
" + self.HLP_TXT.get(hlpkey) + "
" 98 | gc.collect() 99 | if status: 100 | tmp += "
State-info:
" 101 | tmp += str_keys("ap_") + "
" 102 | tmp += str_keys("sta_") + "
" 103 | tmp += str_keys("cred_") + "
" 104 | tmp += str_keys("run_") + "
" 105 | tmp += str_keys("mem_") + "
" 106 | tmp += str_keys("lin_") + "
" 107 | tmp += "
" 108 | gc.collect() 109 | return tmp; 110 | 111 | 112 | def handleFooter(self, link = "/", name = "Back"): 113 | tmp = "" 114 | if link != "": 115 | tmp += "
"+self.handleGet(link,name)+"
" 116 | tmp += '
This  ' + self.connect.platform_name + '  is running on  ' + self.connect.python + '
' 117 | tmp += '
' + self.CR_M + 'RelNo:' + self.connect.rel_no + '
' 118 | tmp += " " 119 | return tmp 120 | 121 | def handleGet(self, lnk, name): 122 | tmp = "
" 123 | tmp += "
\n" 124 | return tmp 125 | 126 | 127 | def handlePost(self, path, name, txt, val): 128 | tmp = "
" 129 | tmp += "
" + txt + "" 130 | tmp += "" 131 | tmp += "
" 132 | tmp += "
\n" 133 | return tmp 134 | 135 | def handleMessage(self, message, blnk, bttn_name, refresh = None): 136 | tmp = self.handleHeader("Message", None, refresh) 137 | tmp += "
" + message + "
" 138 | tmp += self.handleFooter(blnk,bttn_name) 139 | return tmp 140 | 141 | def handleStatus(self, message, blnk, bttn_name, refresh = None): 142 | self.refresh_connect_state() 143 | f = open("status.html","w") 144 | f.write(self.handleHeader("Status", None, refresh, status = True)) 145 | # tmp += "
" + message + "
" 146 | f.write(self.handleFooter(blnk,bttn_name)) 147 | f.close() 148 | return "/status.html" 149 | 150 | # Main Page 151 | def handleRoot(self): 152 | f = open("index.html","w") 153 | f.write(self.handleHeader("", refresh = ("30","/"))) 154 | f.write(self.handleGet("/s","Status")) 155 | f.write(self.handleGet("/wc","Credentials")) 156 | f.write(self.handleGet("/scan","Scan WIFI") + "\n") 157 | f.write(self.handleGet("/heat_on","Water Heater on") + "\n") 158 | f.write(self.handleGet("/heat_off","Water Heater off") + "\n") 159 | if self.connect.mqtt_flg: 160 | f.write(self.handleGet("/ts","Send MQTT message")) 161 | else: 162 | f.write(self.handleGet("/ts","No MQTT...")) 163 | f.write(self.handleGet("/dir/__","Filemanager") + "

") 164 | if self.connect.run_mode() == 1: 165 | f.write(self.handleGet("/rm", "Normal RUN after reboot")) 166 | elif self.connect.run_mode() == 2: 167 | f.write(self.handleGet("/rm", "UPDATE after Reboot")) 168 | else: 169 | f.write(self.handleGet("/rm", "OS mode after Reboot")) 170 | f.write(self.handleGet("/rb","Soft Reboot") + "\n") 171 | f.write(self.handleGet("/rb1","Hard Reboot") + "

\n") 172 | f.write(self.handleGet("/ur","Update Repo") + "

") 173 | # if self.connect.set_ap(): 174 | # tmp += self.handleGet("/ta","Reset AccessPoint") 175 | # else: 176 | # tmp += self.handleGet("/ta","Start AccessPoint") 177 | f.write(self.handleFooter()) 178 | f.close() 179 | return "/index.html" 180 | 181 | def handleFileAction(self, link, dir, fn): 182 | tmp = "

" 183 | tmp += "" 184 | tmp += "" 185 | tmp += "" 186 | tmp += "" 187 | tmp += "" 188 | tmp += "
\n" 189 | return tmp 190 | 191 | def handleUpload(self, dir): 192 | dir1 = dir 193 | if dir == "/": 194 | dir1 = "/__/" 195 | 196 | tmp = "File-Upload
" 197 | tmp += "
\n" 202 | return tmp 203 | 204 | def handleFiles(self, dir): 205 | def gen_dir_href(i): 206 | tmp = "" 207 | tmp += "" 210 | tmp += "(" + i + ")" 211 | tmp += " " 212 | tmp += "
" 213 | return tmp 214 | 215 | def gen_dir_back_href(): 216 | tmp = "" 217 | a2 = 1 218 | a1 = 0 219 | while a2>0: 220 | a = a1 221 | a1 = a2 - 1 222 | a2 = dir.find("/",a2) + 1 223 | if a1 == 0: 224 | return "" 225 | i = dir[:a] 226 | if i == "": 227 | i = "/__" 228 | tmp += "" 231 | tmp += "(..)" 232 | tmp += " " 233 | tmp += "
" 234 | return tmp 235 | 236 | # print("handleFiles dir=", dir) 237 | if dir[-1] != "/": 238 | dir = dir + "/" 239 | if dir[0] != "/": 240 | dir = "/" + dir 241 | f = open("fm.html","w") 242 | f.write(self.handleHeader("Filemanager '" + dir + "'")) 243 | f.write("
") 244 | f.write(gen_dir_back_href()) 245 | s = os.ilistdir(dir) # directories 246 | for i in s: 247 | if i[1] == 0x4000: 248 | f.write(gen_dir_href(i[0])) 249 | s = os.ilistdir(dir) # files 250 | for i in s: 251 | if i[1] == 0x8000: 252 | f.write(self.handleFileAction("/fm", dir, i[0])) 253 | if dir == '/': 254 | dir = '/__/' 255 | f.write("
") 256 | f.write("

" + self.handleUpload(dir) + "

") 257 | f.write(self.handleFooter()) 258 | f.close() 259 | return "fm.html" 260 | 261 | 262 | def handleScan_Networks(self): 263 | tmp = self.handleHeader("Wifi-Networks", refresh = ("20", "/scan")); 264 | tmp += self.connect.scan_html() 265 | tmp += "
" + self.handleGet("/scan", "Rescan") 266 | tmp += self.handleFooter() 267 | return tmp 268 | 269 | 270 | def handleCredentials(self, json_form): 271 | f = open("cred.html","w") 272 | f.write(self.handleHeader("Credentials")) 273 | if self.connect.creds(): 274 | f.write("

" + self.handleGet("/dc","Delete Credentials") + "\n") 275 | if self.connect.creds_bak(): 276 | f.write(self.handleGet("/sc","Swap Credentials")) 277 | else: 278 | f.write("

Credential-File doesn't exist


\n") 279 | if self.connect.creds_bak(): 280 | f.write(self.handleGet("/rc","Restore Credentials")) 281 | 282 | 283 | f.write("

\n") 284 | 285 | # json-format: key,[type, entryname, order] 286 | entries = sorted(json_form.items(), key=lambda x:x[1][2]) 287 | for e in entries: 288 | if e[1][0] == "checkbox": 289 | f.write("

\n") 290 | else: 291 | f.write("

\n") 292 | f.write("
") 293 | f.write("

") 294 | f.write(self.handleFooter()) 295 | f.close() 296 | return "cred.html" 297 | 298 | 299 | -------------------------------------------------------------------------------- /lib/kalman.py: -------------------------------------------------------------------------------- 1 | #Kalman Filter MPU6050 2 | class KalmanAngle: 3 | def __init__(self): 4 | self.QAngle = 0.001 5 | self.QBias = 0.003 6 | self.RMeasure = 0.03 7 | self.angle = 0.0 8 | self.bias = 0.0 9 | self.rate = 0.0 10 | self.P=[[0.0,0.0],[0.0,0.0]] 11 | 12 | '''def kalman(): 13 | QAngle = 0.001 14 | QBias = 0.003 15 | RMeasure = 0.03 16 | 17 | angle = 0.0 18 | bias = 0.0 19 | 20 | P[0][0] = 0.0 21 | P[0][1] = 0.0 22 | P[1][0] = 0.0 23 | P[1][1] = 0.0''' 24 | 25 | def getAngle(self,newAngle, newRate,dt): 26 | #step 1: 27 | self.rate = newRate - self.bias; #new_rate is the latest Gyro measurement 28 | self.angle += dt * self.rate; 29 | 30 | #Step 2: 31 | self.P[0][0] += dt * (dt*self.P[1][1] -self.P[0][1] - self.P[1][0] + self.QAngle) 32 | self.P[0][1] -= dt * self.P[1][1] 33 | self.P[1][0] -= dt * self.P[1][1] 34 | self.P[1][1] += self.QBias * dt 35 | 36 | #Step 3: Innovation 37 | y = newAngle - self.angle 38 | 39 | #Step 4: Innovation covariance 40 | s = self.P[0][0] + self.RMeasure 41 | 42 | #Step 5: Kalman Gain 43 | K=[0.0,0.0] 44 | K[0] = self.P[0][0]/s 45 | K[1] = self.P[1][0]/s 46 | 47 | #Step 6: Update the Angle 48 | self.angle += K[0] * y 49 | self.bias += K[1] * y 50 | 51 | #Step 7: Calculate estimation error covariance - Update the error covariance 52 | P00Temp = self.P[0][0] 53 | P01Temp = self.P[0][1] 54 | 55 | self.P[0][0] -= K[0] * P00Temp; 56 | self.P[0][1] -= K[0] * P01Temp; 57 | self.P[1][0] -= K[1] * P00Temp; 58 | self.P[1][1] -= K[1] * P01Temp; 59 | 60 | return self.angle 61 | 62 | def setAngle(self,angle): 63 | self.angle = angle 64 | 65 | def setQAngle(self,QAngle): 66 | self.QAngle = QAngle 67 | 68 | def setQBias(self,QBias): 69 | self.QBias = QBias 70 | 71 | def setRMeasure(self,RMeasure): 72 | self.RMeasure = RMeasure 73 | 74 | def getRate(self): 75 | return self.rate 76 | 77 | def getQAngle(self): 78 | return self.QAngle 79 | 80 | def getQBias(self): 81 | return self.QBias 82 | 83 | def getRMeasure(self): 84 | return self.RMeasure -------------------------------------------------------------------------------- /lib/logging.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | CRITICAL = 50 4 | ERROR = 40 5 | WARNING = 30 6 | INFO = 20 7 | DEBUG = 10 8 | NOTSET = 0 9 | 10 | _level_dict = { 11 | CRITICAL: "CRIT", 12 | ERROR: "ERROR", 13 | WARNING: "WARN", 14 | INFO: "INFO", 15 | DEBUG: "DEBUG", 16 | } 17 | 18 | _stream = sys.stderr 19 | 20 | class Logger: 21 | 22 | level = NOTSET 23 | 24 | def __init__(self, name): 25 | self.name = name 26 | 27 | def _level_str(self, level): 28 | l = _level_dict.get(level) 29 | if l is not None: 30 | return l 31 | return "LVL%s" % level 32 | 33 | def setLevel(self, level): 34 | self.level = level 35 | 36 | def isEnabledFor(self, level): 37 | return level >= (self.level or _level) 38 | 39 | def log(self, level, msg, *args): 40 | if level >= (self.level or _level): 41 | _stream.write("%s:%s:" % (self._level_str(level), self.name)) 42 | if not args: 43 | print(msg, file=_stream) 44 | else: 45 | print(msg % args, file=_stream) 46 | 47 | def debug(self, msg, *args): 48 | self.log(DEBUG, msg, *args) 49 | 50 | def info(self, msg, *args): 51 | self.log(INFO, msg, *args) 52 | 53 | def warning(self, msg, *args): 54 | self.log(WARNING, msg, *args) 55 | 56 | def error(self, msg, *args): 57 | self.log(ERROR, msg, *args) 58 | 59 | def critical(self, msg, *args): 60 | self.log(CRITICAL, msg, *args) 61 | 62 | def exc(self, e, msg, *args): 63 | self.log(ERROR, msg, *args) 64 | sys.print_exception(e, _stream) 65 | 66 | def exception(self, msg, *args): 67 | self.exc(sys.exc_info()[1], msg, *args) 68 | 69 | 70 | _level = INFO 71 | _loggers = {} 72 | 73 | def getLogger(name): 74 | if name in _loggers: 75 | return _loggers[name] 76 | l = Logger(name) 77 | _loggers[name] = l 78 | return l 79 | 80 | def info(msg, *args): 81 | getLogger(None).info(msg, *args) 82 | 83 | def debug(msg, *args): 84 | getLogger(None).debug(msg, *args) 85 | 86 | def basicConfig(level=INFO, filename=None, stream=None, format=None): 87 | global _level, _stream 88 | _level = level 89 | if stream: 90 | _stream = stream 91 | if filename is not None: 92 | print("logging.basicConfig: filename arg is not supported") 93 | if format is not None: 94 | print("logging.basicConfig: format arg is not supported") 95 | -------------------------------------------------------------------------------- /lib/nanoweb.py: -------------------------------------------------------------------------------- 1 | import uasyncio as asyncio 2 | import uerrno 3 | 4 | 5 | class HttpError(Exception): 6 | pass 7 | 8 | 9 | class Request: 10 | url = "" 11 | method = "" 12 | headers = {} 13 | route = "" 14 | read = None 15 | write = None 16 | close = None 17 | args = None 18 | param = None 19 | 20 | 21 | def __init__(self): 22 | self.url = "" 23 | self.method = "" 24 | self.headers = {} 25 | self.route = "" 26 | self.read = None 27 | self.write = None 28 | self.close = None 29 | 30 | 31 | async def write(request, data): 32 | await request.write( 33 | data.encode('ISO-8859-1') if type(data) == str else data 34 | ) 35 | 36 | 37 | async def error(request, code, reason): 38 | await request.write("HTTP/1.1 %s %s\r\n\r\n" % (code, reason)) 39 | await request.write("

%s

" % (reason)) 40 | 41 | 42 | async def send_file(request, filename, segment=64, binary=False): 43 | #print("send_file:", filename, segment, binary) 44 | try: 45 | with open(filename, 'rb' if binary else 'r') as f: 46 | while True: 47 | data = f.read(segment) 48 | if not data: 49 | break 50 | await request.write(data) 51 | except OSError as e: 52 | if e.args[0] != uerrno.ENOENT: 53 | raise 54 | raise HttpError(request, 404, "File Not Found") 55 | 56 | 57 | class Nanoweb: 58 | 59 | extract_headers = ('Authorization', 'Content-Length', 'Content-Type', 'Content-Disposition', 'User-Agent') 60 | headers = {} 61 | 62 | routes = {} 63 | assets_extensions = ('html', 'css', 'js') 64 | 65 | callback_request = None 66 | callback_error = staticmethod(error) 67 | 68 | STATIC_DIR = '/' 69 | INDEX_FILE = STATIC_DIR + 'index.html' 70 | debug = False 71 | 72 | def __init__(self, port=80, address='0.0.0.0', debug=False, dir='/'): 73 | self.port = port 74 | self.address = address 75 | self.debug = debug 76 | self.STATIC_DIR = dir 77 | 78 | def route(self, route): 79 | """Route decorator""" 80 | def decorator(func): 81 | self.routes[route] = func 82 | return func 83 | return decorator 84 | 85 | async def generate_output(self, request, handler): 86 | """Generate output from handler 87 | 88 | `handler` can be : 89 | * dict representing the template context 90 | * string, considered as a path to a file 91 | * tuple where the first item is filename and the second 92 | is the template context 93 | * callable, the output of which is sent to the client 94 | """ 95 | while True: 96 | if self.debug: print("handler: ", type(handler)) 97 | if isinstance(handler, dict): 98 | if self.debug: print("DICT-Handler: ", request.url, str(handler)) 99 | handler = (request.url, handler) 100 | 101 | if isinstance(handler, str): 102 | await write(request, "HTTP/1.1 200 OK\r\n\r\n") 103 | await send_file(request, handler) 104 | elif isinstance(handler, tuple): 105 | await write(request, "HTTP/1.1 200 OK\r\n\r\n") 106 | filename, context = handler 107 | context = context() if callable(context) else context 108 | try: 109 | with open(filename, "r") as f: 110 | for l in f: 111 | await write(request, l.format(**context)) 112 | except OSError as e: 113 | if e.args[0] != uerrno.ENOENT: 114 | raise 115 | raise HttpError(request, 404, "File Not Found") 116 | else: 117 | handler = await handler(request) 118 | if handler: 119 | # handler can returns data that can be fed back 120 | # to the input of the function 121 | continue 122 | break 123 | 124 | async def handle(self, reader, writer): 125 | items = await reader.readline() 126 | items = items.decode('ascii').split() 127 | if len(items) != 3: 128 | return 129 | 130 | request = Request() 131 | request.read = reader.read 132 | request.write = writer.awrite 133 | request.close = writer.aclose 134 | 135 | request.method, request.url, version = items 136 | 137 | if self.debug: print("Method: ", request.method) 138 | if self.debug: print("URL: ",request.url) 139 | if self.debug: print("Version: ",version) 140 | if request.url.find("?") > -1: 141 | request.url, a = request.url.split("?") 142 | if a != "": 143 | if self.debug: print("PARAM:", a) 144 | request.param = {} 145 | a = a.split("&") 146 | for i in a: 147 | if self.debug: print(i) 148 | q = i.split("=", 1) 149 | if self.debug: print(q) 150 | request.param.update({q[0]: q[1].replace('%2F','/')}) 151 | 152 | 153 | try: 154 | try: 155 | if version not in ("HTTP/1.0", "HTTP/1.1"): 156 | raise HttpError(request, 505, "Version Not Supported") 157 | 158 | while True: 159 | items = await reader.readline() 160 | items = items.decode('ascii').split(":", 1) 161 | if len(items) == 2: 162 | header, value = items 163 | value = value.strip() 164 | 165 | if header in self.extract_headers: 166 | request.headers[header] = value 167 | elif len(items) == 1: 168 | break 169 | 170 | if self.debug: print("Header: ", request.headers) 171 | if request.method == 'POST' and request.headers['Content-Type'] == 'application/x-www-form-urlencoded': 172 | b = int(request.headers['Content-Length']) 173 | a = await request.read(b) 174 | print(a) 175 | a = a.decode('ascii').split("&") 176 | if self.debug: print("POST-Section: args: ", a) 177 | request.args = {} 178 | for i in a: 179 | q = i.split("=", 1) 180 | request.args.update({q[0]: q[1]}) 181 | if self.debug: print("POST: ", request.args) 182 | 183 | if self.callback_request: 184 | self.callback_request(request) 185 | 186 | if self.debug: print("WorkingURL: ", request.url) 187 | if request.url in self.routes: 188 | # 1. If current url exists in routes 189 | if self.debug: print("1. URL in routes: "+request.url) 190 | request.route = request.url 191 | await self.generate_output(request, 192 | self.routes[request.url]) 193 | else: 194 | # 2. Search url in routes with wildcard 195 | for route, handler in self.routes.items(): 196 | # print(route, handler) 197 | if route == request.url \ 198 | or (route[-1] == '*' and 199 | request.url.startswith(route[:-1])): 200 | if self.debug: print("2. URL ww: "+request.url) 201 | request.route = route 202 | await self.generate_output(request, handler) 203 | break 204 | else: 205 | # 3. Try to load index file 206 | if request.url in ('', '/'): 207 | if self.debug: print("3. Indexfile: "+request.url) 208 | await send_file(request, self.INDEX_FILE) 209 | else: 210 | if self.debug: print("4. Step: "+request.url) 211 | # 4. Current url have an assets extension ? 212 | for extension in self.assets_extensions: 213 | if request.url.endswith('.' + extension): 214 | await send_file( 215 | request, 216 | '%s%s' % ( 217 | self.STATIC_DIR, 218 | request.url, 219 | ), 220 | binary=True, 221 | ) 222 | break 223 | else: 224 | raise HttpError(request, 404, "File Not Found") 225 | except HttpError as e: 226 | request, code, message = e.args 227 | await self.callback_error(request, code, message) 228 | except OSError as e: 229 | # Skip ECONNRESET error (client abort request) 230 | if e.args[0] != uerrno.ECONNRESET: 231 | raise 232 | finally: 233 | await writer.aclose() 234 | 235 | async def run(self): 236 | return await asyncio.start_server(self.handle, self.address, self.port) 237 | -------------------------------------------------------------------------------- /lib/web_os.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2022 Dr. Magnus Christ (mc0110) 4 | # 5 | # This is part of the wifimanager package 6 | # 7 | # 8 | import logging 9 | import os 10 | from machine import soft_reset, reset 11 | from gen_html import Gen_Html 12 | from nanoweb import HttpError, Nanoweb, send_file 13 | import uasyncio as asyncio 14 | import gc 15 | import random 16 | 17 | 18 | log = logging.getLogger(__name__) 19 | naw = Nanoweb(100) 20 | # client = MQTTClient(config) 21 | test_mqtt = False 22 | 23 | def init(w, l, n, debug=False, logfile=False): 24 | if debug: 25 | log.setLevel(logging.DEBUG) 26 | else: 27 | log.setLevel(logging.INFO) 28 | log.info("init") 29 | log.debug(f"init debug:{debug} logf:{logfile} l:{l} n:{n}") 30 | global gh 31 | global reboot 32 | global soft_reboot 33 | global repo_update 34 | global repo_success 35 | global repo_update_comment 36 | global naw 37 | global file 38 | global lin 39 | file = logfile 40 | lin = l 41 | naw = n 42 | gc.enable() 43 | gh = Gen_Html(w, lin) 44 | reboot = False 45 | soft_reboot = False 46 | global connect 47 | connect = w 48 | 49 | def unquote(s): 50 | s = s.replace("+"," ") 51 | if '%' not in s: 52 | return s 53 | s = s.split("%") 54 | # print(s) 55 | a = s[0].encode("utf-8") 56 | for i in s[1:]: 57 | # print(bytearray.fromhex(i[:2])) 58 | # print(i[2:],i[2:].encode("utf-8")) 59 | a = a + bytearray.fromhex(i[:2]) + i[2:].encode("utf-8") 60 | return a.decode("utf-8") 61 | 62 | 63 | async def command_loop(): 64 | global reboot 65 | global soft_reboot 66 | global connect 67 | global file 68 | while True: 69 | if file: 70 | logging._stream.flush() 71 | log.debug("stream_flush") 72 | 73 | # logging._stream = open("test.log", "a") 74 | await asyncio.sleep(3) # Update every 10sec 75 | if soft_reboot: 76 | log.debug("soft_reboot") 77 | await asyncio.sleep(5) # Update every 10sec 78 | log.info("Soft reset chip") 79 | soft_reset() 80 | if reboot: 81 | log.debug("reboot") 82 | await asyncio.sleep(5) # Update every 10sec 83 | log.info("Reset chip") 84 | reset() 85 | 86 | # Declare route directly with decorator 87 | @naw.route('/') 88 | async def index(r): 89 | global gh 90 | global repo_update 91 | gc.collect() 92 | repo_update = False 93 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 94 | await send_file(r, gh.handleRoot()) 95 | # await r.write(gh.handleRoot()) 96 | 97 | @naw.route('/s') 98 | async def status(r): 99 | global gh 100 | global repo_update 101 | gh.refresh_connect_state() 102 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 103 | await send_file(r, gh.handleStatus("Device status", "/", "Back",("30","/"))) 104 | # await r.write(gh.handleStatus("Device status", "/", "Back",("30","/"))) 105 | 106 | @naw.route('/loop') 107 | async def loop(r): 108 | pass 109 | 110 | @naw.route('/ta') 111 | async def toggle_ap(r): 112 | global gh 113 | if not(gh.connect.set_mqtt()): 114 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 115 | await r.write(gh.handleMessage("You couldn't release both (AP, STA), then you loose the connection to the port", "/", "Back",("2","/"))) 116 | else: 117 | gh.connect.set_ap(not(gh.connect.set_ap())) 118 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 119 | await r.write(gh.handleRoot()) 120 | 121 | # @naw.route('/ts1') 122 | # async def set_mqtt(r): 123 | # await r.write("HTTP/1.1 200 OK\r\n\r\n") 124 | # global gh 125 | # # while test_mqtt: 126 | # # await asyncio.sleep(5) 127 | # if gh.connect.set_mqtt(): 128 | # await r.write(gh.handleMessage("MQTT-connection established successfull", "/", "Cancel",("5","/"))) 129 | # else: 130 | # await r.write(gh.handleMessage("Try again to establish a MQTT-connection", "/", "Cancel",("5","/ts1"))) 131 | 132 | @naw.route('/ts') 133 | async def set_mqtt(r): 134 | global gh 135 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 136 | if not(gh.connect.creds()): 137 | await r.write(gh.handleMessage("Sorry, you need credentials", "/", "Back",("5","/"))) 138 | else: 139 | s = str(random.randint(0,255)) 140 | await connect.client.publish("service/truma/set/test", s, qos=1) 141 | log.info(f"mqtt: message sent to {s}") 142 | await r.write(gh.handleMessage(f"send 'service/truma/set/test'> {s}", "/", "Back",("5","/"))) 143 | 144 | 145 | @naw.route('/rm') 146 | async def toggle_run_mode(r): 147 | global gh 148 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 149 | if not(gh.connect.creds()): 150 | await r.write(gh.handleMessage("You couldn't switch run-mode without credentials", "/", "Back",("5","/"))) 151 | else: 152 | a = gh.connect.run_mode() 153 | if a < 2: a = 1 - a 154 | else: a=0 155 | gh.connect.run_mode(a) 156 | await r.write(gh.handleMessage("RUN mode changed", "/", "Back",("5","/"))) 157 | 158 | @naw.route('/wc') 159 | # Generate the credential form 160 | async def creds(r): 161 | global gh 162 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 163 | await send_file(r, gh.handleCredentials(gh.JSON)) 164 | 165 | 166 | @naw.route('/scan') 167 | async def scan_networks(r): 168 | global gh 169 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 170 | if gh.connect.set_mqtt(): 171 | await r.write(gh.handleScan_Networks()) 172 | else: 173 | await r.write(gh.handleMessage("This needs STA-mode", "/", "Back",("5","/"))) 174 | 175 | @naw.route('/heat_on') 176 | async def wheater_on(r): 177 | global gh 178 | global lin 179 | lin.app.set_status("target_temp_water", "200") 180 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 181 | await r.write(gh.handleMessage("Send Message: Water heater -> BOOST", "/", "Back",("5","/"))) 182 | 183 | @naw.route('/heat_off') 184 | async def wheater_off(r): 185 | global gh 186 | global lin 187 | lin.app.set_status("target_temp_water", "0") 188 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 189 | await r.write(gh.handleMessage("Send Message: Water heater -> OFF", "/", "Back",("5","/"))) 190 | 191 | 192 | @naw.route('/cp') 193 | async def cp(r): 194 | global gh 195 | json = {} 196 | # convert JSON to json_result = {key: value} 197 | for i in gh.JSON.keys(): 198 | json[i] = "0" 199 | for i in r.args.keys(): 200 | if r.args[i]=="True": 201 | json[i] = "1" 202 | else: 203 | json[i] = unquote(r.args[i]) 204 | gh.connect.store_creds(json) 205 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 206 | await r.write(gh.handleMessage("Credentials are written", "/", "Back",("5","/"))) 207 | 208 | 209 | @naw.route('/dc') 210 | async def del_cred(r): 211 | global gh 212 | gh.connect.delete_creds() 213 | gh.connect.run_mode(0) 214 | log.debug("Credentials moved to bak") 215 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 216 | await r.write(gh.handleMessage("Credentials are deleted", "/", "Back",("5","/wc"))) 217 | 218 | 219 | @naw.route('/sc') 220 | async def swp_cred(r): 221 | global gh 222 | gh.connect.swap_creds() 223 | log.debug("Credentials swapped") 224 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 225 | await r.write(gh.handleMessage("Credentials are swapped", "/", "Back",("5","/wc"))) 226 | 227 | @naw.route('/rc') 228 | async def res_cred(r): 229 | global gh 230 | gh.connect.restore_creds() 231 | log.debug("Credentials restored") 232 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 233 | await r.write(gh.handleMessage("Credentials are restored", "/", "Back",("5","/"))) 234 | 235 | @naw.route('/ur') 236 | async def ur(r): 237 | global gh 238 | if gh.connect.set_mqtt(): 239 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 240 | await r.write(gh.handleMessage("For repo-update press 'UPDATE'", "/ur1", "UPDATE",("5","/"))) 241 | else: 242 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 243 | await r.write(gh.handleMessage("You need a STA-internet-connection", "/", "Back",("5","/"))) 244 | 245 | @naw.route('/ur1') 246 | async def ur1(r): 247 | global gh 248 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 249 | log.debug("Set update mode") 250 | gh.connect.run_mode(2) 251 | global reboot 252 | reboot = True 253 | await r.write(gh.handleMessage("Repo update initiated, port is rebooting ..", "/", "Back",("5","/"))) 254 | 255 | @naw.route('/rb') 256 | async def s_reboot(r): 257 | global soft_reboot 258 | soft_reboot = True 259 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 260 | await r.write(gh.handleMessage("Device will be soft rebooted", "/", "Continue",("4","/"))) 261 | 262 | @naw.route('/rb2') 263 | async def h_reboot(r): 264 | global reboot 265 | reboot = True 266 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 267 | await r.write(gh.handleMessage("Device resetted", "/", "Continue",("4","/"))) 268 | 269 | @naw.route('/rb1') 270 | async def h_reboot(r): 271 | await r.write("HTTP/1.1 200 OK\r\n\r\n") 272 | await r.write(gh.handleMessage("Device will be hard rebooted", "/rb2", "Continue to reboot",("4","/"))) 273 | 274 | @naw.route('/upload*') 275 | async def upload(r): 276 | global gh 277 | dir = r.url[7:] 278 | print("upload-section: "+dir) 279 | if dir == "": dir = "/" 280 | if r.method == "POST": 281 | if "__" in dir: 282 | dir = "/" 283 | else: 284 | dir = "/" + dir.strip("/") + "/" 285 | # obtain the filename and size from request headers 286 | filename = unquote(r.headers['Content-Disposition'].split('filename=')[1].strip('"')) 287 | size = int(r.headers['Content-Length']) 288 | print("dir: "+dir+" fn: "+filename) 289 | # sanitize the filename 290 | # write the file to the files directory in 1K chunks 291 | with open(dir + filename, 'wb') as f: 292 | while size > 0: 293 | chunk = await r.read(min(size, 1024)) 294 | f.write(chunk) 295 | size -= len(chunk) 296 | f.close() 297 | log.info('Successfully saved file: ' + dir + filename) 298 | await r.write("HTTP/1.1 201 Upload \r\n" ) 299 | # await send_file(r, gh.handleFiles(dir)) 300 | else: 301 | await r.write("HTTP/1.1 200 OK\r\n") 302 | await send_file(r, gh.handleFiles(dir)) 303 | 304 | @naw.route('/fm*') 305 | async def fm(r): 306 | global gh 307 | filename = unquote(r.param["fn"]) 308 | direct = unquote(r.param["dir"]) 309 | 310 | if r.param["button"]=="Delete": 311 | log.info("delete file: " + direct+filename) 312 | try: 313 | os.remove(direct+filename) 314 | except OSError as e: 315 | raise HttpError(r, 500, "Internal error") 316 | rp = gh.handleFiles(direct) 317 | await r.write("HTTP/1.1 200 OK\r\n") 318 | await send_file(r, rp) 319 | elif r.param["button"]=="Download": 320 | log.info("download file: " + filename) 321 | await r.write("HTTP/1.1 200 OK\r\n") 322 | await r.write("Content-Type: application/octet-stream\r\n") 323 | await r.write("Content-Disposition: attachment; filename=%s\r\n\r\n" % filename) 324 | await send_file(r, direct+filename) 325 | 326 | 327 | @naw.route('/dir*') 328 | async def set_dir(r): 329 | global gh 330 | new_dir = r.url[5:] 331 | if new_dir.startswith("__"): 332 | await r.write("HTTP/1.1 200 OK\r\n") 333 | await send_file(r, gh.handleFiles("/")) 334 | else: 335 | await r.write("HTTP/1.1 200 OK\r\n") 336 | await send_file(r, gh.handleFiles(new_dir)) 337 | 338 | -------------------------------------------------------------------------------- /lib/web_os_main.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2022 Dr. Magnus Christ (mc0110) 4 | # 5 | # This is part of the wifimanager package 6 | # 7 | # This snippet should you include in your software project to use the wifi manager 8 | 9 | import logging 10 | import web_os as os 11 | from nanoweb import Nanoweb 12 | import uasyncio as asyncio 13 | from machine import UART, Pin 14 | from lin import Lin 15 | 16 | 17 | 18 | log = logging.getLogger(__name__) 19 | connect = None 20 | 21 | 22 | # major ctrl loop for inetbox-communication 23 | async def lin_loop(): 24 | global lin 25 | await asyncio.sleep(1) # Delay at begin 26 | log.info("lin-loop is running") 27 | while True: 28 | await lin.loop_serial() 29 | if not(lin.stop_async): # full performance to send buffer 30 | await asyncio.sleep_ms(1) 31 | 32 | # async def mqtt_loop(): 33 | # global connect 34 | # log.info(f"mqtt_loop:") 35 | # connect.set_mqtt(1) 36 | # log.info(f"await loop_mqtt") 37 | # await connect.loop_mqtt() 38 | 39 | 40 | def run(w, lin_debug, inet_debug, webos_debug, naw_debug, logfile): 41 | global lin 42 | global connect 43 | connect = w 44 | if naw_debug: 45 | log.setLevel(logging.DEBUG) 46 | naw = Nanoweb(80, debug = True) 47 | else: 48 | log.setLevel(logging.INFO) 49 | naw = Nanoweb(80) 50 | 51 | # connect.set_proc(subscript = connect.callback, connect = connect.conn_callback) 52 | 53 | log.info(f"run lin:{lin_debug} inet:{inet_debug} webos:{webos_debug} naw:{naw_debug} file:{logfile}") 54 | # debug=True for debugging 55 | 56 | # hw-specific configuration 57 | log.info(f"run uart:{w.p.get_data('lin_uart')} tx:{w.p.get_data('lin_tx')} rx:{w.p.get_data('lin_rx')}") 58 | log.info(f"HW-Check {w.platform_name}") 59 | if (w.platform=="rp2"): 60 | serial = UART(w.p.get_data("lin_uart"), baudrate=9600, bits=8, parity=None, stop=1, timeout=3, rx=Pin(w.p.get_data("lin_rx")), tx=Pin(w.p.get_data("lin_tx"))) # this is the HW-UART-no 2 61 | if (w.platform=="esp32"): 62 | serial = UART(w.p.get_data("lin_uart"), baudrate=9600, bits=8, parity=None, stop=1, timeout=3, rx=w.p.get_data("lin_rx"), tx=w.p.get_data("lin_tx")) # this is the HW-UART-no 2 63 | 64 | # if (w.platform == "esp32"): 65 | # 66 | # log.info("Found ESP32 Board, using UART2 for LIN on GPIO 16(rx), 17(tx)") 67 | # # ESP32-specific hw-UART (#2) 68 | # serial = UART(2, baudrate=9600, bits=8, parity=None, stop=1, timeout=3) # this is the HW-UART-no 2 69 | # elif (w.platform == "rp2"): 70 | # # RP2 pico w -specific hw-UART (#2) 71 | # log.info("Found Raspberry Pico Board, using UART1 for LIN on GPIO 4(tx), 5(rx)") 72 | # serial = UART(1, baudrate=9600, tx=Pin(4), rx=Pin(5), timeout=3) # this is the HW-UART1 in RP2 pico w 73 | # else: 74 | # log.debug("No compatible Board found!") 75 | 76 | # Initialize the lin-object 77 | lin = Lin(serial, w.p, lin_debug, inet_debug) 78 | os.init(w, lin, naw, webos_debug, logfile) 79 | 80 | naw.STATIC_DIR = "/" 81 | 82 | # connect.config.set_last_will("service/truma/control_status/alive", "OFF", retain=True, qos=0) # last will is important 83 | # connect.set_proc(subscript = callback, connect = conn_callback) 84 | 85 | 86 | loop = asyncio.get_event_loop() 87 | log.info("Start nanoweb server") 88 | loop.create_task(naw.run()) 89 | loop.create_task(lin_loop()) 90 | # loop.create_task(mqtt_loop()) 91 | log.info("Start OS command loop") 92 | loop.create_task(os.command_loop()) 93 | loop.run_forever() 94 | -------------------------------------------------------------------------------- /src/args.dat: -------------------------------------------------------------------------------- 1 | hw=RP2 -------------------------------------------------------------------------------- /src/args.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2023 Dr. Magnus Christ (mc0110) 4 | # 5 | # This is part of the inetbox2mqtt package 6 | # 7 | # Simulate a persistant args-parameter string 8 | # Usable as generator 9 | 10 | import os 11 | import logging 12 | 13 | 14 | class Args(): 15 | 16 | __ARGS = "args.dat" 17 | 18 | def __init__(self, fn = None): 19 | self.log = logging.getLogger(__name__) 20 | self.log.setLevel(logging.INFO) 21 | 22 | self.__arg = "" 23 | if fn != None: 24 | self.fn = fn 25 | else: 26 | self.fn = self.__ARGS 27 | if self.fn in os.listdir("/"): 28 | self.log.info("args_file found -> loaded") 29 | self.load() 30 | 31 | def reset(self): 32 | self.__arg = "" 33 | os.remove(self.fn) 34 | 35 | def load(self): 36 | if self.fn in os.listdir("/"): 37 | with open(self.fn, "r") as f: 38 | self.__arg = f.read() 39 | self.log.info(f"file: {self.fn} content: {self.__arg}") 40 | 41 | def store(self, s): 42 | with open(self.fn, "w") as f: 43 | f.write(s) 44 | self.__arg = s 45 | 46 | def check(self, s): 47 | return s in self.__arg 48 | 49 | def get(self): 50 | a = self.__arg.split() 51 | while a != []: 52 | q = a.pop(0) 53 | yield q 54 | 55 | def get_key(self, key): 56 | for i in self.get(): 57 | q = i.split("=") 58 | if q[0]==key: 59 | return q[1] 60 | return None 61 | -------------------------------------------------------------------------------- /src/boot.py: -------------------------------------------------------------------------------- 1 | # This file is executed on every boot (including wake-boot from deepsleep) 2 | #import esp 3 | #esp.osdebug(None) 4 | #import webrepl 5 | #webrepl.start() 6 | -------------------------------------------------------------------------------- /src/conversions.py: -------------------------------------------------------------------------------- 1 | # from decimal import Decimal 2 | 3 | def bool_to_int(value): 4 | return int(value) 5 | 6 | def int_to_bool(value): 7 | return bool(value) 8 | 9 | # convert two-byte representation of temperature to a Decimal 10 | def temp_code_to_decimal(bytestring) -> str: 11 | if bytestring == 0xAAA or bytestring == 0xAAAA or bytestring == 0x0000: 12 | return "0" 13 | # return str((Decimal(bytestring) / Decimal(10) - Decimal(273)).quantize(Decimal("0.1"))) 14 | return str(round((bytestring / 10.0 - 273.), 1)) 15 | 16 | 17 | # convert two-byte representation of temperature to a str 18 | def temp_code_to_string(bytestring) -> str: 19 | return str(temp_code_to_decimal(bytestring)) 20 | 21 | # inverse function of the above, observe exception for None 22 | def decimal_to_temp_code(decimal) -> int: 23 | if (decimal is None) or (decimal < 5): 24 | # return 0xAAA 25 | return 0x00 26 | return int((decimal + 273.0) * 10.0) 27 | 28 | def string_to_temp_code(string): 29 | return decimal_to_temp_code(float(string)) 30 | 31 | # error status is 1 if a warning exists, rest of values unknown yet 32 | def operating_status_to_string(operating_status): 33 | if operating_status == 0: 34 | return "Off" 35 | elif operating_status == 1: 36 | return "WARNING" 37 | elif operating_status == 4: 38 | return "start/cool down" 39 | elif operating_status == 5: 40 | return "On(5)" 41 | elif operating_status == 6: 42 | return "On(6)" 43 | elif operating_status == 7: 44 | return "On(7)" 45 | else: 46 | return f"On({operating_status})" 47 | 48 | # error code is two bytes, first byte * 100 + second byte is the error code 49 | def error_code_to_string(error_code_bytes): 50 | error_code = int(error_code_bytes/256) * 100 + (error_code_bytes % 256) 51 | return str(error_code) 52 | 53 | # Electric heating power level is stored as a two-byte integer and has 54 | # the values 0, 900, or 1800 55 | def el_power_code_to_string(el_power_code): 56 | return str(el_power_code) 57 | 58 | # inverse of the above 59 | def string_to_el_power_code(string): 60 | code = int(string) 61 | if code == 0 or code == 900 or code == 1800: 62 | return code 63 | else: 64 | raise ValueError(f"Invalid electric heating power code: {code}") 65 | 66 | # energy mix is stored in a byte, with the lowest bit indicating 67 | # whether gas is used and the second lowest bit indicating whether 68 | # electricity is used 69 | energy_mix_mapping = { 70 | 0b00: "none", 71 | 0b01: "gas", 72 | 0b10: "electricity", 73 | 0b11: "mix", 74 | } 75 | 76 | def energy_mix_code_to_string(energy_mix_code): 77 | return energy_mix_mapping[energy_mix_code] 78 | 79 | # inverse of the above 80 | def string_to_energy_mix_code(string): 81 | for code, name in energy_mix_mapping.items(): 82 | if name == string: 83 | return code 84 | raise ValueError(f"Invalid energy mix code: {string}") 85 | 86 | def heating_mode_to_string(heating_mode): 87 | if heating_mode == 0: 88 | return "off" 89 | elif heating_mode == 1: 90 | return "eco" 91 | elif heating_mode == 10: 92 | return "high" 93 | else: 94 | return f"UNKNOWN ({heating_mode})" 95 | 96 | # inverse of the above 97 | def string_to_heating_mode(string): 98 | if string == "off": 99 | return 0 100 | elif string == "eco": 101 | return 1 102 | elif string == "high": 103 | return 10 104 | else: 105 | raise ValueError(f"Invalid heating mode: {string}") 106 | 107 | def aircon_vent_mode_to_string(aircon_vent_mode): 108 | if aircon_vent_mode == 113: 109 | return "low" 110 | elif aircon_vent_mode == 114: 111 | return "mid" 112 | elif aircon_vent_mode == 115: 113 | return "high" 114 | elif aircon_vent_mode == 116: 115 | return "night" 116 | elif aircon_vent_mode == 119: 117 | return "auto" 118 | else: 119 | return f"UNKNOWN ({aircon_vent_mode})" 120 | 121 | # inverse of the above 122 | def string_to_aircon_vent_mode(string): 123 | if string == "low": 124 | return 113 125 | elif string == "mid": 126 | return 114 127 | elif string == "high": 128 | return 115 129 | elif string == "night": 130 | return 116 131 | elif string == "auto": 132 | return 119 133 | else: 134 | raise ValueError(f"Invalid heating mode: {string}") 135 | 136 | def aircon_operating_mode_to_string(aircon_operating_mode): 137 | if aircon_operating_mode == 0: 138 | return "off" 139 | elif aircon_operating_mode == 4: 140 | return "vent" 141 | elif aircon_operating_mode == 5: 142 | return "cool" 143 | elif aircon_operating_mode == 6: 144 | return "hot" 145 | elif aircon_operating_mode == 7: 146 | return "auto" 147 | else: 148 | return f"UNKNOWN ({aircon_operating_mode})" 149 | 150 | # inverse of the above 151 | def string_to_aircon_operating_mode(string): 152 | if string == "off": 153 | return 0 154 | elif string == "vent": 155 | return 4 156 | elif string == "cool": 157 | return 5 158 | elif string == "hot": 159 | return 6 160 | elif string == "auto": 161 | return 7 162 | else: 163 | raise ValueError(f"Invalid aircon operating mode: {string}") 164 | 165 | def clock_to_string(clock): 166 | m = int(clock / 256) 167 | h = int(clock - (m * 256)) 168 | return f"{h:02}:{m:02}" 169 | 170 | -------------------------------------------------------------------------------- /src/cred.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2022 Dr. Magnus Christ (mc0110) 4 | # 5 | # This is part of the wifimanager package 6 | # 7 | # 8 | # For the proper functioning of the connect-library, the keys "SSID", "WIFIPW", "HOSTNAME" should be included. 9 | # Any other keys can be added 10 | 11 | # 12 | # def set_cred_json(): 13 | # import json 14 | # CRED_JSON = "cred.json" 15 | # 16 | # j = { 17 | # "SSID": ["text", "SSID:", "1"], 18 | # "WIFIPW": ["password", "Wifi passcode:", "2"], 19 | # "MQTT": ["text", "Broker name/IP:", "3"], 20 | # "UN": ["text", "Broker User:", "4"], 21 | # "UPW": ["text", "Broker password:", "5"], 22 | # "HOSTNAME": ["text", "Hostname:", "6"], 23 | # "ADC": ["checkbox", "Addon DuoControl :", "7"], 24 | # "ASL": ["checkbox", "Addon SpiritLevel:", "8"], 25 | # # "OSR": ["checkbox", "OS Web:", "9"], 26 | # } 27 | # with open(CRED_JSON, "w") as f: json.dump(j, f) 28 | # 29 | 30 | def update_repo(): 31 | import mip, os 32 | #sleep to give some boards time to initialize, for example Rpi Pico W 33 | 34 | # bootloader for the whole suite 35 | tree = "github:mc0110/inetbox2mqtt" 36 | 37 | env = [ 38 | ["/src/", "args.py", "/"], 39 | ["/src/", "vector.py", "/"], 40 | ["/src/", "spiritlevel.py", "/"], 41 | ["/src/", "duocontrol.py", "/"], 42 | ["/src/", "imu.py", "/"], 43 | ["/lib/", "gen_html.py", "/lib"], 44 | ["/lib/", "kalman.py", "/lib"], 45 | ["/lib/", "web_os.py", "/lib"], 46 | ["/lib/", "web_os_main.py", "/lib"], 47 | 48 | ["/src/", "tools.py", "/"], 49 | ["/src/", "conversions.py", "/"], 50 | ["/src/", "lin.py", "/"], 51 | ["/src/", "inetboxapp.py", "/"], 52 | ["/src/", "main.py", "/"], 53 | ["/src/", "main1.py", "/"], 54 | ["/lib/", "connect.py", "/lib"], 55 | ["/src/", "update.py", "/"], 56 | ] 57 | 58 | 59 | for i in range(len(env)): 60 | errno = 1 61 | while errno and errno<3: 62 | # try: 63 | # try: 64 | # os.remove(env[i][2]+"/"+env[i][1]) 65 | # print(env[i][2]+"/"+env[i][1]+" deleted") 66 | # except: 67 | # pass 68 | mip.install(tree+env[i][0]+env[i][1], target= env[i][2]) 69 | errno = 0 70 | # except: 71 | # errno += 1 72 | s = env[i][1] 73 | st = (errno == 0) 74 | yield (s, st) 75 | 76 | def read_repo_rel(): 77 | import mip 78 | import time 79 | try: 80 | mip.install("github:mc0110/inetbox2mqtt/src/release.py", target = "/") 81 | except: 82 | import machine 83 | machine.reset() 84 | time.sleep(1) 85 | import release 86 | q = release.rel_no 87 | # print("Repo release-no: " + q) 88 | return q 89 | 90 | -------------------------------------------------------------------------------- /src/crypto_keys.py: -------------------------------------------------------------------------------- 1 | # Crypto-Class for Strings with bonding the key with the machine.unique_id 2 | # 3 | # 4 | # This modules are using the machine_unique_number for encrypt / decrypt 5 | # So the data is only on the target-system usable 6 | # based on ESP32 Micropython implementation of cryptographic 7 | # 8 | # reference: 9 | # https://pycryptodome.readthedocs.io/en/latest/src/cipher/classic.html#cbc-mode 10 | # https://docs.micropython.org/en/latest/library/ucryptolib.html 11 | 12 | import os 13 | from ucryptolib import aes 14 | import machine 15 | 16 | class crypto(): 17 | def encrypt(self, text): 18 | BLOCK_SIZE = 32 19 | IV_SIZE = 16 20 | MODE_CBC = 2 21 | # Padding plain text with space 22 | text = text.encode("utf-8") 23 | pad = BLOCK_SIZE - len(text) % BLOCK_SIZE 24 | text = text + " ".encode("utf-8")*pad 25 | key1 = machine.unique_id() 26 | key = bytearray(b'I_am_32bytes=256bits_key_padding') 27 | for i in range(len(key1)-1): 28 | key[i] = key1[i] 29 | key[len(key)-i-1] = key1[i] 30 | # Generate iv with HW random generator 31 | iv = os.urandom(IV_SIZE) 32 | cipher = aes(key, MODE_CBC, iv) 33 | ct_bytes = iv + cipher.encrypt(text) 34 | return ct_bytes 35 | 36 | # you need only one of this modules 37 | def decrypt(self, enc_bytes): 38 | BLOCK_SIZE = 32 39 | IV_SIZE = 16 40 | MODE_CBC = 2 41 | key1 = machine.unique_id() 42 | key = bytearray(b'I_am_32bytes=256bits_key_padding') 43 | for i in range(len(key1)-1): 44 | key[i] = key1[i] 45 | key[len(key)-i-1] = key1[i] 46 | # Generate iv with HW random generator 47 | iv = enc_bytes[:IV_SIZE] 48 | cipher = aes(key, MODE_CBC, iv) 49 | return cipher.decrypt(enc_bytes)[IV_SIZE:].strip().decode("utf-8") 50 | 51 | 52 | 53 | class fn_crypto(): 54 | def __init__(self): 55 | pass 56 | 57 | def fn_write_encrypt(self, f, x): 58 | cip = crypto() 59 | x = cip.encrypt(x) 60 | f.write(len(x).to_bytes(2, 'little')) 61 | f.write(x) 62 | 63 | 64 | def fn_write_eof_encrypt(self, f): 65 | x=0 66 | f.write(x.to_bytes(2, 'little')) 67 | 68 | 69 | def fn_read_decrypt(self, f): 70 | cip = crypto() 71 | x = int.from_bytes(f.read(2), "little") 72 | if x > 0: 73 | return str(cip.decrypt(f.read(x)), 'utf-8') 74 | else: return "" 75 | 76 | 77 | def fn_read_str_decrypt(self, f, x): 78 | cip = crypto() 79 | return str(cip.decrypt(f.read(x)), 'utf-8') 80 | 81 | 82 | def get_decrypt_key(self, fn, key): 83 | with open(fn, "rb") as f: 84 | s = self.fn_read_decrypt(f) 85 | while s != "": 86 | if s.find(key) > -1: 87 | f.close() 88 | return str(s[s.find(":")+1:], 'utf-8') 89 | s = self.fn_read_decrypt(f) 90 | f.close() 91 | print('Err in crypto_keys: key not found') 92 | return '' 93 | -------------------------------------------------------------------------------- /src/duocontrol.py: -------------------------------------------------------------------------------- 1 | 2 | # Auto-discovery-function of home-assistant (HA) 3 | HA_MODEL = 'inetbox' 4 | HA_SWV = 'V02' 5 | HA_STOPIC = 'service/truma/control_status/' 6 | HA_CTOPIC = 'service/truma/set/' 7 | 8 | 9 | class duo_ctrl: 10 | 11 | HA_DC_CONFIG = { 12 | "duo_ctrl_gas_green": ['homeassistant/binary_sensor/duo_ctrl_gas_green/config', '{"name": "truma_duo_ctrl_gas_green", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "device_class": "gas", "state_topic": "' + HA_STOPIC + 'duo_ctrl_gas_green"}'], 13 | "duo_ctrl_gas_red": ['homeassistant/binary_sensor/duo_ctrl_gas_red/config', '{"name": "truma_duo_ctrl_gas_red", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "device_class": "gas", "state_topic": "' + HA_STOPIC + 'duo_ctrl_gas_red"}'], 14 | "duo_ctrl_ctrl_I": ['homeassistant/binary_sensor/duo_ctrl_i/config', '{"name": "truma_duo_ctrl_i", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "device_class": "heat", "state_topic": "' + HA_STOPIC + 'duo_ctrl_i"}'], 15 | "duo_ctrl_ctrl_II": ['homeassistant/binary_sensor/duo_ctrl_ii/config', '{"name": "truma_duo_ctrl_ii", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "device_class": "heat", "state_topic": "' + HA_STOPIC + 'duo_ctrl_ii"}'], 16 | "set_duo_ctrl_i": ['homeassistant/switch/duo_ctrl_i/config', '{"name": "truma_set_duo_ctrl_i", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "command_topic": "' + HA_CTOPIC + 'duo_ctrl_i"}'], 17 | "set_duo_ctrl_ii": ['homeassistant/switch/duo_ctrl_ii/config', '{"name": "truma_set_duo_ctrl_ii", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "command_topic": "' + HA_CTOPIC + 'duo_ctrl_ii"}'], 18 | } 19 | 20 | # dict for duo control: in, pin, inverted 21 | DC_CONFIG = { 22 | "duo_ctrl_gas_green": "dc_green_pin", 23 | "duo_ctrl_gas_red": "dc_red_pin", 24 | "duo_ctrl_i": "dc_i_pin", 25 | "duo_ctrl_ii": "dc_ii_pin", 26 | } 27 | 28 | status = {} 29 | 30 | # build up status and initialize GPIO 31 | def __init__(self, pin_map): 32 | self.pin_map = pin_map 33 | for i in self.DC_CONFIG.keys(): 34 | if self.pin_map(self.DC_CONFIG[i])[0]: # input-pins 35 | log.debug(f"Pin_Map: in:{self.pin_map(DC_CONFIG[i])[0]}") 36 | v = pin_map.get_gpio(self.DC_CONFIG[i]) 37 | if v: 38 | self.status.update({i: ["ON", True]}) 39 | else: 40 | self.status.update({i: ["OFF", True]}) 41 | else: 42 | self.status.update({i:["OFF", True]}) 43 | self.pin_map(self.DC_CONFIG[i]) 44 | 45 | 46 | 47 | def loop(self): 48 | for i in self.DC_CONFIG.keys(): 49 | # only for inputs 50 | if self.pin_map(DC_CONFIG[i])[0]: 51 | v = self.pin_map.get_gpio(self.DC_CONFIG[i]) 52 | v_o = (self.status[i][0] == "ON") 53 | if v != v_o: 54 | self.status[i][1] = True 55 | if v: 56 | self.status[i][0] = "ON" 57 | else: 58 | self.status[i][0] = "OFF" 59 | 60 | 61 | 62 | # if out (in == False) then set pin - all payloads without "ON" set as "OFF" 63 | def set_status(self, key, value): 64 | if key in self.status.keys(): 65 | # check for output 66 | if not(self.DC_CONFIG[key][0]): 67 | self.status[key] = [value, True] 68 | self.pin_map.set_gpio(self.DC_CONFIG[key], (value == "ON")) 69 | 70 | 71 | 72 | # Status-Dump - with False, it sends all status-values 73 | # with True it sends only a list of changed values - but reset the chance-flag 74 | def get_all(self, only_updates): 75 | # print("Status:", self.status) 76 | if not(only_updates): 77 | self.status_updated = False 78 | return {key: self.get_status(key) for key in self.status.keys()} 79 | else: 80 | s = {} 81 | for key in self.status.keys(): 82 | self.status_updated = False 83 | if self.status[key][1]: 84 | self.status[key][1] = False 85 | self.status_updated = True 86 | s.update({key: self.status[key][0]}) 87 | return s 88 | 89 | -------------------------------------------------------------------------------- /src/imu.py: -------------------------------------------------------------------------------- 1 | # imu.py MicroPython driver for the InvenSense inertial measurement units 2 | # This is the base class 3 | # Adapted from Sebastian Plamauer's MPU9150 driver: 4 | # https://github.com/micropython-IMU/micropython-mpu9150.git 5 | # Authors Peter Hinch, Sebastian Plamauer 6 | # V0.2 17th May 2017 Platform independent: utime and machine replace pyb 7 | 8 | ''' 9 | mpu9250 is a micropython module for the InvenSense MPU9250 sensor. 10 | It measures acceleration, turn rate and the magnetic field in three axis. 11 | mpu9150 driver modified for the MPU9250 by Peter Hinch 12 | 13 | The MIT License (MIT) 14 | Copyright (c) 2014 Sebastian Plamauer, oeplse@gmail.com, Peter Hinch 15 | Permission is hereby granted, free of charge, to any person obtaining a copy 16 | of this software and associated documentation files (the "Software"), to deal 17 | in the Software without restriction, including without limitation the rights 18 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | copies of the Software, and to permit persons to whom the Software is 20 | furnished to do so, subject to the following conditions: 21 | The above copyright notice and this permission notice shall be included in 22 | all copies or substantial portions of the Software. 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 29 | THE SOFTWARE. 30 | ''' 31 | 32 | # User access is now by properties e.g. 33 | # myimu = MPU9250('X') 34 | # magx = myimu.mag.x 35 | # accelxyz = myimu.accel.xyz 36 | # Error handling: on code used for initialisation, abort with message 37 | # At runtime try to continue returning last good data value. We don't want aircraft 38 | # crashing. However if the I2C has crashed we're probably stuffed. 39 | 40 | from utime import sleep_ms 41 | from machine import I2C 42 | from vector import Vector3d 43 | 44 | 45 | class MPUException(OSError): 46 | ''' 47 | Exception for MPU devices 48 | ''' 49 | pass 50 | 51 | 52 | def bytes_toint(msb, lsb): 53 | ''' 54 | Convert two bytes to signed integer (big endian) 55 | for little endian reverse msb, lsb arguments 56 | Can be used in an interrupt handler 57 | ''' 58 | if not msb & 0x80: 59 | return msb << 8 | lsb # +ve 60 | return - (((msb ^ 255) << 8) | (lsb ^ 255) + 1) 61 | 62 | 63 | class MPU6050(object): 64 | ''' 65 | Module for InvenSense IMUs. Base class implements MPU6050 6DOF sensor, with 66 | features common to MPU9150 and MPU9250 9DOF sensors. 67 | ''' 68 | 69 | _I2Cerror = "I2C failure when communicating with IMU" 70 | _mpu_addr = (104, 105) # addresses of MPU9150/MPU6050. There can be two devices 71 | _chip_id = 104 72 | 73 | def __init__(self, side_str, device_addr=None, transposition=(0, 1, 2), scaling=(1, 1, 1)): 74 | 75 | self._accel = Vector3d(transposition, scaling, self._accel_callback) 76 | self._gyro = Vector3d(transposition, scaling, self._gyro_callback) 77 | self.buf1 = bytearray(1) # Pre-allocated buffers for reads: allows reads to 78 | self.buf2 = bytearray(2) # be done in interrupt handlers 79 | self.buf3 = bytearray(3) 80 | self.buf6 = bytearray(6) 81 | 82 | sleep_ms(200) # Ensure PSU and device have settled 83 | if isinstance(side_str, str): # Non-pyb targets may use other than X or Y 84 | self._mpu_i2c = I2C(side_str) 85 | elif hasattr(side_str, 'readfrom'): # Soft or hard I2C instance. See issue #3097 86 | self._mpu_i2c = side_str 87 | else: 88 | raise ValueError("Invalid I2C instance") 89 | 90 | if device_addr is None: 91 | devices = set(self._mpu_i2c.scan()) 92 | mpus = devices.intersection(set(self._mpu_addr)) 93 | number_of_mpus = len(mpus) 94 | if number_of_mpus == 0: 95 | raise MPUException("No MPU's detected") 96 | elif number_of_mpus == 1: 97 | self.mpu_addr = mpus.pop() 98 | else: 99 | raise ValueError("Two MPU's detected: must specify a device address") 100 | else: 101 | if device_addr not in (0, 1): 102 | raise ValueError('Device address must be 0 or 1') 103 | self.mpu_addr = self._mpu_addr[device_addr] 104 | 105 | self.chip_id # Test communication by reading chip_id: throws exception on error 106 | # Can communicate with chip. Set it up. 107 | self.wake() # wake it up 108 | self.passthrough = True # Enable mag access from main I2C bus 109 | self.accel_range = 0 # default to highest sensitivity 110 | self.gyro_range = 0 # Likewise for gyro 111 | 112 | # read from device 113 | def _read(self, buf, memaddr, addr): # addr = I2C device address, memaddr = memory location within the I2C device 114 | ''' 115 | Read bytes to pre-allocated buffer Caller traps OSError. 116 | ''' 117 | self._mpu_i2c.readfrom_mem_into(addr, memaddr, buf) 118 | 119 | # write to device 120 | def _write(self, data, memaddr, addr): 121 | ''' 122 | Perform a memory write. Caller should trap OSError. 123 | ''' 124 | self.buf1[0] = data 125 | self._mpu_i2c.writeto_mem(addr, memaddr, self.buf1) 126 | 127 | # wake 128 | def wake(self): 129 | ''' 130 | Wakes the device. 131 | ''' 132 | try: 133 | self._write(0x01, 0x6B, self.mpu_addr) # Use best clock source 134 | except OSError: 135 | raise MPUException(self._I2Cerror) 136 | return 'awake' 137 | 138 | # mode 139 | def sleep(self): 140 | ''' 141 | Sets the device to sleep mode. 142 | ''' 143 | try: 144 | self._write(0x40, 0x6B, self.mpu_addr) 145 | except OSError: 146 | raise MPUException(self._I2Cerror) 147 | return 'asleep' 148 | 149 | # chip_id 150 | @property 151 | def chip_id(self): 152 | ''' 153 | Returns Chip ID 154 | ''' 155 | try: 156 | self._read(self.buf1, 0x75, self.mpu_addr) 157 | except OSError: 158 | raise MPUException(self._I2Cerror) 159 | chip_id = int(self.buf1[0]) 160 | if chip_id != self._chip_id: 161 | raise ValueError('Bad chip ID retrieved: MPU communication failure') 162 | return chip_id 163 | 164 | @property 165 | def sensors(self): 166 | ''' 167 | returns sensor objects accel, gyro 168 | ''' 169 | return self._accel, self._gyro 170 | 171 | # get temperature 172 | @property 173 | def temperature(self): 174 | ''' 175 | Returns the temperature in degree C. 176 | ''' 177 | try: 178 | self._read(self.buf2, 0x41, self.mpu_addr) 179 | except OSError: 180 | raise MPUException(self._I2Cerror) 181 | return bytes_toint(self.buf2[0], self.buf2[1])/340 + 35 # I think 182 | 183 | # passthrough 184 | @property 185 | def passthrough(self): 186 | ''' 187 | Returns passthrough mode True or False 188 | ''' 189 | try: 190 | self._read(self.buf1, 0x37, self.mpu_addr) 191 | return self.buf1[0] & 0x02 > 0 192 | except OSError: 193 | raise MPUException(self._I2Cerror) 194 | 195 | @passthrough.setter 196 | def passthrough(self, mode): 197 | ''' 198 | Sets passthrough mode True or False 199 | ''' 200 | if type(mode) is bool: 201 | val = 2 if mode else 0 202 | try: 203 | self._write(val, 0x37, self.mpu_addr) # I think this is right. 204 | self._write(0x00, 0x6A, self.mpu_addr) 205 | except OSError: 206 | raise MPUException(self._I2Cerror) 207 | else: 208 | raise ValueError('pass either True or False') 209 | 210 | # sample rate. Not sure why you'd ever want to reduce this from the default. 211 | @property 212 | def sample_rate(self): 213 | ''' 214 | Get sample rate as per Register Map document section 4.4 215 | SAMPLE_RATE= Internal_Sample_Rate / (1 + rate) 216 | default rate is zero i.e. sample at internal rate. 217 | ''' 218 | try: 219 | self._read(self.buf1, 0x19, self.mpu_addr) 220 | return self.buf1[0] 221 | except OSError: 222 | raise MPUException(self._I2Cerror) 223 | 224 | @sample_rate.setter 225 | def sample_rate(self, rate): 226 | ''' 227 | Set sample rate as per Register Map document section 4.4 228 | ''' 229 | if rate < 0 or rate > 255: 230 | raise ValueError("Rate must be in range 0-255") 231 | try: 232 | self._write(rate, 0x19, self.mpu_addr) 233 | except OSError: 234 | raise MPUException(self._I2Cerror) 235 | 236 | # Low pass filters. Using the filter_range property of the MPU9250 is 237 | # harmless but gyro_filter_range is preferred and offers an extra setting. 238 | @property 239 | def filter_range(self): 240 | ''' 241 | Returns the gyro and temperature sensor low pass filter cutoff frequency 242 | Pass: 0 1 2 3 4 5 6 243 | Cutoff (Hz): 250 184 92 41 20 10 5 244 | Sample rate (KHz): 8 1 1 1 1 1 1 245 | ''' 246 | try: 247 | self._read(self.buf1, 0x1A, self.mpu_addr) 248 | res = self.buf1[0] & 7 249 | except OSError: 250 | raise MPUException(self._I2Cerror) 251 | return res 252 | 253 | @filter_range.setter 254 | def filter_range(self, filt): 255 | ''' 256 | Sets the gyro and temperature sensor low pass filter cutoff frequency 257 | Pass: 0 1 2 3 4 5 6 258 | Cutoff (Hz): 250 184 92 41 20 10 5 259 | Sample rate (KHz): 8 1 1 1 1 1 1 260 | ''' 261 | # set range 262 | if filt in range(7): 263 | try: 264 | self._write(filt, 0x1A, self.mpu_addr) 265 | except OSError: 266 | raise MPUException(self._I2Cerror) 267 | else: 268 | raise ValueError('Filter coefficient must be between 0 and 6') 269 | 270 | # accelerometer range 271 | @property 272 | def accel_range(self): 273 | ''' 274 | Accelerometer range 275 | Value: 0 1 2 3 276 | for range +/-: 2 4 8 16 g 277 | ''' 278 | try: 279 | self._read(self.buf1, 0x1C, self.mpu_addr) 280 | ari = self.buf1[0]//8 281 | except OSError: 282 | raise MPUException(self._I2Cerror) 283 | return ari 284 | 285 | @accel_range.setter 286 | def accel_range(self, accel_range): 287 | ''' 288 | Set accelerometer range 289 | Pass: 0 1 2 3 290 | for range +/-: 2 4 8 16 g 291 | ''' 292 | ar_bytes = (0x00, 0x08, 0x10, 0x18) 293 | if accel_range in range(len(ar_bytes)): 294 | try: 295 | self._write(ar_bytes[accel_range], 0x1C, self.mpu_addr) 296 | except OSError: 297 | raise MPUException(self._I2Cerror) 298 | else: 299 | raise ValueError('accel_range can only be 0, 1, 2 or 3') 300 | 301 | # gyroscope range 302 | @property 303 | def gyro_range(self): 304 | ''' 305 | Gyroscope range 306 | Value: 0 1 2 3 307 | for range +/-: 250 500 1000 2000 degrees/second 308 | ''' 309 | # set range 310 | try: 311 | self._read(self.buf1, 0x1B, self.mpu_addr) 312 | gri = self.buf1[0]//8 313 | except OSError: 314 | raise MPUException(self._I2Cerror) 315 | return gri 316 | 317 | @gyro_range.setter 318 | def gyro_range(self, gyro_range): 319 | ''' 320 | Set gyroscope range 321 | Pass: 0 1 2 3 322 | for range +/-: 250 500 1000 2000 degrees/second 323 | ''' 324 | gr_bytes = (0x00, 0x08, 0x10, 0x18) 325 | if gyro_range in range(len(gr_bytes)): 326 | try: 327 | self._write(gr_bytes[gyro_range], 0x1B, self.mpu_addr) # Sets fchoice = b11 which enables filter 328 | except OSError: 329 | raise MPUException(self._I2Cerror) 330 | else: 331 | raise ValueError('gyro_range can only be 0, 1, 2 or 3') 332 | 333 | # Accelerometer 334 | @property 335 | def accel(self): 336 | ''' 337 | Acceleremoter object 338 | ''' 339 | return self._accel 340 | 341 | def _accel_callback(self): 342 | ''' 343 | Update accelerometer Vector3d object 344 | ''' 345 | try: 346 | self._read(self.buf6, 0x3B, self.mpu_addr) 347 | except OSError: 348 | raise MPUException(self._I2Cerror) 349 | self._accel._ivector[0] = bytes_toint(self.buf6[0], self.buf6[1]) 350 | self._accel._ivector[1] = bytes_toint(self.buf6[2], self.buf6[3]) 351 | self._accel._ivector[2] = bytes_toint(self.buf6[4], self.buf6[5]) 352 | scale = (16384, 8192, 4096, 2048) 353 | self._accel._vector[0] = self._accel._ivector[0]/scale[self.accel_range] 354 | self._accel._vector[1] = self._accel._ivector[1]/scale[self.accel_range] 355 | self._accel._vector[2] = self._accel._ivector[2]/scale[self.accel_range] 356 | 357 | def get_accel_irq(self): 358 | ''' 359 | For use in interrupt handlers. Sets self._accel._ivector[] to signed 360 | unscaled integer accelerometer values 361 | ''' 362 | self._read(self.buf6, 0x3B, self.mpu_addr) 363 | self._accel._ivector[0] = bytes_toint(self.buf6[0], self.buf6[1]) 364 | self._accel._ivector[1] = bytes_toint(self.buf6[2], self.buf6[3]) 365 | self._accel._ivector[2] = bytes_toint(self.buf6[4], self.buf6[5]) 366 | 367 | # Gyro 368 | @property 369 | def gyro(self): 370 | ''' 371 | Gyroscope object 372 | ''' 373 | return self._gyro 374 | 375 | def _gyro_callback(self): 376 | ''' 377 | Update gyroscope Vector3d object 378 | ''' 379 | try: 380 | self._read(self.buf6, 0x43, self.mpu_addr) 381 | except OSError: 382 | raise MPUException(self._I2Cerror) 383 | self._gyro._ivector[0] = bytes_toint(self.buf6[0], self.buf6[1]) 384 | self._gyro._ivector[1] = bytes_toint(self.buf6[2], self.buf6[3]) 385 | self._gyro._ivector[2] = bytes_toint(self.buf6[4], self.buf6[5]) 386 | scale = (131, 65.5, 32.8, 16.4) 387 | self._gyro._vector[0] = self._gyro._ivector[0]/scale[self.gyro_range] 388 | self._gyro._vector[1] = self._gyro._ivector[1]/scale[self.gyro_range] 389 | self._gyro._vector[2] = self._gyro._ivector[2]/scale[self.gyro_range] 390 | 391 | def get_gyro_irq(self): 392 | ''' 393 | For use in interrupt handlers. Sets self._gyro._ivector[] to signed 394 | unscaled integer gyro values. Error trapping disallowed. 395 | ''' 396 | self._read(self.buf6, 0x43, self.mpu_addr) 397 | self._gyro._ivector[0] = bytes_toint(self.buf6[0], self.buf6[1]) 398 | self._gyro._ivector[1] = bytes_toint(self.buf6[2], self.buf6[3]) 399 | self._gyro._ivector[2] = bytes_toint(self.buf6[4], self.buf6[5]) -------------------------------------------------------------------------------- /src/kalman.py: -------------------------------------------------------------------------------- 1 | #Kalman Filter MPU6050 2 | class KalmanAngle: 3 | def __init__(self): 4 | self.QAngle = 0.001 5 | self.QBias = 0.003 6 | self.RMeasure = 0.03 7 | self.angle = 0.0 8 | self.bias = 0.0 9 | self.rate = 0.0 10 | self.P=[[0.0,0.0],[0.0,0.0]] 11 | 12 | '''def kalman(): 13 | QAngle = 0.001 14 | QBias = 0.003 15 | RMeasure = 0.03 16 | 17 | angle = 0.0 18 | bias = 0.0 19 | 20 | P[0][0] = 0.0 21 | P[0][1] = 0.0 22 | P[1][0] = 0.0 23 | P[1][1] = 0.0''' 24 | 25 | def getAngle(self,newAngle, newRate,dt): 26 | #step 1: 27 | self.rate = newRate - self.bias; #new_rate is the latest Gyro measurement 28 | self.angle += dt * self.rate; 29 | 30 | #Step 2: 31 | self.P[0][0] += dt * (dt*self.P[1][1] -self.P[0][1] - self.P[1][0] + self.QAngle) 32 | self.P[0][1] -= dt * self.P[1][1] 33 | self.P[1][0] -= dt * self.P[1][1] 34 | self.P[1][1] += self.QBias * dt 35 | 36 | #Step 3: Innovation 37 | y = newAngle - self.angle 38 | 39 | #Step 4: Innovation covariance 40 | s = self.P[0][0] + self.RMeasure 41 | 42 | #Step 5: Kalman Gain 43 | K=[0.0,0.0] 44 | K[0] = self.P[0][0]/s 45 | K[1] = self.P[1][0]/s 46 | 47 | #Step 6: Update the Angle 48 | self.angle += K[0] * y 49 | self.bias += K[1] * y 50 | 51 | #Step 7: Calculate estimation error covariance - Update the error covariance 52 | P00Temp = self.P[0][0] 53 | P01Temp = self.P[0][1] 54 | 55 | self.P[0][0] -= K[0] * P00Temp; 56 | self.P[0][1] -= K[0] * P01Temp; 57 | self.P[1][0] -= K[1] * P00Temp; 58 | self.P[1][1] -= K[1] * P01Temp; 59 | 60 | return self.angle 61 | 62 | def setAngle(self,angle): 63 | self.angle = angle 64 | 65 | def setQAngle(self,QAngle): 66 | self.QAngle = QAngle 67 | 68 | def setQBias(self,QBias): 69 | self.QBias = QBias 70 | 71 | def setRMeasure(self,RMeasure): 72 | self.RMeasure = RMeasure 73 | 74 | def getRate(self): 75 | return self.rate 76 | 77 | def getQAngle(self): 78 | return self.QAngle 79 | 80 | def getQBias(self): 81 | return self.QBias 82 | 83 | def getRMeasure(self): 84 | return self.RMeasure -------------------------------------------------------------------------------- /src/lin.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2022 Dr. Magnus Christ (mc0110) 4 | # 5 | # 6 | # version 0.8.2 7 | # 8 | # this project is based on the LIN Specification Package Revision 2.2A 9 | # 10 | # The essential basis is to incorporate the results of the specification in such a way that 11 | # there are no performance problems. Therefore, for example, RAW PIDs are processed in which 12 | # the parity bits have not been separated. These are shown on pages 53ff of the specification. 13 | # Thus 3C/3D with parity becomes 3C/7D. If this leads to confusion, I apologise. 14 | # Same approach for the raw PID 0xD8. This corresponds to a PID 0x18 15 | # This module has been optimised for high performance. 16 | 17 | from tools import calculate_checksum, PIN_MAP 18 | import inetboxapp 19 | import logging 20 | import uasyncio as asyncio 21 | 22 | 23 | class Lin: 24 | 25 | ts_response_buffer = [] 26 | cpp_in_buffer = [bytes([]),bytes([]),bytes([]),bytes([]),bytes([]),bytes([])] 27 | updates_to_send = False 28 | update_request = False 29 | cpp_buffer = {} 30 | cmd_buf = {} 31 | cnt_rows = 1 32 | stop_async = False 33 | log = logging.getLogger(__name__) 34 | cnt_in = 0 35 | # Same approach for the raw PID 0xD8. This corresponds to a PID 0x18 36 | d8_alive = False 37 | 38 | 39 | # Only for display control / slow event timing 40 | CNT_ROWS_MAX = 200 41 | 42 | # Check Alive-status periodically - with 1ms delay it is appx. 9s 43 | # there must be more than 1 D8-requests in this periode, than is alive status "ON" 44 | # otherwise it would set "OFF" 45 | CNT_IN_MAX = 9000 46 | 47 | DISPLAY_STATUS_PIDS = [bytes([0x20]), bytes([0x61]), bytes([0xE2])] 48 | 49 | 50 | # the correct (full) preamble starts in the first frame, but we see only one type of 51 | # frames, all with the same length - so we can use a frame-preamble with a shorter length, 52 | # starting in the 2. frame 53 | BUFFER_PREAMBLE = bytes([0x00, 0x00, 0x22, 0xFF, 0xFF, 0xFF, 0x54, 0x01]) 54 | 55 | 56 | BUFFER_HEADER_RECV = bytes([0x14, 0x33]) 57 | BUFFER_HEADER_TIMER = bytes([0x18, 0x3D]) 58 | BUFFER_HEADER_02 = bytes([0x02, 0x0D]) 59 | BUFFER_HEADER_03 = bytes([0x0A, 0x15]) 60 | BUFFER_HEADER_WRITE = bytes([0x0C, 0x32]) 61 | 62 | 63 | def __init__(self, serial, pin_map, lin_debug, inet_debug): 64 | self.loop_state = False 65 | self.serial = serial 66 | self.pin_map = pin_map 67 | self.cnt_rows = 1 68 | if lin_debug: 69 | self.log.setLevel(logging.DEBUG) 70 | self.lin_debug = lin_debug 71 | self.app = inetboxapp.InetboxApp(inet_debug) 72 | self.pin_map.set_led("lin_led", False) 73 | print("Lin initialized") 74 | 75 | def response_waiting(self): 76 | return len(self.ts_response_buffer) 77 | 78 | 79 | def _send_answer_str(self, data_str): 80 | self._send_answer(self, serial, bytes.fromhex(data_str.replace(" ",""))) 81 | 82 | 83 | def _send_answer_w_cs_calc(self, databytes, pid_for_checksum=None): 84 | if not pid_for_checksum: 85 | cs = calculate_checksum(databytes) 86 | else: 87 | cs = calculate_checksum(bytes([pid_for_checksum]) + databytes) 88 | self.send_answer(self, serial, databytes.extend([cs])) 89 | 90 | 91 | def _send_answer(self, databytes): 92 | self.serial.write(databytes) 93 | self.serial.flush() 94 | self.log.debug("out > " + str(databytes.hex(" "))) 95 | self.pin_map.toggle_led("lin_led") 96 | 97 | 98 | def prepare_tl_str_response(self, message_str, info_str): 99 | self.prepare_tl_response(bytes.fromhex(message_str.replace(" ",""))) 100 | if info_str.startswith("_"): 101 | self.log.debug(info_str) 102 | else: 103 | self.log.info(info_str) 104 | 105 | 106 | def prepare_tl_response(self, messages): 107 | self.ts_response_buffer.extend([messages]) 108 | 109 | 110 | def _answer_tl_request(self): 111 | if len(self.ts_response_buffer): 112 | databytes = bytes(self.ts_response_buffer[0]) 113 | self.ts_response_buffer.pop(0) 114 | self._send_answer(databytes) 115 | else: 116 | self.log.debug("unexpacted behavior - nothing to send") 117 | 118 | 119 | def no_answer(self, s, p): 120 | if self.stop_async: 121 | self.stop_async = self.response_waiting() 122 | self.updates_to_send = (self.app.upload_buffer or self.app.upload02_buffer) 123 | if p.startswith("_"): return 124 | self.log.debug(p) 125 | 126 | 127 | def display_status(self): 128 | pass 129 | # if self.info: 130 | # print() 131 | # print("Overview received buffers") 132 | # for key in self.cpp_buffer.keys(): 133 | # print(f"Buf[{key}]={self.cpp_buffer[key]}") 134 | # print("-----------------------------") 135 | # print() 136 | 137 | 138 | def assemble_cpp_buffer(self): 139 | # gather the transfered frames 140 | # preamble "00 1E 00 00 0x22 0xFF 0xFF 0x54 0x01" 141 | # buffer id (2 bytes) 142 | buf = bytes([]) 143 | for i in range(5): 144 | buf += self.cpp_in_buffer[i] 145 | #print(buf.hex("+")) 146 | if buf[:8] != self.BUFFER_PREAMBLE: 147 | self.log.debug("buffer preamble doesn't match") 148 | return False 149 | buf_id = buf[8:10] 150 | self.d8_alive = True 151 | self.cpp_buffer[buf_id] = buf[10:] 152 | self.log.debug(f"Buf[{buf_id}]={self.cpp_buffer[buf_id]}") 153 | self.app.process_status_buffer_update(buf_id, self.cpp_buffer[buf_id]) 154 | return True 155 | 156 | 157 | # send out - warm water 158 | def generate_inet_upload(self, s, p): 159 | # Message warm water / counter = 1 160 | # self.prepare_tl_response(bytes.fromhex("03 10 29 fa 00 1f 00 1e 8b".replace(" ",""))) 161 | # self.prepare_tl_response(bytes.fromhex("03 21 00 00 22 ff ff ff b9".replace(" ",""))) 162 | # self.prepare_tl_response(bytes.fromhex("03 22 54 01 0c 32 02 22 23".replace(" ",""))) 163 | # self.prepare_tl_response(bytes.fromhex("03 23 00 00 00 00 00 00 d9".replace(" ",""))) 164 | # self.prepare_tl_response(bytes.fromhex("03 24 3a 0c 00 00 01 01 90".replace(" ",""))) 165 | # self.prepare_tl_response(bytes.fromhex("03 25 00 00 00 00 00 00 d7".replace(" ",""))) 166 | # self.prepare_tl_response(bytes.fromhex("03 26 00 00 00 00 00 00 d6".replace(" ",""))) 167 | 168 | if self.app.upload_buffer: 169 | self.log.debug("heater_status to be generated") 170 | self.cmd_buf = self.app._get_status_buffer_for_writing() 171 | self.stop_async = True 172 | if self.app.upload_buffer > 0: self.app.upload_buffer -= 1 173 | 174 | if self.app.upload02_buffer: 175 | self.log.debug("aircon_status to be generated") 176 | self.cmd_buf = self.app._get_status_buffer1_for_writing() 177 | self.stop_async = True 178 | if self.app.upload02_buffer > 0: self.app.upload02_buffer -= 1 179 | 180 | if (self.cmd_buf == None) or (self.cmd_buf == {}): 181 | self.log.debug("cmd_buffer is empty") 182 | return 183 | self.d8_alive = True 184 | self.stop_async = True 185 | for i in self.cmd_buf: 186 | self.prepare_tl_response(i) 187 | self.updates_to_send = False 188 | if p.startswith("_"): return 189 | self.log.debug(p) 190 | 191 | async def watchdog(self): 192 | self.log.info("watchdog activated") 193 | await asyncio.sleep(60) 194 | if (self.app.status["alive"][0] == "ON"): 195 | self.log.info("watchdog deactivated_s1") 196 | return 197 | await asyncio.sleep(60) 198 | if (self.app.status["alive"][0] == "ON"): 199 | self.log.info("watchdog deactivated_s2") 200 | return 201 | await asyncio.sleep(60) 202 | if (self.app.status["alive"][0] == "ON"): 203 | self.log.info("watchdog deactivated_s3") 204 | else: 205 | if self.lin_debug: 206 | self.log.debug("system reboot in debug_mode suppressed") 207 | else: 208 | self.log.info("system reboot required") 209 | import machine 210 | machine.reset() 211 | 212 | # check alive status 213 | def status_monitor(self): 214 | self.cnt_in += 1 215 | if not(self.cnt_in % self.CNT_IN_MAX): 216 | self.cnt_in=0 217 | self.app.status["alive"] = ["ON", True, False] 218 | # Same approach for the raw PID 0xD8. This corresponds to a PID 0x18 219 | if self.d8_alive: 220 | self.app.status["alive"][0] = "ON" 221 | else: 222 | self.app.status["alive"][0] = "OFF" 223 | self.d8_alive = False 224 | self.pin_map.set_led("lin_led", False) 225 | 226 | 227 | 228 | async def loop_serial(self): 229 | 230 | self.status_monitor() 231 | # New input process: idea is, nothing to forget. So there is a turing-machine nessecary. This read 1 byte and decide to switch in the next level or to throw the input away 232 | # So there is a much higher probability for synchronizing 233 | if not(self.serial.any()): 234 | return 235 | self.pin_map.dtoggle_led("lin_led") 236 | ####### Many thanks to florent314 see also issue #69 237 | #line = self.serial.read() 238 | ##if self.loop_state: # this means level 2 239 | ## if line[0] == 0x55: # here it is clear, we saw a correct synchronizing 240 | ## line = bytes([0x00, 0x55]) + self.serial.read(1) 241 | ## self.loop_state = False 242 | ## # this is the exit point of the turing machine 243 | ## else: # recycling the byte, if it is 0x00 244 | ## self.loop_state = (line[0] == 0x00) # e.g. 0x00 0x00 0x55 would be found 245 | ## return 246 | ##else: # this is level 1 - waiting for 0x00 for next level 247 | ## self.loop_state = (line[0] == 0x00) 248 | ## if not(self.loop_state): 249 | ## #if self.debug: 250 | ## print(f"in < {line[0]:02x} not a proper sync -wait for sync-") 251 | ## pass 252 | ## return 253 | line = b'\x00'+ self.serial.read(1) 254 | while(not line[1]==0x55 ): 255 | if self.serial.any()==0: 256 | return 257 | line = b'\x00'+ self.serial.read(1) 258 | 259 | line += self.serial.read(1) 260 | #line = self.serial.read(2) 261 | 262 | #if(not len(line)==11 ): 263 | # 264 | # return 265 | 266 | raw_pid = line[2] 267 | if raw_pid in self.DISPLAY_STATUS_PIDS: print(f"status-message found with {raw_pid:x}")#0x20 0x61 0xe2 268 | 269 | # Same approach for the raw PID 0xD8. This corresponds to a PID 0x18 270 | if raw_pid == 0xd8: 271 | self.d8_alive = True 272 | self.app.status["alive"] = ["ON", True, False] 273 | self.pin_map.set_led("lin_led", True) 274 | self.log.debug(f"in < {line.hex(" ")}") 275 | s = False 276 | if not(self.app.upload_wait): s = (self.app.upload_buffer or self.app.upload02_buffer) 277 | if s: 278 | self.app.upload_wait = 4 279 | self.stop_async = True 280 | self.log.debug("0x18 - update-requested") 281 | self._send_answer(bytearray.fromhex("ff ff ff ff ff ff ff ff 27".replace(" ",""))) 282 | return 283 | else: 284 | self._send_answer(bytearray.fromhex("fe ff ff ff ff ff ff ff 28".replace(" ",""))) 285 | if self.app.upload_wait: 286 | self.app.upload_wait -= 1 287 | return 288 | # send requested answer to 0x3d -> 0x7d with parity) but only, if I have the need to answer 289 | if raw_pid == 0x7d: 290 | if self.response_waiting(): 291 | self.log.debug(f"in < {line.hex(" ")}") 292 | self._answer_tl_request() 293 | return 294 | else: return 295 | 296 | while self.serial.any()<9: 297 | pass 298 | #print("Debug:ici") 299 | line += self.serial.read(9) 300 | deb="" 301 | for b in line: 302 | deb=deb+f"{b:02x} " 303 | if(len(line)>2): 304 | print("debug:%s, %d, 0x%x"%(deb,len(line),line[2])) 305 | else: 306 | print("debug:%s, %d"%(deb,len(line))) 307 | # the idea is to trigger events from the loop-timing 308 | # seeing completed rows at this point (rows means LIN-frames) 309 | # but we don't use this functionality at the moment 310 | self.cnt_rows += 1 311 | self.cnt_rows = self.cnt_rows % self.CNT_ROWS_MAX 312 | if not(self.cnt_rows): self.display_status() 313 | 314 | self.log.debug(f"in < {line.hex(" ")}") 315 | # if len(line) != 12: 316 | # return # exit, length isn't correct 317 | # 318 | 319 | # most of the following comments are only used in the test-phase 320 | # so the idea was, to hide all comments with a begining underline 321 | 322 | 323 | # multi-frame receive for buffer download from CPplus 324 | buf_trans_id = bytes([0x00, 0x55, 0x3c, 0x03]) 325 | if (line[:4]==buf_trans_id) and (line[4] in range(0x21, 0x27)): 326 | # self.("Buffer-check:" + str(line.hex("-"))) 327 | self.cpp_in_buffer[line[4] - 0x21] = line[5:-1] # fill into buffer-segment 328 | # self.log.debug(str(self.cpp_in_buffer[line[4] - 0x21].hex("*"))+ str(line[4] - 0x21)) 329 | if (line[4] == 0x26): 330 | if (self.assemble_cpp_buffer()): 331 | self.prepare_tl_str_response("03 01 fb ff ff ff ff ff 00", "_send ackn-response for buffer delivery") # ackn buffer-upload 332 | return # Line is stored in buffer - nothing else to do 333 | else: 334 | return 335 | 336 | cmd = line.hex(" ") 337 | # single frame messages - answers to send buffer 338 | # comments could be set "unshow" in info-log with a starting underline 339 | # attention: this are raw frames with checksum -> see specification for details 340 | cmd_ctrl = { 341 | "00 55 3c 7f 06 b2 00 17 46 00 1f 4b": [self.prepare_tl_str_response, "03 06 f2 17 46 00 1f 00 87", "_B2 - response request"], # B2-Message I - Initialization started 342 | "00 55 03 aa 0a ff ff ff ff ff ff 48": [self.no_answer, "", "_NAD 03 response - ack"], # reaction to B2 - ackn 343 | "00 55 3c 03 06 b2 20 17 46 00 1f a7": [self.prepare_tl_str_response, "03 06 f2 17 46 00 1f 00 87", "B2 - identifier for NAD 03"], # B2-Message II: Looking for my ID-no 17 46 00 1f 344 | "00 55 3c 03 06 b2 22 17 46 00 1f a5": [self.prepare_tl_str_response, "03 06 f2 17 46 00 1f 00 87", "B2 - initializer for NAD 03 -----------------> start registration"], # B2-Message Initializer 345 | "00 55 3c 7f 06 b0 17 46 00 1f 03 4a": [self.prepare_tl_str_response, "03 01 f0 ff ff ff ff ff 0b", "B0 - init finalized - send ackn ---------------> registration finalized"], # B0-Message - registation finalized 346 | "00 55 3c 03 05 b9 00 1f 00 00 ff 1f": [self.prepare_tl_str_response, "03 02 f9 00 ff ff ff ff 01", "_Heartbeat for NAD 03 - send response"], # Heartbeat 347 | "00 55 3c 03 10 29 bb 00 1f 00 1e ca": [self.no_answer, "", "_Frame 1 of buffer-transfer (6 frames) from CPplus"], #0xBB notice to send buffer 348 | "00 55 3c 03 10 0b ba 00 1f 00 1e e9": [self.generate_inet_upload, "", "BA-request: upload started"], # 0xBA request for inetBox to upload the buffer-frames 349 | "00 55 03 aa 0a ff ff ff ff ff ff 48": [self.no_answer, "", "_ackn from CPplus"], # ackn from CPplus 350 | } 351 | if not(cmd in cmd_ctrl.keys()): 352 | #self.log.debug(str(line.hex(" ")) + "-> no processing") 353 | return # no processing necessary 354 | cmd_ctrl[cmd][0](cmd_ctrl[cmd][1], cmd_ctrl[cmd][2]) # do it 355 | return 356 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2022 Dr. Magnus Christ (mc0110) 4 | # 5 | # This is part of the wifimanager package 6 | # 7 | import logging 8 | import time 9 | import connect 10 | import machine, os 11 | from args import Args 12 | 13 | UPDATE = "update.py" 14 | 15 | appname = "inetbox2mqtt" 16 | rel_no = "2.6.5" 17 | 18 | 19 | #sleep to give some boards time to initialize, for example Rpi Pico W 20 | time.sleep(3) 21 | 22 | args = Args() 23 | 24 | file = args.get_key("file") 25 | if file != None: 26 | f = open(file, "a") 27 | logging.basicConfig(stream=f) 28 | 29 | log = logging.getLogger(__name__) 30 | 31 | 32 | log.setLevel(logging.INFO) 33 | 34 | log.info(f"release no: {rel_no}") 35 | w=connect.Connect(args.get_key("hw"), debuglog=args.check("connect=debug")) 36 | w.appname = appname 37 | w.rel_no = rel_no 38 | 39 | # run-mode > 1 means OTA-repo-checks 40 | if (w.run_mode() > 1): 41 | # if run_mode > 1, then there should be credentials 42 | # rp2 needs sometimes more than 1 reboot for wifi-connection 43 | if not(w.set_sta(1)): 44 | machine.reset() 45 | import mip 46 | import time 47 | try: 48 | mip.install("github:mc0110/inetbox2mqtt/src/" + UPDATE, target = "/") 49 | except: 50 | import machine 51 | machine.reset() 52 | time.sleep(1) 53 | import update 54 | # download the release-no from repo 55 | rel_new = update.read_repo_rel() 56 | if (rel_new != rel_no): 57 | log.info("Update-Process starts ....") 58 | status = True 59 | # cred.set_cred_json() 60 | for i, st in update.update_repo(): 61 | print(i, st) 62 | status = status and st 63 | # if status = False, then process wasn't successful 64 | if not(status): 65 | machine.reset() 66 | else: 67 | # Repo download was successful 68 | # run_mode must be reset to the original value 69 | w.run_mode(w.run_mode() - 2) 70 | machine.reset() 71 | else: 72 | log.info("release is actual") 73 | w.run_mode(w.run_mode() - 2) 74 | machine.soft_reset() 75 | 76 | # normal mode (run mode == 1) or OS run mode (run_mode == 0) execution 77 | else: 78 | if w.creds() and (w.run_mode() == 1): 79 | log.info("Normal mode activated - for chance to OS-mode type in terminal:") 80 | w.connect() 81 | print(">>>import os") 82 | print(">>>os.remove('run_mode.dat')") 83 | import main1 84 | main1.run(w, args.check("lin=debug"), args.check("inet=debug"), args.check("mqtt=debug"), args.get_key("file")!=None) 85 | else: 86 | log.info("OS mode activated") 87 | w.set_ap(1) 88 | w.connect() 89 | 90 | import web_os_main 91 | web_os_main.run(w, args.check("lin=debug"), args.check("inet=debug"), args.check("webos=debug"), args.check("naw=debug"), args.get_key("file")!=None) 92 | 93 | -------------------------------------------------------------------------------- /src/main1.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2022 Dr. Magnus Christ (mc0110) 4 | # Copyright (c) 2023 Dr. Magnus Christ (mc0110) 5 | # 6 | # TRUMA-inetbox-simulation 7 | # 8 | # Credentials and MQTT-server-adress must be filled 9 | # If the mqtt-server needs authentification, this can also filled 10 | # 11 | # The communication with the CPplus uses ESP32-UART2 - connect (tx:GPIO17, rx:GPIO16) 12 | # 13 | # 14 | # 15 | # Version: 2.5.0 16 | # 17 | # change_log: 18 | # 0.8.2 HA_autoConfig für den status error_code, clock ergänzt 19 | # 0.8.3 encrypted credentials, including duo_control, improve the MQTT-detection 20 | # 0.8.4 Tested with RP pico w R2040 - only UART-definition must be changed 21 | # 0.8.5 Added support for MPU6050 implementing a 2D-spiritlevel, added board-based autoconfig for UART, 22 | # added config variables for activating duoControl and spirit-level features 23 | # 0.8.6 added board-based autoconfig for I2C bus definition 24 | # 1.0.0 web-frontend implementation 25 | # 1.0.1 using mqtt-commands for reboot, ota, OS-run 26 | # 1.5.x chance browser behavior 27 | # 2.0.x chance connect and integrate mqtt-logic 28 | 29 | 30 | import logging 31 | import uasyncio as asyncio 32 | from lin import Lin 33 | from duocontrol import duo_ctrl 34 | from spiritlevel import spirit_level 35 | import time 36 | from machine import UART, Pin, I2C, soft_reset 37 | 38 | log = logging.getLogger(__name__) 39 | 40 | # define global objects - important for processing 41 | connect = None 42 | lin = None 43 | dc = None 44 | sl = None 45 | 46 | # Change the following configs to suit your environment 47 | topic_root = 'truma' 48 | S_TOPIC_1 = '' 49 | S_TOPIC_2 = '' 50 | Pub_Prefix = '' 51 | Pub_SL_Prefix = '' 52 | HA_STOPIC = '' 53 | HA_CTOPIC = '' 54 | HA_CONFIG = '' 55 | 56 | 57 | 58 | def set_prefix(topic): 59 | global topic_root 60 | global S_TOPIC_1 61 | global S_TOPIC_2 62 | global Pub_Prefix 63 | global Pub_SL_Prefix 64 | global HA_STOPIC 65 | global HA_CTOPIC 66 | global HA_CONFIG 67 | 68 | topic_root = topic 69 | S_TOPIC_1 = 'service/' + topic_root + '/set/' 70 | S_TOPIC_2 = 'homeassistant/status' 71 | Pub_Prefix = 'service/' + topic_root + '/control_status/' 72 | Pub_SL_Prefix = 'service/spiritlevel/status/' 73 | 74 | # Auto-discovery-function of home-assistant (HA) 75 | HA_MODEL = 'inetbox' 76 | HA_SWV = 'V03' 77 | HA_STOPIC = 'service/' + topic_root + '/control_status/' 78 | HA_CTOPIC = 'service/' + topic_root + '/set/' 79 | 80 | HA_CONFIG = { 81 | "alive": ['homeassistant/binary_sensor/' + topic_root + '/alive/config', '{"name": "' + topic_root + '_alive", "model": "' + HA_MODEL + '", "sw_version": "' + HA_SWV + '", "device_class": "running", "state_topic": "' + HA_STOPIC + 'alive"}'], 82 | "release": ['homeassistant/sensor/release/config', '{"name": "' + topic_root + '_release", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "state_topic": "' + HA_STOPIC + 'release"}'], 83 | "current_temp_room": ['homeassistant/sensor/current_temp_room/config', '{"name": "' + topic_root + '_current_temp_room", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "device_class": "temperature", "unit_of_measurement": "°C", "state_topic": "' + HA_STOPIC + 'current_temp_room"}'], 84 | "current_temp_water": ['homeassistant/sensor/current_temp_water/config', '{"name": "' + topic_root + '_current_temp_water", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "device_class": "temperature", "unit_of_measurement": "°C", "state_topic": "' + HA_STOPIC + 'current_temp_water"}'], 85 | "target_temp_room": ['homeassistant/sensor/target_temp_room/config', '{"name": "' + topic_root + '_target_temp_room", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "device_class": "temperature", "unit_of_measurement": "°C", "state_topic": "' + HA_STOPIC + 'target_temp_room"}'], 86 | "target_temp_aircon": ['homeassistant/sensor/target_temp_aircon/config', '{"name": "' + topic_root + '_target_temp_aircon", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "device_class": "temperature", "unit_of_measurement": "°C", "state_topic": "' + HA_STOPIC + 'target_temp_aircon"}'], 87 | "target_temp_water": ['homeassistant/sensor/target_temp_water/config', '{"name": "' + topic_root + '_target_temp_water", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "device_class": "temperature", "unit_of_measurement": "°C", "state_topic": "' + HA_STOPIC + 'target_temp_water"}'], 88 | "energy_mix": ['homeassistant/sensor/energy_mix/config', '{"name": "' + topic_root + '_energy_mix", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "state_topic": "' + HA_STOPIC + 'energy_mix"}'], 89 | "el_power_level": ['homeassistant/sensor/el_level/config', '{"name": "' + topic_root + '_el_power_level", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "state_topic": "' + HA_STOPIC + 'el_power_level"}'], 90 | "heating_mode": ['homeassistant/sensor/heating_mode/config', '{"name": "' + topic_root + '_heating_mode", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "state_topic": "' + HA_STOPIC + 'heating_mode"}'], 91 | "operating_status": ['homeassistant/sensor/operating_status/config', '{"name": "' + topic_root + '_operating_status", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "state_topic": "' + HA_STOPIC + 'operating_status"}'], 92 | "aircon_operating_mode": ['homeassistant/sensor/aircon_operating_mode/config', '{"name": "' + topic_root + '_aircon_operating_mode", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "state_topic": "' + HA_STOPIC + 'aircon_operating_mode"}'], 93 | "aircon_vent_mode": ['homeassistant/sensor/aircon_vent_mode/config', '{"name": "' + topic_root + '_aircon_vent_mode", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "state_topic": "' + HA_STOPIC + 'aircon_vent_mode"}'], 94 | "operating_status": ['homeassistant/sensor/operating_status/config', '{"name": "' + topic_root + '_operating_status", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "state_topic": "' + HA_STOPIC + 'operating_status"}'], 95 | "error_code": ['homeassistant/sensor/error_code/config', '{"name": "' + topic_root + '_error_code", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "state_topic": "' + HA_STOPIC + 'error_code"}'], 96 | "clock": ['homeassistant/sensor/clock/config', '{"name": "' + topic_root + '_clock", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "state_topic": "' + HA_STOPIC + 'clock"}'], 97 | "set_target_temp_room": ['homeassistant/select/target_temp_room/config', '{"name": "' + topic_root + '_set_roomtemp", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "command_topic": "' + HA_CTOPIC + 'target_temp_room", "options": ["0", "10", "15", "18", "20", "21", "22"] }'], 98 | "set_target_temp_aircon":['homeassistant/select/target_temp_aircon/config', '{"name": "' + topic_root + '_set_aircontemp", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "command_topic": "' + HA_CTOPIC + 'target_temp_aircon", "options": ["16", "18", "20", "22", "24", "26", "28"] }'], 99 | "set_target_temp_water": ['homeassistant/select/target_temp_water/config', '{"name": "' + topic_root + '_set_warmwater", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "command_topic": "' + HA_CTOPIC + 'target_temp_water", "options": ["0", "40", "60", "200"] }'], 100 | "set_heating_mode": ['homeassistant/select/heating_mode/config', '{"name": "' + topic_root + '_set_heating_mode", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "command_topic": "' + HA_CTOPIC + 'heating_mode", "options": ["off", "eco", "high"] }'], 101 | "set_aircon_mode": ['homeassistant/select/aircon_mode/config', '{"name": "' + topic_root + '_set_aircon_mode", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "command_topic": "' + HA_CTOPIC + 'aircon_operating_mode", "options": ["off", "vent", "cool", "hot", "auto"] }'], 102 | "set_vent_mode": ['homeassistant/select/vent_mode/config', '{"name": "' + topic_root + '_set_vent_mode", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "command_topic": "' + HA_CTOPIC + 'aircon_vent_mode", "options": ["low", "mid", "high", "night", "auto"] }'], 103 | "set_energy_mix": ['homeassistant/select/energy_mix/config', '{"name": "' + topic_root + '_set_energy_mix", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "command_topic": "' + HA_CTOPIC + 'energy_mix", "options": ["none", "gas", "electricity", "mix"] }'], 104 | "set_el_power_level": ['homeassistant/select/el_power_level/config', '{"name": "' + topic_root + '_set_el_power_level", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "command_topic": "' + HA_CTOPIC + 'el_power_level", "options": ["0", "900", "1800"] }'], 105 | "set_reboot": ['homeassistant/select/set_reboot/config', '{"name": "' + topic_root + '_set_reboot", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "command_topic": "' + HA_CTOPIC + 'reboot", "options": ["0", "1"] }'], 106 | "set_os_run": ['homeassistant/select/set_os_run/config', '{"name": "' + topic_root + '_set_os_run", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "command_topic": "' + HA_CTOPIC + 'os_run", "options": ["0", "1"] }'], 107 | "ota_update": ['homeassistant/select/ota_update/config', '{"name": "' + topic_root + '_ota_update", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "command_topic": "' + HA_CTOPIC + 'ota_update", "options": ["0", "1"] }'], 108 | } 109 | 110 | # Universal callback function for all subscriptions 111 | def callback(topic, msg, retained, qos): 112 | global connect 113 | log.debug(f"received: {topic}: {msg}") 114 | topic = str(topic) 115 | topic = topic[2:-1] 116 | msg = str(msg) 117 | msg = msg[2:-1] 118 | # Command received from broker 119 | if topic.startswith(S_TOPIC_1): 120 | topic = topic[len(S_TOPIC_1):] 121 | if topic == "reboot": 122 | if msg == "1": 123 | log.info("reboot device request via mqtt") 124 | soft_reset() 125 | return 126 | if topic == "os_run": 127 | if msg == "1": 128 | log.info("switch to os_run -> AP-access: 192.168.4.1:80") 129 | connect.run_mode(0) 130 | soft_reset() 131 | return 132 | if topic == "ota_update": 133 | if msg == "1": 134 | log.info("update software via OTA") 135 | connect.run_mode(3) 136 | soft_reset() 137 | return 138 | # log.info("Received command: "+str(topic)+" payload: "+str(msg)) 139 | if topic in lin.app.status.keys(): 140 | log.info("inet-key:"+str(topic)+" value: "+str(msg)) 141 | try: 142 | lin.app.set_status(topic, msg) 143 | except Exception as e: 144 | log.debug(Exception(e)) 145 | # send via mqtt 146 | elif not(dc == None): 147 | if topic in dc.status.keys(): 148 | log.info("dc-key:"+str(topic)+" value: "+str(msg)) 149 | try: 150 | dc.set_status(topic, msg) 151 | except Exception as e: 152 | log.debug(Exception(e)) 153 | # send via mqtt 154 | else: 155 | log.debug("key incl. dc is unkown") 156 | else: 157 | log.debug("key w/o dc is unkown") 158 | # HA-server send ONLINE message 159 | if (topic == S_TOPIC_2) and (msg == 'online'): 160 | log.info("Received HOMEASSISTANT-online message") 161 | await set_ha_autoconfig(connect.client) 162 | 163 | 164 | # Initialze the subscripted topics 165 | async def conn_callback(client): 166 | log.debug("Set subscription") 167 | # inetbox_set_commands 168 | await connect.client.subscribe(S_TOPIC_1+"#", 1) 169 | # HA_online_command 170 | await connect.client.subscribe(S_TOPIC_2, 1) 171 | 172 | 173 | # HA autodiscovery - delete all entities 174 | async def del_ha_autoconfig(c): 175 | for i in HA_CONFIG.keys(): 176 | await asyncio.sleep(0) # clean asyncio programming 177 | try: 178 | await c.publish(HA_CONFIG[i][0], "{}", qos=1) 179 | except: 180 | log.debug("Publishing error in del_ha_autoconfig") 181 | log.info("del ha_autoconfig completed") 182 | 183 | # HA auto discovery: define all auto config entities 184 | async def set_ha_autoconfig(c): 185 | global connect 186 | for i in HA_CONFIG.keys(): 187 | await asyncio.sleep(0) # clean asyncio programming 188 | try: 189 | await c.publish(HA_CONFIG[i][0], HA_CONFIG[i][1], qos=1) 190 | # print(i,": [" + HA_CONFIG[i][0] + "payload: " + HA_CONFIG[i][1] + "]") 191 | except: 192 | log.debug("Publishing error in set_ha_autoconfig") 193 | await c.publish(Pub_Prefix + "release", connect.rel_no, qos=1) 194 | log.info("set ha_autoconfig completed") 195 | 196 | # main publisher-loop 197 | async def main(): 198 | global repo_update 199 | global connect 200 | global file 201 | log.debug("main-loop is running") 202 | 203 | await del_ha_autoconfig(connect.client) 204 | await set_ha_autoconfig(connect.client) 205 | log.info("Initializing completed") 206 | 207 | i = 0 208 | wd = False 209 | while True: 210 | await asyncio.sleep(10) # Update every 10sec 211 | if file: logging._stream.flush() 212 | s =lin.app.get_all(True) 213 | for key in s.keys(): 214 | log.debug(f'publish {key}:{s[key]}') 215 | try: 216 | await connect.client.publish(Pub_Prefix+key, str(s[key]), qos=1) 217 | except: 218 | log.debug("Error in LIN status publishing") 219 | if lin.app.status["alive"][0]=="OFF": 220 | if not(wd): 221 | asyncio.create_task(lin.watchdog()) 222 | wd = True 223 | else: wd = False 224 | if not(dc == None): 225 | s = dc.get_all(True) 226 | for key in s.keys(): 227 | log.debug(f'publish {key}:{s[key]}') 228 | try: 229 | await connect.client.publish(Pub_Prefix+key, str(s[key]), qos=1) 230 | except: 231 | log.debug("Error in duo_ctrl status publishing") 232 | if not(sl == None): 233 | s = sl.get_all() 234 | for key in s.keys(): 235 | log.debug(f'publish {key}:{s[key]}') 236 | try: 237 | await connect.client.publish(Pub_SL_Prefix+key, str(s[key]), qos=1) 238 | except: 239 | log.debug("Error in spirit_level status publishing") 240 | i += 1 241 | if not(i % 6): 242 | i = 0 243 | lin.app.status["alive"][1] = True # publish alive-heartbeat every min 244 | 245 | 246 | # major ctrl loop for inetbox-communication 247 | async def lin_loop(): 248 | global lin 249 | await asyncio.sleep(1) # Delay at begin 250 | log.info("lin-loop is running") 251 | while True: 252 | await lin.loop_serial() 253 | if not(lin.stop_async): # full performance to send buffer 254 | await asyncio.sleep_ms(1) 255 | 256 | 257 | # major ctrl loop for duo_ctrl_check 258 | async def dc_loop(): 259 | await asyncio.sleep(30) # Delay at begin 260 | log.info("duo_ctrl-loop is running") 261 | while True: 262 | dc.loop() 263 | await asyncio.sleep(10) 264 | 265 | async def sl_loop(): 266 | await asyncio.sleep(5) # Delay at begin 267 | log.info("spirit-level-loop is running") 268 | while True: 269 | sl.loop() 270 | #print("Angle X: " + str(sl.get_roll()) + " Angle Y: " +str(sl.get_pitch()) ) 271 | await asyncio.sleep_ms(100) 272 | 273 | async def ctrl_loop(): 274 | loop = asyncio.get_event_loop() 275 | a=asyncio.create_task(main()) 276 | b=asyncio.create_task(lin_loop()) 277 | if not(dc == None): 278 | c=asyncio.create_task(dc_loop()) 279 | if not(sl == None): 280 | d=asyncio.create_task(sl_loop()) 281 | while True: 282 | await asyncio.sleep(10) 283 | if a.done(): 284 | log.info("Restart main_loop") 285 | a=asyncio.create_task(main()) 286 | if b.done(): 287 | log.info("Restart lin_loop") 288 | b=asyncio.create_task(lin_loop()) 289 | 290 | 291 | def run(w, lin_debug, inet_debug, mqtt_debug, logfile): 292 | global topic_root 293 | global connect 294 | global lin 295 | global dc 296 | global sl 297 | global file 298 | connect = w 299 | 300 | file = logfile 301 | cred = connect.read_json_creds() 302 | activate_duoControl = (cred["ADC"] == "1") 303 | activate_spiritlevel = (cred["ASL"] == "1") 304 | 305 | if mqtt_debug: 306 | log.setLevel(logging.DEBUG) 307 | else: 308 | log.setLevel(logging.INFO) 309 | 310 | if lin_debug: log.info("LIN-LOG defined") 311 | if inet_debug: log.info("INET-LOG defined") 312 | if mqtt_debug: log.info("MQTT-LOG defined") 313 | 314 | # hw-specific configuration 315 | # if ("ESP32" in uos.uname().machine): 316 | 317 | log.info(f"HW-Check {w.platform_name}") 318 | # serial = UART(w.p.get_pin("lin_uart"), rx= w.p.get_pin("lin_rx"), tx=w.p.get_pin("lin_tx"), baudrate=9600, bits=8, parity=None, stop=1, timeout=3) # this is the HW-UART-no 2 319 | # serial = UART(w.p.get_data("lin_uart"), baudrate=9600, bits=8, parity=None, stop=1, timeout=3, rx=Pin(w.p.get_data("lin_rx")), tx=Pin(w.p.get_data("lin_tx"))) # this is the HW-UART-no 2 320 | if (w.platform=="rp2"): 321 | serial = UART(w.p.get_data("lin_uart"), baudrate=9600, bits=8, parity=None, stop=1, timeout=3, rx=Pin(w.p.get_data("lin_rx")), tx=Pin(w.p.get_data("lin_tx"))) # this is the HW-UART-no 2 322 | if (w.platform=="esp32"): 323 | serial = UART(w.p.get_data("lin_uart"), baudrate=9600, bits=8, parity=None, stop=1, timeout=3, rx=w.p.get_data("lin_rx"), tx=w.p.get_data("lin_tx")) # this is the HW-UART-no 2 324 | 325 | if activate_duoControl: 326 | log.info("Activate duoControl set to true, using GPIO 18,19 as input, 22,23 as output") 327 | dc = duo_ctrl() 328 | else: 329 | dc = None 330 | if activate_spiritlevel: 331 | # debugging from Christian S. - thanks a lot for this hint 332 | sda = w.p.get_data("sl_sda") 333 | scl = w.p.get_data("sl_scl") 334 | # debugging from Markus (trinler007) - thanks a lot for this hint 335 | log.info(f"Activate spirit_level set to true, using I2C- on GPIO {scl}(scl), {sda}(sda)") 336 | i2c = I2C(1, sda=Pin(sda), scl=Pin(scl), freq=400000) 337 | time.sleep(1.5) 338 | sl = spirit_level(i2c) 339 | 340 | # Initialize the lin-object 341 | lin = Lin(serial, w.p, lin_debug, inet_debug) 342 | if cred["TOPIC"] != "": 343 | topic_root = cred["TOPIC"] 344 | 345 | set_prefix(topic_root) 346 | log.info(f"prefix: '{topic_root}' set: {S_TOPIC_1} rec: {Pub_Prefix}") 347 | connect.config.set_last_will("service/" + topic_root + "/control_status/alive", "OFF", retain=True, qos=0) # last will is important 348 | connect.set_proc(subscript = callback, connect = conn_callback) 349 | 350 | if not(dc == None): 351 | HA_CONFIG.update(dc.HA_DC_CONFIG) 352 | if not(sl == None): 353 | HA_CONFIG.update(sl.HA_SL_CONFIG) 354 | 355 | asyncio.run(ctrl_loop()) 356 | #loop.run_forever() 357 | 358 | 359 | -------------------------------------------------------------------------------- /src/release.py: -------------------------------------------------------------------------------- 1 | rel_no = "2.6.5" -------------------------------------------------------------------------------- /src/spiritlevel.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2022 Sönke Krebber 3 | # 4 | # For leveling of an RV-car a MPU6050 IMU is used to publish pitch and roll angles. 5 | # 6 | # 7 | # version 0.2 8 | # 9 | # change_log: 10 | # 0.1 Initial release 11 | # 0.2 cleand up some code 12 | 13 | from imu import MPU6050 14 | from kalman import KalmanAngle 15 | import time 16 | import math 17 | 18 | # Auto-discovery-function of home-assistant (HA) 19 | HA_MODEL = 'inetbox' 20 | HA_SWV = 'V02' 21 | HA_STOPIC = 'service/spiritlevel/status/' 22 | #HA_CTOPIC = 'service/spiritlevel/set/' 23 | 24 | radToDeg = 57.2957786 25 | RestrictPitch = True 26 | 27 | class spirit_level: 28 | 29 | HA_SL_CONFIG = { 30 | "spirit_level_pitch": ['homeassistant/sensor/spirit_level_pitch/config', '{"name": "spirit_level_pitch", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "device_class": "None", "unit_of_measurement": "°", "state_topic": "' + HA_STOPIC + 'spirit_level_pitch"}'], 31 | "spirit_level_roll": ['homeassistant/sensor/spirit_level_roll/config', '{"name": "spirit_level_roll", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "device_class": "None", "unit_of_measurement": "°", "state_topic": "' + HA_STOPIC + 'spirit_level_roll"}'], 32 | #"spirit_level_set_speed": ['homeassistant/switch/spirit_level_set_speed/config', '{"name": "spirit_level_set_speed", "model": "' + HA_MODEL + '", "sw_version":"' + HA_SWV + '", "command_topic": "' + HA_CTOPIC + 'spirit_level_set_speed"}'], 33 | } 34 | 35 | 36 | # build up status 37 | def __init__(self, i2c): 38 | self.i2c = i2c 39 | self.imu = MPU6050(self.i2c) 40 | 41 | self.kalmanX = KalmanAngle() 42 | self.kalmanY = KalmanAngle() 43 | self.kalAngleX = 0 44 | self.kalAngleY = 0 45 | 46 | #Read Accelerometer raw value 47 | accX = self.imu.accel.x 48 | accY = self.imu.accel.y 49 | accZ = self.imu.accel.z 50 | 51 | if (RestrictPitch): 52 | roll = math.atan2(accY, accZ) * radToDeg 53 | if (math.sqrt((accY ** 2) + (accZ ** 2)) > 0): 54 | pitch = math.atan(-accX / math.sqrt((accY ** 2) + (accZ ** 2))) * radToDeg 55 | else: 56 | pitch = 0 57 | else: 58 | if (math.sqrt((accX ** 2) + (accZ ** 2)) > 0): 59 | roll = math.atan(accY / math.sqrt((accX ** 2) + (accZ ** 2))) * radToDeg 60 | else: 61 | roll = 0 62 | pitch = math.atan2(-accX, accZ) * radToDeg 63 | 64 | self.kalmanX.setAngle(roll) 65 | self.kalmanY.setAngle(pitch) 66 | 67 | self.timer = time.time() 68 | 69 | 70 | def loop(self): 71 | try: 72 | #Read Accelerometer raw value 73 | accX = self.imu.accel.x 74 | accY = self.imu.accel.y 75 | accZ = self.imu.accel.z 76 | 77 | #Read Gyroscope raw value 78 | gyroX = self.imu.gyro.x 79 | gyroY = self.imu.gyro.y 80 | gyroZ = self.imu.gyro.z 81 | 82 | dt = time.time() - self.timer 83 | self.timer = time.time() 84 | 85 | if (RestrictPitch): 86 | roll = math.atan2(accY,accZ) * radToDeg 87 | pitch = math.atan(-accX/math.sqrt((accY**2)+(accZ**2))) * radToDeg 88 | else: 89 | roll = math.atan(accY/math.sqrt((accX**2)+(accZ**2))) * radToDeg 90 | pitch = math.atan2(-accX,accZ) * radToDeg 91 | 92 | gyroXRate = gyroX/131 93 | gyroYRate = gyroY/131 94 | 95 | if (RestrictPitch): 96 | if((roll < -90 and self.kalAngleX >90) or (roll > 90 and self.kalAngleX < -90)): 97 | self.kalmanX.setAngle(roll) 98 | else: 99 | self.kalAngleX = self.kalmanX.getAngle(roll,gyroXRate,dt) 100 | if(abs(self.kalAngleY)>90 or True): 101 | gyroYRate = -gyroYRate 102 | self.kalAngleY = self.kalmanY.getAngle(pitch,gyroYRate,dt) 103 | else: 104 | 105 | if((pitch < -90 and self.kalAngleY >90) or (pitch > 90 and self.kalAngleY < -90)): 106 | self.kalmanY.setAngle(pitch) 107 | else: 108 | self.kalAngleY = self.kalmanY.getAngle(pitch,gyroYRate,dt) 109 | if(abs(kalAngleX)>90): 110 | gyroXRate = -gyroXRate 111 | self.kalAngleX = self.kalmanX.getAngle(roll,gyroXRate,dt) 112 | 113 | #print("Angle X: " + str(self.kalAngleX)+" " +"Angle Y: " + str(self.kalAngleY)) 114 | 115 | except Exception as exc: 116 | print(exc) 117 | 118 | 119 | # get Angles 120 | def get_pitch(self): 121 | return self.kalAngleY 122 | 123 | def get_roll(self): 124 | return self.kalAngleX 125 | 126 | def get_angles(self): 127 | return {self.kalAngleX, self.kalAngleY} 128 | 129 | def get_all(self): 130 | return {"spirit_level_pitch": self.kalAngleY, 131 | "spirit_level_roll": self.kalAngleX } -------------------------------------------------------------------------------- /src/tools.py: -------------------------------------------------------------------------------- 1 | from machine import Pin 2 | 3 | # this routine isn't nessecary - see bytes.hex(" ") 4 | # def format_bytes(bytestring): 5 | # return " ".join("{:02x}".format(c) for c in bytestring) 6 | 7 | 8 | def calculate_checksum(bytestring): 9 | # The checksum contains the inverted eight bit sum with carry over all data bytes or all data bytes and the protected identifier. 10 | cs = 0 11 | for b in bytestring: 12 | cs = (cs + b) % 0xFF 13 | 14 | cs = ~cs & 0xFF 15 | if cs == 0xFF: 16 | cs = 0 17 | return cs 18 | 19 | PIN_MAPS = { 20 | # dc: in=true, pin-no, inverted=true 21 | 22 | 23 | "ESP32": 24 | { 25 | "logic": "esp32", 26 | "mqtt_led": 4, 27 | "lin_led": 15, 28 | "lan": 0, 29 | "mdc" : 0, 30 | "mdio": 0, 31 | "ref_clk": 0, 32 | "lin_uart": 2, 33 | "lin_rx": 16, 34 | "lin_tx": 17, 35 | "dc_green_pin": [1, 18, 1], 36 | "dc_red_pin": [1, 19, 1], 37 | "dc_i_pin": [0, 22, 1], 38 | "dc_ii_pin": [0, 23, 1], 39 | "sl_i2c": 1, 40 | "sl_sda": 26, 41 | "sl_scl": 25, 42 | }, 43 | 44 | 45 | "PEKAWAY": 46 | { 47 | "logic": "esp32", 48 | "mqtt_led": 0, 49 | "lin_led": 0, 50 | "lan": 0, 51 | "mdc" : 0, 52 | "mdio": 0, 53 | "ref_clk": 0, 54 | "lin_uart": 1, 55 | "lin_rx": 20, 56 | "lin_tx": 21, 57 | "dc_green_pin": [1, 0, 1], 58 | "dc_red_pin": [1, 0, 1], 59 | "dc_i_pin": [0, 0, 1], 60 | "dc_ii_pin": [0, 0, 1], 61 | "sl_i2c": 1, 62 | "sl_sda": 0, 63 | "sl_scl": 0, 64 | }, 65 | 66 | "WOMOLIN": 67 | { 68 | "logic": "esp32", 69 | "mqtt_led": 17, 70 | "lin_led": 2, 71 | "lan": 0, 72 | "mdc" : 23, 73 | "mdio": 18, 74 | "ref_clk": 16, 75 | "nw_type": 1, 76 | "lin_uart": 1, 77 | "lin_rx": 14, 78 | "lin_tx": 15, 79 | "dc_green_pin": [1, 4, 1], 80 | "dc_red_pin": [1, 5, 1], 81 | "dc_i_pin": [0, 32, 1], 82 | "dc_ii_pin": [0, 33, 1], 83 | "sl_i2c": 1, 84 | "sl_sda": 4, 85 | "sl_scl": 5, 86 | }, 87 | 88 | "WOMOLIN_LIN2": 89 | { 90 | "logic": "esp32", 91 | "mqtt_led": 17, 92 | "lin_led": 2, 93 | "lan": 0, 94 | "mdc" : 23, 95 | "mdio": 18, 96 | "ref_clk": 16, 97 | "nw_type": 1, 98 | "lin_uart": 1, 99 | "lin_rx": 14, 100 | "lin_tx": 15, 101 | "dc_green_pin": [1, 4, 1], 102 | "dc_red_pin": [1, 5, 1], 103 | "dc_i_pin": [0, 32, 1], 104 | "dc_ii_pin": [0, 33, 1], 105 | "sl_i2c": 1, 106 | "sl_sda": 4, 107 | "sl_scl": 5, 108 | }, 109 | 110 | "RP2": 111 | { 112 | "logic": "rp2", 113 | "mqtt_led": 14, 114 | "lin_led": 12, 115 | "lan": 0, 116 | "mdc" : 0, 117 | "mdio": 0, 118 | "ref_clk": 0, 119 | "lin_uart": 1, 120 | "lin_rx": 5, 121 | "lin_tx": 4, 122 | "dc_green_pin": [1, 18, 1], 123 | "dc_red_pin": [1, 19, 1], 124 | "dc_i_pin": [0, 22, 1], 125 | "dc_ii_pin": [0, 23, 1], 126 | "sl_i2c": 1, 127 | "sl_sda": 2, 128 | "sl_scl": 3, 129 | }, 130 | 131 | } 132 | 133 | 134 | 135 | class PIN_MAP(): 136 | 137 | _PIN_MAP = {} 138 | 139 | def __init__(self, p): 140 | self._PIN_MAP = p 141 | 142 | def get_pin(self, s): 143 | return self._PIN_MAP[s] 144 | 145 | def get_data(self, s): 146 | return self._PIN_MAP[s] 147 | 148 | def set_led(self, s, b): 149 | p = Pin(self._PIN_MAP[s], Pin.OUT) 150 | if b: p.value(0) 151 | else: p.value(1) 152 | 153 | def toggle_led(self, s): 154 | p = Pin(self._PIN_MAP[s], Pin.OUT) 155 | p.value(not(p.value())) 156 | 157 | def dtoggle_led(self, s): 158 | p = Pin(self._PIN_MAP[s], Pin.OUT) 159 | p.value(not(p.value())) 160 | p.value(not(p.value())) 161 | 162 | # input pin, mode:inverted 163 | def get_gpio(self, s): 164 | if self._PIN_MAP[2]: 165 | p0 = Pin(self._PIN_MAP[s][1], Pin.IN, Pin.PULL_UP) 166 | else: 167 | p0 = Pin(self._PIN_MAP[s][1], Pin.IN, Pin.PULL_DOWN) 168 | v = (p0.value() != self._PIN_MAP[2]) 169 | # print("check pin", p, " inv: ", i, " Val: ", v) 170 | return v 171 | 172 | # pin, inverted, value ("ON", "OFF") 173 | def set_gpio(self, s, v): 174 | # p, i, v): 175 | p0 = Pin(self._PIN_MAP[s][1], Pin.OUT) 176 | v = (v != self._PIN_MAP[s][2]) 177 | p0.value(v) 178 | # print("set pin", p, " inv: ", i, " to: ", v) 179 | 180 | -------------------------------------------------------------------------------- /src/update.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2022 Dr. Magnus Christ (mc0110) 4 | # 5 | # This is part of the wifimanager package 6 | # 7 | # 8 | # For the proper functioning of the connect-library, the keys "SSID", "WIFIPW", "HOSTNAME" should be included. 9 | # Any other keys can be added 10 | 11 | # 12 | # def set_cred_json(): 13 | # import json 14 | # CRED_JSON = "cred.json" 15 | # 16 | # j = { 17 | # "SSID": ["text", "SSID:", "1"], 18 | # "WIFIPW": ["password", "Wifi passcode:", "2"], 19 | # "MQTT": ["text", "Broker name/IP:", "3"], 20 | # "UN": ["text", "Broker User:", "4"], 21 | # "UPW": ["text", "Broker password:", "5"], 22 | # "HOSTNAME": ["text", "Hostname:", "6"], 23 | # "ADC": ["checkbox", "Addon DuoControl :", "7"], 24 | # "ASL": ["checkbox", "Addon SpiritLevel:", "8"], 25 | # # "OSR": ["checkbox", "OS Web:", "9"], 26 | # } 27 | # with open(CRED_JSON, "w") as f: json.dump(j, f) 28 | # 29 | 30 | def update_repo(): 31 | import mip, os 32 | #sleep to give some boards time to initialize, for example Rpi Pico W 33 | 34 | # bootloader for the whole suite 35 | tree = "github:mc0110/inetbox2mqtt" 36 | 37 | env = [ 38 | ["/src/", "args.py", "/"], 39 | ["/src/", "vector.py", "/"], 40 | ["/src/", "spiritlevel.py", "/"], 41 | ["/src/", "duocontrol.py", "/"], 42 | ["/src/", "imu.py", "/"], 43 | ["/lib/", "gen_html.py", "/lib"], 44 | ["/lib/", "kalman.py", "/lib"], 45 | ["/lib/", "web_os.py", "/lib"], 46 | ["/lib/", "web_os_main.py", "/lib"], 47 | 48 | ["/src/", "tools.py", "/"], 49 | ["/src/", "conversions.py", "/"], 50 | ["/src/", "lin.py", "/"], 51 | ["/src/", "inetboxapp.py", "/"], 52 | ["/src/", "main.py", "/"], 53 | ["/src/", "main1.py", "/"], 54 | ["/lib/", "connect.py", "/lib"], 55 | ["/src/", "update.py", "/"], 56 | ] 57 | 58 | 59 | for i in range(len(env)): 60 | errno = 1 61 | while errno and errno<3: 62 | # try: 63 | # try: 64 | # os.remove(env[i][2]+"/"+env[i][1]) 65 | # print(env[i][2]+"/"+env[i][1]+" deleted") 66 | # except: 67 | # pass 68 | mip.install(tree+env[i][0]+env[i][1], target= env[i][2]) 69 | errno = 0 70 | # except: 71 | # errno += 1 72 | s = env[i][1] 73 | st = (errno == 0) 74 | yield (s, st) 75 | 76 | def read_repo_rel(): 77 | import mip 78 | import time 79 | try: 80 | mip.install("github:mc0110/inetbox2mqtt/src/release.py", target = "/") 81 | except: 82 | import machine 83 | machine.reset() 84 | time.sleep(1) 85 | import release 86 | q = release.rel_no 87 | # print("Repo release-no: " + q) 88 | return q 89 | 90 | -------------------------------------------------------------------------------- /src/vector.py: -------------------------------------------------------------------------------- 1 | # vector3d.py 3D vector class for use in inertial measurement unit drivers 2 | # Authors Peter Hinch, Sebastian Plamauer 3 | 4 | # V0.7 17th May 2017 pyb replaced with utime 5 | # V0.6 18th June 2015 6 | 7 | ''' 8 | The MIT License (MIT) 9 | Copyright (c) 2014 Sebastian Plamauer, oeplse@gmail.com, Peter Hinch 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | The above copyright notice and this permission notice shall be included in 17 | all copies or substantial portions of the Software. 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | THE SOFTWARE. 25 | ''' 26 | 27 | from utime import sleep_ms 28 | from math import sqrt, degrees, acos, atan2 29 | 30 | 31 | def default_wait(): 32 | ''' 33 | delay of 50 ms 34 | ''' 35 | sleep_ms(50) 36 | 37 | 38 | class Vector3d(object): 39 | ''' 40 | Represents a vector in a 3D space using Cartesian coordinates. 41 | Internally uses sensor relative coordinates. 42 | Returns vehicle-relative x, y and z values. 43 | ''' 44 | def __init__(self, transposition, scaling, update_function): 45 | self._vector = [0, 0, 0] 46 | self._ivector = [0, 0, 0] 47 | self.cal = (0, 0, 0) 48 | self.argcheck(transposition, "Transposition") 49 | self.argcheck(scaling, "Scaling") 50 | if set(transposition) != {0, 1, 2}: 51 | raise ValueError('Transpose indices must be unique and in range 0-2') 52 | self._scale = scaling 53 | self._transpose = transposition 54 | self.update = update_function 55 | 56 | def argcheck(self, arg, name): 57 | ''' 58 | checks if arguments are of correct length 59 | ''' 60 | if len(arg) != 3 or not (type(arg) is list or type(arg) is tuple): 61 | raise ValueError(name + ' must be a 3 element list or tuple') 62 | 63 | def calibrate(self, stopfunc, waitfunc=default_wait): 64 | ''' 65 | calibration routine, sets cal 66 | ''' 67 | self.update() 68 | maxvec = self._vector[:] # Initialise max and min lists with current values 69 | minvec = self._vector[:] 70 | while not stopfunc(): 71 | waitfunc() 72 | self.update() 73 | maxvec = list(map(max, maxvec, self._vector)) 74 | minvec = list(map(min, minvec, self._vector)) 75 | self.cal = tuple(map(lambda a, b: (a + b)/2, maxvec, minvec)) 76 | 77 | @property 78 | def _calvector(self): 79 | ''' 80 | Vector adjusted for calibration offsets 81 | ''' 82 | return list(map(lambda val, offset: val - offset, self._vector, self.cal)) 83 | 84 | @property 85 | def x(self): # Corrected, vehicle relative floating point values 86 | self.update() 87 | return self._calvector[self._transpose[0]] * self._scale[0] 88 | 89 | @property 90 | def y(self): 91 | self.update() 92 | return self._calvector[self._transpose[1]] * self._scale[1] 93 | 94 | @property 95 | def z(self): 96 | self.update() 97 | return self._calvector[self._transpose[2]] * self._scale[2] 98 | 99 | @property 100 | def xyz(self): 101 | self.update() 102 | return (self._calvector[self._transpose[0]] * self._scale[0], 103 | self._calvector[self._transpose[1]] * self._scale[1], 104 | self._calvector[self._transpose[2]] * self._scale[2]) 105 | 106 | @property 107 | def magnitude(self): 108 | x, y, z = self.xyz # All measurements must correspond to the same instant 109 | return sqrt(x**2 + y**2 + z**2) 110 | 111 | @property 112 | def inclination(self): 113 | x, y, z = self.xyz 114 | return degrees(acos(z / sqrt(x**2 + y**2 + z**2))) 115 | 116 | @property 117 | def elevation(self): 118 | return 90 - self.inclination 119 | 120 | @property 121 | def azimuth(self): 122 | x, y, z = self.xyz 123 | return degrees(atan2(y, x)) 124 | 125 | # Raw uncorrected integer values from sensor 126 | @property 127 | def ix(self): 128 | return self._ivector[0] 129 | 130 | @property 131 | def iy(self): 132 | return self._ivector[1] 133 | 134 | @property 135 | def iz(self): 136 | return self._ivector[2] 137 | 138 | @property 139 | def ixyz(self): 140 | return self._ivector 141 | 142 | @property 143 | def transpose(self): 144 | return tuple(self._transpose) 145 | 146 | @property 147 | def scale(self): 148 | return tuple(self._scale) 149 | --------------------------------------------------------------------------------