├── .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 | 
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 | 
21 |
22 |
23 |
24 | On the **RP2 pico w** we recommend the use of UART1 (**Tx - GPIO04, Rx - GPIO05**):
25 |
26 |
27 |
28 | 
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 | 
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 | 
18 |
19 |
20 | ### TRUMA aircon
21 |
22 | 
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 | 
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 += "SSID | Auth | Channel | RSSIS | BSSID |
\n"
304 | for (ssid, bssid, channel, RSSI, authmode, hidden) in a:
305 | tmp += "{:s}".format(ssid) + " | "
306 | tmp += "{} {}".format(authmodes[authmode-1], hidden) + " | "
307 | tmp += "{}".format(channel) + " | "
308 | tmp += "{}".format(RSSI) + " | "
309 | tmp += "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}".format(*bssid) + " |
\n"
310 | tmp += "
"
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 = " \n"
124 | return tmp
125 |
126 |
127 | def handlePost(self, path, name, txt, val):
128 | tmp = ""
129 | 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 = "
\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("")
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 |
--------------------------------------------------------------------------------