├── .gitattributes
├── .gitignore
├── .idea
├── .gitignore
├── codeStyles
│ ├── Project.xml
│ └── codeStyleConfig.xml
├── inspectionProfiles
│ └── Project_Default.xml
├── misc.xml
├── modules.xml
└── vcs.xml
├── .project
├── .pydevproject
├── .settings
└── org.eclipse.core.resources.prefs
├── CREDITS.html
├── LICENSE
├── README.md
├── data-logger.desktop
├── data-logger.iml
├── datalogger.kv
├── graphsview.py
├── history.py
├── images
├── graphs.png
├── graphs.svg
├── service.png
├── service.svg
├── temperature-measure.png
└── temperature-measure.svg
├── log.py
├── main.py
├── measurementsview.py
├── mocksignalsourcesconfig.py
├── mqttclient.py
├── popups.py
├── powermeterapatorec3.py
├── powermetersmlobis.py
├── requirements.txt
├── screenshots
├── graphs.png
└── measurements.png
├── serviceview.py
├── signalsources.py
├── signalsourcesconfig.py
├── start.sh
├── test_powermeterapatorec3.py
└── utils.py
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Set the default behavior, in case people don't have core.autocrlf set.
2 | * text=auto
3 |
4 | # Explicitly declare text files you want to always be normalized and converted
5 | # to native line endings on checkout.
6 | *.py text
7 | *.md text
8 | *.txt text
9 | *.html text
10 | *.desktop text eol=lf
11 | *.sh text eol=lf
12 |
13 | # Denote all files that are truly binary and should not be modified.
14 | *.png binary
15 | *.jpg binary
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /csv/
2 | /logs/
3 | /__pycache__/
4 | /env/
5 | /.idea/sonarlint/
6 | /venv/
7 | /*.pyc
8 | *secret*
9 | /nohup.out
10 | /*.pem
11 |
12 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.project:
--------------------------------------------------------------------------------
1 |
2 |
3 | data-logger
4 |
5 |
6 |
7 |
8 |
9 | org.python.pydev.PyDevBuilder
10 |
11 |
12 |
13 |
14 |
15 | org.python.pydev.pythonNature
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.pydevproject:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | /${PROJECT_DIR_NAME}
5 |
6 | python interpreter
7 | data-logger
8 |
9 |
--------------------------------------------------------------------------------
/.settings/org.eclipse.core.resources.prefs:
--------------------------------------------------------------------------------
1 | eclipse.preferences.version=1
2 | encoding/=UTF-8
3 |
--------------------------------------------------------------------------------
/CREDITS.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Credits
6 |
21 |
22 |
23 |
24 | Credits and License for Contained Icons
25 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Data Logger
2 | Measure, display and log temperatures with Raspberry PI 3B.
3 |
4 | This is a simple hobby project for logging temperatures of my heat pump and heating system.
5 | Feel free to use it in your own projects (Apache License Version 2.0).
6 |
7 | It contains some icons from [Flaticon](https://www.flaticon.com/), license [Creative Commons BY 3.0](http://creativecommons.org/licenses/by/3.0/), see [CREDITS](CREDITS.html) for details.
8 |
9 | ## Features
10 |
11 | ### Current Temperatures
12 | Show the current readings
13 |
14 | 
15 |
16 | ### Temperature Graphs
17 | Show how temperatures developed over the last 24 hours
18 |
19 | 
20 |
21 | ### MQTT Client
22 | Publish all temperature readings to a MQTT broker. This allows to track and show them in common MQTT apps.
23 | The MQTT messages contain JSON with timestamp and value:
24 |
25 | { "status": "ok", "timestamp": "2020-11-13T17:08:57.753064", "value": 9.697, "unit": "°C", "formatted": "9.7" }
26 |
27 | ### CSV Logging
28 | The temperature readings are written to CSV files and held for 32 days. This also allows to immediately re-fill the graphs view after restart.
29 |
30 | ## Hardware
31 |
32 | - Raspberry PI 3B
33 | - Official Raspberry PI 7" touch display, 800x480 (expensive, but well supported in Raspbian, multi-touch)
34 | - Official Raspberry power supply 5.1V/2.5A with additional 330uF capacitor for stabilization (Raspi is quite picky here...)
35 | - Heat sink on Raspberry CPU (... to avoid the random crashes that may occur otherwise)
36 | - DS 18B20 temperature sensors (1-Wire bus allows many sensors at the same GPIO port, well supported in Raspbian)
37 | - TSIC 306 temperature sensors (high accurracy, expensive, but already installed from a previous project)
38 |
39 | ## Platform
40 |
41 | - Raspbian Buster
42 | - Python 3.7
43 | - UI toolkit Kivy (nice and simple UIs with multi-touch support)
44 | - Charts with Kivy Garden Graph (fast drawing)
45 | - TSIC temperature sensors: python module tsic (sensor reading based on pigpio)
46 | - GPIO access: pigpio (IO with precise timing)
47 |
48 | ## Installation / Configuration
49 |
50 | Steps last performed on latest Raspbian of 2021-01-23
51 | ```
52 | # Install Raspbian buster to SD card,
53 | # optionally create files "ssh" and "wpa_supplicant.conf" according to doc
54 | # in boot partition for set-up without keybord and monitor before initial boot,
55 | # boot Raspi and ssh to raspberrypi as user pi password raspberry,
56 | # change user pi's password to a secure one!
57 |
58 | # Desktop packages
59 | sudo apt update
60 | sudo apt install lightdm lxsession
61 |
62 | # Basic config
63 | sudo raspi-config
64 | - set hostname
65 | - set time zone
66 | - enable VNC for remote access to desktop
67 | - enable 1-Wire
68 | - set boot to desktop with auto-login
69 |
70 | # Serial port for powermeter IR interface support
71 | # (will disable bluetooth that is attached to UART)
72 | sudo raspi-config
73 | - disable login shell over serial, but enable serial port hardware
74 | sudo echo "dtoverlay=disable-bt" >>/boot/config.txt
75 | sudo systemctl disable hciuart
76 |
77 | # GPIO access for TSIC sensor support
78 | sudo apt install pigpio
79 | sudo systemctl enable pigpiod
80 | sudo systemctl start pigpiod
81 |
82 | # Data-logger and required packages
83 | sudo apt install python3-pip
84 | cd ~
85 | git clone https://github.com/grillbaer/data-logger.git
86 | cd data-logger
87 | pip3 install -r requirements.txt
88 |
89 | # Graphical UI Kivy 2.0.0 installation steps according
90 | # to https://kivy.org/doc/stable/installation/installation-rpi.html
91 | sudo apt install pkg-config libgl1-mesa-dev libgles2-mesa-dev \
92 | libgstreamer1.0-dev \
93 | gstreamer1.0-plugins-{bad,base,good,ugly} \
94 | gstreamer1.0-{omx,alsa} libmtdev-dev \
95 | xclip xsel libjpeg-dev
96 | sudo apt install libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev
97 |
98 | # Official Raspberry Pi 7" display multi-touch and fullscreen
99 | # nano ~/.kivy/config.ini:
100 | [graphics]
101 | ...
102 | fullscreen = 1
103 | ...
104 | [input]
105 | mouse = mouse
106 | %(name)s = probesysfs,provider=hidinput
107 | mtdev_%(name)s = probesysfs,provider=mtdev
108 | hid_%(name)s = probesysfs,provider=hidinput
109 | ...
110 |
111 | # Display backlight switch permissions for pi
112 | echo 'SUBSYSTEM=="backlight",RUN+="/bin/chmod 666 /sys/class/backlight/%k/brightness /sys/class/backlight/%k/bl_power"' | \
113 | sudo tee /etc/udev/rules.d/backlight-permissions.rules
114 |
115 | # Disable regular screensaver in desktop preferences
116 |
117 | # Manually start data-logger
118 | data-logger/start.sh
119 |
120 | # Autostart data-logger at desktop login
121 | mkdir -p ~/.config/autostart
122 | ln -sf /home/pi/data-logger/data-logger.desktop ~/.config/autostart/
123 |
124 | # For data-logger configuration change data-logger/signalsourcesconfig.py,
125 | # you may use testsignalsourcesconfig.py for demo simulation
126 |
127 | # MQTT secret and TLS certificate
128 | echo "my-secret-mqtt-broker-password" >data-logger/secret-mqtt-password
129 | cp my-mqtt-broker-public-root-certificate >mqtt_broker_cacert.pem
130 | # set mqtt_broker_host: '' in signalsourcesconfig.py to disable MQTT
131 | ```
132 |
133 | - Log files path `logs/`
134 | - CSV files path `csv/`
135 | - Discover the DS18B20 sensor IDs in `/sys/bus/w1/devices/`
136 | - Configure data logger by changing `signalsourcesconfig.py`
137 | - Prefer WLAN to LAN to avoid power surges depending on sensor cabling!
138 |
--------------------------------------------------------------------------------
/data-logger.desktop:
--------------------------------------------------------------------------------
1 | [Desktop Entry]
2 | Type=Application
3 | Name=Data-Logger
4 | Exec=/home/pi/data-logger/start.sh
5 |
--------------------------------------------------------------------------------
/data-logger.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/datalogger.kv:
--------------------------------------------------------------------------------
1 | # UI definition
2 | # Copyright 2021, Holger Fleischmann, Bavaria/Germany
3 | # Apache License 2.0
4 |
5 | #:import MeasurementsGroup measurementsview
6 | #:import GraphsScreen graphsview
7 | #:import ServiceScreen serviceview
8 |
9 | :
10 | padding: [6, 6, 6, 6]
11 | orientation: 'vertical'
12 |
13 | status_text: ''
14 | time_text: ' '
15 | date_text: ' '
16 |
17 | # header bar:
18 | BoxLayout:
19 | orientation: 'horizontal'
20 | height: time_label.height + date_label.height
21 | size_hint_y: None
22 |
23 | Button:
24 | canvas:
25 | Rectangle:
26 | source: 'images/temperature-measure.png'
27 | pos: self.x + (self.width - 35) / 2, self.y + (self.height - 35) / 2
28 | size: 35, 35
29 | background_color: [0.6, 0.6, 1, 1] if screen_manager.index == 0 else [0.5, 0.5, 1, 0]
30 | width: self.height
31 | size_hint_x: None
32 | on_press:
33 | screen_manager.index = 0
34 |
35 | Button:
36 | canvas:
37 | Rectangle:
38 | source: 'images/graphs.png'
39 | pos: self.x + (self.width - 35) / 2, self.y + (self.height - 35) / 2
40 | size: 35, 35
41 | background_color: [0.6, 0.6, 1, 1] if screen_manager.index == 1 else [0.5, 0.5, 1, 0]
42 | width: self.height
43 | size_hint_x: None
44 | on_press:
45 | screen_manager.index = 1
46 |
47 | Button:
48 | canvas:
49 | Rectangle:
50 | source: 'images/service.png'
51 | pos: self.x + (self.width - 35) / 2, self.y + (self.height - 35) / 2
52 | size: 35, 35
53 | background_color: [0.6, 0.6, 1, 1] if screen_manager.index == 2 else [0.5, 0.5, 1, 0]
54 | width: self.height
55 | size_hint_x: None
56 | on_press:
57 | screen_manager.index = 2
58 |
59 | # status text:
60 | Label:
61 | id: status_label
62 | text: root.status_text
63 | halign: 'left'
64 | valign: 'center'
65 | font_size: '20sp'
66 | text_size: self.size
67 |
68 | # time and date in the top right corner:
69 | BoxLayout:
70 | orientation: 'vertical'
71 | size_hint_x: None
72 | Label:
73 | id: time_label
74 | text: root.time_text
75 | halign: 'center'
76 | valign: 'top'
77 | font_size: '24sp'
78 | size: self.texture_size
79 | Label:
80 | id: date_label
81 | font_size: '16sp'
82 | text: root.date_text
83 | halign: 'center'
84 | valign: 'top'
85 | color: [1, 1, 1, 0.85]
86 | size: self.texture_size
87 |
88 | Carousel:
89 | id: screen_manager
90 | direction: 'right'
91 | anim_type: 'in_out_circ'
92 | MeasurementsScreen:
93 | id: measurements_screen
94 | name: 'measurements'
95 | GraphsScreen:
96 | id: graphs_screen
97 | name: 'graphs'
98 | ServiceScreen:
99 | id: service_screen
100 | name: 'graphs'
101 |
102 |
103 | :
104 | BoxLayout:
105 | id: columns
106 | orientation: 'horizontal'
107 |
108 |
109 | :
110 | orientation: 'vertical'
111 |
112 | Label:
113 | id: header_text
114 | text: root.header_text
115 | font_size: '20sp'
116 | padding_y: 6
117 | #bold: True
118 | size: self.texture_size
119 | size_hint_y: None
120 | canvas.before:
121 | Color:
122 | rgba: (.5, 0.5, .5, .5)
123 | Rectangle:
124 | pos: (self.x + 1, self.y + 2)
125 | size: (self.width - 2, self.height - 4)
126 |
127 | ScrollView:
128 | GridLayout:
129 | id: measurements_view
130 | cols: 1
131 | size_hint_y: None
132 | height: self.minimum_height
133 |
134 | :
135 | font_size: '20sp'
136 |
137 | :
138 |
139 | signal_color: (0.0, 0.9, 0.1, 1.0)
140 |
141 | orientation: 'horizontal'
142 | height: 30
143 | size_hint_y: None
144 | canvas.before:
145 | Color:
146 | rgba: (.0, 0.9, .1, .3) if self.selected else (0, 0, 0, 1)
147 | Rectangle:
148 | pos: self.pos
149 | size: self.size
150 |
151 | Label:
152 | width: 24
153 | height: 30
154 | size_hint_x: None
155 | size_hint_y: None
156 | canvas.before:
157 | Color:
158 | rgba: root.signal_color
159 | Rectangle:
160 | pos: (self.x + 6, self.y + 8)
161 | size: (16, 16)
162 |
163 | Label:
164 | text: root.label
165 | markup: True
166 | font_size: '15sp'
167 | color: [1, 1, 1, 0.85]
168 | text_size: self.size
169 | halign: 'left'
170 | valign: 'center'
171 | size_hint_x: None
172 | width: 126
173 | padding_x: 3
174 | Label:
175 | # canvas.before:
176 | # Color:
177 | # rgba: (.9, 0.9, .1, .3)
178 | # Rectangle:
179 | # pos: self.pos
180 | # size: self.size
181 | text: root.value
182 | bold: True
183 | color: (1, 1, 1, .2) if root.stale else (1, 1, 1, 1)
184 | font_size: '16sp' if root.small else self.font_size
185 | text_size: self.size
186 | halign: 'right'
187 | valign: 'center'
188 | size_hint_x: None
189 | width: 65
190 | padding_x: 3
191 | Label:
192 | text: root.unit
193 | color: [1, 1, 1, 0.85]
194 | text_size: self.size
195 | font_size: '16sp' if root.small else self.font_size
196 | halign: 'left'
197 | valign: 'center'
198 | width : 65
199 | size_hint_x: None
200 | padding_x: 3
201 |
202 | :
203 |
204 | :
205 | BoxLayout:
206 | padding: [5, 0, 15, 0]
207 | GraphsCanvas:
208 | id: graphs_canvas
209 | padding: 5
210 | x_grid_label: False
211 | x_grid: True
212 | y_grid_label: True
213 | y_grid: True
214 |
215 | :
216 | BoxLayout:
217 | orientation: 'vertical'
218 | width: 300
219 | size_hint_x: None
220 | height: 200
221 | size_hint_y: None
222 | Button:
223 | text: 'Neustart'
224 | on_release:
225 | root.reboot_action()
226 |
227 | Button:
228 | text: 'Herunterfahren'
229 | on_release:
230 | root.shutdown_action()
231 |
232 | Button:
233 | text: 'Aktualisieren'
234 | on_release:
235 | root.update_action()
236 |
237 | :
238 | message: 'missing text'
239 | BoxLayout:
240 | orientation: 'vertical'
241 | AnchorLayout:
242 | Label:
243 | text: root.message
244 | AnchorLayout:
245 | anchor_x: 'right'
246 | anchor_y: 'bottom'
247 | size_hint_y: None
248 | BoxLayout:
249 | orientation: 'horizontal'
250 | Button:
251 | text: 'OK'
252 | on_release:
253 | root.ok_action()
254 | Button:
255 | text: 'Abbrechen'
256 | on_release:
257 | root.cancel_action()
--------------------------------------------------------------------------------
/graphsview.py:
--------------------------------------------------------------------------------
1 | """
2 | UI implementation of the graphs view.
3 | """
4 |
5 | __author__ = 'Holger Fleischmann'
6 | __copyright__ = 'Copyright 2021, Holger Fleischmann, Bavaria/Germany'
7 | __license__ = 'Apache License 2.0'
8 |
9 | from kivy.uix.boxlayout import BoxLayout
10 | from kivy_garden.graph import Graph, LinePlot
11 | from kivy.clock import Clock
12 |
13 | from history import SignalHistory
14 | import time
15 |
16 |
17 | class GraphsCanvas(Graph):
18 |
19 | def __init__(self, **kwargs):
20 | self.allowed_min_y = -15
21 | self.allowed_max_y = 60
22 | # note: ticks_minor means the number of subdivisions
23 | super().__init__(x_ticks_minor=6,
24 | x_ticks_major=3600,
25 | xlabel='Zeit',
26 | y_ticks_major=5,
27 | y_ticks_minor=5,
28 | ylabel='Temperaturen °C',
29 | xmin=0,
30 | xmax=3600,
31 | ymin=self.allowed_min_y,
32 | ymax=self.allowed_max_y)
33 |
34 |
35 | class GestureDetector(BoxLayout):
36 |
37 | def __init__(self, **kwargs):
38 | super().__init__(**kwargs)
39 | self.__touches = []
40 | self.pinch_continue = False
41 | self.__max_touches = 0
42 |
43 | def on_touch_down(self, touch):
44 | if self.collide_point(*touch.pos):
45 | self.pinch_continue = False
46 | touch.grab(self)
47 | self.__touches.append(touch)
48 | self.__max_touches += 1
49 | return super().on_touch_down(touch)
50 |
51 | def on_touch_move(self, touch):
52 | if touch.grab_current is not self or len(self.__touches) != 2:
53 | return super().on_touch_move(touch)
54 | t1 = self.__touches[0]
55 | t2 = self.__touches[1]
56 | # ox, oy are only consistent after seconds touch point moved:
57 | if touch != t2:
58 | return
59 | self.on_pinch(
60 | self.pinch_continue,
61 | ((t1.ox + t2.ox) / 2., (t1.oy + t2.oy) / 2.),
62 | ((t1.x + t2.x) / 2., (t1.y + t2.y) / 2.),
63 | (abs(t1.ox - t2.ox), abs(t1.oy - t2.oy)),
64 | (abs(t1.x - t2.x), abs(t1.y - t2.y))
65 | )
66 | self.pinch_continue = True
67 | return True
68 |
69 | def on_touch_up(self, touch):
70 | if touch.grab_current is self:
71 |
72 | if len(self.__touches) == 1:
73 | if self.__max_touches == 1 and touch.is_double_tap:
74 | self.on_double_tap((touch.x, touch.y))
75 | self.__max_touches = 0
76 |
77 | self.pinch_continue = False
78 | touch.ungrab(self)
79 | self.__touches.remove(touch)
80 | return super().on_touch_up(touch)
81 |
82 | def on_pinch(self, pinch_continue, orig_center, center, orig_size, size):
83 | pass
84 |
85 | def on_double_tap(self, center):
86 | pass
87 |
88 |
89 | class GraphsScreen(GestureDetector):
90 |
91 | def __init__(self, **kwargs):
92 | super().__init__(**kwargs)
93 | self.graph_labels = []
94 | self.history = SignalHistory()
95 | self.plots = []
96 | self.graph_visible = []
97 | self.x_range = self.history.max_seconds
98 | self.x_max = None
99 | self.begin_min_y = None
100 | self.begin_max_y = None
101 |
102 | def use_signals_config(self, signal_sources_config):
103 | self.graph_labels = []
104 |
105 | self.history = SignalHistory()
106 | if 'history_max' in signal_sources_config:
107 | self.history.max_seconds = signal_sources_config['history_max']
108 | if 'history_delta' in signal_sources_config:
109 | self.history.delta_seconds = signal_sources_config['history_delta']
110 |
111 | self.plots = []
112 | self.graph_visible = []
113 |
114 | for group in signal_sources_config['groups']:
115 | group_label = group['label']
116 | for source in group['sources']:
117 | self.graph_labels.append(group_label + ' ' + source.label)
118 | self.history.add_source(source)
119 | self.graph_visible.append(source.with_graph)
120 | plot = LinePlot(color=source.color)
121 | self.plots.append(plot)
122 |
123 | for source_plot in sorted(zip(self.history.sources, self.plots), key=lambda sp: sp[0].z_order):
124 | self.ids.graphs_canvas.add_plot(source_plot[1])
125 |
126 | self.history.write_to_csv('csv/signals')
127 | self.history.load_from_csv_files()
128 | self.history.start()
129 |
130 | self.x_range = self.history.max_seconds
131 | self.x_max = None
132 |
133 | Clock.schedule_once(self.update_graphs, 0.)
134 |
135 | def update_graphs(self, dt):
136 | with self.history:
137 | if self.x_max is None:
138 | now = time.time()
139 | self.ids.graphs_canvas.xmax = now + 240 # workaround for cut-off right graph edge
140 | self.ids.graphs_canvas.xmin = now - self.x_range
141 | else:
142 | self.ids.graphs_canvas.xmax = self.x_max
143 | self.ids.graphs_canvas.xmin = self.x_max - self.x_range
144 |
145 | for (source, plot, visible) in zip(self.history.sources, self.plots, self.graph_visible):
146 | if visible:
147 | plot.points = self.history.get_values(source)
148 | else:
149 | plot.points = []
150 |
151 | Clock.schedule_once(self.update_graphs, self.history.delta_seconds / 2)
152 | pass
153 |
154 | def on_double_tap(self, center):
155 | graph = self.ids.graphs_canvas
156 | pos = graph.to_data(center[0], center[1])
157 | if self.x_max is None:
158 | # zoom in
159 | self.x_range = self.history.max_seconds / 12
160 | self.x_max = min(time.time(), pos[0] + self.x_range / 2)
161 | else:
162 | # reset zoom
163 | self.x_range = self.history.max_seconds
164 | self.x_max = None
165 | self.update_graphs(None)
166 |
167 | def on_pinch(self, pinch_continue, orig_center, center, orig_size, size):
168 | graph = self.ids.graphs_canvas
169 | # if not pinch_continue:
170 | # print()
171 | # print()
172 | # print()
173 | # print(str(pinch_continue) + " oc=" + str(orig_center) + " os=" + str(orig_size) + " c=" + str(center) + " s=" + str(size))
174 | if pinch_continue:
175 | new_center_y = (self.begin_min_y + self.begin_max_y) / 2.
176 | new_center_y -= (
177 | graph.to_data(0, center[1] - graph.y)[1]
178 | - graph.to_data(0, orig_center[1] - graph.y)[1]
179 | )
180 | new_delta_y = self.begin_max_y - self.begin_min_y
181 | if orig_size[1] > 50:
182 | new_delta_y = new_delta_y * (orig_size[1] / max(1, size[1]))
183 | new_delta_y = (max(5, new_delta_y) // 5 + 1) * 5
184 | # print('new center=' + str(new_center_y) + ' delta=' + str(new_delta_y))
185 | new_min_y = new_center_y - new_delta_y / 2.
186 | new_min_y = (new_min_y + 2.5) // 5 * 5
187 | new_min_y = min(graph.allowed_max_y - new_delta_y, new_min_y)
188 | new_min_y = max(graph.allowed_min_y, new_min_y)
189 | new_max_y = new_min_y + new_delta_y
190 | new_max_y = min(graph.allowed_max_y, new_max_y)
191 | graph.ymin = new_min_y
192 | graph.ymax = new_max_y
193 | else:
194 | self.begin_min_y = graph.ymin
195 | self.begin_max_y = graph.ymax
196 | # print('begin_min=' + str(self.begin_min_y) + ' _max=' + str(self.begin_max_y))
197 |
--------------------------------------------------------------------------------
/history.py:
--------------------------------------------------------------------------------
1 | """
2 | Signal history in memory and persistence in plain CSV files.
3 | Used as model of the graphs view.
4 | """
5 |
6 | __author__ = 'Holger Fleischmann'
7 | __copyright__ = 'Copyright 2021, Holger Fleischmann, Bavaria/Germany'
8 | __license__ = 'Apache License 2.0'
9 |
10 | from typing import List, Dict, Tuple
11 |
12 | import csv
13 | from datetime import datetime
14 | import logging
15 | import os
16 | import re
17 | from threading import RLock
18 | import time
19 |
20 | from signalsources import SignalSource
21 | from utils import RepeatTimer
22 |
23 | logger = logging.getLogger().getChild(__name__)
24 |
25 |
26 | class SignalHistory:
27 | MAX_SECONDS_DEFAULT = 24 * 3600 # 1 day
28 | DELTA_SECONDS_DEFAULT = 60 # every minute
29 | MAX_SECONDS_CSV_FILES = 24 * 3600 * 32 # 32 days
30 |
31 | sources: List[SignalSource]
32 | _values_by_source_id: Dict[int, List[Tuple[float, float]]]
33 |
34 | def __init__(self):
35 |
36 | self.max_seconds = SignalHistory.MAX_SECONDS_DEFAULT
37 | self.delta_seconds = SignalHistory.DELTA_SECONDS_DEFAULT
38 | self.max_seconds_csv_files = SignalHistory.MAX_SECONDS_CSV_FILES
39 | self.max_csv_lines = self.max_seconds // self.delta_seconds + 1
40 |
41 | self.sources = []
42 | self._values_by_source_id = {}
43 | self._timer = None
44 | self._data_lock = RLock()
45 | self._csv_file_basename = None
46 | self._csv_file = None
47 | self._csv_writer = None
48 | self._csv_lines = 0
49 |
50 | def __enter__(self):
51 | self._data_lock.acquire()
52 |
53 | def __exit__(self, exc_type, exc_value, traceback):
54 | self._data_lock.release()
55 |
56 | def add_source(self, signal_source: SignalSource) -> None:
57 | with self._data_lock:
58 | if signal_source in self.sources:
59 | self.sources.remove(signal_source)
60 | self.sources.append(signal_source)
61 | self._values_by_source_id[id(signal_source)] = []
62 |
63 | def remove_source(self, signal_source):
64 | with self._data_lock:
65 | self.sources.remove(signal_source)
66 | self._values_by_source_id.pop(id(signal_source))
67 |
68 | def start(self):
69 | if self._timer is None:
70 | logger.info('Starting to record history every ' +
71 | str(self.delta_seconds) + 's for ' + str(self.max_seconds) + 's')
72 | self._begin_new_csv_file()
73 | self._timer = RepeatTimer(self.delta_seconds, self.record)
74 | self._timer.start()
75 |
76 | def stop(self):
77 | if self._timer is not None:
78 | self._timer.cancel()
79 | self._timer = None
80 | self._close_csv_file()
81 | logger.info('Stopped recording history')
82 |
83 | def get_values(self, signal_source):
84 | with self._data_lock:
85 | return self._values_by_source_id[id(signal_source)]
86 |
87 | def record(self):
88 | row = []
89 | with self._data_lock:
90 | now = time.time()
91 | row.append(round(now, 3))
92 | self.__clean_old_history(now)
93 | for source in self.sources:
94 | value = source.last_value
95 | if (value is not None
96 | and value.status == SignalSource.STATUS_OK
97 | and value.timestamp > now - self.delta_seconds
98 | and source.running):
99 | self._values_by_source_id[id(source)].append((now, value.value))
100 | row.append(float(source.value_format.format(value.value)))
101 | else:
102 | row.append(None)
103 |
104 | if self._csv_writer is not None:
105 | self._csv_writer.writerow(row)
106 | self._csv_lines += 1
107 | self._csv_file.flush()
108 | if self._csv_lines >= self.max_csv_lines:
109 | self._begin_new_csv_file()
110 |
111 | def __clean_old_history(self, now):
112 | with self._data_lock:
113 | for source_id in self._values_by_source_id:
114 | values = self._values_by_source_id[source_id]
115 | while len(values) > 1 and (now - values[1][0] > self.max_seconds):
116 | values.pop(0)
117 |
118 | def write_to_csv(self, file_basename):
119 | self._close_csv_file()
120 | self._csv_file_basename = file_basename
121 |
122 | def _begin_new_csv_file(self):
123 | self._close_csv_file()
124 | if self._csv_file_basename is not None:
125 | self._delete_old_csv_files()
126 | file_name = self._new_csv_file_name()
127 | dir_name = os.path.split(file_name)[0]
128 | if dir_name != '':
129 | os.makedirs(dir_name, 0o775, True)
130 | logger.info("Writing new CSV file '" + file_name + "'")
131 | self._csv_file = open(file_name, 'w', newline='', encoding='utf-8')
132 | self._csv_writer = csv.writer(self._csv_file)
133 | self._csv_writer.writerow(['Time'] + [source.label for source in self.sources])
134 | self._csv_lines = 1
135 |
136 | def _close_csv_file(self):
137 | if self._csv_file is not None:
138 | logger.info("'Closing CSV file '" + self._csv_file.name + "'")
139 | self._csv_file.close()
140 | self._csv_file = None
141 | self._csv_writer = None
142 | self._csv_lines = 0
143 |
144 | def _new_csv_file_name(self):
145 | return '{:}-{:%Y-%m-%d-%H%M%S}.csv'.format(self._csv_file_basename, datetime.now())
146 |
147 | def _delete_old_csv_files(self):
148 | try:
149 | for file_info in self._list_csv_files():
150 | if file_info[1] + self.max_seconds < time.time() - self.max_seconds_csv_files:
151 | logger.info("Deleting old CSV file '" + file_info[0] + "'")
152 | os.remove(file_info[0])
153 | except:
154 | logger.exception('Failed to delete old CSV files')
155 |
156 | def _list_csv_files(self):
157 | """
158 | Read list of existing CSV files and their begin and modification times as list of tuples
159 | [(full_file_name, begin_timestamp, last_modified_timestamp), ...].
160 | """
161 | dir_name, file_prefix = os.path.split(self._csv_file_basename)
162 | file_pattern = re.compile(
163 | '^' + file_prefix + '-(([0-9]{4})-([0-9]{2})-([0-9]{2})-([0-9]{2})([0-9]{2})([0-9]{2})).csv$')
164 |
165 | csv_files = []
166 | for file in os.listdir(dir_name):
167 | match = file_pattern.match(file)
168 | if match is not None:
169 | full_path = os.path.join(dir_name, file)
170 | if os.path.isfile(full_path):
171 | begin = datetime.strptime(match.group(1), '%Y-%m-%d-%H%M%S').timestamp()
172 | modified = os.stat(full_path).st_mtime
173 | csv_files.append((full_path, begin, modified))
174 |
175 | return sorted(csv_files, key=lambda x: x[1])
176 |
177 | def load_from_csv_files(self):
178 | logger.info('Trying to restore history from CSV files...')
179 | try:
180 | begin_time = time.time() - self.max_seconds
181 | for file_info in self._list_csv_files():
182 | if file_info[2] > begin_time:
183 | self._load_rows_from_csv_file(begin_time, file_info[0])
184 | except:
185 | logger.exception('Failed to restore history from CSV files')
186 |
187 | def _load_rows_from_csv_file(self, begin_time, csv_file):
188 | logger.info("Restoring history from CSV file '" + csv_file + "'")
189 | len_sources = len(self.sources)
190 | with open(csv_file, 'r', encoding='utf-8') as file:
191 | csv_reader = csv.reader(file)
192 | first_line = True
193 | for row in csv_reader:
194 | if first_line:
195 | first_line = False
196 | elif len(row) == 1 + len_sources:
197 | row_time = float(row[0])
198 | if row_time >= begin_time:
199 | for source, value_string in zip(self.sources, row[1:]):
200 | if len(value_string) > 0:
201 | value = float(value_string)
202 | self._values_by_source_id[id(source)].append((row_time, value))
203 |
--------------------------------------------------------------------------------
/images/graphs.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/grillbaer/data-logger/8f99e75a951b3f443e1b993cccf7b12c407d88a8/images/graphs.png
--------------------------------------------------------------------------------
/images/graphs.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/images/service.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/grillbaer/data-logger/8f99e75a951b3f443e1b993cccf7b12c407d88a8/images/service.png
--------------------------------------------------------------------------------
/images/service.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | image/svg+xml
--------------------------------------------------------------------------------
/images/temperature-measure.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/grillbaer/data-logger/8f99e75a951b3f443e1b993cccf7b12c407d88a8/images/temperature-measure.png
--------------------------------------------------------------------------------
/images/temperature-measure.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/log.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | Logging set-up.
4 |
5 | NOTE: kivy sets the root logger to some strange and buggy behavior,
6 | a patch is necessary: comment out 'logging.root = Logger' in kivy/logger.py
7 | """
8 |
9 | __author__ = 'Holger Fleischmann'
10 | __copyright__ = 'Copyright 2018, Holger Fleischmann, Bavaria/Germany'
11 | __license__ = 'Apache License 2.0'
12 |
13 | import logging.config
14 | import os
15 | import psutil
16 | import subprocess
17 | import utils
18 |
19 | os.makedirs('logs', 0o775, True)
20 |
21 | LOGGING = {
22 | 'version': 1,
23 | 'disable_existing_loggers': True,
24 | 'formatters': {
25 | 'standard': {
26 | 'format': '%(asctime)s %(levelname)-8.8s [%(threadName)-12.12s] %(name)-13.13s: %(message)s [%(filename)s:%(lineno)d]',
27 | 'datefmt': "%Y-%m-%d %H:%M:%S",
28 | }
29 | },
30 | 'handlers': {
31 | 'console': {
32 | 'level': 'DEBUG',
33 | 'formatter': 'standard',
34 | 'class': 'logging.StreamHandler',
35 | },
36 | 'rotate_file': {
37 | 'level': 'DEBUG',
38 | 'formatter': 'standard',
39 | 'class': 'logging.handlers.RotatingFileHandler',
40 | 'filename': 'logs/datalogger.log',
41 | 'encoding': 'utf8',
42 | 'maxBytes': 1000000,
43 | 'backupCount': 9,
44 | }
45 | },
46 | 'loggers': {
47 | '': {
48 | 'handlers': ['rotate_file'],
49 | 'level': 'DEBUG',
50 | },
51 | }
52 | }
53 |
54 | logging.config.dictConfig(LOGGING)
55 |
56 | logger = logging.getLogger().getChild(__name__)
57 | logger.info('''
58 |
59 |
60 | --------------------------------------------------------------------
61 | --- DATALOGGER APPLICATION RESTART --------------------------------
62 | --------------------------------------------------------------------
63 |
64 | ''')
65 |
66 | # ensure that the header has been printed before any other output
67 | for handler in logger.handlers:
68 | handler.flush()
69 |
70 |
71 | def _tick_out() -> None:
72 | try:
73 | cpu_temp = subprocess.check_output(
74 | 'vcgencmd measure_temp',
75 | stderr=subprocess.STDOUT,
76 | shell=True).decode('latin-1').replace('\n', '').replace("'", "°")
77 | except:
78 | cpu_temp = 'temp unknown'
79 |
80 | try:
81 | this_process = psutil.Process(os.getpid())
82 | mem_usage = 'VM {:.1f}MB, RSS {:.1f}MB'.format(
83 | this_process.memory_info().vms / 1e6,
84 | this_process.memory_info().rss / 1e6)
85 | except:
86 | mem_usage = 'mem unknown'
87 |
88 | logging.debug('TICK - CPU ' + cpu_temp + ', memory usage ' + mem_usage)
89 |
90 |
91 | # Setup log timer tick to support diagnostics of power supply problems:
92 | utils.RepeatTimer(60, _tick_out).start()
93 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/python3
2 |
3 | """
4 | Data Logger application.
5 | """
6 |
7 | __author__ = 'Holger Fleischmann'
8 | __copyright__ = 'Copyright 2021, Holger Fleischmann, Bavaria/Germany'
9 | __license__ = 'Apache License 2.0'
10 |
11 | # initialize custom logging:
12 | import log
13 |
14 | import logging
15 | from datetime import datetime
16 | import time
17 |
18 | from kivy.config import Config
19 | from kivy.app import App
20 | from kivy.clock import Clock
21 | from kivy.properties import StringProperty
22 | from kivy.uix.boxlayout import BoxLayout
23 |
24 | from mqttclient import MqttClient
25 |
26 | from signalsourcesconfig import signal_sources_config
27 | # from mocksignalsourcesconfig import signal_sources_config
28 |
29 | logger = logging.getLogger().getChild(__name__)
30 |
31 |
32 | class DataLoggerWidget(BoxLayout):
33 | """
34 | Main widget of the application.
35 | """
36 | status_text = StringProperty()
37 | time_text = StringProperty()
38 | date_text = StringProperty()
39 |
40 | SCREENSAVER_DELAY = 600
41 |
42 | def __init__(self, **kwargs):
43 | super().__init__(**kwargs)
44 | self.clock_update_event = Clock.schedule_interval(self.update_clock, 1.)
45 | self.ids.measurements_screen.use_signals_config(signal_sources_config)
46 | self.ids.graphs_screen.use_signals_config(signal_sources_config)
47 |
48 | self._last_user_activity = time.time()
49 | self._screensaver_active = None
50 | self._activate_screensaver(False)
51 |
52 | def update_clock(self, *args):
53 | now = time.time()
54 | dt = datetime.fromtimestamp(now)
55 | self.time_text = '{:%H:%M:%S}'.format(dt)
56 | self.date_text = '{:%d.%m.%Y}'.format(dt)
57 |
58 | if self._last_user_activity is not None and self._last_user_activity < now - DataLoggerWidget.SCREENSAVER_DELAY:
59 | self._activate_screensaver(True)
60 | self._last_user_activity = None
61 |
62 | def on_touch_down(self, touch):
63 | self._last_user_activity = time.time()
64 | self._activate_screensaver(False)
65 | return super().on_touch_down(touch)
66 |
67 | def _activate_screensaver(self, activate):
68 | if self._screensaver_active != activate:
69 | self._screensaver_active = activate
70 | try:
71 | with open('/sys/class/backlight/rpi_backlight/bl_power', 'w') as file:
72 | file.write('1' if activate else '0')
73 | except:
74 | logger.error('Could not ' + ('activate' if activate else 'deactivate') + ' screensaver')
75 |
76 |
77 | class DataLoggerApp(App):
78 |
79 | def build(self):
80 | return DataLoggerWidget()
81 |
82 |
83 | if __name__ == '__main__':
84 | logger.error('Starting application')
85 | Config.set('graphics', 'width', '800')
86 | Config.set('graphics', 'height', '480')
87 | mqtt_client = MqttClient()
88 | mqtt_client.use_signals_config(signal_sources_config)
89 | mqtt_client.start()
90 | DataLoggerApp().run()
91 |
--------------------------------------------------------------------------------
/measurementsview.py:
--------------------------------------------------------------------------------
1 | """
2 | UI implementation of the measurements view.
3 | """
4 |
5 | __author__ = 'Holger Fleischmann'
6 | __copyright__ = 'Copyright 2018, Holger Fleischmann, Bavaria/Germany'
7 | __license__ = 'Apache License 2.0'
8 |
9 | import logging
10 | import time
11 |
12 | from kivy.clock import Clock
13 | from kivy.clock import mainthread
14 | from kivy.properties import BooleanProperty
15 | from kivy.properties import StringProperty
16 | from kivy.properties import ListProperty
17 | from kivy.uix.boxlayout import BoxLayout
18 |
19 | from signalsources import SignalSource, SignalValue
20 |
21 | logger = logging.getLogger().getChild(__name__)
22 |
23 |
24 | class MeasurementsScreen(BoxLayout):
25 |
26 | def __init__(self, **kwargs):
27 | super().__init__(**kwargs)
28 | self.history = None
29 |
30 | def use_signals_config(self, signal_sources_config):
31 | for group in signal_sources_config['groups']:
32 | group_label = group['label']
33 | group_widget = MeasurementsGroup(group_label)
34 | self.ids.columns.add_widget(group_widget)
35 | for source in group['sources']:
36 | logger.info('Adding signal {:12} > {:18} - {}'.format(group_label, source.label, str(source)))
37 | group_widget.add_source(source)
38 | source.start()
39 |
40 |
41 | class MeasurementsGroup(BoxLayout):
42 | header_text = StringProperty()
43 |
44 | def __init__(self, header_text, **kwargs):
45 | super().__init__(**kwargs)
46 | self.header_text = header_text
47 |
48 | def add_source(self, source):
49 | self.ids.measurements_view.add_widget(MeasurementItem(source))
50 |
51 |
52 | class MeasurementItem(BoxLayout):
53 | selected = BooleanProperty(False)
54 | label = StringProperty()
55 | value = StringProperty()
56 | unit = StringProperty()
57 | stale = BooleanProperty(False)
58 | small = BooleanProperty(False)
59 | signal_color = ListProperty()
60 |
61 | def __init__(self, source, **kwargs):
62 | super().__init__(**kwargs)
63 | self.source = source
64 | self.label = source.label
65 | self.unit = source.unit
66 | self.signal_color = source.color
67 | self.small = source.small
68 | self.stale_clock = None
69 | self.update_value(SignalValue(0, SignalSource.STATUS_MISSING, time.time()))
70 | self.source.add_callback(self.update_value)
71 |
72 | @mainthread
73 | def update_value(self, signal_value):
74 | self.value = '---' if signal_value.status != SignalSource.STATUS_OK else self.source.format(signal_value.value)
75 | self.stale = signal_value.value is None or signal_value.status != SignalSource.STATUS_OK
76 | if not self.stale:
77 | self.start_stale_timer()
78 |
79 | def mark_stale(self, dt):
80 | self.stale = True
81 | self.stop_stale_timer()
82 |
83 | def start_stale_timer(self):
84 | self.stop_stale_timer()
85 | self.stale_clock = Clock.schedule_once(self.mark_stale, self.source.stale_secs)
86 |
87 | def stop_stale_timer(self):
88 | if self.stale_clock is not None:
89 | self.stale_clock.cancel()
90 | self.stale_clock = None
91 |
--------------------------------------------------------------------------------
/mocksignalsourcesconfig.py:
--------------------------------------------------------------------------------
1 | """
2 | Test configuration for development containing random test signals.
3 | To be used as import in main.py for simulation.
4 | """
5 |
6 | __author__ = 'Holger Fleischmann'
7 | __copyright__ = 'Copyright 2021, Holger Fleischmann, Bavaria/Germany'
8 | __license__ = 'Apache License 2.0'
9 |
10 | from signalsources import TestSource, TestDigitalSource, DeltaSource
11 |
12 | _quelle_ein = TestSource( 'temp-from-well', 12.5, 1, label='Quelle ein', unit='°C', value_format='{:.1f}', color=[0.5, 0.5, 1.0, 1.0], z_order=1)
13 | _quelle_aus = TestSource( 'temp-to-well', 10.3, 1, label='Quelle aus', unit='°C', value_format='{:.1f}', color=[0.0, 0.2, 1.0, 1.0], z_order=2)
14 | _wp_hz_vor = TestSource( 'temp-hp-heat-supply', 31.5, 1, label='Heizung Vorlauf', unit='°C', value_format='{:.1f}', color=[1.0, 0.0, 0.0, 1.0], z_order=2)
15 | _wp_ww_vor = TestSource( 'temp-hp-water-supply', 49.6, 1, label='Wasser Vorlauf', unit='°C', value_format='{:.1f}', color=[0.2, 0.3, 0.9, 1.0], z_order=0)
16 | _wp_rueck = TestSource( 'temp-hp-return', 25.1, 1, label='Rücklauf', unit='°C', value_format='{:.1f}', color=[0.5, 0.1, 0.7, 1.0], z_order=1)
17 | _wp_mode = TestDigitalSource('mode-boiler', 60, label='Modus', unit='', text_0='HZ', text_1='WW', color=[0.4, 0.4, 0.4, 1.0], z_order=-1)
18 | _hz_vor = TestSource( 'temp-heat-supply', 30.1, 1, label='Heizung Vorlauf', unit='°C', value_format='{:.1f}', color=[1.0, 0.6, 0.6, 1.0], z_order=2)
19 | _hz_rueck = TestSource( 'temp-heat-return', 24.5, 1, label='Heizung Rücklauf', unit='°C', value_format='{:.1f}', color=[0.7, 0.6, 1.0, 1.0], z_order=1)
20 | _ww_speicher_o = TestSource( 'temp-water-boiler-top', 46.2, 1, label='Wasser oben', unit='°C', value_format='{:.1f}', color=[0.8, 0.7, 1.0, 1.0], z_order=-1, corr_offset=+2.5)
21 | _ww_speicher_u = TestSource( 'temp-water-boiler-middle', 38.8, 1, label='Wasser unten', unit='°C', value_format='{:.1f}', color=[0.4, 0.3, 0.5, 1.0], z_order=-2)
22 | _ww_zirk = TestSource( 'temp-water-circ-return', 34.2, 1, label='Zirkulation', unit='°C', value_format='{:.1f}', color=[0.1, 0.6, 0.4, 1.0], z_order=-1)
23 | _lu_frisch = TestSource( 'temp-air-fresh', 5.2, 1, label='Frischluft', unit='°C', value_format='{:.1f}', color=[0.2, 0.8, 1.0, 1.0], z_order=-1)
24 | _lu_fort = TestSource( 'temp-air-exhaust', 8.7, 1, label='Fortluft', unit='°C', value_format='{:.1f}', color=[0.7, 0.3, 0.1, 1.0], z_order=-1)
25 | _lu_zu = TestSource( 'temp-air-supply', 21.2, 1, label='Zuluft', unit='°C', value_format='{:.1f}', color=[0.7, 0.8, 0.9, 1.0], z_order=0)
26 | _lu_ab = TestSource( 'temp-air-return', 21.8, 1, label='Abluft', unit='°C', value_format='{:.1f}', color=[0.9, 0.6, 0.3, 1.0], z_order=0)
27 | _lu_aussen = TestSource( 'temp-outdoor', 5.0, 1, label='Außentemperatur', unit='°C', value_format='{:.1f}', color=[0.1, 0.5, 0.2, 1.0], z_order=1)
28 | _ht_leistung = TestSource( 'power-heat-high-tariff', 2000, 10, label='Leistung HT', unit='W', value_format='{:.0f}', color=[0.9, 0.4, 0.1, 1.0], with_graph=False, small=False)
29 | _nt_leistung = TestSource( 'power-heat-low-tariff', 2000, 10, label='Leistung NT', unit='W', value_format='{:.0f}', color=[0.2, 0.3, 0.9, 1.0], with_graph=False, small=False)
30 | _ht_reading = TestSource( 'reading-heat-high-tariff', 12345, 10, label='Stand HT', unit='kWh',value_format='{:.1f}', color=[0.9, 0.4, 0.1, 0.5], with_graph=False, small=True)
31 | _nt_reading = TestSource( 'reading-heat-low-tariff', 12345, 10, label='Stand NT', unit='kWh',value_format='{:.1f}', color=[0.2, 0.3, 0.9, 0.5], with_graph=False, small=True)
32 | _hh_leistung = TestSource( 'power-household', 12345, 10, label='Haushalt', unit='kWh',value_format='{:.1f}', color=[0.9, 0.8, 0.1, 1.0], with_graph=False, small=True)
33 |
34 | signal_sources_config = {
35 | 'groups' : [
36 | {'label' : 'Wärmepumpe',
37 | 'sources' : [
38 | _quelle_ein,
39 | _quelle_aus,
40 | DeltaSource('temp-well-delta', _quelle_ein, _quelle_aus, label='\u0394 Quelle', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
41 | _wp_hz_vor,
42 | _wp_mode,
43 | DeltaSource('temp-hp-heat-delta', _wp_hz_vor, _wp_rueck, label='\u0394 Heizung', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
44 | _wp_ww_vor,
45 | DeltaSource('temp-hp-water-delta', _wp_ww_vor, _wp_rueck, label='\u0394 Wasser', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
46 | _wp_rueck,
47 | ]},
48 | {'label' : 'Heizung/Warmwasser',
49 | 'sources' : [
50 | _hz_vor,
51 | _hz_rueck,
52 | DeltaSource('temp-heat-delta', _hz_vor, _hz_rueck, label='\u0394 Heizung', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
53 | DeltaSource('temp-heat-supply-hp-delta', _wp_hz_vor, _hz_vor, label=' \u0394 WP-Hz Vor', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
54 | DeltaSource('temp-heat-return-hp-delta', _wp_rueck, _hz_rueck, label=' \u0394 WP-Hz Rück', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
55 | _ww_speicher_o,
56 | _ww_speicher_u,
57 | _ww_zirk,
58 | DeltaSource('temp-water-circ-return-delta', _ww_speicher_o, _ww_zirk, label='\u0394 Wasser-Zirk', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
59 | ]},
60 | {'label' : 'Lüftung/Zähler',
61 | 'sources' : [
62 | _lu_frisch,
63 | _lu_fort,
64 | DeltaSource('temp-air-fresh-exhaust-delta', _lu_fort, _lu_frisch, label='\u0394 Fort-Frisch', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
65 | _lu_zu,
66 | _lu_ab,
67 | DeltaSource('temp-air-supply-return-delta', _lu_ab, _lu_zu, label='\u0394 Ab-Zu', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
68 | _lu_aussen,
69 | _ht_leistung,
70 | _nt_leistung,
71 | _ht_reading,
72 | _nt_reading,
73 | _hh_leistung,
74 | ]}
75 | ],
76 |
77 | 'mqtt_broker_host' : '',
78 | 'mqtt_broker_port' : 1883,
79 | 'mqtt_broker_user' : '',
80 | 'mqtt_broker_password' : '',
81 | 'mqtt_broker_base_topic' : 'data-logger-test',
82 | 'mqtt_use_ssl' : True,
83 | 'mqtt_broker_ca_certs' : 'cacerts.pem'
84 | }
85 |
--------------------------------------------------------------------------------
/mqttclient.py:
--------------------------------------------------------------------------------
1 | """
2 | MQTT client for sending measurements to a MQTT broker.
3 | """
4 |
5 | __author__ = 'Holger Fleischmann'
6 | __copyright__ = 'Copyright 2018, Holger Fleischmann, Bavaria/Germany'
7 | __license__ = 'Apache License 2.0'
8 |
9 | import logging
10 | import json
11 | from datetime import datetime, timezone
12 |
13 | import paho.mqtt.client as mqtt
14 |
15 | from signalsources import SignalSource
16 | from utils import RepeatTimer
17 |
18 | logger = logging.getLogger().getChild(__name__)
19 |
20 |
21 | class MqttClient:
22 | """
23 | Sends all signal changes to a MQTT broker.
24 | """
25 |
26 | DELTA_SECONDS_DEFAULT = 10 # seconds
27 |
28 | def __init__(self):
29 | self.broker_host = 'localhost'
30 | self.broker_user = ''
31 | self.broker_port = 1883
32 | self.broker_password = ''
33 | self.use_ssl = False
34 | self.broker_ca_certs = None
35 | self.broker_base_topic = 'datalogger'
36 | self.client = mqtt.Client()
37 | # self.client.enable_logger(logger)
38 | self.client.on_connect = self._on_connect
39 | self.client.on_disconnect = self._on_disconnect
40 | self.client.on_message = self._on_message
41 | self.__started = False
42 | self.__timer = None
43 | self.delta_seconds = MqttClient.DELTA_SECONDS_DEFAULT
44 | self.sources = []
45 |
46 | def use_signals_config(self, signal_sources_config):
47 | self.broker_host = signal_sources_config['mqtt_broker_host']
48 | self.broker_port = signal_sources_config['mqtt_broker_port']
49 | self.broker_user = signal_sources_config['mqtt_broker_user']
50 | self.broker_password = signal_sources_config['mqtt_broker_password']
51 | self.use_ssl = signal_sources_config['mqtt_use_ssl']
52 | self.broker_ca_certs = signal_sources_config['mqtt_broker_ca_certs']
53 | self.broker_base_topic = signal_sources_config['mqtt_broker_base_topic']
54 | for group in signal_sources_config['groups']:
55 | for source in group['sources']:
56 | self.sources.append(source)
57 |
58 | def start(self):
59 | if not self.__started:
60 | if self.broker_host == '':
61 | logger.info("NOT starting MQTT client because of config with empty broker")
62 | else:
63 | logger.info("Starting MQTT client for broker " + self.broker_host)
64 | if self.broker_user != '':
65 | self.client.username_pw_set(self.broker_user, self.broker_password)
66 | if self.use_ssl:
67 | self.client.tls_set(ca_certs=self.broker_ca_certs)
68 | self.client.connect_async(self.broker_host, self.broker_port)
69 | self.client.loop_start()
70 | self.__timer = RepeatTimer(self.delta_seconds, self.publish)
71 | self.__timer.start()
72 | self.__started = True
73 |
74 | def stop(self):
75 | if self.__started:
76 | logger.info("Stopping MQTT client for broker " + self.broker_host)
77 | self.__started = False
78 | self.__timer.cancel()
79 | self.__timer = None
80 | self.client.disconnect()
81 | self.client.loop_stop(True)
82 |
83 | def publish(self):
84 | for source in self.sources:
85 | signal_value = source.last_value
86 | if signal_value is not None:
87 | topic = self.broker_base_topic + '/' + source.identifier
88 | json_value = json.dumps({
89 | 'value': signal_value.value,
90 | 'status': signal_value.status,
91 | 'formatted':
92 | '---' if signal_value.status != SignalSource.STATUS_OK
93 | else source.format(signal_value.value),
94 | 'timestamp': datetime.fromtimestamp(signal_value.timestamp).astimezone(timezone.utc).
95 | strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
96 | 'unit': source.unit
97 | })
98 | self.client.publish(topic, json_value, 0, True)
99 |
100 | def _on_connect(self, client, userdata, flags, rc):
101 | if rc == 0:
102 | logger.info("Connected to MQTT broker " + self.broker_host)
103 | else:
104 | logger.error("Failed to connect to MQTT broker " + self.broker_host + " rc=" + str(rc))
105 | # this would be the place to client.subscribe("#")
106 |
107 | def _on_disconnect(self, client, userdata, rc):
108 | if rc == 0:
109 | logger.info("Disconnected from MQTT broker " + self.broker_host)
110 | else:
111 | logger.error("Connection lost to MQTT broker " + self.broker_host + " rc=" + str(rc))
112 |
113 | def _on_message(self, client, userdata, message):
114 | # this would be the place to receive subscription messages
115 | pass
116 |
--------------------------------------------------------------------------------
/popups.py:
--------------------------------------------------------------------------------
1 | """
2 | UI popups.
3 | """
4 |
5 | __author__ = 'Holger Fleischmann'
6 | __copyright__ = 'Copyright 2018, Holger Fleischmann, Bavaria/Germany'
7 | __license__ = 'Apache License 2.0'
8 |
9 | from kivy.properties import StringProperty
10 | from kivy.uix.popup import Popup
11 |
12 |
13 | class OkCancelPopup(Popup):
14 |
15 | message = StringProperty()
16 | result = StringProperty()
17 |
18 | def __init__(self, ok_callback=None, title='missing title', **kwargs):
19 | super().__init__(title=title, **kwargs)
20 | self.ok_callback = ok_callback
21 | self.open()
22 |
23 | def ok_action(self):
24 | self.dismiss()
25 | self.result = 'ok'
26 | if self.ok_callback is not None:
27 | self.ok_callback()
28 |
29 | def cancel_action(self):
30 | self.dismiss()
31 | self.result = 'cancel'
32 |
--------------------------------------------------------------------------------
/powermeterapatorec3.py:
--------------------------------------------------------------------------------
1 | """
2 | Communication with APATOR EC3 power meter to get its actual readings.
3 | """
4 |
5 | from __future__ import annotations
6 |
7 | __author__ = 'Holger Fleischmann'
8 | __copyright__ = 'Copyright 2021, Holger Fleischmann, Bavaria/Germany'
9 | __license__ = 'Apache License 2.0'
10 |
11 | import logging
12 | import time
13 | from typing import NamedTuple, Optional, Callable, List
14 |
15 | import serial
16 | from serial import SEVENBITS, PARITY_EVEN, SerialException
17 |
18 | from utils import RepeatTimer
19 |
20 | logger = logging.getLogger().getChild(__name__)
21 |
22 |
23 | class PowerMeterReading(NamedTuple):
24 | success: bool
25 | consumption_total_sum_kwh: Optional[float]
26 | consumption_high_sum_kwh: Optional[float]
27 | consumption_low_sum_kwh: Optional[float]
28 |
29 |
30 | class PowerMeterApatorEC3:
31 | """
32 | Communication object to get readings from an APATOR EC3 electrical power meter.
33 | Tested only with a 12EC3 two tariff version to get the readings for 1.8.1 and 1.8.2 OBIS values.
34 | Unfortunately, this meter does not provide any actual effective power values.
35 |
36 | Uses serial communication with the front IR interface.
37 | Sends a request to the power meter and reads it's response, i.e. a bidirectional
38 | TX/RX infrared interface must be connected to the serial port.
39 | Communication needs quite long timeouts and delays because the meter is reaaaaally slow.
40 | """
41 |
42 | serial_port: str
43 | _serial: Optional[serial.Serial]
44 |
45 | def __init__(self, serial_port: str):
46 | """
47 | Create new communication object for power meter.
48 | Does not yet open the serial port.
49 |
50 | :param serial_port: serial port to use, e.g. "COM5" on Windows or "/dev/serialUSB0" on Linux
51 | """
52 | self.serial_port = serial_port
53 | self._serial = None
54 |
55 | def open(self) -> None:
56 | """
57 | Open the serial port if not open yet. Don't forget to close it when not needed any more.
58 |
59 | :raises: serial.serialutil.SerialException
60 | """
61 | if self._serial is None:
62 | logger.info("Opening serial port " + self.serial_port)
63 | self._serial = \
64 | serial.Serial(self.serial_port,
65 | baudrate=300, bytesize=SEVENBITS, parity=PARITY_EVEN,
66 | timeout=10)
67 |
68 | def close(self) -> None:
69 | """
70 | Close the serial port if open.
71 | """
72 | if self._serial is not None:
73 | logger.info("Closing serial port " + self.serial_port)
74 | self._serial.close()
75 | self._serial = None
76 |
77 | def read_raw(self) -> str:
78 | """
79 | Read the raw response from the power meter.
80 |
81 | :return: raw response string
82 |
83 | :raises: serial.serialutil.SerialException if communication failed
84 | """
85 | logger.debug("Sending request on serial port ...")
86 | request = b'/?!\r\n'
87 | self._serial.write(request)
88 | self._serial.flush()
89 | time.sleep(2)
90 |
91 | ack_output = b'\x06000\r\n'
92 | self._serial.write(ack_output)
93 | self._serial.flush()
94 | time.sleep(2)
95 |
96 | logger.debug("Reading response from serial port ...")
97 | data = self._serial.read(65536)
98 | if len(data) > 0:
99 | logger.debug("Response:\n" + data.decode("ascii"))
100 | return data.decode("ascii")
101 |
102 | def read(self) -> PowerMeterReading:
103 | """
104 | Try to read values from the power meter. Automatically opens the serial interface
105 | if not yet open. Closes it upon SerialException to force reopening on next attempt.
106 |
107 | :return: reading with values for the case of success, empty reading in case of failure
108 | """
109 | try:
110 | self.open()
111 | return self._parse_raw(self.read_raw())
112 | except SerialException:
113 | self.close()
114 | return PowerMeterReading(False, None, None, None)
115 |
116 | def _parse_raw(self, raw: str) -> PowerMeterReading:
117 | high = None
118 | low = None
119 |
120 | for line in raw.splitlines(keepends=False):
121 | cleaned = line.strip('\x02\x03\n\r \t')
122 | if cleaned.startswith("1.8.1*"):
123 | high = self._parse_line_float(cleaned)
124 | elif cleaned.startswith("1.8.2*"):
125 | low = self._parse_line_float(cleaned)
126 |
127 | if high is not None and low is not None:
128 | total = high + low
129 | else:
130 | total = None
131 |
132 | return PowerMeterReading(True, total, high, low)
133 |
134 | def _parse_line_str(self, cleaned_line: str) -> Optional[str]:
135 | begin = cleaned_line.find("(") + 1
136 | end = cleaned_line.rfind(")")
137 | if begin != -1 and end != -1:
138 | return cleaned_line[begin:end]
139 | else:
140 | return None
141 |
142 | def _parse_line_float(self, cleaned_line: str) -> Optional[float]:
143 | try:
144 | return float(self._parse_line_str(cleaned_line))
145 | except ValueError:
146 | return None
147 |
148 |
149 | class SingleCounter:
150 | _prev_reading: Optional[float]
151 | _prev_was_edge: bool
152 | power: Optional[float]
153 | power_from_ts: Optional[float]
154 | power_to_ts: Optional[float]
155 |
156 | def __init__(self):
157 | self._prev_reading = None
158 | self._prev_was_edge = False
159 | self.power = None
160 | self.power_from_ts = None
161 | self.power_to_ts = None
162 |
163 | def update(self, reading_kwh: Optional[float], reading_ts: float, min_averaging_secs: float,
164 | other_counter: SingleCounter):
165 | if reading_kwh is not None \
166 | and self._prev_reading != reading_kwh \
167 | and (self.power_to_ts is None or (reading_ts - self.power_to_ts) >= min_averaging_secs):
168 | if self._prev_was_edge and self.power_to_ts is not None:
169 | self.power = (reading_kwh - self._prev_reading) * 3.6e6 / \
170 | (reading_ts - self.power_to_ts)
171 | self.power_from_ts = self.power_to_ts
172 | other_counter.power = 0
173 | other_counter.power_from_ts = self.power_from_ts
174 | other_counter._prev_was_edge = True
175 |
176 | if self._prev_reading is not None:
177 | self._prev_was_edge = True
178 | self._prev_reading = reading_kwh
179 | self.power_to_ts = reading_ts
180 |
181 |
182 | class PowerMeterApatorEC3Repeating:
183 | min_averaging_secs: float
184 | _power_meter: PowerMeterApatorEC3
185 | _timer: RepeatTimer
186 |
187 | reading: Optional[PowerMeterReading]
188 | reading_ts: Optional[float]
189 | success: bool
190 |
191 | high: SingleCounter
192 | low: SingleCounter
193 |
194 | callbacks: List[Callable[[Optional[PowerMeterReading]], None]]
195 |
196 | def __init__(self, power_meter: PowerMeterApatorEC3, interval: float, min_averaging_secs: float):
197 | self.min_averaging_secs = min_averaging_secs
198 | self._power_meter = power_meter
199 | self._timer = RepeatTimer(interval, self._acquire)
200 | self.reading = None
201 | self.reading_ts = None
202 | self.success = False
203 | self.high = SingleCounter()
204 | self.low = SingleCounter()
205 | self.callbacks = []
206 |
207 | def add_callback(self, callback: Callable[[Optional[PowerMeterReading]], None]):
208 | self.callbacks.append(callback)
209 |
210 | def start(self):
211 | if not self._timer.is_alive():
212 | self._timer.start()
213 |
214 | def stop(self):
215 | self._timer.cancel()
216 | self._power_meter.close()
217 |
218 | def _acquire(self):
219 | try:
220 | ts = time.time()
221 | self.reading = self._power_meter.read()
222 | self.reading_ts = ts
223 | self._update_high_power()
224 | self._update_low_power()
225 | self.success = True
226 | except SerialException:
227 | self.success = False
228 | self._fire()
229 |
230 | def _update_low_power(self):
231 | self.low.update(self.reading.consumption_low_sum_kwh, self.reading_ts, self.min_averaging_secs, self.high)
232 |
233 | def _update_high_power(self):
234 | self.high.update(self.reading.consumption_high_sum_kwh, self.reading_ts, self.min_averaging_secs, self.low)
235 |
236 | def _fire(self):
237 | for callback in self.callbacks:
238 | callback(self.reading)
239 |
240 |
241 | if __name__ == '__main__':
242 | pm = PowerMeterApatorEC3Repeating(PowerMeterApatorEC3("COM5"), 30, 10)
243 | pm.callbacks.append(lambda r: print(pm.success, r, pm.reading_ts, pm.low.power, pm.high.power))
244 | pm.start()
245 |
--------------------------------------------------------------------------------
/powermetersmlobis.py:
--------------------------------------------------------------------------------
1 | from threading import Thread, Event
2 | from typing import Optional, Callable, List, Dict
3 |
4 | import logging
5 | from serial import Serial, SerialException
6 | from smllib import SmlStreamReader
7 | from smllib.errors import SmlLibException
8 | from smllib.sml import SmlListEntry
9 |
10 | logger = logging.getLogger().getChild(__name__)
11 |
12 |
13 | class ObisMeta:
14 | obis_short: str
15 | unit_code: int
16 | identifier: str
17 | meta_scaler: int
18 | unit: str
19 |
20 | def __init__(self, obis_short: str, unit_code: int, identifier: str, meta_scaler: int, unit: str):
21 | self.obis_short = obis_short
22 | self.unit_code = unit_code
23 | self.identifier = identifier
24 | self.meta_scaler = meta_scaler
25 | self.unit = unit
26 |
27 |
28 | class ObisValue:
29 | sml_raw: SmlListEntry
30 | meta: Optional[ObisMeta]
31 | numeric_value: Optional[float]
32 | string_value: Optional[str]
33 |
34 | def __init__(self, sml_raw: SmlListEntry):
35 | self.sml_raw = sml_raw
36 |
37 | obis_short = sml_raw.obis.obis_short
38 | unit_code = sml_raw.unit
39 | value_scaler = sml_raw.scaler
40 | value = sml_raw.value
41 |
42 | self.meta = _OBIS_METAS_BY_OBIS_SHORT.get(obis_short)
43 | if self.meta is not None and self.meta.unit_code != unit_code:
44 | self.meta = None
45 |
46 | if self.meta is not None and self.meta.unit_code is not None and value_scaler is not None:
47 | scaler = value_scaler + self.meta.meta_scaler
48 | else:
49 | scaler = None
50 |
51 | if scaler is not None:
52 | value *= 10 ** scaler
53 | self.numeric_value = value
54 | value_format = "{:." + str(max(0, -scaler)) + "f}"
55 | else:
56 | value_format = "{}"
57 |
58 | self.string_value = value_format.format(value)
59 |
60 | def __str__(self) -> str:
61 | obis_short = self.sml_raw.obis.obis_short
62 | if self.meta is not None:
63 | identifier = self.meta.identifier
64 | unit = self.meta.unit
65 | else:
66 | identifier = ""
67 | unit = "" if self.sml_raw.unit is None else self.sml_raw.unit
68 |
69 | return f"{obis_short:8} {identifier:25} {self.string_value:>20} {unit}"
70 |
71 |
72 | # Unit codes:
73 | _UC_WATT_HOUR = 30
74 | _UC_WATT = 27
75 | _UC_VOLT = 35
76 | _UC_AMPERE = 33
77 | _UC_HERTZ = 44
78 | _UC_DEGREE = 8
79 |
80 | # Known OBIS value meta data infos:
81 | _OBIS_METAS = [
82 | ObisMeta("1.8.0", _UC_WATT_HOUR, "energy_import", -3, "kWh"),
83 | ObisMeta("1.8.1", _UC_WATT_HOUR, "energy_import_tariff_1", -3, "kWh"),
84 | ObisMeta("1.8.2", _UC_WATT_HOUR, "energy_import_tariff_2", -3, "kWh"),
85 | ObisMeta("2.8.0", _UC_WATT_HOUR, "energy_export", -3, "kWh"),
86 | ObisMeta("16.7.0", _UC_WATT, "active_power", 0, "W"),
87 | ObisMeta("36.7.0", _UC_WATT, "active_power_l1", 0, "W"),
88 | ObisMeta("56.7.0", _UC_WATT, "active_power_l2", 0, "W"),
89 | ObisMeta("76.7.0", _UC_WATT, "active_power_l3", 0, "W"),
90 | ObisMeta("12.7.0", _UC_VOLT, "voltage", 0, "V"),
91 | ObisMeta("32.7.0", _UC_VOLT, "voltage_l1", 0, "V"),
92 | ObisMeta("52.7.0", _UC_VOLT, "voltage_l2", 0, "V"),
93 | ObisMeta("72.7.0", _UC_VOLT, "voltage_l3", 0, "V"),
94 | ObisMeta("11.7.0", _UC_AMPERE, "current", 0, "A"),
95 | ObisMeta("31.7.0", _UC_AMPERE, "current_l1", 0, "A"),
96 | ObisMeta("51.7.0", _UC_AMPERE, "current_l2", 0, "A"),
97 | ObisMeta("71.7.0", _UC_AMPERE, "current_l3", 0, "A"),
98 | ObisMeta("14.7.0", _UC_HERTZ, "frequency", 0, "Hz"),
99 | ObisMeta("81.7.1", _UC_DEGREE, "phase_voltage_l2_l1", 0, "°"),
100 | ObisMeta("81.7.2", _UC_DEGREE, "phase_voltage_l3_l1", 0, "°"),
101 | ObisMeta("81.7.4", _UC_DEGREE, "phase_current_voltage_l1", 0, "°"),
102 | ObisMeta("81.7.15", _UC_DEGREE, "phase_current_voltage_l2", 0, "°"),
103 | ObisMeta("81.7.26", _UC_DEGREE, "phase_current_voltage_l3", 0, "°"),
104 | ]
105 |
106 | _OBIS_METAS_BY_OBIS_SHORT = dict()
107 | for obis_meta in _OBIS_METAS:
108 | _OBIS_METAS_BY_OBIS_SHORT[obis_meta.obis_short] = obis_meta
109 |
110 |
111 | class PowerMeterSmlObisReader:
112 | _thread: Optional[Thread]
113 | _running_event: Event
114 | _receive_callbacks: List[Callable[['PowerMeterSmlObisReader'], None]]
115 |
116 | _serial_factory: Callable[[], Serial]
117 | _serial: Optional[Serial]
118 |
119 | _reader: SmlStreamReader
120 |
121 | values: List[ObisValue]
122 | values_by_id: Dict[str, ObisValue]
123 |
124 | def __init__(self,
125 | serial_factory: Callable[[], Serial]):
126 | super().__init__()
127 | self._thread = None
128 | self._running_event = Event()
129 | self._receive_callbacks = []
130 | self._serial_factory = serial_factory
131 | self._serial = None
132 | self.values = []
133 | self.values_by_id = dict()
134 |
135 | def add_callback(self, callback: Callable[['PowerMeterSmlObisReader'], None]) -> None:
136 | self._receive_callbacks.append(callback)
137 |
138 | def start(self) -> None:
139 | if self._thread is None:
140 | self._reader = SmlStreamReader()
141 | self._running_event.set()
142 | self._thread = Thread(target=self._run)
143 | self._thread.start()
144 |
145 | def stop(self) -> None:
146 | if self._thread is not None:
147 | self._running_event.clear()
148 | self._thread = None
149 |
150 | def _fire_received(self) -> None:
151 | for callback in self._receive_callbacks:
152 | callback(self)
153 |
154 | def _run(self) -> None:
155 | logger.info("Starting acquisition of power meter SML OBIS values")
156 | while self._running_event.is_set():
157 | try:
158 | self._step()
159 | except Exception as err:
160 | logger.error("Unexpected exception: " + str(type(err)) + ": " + str(err))
161 | self._close_serial()
162 | logger.info("Stopped acquisition of power meter SML OBIS values")
163 |
164 | def _step(self) -> None:
165 | self._open_serial()
166 | try:
167 | data = self._serial.read(1024)
168 | except SerialException as err:
169 | self._close_serial()
170 | return
171 |
172 | self._reader.add(data)
173 | try:
174 | frame = self._reader.get_frame()
175 | if frame is None:
176 | return
177 |
178 | frame.parse_frame()
179 | sml_raws = frame.get_obis()
180 |
181 | values = []
182 | values_by_id = dict()
183 |
184 | for sml_raw in sml_raws:
185 | obis_value = ObisValue(sml_raw)
186 | values.append(obis_value)
187 | if obis_value.meta is not None:
188 | values_by_id[obis_value.meta.identifier] = obis_value
189 |
190 | self.values = values
191 | self.values_by_id = values_by_id
192 |
193 | self._fire_received()
194 |
195 | except SmlLibException as err:
196 | logger.warning("SML decoding error: " + str(type(err)) + ": " + str(err))
197 |
198 | def _open_serial(self) -> None:
199 | if self._serial is None:
200 | self._serial = self._serial_factory()
201 |
202 | def _close_serial(self) -> None:
203 | if self._serial is not None:
204 | self._serial.close()
205 | self._serial = None
206 |
207 |
208 | if __name__ == "__main__":
209 |
210 | def _print_callback(reader: PowerMeterSmlObisReader):
211 | print("--------------------------------------------------------------")
212 | for value in reader.values:
213 | print(value)
214 |
215 |
216 | def _serial_factory():
217 | return Serial(port="/dev/ttyUSB0", baudrate=9600, timeout=0.2)
218 |
219 |
220 | reader = PowerMeterSmlObisReader(serial_factory=_serial_factory)
221 | reader.add_callback(_print_callback)
222 | reader.start()
223 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | tsic
2 | psutil
3 | paho-mqtt
4 | pigpio
5 | Kivy
6 | kivy-garden.graph
7 | pyserial
8 | smllib
9 |
10 | # install this manually AFTER other deps:
11 | #https://github.com/kivy-garden/graph/archive/master.zip
12 |
13 | # patch kivy/logger.py and comment out change of the kivy root logger like this:
14 | #logging.root = Logger
15 |
--------------------------------------------------------------------------------
/screenshots/graphs.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/grillbaer/data-logger/8f99e75a951b3f443e1b993cccf7b12c407d88a8/screenshots/graphs.png
--------------------------------------------------------------------------------
/screenshots/measurements.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/grillbaer/data-logger/8f99e75a951b3f443e1b993cccf7b12c407d88a8/screenshots/measurements.png
--------------------------------------------------------------------------------
/serviceview.py:
--------------------------------------------------------------------------------
1 | """
2 | UI implementation of the service view.
3 | """
4 |
5 | __author__ = 'Holger Fleischmann'
6 | __copyright__ = 'Copyright 2018, Holger Fleischmann, Bavaria/Germany'
7 | __license__ = 'Apache License 2.0'
8 |
9 | import logging
10 | import os
11 | from subprocess import call
12 |
13 | from kivy.uix.boxlayout import BoxLayout
14 |
15 | from popups import OkCancelPopup
16 |
17 | logger = logging.getLogger().getChild(__name__)
18 |
19 |
20 | class ServiceScreen(BoxLayout):
21 |
22 | def reboot_action(self):
23 | OkCancelPopup(title='Neustart', message='System jetzt neu starten?', ok_callback=self.reboot)
24 |
25 | def reboot(self):
26 | logger.info('REBOOT triggered')
27 | self.exec(['sudo', 'reboot', 'now'])
28 |
29 | def shutdown_action(self):
30 | OkCancelPopup(title='Herunterfahren', message='System jetzt herunterfahren?', ok_callback=self.shutdown)
31 |
32 | def shutdown(self):
33 | logger.info('SHUTDOWN triggered')
34 | self.exec(['sudo', 'shutdown', 'now'])
35 |
36 | def update_action(self):
37 | OkCancelPopup(title='Aktualisieren', message='Software jetzt aktualisieren und System neu starten?',
38 | ok_callback=self.update)
39 |
40 | def update(self):
41 | logger.info('UPDATE triggered')
42 | if self.exec(['sh', '-c', 'cd ' + os.getcwd() + '; git pull']) == 0:
43 | self.reboot()
44 |
45 | def exec(self, command):
46 | try:
47 | exit_code = call(command)
48 | if exit_code != 0:
49 | logger.error('Command ' + str(command) + ' exited with code ' + str(exit_code))
50 | return exit_code
51 | except IOError as ex:
52 | logger.error('Command ' + str(command) + ' failed: ' + str(ex))
53 | return -1
54 |
--------------------------------------------------------------------------------
/signalsources.py:
--------------------------------------------------------------------------------
1 | """
2 | Definition of the different input signal sources that can be used for
3 | measurements.
4 | """
5 |
6 | __author__ = 'Holger Fleischmann'
7 | __copyright__ = 'Copyright 2021, Holger Fleischmann, Bavaria/Germany'
8 | __license__ = 'Apache License 2.0'
9 |
10 | import logging
11 | import random
12 | import re
13 | import time
14 | from typing import Callable, Any, Optional, NamedTuple, List
15 |
16 | import pigpio
17 | from tsic import TsicInputChannel, PigpioNotConnectedError
18 |
19 | from utils import RepeatTimer
20 |
21 | logger = logging.getLogger().getChild(__name__)
22 |
23 |
24 | class SignalValue(NamedTuple):
25 | value: float
26 | status: str
27 | timestamp: Optional[float]
28 |
29 |
30 | class SignalSource:
31 | """
32 | Common signal source for measurement values.
33 | """
34 | STATUS_OK = 'ok'
35 | STATUS_MISSING = 'missing'
36 |
37 | SEND_MIN_DELTA = 0.5
38 |
39 | identifier: str
40 |
41 | label: str
42 | unit: str
43 | value_format: str
44 |
45 | color: List[float]
46 | z_order: int
47 | with_graph: bool
48 | small: bool
49 |
50 | stale_secs: float
51 | corr_offset: float
52 |
53 | callbacks: List[Callable[[SignalValue], None]]
54 | last_sent_timestamp: Optional[float]
55 | last_value: Optional[SignalValue]
56 | running: bool
57 |
58 | def __init__(self,
59 | identifier: str,
60 | label: str = 'Value',
61 | unit: str = '',
62 | value_format: str = '{:.1f}',
63 | color: List[float] = [0.6, 0.6, 0.6, 1.0],
64 | z_order: int = 0,
65 | with_graph: bool = True,
66 | small: bool = False,
67 | stale_secs: float = 10,
68 | corr_offset: float = 0.0):
69 | self.identifier = identifier
70 | self.label = label
71 | self.unit = unit
72 | self.value_format = value_format
73 | self.color = color
74 | self.z_order = z_order
75 | self.with_graph = with_graph
76 | self.small = small
77 | self.stale_secs = stale_secs
78 | self.corr_offset = corr_offset
79 | self.callbacks = []
80 | self.last_sent_timestamp = None
81 | self.last_value = None
82 | self.running = False
83 |
84 | def add_callback(self, callback: Callable[[SignalValue], None]) -> None:
85 | """
86 | Add a callback to receive signal values as
87 | callback(SignalValue).
88 | The callback may be called on arbitrary threads and must return quickly.
89 | """
90 | self.callbacks.append(callback)
91 |
92 | def remove_callback(self, callback: Callable[[SignalValue], None]) -> None:
93 | self.callbacks.remove(callback)
94 |
95 | def start(self) -> None:
96 | """
97 | Start sending measurements to the callbacks.
98 | Implementors must override this function to start measurement and
99 | call super().start(callback).
100 | Send signal values using _send(value, status).
101 | """
102 | logger.debug('Starting ' + str(self))
103 | self.running = True
104 |
105 | def stop(self) -> None:
106 | """
107 | Stop sending measurements.
108 | Implementors must override this function to stop measurement and
109 | call super().start(callback).
110 | """
111 | logger.debug('Stopping ' + str(self))
112 | self.running = False
113 |
114 | def _send(self, value: float, status: str = STATUS_OK, timestamp: Optional[float] = None):
115 | if self.running:
116 | ts = time.time() if timestamp is None else timestamp
117 | self._send_signal_value(SignalValue(value + self.corr_offset, status, ts))
118 |
119 | def _send_signal_value(self, signal_value: SignalValue) -> None:
120 | if self.running:
121 | self.last_value = signal_value
122 | if self.last_sent_timestamp is None or self.last_sent_timestamp + SignalSource.SEND_MIN_DELTA <= signal_value.timestamp:
123 | self.last_sent_timestamp = signal_value.timestamp
124 | for callback in self.callbacks:
125 | try:
126 | callback(self.last_value)
127 | except:
128 | logger.exception('Exception from signal source callback ' + str(self))
129 |
130 | def format(self, value: float) -> str:
131 | return self.value_format.format(value)
132 |
133 | def __repr__(self) -> str:
134 | return self.__class__.__name__
135 |
136 |
137 | class TestSource(SignalSource):
138 | """
139 | Random test measurement signal source.
140 | """
141 |
142 | def __init__(self, identifier: str, value: float, interval: float, **kwargs):
143 | super().__init__(identifier, **kwargs)
144 | self.value = value
145 | self.interval = interval
146 | self._timer = RepeatTimer(interval, self._send_random_value)
147 |
148 | def _send_random_value(self) -> None:
149 | self._send(round(random.gauss(self.value, 2), 3), self.STATUS_OK)
150 |
151 | def start(self) -> None:
152 | super().start()
153 | self._timer.start()
154 |
155 | def stop(self) -> None:
156 | super().stop()
157 | self._timer.cancel()
158 |
159 |
160 | class TestDigitalSource(SignalSource):
161 | """
162 | Random test digital input signal source.
163 | """
164 |
165 | def __init__(self, identifier: str, interval: float, text_0: str = 'off', text_1: str = 'on', **kwargs):
166 | super().__init__(identifier, **kwargs)
167 | self.text_0 = text_0
168 | self.text_1 = text_1
169 | self._timer = RepeatTimer(interval, self._send_value)
170 |
171 | def _send_value(self) -> None:
172 | self._send(random.choice([0, 1]), self.STATUS_OK)
173 |
174 | def start(self) -> None:
175 | super().start()
176 | self._timer.start()
177 |
178 | def stop(self) -> None:
179 | super().stop()
180 | self._timer.cancel()
181 |
182 | def format(self, value: float) -> str:
183 | return self.text_1 if value != 0 else self.text_0
184 |
185 |
186 | class DeltaSource(SignalSource):
187 | """
188 | Signal source that calculates the delta between two other signals.
189 | """
190 |
191 | def __init__(self, identifier: str, signal_a: SignalSource, signal_b: SignalSource, **kwargs):
192 | super().__init__(identifier, **kwargs)
193 | self.signal_a = signal_a
194 | self.signal_b = signal_b
195 | self.signal_a.add_callback(self._a_updated)
196 | self.signal_b.add_callback(self._b_updated)
197 | self._value_a = SignalValue(0, self.STATUS_MISSING, None)
198 | self._value_b = self._value_a
199 |
200 | def _a_updated(self, value: SignalValue) -> None:
201 | self._value_a = value
202 | self._send_value()
203 |
204 | def _b_updated(self, value: SignalValue) -> None:
205 | self._value_b = value
206 | self._send_value()
207 |
208 | def _send_value(self) -> None:
209 | if self._value_a == self.STATUS_MISSING or self._value_b == self.STATUS_MISSING:
210 | self._send(0, self.STATUS_MISSING)
211 | else:
212 | self._send(round(self._value_a.value - self._value_b.value, 3), self.STATUS_OK)
213 |
214 |
215 | class MappingSource(SignalSource):
216 | """
217 | Signal source that maps its value from some other input.
218 | The mapping function must map (input_source, input_value) -> SignalValue.
219 | The input_source must have a method add_callback(callback_func(input_value)) and a start() method.
220 | """
221 |
222 | def __init__(self,
223 | identifier: str,
224 | input_source: Any,
225 | mapping_func: Callable[[Any, Any], SignalValue],
226 | **kwargs):
227 | super().__init__(identifier, **kwargs)
228 | self._input_source = input_source
229 | self._input_source.add_callback(self._updated)
230 | self._mapping_func = mapping_func
231 |
232 | def _updated(self, input_value) -> None:
233 | signal_value = self._mapping_func(self._input_source, input_value)
234 | if signal_value.timestamp is None:
235 | signal_value = SignalValue(signal_value.value, signal_value.status, time.time())
236 | self._send_signal_value(signal_value)
237 |
238 | def start(self) -> None:
239 | super().start()
240 | self._input_source.start()
241 |
242 | def stop(self) -> None:
243 | super().stop()
244 | self._input_source.stop()
245 |
246 |
247 | class TsicSource(SignalSource):
248 | """
249 | Temperature measurement signal source from TSIC 206/306 connected to GPIO.
250 | """
251 |
252 | def __init__(self, identifier: str,
253 | pigpio_pi: pigpio,
254 | gpio_bcm: int,
255 | **kwargs):
256 | super().__init__(identifier, **kwargs)
257 | try:
258 | self.__gpio = gpio_bcm
259 | self.tsic = TsicInputChannel(pigpio_pi, gpio_bcm)
260 | except Exception as e:
261 | logger.warning('Failed to initialize TSIC input channel: ' + str(e))
262 | # handle missing GPIO access for windows development
263 | self.tsic = None
264 |
265 | def start(self) -> None:
266 | super().start()
267 | if self.tsic is not None:
268 | self.tsic.start(lambda m: self._send(round(m.degree_celsius, 3), self.STATUS_OK))
269 |
270 | def stop(self) -> None:
271 | super().stop()
272 | if self.tsic is not None:
273 | self.tsic.stop()
274 |
275 | def __repr__(self) -> str:
276 | return super().__repr__() + ' bcm_gpio=' + str(self.__gpio)
277 |
278 |
279 | class Ds1820Source(SignalSource):
280 | """
281 | Temperature measurement signal source from DS18x20 connected to W1 bus GPIO.
282 | """
283 |
284 | def __init__(self, identifier: str, sensor_id: str, interval: float, **kwargs):
285 | super().__init__(identifier, **kwargs)
286 | self.sensor_id = sensor_id
287 | self._timer = RepeatTimer(interval, self._read_and_send_value)
288 |
289 | def start(self) -> None:
290 | super().start()
291 | self._timer.start()
292 |
293 | def stop(self) -> None:
294 | super().stop()
295 | self._timer.cancel()
296 |
297 | def _read_and_send_value(self) -> None:
298 | temp = self.read_once()
299 | if temp is not None:
300 | self._send(round(temp, 3), self.STATUS_OK)
301 |
302 | def read_once(self) -> Optional[float]:
303 | try:
304 | with open('/sys/bus/w1/devices/' + self.sensor_id + '/w1_slave', 'r') as file:
305 | file.readline()
306 | temp_line = file.readline()
307 | match = re.search('.*t=(-?[0-9]+)', temp_line)
308 | if match is not None:
309 | return float(match.group(1)) / 1000.
310 | except OSError:
311 | logger.warning("Failed to read DS1820 file for " + self.sensor_id)
312 | return None
313 |
314 | def __repr__(self) -> str:
315 | return super().__repr__() + ' id=' + self.sensor_id
316 |
317 |
318 | class DigitalInSource(SignalSource):
319 | """
320 | Digital GPIO input signal source.
321 | """
322 |
323 | pi: pigpio
324 |
325 | def __init__(self,
326 | identifier: str,
327 | pigpio_pi: pigpio,
328 | gpio_bcm: int,
329 | interval: float,
330 | text_0: str = 'off',
331 | text_1: str = 'on',
332 | **kwargs):
333 | super().__init__(identifier, **kwargs)
334 | self.pi = pigpio_pi
335 | self.gpio_bcm = gpio_bcm
336 | self.interval = interval
337 | self.text_0 = text_0
338 | self.text_1 = text_1
339 | if self.pi.connected:
340 | self.pi.set_mode(self.gpio_bcm, pigpio.INPUT)
341 | self.pi.set_pull_up_down(self.gpio_bcm, pigpio.PUD_OFF)
342 | else:
343 | raise PigpioNotConnectedError(
344 | 'pigpio.pi is not connected, input for gpio ' + str(gpio_bcm) + ' will not work')
345 | self._timer = RepeatTimer(interval, self._read_and_send_value)
346 |
347 | def start(self) -> None:
348 | super().start()
349 | self._timer.start()
350 |
351 | def stop(self) -> None:
352 | super().stop()
353 | self._timer.cancel()
354 |
355 | def _read_and_send_value(self) -> None:
356 | reading = self.read_once()
357 | if reading is not None:
358 | self._send(reading, self.STATUS_OK)
359 | else:
360 | self._send(0, self.STATUS_MISSING)
361 |
362 | def read_once(self) -> Optional[int]:
363 | return self.pi.read(self.gpio_bcm) if self.pi.connected else None
364 |
365 | def format(self, value: float) -> str:
366 | return self.text_1 if value != 0 else self.text_0
367 |
368 | def __repr__(self) -> str:
369 | return super().__repr__() + ' gpio_bcm=' + str(self.gpio_bcm)
370 |
371 |
372 | class PigpioTimestamp:
373 | """
374 | Timestamp from pi gpio which handles both the pigpio tick accuracy and
375 | also long durations without tick rollover.
376 | """
377 | ticks: int
378 | epoch_secs: float
379 |
380 | def __init__(self, ticks: int):
381 | self.ticks = ticks
382 | self.epoch_secs = time.time()
383 |
384 | def delta_secs_to(self, latter_timestamp: 'PigpioTimestamp') -> float:
385 | rough_delta_secs = latter_timestamp.epoch_secs - self.epoch_secs
386 | if rough_delta_secs >= 3600: # pigpio ticks wrap around in about 71 minutes
387 | return rough_delta_secs
388 | else:
389 | return pigpio.tickDiff(self.ticks, latter_timestamp.ticks) / 1e6
390 |
391 |
392 | class PulseSource(SignalSource):
393 | """
394 | Digital GPIO pulse counting and time delta measuring signal source.
395 | Useful for calculating rates, throughput or power from pulse sources.
396 |
397 | Output value calculation is performed by a passed lambda function
398 | calc_value_func(counter: int, delta_secs: float) -> Optional[float].
399 | """
400 | pi: pigpio
401 | gpio_bcm: int
402 | trigger_level: int
403 | dead_time_secs: float
404 | pulse_min_secs: float
405 | calc_value_func: Callable[[int, float], Optional[float]]
406 |
407 | counter: int
408 | delta_secs: Optional[float]
409 |
410 | _last_maybe_pulse_timestamp: Optional[PigpioTimestamp]
411 | _last_timestamp: Optional[PigpioTimestamp]
412 |
413 | def __init__(self,
414 | identifier: str,
415 | pigpio_pi: pigpio,
416 | gpio_bcm: int,
417 | trigger_level: int,
418 | dead_time_secs: float,
419 | pulse_min_secs: float,
420 | calc_value_func: Callable[[int, float], Optional[float]],
421 | **kwargs):
422 | super().__init__(identifier, **kwargs)
423 | self.pi = pigpio_pi
424 | self.gpio_bcm = gpio_bcm
425 | self.trigger_level = trigger_level
426 | self.dead_time_secs = dead_time_secs
427 | self.pulse_min_secs = pulse_min_secs
428 | self.calc_value_func = calc_value_func
429 | self.counter = 0
430 | self.delta_secs = None
431 | self._last_timestamp = None
432 | self._last_maybe_pulse_timestamp = None
433 | self.__pi_callback = None
434 | if self.pi.connected:
435 | self.pi.set_mode(self.gpio_bcm, pigpio.INPUT)
436 | self.pi.set_pull_up_down(self.gpio_bcm, pigpio.PUD_OFF)
437 | else:
438 | raise PigpioNotConnectedError(
439 | 'pigpio.pi is not connected, pulse input for gpio ' + str(gpio_bcm) + ' will not work')
440 |
441 | def start(self) -> None:
442 | super().start()
443 | if self.pi.connected:
444 | self.__pi_callback = self.pi.callback(
445 | self.gpio_bcm, pigpio.EITHER_EDGE,
446 | lambda gpio, level, tick: self.__gpio_callback(gpio, level, tick))
447 |
448 | def stop(self) -> None:
449 | super().stop()
450 | if self.__pi_callback is not None:
451 | self.__pi_callback.cancel()
452 | self.__pi_callback = None
453 |
454 | def __gpio_callback(self, gpio: int, level: int, tick: int) -> None:
455 | timestamp = PigpioTimestamp(tick)
456 | # filter bouncing with requiring min pulse time:
457 | if self.trigger_level == level:
458 | self._last_maybe_pulse_timestamp = timestamp
459 | return
460 | else:
461 | if self._last_maybe_pulse_timestamp is not None:
462 | delta_secs = self._last_maybe_pulse_timestamp.delta_secs_to(timestamp)
463 | if delta_secs < self.pulse_min_secs:
464 | return
465 |
466 | self.counter += 1
467 | if self._last_timestamp is not None:
468 | delta_secs = self._last_timestamp.delta_secs_to(timestamp)
469 | if delta_secs <= self.dead_time_secs:
470 | return
471 |
472 | self.delta_secs = delta_secs
473 | begin_ts = timestamp.epoch_secs - delta_secs
474 | value = self.calc_value_func(self.counter, self.delta_secs)
475 | logger.debug(
476 | 'Pulse from gpio_bcm=' + str(self.gpio_bcm) +
477 | ' after ' + str(self.delta_secs) +
478 | ' secs => value=' + str(value))
479 | self._send(value, self.STATUS_OK if value is not None else self.STATUS_MISSING, timestamp=begin_ts)
480 | self._last_timestamp = timestamp
481 |
482 | def __repr__(self):
483 | return super().__repr__() + ' gpio_bcm=' + str(self.gpio_bcm)
484 |
--------------------------------------------------------------------------------
/signalsourcesconfig.py:
--------------------------------------------------------------------------------
1 | """
2 | Configuration for a setup with various temperature sensors of type
3 | DS18x20 and TSIC 306.
4 | """
5 |
6 | __author__ = 'Holger Fleischmann'
7 | __copyright__ = 'Copyright 2018, Holger Fleischmann, Bavaria/Germany'
8 | __license__ = 'Apache License 2.0'
9 |
10 | import time
11 | from functools import partial
12 | from typing import Any
13 |
14 | import pigpio
15 | from serial import Serial
16 |
17 | from powermetersmlobis import PowerMeterSmlObisReader
18 | from signalsources import TsicSource, Ds1820Source, DeltaSource, DigitalInSource, MappingSource, SignalSource, \
19 | SignalValue, PulseSource
20 | from powermeterapatorec3 import PowerMeterApatorEC3Repeating, PowerMeterApatorEC3
21 |
22 | try:
23 | with open("secret-mqtt-password", "r") as f:
24 | mqtt_password = f.read().strip()
25 | except IOError:
26 | mqtt_password = ''
27 |
28 | pigpio_pi = pigpio.pi()
29 |
30 | power_meter_heat = PowerMeterApatorEC3Repeating(PowerMeterApatorEC3("/dev/serial0"), 10, 2*60)
31 | power_meter_household = PowerMeterSmlObisReader(serial_factory=lambda: Serial(port="/dev/ttyUSB0", baudrate=9600, timeout=0.2))
32 |
33 |
34 | def power_meter_hh_map_func(identifier: str, pmeter: PowerMeterSmlObisReader, _: Any) -> SignalValue:
35 | obis_value = pmeter.values_by_id.get(identifier)
36 | if obis_value is not None:
37 | return SignalValue(obis_value.numeric_value, SignalSource.STATUS_OK, time.time())
38 | else:
39 | return SignalValue(0., SignalSource.STATUS_MISSING, time.time())
40 |
41 |
42 | _quelle_ein = Ds1820Source( 'temp-from-well', '28-0000089b1ca2', 1, label='Quelle ein', unit='°C', value_format='{:.1f}', color=[0.5, 0.5, 1.0, 1.0], z_order=1)
43 | _quelle_aus = Ds1820Source( 'temp-to-well', '28-000008640446', 1, label='Quelle aus', unit='°C', value_format='{:.1f}', color=[0.0, 0.2, 1.0, 1.0], z_order=2)
44 | _wp_hz_vor = Ds1820Source( 'temp-hp-heat-supply', '10-000801dd3c70', 1, label='Heizung Vorlauf', unit='°C', value_format='{:.1f}', color=[1.0, 0.0, 0.0, 1.0], z_order=1)
45 | _wp_ww_vor = TsicSource( 'temp-hp-water-supply', pigpio_pi, 26, label='Wasser Vorlauf', unit='°C', value_format='{:.1f}', color=[0.2, 0.3, 0.9, 1.0], z_order=0)
46 | _wp_rueck = TsicSource( 'temp-hp-return', pigpio_pi, 12, label='Rücklauf', unit='°C', value_format='{:.1f}', color=[0.5, 0.1, 0.7, 1.0], z_order=2)
47 | _wp_mode = DigitalInSource('mode-boiler', pigpio_pi, 5, 1, label='Modus', unit='', text_0='HZ', text_1='WW', color=[0.4, 0.4, 0.4, 1.0], z_order=-1)
48 | _hz_vor = Ds1820Source( 'temp-heat-supply', '10-000801f6dc25', 1, label='Heizung Vorlauf', unit='°C', value_format='{:.1f}', color=[1.0, 0.6, 0.6, 1.0], z_order=3)
49 | _hz_rueck = Ds1820Source( 'temp-heat-return', '10-000801dd3975', 1, label='Heizung Rücklauf', unit='°C', value_format='{:.1f}', color=[0.7, 0.6, 1.0, 1.0], z_order=4)
50 | _ww_speicher_o = TsicSource( 'temp-water-boiler-top', pigpio_pi, 19, label='Wasser oben', unit='°C', value_format='{:.1f}', color=[0.8, 0.7, 1.0, 1.0], z_order=-1, corr_offset=+2.5)
51 | _ww_speicher_u = Ds1820Source( 'temp-water-boiler-middle', '28-0000089967c2', 1, label='Wasser mitte', unit='°C', value_format='{:.1f}', color=[0.4, 0.3, 0.5, 1.0], z_order=-2)
52 | _ww_zirk = Ds1820Source( 'temp-water-circ-return', '28-0000089a5063', 1, label='Zirkulation', unit='°C', value_format='{:.1f}', color=[0.1, 0.6, 0.4, 1.0], z_order=-1)
53 |
54 | _wasser_haupt_flow = PulseSource('flow-water-main', pigpio_pi, 6, label='Wasser Haupt', unit='l/h', value_format='{:.1f}', color=[0.2, 0.2, 0.8, 1.0], with_graph=False, stale_secs=5*60,
55 | trigger_level=pigpio.HIGH, dead_time_secs=4, pulse_min_secs=2,
56 | calc_value_func=lambda counter, delta_secs: 10.0*3600 / delta_secs) # 1 pulse/ 10 l
57 |
58 | _lu_frisch = Ds1820Source( 'temp-air-fresh', '28-000008656f81', 1, label='Frischluft', unit='°C', value_format='{:.1f}', color=[0.2, 0.8, 1.0, 1.0], z_order=-1)
59 | _lu_fort = Ds1820Source( 'temp-air-exhaust', '10-000801dcfc0f', 1, label='Fortluft', unit='°C', value_format='{:.1f}', color=[0.7, 0.3, 0.1, 1.0], z_order=-1)
60 | _lu_zu = TsicSource( 'temp-air-supply', pigpio_pi, 16, label='Zuluft', unit='°C', value_format='{:.1f}', color=[0.7, 0.8, 0.9, 1.0], z_order=0)
61 | _lu_ab = TsicSource( 'temp-air-return', pigpio_pi, 20, label='Abluft', unit='°C', value_format='{:.1f}', color=[0.9, 0.6, 0.3, 1.0], z_order=0)
62 | _lu_aussen = TsicSource( 'temp-outdoor', pigpio_pi, 21, label='Außentemperatur', unit='°C', value_format='{:.1f}', color=[0.1, 0.5, 0.2, 1.0], z_order=1)
63 |
64 | _ht_leistung = MappingSource( 'power-heat-high-tariff', power_meter_heat, label='Leistung HT', unit='W', value_format='{:.0f}', color=[0.9, 0.4, 0.1, 1.0], with_graph=False, stale_secs=10*60,
65 | mapping_func=lambda pmeter, reading: SignalValue(pmeter.high.power,
66 | SignalSource.STATUS_OK if pmeter.success and pmeter.high.power is not None else SignalSource.STATUS_MISSING,
67 | pmeter.high.power_from_ts))
68 | _nt_leistung = MappingSource( 'power-heat-low-tariff', power_meter_heat, label='Leistung NT', unit='W', value_format='{:.0f}', color=[0.2, 0.3, 0.9, 1.0], with_graph=False, stale_secs=10*60,
69 | mapping_func=lambda pmeter, reading: SignalValue(pmeter.low.power,
70 | SignalSource.STATUS_OK if pmeter.success and pmeter.low.power is not None else SignalSource.STATUS_MISSING,
71 | pmeter.low.power_from_ts))
72 | _ht_reading = MappingSource( 'reading-heat-high-tariff', power_meter_heat, label='Stand HT', unit='kWh',value_format='{:.1f}', color=[0.9, 0.4, 0.1, 0.5], with_graph=False, stale_secs=30, small=True,
73 | mapping_func=lambda pmeter, reading: SignalValue(reading.consumption_high_sum_kwh,
74 | SignalSource.STATUS_OK if reading.consumption_high_sum_kwh is not None else SignalSource.STATUS_MISSING,
75 | pmeter.reading_ts))
76 | _nt_reading = MappingSource( 'reading-heat-low-tariff', power_meter_heat, label='Stand NT', unit='kWh',value_format='{:.1f}', color=[0.2, 0.3, 0.9, 0.5], with_graph=False, stale_secs=30, small=True,
77 | mapping_func=lambda pmeter, reading: SignalValue(reading.consumption_low_sum_kwh,
78 | SignalSource.STATUS_OK if reading.consumption_low_sum_kwh is not None else SignalSource.STATUS_MISSING,
79 | pmeter.reading_ts))
80 | _hh_leistung = MappingSource( 'power-household', power_meter_household, label='Leistung Haushalt', unit='W', value_format='{:.0f}', color=[0.9, 0.8, 0.1, 1.0], with_graph=False, stale_secs=10,
81 | mapping_func=partial(power_meter_hh_map_func, "active_power"))
82 | _hh_reading = MappingSource( 'reading-household-import',power_meter_household, label='Stand Bezug', unit='kWh',value_format='{:.1f}', color=[0.9, 0.8, 0.1, 0.6], with_graph=False, stale_secs=10, small=True,
83 | mapping_func=partial(power_meter_hh_map_func, "energy_import"))
84 | _hh_reading_exp= MappingSource( 'reading-household-export',power_meter_household, label='Stand Einsp.', unit='kWh',value_format='{:.1f}', color=[0.3, 1.0, 0.1, 0.6], with_graph=False, stale_secs=10, small=True,
85 | mapping_func=partial(power_meter_hh_map_func, "energy_export"))
86 | _hh_frequency = MappingSource( 'frequency-household', power_meter_household, label=' Frequenz', unit='Hz', value_format='{:.2f}', color=[0.7, 0.7, 0.7, 1.0], with_graph=False, stale_secs=10,
87 | mapping_func=partial(power_meter_hh_map_func, "frequency"))
88 | _hh_leistung_l1= MappingSource( 'power-household-l1', power_meter_household, label=' Leistung L1 Hh.', unit='W', value_format='{:.0f}', color=[0.6, 0.6, 0.4, 1.0], with_graph=False, stale_secs=10,
89 | mapping_func=partial(power_meter_hh_map_func, "active_power_l1"))
90 | _hh_leistung_l2= MappingSource( 'power-household-l2', power_meter_household, label=' Leistung L2 Hh.', unit='W', value_format='{:.0f}', color=[0.6, 0.6, 0.4, 1.0], with_graph=False, stale_secs=10,
91 | mapping_func=partial(power_meter_hh_map_func, "active_power_l2"))
92 | _hh_leistung_l3= MappingSource( 'power-household-l3', power_meter_household, label=' Leistung L3 Hh.', unit='W', value_format='{:.0f}', color=[0.6, 0.6, 0.4, 1.0], with_graph=False, stale_secs=10,
93 | mapping_func=partial(power_meter_hh_map_func, "active_power_l3"))
94 | _hh_voltage_l1 = MappingSource( 'voltage-household-l1', power_meter_household, label=' Spannung L1 Hh.', unit='V', value_format='{:.1f}', color=[0.2, 0.6, 0.7, 1.0], with_graph=False, stale_secs=10,
95 | mapping_func=partial(power_meter_hh_map_func, "voltage_l1"))
96 | _hh_voltage_l2 = MappingSource( 'voltage-household-l2', power_meter_household, label=' Spannung L2 Hh.', unit='V', value_format='{:.1f}', color=[0.2, 0.6, 0.7, 1.0], with_graph=False, stale_secs=10,
97 | mapping_func=partial(power_meter_hh_map_func, "voltage_l2"))
98 | _hh_voltage_l3 = MappingSource( 'voltage-household-l3', power_meter_household, label=' Spannung L3 Hh.', unit='V', value_format='{:.1f}', color=[0.2, 0.6, 0.7, 1.0], with_graph=False, stale_secs=10,
99 | mapping_func=partial(power_meter_hh_map_func, "voltage_l3"))
100 |
101 | signal_sources_config = {
102 | 'groups' : [
103 | {'label' : 'Wärmepumpe',
104 | 'sources' : [
105 | _quelle_ein,
106 | _quelle_aus,
107 | DeltaSource('temp-well-delta', _quelle_ein, _quelle_aus, label='\u0394 Quelle', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
108 | _wp_hz_vor,
109 | _wp_mode,
110 | DeltaSource('temp-hp-heat-delta', _wp_hz_vor, _wp_rueck, label='\u0394 Heizung', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
111 | _wp_ww_vor,
112 | DeltaSource('temp-hp-water-delta', _wp_ww_vor, _wp_rueck, label='\u0394 Wasser', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
113 | _wp_rueck,
114 | ]},
115 | {'label' : 'Heizung/Warmwasser',
116 | 'sources' : [
117 | _hz_vor,
118 | _hz_rueck,
119 | DeltaSource('temp-heat-delta', _hz_vor, _hz_rueck, label='\u0394 Heizung', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
120 | DeltaSource('temp-heat-supply-hp-delta', _wp_hz_vor, _hz_vor, label=' \u0394 WP-Hz Vor', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
121 | DeltaSource('temp-heat-return-hp-delta', _wp_rueck, _hz_rueck, label=' \u0394 WP-Hz Rück', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
122 | _ww_speicher_o,
123 | _ww_speicher_u,
124 | _ww_zirk,
125 | DeltaSource('temp-water-circ-return-delta', _ww_speicher_o, _ww_zirk, label='\u0394 Wasser-Zirk', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
126 | _wasser_haupt_flow,
127 | ]},
128 | {'label' : 'Lüftung/Zähler',
129 | 'sources' : [
130 | _lu_frisch,
131 | _lu_fort,
132 | DeltaSource('temp-air-fresh-exhaust-delta', _lu_fort, _lu_frisch, label='\u0394 Fort-Frisch', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
133 | _lu_zu,
134 | _lu_ab,
135 | DeltaSource('temp-air-supply-return-delta', _lu_ab, _lu_zu, label='\u0394 Ab-Zu', unit='K', value_format='{:.1f}', color=[0.0, 0.0, 0.0, 1.0], with_graph = False),
136 | _lu_aussen,
137 | _ht_leistung,
138 | _nt_leistung,
139 | _ht_reading,
140 | _nt_reading,
141 | _hh_leistung,
142 | _hh_reading,
143 | _hh_reading_exp,
144 | _hh_frequency,
145 | _hh_leistung_l1,
146 | _hh_leistung_l2,
147 | _hh_leistung_l3,
148 | _hh_voltage_l1,
149 | _hh_voltage_l2,
150 | _hh_voltage_l3
151 | ]}
152 | ],
153 |
154 | 'mqtt_broker_host' : 'homeserver.fritz.box',
155 | 'mqtt_broker_port' : 8883,
156 | 'mqtt_broker_user' : 'user',
157 | 'mqtt_broker_password' : mqtt_password,
158 | 'mqtt_broker_base_topic' : 'home/heating/data-logger',
159 | 'mqtt_use_ssl': True,
160 | 'mqtt_broker_ca_certs': 'mqtt_broker_cacert.pem'
161 | }
162 |
--------------------------------------------------------------------------------
/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | SCRIPT="$(realpath -s $0)"
6 | SCRIPTDIR="$(dirname $SCRIPT)"
7 | cd "$SCRIPTDIR"
8 |
9 | exec python3 main.py
10 |
--------------------------------------------------------------------------------
/test_powermeterapatorec3.py:
--------------------------------------------------------------------------------
1 | __author__ = 'Holger Fleischmann'
2 | __copyright__ = 'Copyright 2021, Holger Fleischmann, Bavaria/Germany'
3 | __license__ = 'Apache License 2.0'
4 |
5 | from unittest import TestCase, main
6 |
7 | from powermeterapatorec3 import PowerMeterReading, PowerMeterApatorEC3Repeating, PowerMeterApatorEC3
8 |
9 |
10 | class MockPowerMeterApatorEC3:
11 | def start(self):
12 | pass
13 |
14 | def close(self):
15 | pass
16 |
17 | def read(self):
18 | return PowerMeterReading(False, None, None, None)
19 |
20 |
21 | class TestPowerMeterApatorEC3(TestCase):
22 | def test__parse_line_str(self):
23 | pm = PowerMeterApatorEC3("none")
24 | self.assertEqual("008482.46", pm._parse_line_str("1.8.1*00(008482.46) "))
25 |
26 | def test__parse_(self):
27 | pm = PowerMeterApatorEC3("none")
28 | self.assertEqual(8482.46, pm._parse_line_float("1.8.1*00(008482.46) "))
29 |
30 |
31 | class TestPowerMeterApatorEC3Repeating(TestCase):
32 | def setUp(self):
33 | self.pm = PowerMeterApatorEC3Repeating(MockPowerMeterApatorEC3(), 1, 30)
34 |
35 | def test_update_high_power(self):
36 | self.pm.reading_ts = 1e9 - 200
37 | self.pm.reading = PowerMeterReading(False, None, None, None)
38 | self.pm._update_high_power()
39 | self.pm._update_low_power()
40 | self.assertIsNone(self.pm.high.power)
41 |
42 | self.pm.reading_ts = 1e9 - 100
43 | self.pm.reading = PowerMeterReading(True, 3000, 1999.9, 1000)
44 | self.pm._update_high_power()
45 | self.pm._update_low_power()
46 | self.assertIsNone(self.pm.high.power)
47 |
48 | self.pm.reading_ts = 1e9
49 | self.pm.reading = PowerMeterReading(True, 3000, 2000, 1000)
50 | self.pm._update_high_power()
51 | self.pm._update_low_power()
52 | self.assertIsNone(self.pm.high.power)
53 |
54 | self.pm.reading_ts = 1e9 + 3600
55 | self.pm.reading = PowerMeterReading(True, 3000, 2000.1, 1000)
56 | self.pm._update_high_power()
57 | self.pm._update_low_power()
58 | self.assertAlmostEqual(self.pm.high.power, 100.)
59 |
60 | self.pm.reading_ts += 600
61 | self.pm.reading = PowerMeterReading(True, 3000, 2000.1, 1000)
62 | self.pm.low.power = 123
63 | self.pm._update_high_power()
64 | self.pm._update_low_power()
65 | self.assertAlmostEqual(self.pm.high.power, 100.)
66 | self.assertEqual(self.pm.low.power, 123)
67 |
68 | self.pm.reading_ts += 600
69 | self.pm.reading = PowerMeterReading(True, 3000, 2000.2, 1000)
70 | self.pm._update_high_power()
71 | self.pm._update_low_power()
72 | self.assertAlmostEqual(self.pm.high.power, 300.)
73 |
74 | self.pm.reading_ts += 100
75 | self.pm.reading = PowerMeterReading(False, None, None, None)
76 | self.pm._update_high_power()
77 | self.pm._update_low_power()
78 | self.assertAlmostEqual(self.pm.high.power, 300.)
79 |
80 | self.pm.reading_ts += 500
81 | self.pm.reading = PowerMeterReading(True, 3000, 2001.2, 1000)
82 | self.pm.low.power = 123
83 | self.pm._update_high_power()
84 | self.pm._update_low_power()
85 | self.assertAlmostEqual(self.pm.high.power, 3600 * 1000 / 600.)
86 | self.assertEqual(self.pm.low.power, 0)
87 |
88 | def test_update_low_power(self):
89 | self.pm.reading_ts = 1e9 - 60
90 | self.pm.reading = PowerMeterReading(False, None, None, None)
91 | self.pm._update_high_power()
92 | self.pm._update_low_power()
93 | self.assertIsNone(self.pm.low.power)
94 |
95 | self.pm.reading_ts = 1e9 - 30
96 | self.pm.reading = PowerMeterReading(True, 3000, 2000, 999.9)
97 | self.pm._update_high_power()
98 | self.pm._update_low_power()
99 | self.assertIsNone(self.pm.low.power)
100 |
101 | self.pm.reading_ts = 1e9
102 | self.pm.reading = PowerMeterReading(True, 3000, 2000, 1000)
103 | self.pm._update_high_power()
104 | self.pm._update_low_power()
105 | self.assertIsNone(self.pm.low.power)
106 |
107 | self.pm.reading_ts = 1e9 + 3600
108 | self.pm.reading = PowerMeterReading(True, 3000, 2000, 1000.1)
109 | self.pm._update_high_power()
110 | self.pm._update_low_power()
111 | self.assertAlmostEqual(self.pm.low.power, 100.)
112 |
113 | self.pm.reading_ts += 600
114 | self.pm.reading = PowerMeterReading(True, 3000, 2000, 1000.1)
115 | self.pm.high.power = 123
116 | self.pm._update_high_power()
117 | self.pm._update_low_power()
118 | self.assertAlmostEqual(self.pm.low.power, 100.)
119 | self.assertEqual(self.pm.high.power, 123)
120 |
121 | self.pm.reading_ts += 600
122 | self.pm.reading = PowerMeterReading(True, 3000, 2000, 1000.2)
123 | self.pm._update_high_power()
124 | self.pm._update_low_power()
125 | self.assertAlmostEqual(self.pm.low.power, 300.)
126 |
127 | self.pm.reading_ts += 100
128 | self.pm.reading = PowerMeterReading(False, None, None, None)
129 | self.pm._update_high_power()
130 | self.pm._update_low_power()
131 | self.assertAlmostEqual(self.pm.low.power, 300.)
132 |
133 | self.pm.reading_ts += 500
134 | self.pm.reading = PowerMeterReading(True, 3000, 2000, 1001.2)
135 | self.pm.high.power = 123
136 | self.pm._update_high_power()
137 | self.pm._update_low_power()
138 | self.assertAlmostEqual(self.pm.low.power, 3600 * 1000 / 600.)
139 | self.assertEqual(self.pm.high.power, 0)
140 |
141 |
142 | if __name__ == '__main__':
143 | main()
144 |
--------------------------------------------------------------------------------
/utils.py:
--------------------------------------------------------------------------------
1 | """
2 | Common utilities used in this project.
3 | """
4 |
5 | __author__ = 'Holger Fleischmann'
6 | __copyright__ = 'Copyright 2018, Holger Fleischmann, Bavaria/Germany'
7 | __license__ = 'Apache License 2.0'
8 |
9 | from threading import Thread
10 | from threading import Event
11 | import time
12 | from typing import Callable
13 |
14 |
15 | class RepeatTimer(Thread):
16 | """
17 | Repeating timer that calls a callback task() at regular interval seconds.
18 | """
19 |
20 | interval: float
21 | task: Callable[[], None]
22 | event: Event
23 |
24 | def __init__(self, interval: float, task: Callable[[], None]):
25 | super().__init__()
26 | self.interval = interval
27 | self.task = task
28 | self.event = Event()
29 | self.event.set()
30 |
31 | def run(self) -> None:
32 | while self.event.is_set():
33 | self.task()
34 | time.sleep(self.interval)
35 |
36 | def cancel(self) -> None:
37 | self.event.clear()
38 |
--------------------------------------------------------------------------------