├── .gitignore ├── CHANGELOG.md ├── FEATURES.md ├── LICENSE ├── README.md ├── apps.py ├── buttons.py ├── clock.py ├── config.json ├── configuration.py ├── constants.py ├── display.py ├── ds3231_port.py ├── helpers.py ├── localPTZtime.py ├── main.py ├── mqtt.py ├── pico_temperature.py ├── pomodoro.py ├── rtc.py ├── run ├── scheduler.py ├── setup └── install-umqtt ├── speaker.py ├── temperature.py ├── test.py ├── time_set.py ├── util.py └── wifi.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Virtual enviroment 2 | venv 3 | 4 | # Pico-W-Go 5 | .picowgo 6 | .vscode/ 7 | 8 | # Config file 9 | configuration_old.py 10 | lib/ 11 | config.json 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | - **2023/05/09**: Fix weekdays 4 | - **2023/05/08**: Add ntp config and [localPTZtime](https://github.com/bellingeri/localPTZtime) 5 | 6 | ## Pre Fork 7 | 8 | - **2021/10/05**: Unified interface for the app classes 9 | - **2021/10/02**: Add basic time-set app 10 | - **2021/10/01**: Improved 'app' functionality 11 | Top button switches between apps 12 | - **2021/09/19**: Added 'Pomodoro timer' app 13 | - **2021/09/19**: Updated scheduler to know about "apps" 14 | -------------------------------------------------------------------------------- /FEATURES.md: -------------------------------------------------------------------------------- 1 | # Features 2 | 3 | ## Supported 4 | 5 | - Time viewing and setting with AM/PM icon support 6 | - Temperature display with support for celsius and fahrenheit 7 | - Day display 8 | - Autolight 9 | - Blinking time animation 10 | - Text scrolling animation 11 | - Display of any text 12 | - Pomodoro timer in minutes 13 | 14 | ## To be Added 15 | 16 | - MQTT support for control of the clock. 17 | - Alarm clocks 18 | - Stopwatch 19 | - Different animations for the text 20 | -------------------------------------------------------------------------------- /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 | # MicroPython Pico-Clock-Green 2 | 3 | Python port of the C code for the Waveshare [Pico-Clock-Green](https://www.waveshare.com/wiki/Pico-Clock-Green) product with support for MQTT (majority to be added) via the Pico W. 4 | 5 | > The original Python code of this project written by Malcolm and contributors ([GitHub link](https://github.com/malcolmholmes/pico-clock-green-python)). This will be a maintained fork with lots of more features added. 6 | 7 | ## Development 8 | 9 | This code is changing a lot at present, as all the necessary features are added. Please expect change, and please expect classes and APIs to change. 10 | 11 | ## Instructions (Linux/Mac) 12 | 13 | - Flash MicroPython onto the Pico. 14 | https://null-byte.wonderhowto.com/how-to/use-upip-load-micropython-libraries-onto-microcontroller-over-wi-fi-0237888/ 15 | - Install `ampy` from AdaFruit. 16 | - Execute the `run` bash script to upload Python files and execute 17 | main.py 18 | 19 | ## Instructions (Windows) 20 | 21 | If not running on Linux, use your usual method for uploading code to the Pico. 22 | 23 | ## Changelog 24 | 25 | See [CHANGELOG.md](CHANGELOG.md) for a list of updates. 26 | 27 | ## New Features 28 | 29 | See [Features.md](FEATURES.md) for a list of features supported/to be added. 30 | -------------------------------------------------------------------------------- /apps.py: -------------------------------------------------------------------------------- 1 | from buttons import Buttons 2 | from display import Display 3 | from speaker import Speaker 4 | 5 | 6 | class App: 7 | def __init__(self, name): 8 | self.name = name 9 | self.active = False 10 | self.grab_top_button = False 11 | 12 | def top_button(self): 13 | print("top_button not implemented for " + self.name) 14 | 15 | 16 | class Apps: 17 | def __init__(self, scheduler): 18 | self.scheduler = scheduler 19 | self.display = Display(scheduler) 20 | self.buttons = Buttons(scheduler) 21 | self.speaker = Speaker(scheduler) 22 | self.apps = [] 23 | self.current_app = 0 24 | self.buttons.add_callback(1, self.app_chooser, min=500) 25 | self.buttons.add_callback(1, self.app_top_button, max=500) 26 | 27 | async def start(self): 28 | await self.apps[0].enable() 29 | 30 | def add(self, app): 31 | self.apps.append(app) 32 | 33 | async def app_chooser(self): 34 | print("APP CHOOSER") 35 | if len(self.apps) == 0: 36 | return 37 | 38 | await self.disable_current_app() 39 | 40 | self.buttons.add_callback(2, self.next_app, max=500) 41 | self.buttons.add_callback(3, self.previous_app, max=500) 42 | 43 | await self.show_current_app_name() 44 | 45 | async def enable_current_app(self): 46 | self.buttons.clear_callbacks(2) 47 | self.buttons.clear_callbacks(3) 48 | self.display.display_queue.clear() 49 | self.display.clear_text() 50 | print("SWITCHING TO", self.apps[self.current_app].name) 51 | # self.speaker.beep(200) 52 | await self.apps[self.current_app].enable() 53 | 54 | async def disable_current_app(self): 55 | app = self.apps[self.current_app] 56 | app.disable() 57 | app.active = False 58 | app.grab_top_button = False 59 | self.buttons.clear_callbacks(2) 60 | self.buttons.clear_callbacks(3) 61 | 62 | async def show_current_app_name(self): 63 | app = self.apps[self.current_app] 64 | self.display.display_queue.clear() 65 | await self.display.animate_text(app.name, force=True) 66 | await self.display.show_text(app.name) 67 | 68 | async def next_app(self): 69 | self.current_app = (self.current_app + 1) % len(self.apps) 70 | await self.show_current_app_name() 71 | 72 | async def previous_app(self): 73 | self.current_app = (self.current_app - 1) % len(self.apps) 74 | await self.show_current_app_name() 75 | 76 | async def app_top_button(self): 77 | app = self.apps[self.current_app] 78 | if app.active and app.grab_top_button: 79 | should_go_next: bool = await app.top_button() 80 | if should_go_next: 81 | await self.app_chooser() 82 | else: 83 | return 84 | else: 85 | await self.enable_current_app() 86 | -------------------------------------------------------------------------------- /buttons.py: -------------------------------------------------------------------------------- 1 | from machine import Pin 2 | import time 3 | from constants import SCHEDULER_BUTTON_PRESS 4 | 5 | from util import singleton 6 | 7 | STATE_UNPRESSED = 1 8 | STATE_PRESSED = 2 9 | 10 | PINS = { 11 | 1: 2, 12 | 2: 17, 13 | 3: 15, 14 | } 15 | 16 | 17 | @singleton 18 | class Buttons: 19 | class Button: 20 | class Callback: 21 | def __init__(self, callback, min=0, max=-1): 22 | self.callback = callback 23 | self.min = min 24 | self.max = max 25 | 26 | def __init__(self, number): 27 | self.pin = Pin(PINS[number], Pin.IN, Pin.PULL_UP) 28 | self.number = number 29 | self.state = STATE_UNPRESSED 30 | self.callbacks = [] 31 | self.pressed: int 32 | 33 | def add_callback(self, callback, min=0, max=-1): 34 | callback_obj = self.Callback(callback, min, max) 35 | self.callbacks.append(callback_obj) 36 | return callback_obj 37 | 38 | def remove_callback(self, callback, min=0, max=-1): 39 | for callback in self.callbacks: 40 | if callback.callback == callback and callback.min == min and callback.max == max: 41 | self.callbacks.remove(callback) 42 | break 43 | 44 | def clear_callbacks(self): 45 | self.callbacks = [] 46 | 47 | def __init__(self, scheduler): 48 | self.buttons = [ 49 | self.Button(number) for number in (1, 2, 3) 50 | ] 51 | scheduler.schedule(SCHEDULER_BUTTON_PRESS, 1, self.millis_callback) 52 | 53 | def add_callback(self, number, callback, min=0, max=-1): 54 | self.buttons[number - 1].add_callback(callback, min, max) 55 | 56 | def remove_callback(self, number, callback, min=0, max=-1): 57 | self.buttons[number - 1].remove_callback(callback, min, max) 58 | 59 | def clear_callbacks(self, number): 60 | self.buttons[number - 1].clear_callbacks() 61 | 62 | def get_button(self, number): 63 | return self.buttons[number - 1] 64 | 65 | async def millis_callback(self): 66 | for button in self.buttons: 67 | if len(button.callbacks) > 0: 68 | if button.state == STATE_UNPRESSED and button.pin.value() == 0: 69 | button.state = STATE_PRESSED 70 | button.pressed = time.ticks_ms() 71 | elif button.state == STATE_PRESSED and button.pin.value() == 1: 72 | button.state = STATE_UNPRESSED 73 | tm = time.ticks_ms() 74 | press_duration = time.ticks_diff(tm, button.pressed) 75 | print("Button %d pressed for %dms" % 76 | (button.number, press_duration)) 77 | for callback in button.callbacks: 78 | if callback.min < press_duration and ( 79 | callback.max == -1 or press_duration <= callback.max): 80 | await callback.callback() 81 | break 82 | button.pressed = int() 83 | -------------------------------------------------------------------------------- /clock.py: -------------------------------------------------------------------------------- 1 | from apps import App 2 | from constants import APP_CLOCK, SCHEDULER_CLOCK_SECOND 3 | from display import Display 4 | from rtc import RTC 5 | from buttons import Buttons 6 | from configuration import Configuration 7 | import helpers 8 | 9 | 10 | class Clock(App): 11 | def __init__(self, scheduler): 12 | App.__init__(self, APP_CLOCK) 13 | self.config = Configuration() 14 | self.display = Display(scheduler) 15 | self.rtc = RTC() 16 | self.enabled = True 17 | self.buttons = Buttons(scheduler) 18 | self.hour = 0 19 | self.minute = 0 20 | self.second = 0 21 | scheduler.schedule(SCHEDULER_CLOCK_SECOND, 1000, self.secs_callback) 22 | 23 | async def enable(self): 24 | self.enabled = True 25 | self.buttons.add_callback(2, self.temp_callback, max=500) 26 | self.buttons.add_callback( 27 | 2, self.switch_temperature_callback, min=500, max=5000) 28 | self.buttons.add_callback(3, self.backlight_callback, max=500) 29 | self.buttons.add_callback( 30 | 3, self.switch_blink_callback, min=500, max=5000) 31 | await self.update_time() 32 | await self.show_time() 33 | self.display.show_temperature_icon() 34 | 35 | def disable(self): 36 | self.enabled = False 37 | 38 | async def secs_callback(self): 39 | if self.enabled: 40 | await self.update_time() 41 | if self.should_blink(): 42 | if self.second % 2 == 0: 43 | # makes : display 44 | self.display.show_char(":", pos=10) 45 | else: 46 | # makes : not display 47 | self.display.show_char(" :", pos=10) 48 | 49 | def should_blink(self): 50 | return self.config.blink_time_colon and not self.display.animating and self.display.showing_time 51 | 52 | async def update_time(self): 53 | t = self.rtc.get_time() 54 | self.second = t[5] 55 | if self.hour != t[3] or self.minute != t[4]: 56 | self.hour = t[3] 57 | self.minute = t[4] 58 | self.show_time_icon() 59 | self.display.show_day(t[6]) 60 | await self.show_time() 61 | elif t[5] == 20 and self.config.show_temp: 62 | await self.show_temperature() 63 | 64 | async def show_time(self): 65 | hour = self.hour 66 | if self.config.clock_type == "12": 67 | hour = helpers.convert_twenty_four_to_twelve_hour(hour) 68 | await self.display.show_time("%02d:%02d" % (hour, self.minute)) 69 | 70 | def show_time_icon(self): 71 | if self.config.clock_type == "12": 72 | if self.hour >= 12: 73 | self.display.show_icon("PM") 74 | self.display.hide_icon("AM") 75 | else: 76 | self.display.show_icon("AM") 77 | self.display.hide_icon("PM") 78 | 79 | async def show_temperature(self): 80 | temp = self.rtc.get_temperature() 81 | await self.display.show_temperature(temp) 82 | 83 | async def temp_callback(self): 84 | await self.show_temperature() 85 | 86 | async def switch_temperature_callback(self): 87 | self.config.switch_temp_value() 88 | self.display.show_temperature_icon() 89 | 90 | async def backlight_callback(self): 91 | self.display.switch_backlight() 92 | 93 | async def switch_blink_callback(self): 94 | self.config.switch_blink_time_colon_value() 95 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "runConfig": { 3 | "blinkTimeColon": true, 4 | "temp": "c", 5 | "clockType": "24", 6 | "showTemp": true, 7 | "autolight": true 8 | }, 9 | "wifiConfig": { 10 | "enabled": false, 11 | "hostname": "pico-clock-green", 12 | "ssid": "", 13 | "passphrase": "", 14 | "ntpEnabled": true, 15 | "ntpPTZ": "GMT0" 16 | }, 17 | "mqttConfig": { 18 | "enabled": false, 19 | "broker": "", 20 | "prefix": "" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /configuration.py: -------------------------------------------------------------------------------- 1 | from constants import CONFIGURATION_FILE, CONFIGURATION_RUN_BLINK_TIME_COLON, CONFIGURATION_RUN_CLOCK_TYPE, CONFIGURATION_RUN_AUTOLIGHT, CONFIGURATION_RUN_SHOW_TEMP, CONFIGURATION_MQTT_BROKER, CONFIGURATION_MQTT_CONFIG, CONFIGURATION_MQTT_ENABLED, CONFIGURATION_MQTT_PREFIX, CONFIGURATION_RUN_CONFIG, CONFIGURATION_RUN_TEMP, CONFIGURATION_WIFI_CONFIG, CONFIGURATION_WIFI_ENABLED, CONFIGURATION_WIFI_HOSTNAME, CONFIGURATION_WIFI_SSID, CONFIGURATION_WIFI_PASSPHRASE, CONFIGURATION_WIFI_NTP_ENABLED, CONFIGURATION_WIFI_NTP_PTZ 2 | from util import singleton 3 | from helpers import read_json_file, write_json_file 4 | 5 | 6 | @singleton 7 | class Configuration: 8 | class WifiConfiguration: 9 | def __init__(self, enabled: bool, hostname: str, ssid: str, passphrase: str, ntp_enabled: bool, ntp_ptz: str) -> None: 10 | self.enabled = enabled 11 | self.hostname = hostname 12 | self.ssid = ssid 13 | self.passphrase = passphrase 14 | self.ntp_enabled = ntp_enabled 15 | self.ntp_ptz = ntp_ptz 16 | 17 | class MQTTConfiguration: 18 | def __init__(self, enabled: bool, broker: str, prefix: str) -> None: 19 | self.enabled = enabled 20 | self.broker = broker 21 | self.prefix = prefix 22 | self.base_topic = prefix + "/" 23 | 24 | def __init__(self) -> None: 25 | self.config = {} 26 | self.blink_time_colon = False 27 | self.temp = "c" 28 | self.clock_type = "24" 29 | self.show_temp = True 30 | self.autolight = False 31 | self.read_config_file() 32 | 33 | def read_config_file(self): 34 | self.config = read_json_file(CONFIGURATION_FILE) 35 | self.update_config_variables() 36 | 37 | def update_config_variables(self): 38 | self.blink_time_colon = self.config[CONFIGURATION_RUN_CONFIG][CONFIGURATION_RUN_BLINK_TIME_COLON] 39 | self.temp = self.config[CONFIGURATION_RUN_CONFIG][CONFIGURATION_RUN_TEMP] 40 | self.clock_type = self.config[CONFIGURATION_RUN_CONFIG][CONFIGURATION_RUN_CLOCK_TYPE] 41 | self.show_temp = self.config[CONFIGURATION_RUN_CONFIG][CONFIGURATION_RUN_SHOW_TEMP] 42 | self.autolight = self.config[CONFIGURATION_RUN_CONFIG][CONFIGURATION_RUN_AUTOLIGHT] 43 | 44 | self.wifi_config = self.WifiConfiguration( 45 | enabled=self.config[CONFIGURATION_WIFI_CONFIG][CONFIGURATION_WIFI_ENABLED], 46 | hostname=self.config[CONFIGURATION_WIFI_CONFIG][CONFIGURATION_WIFI_HOSTNAME], 47 | ssid=self.config[CONFIGURATION_WIFI_CONFIG][CONFIGURATION_WIFI_SSID], 48 | passphrase=self.config[CONFIGURATION_WIFI_CONFIG][CONFIGURATION_WIFI_PASSPHRASE], 49 | ntp_enabled=self.config[CONFIGURATION_WIFI_CONFIG][CONFIGURATION_WIFI_NTP_ENABLED], 50 | ntp_ptz=self.config[CONFIGURATION_WIFI_CONFIG][CONFIGURATION_WIFI_NTP_PTZ] 51 | ) 52 | 53 | self.mqtt_config = self.MQTTConfiguration( 54 | enabled=self.config[CONFIGURATION_MQTT_CONFIG][CONFIGURATION_MQTT_ENABLED], 55 | broker=self.config[CONFIGURATION_MQTT_CONFIG][CONFIGURATION_MQTT_BROKER], 56 | prefix=self.config[CONFIGURATION_MQTT_CONFIG][CONFIGURATION_MQTT_PREFIX] 57 | ) 58 | 59 | def write_config_file(self): 60 | write_json_file(CONFIGURATION_FILE, self.config) 61 | self.update_config_variables() 62 | 63 | def switch_blink_time_colon_value(self): 64 | if self.config[CONFIGURATION_RUN_CONFIG][CONFIGURATION_RUN_BLINK_TIME_COLON]: 65 | self.config[CONFIGURATION_RUN_CONFIG][CONFIGURATION_RUN_BLINK_TIME_COLON] = False 66 | else: 67 | self.config[CONFIGURATION_RUN_CONFIG][CONFIGURATION_RUN_BLINK_TIME_COLON] = True 68 | 69 | self.write_config_file() 70 | 71 | def switch_temp_value(self): 72 | if self.config[CONFIGURATION_RUN_CONFIG][CONFIGURATION_RUN_TEMP] == "c": 73 | self.config[CONFIGURATION_RUN_CONFIG][CONFIGURATION_RUN_TEMP] = "f" 74 | else: 75 | self.config[CONFIGURATION_RUN_CONFIG][CONFIGURATION_RUN_TEMP] = "c" 76 | 77 | self.write_config_file() 78 | 79 | def update_clock_type_value(self, value): 80 | self.config[CONFIGURATION_RUN_CONFIG][CONFIGURATION_RUN_CLOCK_TYPE] = value 81 | self.write_config_file() 82 | 83 | def update_autolight_value(self, value): 84 | self.config[CONFIGURATION_RUN_CONFIG][CONFIGURATION_RUN_AUTOLIGHT] = value 85 | self.write_config_file() 86 | -------------------------------------------------------------------------------- /constants.py: -------------------------------------------------------------------------------- 1 | # Configuration names 2 | CONFIGURATION_FILE = "config.json" 3 | 4 | CONFIGURATION_RUN_CONFIG = "runConfig" 5 | CONFIGURATION_RUN_BLINK_TIME_COLON = "blinkTimeColon" 6 | CONFIGURATION_RUN_TEMP = "temp" 7 | CONFIGURATION_RUN_CLOCK_TYPE = "clockType" 8 | CONFIGURATION_RUN_SHOW_TEMP = "showTemp" 9 | CONFIGURATION_RUN_AUTOLIGHT = "autolight" 10 | 11 | CONFIGURATION_WIFI_CONFIG = "wifiConfig" 12 | CONFIGURATION_WIFI_ENABLED = "enabled" 13 | CONFIGURATION_WIFI_HOSTNAME = "hostname" 14 | CONFIGURATION_WIFI_SSID = "ssid" 15 | CONFIGURATION_WIFI_PASSPHRASE = "passphrase" 16 | CONFIGURATION_WIFI_NTP_ENABLED = "ntpEnabled" 17 | CONFIGURATION_WIFI_NTP_PTZ = "ntpPTZ" 18 | 19 | CONFIGURATION_MQTT_CONFIG = "mqttConfig" 20 | CONFIGURATION_MQTT_ENABLED = "enabled" 21 | CONFIGURATION_MQTT_BROKER = "broker" 22 | CONFIGURATION_MQTT_PREFIX = "prefix" 23 | 24 | # Scheduler name callbacks 25 | SCHEDULER_BUTTON_PRESS = "button-press" 26 | 27 | SCHEDULER_CLOCK_SECOND = "clock-second" 28 | 29 | SCHEDULER_ENABLE_LEDS = "enable-leds" 30 | SCHEDULER_ANIMATION = "animation" 31 | SCHEDULER_UPDATE_BACKLIGHT_VALUE = "update_auto_backlight_value" 32 | 33 | SCHEDULER_MQTT_HEARTBEAT = "mqtt-heartbeat" 34 | SCHEDULER_MQTT_CHECK = "mqtt-check" 35 | SCHEDULER_MQTT_STATE = "mqtt-state" 36 | 37 | SCHEDULER_POMODORO_SECOND = "pomodoro-second" 38 | 39 | SCHEDULER_SPEAKER_BEEPS = "speaker-beeps" 40 | 41 | SCHEDULER_TIME_SET_HALF_SECOND = "time-set-half-second" 42 | SCHEDULER_TIME_SET_MINUTE = "time-set-minute" 43 | 44 | 45 | # App names 46 | APP_CLOCK = "CLOCK" 47 | APP_POMODORO = "POMODORO" 48 | APP_TIME_SET = "TIME SET" 49 | -------------------------------------------------------------------------------- /display.py: -------------------------------------------------------------------------------- 1 | from machine import Pin, ADC 2 | from constants import SCHEDULER_ANIMATION, SCHEDULER_UPDATE_BACKLIGHT_VALUE 3 | import uasyncio 4 | import time 5 | from util import partial, singleton 6 | from utime import sleep_us 7 | from configuration import Configuration 8 | import helpers 9 | 10 | 11 | @singleton 12 | class Display: 13 | class WaitForAnimation: 14 | def __init__(self, callback, *args, **kwargs) -> None: 15 | self.callback = partial(callback, *args, **kwargs) 16 | 17 | def __init__(self, scheduler): 18 | self.a0 = Pin(16, Pin.OUT) 19 | self.a1 = Pin(18, Pin.OUT) 20 | self.a2 = Pin(22, Pin.OUT) 21 | self.oe = Pin(13, Pin.OUT) 22 | self.sdi = Pin(11, Pin.OUT) 23 | self.clk = Pin(10, Pin.OUT) 24 | self.le = Pin(12, Pin.OUT) 25 | self.ain = ADC(26) 26 | 27 | self.row = 0 28 | self.leds = [[0] * 32 for i in range(0, 8)] 29 | self.animating = False 30 | self.showing_time = False 31 | self.display_text_width = 32 32 | self.disp_offset = 2 33 | self.display_queue = [] 34 | self.initialise_fonts() 35 | self.initialise_icons() 36 | self.initialise_days() 37 | 38 | self.scheduler = scheduler 39 | self.config = Configuration() 40 | 41 | self.initialise_backlight() 42 | self.show_temperature_icon() 43 | 44 | def enable_leds(self): 45 | while True: 46 | self.row = (self.row + 1) % 8 47 | led_row = self.leds[self.row] 48 | for col in range(32): 49 | self.clk.value(0) 50 | self.sdi.value(led_row[col]) 51 | self.clk.value(1) 52 | self.le.value(1) 53 | self.le.value(0) 54 | 55 | self.a0.value(1 if self.row & 0x01 else 0) 56 | self.a1.value(1 if self.row & 0x02 else 0) 57 | self.a2.value(1 if self.row & 0x04 else 0) 58 | 59 | self.oe.value(0) 60 | sleep_us(self.backlight_sleep[self.current_backlight]) 61 | self.oe.value(1) 62 | 63 | async def animate_text(self, text: str, delay=1000, clear=True, force=False): 64 | if self.animating and not force: 65 | self.display_queue.append( 66 | self.WaitForAnimation(self.animate_text, text, delay, clear=clear)) 67 | return 68 | 69 | # add blank whitespace for text to show correctly 70 | text = text + " " 71 | if clear: 72 | self.clear_text() 73 | await self.show_text(text) 74 | self.animate(delay) 75 | 76 | def animate(self, delay=1000): 77 | self.runs = 0 78 | self.animating = True 79 | self.scheduler.schedule( 80 | SCHEDULER_ANIMATION, 200, self.scroll_text_left, delay) 81 | 82 | async def scroll_text_left(self): 83 | for row in range(8): 84 | led_row = self.leds[row] 85 | for col in range(self.display_text_width): 86 | if row > 0 and col > 2: 87 | self.leds[row][col-1] = led_row[col] 88 | self.runs += 1 89 | 90 | if self.runs == self.display_text_width - 3: # account for whitespace 91 | self.animating = False 92 | self.scheduler.remove(SCHEDULER_ANIMATION) 93 | await self.process_callback_queue() 94 | 95 | async def process_callback_queue(self, *args): 96 | if len(self.display_queue) == 0: 97 | if not self.showing_time: 98 | await self.show_time() 99 | else: 100 | await self.display_queue[0].callback() 101 | self.display_queue.pop(0) 102 | 103 | def clear(self, x=0, y=0, w=24, h=7): 104 | self.display_text_width = 0 105 | for yy in range(y, y + h + 1): 106 | for xx in range(x, x + w + 1): 107 | self.leds[yy][xx] = 0 108 | 109 | def clear_text(self): 110 | self.animating = False 111 | self.scheduler.remove(SCHEDULER_ANIMATION) 112 | self.clear(x=2, y=1, w=24, h=6) 113 | 114 | def reset(self): 115 | self.clear_text() 116 | self.display_queue = [] 117 | 118 | def show_char(self, character, pos): 119 | pos += self.disp_offset # Plus the offset of the status indicator 120 | char = self.ziku[character] 121 | for row in range(1, 8): 122 | byte = char.rows[row - 1] 123 | for col in range(0, char.width): 124 | self.leds[row][pos + col] = (byte >> col) % 2 125 | 126 | async def show_text_for_period(self, text, pos=0, clear=True, display_period=5000): 127 | if self.animating: 128 | self.display_queue.append( 129 | self.WaitForAnimation(self.show_text_for_period, text, pos, display_period)) 130 | return 131 | 132 | await self.show_text(text, pos, clear) 133 | await uasyncio.sleep_ms(display_period) 134 | await self.process_callback_queue() 135 | 136 | async def show_text(self, text, pos=0, clear=True): 137 | if self.animating: 138 | self.display_queue.append( 139 | self.WaitForAnimation(self.show_text, text, pos, clear)) 140 | return 141 | 142 | if clear: 143 | self.clear_text() 144 | 145 | i = 0 146 | while i < len(text): 147 | if text[i:i + 2] in self.ziku: 148 | c = text[i:i + 2] 149 | i += 2 150 | else: 151 | c = text[i] 152 | i += 1 153 | width = self.ziku[c].width 154 | self.display_text_width += width 155 | # account for whitespace between text words 156 | self.display_text_width += len(text) + 1 157 | 158 | # handle small text by resetting back to 32 159 | if self.display_text_width < 32: 160 | self.display_text_width = 32 161 | self.set_new_led_rows() 162 | 163 | i = 0 164 | while i < len(text): 165 | if text[i:i + 2] in self.ziku: 166 | c = text[i:i + 2] 167 | i += 2 168 | else: 169 | c = text[i] 170 | i += 1 171 | self.show_char(c, pos) 172 | width = self.ziku[c].width 173 | pos += width + 1 174 | 175 | async def show_time(self, time=None): 176 | self.showing_time = True 177 | if time != None: 178 | self.time = time 179 | await self.show_text(self.time) 180 | 181 | async def show_temperature(self, temp): 182 | self.showing_time = False 183 | symbol = "" 184 | if self.config.temp == "c": 185 | symbol = "°C" 186 | else: 187 | temp = helpers.convert_celsius_to_temperature(temp) 188 | symbol = "°F" 189 | temp = str(temp) 190 | await self.animate_text(self.time + " " + temp + 191 | symbol, delay=0, clear=False) 192 | 193 | async def show_message(self, text: str): 194 | self.showing_time = False 195 | if len(text) > 3: 196 | await self.animate_text(text) 197 | else: 198 | await self.show_text_for_period(text, display_period=8000) 199 | 200 | def show_icon(self, name): 201 | icon = self.Icons[name] 202 | for w in range(icon.width): 203 | self.leds[icon.y][icon.x + w] = 1 204 | 205 | def hide_icon(self, name): 206 | icon = self.Icons[name] 207 | for w in range(icon.width): 208 | self.leds[icon.y][icon.x + w] = 0 209 | 210 | def hide_temperature_icons(self): 211 | self.hide_icon("°F") 212 | self.hide_icon("°C") 213 | 214 | def set_new_led_rows(self): 215 | # copy days of week led row 216 | day_of_week_row = self.leds[0] 217 | icon_column_one = [] 218 | icon_column_two = [] 219 | # copy column 1 and two (icons) 220 | for row in range(1, 9): 221 | icon_column_one.append(self.leds[row - 1][0]) 222 | icon_column_two.append(self.leds[row - 1][1]) 223 | 224 | # reset led array to use new display width (allows for text bigger than screen) 225 | new_leds = [[0] * self.display_text_width for i in range(0, 8)] 226 | 227 | # put back previously captured values as they were reset in led reset 228 | new_leds[0] = day_of_week_row 229 | for row in range(1, 9): 230 | new_leds[row - 1][0] = icon_column_one[row - 1] 231 | new_leds[row - 1][1] = icon_column_two[row - 1] 232 | 233 | self.leds = new_leds 234 | 235 | def sidelight_on(self): 236 | self.leds[0][2] = 1 237 | self.leds[0][5] = 1 238 | 239 | def sidelight_off(self): 240 | self.leds[0][2] = 0 241 | self.leds[0][5] = 0 242 | 243 | # this is called whenever the user presses the "light" button to switch to a different setting 244 | def switch_backlight(self): 245 | if self.auto_backlight: 246 | self.auto_backlight = False 247 | self.hide_icon("AutoLight") 248 | self.current_backlight = 0 249 | self.scheduler.remove( 250 | SCHEDULER_UPDATE_BACKLIGHT_VALUE) 251 | self.config.update_autolight_value(False) 252 | elif self.current_backlight == len(self.backlight_sleep)-1: 253 | self.show_icon("AutoLight") 254 | self.auto_backlight = True 255 | self.update_auto_backlight_value() 256 | self.scheduler.schedule( 257 | SCHEDULER_UPDATE_BACKLIGHT_VALUE, 1000, self.update_backlight_callback) 258 | self.config.update_autolight_value(True) 259 | else: 260 | self.current_backlight += 1 261 | 262 | def initialise_backlight(self): 263 | # CPU freq needs to be increase to 250 for better results 264 | # From 10 (low) to 1250(High) 265 | self.backlight_sleep = [10, 100, 300, 400, 700, 1250, 2000] 266 | self.current_backlight = 6 267 | self.auto_backlight = self.config.autolight 268 | self.update_auto_backlight_value() 269 | self.last_backlight_update = time.ticks_ms() 270 | 271 | if self.auto_backlight: 272 | self.show_icon("AutoLight") 273 | self.scheduler.schedule( 274 | SCHEDULER_UPDATE_BACKLIGHT_VALUE, 1000, self.update_backlight_callback) 275 | 276 | def update_auto_backlight_value(self): 277 | backlight = 0 278 | aim = self.ain.read_u16() 279 | if aim > 65000: # Low light 280 | backlight = 0 281 | elif aim > 60000: 282 | backlight = 1 283 | elif aim > 40000: 284 | backlight = 2 285 | else: 286 | backlight = 3 287 | 288 | if backlight != self.current_backlight: 289 | self.current_backlight = backlight 290 | self.last_backlight_update = time.ticks_ms() 291 | 292 | async def update_backlight_callback(self): 293 | tm = time.ticks_ms() 294 | difference = time.ticks_diff(tm, self.last_backlight_update) 295 | if difference > 3000: 296 | self.update_auto_backlight_value() 297 | 298 | def show_temperature_icon(self): 299 | if self.config.temp == "c": 300 | self.show_icon("°C") 301 | self.hide_icon("°F") 302 | else: 303 | self.show_icon("°F") 304 | self.hide_icon("°C") 305 | 306 | def print(self): 307 | for row in range(0, 8): 308 | for pos in range(0, 24): 309 | print("X" if self.leds[row][pos] == 1 else " ", end="") 310 | print("") 311 | 312 | def square(self): 313 | ''' 314 | Prints a crossed square. For debugging purposes. 315 | ''' 316 | for row in range(1, 8): 317 | self.leds[row][2] = 1 318 | self.leds[row][23] = 1 319 | for col in range(2, 23): 320 | self.leds[1][col] = 1 321 | self.leds[7][col] = 1 322 | self.leds[int(col / 24 * 7) + 1][col] = 1 323 | self.leds[7 - int(col / 24 * 7)][col] = 1 324 | 325 | class Character: 326 | def __init__(self, width, rows, offset=2): 327 | self.width = width 328 | self.rows = rows 329 | self.offset = offset 330 | 331 | class Icon: 332 | def __init__(self, x, y, width=1): 333 | self.x = x 334 | self.y = y 335 | self.width = width 336 | 337 | def initialise_icons(self): 338 | self.Icons = { 339 | "MoveOn": self.Icon(0, 0, width=2), 340 | "AlarmOn": self.Icon(0, 1, width=2), 341 | "CountDown": self.Icon(0, 2, width=2), 342 | "°F": self.Icon(0, 3), 343 | "°C": self.Icon(1, 3), 344 | "AM": self.Icon(0, 4), 345 | "PM": self.Icon(1, 4), 346 | "CountUp": self.Icon(0, 5, width=2), 347 | "Hourly": self.Icon(0, 6, width=2), 348 | "AutoLight": self.Icon(0, 7, width=2), 349 | "Mon": self.Icon(3, 0, width=2), 350 | "Tue": self.Icon(6, 0, width=2), 351 | "Wed": self.Icon(9, 0, width=2), 352 | "Thur": self.Icon(12, 0, width=2), 353 | "Fri": self.Icon(15, 0, width=2), 354 | "Sat": self.Icon(18, 0, width=2), 355 | "Sun": self.Icon(21, 0, width=2), 356 | } 357 | 358 | def initialise_days(self): 359 | self.days_of_week = { 360 | 0: "Mon", 361 | 1: "Tue", 362 | 2: "Wed", 363 | 3: "Thur", 364 | 4: "Fri", 365 | 5: "Sat", 366 | 6: "Sun" 367 | } 368 | 369 | def show_day(self, int): 370 | for key in self.days_of_week: 371 | if key == int: 372 | self.show_icon(self.days_of_week[key]) 373 | else: 374 | self.hide_icon(self.days_of_week[key]) 375 | 376 | # Derived from c code created by yufu on 2021/1/23. 377 | # Modulus method: negative code, reverse, line by line, 4X7 font 378 | def initialise_fonts(self): 379 | self.ziku = { 380 | "all": self.Character(width=3, rows=[0x05, 0x05, 0x03, 0x03, 0x03, 0x03, 0x03]), 381 | "0": self.Character(width=4, rows=[0x06, 0x09, 0x09, 0x09, 0x09, 0x09, 0x06]), 382 | "1": self.Character(width=4, rows=[0x04, 0x06, 0x04, 0x04, 0x04, 0x04, 0x0E]), 383 | "2": self.Character(width=4, rows=[0x06, 0x09, 0x08, 0x04, 0x02, 0x01, 0x0F]), 384 | "3": self.Character(width=4, rows=[0x06, 0x09, 0x08, 0x06, 0x08, 0x09, 0x06]), 385 | "4": self.Character(width=4, rows=[0x08, 0x0C, 0x0A, 0x09, 0x0F, 0x08, 0x08]), 386 | "5": self.Character(width=4, rows=[0x0F, 0x01, 0x07, 0x08, 0x08, 0x09, 0x06]), 387 | "6": self.Character(width=4, rows=[0x04, 0x02, 0x01, 0x07, 0x09, 0x09, 0x06]), 388 | "7": self.Character(width=4, rows=[0x0F, 0x09, 0x04, 0x04, 0x04, 0x04, 0x04]), 389 | "8": self.Character(width=4, rows=[0x06, 0x09, 0x09, 0x06, 0x09, 0x09, 0x06]), 390 | "9": self.Character(width=4, rows=[0x06, 0x09, 0x09, 0x0E, 0x08, 0x04, 0x02]), 391 | 392 | "A": self.Character(width=4, rows=[0x06, 0x09, 0x09, 0x0F, 0x09, 0x09, 0x09]), 393 | "B": self.Character(width=4, rows=[0x07, 0x09, 0x09, 0x07, 0x09, 0x09, 0x07]), 394 | "C": self.Character(width=4, rows=[0x06, 0x09, 0x01, 0x01, 0x01, 0x09, 0x06]), 395 | "D": self.Character(width=4, rows=[0x07, 0x09, 0x09, 0x09, 0x09, 0x09, 0x07]), 396 | "E": self.Character(width=4, rows=[0x0F, 0x01, 0x01, 0x0F, 0x01, 0x01, 0x0F]), 397 | "F": self.Character(width=4, rows=[0x0F, 0x01, 0x01, 0x0F, 0x01, 0x01, 0x01]), 398 | "G": self.Character(width=4, rows=[0x06, 0x09, 0x01, 0x0D, 0x09, 0x09, 0x06]), 399 | "H": self.Character(width=4, rows=[0x09, 0x09, 0x09, 0x0F, 0x09, 0x09, 0x09]), 400 | "I": self.Character(width=3, rows=[0x07, 0x02, 0x02, 0x02, 0x02, 0x02, 0x07]), 401 | "J": self.Character(width=4, rows=[0x0F, 0x08, 0x08, 0x08, 0x09, 0x09, 0x06]), 402 | "K": self.Character(width=4, rows=[0x09, 0x05, 0x03, 0x01, 0x03, 0x05, 0x09]), 403 | "L": self.Character(width=4, rows=[0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x0F]), 404 | "M": self.Character(width=5, rows=[0x11, 0x1B, 0x15, 0x11, 0x11, 0x11, 0x11]), 405 | "N": self.Character(width=4, rows=[0x09, 0x09, 0x0B, 0x0D, 0x09, 0x09, 0x09]), 406 | "O": self.Character(width=4, rows=[0x06, 0x09, 0x09, 0x09, 0x09, 0x09, 0x06]), 407 | "P": self.Character(width=4, rows=[0x07, 0x09, 0x09, 0x07, 0x01, 0x01, 0x01]), 408 | "Q": self.Character(width=5, rows=[0x0E, 0x11, 0x11, 0x11, 0x15, 0x19, 0x0E]), 409 | "R": self.Character(width=4, rows=[0x07, 0x09, 0x09, 0x07, 0x03, 0x05, 0x09]), 410 | "S": self.Character(width=4, rows=[0x06, 0x09, 0x02, 0x04, 0x08, 0x09, 0x06]), 411 | "T": self.Character(width=5, rows=[0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04]), 412 | "U": self.Character(width=4, rows=[0x09, 0x09, 0x09, 0x09, 0x09, 0x09, 0x06]), 413 | "V": self.Character(width=5, rows=[0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04]), 414 | "W": self.Character(width=5, rows=[0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11]), 415 | "X": self.Character(width=5, rows=[0x11, 0x0A, 0x04, 0x04, 0x04, 0x0A, 0x11]), 416 | "Y": self.Character(width=4, rows=[0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04]), 417 | "Z": self.Character(width=4, rows=[0x0F, 0x08, 0x04, 0x02, 0x01, 0x0F, 0x00]), 418 | 419 | ":": self.Character(width=2, rows=[0x00, 0x03, 0x03, 0x00, 0x03, 0x03, 0x00]), 420 | # colon width space 421 | " :": self.Character(width=2, rows=[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), 422 | 423 | # temp symbol 424 | "°": self.Character(width=2, rows=[0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00]), 425 | # space 426 | " ": self.Character(width=2, rows=[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), 427 | ".": self.Character(width=1, rows=[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]), 428 | "-": self.Character(width=2, rows=[0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00]), 429 | "/": self.Character(width=2, rows=[0x02, 0x02, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01]), 430 | } 431 | -------------------------------------------------------------------------------- /ds3231_port.py: -------------------------------------------------------------------------------- 1 | # FROM HERE: https://github.com/peterhinch/micropython-samples/tree/master/DS3231 2 | 3 | # ds3231_port.py Portable driver for DS3231 precison real time clock. 4 | # Adapted from WiPy driver at https://github.com/scudderfish/uDS3231 5 | 6 | # Author: Peter Hinch 7 | # Copyright Peter Hinch 2018 Released under the MIT license. 8 | 9 | import utime 10 | import machine 11 | import sys 12 | DS3231_I2C_ADDR = 104 13 | 14 | try: 15 | rtc = machine.RTC() 16 | except: 17 | print('Warning: machine module does not support the RTC.') 18 | rtc = None 19 | 20 | 21 | def bcd2dec(bcd): 22 | return (((bcd & 0xf0) >> 4) * 10 + (bcd & 0x0f)) 23 | 24 | 25 | def dec2bcd(dec): 26 | tens, units = divmod(dec, 10) 27 | return (tens << 4) + units 28 | 29 | 30 | def tobytes(num): 31 | return num.to_bytes(1, 'little') 32 | 33 | 34 | class DS3231: 35 | def __init__(self, i2c): 36 | self.ds3231 = i2c 37 | self.timebuf = bytearray(7) 38 | if DS3231_I2C_ADDR not in self.ds3231.scan(): 39 | raise RuntimeError( 40 | "DS3231 not found on I2C bus at %d" % DS3231_I2C_ADDR) 41 | 42 | def get_time(self, set_rtc=False): 43 | if set_rtc: 44 | self.await_transition() # For accuracy set RTC immediately after a seconds transition 45 | else: 46 | self.ds3231.readfrom_mem_into( 47 | DS3231_I2C_ADDR, 0, self.timebuf) # don't wait 48 | return self.convert(set_rtc) 49 | 50 | def convert(self, set_rtc=False): # Return a tuple in localtime() format (less yday) 51 | data = self.timebuf 52 | ss = bcd2dec(data[0]) 53 | mm = bcd2dec(data[1]) 54 | if data[2] & 0x40: 55 | hh = bcd2dec(data[2] & 0x1f) 56 | if data[2] & 0x20: 57 | hh += 12 58 | else: 59 | hh = bcd2dec(data[2]) 60 | wday = data[3] 61 | DD = bcd2dec(data[4]) 62 | MM = bcd2dec(data[5] & 0x1f) 63 | YY = bcd2dec(data[6]) 64 | if data[5] & 0x80: 65 | YY += 2000 66 | else: 67 | YY += 1900 68 | # Time from DS3231 in time.localtime() format (less yday) 69 | result = YY, MM, DD, hh, mm, ss, wday, 0 70 | if set_rtc: 71 | if rtc is None: 72 | # Best we can do is to set local time 73 | secs = utime.mktime(result) 74 | utime.localtime(secs) 75 | else: 76 | rtc.datetime((YY, MM, DD, wday, hh, mm, ss, 0)) 77 | return result 78 | 79 | def save_time(self, t): 80 | (YY, MM, mday, hh, mm, ss, wday, yday) = t 81 | self.ds3231.writeto_mem(DS3231_I2C_ADDR, 0, tobytes(dec2bcd(ss))) 82 | self.ds3231.writeto_mem(DS3231_I2C_ADDR, 1, tobytes(dec2bcd(mm))) 83 | self.ds3231.writeto_mem(DS3231_I2C_ADDR, 2, tobytes( 84 | dec2bcd(hh))) # Sets to 24hr mode 85 | self.ds3231.writeto_mem(DS3231_I2C_ADDR, 3, tobytes( 86 | dec2bcd(wday))) # 0 == Monday, 6 == Sunday 87 | self.ds3231.writeto_mem(DS3231_I2C_ADDR, 4, tobytes( 88 | dec2bcd(mday))) # Day of month 89 | if YY >= 2000: 90 | self.ds3231.writeto_mem(DS3231_I2C_ADDR, 5, tobytes( 91 | dec2bcd(MM) | 0b10000000)) # Century bit 92 | self.ds3231.writeto_mem( 93 | DS3231_I2C_ADDR, 6, tobytes(dec2bcd(YY-2000))) 94 | else: 95 | self.ds3231.writeto_mem(DS3231_I2C_ADDR, 5, tobytes(dec2bcd(MM))) 96 | self.ds3231.writeto_mem( 97 | DS3231_I2C_ADDR, 6, tobytes(dec2bcd(YY-1900))) 98 | 99 | # Wait until DS3231 seconds value changes before reading and returning data 100 | def await_transition(self): 101 | self.ds3231.readfrom_mem_into(DS3231_I2C_ADDR, 0, self.timebuf) 102 | ss = self.timebuf[0] 103 | while ss == self.timebuf[0]: 104 | self.ds3231.readfrom_mem_into(DS3231_I2C_ADDR, 0, self.timebuf) 105 | return self.timebuf 106 | 107 | # Test hardware RTC against DS3231. Default runtime 10 min. Return amount 108 | # by which DS3231 clock leads RTC in PPM or seconds per year. 109 | # Precision is achieved by starting and ending the measurement on DS3231 110 | # one-seond boundaries and using ticks_ms() to time the RTC. 111 | # For a 10 minute measurement +-1ms corresponds to 1.7ppm or 53s/yr. Longer 112 | # runtimes improve this, but the DS3231 is "only" good for +-2ppm over 0-40C. 113 | def rtc_test(self, runtime=600, ppm=False, verbose=True): 114 | if rtc is None: 115 | raise RuntimeError('machine.RTC does not exist') 116 | verbose and print('Waiting {} minutes for result'.format(runtime//60)) 117 | factor = 1_000_000 if ppm else 114_155_200 # seconds per year 118 | 119 | self.await_transition() # Start on transition of DS3231. Record time in .timebuf 120 | t = utime.ticks_ms() # Get system time now 121 | ss = rtc.datetime()[6] # Seconds from system RTC 122 | while ss == rtc.datetime()[6]: 123 | pass 124 | ds = utime.ticks_diff(utime.ticks_ms(), t) # ms to transition of RTC 125 | # Time when transition occurred 126 | ds3231_start = utime.mktime(self.convert()) 127 | t = rtc.datetime() 128 | rtc_start = utime.mktime( 129 | (t[0], t[1], t[2], t[4], t[5], t[6], t[3], 0)) # y m d h m s wday 0 130 | 131 | utime.sleep(runtime) # Wait a while (precision doesn't matter) 132 | 133 | self.await_transition() # of DS3231 and record the time 134 | t = utime.ticks_ms() # and get system time now 135 | ss = rtc.datetime()[6] # Seconds from system RTC 136 | while ss == rtc.datetime()[6]: 137 | pass 138 | de = utime.ticks_diff(utime.ticks_ms(), t) # ms to transition of RTC 139 | # Time when transition occurred 140 | ds3231_end = utime.mktime(self.convert()) 141 | t = rtc.datetime() 142 | # y m d h m s wday 0 143 | rtc_end = utime.mktime((t[0], t[1], t[2], t[4], t[5], t[6], t[3], 0)) 144 | 145 | d_rtc = 1000 * (rtc_end - rtc_start) + de - ds # ms recorded by RTC 146 | d_ds3231 = 1000 * (ds3231_end - ds3231_start) # ms recorded by DS3231 147 | ratio = (d_ds3231 - d_rtc) / d_ds3231 148 | ppm = ratio * 1_000_000 149 | verbose and print( 150 | 'DS3231 leads RTC by {:4.1f}ppm {:4.1f}mins/yr'.format(ppm, ppm*1.903)) 151 | return ratio * factor 152 | 153 | def _twos_complement(self, input_value: int, num_bits: int) -> int: 154 | mask = 2 ** (num_bits - 1) 155 | return -(input_value & mask) + (input_value & ~mask) 156 | 157 | def get_temperature(self): 158 | t = self.ds3231.readfrom_mem(DS3231_I2C_ADDR, 0x11, 2) 159 | i = t[0] << 8 | t[1] 160 | return self._twos_complement(i >> 6, 10) * 0.25 161 | -------------------------------------------------------------------------------- /helpers.py: -------------------------------------------------------------------------------- 1 | import ujson 2 | 3 | 4 | def convert_twenty_four_to_twelve_hour(hour): 5 | if hour <= 12: 6 | return hour 7 | elif hour == 13: 8 | return 1 9 | elif hour == 14: 10 | return 2 11 | elif hour == 15: 12 | return 3 13 | elif hour == 16: 14 | return 4 15 | elif hour == 17: 16 | return 5 17 | elif hour == 18: 18 | return 6 19 | elif hour == 19: 20 | return 7 21 | elif hour == 20: 22 | return 8 23 | elif hour == 21: 24 | return 9 25 | elif hour == 22: 26 | return 10 27 | elif hour == 23: 28 | return 11 29 | 30 | 31 | def convert_celsius_to_temperature(temp): 32 | return (temp * 1.8) + 32 33 | 34 | 35 | def read_json_file(filename): 36 | with open(filename) as json_file: 37 | data = ujson.loads(json_file.read()) 38 | return data 39 | 40 | 41 | def write_json_file(filename, json_dict): 42 | json_string = ujson.dumps(json_dict) 43 | with open(filename, "w") as json_file: 44 | json_file.write(json_string) 45 | -------------------------------------------------------------------------------- /localPTZtime.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | """ 3 | Method to convert time - in seconds passed since Unix epoch - from GMT time zone to given time zone expressed in Posix Time Zone format. 4 | 5 | :author: Roberto Bellingeri 6 | :copyright: Copyright 2023 - NetGuru 7 | :license: GPL 8 | """ 9 | 10 | """ 11 | Changelog: 12 | 13 | 0.0.1 14 | initial release 15 | 0.0.2 16 | changed returned format 17 | 0.0.3 18 | fixed bug in leap year calculation 19 | """ 20 | 21 | __version__ = "0.0.3" 22 | 23 | 24 | import time 25 | import re 26 | 27 | def checkptz(ptz_string: str): 28 | """ 29 | Check if the format of the string complies with what is described here: https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html 30 | Only for testing purposes: on MicroPython always return 'None'. 31 | 32 | Parameters: 33 | ptz_string (str): String in Posix Time Zone format 34 | 35 | Returns: 36 | bool: Test result 37 | """ 38 | ptz_string = _normalize(ptz_string) 39 | 40 | result = None 41 | 42 | if hasattr(re, 'fullmatch'): # re.fullmatch() is not defined in MicroPython 43 | check_re = r"^" 44 | check_re += r"[^:\d+-]{3,}" # std name 45 | check_re += r"[+-]?\d{1,2}(?::\d{1,2}){0,2}" # std offset 46 | 47 | check_re += r"(?:" # dst zone begin 48 | check_re += r"[^:\d+-,]{3,}" # dst name 49 | check_re += r"(?:" # dst offset begin 50 | check_re += r"(?:[+-]?\d{1,2}(?::\d{1,2}){0,2})?" # dst offset time, can be omitted 51 | check_re += r"(?:,(?:J\d{1,3}|\d{1,3}|M(?:[1-9]|1[0-2])\.[1-5]\.[0-6])(?:\/\d{1,2}(?::\d{1,2}){0,2})?){2}" #dst start/end date - time can be omitted 52 | check_re += r")" # dst offset end 53 | check_re += r")?" # dst zone finish, can be omitted 54 | 55 | check_re += r"$" 56 | 57 | #print(check_re) 58 | 59 | if (re.fullmatch(check_re,ptz_string) == None): # type: ignore 60 | result = False 61 | else: 62 | result = True 63 | 64 | return result 65 | 66 | 67 | def tztime(timestamp: float, ptz_string: str): 68 | """ 69 | Converts a time expressed in seconds in struct_time style 9-tuple according to the time zone provided in Posix Time Zone format 70 | 71 | Parameters: 72 | timestamp (float): Time in second 73 | ptz_string (str): Time zone in Posix format 74 | 75 | Returns: 76 | struct_time style tuple: 77 | * ``year`` 78 | * ``month`` 79 | * ``mday`` 80 | * ``hour`` 81 | * ``minute`` 82 | * ``second`` 83 | * ``weekday`` 84 | * ``yearday`` 85 | * ``isdst`` 86 | """ 87 | return _timecalc(timestamp, ptz_string)[:9] 88 | 89 | 90 | def tziso(timestamp: float, ptz_string: str, zone_designator = True): 91 | """ 92 | Return an ISO 8601 date and time string according to the time zone provided in Posix Time Zone format 93 | 94 | Parameters: 95 | timestamp (float): Time in second 96 | ptz_string (str): Time Zone in Posix format 97 | zone_designator (bool): Insert zone designator in returned string - default: True 98 | 99 | Returns: 100 | string: ISO 8601 date and time string 101 | """ 102 | tx = _timecalc(timestamp, ptz_string) 103 | 104 | stx = f"{tx[0]}-{tx[1]:02d}-{tx[2]:02d}T{tx[3]:02d}:{tx[4]:02d}:{tx[5]:02d}" 105 | if (zone_designator == True): 106 | if (tx[9] != 0): 107 | stx += f"{(tx[9] // 3600):+03d}" 108 | 109 | mins = abs(tx[9]) % 3600 110 | if (mins != 0): 111 | stx += ":" + f"{(mins // 60):02d}" 112 | else: 113 | stx += "Z" 114 | 115 | return stx 116 | 117 | 118 | def _timecalc(timestamp: float, ptz_string: str): 119 | """ 120 | Converts a time expressed in seconds in 10-tuple according to the time zone provided in Posix Time Zone format 121 | 122 | Parameters: 123 | timestamp (float): Time in second 124 | ptz_string (str): Time zone in Posix format 125 | zone_designator (bool): Insert zone designator in returned string - default: True 126 | 127 | Returns: 128 | tuple: 129 | * ``year`` 130 | * ``month`` 131 | * ``mday`` 132 | * ``hour`` 133 | * ``minute`` 134 | * ``second`` 135 | * ``weekday`` 136 | * ``yearday`` 137 | * ``isdst`` 138 | * ``utcoffset`` 139 | """ 140 | ptz_string = _normalize(ptz_string) 141 | 142 | std_offset_seconds = 0 143 | dst_offset_seconds = 0 144 | tot_offset_seconds = 0 145 | is_dst = False 146 | 147 | ptz_parts = ptz_string.split(",") 148 | 149 | #offsetHours = re.split(r"[^\d\+\-\:]+", ptz_parts[0]) 150 | offsetHours = re.compile(r"[^\d\+\-\:]+").split(ptz_parts[0]) # re.compile() is used for MicroPython compatibility 151 | offsetHours = list(filter(None, offsetHours)) 152 | #print(offsetHours) 153 | 154 | if (len(offsetHours) > 0): 155 | 156 | std_offset_seconds = - _hours2secs(offsetHours[0]) 157 | 158 | if (len(offsetHours)>1): 159 | dst_offset_seconds = _hours2secs(offsetHours[1]) 160 | else: 161 | dst_offset_seconds = 3600 162 | 163 | #print("timestamp:\t" + str(int(timestamp))) 164 | 165 | if (len(ptz_parts)==3): 166 | year = time.gmtime(int(timestamp))[0] 167 | dst_start = _parseposixtransition(ptz_parts[1], year) 168 | dst_end = _parseposixtransition(ptz_parts[2], year) 169 | 170 | if (dst_start < dst_end): #northern hemisphere 171 | if ((timestamp + std_offset_seconds) < dst_start): 172 | is_dst = False 173 | tot_offset_seconds = std_offset_seconds 174 | elif ((timestamp + std_offset_seconds + dst_offset_seconds) < dst_end): 175 | is_dst = True 176 | tot_offset_seconds = std_offset_seconds + dst_offset_seconds 177 | else: 178 | is_dst = False 179 | tot_offset_seconds = std_offset_seconds 180 | else: # southern hemisphere 181 | if ((timestamp + std_offset_seconds + dst_offset_seconds) < dst_end): 182 | is_dst = True 183 | tot_offset_seconds = std_offset_seconds + dst_offset_seconds 184 | elif ((timestamp + std_offset_seconds) < dst_start): 185 | is_dst = False 186 | tot_offset_seconds = std_offset_seconds 187 | else: 188 | is_dst = True 189 | tot_offset_seconds = std_offset_seconds + dst_offset_seconds 190 | 191 | #print("dstOffset:\t" + str(dst_offset_seconds)) 192 | #print("dstStart:\t" + str(dst_start) + "\t" + str(time.gmtime(dst_start))) 193 | #print("dstEnd: \t" + str(dst_end) + "\t" + str(time.gmtime(dst_end))) 194 | 195 | else: 196 | tot_offset_seconds = std_offset_seconds 197 | 198 | timemod = timestamp + tot_offset_seconds 199 | 200 | t = time.gmtime(int(timemod)) 201 | 202 | tx = (t[0], t[1], t[2], t[3], t[4], t[5], t[6], t[7], int(is_dst), tot_offset_seconds) 203 | 204 | return tx 205 | 206 | 207 | def _normalize(ptz_string: str): 208 | """ 209 | Return simple normalization of PTZ string 210 | 211 | Parameters: 212 | ptz_string (str): PTZ string 213 | 214 | Returns: 215 | str: Normalized PTZ string 216 | """ 217 | ptz_string = ptz_string.upper() 218 | ptz_string = re.compile(r"\<[^\>]*\>").sub("DUMMY",ptz_string) # For what appear to be non-standard strings like "<+11>-11<+12>,M10.1.0,M4.1.0/3" 219 | 220 | return ptz_string 221 | 222 | 223 | def _parseposixtransition(transition: str, year: int): 224 | """ 225 | Returns the moment of the transition from std to dst and vice-versa 226 | 227 | Parameters: 228 | transition (str): Part of Posix Time Zone string related to the transition 229 | year (int): The year 230 | 231 | Returns: 232 | float: Time adjusted 233 | """ 234 | parts = transition.split('/') 235 | seconds = 0 236 | tr = 0 237 | 238 | if (len(parts) == 2): 239 | seconds = _hours2secs(parts[1]) 240 | 241 | else: 242 | seconds = 2 * 3600 243 | 244 | 245 | if (transition[0] == "M"): 246 | # 'Mm.n.d' format. 247 | 248 | date_parts = parts[0][1:].split('.') 249 | if (len(date_parts)==3): 250 | month = int(date_parts[0]) # month from '1' to '12' 251 | week_of_month = int(date_parts[1]) # week number from '1' to '5'. '5' always the last. 252 | day_of_week = int(date_parts[2]) # day of week - 0:Sunday 1:Monday 2:Tuesday 3:Wednesday 4:Thursday 5:Friday 6:Saturday 253 | 254 | base_year = 1970 255 | base_year_1st_day = 4 # the first day of the year 1970 was Thursday 256 | 257 | month_days = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] 258 | if ((((year % 4) == 0) and ((year % 100) != 0)) or (year % 400) == 0): 259 | month_days[1] = 29 260 | 261 | # calculate the number of days since 1/1/base_year 262 | days_since_base_date = (year - base_year) * 365 263 | 264 | for y in range(base_year, year): 265 | if ((((y % 4) == 0) and ((y % 100) != 0)) or (y % 400) == 0): 266 | days_since_base_date += 1 267 | 268 | days_since_base_date += sum(month_days[:month - 1]) 269 | 270 | # calculate the day of the week for the first day of month 271 | first_day_of_month = (days_since_base_date + base_year_1st_day) % 7 272 | 273 | # calculate the day of the month 274 | day_of_month = 1 + (week_of_month - 1) * 7 + (day_of_week - first_day_of_month) % 7 275 | 276 | if day_of_month > month_days[month - 1]: 277 | day_of_month -= 7 278 | 279 | tr = time.mktime((year, month, day_of_month, 0, 0, 0, 0, 0, 0)) 280 | 281 | elif (transition[0] == "J"): 282 | # 'Jn' format. Counting from 1 to 365, and February 29 is never counted. 283 | 284 | day_num = int(parts[0][1:]) 285 | if (((((year % 4) == 0) and ((year % 100) != 0)) or (year % 400) == 0) and (day_num > (31 + 28))): # after February 28 in leap years 286 | day_num += 1 287 | tr = time.mktime((year,1,1,0,0,0,0,0,0)) + ((day_num - 1) * 86400) 288 | 289 | else: 290 | # 'n' format. Counting from zero to 364, or to 365 in leap years. 291 | 292 | day_num = int(parts[0]) 293 | tr = time.mktime((year,1,1,0,0,0,0,0,0)) + (day_num * 86400) 294 | 295 | return tr + seconds 296 | 297 | 298 | def _hours2secs(hours: str): 299 | """ 300 | Convert hours string in seconds 301 | 302 | Parameters: 303 | hours (str): Hours in format 00[:00][:00] 304 | 305 | Returns: 306 | int: seconds 307 | """ 308 | seconds = 0 309 | 310 | hours_parts = hours.split(':') 311 | 312 | if (len(hours_parts)>0): 313 | seconds = int(hours_parts[0]) * 3600 314 | if (len(hours_parts)>1): 315 | seconds += int(hours_parts[1]) * 60 316 | if (len(hours_parts)>2): 317 | seconds += int(hours_parts[2]) 318 | 319 | return seconds 320 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from utime import sleep 2 | from display import Display 3 | from pico_temperature import PicoTemperature 4 | from scheduler import Scheduler 5 | from clock import Clock 6 | from apps import Apps, App 7 | from pomodoro import Pomodoro 8 | from temperature import Temperature 9 | from time_set import TimeSet 10 | from wifi import WLAN 11 | from mqtt import MQTT 12 | from configuration import Configuration 13 | import machine 14 | import uasyncio 15 | import _thread 16 | 17 | 18 | machine.freq(250_000_000) # type: ignore 19 | 20 | APP_CLASSES = [ 21 | Clock, 22 | Pomodoro, 23 | TimeSet 24 | ] 25 | 26 | print("-" * 10) 27 | print("PICO CLOCK") 28 | print("-" * 10) 29 | 30 | print("Configuring...") 31 | config = Configuration() 32 | 33 | scheduler = Scheduler() 34 | wlan = WLAN(scheduler) 35 | mqtt = MQTT(scheduler) 36 | display = Display(scheduler) 37 | pico_temperature = PicoTemperature(scheduler, mqtt) 38 | temperature = Temperature(mqtt) 39 | apps = Apps(scheduler) 40 | 41 | # register apps 42 | for App in APP_CLASSES: 43 | apps.add(App(scheduler)) 44 | 45 | 46 | async def start(): 47 | print("STARTING...") 48 | 49 | # start async scheduler 50 | scheduler.start() 51 | 52 | # create thread for UI updates. 53 | _thread.start_new_thread(display.enable_leds, ()) 54 | 55 | # start apps 56 | await apps.start() 57 | 58 | uasyncio.run(start()) 59 | loop = uasyncio.get_event_loop() 60 | loop.run_forever() 61 | -------------------------------------------------------------------------------- /mqtt.py: -------------------------------------------------------------------------------- 1 | import time 2 | import json 3 | from configuration import Configuration 4 | from constants import SCHEDULER_MQTT_CHECK, SCHEDULER_MQTT_HEARTBEAT, SCHEDULER_MQTT_STATE 5 | from scheduler import Scheduler 6 | from util import singleton 7 | 8 | 9 | @singleton 10 | class MQTT: 11 | class MQTT_Callback: 12 | def __init__(self, topic: str, callback: function) -> None: 13 | self.topic = topic 14 | self.callback = callback 15 | 16 | class MQTT_State: 17 | def __init__(self, name: str, callback: function) -> None: 18 | self.name = name 19 | self.callback = callback 20 | 21 | def __init__(self, scheduler: Scheduler): 22 | self.scheduler = scheduler 23 | self.lastping = 0 24 | self.registered_callbacks = [] 25 | self.state_callbacks = [] 26 | self.configuration = Configuration().mqtt_config 27 | if self.configuration.enabled: 28 | from umqtt.simple import MQTTClient 29 | self.client = MQTTClient(self.configuration.prefix, self.configuration.broker, user=None, 30 | password=None, keepalive=300, ssl=False, ssl_params={}) 31 | self.connect() 32 | scheduler.schedule(SCHEDULER_MQTT_HEARTBEAT, 250, 33 | self.scheduler_heartbeat_callback) 34 | scheduler.schedule(SCHEDULER_MQTT_CHECK, 1, 35 | self.scheduler_mqtt_callback) 36 | scheduler.schedule(SCHEDULER_MQTT_STATE, 60000, 37 | self.scheduler_mqtt_state) 38 | 39 | def connect(self): 40 | print("Connecting to MQTT") 41 | self.client.connect() 42 | self.heartbeat(True) 43 | self.client.set_callback(self.mqtt_callback) 44 | topic = self.configuration.prefix + "#" 45 | self.client.subscribe(topic) 46 | print("Subscribed to " + topic) 47 | 48 | def heartbeat(self, first=False): 49 | if first: 50 | self.client.ping() 51 | self.lastping = time.ticks_ms() 52 | if time.ticks_diff(time.ticks_ms(), self.lastping) >= 300000: 53 | self.client.ping() 54 | self.lastping = time.ticks_ms() 55 | return 56 | 57 | async def scheduler_heartbeat_callback(self): 58 | self.heartbeat(False) 59 | 60 | async def scheduler_mqtt_callback(self): 61 | self.client.check_msg() 62 | 63 | async def scheduler_mqtt_state(self): 64 | self.send_state() 65 | 66 | def mqtt_callback(self, topic, msg): 67 | t = topic.decode().lstrip(mqtt_prefix) 68 | for c in self.registered_callbacks: 69 | if t == c.topic: 70 | c.callback(topic, msg) 71 | 72 | def send_event(self, topic: str, msg: str): 73 | topic = mqtt_prefix + topic 74 | self.client.publish(topic, msg) 75 | 76 | def send_state(self): 77 | self.client.publish(mqtt_base_topic, self.build_state()) 78 | 79 | def build_state(self): 80 | state = dict() 81 | for s in self.state_callbacks: 82 | item_name = s.name 83 | item_state = s.callback() 84 | state[item_name] = item_state 85 | return json.dumps(state) 86 | 87 | def register_topic_callback(self, topic, callback): 88 | self.registered_callbacks.append(self.MQTT_Callback(topic, callback)) 89 | 90 | def register_state_callback(self, name, callback): 91 | self.state_callbacks.append(self.MQTT_State(name, callback)) 92 | -------------------------------------------------------------------------------- /pico_temperature.py: -------------------------------------------------------------------------------- 1 | import machine 2 | from mqtt import MQTT 3 | from scheduler import Scheduler 4 | from util import singleton 5 | 6 | 7 | @singleton 8 | class PicoTemperature: 9 | def __init__(self, scheduler: Scheduler, mqtt: MQTT): 10 | self.scheduler = scheduler 11 | self.mqtt = mqtt 12 | self.sensor = machine.ADC(4) 13 | self.conversion_factor = 3.3 / (65535) 14 | self.temperature = 0 15 | mqtt.register_state_callback( 16 | "device_temperature", self.mqtt_state_callback) 17 | 18 | def get_temperature(self): 19 | reading = self.sensor.read_u16() * self.conversion_factor 20 | self.temperature = 27 - (reading - 0.706)/0.001721 21 | return self.temperature 22 | 23 | def mqtt_state_callback(self): 24 | temp = self.get_temperature() 25 | return str(temp) 26 | -------------------------------------------------------------------------------- /pomodoro.py: -------------------------------------------------------------------------------- 1 | import time 2 | from apps import App 3 | from buttons import Buttons 4 | from constants import APP_POMODORO, SCHEDULER_POMODORO_SECOND 5 | from display import Display 6 | from speaker import Speaker 7 | 8 | 9 | class Pomodoro(App): 10 | def __init__(self, scheduler): 11 | self.scheduler = scheduler 12 | App.__init__(self, APP_POMODORO) 13 | self.display = Display(scheduler) 14 | self.speaker = Speaker(scheduler) 15 | self.buttons = Buttons(scheduler) 16 | self.enabled = False 17 | self.started = False 18 | self.start_time = None 19 | self.time_left = None 20 | self.grab_top_button = True 21 | scheduler.schedule(SCHEDULER_POMODORO_SECOND, 1000, self.secs_callback) 22 | self.minutes = 25 23 | self.pomodoro_duration = self.minutes * 60 24 | 25 | async def enable(self): 26 | self.enabled = True 27 | self.active = True 28 | self.buttons.add_callback(2, self.up_callback, max=500) 29 | self.buttons.add_callback(3, self.down_callback, max=500) 30 | self.display.hide_temperature_icons() 31 | self.display.show_icon("CountDown") 32 | await self.show_time(self.pomodoro_duration) 33 | 34 | def disable(self): 35 | self.enabled = False 36 | self.started = False 37 | self.start_time = None 38 | self.display.hide_icon("CountDown") 39 | 40 | async def top_button(self): 41 | if self.enabled and self.started: 42 | self.stop() 43 | else: 44 | print("START POMODORO") 45 | self.start() 46 | 47 | async def up_callback(self): 48 | if self.time_left: 49 | now = int(self._time_left()) 50 | self.minutes = now // 60 51 | self.time_left = None 52 | self.minutes += 1 53 | await self.update_pomodoro_duration() 54 | 55 | async def down_callback(self): 56 | if self.time_left: 57 | now = int(self._time_left()) 58 | self.minutes = now // 60 59 | self.time_left = None 60 | if self.minutes > 1: 61 | self.minutes -= 1 62 | await self.update_pomodoro_duration() 63 | 64 | async def update_pomodoro_duration(self): 65 | self.pomodoro_duration = self.minutes * 60 66 | await self.show_time(self.pomodoro_duration) 67 | 68 | def start(self): 69 | self.started = True 70 | self.start_time = time.ticks_ms() 71 | if not self.time_left: 72 | self.time_left = self.pomodoro_duration 73 | 74 | def _time_left(self): 75 | return self.time_left - (time.ticks_diff(time.ticks_ms(), self.start_time)/1000) 76 | 77 | def stop(self): 78 | self.started = False 79 | self.time_left = self._time_left() 80 | 81 | async def show_time(self, time): 82 | t = "%02d:%02d" % (time // 60, time % 60) 83 | await self.display.show_text(t) 84 | 85 | async def secs_callback(self): 86 | if self.enabled and self.started: 87 | now = int(self._time_left()) 88 | await self.show_time(now) 89 | if now <= 0: 90 | self.speaker.beep(1000) 91 | self.started = False 92 | self.start_time = None 93 | self.time_left = None 94 | -------------------------------------------------------------------------------- /rtc.py: -------------------------------------------------------------------------------- 1 | from machine import Pin, SoftI2C 2 | from ds3231_port import DS3231 3 | from util import singleton 4 | 5 | 6 | @singleton 7 | class RTC: 8 | def __init__(self): 9 | rtc_i2c = SoftI2C(scl=Pin(7), sda=Pin(6), freq=100000) # type: ignore 10 | self.ds = DS3231(rtc_i2c) 11 | pass 12 | 13 | def get_time(self): 14 | return self.ds.get_time() 15 | 16 | def save_time(self, t): 17 | return self.ds.save_time(t) 18 | 19 | def get_temperature(self): 20 | temp = self.ds.get_temperature() 21 | print(temp) 22 | return temp 23 | -------------------------------------------------------------------------------- /run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import contextlib 4 | import os 5 | import subprocess 6 | import sys 7 | from datetime import datetime 8 | from pathlib import Path 9 | 10 | parser = argparse.ArgumentParser() 11 | parser.add_argument('--device', action='store_true', 12 | default="/dev/cu.usbmodem14101") 13 | args = parser.parse_args() 14 | 15 | CURRENT_DIR = Path(__file__).parent 16 | NOW = datetime.now() 17 | with contextlib.suppress(FileNotFoundError): 18 | LASTRUN = datetime(year=2000, month=1, day=1) 19 | with open("LASTRUN") as last_run_file: 20 | LASTRUN = datetime.utcfromtimestamp(int(last_run_file.read())) 21 | with open("LASTRUN", "w") as last_run_file: 22 | last_run_file.write(NOW.strftime("%s")) 23 | 24 | found_config = False 25 | for file in os.listdir(CURRENT_DIR): 26 | if file.split(".")[-1] == "py" or file.split(".")[-1] == "json": 27 | if file == "configuration.py": 28 | found_config = True 29 | 30 | if datetime.utcfromtimestamp(os.path.getmtime(CURRENT_DIR.joinpath(file))) > LASTRUN: 31 | print(f"copy: {file}") 32 | subprocess.run( 33 | ["ampy", "--port", args.device, "put", file, f"/{file}"]) 34 | else: 35 | print(f"up to date: {file}") 36 | 37 | if not found_config: 38 | print("You need to create a configuration.py file. See the README.") 39 | sys.exit("No configuration.py file found") 40 | 41 | print("Uploaded code") 42 | print("") 43 | 44 | subprocess.run(["ampy", "--port", args.device, "run", "main.py"]) 45 | -------------------------------------------------------------------------------- /scheduler.py: -------------------------------------------------------------------------------- 1 | import uasyncio 2 | 3 | 4 | class Scheduler: 5 | class Schedule: 6 | def __init__(self, name: str, duration: int, callback: uasyncio.Task, initial_delay: int): 7 | self.name = name 8 | self.duration = duration 9 | self.callback = callback 10 | self.initial_delay = initial_delay 11 | self.cancelled = False 12 | 13 | def __init__(self): 14 | self.started = False 15 | self.schedules = [] 16 | self.display_task = None 17 | self.event_loop = uasyncio.get_event_loop() 18 | 19 | def start(self): 20 | self.started = True 21 | for schedule in self.schedules: 22 | self.event_loop.create_task(self._start_task(schedule)) 23 | 24 | async def _start_task(self, task: Schedule): 25 | if task.initial_delay != 0: 26 | await uasyncio.sleep_ms(task.initial_delay) 27 | while True: 28 | if task.cancelled: 29 | break 30 | await task.callback() 31 | await uasyncio.sleep_ms(task.duration) 32 | 33 | def schedule(self, name, duration, callback, initial_delay=0): 34 | task = self.Schedule(name, duration, callback, initial_delay) 35 | self.schedules.append(task) 36 | if self.started: 37 | self.event_loop.create_task(self._start_task(task)) 38 | 39 | def remove(self, name): 40 | for schedule in self.schedules: 41 | if schedule.name == name: 42 | schedule.cancelled = True 43 | self.schedules.remove(schedule) 44 | -------------------------------------------------------------------------------- /setup/install-umqtt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import time 3 | import network 4 | import upip 5 | from configuration_old import wlan_id, wlan_password 6 | 7 | print("Connecting to wifi") 8 | wlan = network.WLAN(network.STA_IF) 9 | wlan.active(True) 10 | wlan.config(pm=0xa11140) # Disable powersave mode 11 | wlan.connect(wlan_id, wlan_password) 12 | 13 | # Wait for connect or fail 14 | max_wait = 10 15 | while max_wait > 0: 16 | if wlan.status() < 0 or wlan.status() >= 3: 17 | break 18 | max_wait -= 1 19 | print('Waiting for connection...') 20 | time.sleep(1) 21 | 22 | print("Installing umqtt.simple") 23 | upip.install('umqtt.simple') 24 | -------------------------------------------------------------------------------- /speaker.py: -------------------------------------------------------------------------------- 1 | from machine import Pin 2 | import time 3 | from constants import SCHEDULER_SPEAKER_BEEPS 4 | 5 | from util import singleton 6 | 7 | 8 | @singleton 9 | class Speaker: 10 | def __init__(self, scheduler): 11 | self.buzz = Pin(14, Pin.OUT) 12 | self.buzz.value(0) 13 | self.buzz_start = 0 14 | self.duration = 0 15 | scheduler.schedule(SCHEDULER_SPEAKER_BEEPS, 1, self.beep_callback) 16 | 17 | def beep(self, duration): 18 | self.buzz.value(1) 19 | self.buzz_start = time.ticks_ms() 20 | self.duration = duration 21 | 22 | def beep_off(self): 23 | self.buzz.value(0) 24 | self.duration = 0 25 | self.buzz_start = 0 26 | 27 | async def beep_callback(self): 28 | if self.buzz_start != 0: 29 | tm = time.ticks_ms() 30 | if time.ticks_diff(tm, self.buzz_start) > self.duration: 31 | self.beep_off() 32 | -------------------------------------------------------------------------------- /temperature.py: -------------------------------------------------------------------------------- 1 | from machine import SoftI2C 2 | from machine import Pin 3 | from ds3231_port import DS3231 4 | from util import singleton 5 | from mqtt import MQTT 6 | 7 | 8 | @singleton 9 | class Temperature: 10 | def __init__(self, mqtt: MQTT): 11 | self.mqtt = mqtt 12 | rtc_i2c = SoftI2C(scl=Pin(7), sda=Pin(6), freq=100000) # type: ignore 13 | self.ds = DS3231(rtc_i2c) 14 | mqtt.register_state_callback("temperature", self.get_temperature) 15 | pass 16 | 17 | def get_time(self): 18 | return self.ds.get_time() 19 | 20 | def save_time(self, t): 21 | return self.ds.save_time(t) 22 | 23 | def get_temperature(self): 24 | return self.ds.get_temperature() 25 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | from rtc import RTC 2 | from display import Display 3 | from scheduler import Scheduler 4 | scheduler = Scheduler() 5 | dis = Display(scheduler) 6 | scheduler.start() 7 | clock = RTC() 8 | -------------------------------------------------------------------------------- /time_set.py: -------------------------------------------------------------------------------- 1 | from apps import App 2 | from buttons import Buttons 3 | from constants import APP_TIME_SET, SCHEDULER_TIME_SET_HALF_SECOND, SCHEDULER_TIME_SET_MINUTE 4 | from display import Display 5 | from rtc import RTC 6 | import uasyncio 7 | 8 | month_max = { 9 | 1: 31, # January 10 | 2: 29, # February 11 | 3: 31, # March 12 | 4: 30, # April 13 | 5: 31, # May 14 | 6: 30, # June 15 | 7: 31, # July 16 | 8: 31, # August 17 | 9: 30, # September 18 | 10: 31, # October 19 | 11: 30, # November 20 | 12: 31, # December 21 | } 22 | 23 | 24 | class TimeSet(App): 25 | class State: 26 | def __init__(self, name, position, panel, index, max, length=2, offset=0): 27 | self.name = name 28 | self.position = position 29 | self.panel = panel 30 | self.index = index 31 | self.max = max 32 | self.length = length 33 | self.offset = offset 34 | 35 | def __init__(self, scheduler): 36 | App.__init__(self, APP_TIME_SET) 37 | 38 | self.display = Display(scheduler) 39 | self.scheduler = scheduler 40 | self.buttons = Buttons(scheduler) 41 | self.rtc = RTC() 42 | self.grab_top_button = True 43 | self.enabled = False 44 | self.active = False 45 | self.state = None 46 | self.state_index = -1 47 | self.flash_count = 0 48 | self.flash_state = False 49 | scheduler.schedule(SCHEDULER_TIME_SET_HALF_SECOND, 500, 50 | self.half_secs_callback) 51 | scheduler.schedule(SCHEDULER_TIME_SET_MINUTE, 52 | 60000, self.mins_callback) 53 | self.initialise_states() 54 | 55 | def initialise_states(self): 56 | self.states = [ 57 | TimeSet.State("dow", 0, "dow", 6, 7, length=7, offset=0), 58 | TimeSet.State("hours", 0, "time", 3, 24), 59 | TimeSet.State("minutes", 13, "time", 4, 60), 60 | TimeSet.State("year", 0, "year", 0, 3000, length=4), 61 | TimeSet.State("month", 0, "date", 1, 12, offset=1), 62 | TimeSet.State("day", 13, "date", 2, -1, offset=1), 63 | ] 64 | 65 | async def enable(self): 66 | self.active = True 67 | self.enabled = True 68 | self.state_index = 0 69 | self.state = self.states[self.state_index] 70 | self.display.hide_temperature_icons() 71 | await self.update_display() 72 | self.buttons.add_callback(2, self.up_callback, max=500) 73 | self.buttons.add_callback(3, self.down_callback, max=500) 74 | 75 | def disable(self): 76 | self.active = False 77 | self.enabled = False 78 | self.state = None 79 | 80 | async def half_secs_callback(self): 81 | if self.enabled: 82 | self.flash_count = (self.flash_count+1) % 3 83 | if self.flash_count == 2: 84 | if self.state.length == 2: 85 | await self.display.show_text(" ", pos=self.state.position) 86 | elif self.state.length == 4: 87 | await self.display.show_text(" ", pos=self.state.position) 88 | self.flash_state = False 89 | else: 90 | if not self.flash_state: 91 | self.flash_state = True 92 | if self.state.length == 2: 93 | await self.display.show_text( 94 | "%02d" % self.time[self.state.index], pos=self.state.position) 95 | elif self.state.length == 4: 96 | await self.display.show_text( 97 | "%04d" % self.time[self.state.index], pos=self.state.position) 98 | 99 | async def mins_callback(self): 100 | if self.enabled: 101 | await self.update_display() 102 | 103 | async def update_display(self): 104 | self.time = self.rtc.get_time() 105 | self.display.reset() 106 | if self.state.panel == "time": 107 | t = self.rtc.get_time() 108 | now = "%02d:%02d" % (t[3], t[4]) 109 | await self.display.show_text(now) 110 | elif self.state.panel == "year": 111 | t = self.rtc.get_time() 112 | now = "%04d" % (t[0]) 113 | await self.display.show_text(now) 114 | elif self.state.panel == "date": 115 | t = self.rtc.get_time() 116 | now = "%02d/%02d" % (t[1], t[2]) 117 | await self.display.show_text(now) 118 | elif self.state.panel == "dow": 119 | print ("Entering Day-of-week panel!") 120 | t = self.rtc.get_time() 121 | now = self.display.days_of_week[t[6]].upper() 122 | print ("Day of week: %s" % now) 123 | # "" % (self.display.days_of_week[ t[6] ]) 124 | await self.display.show_text(now) 125 | self.display.show_day(t[6]) 126 | 127 | async def up_callback(self): 128 | t = list(self.rtc.get_time()) 129 | max = self.state.max 130 | if max == -1: 131 | # This is "day of month", which varies 132 | month = t[1] 133 | max = month_max[month] 134 | 135 | t[self.state.index] = (t[self.state.index]+1 - 136 | self.state.offset) % max + self.state.offset 137 | self.rtc.save_time(tuple(t)) 138 | self.flash_count = 0 139 | await self.update_display() 140 | 141 | async def down_callback(self): 142 | t = list(self.rtc.get_time()) 143 | max = self.state.max 144 | if max == -1: 145 | # This is "day of month", which varies 146 | month = t[1] 147 | max = month_max[month] 148 | 149 | t[self.state.index] = (t[self.state.index]-1 - 150 | self.state.offset) % max + self.state.offset 151 | self.rtc.save_time(tuple(t)) 152 | self.flash_count = 0 153 | await self.update_display() 154 | 155 | async def top_button(self): 156 | if self.state_index == len(self.states) - 1: 157 | self.disable() 158 | await self.display.show_text("DONE") 159 | await uasyncio.sleep(2) 160 | return True 161 | else: 162 | self.flash_count = 0 163 | self.state_index = (self.state_index + 1) % len(self.states) 164 | self.state = self.states[self.state_index] 165 | self.display.reset() 166 | await self.update_display() 167 | return False 168 | -------------------------------------------------------------------------------- /util.py: -------------------------------------------------------------------------------- 1 | def singleton(class_): 2 | instances = {} 3 | 4 | def getinstance(*args, **kwargs): 5 | if class_ not in instances: 6 | instances[class_] = class_(*args, **kwargs) 7 | return instances[class_] 8 | return getinstance 9 | 10 | 11 | def partial(func, *args, **kwargs): 12 | def inner(*iargs, **ikwargs): 13 | return func(*args, *iargs, **kwargs, **ikwargs) 14 | 15 | return inner 16 | -------------------------------------------------------------------------------- /wifi.py: -------------------------------------------------------------------------------- 1 | import time 2 | from configuration import Configuration 3 | from util import singleton 4 | 5 | import network 6 | from rtc import RTC 7 | import time 8 | import ntptime 9 | import localPTZtime 10 | 11 | @singleton 12 | class WLAN: 13 | def __init__(self, scheduler): 14 | self.scheduler = scheduler 15 | self.configuration = Configuration().wifi_config 16 | self.wlan = None 17 | self.rtc = RTC() 18 | if self.configuration.enabled: 19 | self.connect_to_wifi() 20 | 21 | def connect_to_wifi(self): 22 | import network 23 | print("Connecting to WiFi") 24 | #network.hostname(self.configuration.hostname) 25 | self.wlan = network.WLAN(network.STA_IF) 26 | 27 | self.wlan.active(True) 28 | self.wlan.config(pm=0xa11140) # type: ignore - Disable powersave mode 29 | self.wlan.connect(self.configuration.ssid, 30 | self.configuration.passphrase) 31 | 32 | # Wait for connect or fail 33 | max_wait = 20 34 | while max_wait > 0: 35 | if self.wlan.status() < 0 or self.wlan.status() >= 3: 36 | break 37 | max_wait -= 1 38 | print('Waiting for connection...') 39 | time.sleep(1) 40 | 41 | # Handle connection error 42 | if self.wlan.status() != 3: 43 | raise RuntimeError('WiFi connection failed') 44 | else: 45 | status = self.wlan.ifconfig() 46 | print('IP = ' + status[0]) 47 | 48 | if self.configuration.ntp_enabled: 49 | ntptime.settime() 50 | local_time= localPTZtime.tztime(time.time(), self.configuration.ntp_ptz) 51 | self.rtc.save_time(local_time[:8]) 52 | --------------------------------------------------------------------------------