├── countdown ├── screenshot.jpg ├── README.md ├── manifest.yml └── __init__.py ├── pomodoro ├── screenshot.jpg ├── manifest.yml ├── README.md ├── base.py └── __init__.py ├── sensor_app ├── screenshot.jpg ├── ui_page │ ├── __init__.py │ ├── tips.py │ ├── details.py │ ├── history.py │ └── home.py ├── asset │ └── font │ │ └── font_probe_icon.bin ├── product │ ├── __init__.py │ └── virtual_sensor │ │ ├── __init__.py │ │ ├── config.py │ │ ├── ui_style.py │ │ ├── ble_broadcast.py │ │ ├── ui_details.py │ │ ├── data_storage.py │ │ └── ui_home.py ├── manifest.yml ├── demo │ ├── README.md │ └── ble_advertiser.py ├── __init__.py ├── README.md ├── bluetooth │ └── __init__.py ├── base.py └── routes.py ├── stock_view ├── screenshot.jpg ├── manifest.yml ├── README.md ├── service.py └── __init__.py ├── webcam ├── resources │ ├── bg.png │ └── icon.png ├── LICENSE ├── README.md └── __init__.py ├── Days Matter ├── screenshot.jpg ├── resources │ └── icon.png ├── README.md ├── manifest.yml ├── base.py ├── __init__.py └── ui.py ├── hello_world ├── screenshot.jpg ├── README.md ├── manifest.yml └── __init__.py ├── photo_album ├── resources │ ├── 1.jpg │ ├── 2.jpg │ ├── 3.jpg │ ├── 4.jpg │ └── icon.png ├── screenshot.jpg ├── README.md ├── manifest.yml └── __init__.py ├── widgets_demo ├── screenshot.jpg ├── README.md ├── manifest.yml └── __init__.py ├── calendar_view ├── screenshot.jpg ├── README.md ├── manifest.yml └── __init__.py ├── LICENSE └── README.md /countdown/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/countdown/screenshot.jpg -------------------------------------------------------------------------------- /pomodoro/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/pomodoro/screenshot.jpg -------------------------------------------------------------------------------- /sensor_app/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/sensor_app/screenshot.jpg -------------------------------------------------------------------------------- /stock_view/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/stock_view/screenshot.jpg -------------------------------------------------------------------------------- /webcam/resources/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/webcam/resources/bg.png -------------------------------------------------------------------------------- /webcam/resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/webcam/resources/icon.png -------------------------------------------------------------------------------- /Days Matter/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/Days Matter/screenshot.jpg -------------------------------------------------------------------------------- /hello_world/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/hello_world/screenshot.jpg -------------------------------------------------------------------------------- /photo_album/resources/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/photo_album/resources/1.jpg -------------------------------------------------------------------------------- /photo_album/resources/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/photo_album/resources/2.jpg -------------------------------------------------------------------------------- /photo_album/resources/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/photo_album/resources/3.jpg -------------------------------------------------------------------------------- /photo_album/resources/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/photo_album/resources/4.jpg -------------------------------------------------------------------------------- /photo_album/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/photo_album/screenshot.jpg -------------------------------------------------------------------------------- /widgets_demo/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/widgets_demo/screenshot.jpg -------------------------------------------------------------------------------- /Days Matter/resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/Days Matter/resources/icon.png -------------------------------------------------------------------------------- /calendar_view/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/calendar_view/screenshot.jpg -------------------------------------------------------------------------------- /photo_album/resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/photo_album/resources/icon.png -------------------------------------------------------------------------------- /sensor_app/ui_page/__init__.py: -------------------------------------------------------------------------------- 1 | from . import home 2 | from . import tips 3 | from . import details 4 | from . import history 5 | -------------------------------------------------------------------------------- /sensor_app/asset/font/font_probe_icon.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myvobot/dock-mini-apps/HEAD/sensor_app/asset/font/font_probe_icon.bin -------------------------------------------------------------------------------- /Days Matter/README.md: -------------------------------------------------------------------------------- 1 | # Days Matter 2 | 3 | Days Matter is a simple app that helps you track important events by showing how many days are left until your specified date. 4 | 5 | ![Screenshot](screenshot.jpg) 6 | 7 | Discover more on the 8 | [Mini Dock Developer Website](https://dock.myvobot.com/developer/). 9 | -------------------------------------------------------------------------------- /calendar_view/README.md: -------------------------------------------------------------------------------- 1 | # Calendar View 2 | 3 | This is an application that allows users to view the calendar for the past and future five years by rotating an encoder. 4 | 5 | ![Screenshot](screenshot.jpg) 6 | 7 | Discover more on the 8 | [Mini Dock Developer Website](https://dock.myvobot.com/developer/). 9 | -------------------------------------------------------------------------------- /countdown/README.md: -------------------------------------------------------------------------------- 1 | # Countdown 2 | 3 | Countdown is a minimalist countdown timer app powered by the LVGL graphics library, allowing users to set a timer and monitor countdowns through an intuitive interface. 4 | 5 | ![Screenshot](screenshot.jpg) 6 | 7 | Discover more on the 8 | [Mini Dock Developer Website](https://dock.myvobot.com/developer/). 9 | -------------------------------------------------------------------------------- /hello_world/README.md: -------------------------------------------------------------------------------- 1 | # Hello World 2 | 3 | This is a straightforward application that renders simple text on the screen and demonstrates the basic method of creating interactive applications using the LVGL graphics library. 4 | 5 | ![Screenshot](screenshot.jpg) 6 | 7 | Discover more on the 8 | [Mini Dock Developer Website](https://dock.myvobot.com/developer/). 9 | -------------------------------------------------------------------------------- /widgets_demo/README.md: -------------------------------------------------------------------------------- 1 | # Widgets Demo 2 | 3 | This is a simple application that presents some basic controls of the LVGL graphics library on the screen and allows interaction with these controls using an encoder and buttons. 4 | 5 | ![Screenshot](screenshot.jpg) 6 | 7 | Discover more on the 8 | [Mini Dock Developer Website](https://dock.myvobot.com/developer/). 9 | -------------------------------------------------------------------------------- /sensor_app/product/__init__.py: -------------------------------------------------------------------------------- 1 | from . import virtual_sensor 2 | 3 | def get_product_registry(): 4 | """ 5 | Get a mapping of product names to their corresponding modules. 6 | """ 7 | product_registry = {} 8 | 9 | # Register virtual sensor product 10 | product_registry[virtual_sensor.get_product_name()] = virtual_sensor 11 | 12 | return product_registry 13 | -------------------------------------------------------------------------------- /Days Matter/manifest.yml: -------------------------------------------------------------------------------- 1 | application: 2 | name: Days Matter 3 | version: 1.0.1 4 | description: This is a sample application for MiniDock. 5 | author: Vobot 6 | author_email: support@getvobot.com 7 | 8 | attributes: 9 | enable_auto_carousel: true 10 | 11 | system_requirements: 12 | minimum_version: 1.1.3 13 | compatible_devices: 14 | - MiniDock 15 | 16 | files: 17 | include: 18 | - __init__.py 19 | - base.py 20 | - ui.py 21 | -------------------------------------------------------------------------------- /photo_album/README.md: -------------------------------------------------------------------------------- 1 | # Photo Album 2 | 3 | This is a sleek photo album application powered by the LVGL graphics library. It provides a distraction-free interface for users to browse through a collection of preset photos. With an encoder knob, users can effortlessly switch between images, allowing them to relive their cherished memories with ease. 4 | 5 | ![Screenshot](screenshot.jpg) 6 | 7 | Discover more on the 8 | [Mini Dock Developer Website](https://dock.myvobot.com/developer/). 9 | -------------------------------------------------------------------------------- /sensor_app/manifest.yml: -------------------------------------------------------------------------------- 1 | application: 2 | name: Sensor App 3 | version: 1.0.0 4 | description: This is an app that displays sensor data. 5 | author: Vobot 6 | author_email: support@getvobot.com 7 | 8 | attributes: 9 | enable_auto_carousel: true 10 | 11 | system_requirements: 12 | minimum_version: 1.1.0 13 | compatible_devices: 14 | - MiniDock 15 | 16 | files: 17 | include: 18 | - __init__.py 19 | - base.py 20 | - routes.py 21 | - asset/ 22 | - product/ 23 | - bluetooth/ 24 | - ui_page/ 25 | -------------------------------------------------------------------------------- /photo_album/manifest.yml: -------------------------------------------------------------------------------- 1 | application: 2 | name: Photo Album 3 | version: 1.0.0 4 | description: This is a sleek photo album application powered by the LVGL graphics library. 5 | author: Vobot 6 | author_email: support@getvobot.com 7 | bugs: https://github.com/myvobot/dock-mini-apps/issues 8 | repository: https://github.com/myvobot/dock-mini-apps/tree/main/photo_album 9 | licenses: MIT 10 | 11 | system_requirements: 12 | minimum_version: 1.1.0 13 | compatible_devices: 14 | - MiniDock 15 | 16 | files: 17 | include: 18 | - __init__.py 19 | - resources/ 20 | -------------------------------------------------------------------------------- /countdown/manifest.yml: -------------------------------------------------------------------------------- 1 | application: 2 | name: Countdown of App 3 | version: 1.0.0 4 | description: Countdown is a minimalist countdown timer app powered by the LVGL graphics library, allowing users to set a timer and monitor countdowns through an intuitive interface. 5 | author: Vobot 6 | author_email: support@getvobot.com 7 | bugs: https://github.com/myvobot/dock-mini-apps/issues 8 | repository: https://github.com/myvobot/dock-mini-apps/tree/main/countdown 9 | licenses: MIT 10 | 11 | system_requirements: 12 | minimum_version: 1.1.0 13 | compatible_devices: 14 | - MiniDock 15 | 16 | files: 17 | include: 18 | - __init__.py -------------------------------------------------------------------------------- /widgets_demo/manifest.yml: -------------------------------------------------------------------------------- 1 | application: 2 | name: Widgets Demo 3 | version: 1.0.0 4 | description: This is a sample application that showcases some LVGL widgets, designed to help you flexibly apply these elements! 5 | author: Vobot 6 | author_email: support@getvobot.com 7 | bugs: https://github.com/myvobot/dock-mini-apps/issues 8 | repository: https://github.com/myvobot/dock-mini-apps/tree/main/widgets_demo 9 | licenses: MIT 10 | 11 | attributes: 12 | enable_auto_carousel: true 13 | 14 | system_requirements: 15 | minimum_version: 1.1.0 16 | compatible_devices: 17 | - MiniDock 18 | 19 | files: 20 | include: 21 | - __init__.py 22 | -------------------------------------------------------------------------------- /hello_world/manifest.yml: -------------------------------------------------------------------------------- 1 | application: 2 | name: Hello World 3 | version: 1.0.0 4 | description: This is a straightforward application that renders simple text on the screen and demonstrates the basic method of creating interactive applications using the LVGL graphics library. 5 | author: Vobot 6 | author_email: support@getvobot.com 7 | bugs: https://github.com/myvobot/dock-mini-apps/issues 8 | repository: https://github.com/myvobot/dock-mini-apps/tree/main/hello_world 9 | licenses: MIT 10 | 11 | system_requirements: 12 | minimum_version: 1.1.0 13 | compatible_devices: 14 | - MiniDock 15 | 16 | files: 17 | include: 18 | - __init__.py 19 | -------------------------------------------------------------------------------- /calendar_view/manifest.yml: -------------------------------------------------------------------------------- 1 | application: 2 | name: Calendar View 3 | version: 1.0.0 4 | description: This is an application that allows users to view the calendar for the past and future five years by rotating an encoder. 5 | author: Vobot 6 | author_email: support@getvobot.com 7 | bugs: https://github.com/myvobot/dock-mini-apps/issues 8 | repository: https://github.com/myvobot/dock-mini-apps/tree/main/calendar_view 9 | licenses: MIT 10 | 11 | attributes: 12 | enable_auto_carousel: true 13 | 14 | system_requirements: 15 | minimum_version: 1.1.0 16 | compatible_devices: 17 | - MiniDock 18 | 19 | files: 20 | include: 21 | - __init__.py 22 | -------------------------------------------------------------------------------- /stock_view/manifest.yml: -------------------------------------------------------------------------------- 1 | application: 2 | name: Stock View 3 | version: 1.0.0 4 | description: This is an application that allows users to view different stock information by modifying configuration settings. 5 | author: Vobot 6 | author_email: support@getvobot.com 7 | bugs: https://github.com/myvobot/dock-mini-apps/issues 8 | repository: https://github.com/myvobot/dock-mini-apps/tree/main/stock_view 9 | licenses: MIT 10 | 11 | attributes: 12 | enable_auto_carousel: true 13 | 14 | system_requirements: 15 | minimum_version: 1.1.0 16 | compatible_devices: 17 | - MiniDock 18 | 19 | files: 20 | include: 21 | - __init__.py 22 | - service.py 23 | -------------------------------------------------------------------------------- /pomodoro/manifest.yml: -------------------------------------------------------------------------------- 1 | application: 2 | name: Pomodoro Timer 3 | version: 1.0.0 4 | description: This is a Pomodoro Timer app that helps users manage their time efficiently through clear modes and flexible settings. 5 | author: Vobot 6 | author_email: support@getvobot.com 7 | bugs: https://github.com/myvobot/dock-mini-apps/issues 8 | repository: https://github.com/myvobot/dock-mini-apps/tree/main/pomodoro 9 | licenses: MIT 10 | 11 | attributes: 12 | enable_auto_carousel: false 13 | 14 | system_requirements: 15 | minimum_version: 1.1.0 16 | compatible_devices: 17 | - MiniDock 18 | 19 | files: 20 | include: 21 | - __init__.py 22 | - base.py 23 | -------------------------------------------------------------------------------- /hello_world/__init__.py: -------------------------------------------------------------------------------- 1 | import lvgl as lv 2 | 3 | # Name of the App 4 | NAME = "Hello World" 5 | 6 | CAN_BE_AUTO_SWITCHED = True 7 | 8 | # Initialize LVGL objects 9 | scr = lv.obj() 10 | label = None 11 | counter = 0 12 | 13 | async def on_running_foreground(): 14 | """Called when the app is active, approximately every 200ms.""" 15 | global counter 16 | counter += 1 17 | # Update the text of the label widget. 18 | label.set_text(f'{NAME} {counter}') 19 | 20 | async def on_stop(): 21 | scr.clean() 22 | 23 | async def on_start(): 24 | global label 25 | # Create and initialize LVGL widgets 26 | label = lv.label(scr) 27 | label.center() 28 | label.set_text(NAME) 29 | lv.scr_load(scr) 30 | -------------------------------------------------------------------------------- /sensor_app/demo/README.md: -------------------------------------------------------------------------------- 1 | # Ble Advertiser 2 | 3 | ## Introduction 4 | 5 | `ble_advertiser.py` is a Python program used to simulate a Bluetooth sensor and broadcast on a computer. 6 | This program can simulate sensor data and broadcast it periodically. 7 | 8 | **Note: `ble_advertiser.py` only supports Linux systems. Do not run it on Windows or macOS.** 9 | 10 | ## Dependencies 11 | 12 | 1. This program requires `bleson` to be installed. You can install the dependency with the following command: 13 | - pip install bleson 14 | 15 | 2. Before running the example program, please make sure your computer supports Bluetooth advertising. 16 | 17 | ## Run the Program 18 | 19 | ```bash 20 | sudo python ble_advertiser.py 21 | ``` 22 | -------------------------------------------------------------------------------- /stock_view/README.md: -------------------------------------------------------------------------------- 1 | # Stock View 2 | 3 | This application displays basic information for multiple stocks with the following features: 4 | 5 | - Stock Display Configuration: Users can configure settings to choose which stocks to display. 6 | - Data Retrieval: Users must provide their own server API for stock data. 7 | - Default Simulated Data: By default, the application simulates stock information for testing and display purposes. 8 | - API Configuration: To set the server API, modify the `_STOCK_API_URL` variable in `service.py`. 9 | - Disable Simulated Data: To stop using simulated stock information, change the `_USE_SIMULATED_DATA` variable in the same `service.py` file. 10 | 11 | These features allow users to easily manage and display the stock information they need. 12 | 13 | ![Screenshot](screenshot.jpg) 14 | 15 | Discover more on the 16 | [Mini Dock Developer Website](https://dock.myvobot.com/developer/). 17 | -------------------------------------------------------------------------------- /sensor_app/__init__.py: -------------------------------------------------------------------------------- 1 | from . import base 2 | from . import routes 3 | 4 | NAME = "Sensor App" 5 | CAN_BE_AUTO_SWITCHED = True # Indicates if the app can be auto-switched 6 | 7 | def get_settings_json(): 8 | return { 9 | "form": [{ 10 | "type": "customPage", 11 | "html_file": "asset/html/home.html", 12 | "routes": routes.get_routes(), 13 | }] 14 | } 15 | 16 | async def on_start(): 17 | """Initialize the screen and load the UI when the app starts.""" 18 | await base.on_start() 19 | 20 | async def on_stop(): 21 | """Clean up the screen and leave the app when it stops.""" 22 | await base.on_stop() 23 | 24 | async def on_boot(apm): 25 | """Initialize the app manager on boot.""" 26 | routes.init(apm) 27 | await base.on_boot(apm) 28 | 29 | async def on_running_foreground(): 30 | """ 31 | Handle actions when the app is running in the foreground. 32 | """ 33 | await base.on_running_foreground() 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 VOBOT 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /webcam/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Tobias Schulz-Hess 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /sensor_app/product/virtual_sensor/__init__.py: -------------------------------------------------------------------------------- 1 | from .ui_home import show_card 2 | from .config import getProfile, get_sensor_models 3 | from .ui_details import show_details, update_details 4 | from .ui_history import reset_history_info, refresh_history, show_history 5 | from .data_storage import get_live_info, get_record_info, load_sensor_history_data, remove_live_info, clear_cache 6 | from .ble_broadcast import on_ble_broadcast, set_active_state_callback, sync_selected_device, get_sensor_found 7 | 8 | _PRODUCT_NAME = "Virtual Sensor" # Product name constant 9 | _SENSOR_GAP_NAME = "SENSOR" # GAP name for sensor 10 | 11 | def get_product_name(): 12 | # Return the product name 13 | return _PRODUCT_NAME 14 | 15 | def get_gap_name_callbacks(): 16 | # Return a dictionary mapping GAP names to their broadcast handlers 17 | return {_SENSOR_GAP_NAME: on_ble_broadcast} 18 | 19 | def get_profile(model): 20 | # Return the profile for a given model 21 | return getProfile(model) 22 | 23 | def get_sensor_data(sensor_id): 24 | # Return the live information for a given sensor ID 25 | return get_live_info(sensor_id) 26 | 27 | def delete_sensor_data(sensor_id): 28 | # Clear the cache for a given sensor ID 29 | remove_live_info(sensor_id) 30 | clear_cache(sensor_id) 31 | -------------------------------------------------------------------------------- /webcam/README.md: -------------------------------------------------------------------------------- 1 | # minidock_webcam 2 | Webcam app for the Vobot MiniDock 3 | 4 | This is a sleek webcam application powered by the LVGL graphics library. 5 | You can configure up to 5 webcams in the vobot's application settings screen. 6 | This app provides a distraction-free interface for users to browse through 7 | user's configured webcams. With an encoder knob, users can effortlessly 8 | switch between different webcams, allowing them to 9 | watch different places of the earth in real time with ease. 10 | 11 | Due to limited processing power, expect around 1 image per second. 12 | This obviously depends largely on the speed of your webcam and your network bandwidth. 13 | 14 | Please note, that images cannot be scaled due to limited processing power. The URLs you provide must 15 | present a JPEG image (not MJPEG or any other format) in 320x240 pixels resolution. 16 | 17 | # Installation 18 | 19 | 1. Create a new directory `webcam` inside the `apps` directory of your Minidock. 20 | 2. Copy all files and directories into this `webcam` directory. 21 | 3. Restart your Vobot Minidock. 22 | 23 | For more details, please read [https://github.com/myvobot/dock-mini-apps/blob/main/README.md](https://github.com/myvobot/dock-mini-apps/blob/main/README.md) 24 | 25 | # Development 26 | 27 | Install `black` and `pre-commit` for the pre-commit-hooks. 28 | 29 | On Mac you can use: 30 | ```bash 31 | brew install black pre-commit 32 | ``` 33 | 34 | On other system, please use: 35 | ```bash 36 | pip install black pre-commit 37 | ``` 38 | 39 | -------------------------------------------------------------------------------- /pomodoro/README.md: -------------------------------------------------------------------------------- 1 | # Pomodoro Timer 2 | 3 | The Pomodoro Timer app is designed to help users improve work efficiency by combining focused work sessions with short breaks, thereby promoting better time management. The app offers three modes: Focus Mode, Short Break Mode, and Long Break Mode. 4 | 5 | ### Mode Descriptions 6 | 7 | 1. Focus Mode (MODE_FOCUS) 8 | - Default Duration: 25 minutes 9 | - This mode is used for concentrating on tasks and completing work. 10 | - When switching to this mode, the screen and buzzer will provide a notification. 11 | 12 | 2. Short Break Mode (MODE_SHORT_BREAK) 13 | - Default Duration: 5 minutes 14 | - This mode is designed for short breaks to help users relieve fatigue. 15 | - When switching to this mode, the screen and buzzer will provide a notification. 16 | 17 | 3. Long Break Mode (MODE_LONG_BREAK) 18 | - Default Duration: 30 minutes 19 | - This mode is intended for longer breaks, suitable for use after completing multiple focus sessions. 20 | - When switching to this mode, the screen and buzzer will provide a notification. 21 | 22 | ### Configuration Options 23 | 24 | Users can customize the duration of each mode in the app's settings to fit their work habits and preferences. 25 | 26 | ### Re-Entry Prompt 27 | 28 | If users exit the app while a countdown is still running, they will receive a prompt upon re-entering the app, asking whether they would like to continue the previous countdown. 29 | 30 | ![Screenshot](screenshot.jpg) 31 | 32 | Discover more on the 33 | [Mini Dock Developer Website](https://dock.myvobot.com/developer/). 34 | -------------------------------------------------------------------------------- /stock_view/service.py: -------------------------------------------------------------------------------- 1 | import random 2 | import arequests as request 3 | 4 | _STOCK_API_URL = "" # Server URL, needs to be configured 5 | _USE_SIMULATED_DATA = True # Whether to use simulated stock information 6 | 7 | def generate_mock_stock_info(symbols): 8 | # Generate simulated stock information 9 | res = {"stocks": []} 10 | for symbol in symbols: 11 | item = { 12 | 'previousClose': random.randint(400, 450), 13 | 'currentPrice': random.randint(400, 450), 14 | 'currency': 'USD' 15 | } 16 | item["symbol"], item["shortName"] = symbol.split(":") 17 | res["stocks"].append(item) 18 | return res 19 | 20 | async def fetch_stock_info(symbols="MSFT"): 21 | # Fetch stock information 22 | url = f"{_STOCK_API_URL}?symbols={symbols}" 23 | 24 | try: 25 | response = await request.request("GET", url) 26 | if response.status_code == 200: return await response.json() 27 | except Exception as e: 28 | pass 29 | 30 | return {} 31 | 32 | async def get_stock_details(symbols): 33 | # Fetch stock details information 34 | details = [] 35 | if not symbols: return details 36 | 37 | if _USE_SIMULATED_DATA: 38 | res = generate_mock_stock_info(symbols) 39 | else: 40 | res = await fetch_stock_info(",".join(symbols)) 41 | 42 | for index, item in enumerate(res.get("stocks", [])): 43 | # No symbol field in data? Get from symbols 44 | if item.get("symbol", None) is None: 45 | item["symbol"], item["shortName"] = symbols[index].split(":") 46 | details.append(item) 47 | 48 | return details 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mini Dock App Marketplace 2 | 3 | Welcome to the official Git repository for the Mini Dock App Marketplace! This repository serves as a convenient hub for developers and users to temporarily store, share, and discover apps for Mini Dock. 4 | 5 | ## Getting Started 6 | 7 | To start using the Mini Dock App Marketplace, please follow the steps below: 8 | 9 | ### Prerequisites 10 | 11 | Make sure you have installed [Thonny IDE](https://thonny.org/) and connected to your Mini Dock device via a data cable, which is necessary to deploy apps to your Mini Dock. 12 | 13 | ### Installation 14 | 15 | 1. Clone or download the repository to your local machine. 16 | 2. Connect your Mini Dock device and run Thonny. 17 | 3. Place the downloaded app files into the Mini Dock apps directory. 18 | 4. Run the app through Thonny IDE. 19 | > For more details, refer to [Mini Dock Getting Started](https://dock.myvobot.com/developer/) 20 | 21 | 22 | ## App Directory 23 | 24 | In this repository, you'll find a variety of apps for Mini Dock. Each app has its own folder containing all the files needed to run the app. 25 | 26 | ## Contributing 27 | 28 | We welcome and encourage community members to contribute new apps or improvements to existing ones. If you wish to contribute, please follow the [Mini Dock Publishing Guidelines](https://dock.myvobot.com/developer/) 29 | 30 | ## Support 31 | 32 | If you encounter any issues while using the Mini Dock App Marketplace, or if you have any questions, please visit the [Mini Dock FAQ](https://dock.myvobot.com/faq/general/). 33 | 34 | ## License 35 | 36 | This project is licensed under the MIT License - see the LICENSE file for details. 37 | 38 | ## More Information 39 | 40 | - [Mini Dock Developer Documentation](https://dock.myvobot.com/developer/) 41 | 42 | Thank you for choosing the Mini Dock App Marketplace, and happy developing! -------------------------------------------------------------------------------- /sensor_app/product/virtual_sensor/config.py: -------------------------------------------------------------------------------- 1 | # Virtual sensor models 2 | _WINDOW_MODEL = 0x00 # Windows virtual sensor model code 3 | _LINUX_MODEL = 0x01 # Linux virtual sensor model code 4 | _MAC_MODEL = 0x02 # Mac virtual sensor model code 5 | 6 | # Maximum number of historical measurements allowed to be stored 7 | _MAX_HISTORY_MEASUREMENTS = 50 # Maximum history records 8 | 9 | # Tuple of all model codes 10 | _MODEL_CODE = (_WINDOW_MODEL, _LINUX_MODEL, _MAC_MODEL) 11 | 12 | _PROFILE = { 13 | _WINDOW_MODEL: { 14 | "model": "Window Sensor", 15 | "name": "Virtual Temperature Sensor", 16 | "attr": {"display": {"attachInfo": ["temperature"]}} 17 | }, 18 | _LINUX_MODEL: { 19 | "model": "Linux Sensor", 20 | "name": "Virtual Temperature Sensor", 21 | "attr": {"display": {"attachInfo": ["temperature"]}} 22 | }, 23 | _MAC_MODEL: { 24 | "model": "Mac Sensor", 25 | "name": "Virtual Temperature Sensor", 26 | "attr": {"display": {"attachInfo": ["temperature"]}} 27 | } 28 | } 29 | 30 | def createFolder(p): 31 | # Create a folder if it does not exist 32 | try: 33 | import os 34 | os.mkdir(p) 35 | except OSError: 36 | pass 37 | 38 | def remove(fileOrFolder): 39 | # Remove a file or folder, return True if successful, otherwise False 40 | try: 41 | import os 42 | os.remove(fileOrFolder) 43 | return True 44 | except: 45 | return False 46 | 47 | def celsius2Fahrenheit(c): 48 | # Convert Celsius to Fahrenheit 49 | return c * 9 / 5 + 32 50 | 51 | def getProfile(model): 52 | # Get the profile configuration for a given model code 53 | return _PROFILE.get(model, {}) 54 | 55 | def get_sensor_models(): 56 | # Get a list of all available sensor models and their codes 57 | result = [] 58 | for model_code in _MODEL_CODE: 59 | model_name = _PROFILE.get(model_code, {}).get("model", None) 60 | if model_name is None: continue 61 | result.append({model_name: model_code}) 62 | return result 63 | -------------------------------------------------------------------------------- /photo_album/__init__.py: -------------------------------------------------------------------------------- 1 | import lvgl as lv 2 | 3 | # App Name 4 | NAME = "Photo Album" 5 | 6 | CAN_BE_AUTO_SWITCHED = True 7 | 8 | # App Icon 9 | ICON = "A:apps/photo_album/resources/icon.png" 10 | 11 | # LVGL widgets 12 | scr = None 13 | 14 | # Image paths 15 | PHOTO_PATHS = [ 16 | "A:apps/photo_album/resources/1.jpg", 17 | "A:apps/photo_album/resources/2.jpg", 18 | "A:apps/photo_album/resources/3.jpg", 19 | "A:apps/photo_album/resources/4.jpg" 20 | ] 21 | 22 | # Constants 23 | PHOTO_COUNT = len(PHOTO_PATHS) # Number of photos 24 | DEFAULT_BG_COLOR = lv.color_hex3(0x000) 25 | 26 | # Current image index 27 | photo_index = 0 28 | 29 | def load_photo(index): 30 | global scr 31 | if scr: 32 | scr.set_style_bg_img_src(PHOTO_PATHS[index], lv.PART.MAIN) 33 | 34 | def change_photo(delta): 35 | global photo_index 36 | photo_index = (photo_index + delta) % PHOTO_COUNT 37 | load_photo(photo_index) 38 | 39 | def event_handler(event): 40 | e_code = event.get_code() 41 | if e_code == lv.EVENT.KEY: 42 | e_key = event.get_key() 43 | if e_key == lv.KEY.RIGHT: 44 | change_photo(1) 45 | elif e_key == lv.KEY.LEFT: 46 | change_photo(-1) 47 | elif e_code == lv.EVENT.FOCUSED: 48 | # If not in edit mode, set to edit mode. 49 | if not lv.group_get_default().get_editing(): 50 | lv.group_get_default().set_editing(True) 51 | 52 | async def on_stop(): 53 | print('on stop') 54 | global scr 55 | if scr: 56 | scr.clean() 57 | scr.del_async() 58 | scr = None 59 | 60 | async def on_start(): 61 | print('on start') 62 | global scr 63 | scr = lv.obj() 64 | lv.scr_load(scr) 65 | 66 | scr.set_style_bg_color(DEFAULT_BG_COLOR, lv.PART.MAIN) 67 | load_photo(photo_index) # Load the initial photo 68 | 69 | scr.add_event(event_handler, lv.EVENT.ALL, None) 70 | 71 | # Focus the key operation on the current screen and enable editing mode. 72 | lv.group_get_default().add_obj(scr) 73 | lv.group_focus_obj(scr) 74 | lv.group_get_default().set_editing(True) 75 | -------------------------------------------------------------------------------- /countdown/__init__.py: -------------------------------------------------------------------------------- 1 | import time 2 | import lvgl as lv 3 | 4 | # App Name 5 | NAME = "Countdown of App" 6 | 7 | # LVGL widgets 8 | scr = None 9 | label = None 10 | 11 | # Countdown parameters 12 | DEFAULT_REMAINDER = 30 13 | remainder = DEFAULT_REMAINDER 14 | 15 | app_mgr = None 16 | last_recorded_time = 0 17 | countdown_is_running = False 18 | 19 | def get_settings_json(): 20 | return { 21 | "form": [{ 22 | "type": "input", 23 | "default": str(DEFAULT_REMAINDER), 24 | "caption": "Countdown Timer Duration(s)", 25 | "name": "remainder", 26 | "attributes": {"maxLength": 6, "placeholder": "e.g., 300"}, 27 | }] 28 | } 29 | 30 | def reset_countdown(): 31 | global remainder, countdown_is_running, last_recorded_time 32 | last_recorded_time = 0 33 | countdown_is_running = False 34 | remainder = int(app_mgr.config().get("remainder", DEFAULT_REMAINDER)) 35 | 36 | def update_label(): 37 | global label, remainder 38 | if label: 39 | label.set_text("{:02d}:{:02d}".format(remainder // 60, remainder % 60)) 40 | 41 | def event_handler(event): 42 | e_code = event.get_code() 43 | if e_code == lv.EVENT.KEY: 44 | e_key = event.get_key() 45 | if e_key == lv.KEY.ENTER: 46 | global countdown_is_running, last_recorded_time 47 | last_recorded_time = time.ticks_ms() 48 | countdown_is_running = not countdown_is_running 49 | 50 | async def on_boot(apm): 51 | global app_mgr 52 | app_mgr = apm 53 | 54 | async def on_stop(): 55 | print('on stop') 56 | global scr 57 | if scr: 58 | scr.clean() 59 | scr.del_async() 60 | scr = None 61 | reset_countdown() 62 | 63 | async def on_start(): 64 | print('on start') 65 | global scr, label 66 | reset_countdown() 67 | 68 | scr = lv.obj() 69 | lv.scr_load(scr) 70 | 71 | label = lv.label(scr) 72 | update_label() 73 | label.center() 74 | 75 | scr.add_event(event_handler, lv.EVENT.ALL, None) 76 | 77 | group = lv.group_get_default() 78 | if group: 79 | group.add_obj(scr) 80 | lv.group_focus_obj(scr) 81 | group.set_editing(True) 82 | 83 | async def on_running_foreground(): 84 | """Called when the app is active, approximately every 200ms.""" 85 | global remainder, last_recorded_time 86 | if not countdown_is_running or remainder <= 0: 87 | return 88 | 89 | current_time = time.ticks_ms() 90 | elapsed_time = time.ticks_diff(current_time, last_recorded_time) // 1000 91 | if elapsed_time > 0: 92 | remainder -= elapsed_time 93 | last_recorded_time = current_time 94 | remainder = max(remainder, 0) 95 | update_label() 96 | 97 | -------------------------------------------------------------------------------- /sensor_app/product/virtual_sensor/ui_style.py: -------------------------------------------------------------------------------- 1 | import lvgl as lv 2 | 3 | SINGLE_CARD = 1 4 | DUAL_CARD = 2 5 | QUAD_CARD = 4 6 | 7 | # Card common style, distinguished by card type 8 | CARD_STYLE = { 9 | SINGLE_CARD: { 10 | "sensor_name": { # Sensor name style 11 | "width": 180, 12 | "font": lv.font_ascii_bold_28 13 | }, 14 | "partition": True, # Whether to show the divider line 15 | "icon": { 16 | "type": ["battery", "signal"], # Types of icons to display 17 | "cover": False, # Whether to cover the previous icon 18 | }, 19 | "elapsed_time": True, # Whether to show elapsed time and measurement difference since last report 20 | "data": { # Measurement data container style 21 | "size": (320, 165), 22 | "align": (lv.ALIGN.TOP_LEFT, 0, 33) 23 | }, 24 | }, 25 | DUAL_CARD: { 26 | "sensor_name": { 27 | "width": 180, 28 | "font": lv.font_ascii_22 29 | }, 30 | "partition": False, 31 | "icon": { 32 | "type": ["battery", "signal"], 33 | "cover": False 34 | }, 35 | "elapsed_time": True, 36 | "data": { 37 | "size": (320, 74), 38 | "align": (lv.ALIGN.TOP_LEFT, 0, 25), 39 | }, 40 | }, 41 | QUAD_CARD: { 42 | "sensor_name": { 43 | "width": 135, 44 | "font": lv.font_ascii_22 45 | }, 46 | "partition": False, 47 | "icon": { 48 | "type": [], 49 | "cover": True 50 | }, 51 | "elapsed_time": False, 52 | "data": { 53 | "size": (159, 95), 54 | "align": (lv.ALIGN.TOP_LEFT, 0, 25), 55 | }, 56 | } 57 | } 58 | 59 | DATA_STYLE = { 60 | SINGLE_CARD: { 61 | (("temperature",),): { 62 | "value_font": [lv.font_numbers_92], # Font for value 63 | "symbol_font": [lv.font_ascii_22], # Font for symbol 64 | "value_align": [(lv.ALIGN.CENTER, 0, 0)], # Alignment for value 65 | "symbol_align": [(lv.ALIGN.OUT_RIGHT_BOTTOM, 0, 5)], # Alignment for symbol 66 | "placement_mode": [0], # Layout mode for symbol and value, 0: symbol follows value; 1: value follows symbol [valid when symbol exists] 67 | }, 68 | }, 69 | DUAL_CARD: { 70 | (("temperature",),): { 71 | "value_font": [lv.font_numbers_72], 72 | "symbol_font": [lv.font_ascii_22], 73 | "value_align": [(lv.ALIGN.CENTER, 0, 5)], 74 | "symbol_align": [(lv.ALIGN.OUT_RIGHT_BOTTOM, 0, -10)], 75 | "placement_mode": [0], 76 | }, 77 | }, 78 | QUAD_CARD: { 79 | (("temperature",),): { 80 | "value_font": [lv.font_ascii_bold_48], 81 | "symbol_font": [lv.font_ascii_22], 82 | "value_align": [(lv.ALIGN.CENTER, -10, 0)], 83 | "symbol_align": [(lv.ALIGN.OUT_RIGHT_BOTTOM, 0, 5)], 84 | "placement_mode": [0], 85 | }, 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /sensor_app/README.md: -------------------------------------------------------------------------------- 1 | # Sensor App 2 | 3 | ## Introduction 4 | 5 | Sensor App is an application for viewing measurement information from specified product Bluetooth sensors. Users can view the current measurement data of the sensor in real time, and some sensors also support querying historical measurement data. In addition, the app provides detailed information display of Bluetooth sensors, making it convenient for users to fully understand and manage sensor devices. 6 | 7 | ## Main Features 8 | 9 | - **Real-time Measurement Information** 10 | Supports viewing the current measurement data of Bluetooth sensors. 11 | 12 | - **Historical Data Query** 13 | Some sensors support viewing recent measurement history records. 14 | 15 | - **Sensor Details** 16 | View detailed information of Bluetooth sensors, including device parameters, etc. 17 | 18 | ## Directory Structure 19 | 20 | ```bash 21 | sensor_app/ 22 | ├── asset/ # For storing resource files, such as .html files 23 | ├── bluetooth/ # Bluetooth scanning module 24 | ├── demo/ # Sensor simulation programs 25 | ├── product/ # Modules for different products 26 | ├── ui_page/ # UI page generation and switching module 27 | ├── `__init__.py` # App entry point 28 | ├── `base.py` # Main process handling module 29 | ├── `routes.py` # Route configuration and corresponding functions 30 | ``` 31 | 32 | ## product Directory Description 33 | 34 | The `product` directory contains modules for different products. Each product module should implement the logic for data processing and display. 35 | The `product/virtual_sensor` directory contains modules for virtual sensors, used to simulate sensor measurement data. The corresponding virtual sensor example program is `demo/ble_advertiser.py`. 36 | 37 | ## How to Add a New Product Module 38 | 39 | 1. Add a new product module under the `product` directory, such as `product/new_product`. 40 | 2. The new module should implement the following functions: 41 | - `get_product_name`: Returns the product name 42 | - `get_gap_name_callbacks`: Returns the GAP name corresponding to the product and the corresponding broadcast processing function 43 | - `get_profile`: Returns the profile corresponding to the product 44 | - `get_sensor_data`: Returns the real-time data of the corresponding sensor 45 | - `delete_sensor_data`: Deletes the sensor information and data corresponding to the product 46 | - `sync_selected_device`: Synchronizes the currently selected sensor 47 | - `get_sensor_found`: Returns the currently discovered sensors 48 | - `load_sensor_history_data`: Loads the sensor's historical data 49 | - `get_record_info`: Returns the sensor's historical data 50 | - `clear_cache`: Clears the sensor's historical data 51 | - `get_sensor_models`: Returns sensor models 52 | - `show_details`: Displays sensor details 53 | - `update_details`: Updates sensor details 54 | - `show_history`: Displays sensor historical data 55 | - `refresh_history`: Refreshes sensor historical data 56 | - `show_card`: Displays sensor measurement data information 57 | - `set_active_state_callback`: Sets the sensor activation state callback 58 | 3. Register the product module in `product/__init__.py` 59 | 60 | ## Screenshot 61 | 62 | ![Screenshot](screenshot.jpg) 63 | 64 | ## Related Resources 65 | 66 | Discover more on the 67 | [Mini Dock Developer Website](https://dock.myvobot.com/developer/). 68 | -------------------------------------------------------------------------------- /sensor_app/bluetooth/__init__.py: -------------------------------------------------------------------------------- 1 | # Module: BLE scan utility functions 2 | 3 | import aioble 4 | import asyncio 5 | 6 | # Flag to indicate whether scanning is enabled 7 | _scan_enable = False 8 | 9 | # Flag to indicate whether the scan task has finished 10 | _is_scan_finished = True 11 | 12 | # Mapping from GAP name to its callback: { gap_name: callback_func } 13 | _gap_name_callbacks = {} 14 | 15 | async def scan_ble_devices(duration_ms=60000, 16 | interval_ms=30, 17 | window_ms=30, 18 | active=True, 19 | filter_dup=True, 20 | loop_count=-1): 21 | """ 22 | Perform BLE scan with the given parameters. 23 | Invoke registered callbacks when a matching device is found. 24 | """ 25 | global _is_scan_finished 26 | # If a previous scan is still running, skip starting a new one 27 | if not _is_scan_finished: return 28 | _is_scan_finished = False 29 | 30 | # Continue scanning until loop_count expires or scanning is disabled 31 | while loop_count < 0 or loop_count > 0: 32 | try: 33 | # Enter scanning context manager 34 | async with aioble.scan( 35 | duration_ms=duration_ms, 36 | interval_us=interval_ms * 1000, 37 | window_us=window_ms * 1000, 38 | active=active, filter_dup=filter_dup) as scanner: 39 | 40 | # Iterate over scan results 41 | async for result in scanner: 42 | # If user requested to stop, cancel this scanner 43 | if not _scan_enable: 44 | await scanner.cancel() 45 | break 46 | 47 | name = result.name() 48 | # Only process results that have a registered callback 49 | if name not in list(_gap_name_callbacks.keys()): continue 50 | # Invoke the callback with (address, rssi, adv_data) 51 | _gap_name_callbacks[name](result.device.addr, result.rssi, result.adv_data) 52 | 53 | # If loop_count is positive, decrement it 54 | if loop_count > 0: loop_count -= 1 55 | # If scanning is disabled, break out of the loop 56 | elif not _scan_enable: break 57 | except Exception as e: 58 | # Log exception and continue retrying 59 | print(f"Error in BLE scan task: {str(e)}") 60 | 61 | # Brief pause before the next scan iteration 62 | await asyncio.sleep(1) 63 | 64 | # Mark scan task as finished 65 | _is_scan_finished = True 66 | 67 | async def start_scan(duration_ms=60000, loop_count=-1): 68 | """ 69 | Enable scanning and launch the BLE scan task. 70 | """ 71 | global _scan_enable 72 | _scan_enable = True 73 | 74 | # Only start a new task if previous scan has finished 75 | if _is_scan_finished: asyncio.create_task(scan_ble_devices(duration_ms=duration_ms, loop_count=loop_count)) 76 | 77 | # Wait until the scan task actually begins 78 | while _is_scan_finished: await asyncio.sleep_ms(100) 79 | 80 | async def stop_scan(): 81 | """ 82 | Disable scanning and wait for the current scan task to complete. 83 | """ 84 | global _scan_enable 85 | _scan_enable = False 86 | 87 | await wait_scan_complete() 88 | 89 | async def wait_scan_complete(): 90 | """ 91 | Busy-wait until the scan task signals that it has finished. 92 | """ 93 | while not _is_scan_finished: await asyncio.sleep_ms(100) 94 | 95 | def set_gap_name_callbacks(gap_name_callbacks): 96 | """ 97 | Replace current GAP name → callback mapping with the provided one. 98 | """ 99 | global _gap_name_callbacks 100 | _gap_name_callbacks = gap_name_callbacks 101 | -------------------------------------------------------------------------------- /sensor_app/product/virtual_sensor/ble_broadcast.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import asyncio 3 | import binascii 4 | import clocktime 5 | from . import config 6 | from . import data_storage 7 | 8 | _ADV_TYPE_CUSTOMDATA = 0xff # Custom data type for BLE advertisement 9 | 10 | _selected_sensors = [] # List of selected sensor IDs 11 | _discovered_sensors = {} # Dictionary to store discovered sensors 12 | _display_active_effect_cb = None # Callback for displaying active effect 13 | 14 | def sync_selected_device(devs): 15 | # Synchronize the selected sensors 16 | global _selected_sensors 17 | _selected_sensors = devs 18 | 19 | def set_active_state_callback(cb): 20 | # Set the callback function for displaying the active effect 21 | global _display_active_effect_cb 22 | _display_active_effect_cb = cb 23 | 24 | def get_sensor_found(dev_model): 25 | # Get the discovered sensor information for a specific model 26 | if dev_model not in _discovered_sensors: return [] 27 | return _discovered_sensors[dev_model] 28 | 29 | def decode_all_fields(payload): 30 | # Decode all fields in the BLE advertisement payload 31 | result = {} 32 | while payload: 33 | # Format: [data length{1(data type) + data length}, data type, data] 34 | if len(payload) < 2: break 35 | adv_len = payload[0] 36 | if adv_len != 0: 37 | adv_type = payload[1] 38 | adv_data = payload[2:adv_len + 1] 39 | 40 | if adv_type in result: result[adv_type].append(adv_data) 41 | else: result[adv_type] = [adv_data, ] 42 | 43 | payload = payload[adv_len + 1:] 44 | return result 45 | 46 | def on_ble_broadcast(addr, rssi, adv_data): 47 | # Parse the BLE broadcast data 48 | adv_fields = decode_all_fields(adv_data) 49 | 50 | # Check if custom data exists in the advertisement 51 | custom_data = adv_fields.get(_ADV_TYPE_CUSTOMDATA, []) 52 | if len(custom_data) < 1: return 53 | custom_data = custom_data[0] 54 | if not custom_data: return 55 | 56 | # Parse the custom data fields 57 | model = custom_data[0] # Sensor model code 58 | measure_id = custom_data[1] # Measurement ID 59 | timestamp = clocktime.now() # Current timestamp 60 | sensor_id = f"00{binascii.hexlify(addr).decode().upper()}00" # Generate sensor ID from address 61 | 62 | # Check if the model is valid and timestamp is valid 63 | if model not in config._MODEL_CODE or timestamp < 0: return 64 | 65 | # Update the discovered sensor information 66 | if model not in _discovered_sensors: 67 | _discovered_sensors[model] = {sensor_id:{"rssi": rssi}} 68 | else: 69 | _discovered_sensors[model][sensor_id] = {"rssi": rssi} 70 | 71 | # Check if the sensor is in the selected sensor list 72 | if sensor_id not in _selected_sensors: return 73 | 74 | # Parse the remaining fields from custom data 75 | info = { 76 | "rssi": rssi, 77 | "dev_model": model, 78 | "timestamp": timestamp, 79 | "measure_id": measure_id, 80 | "btn_state": custom_data[4], # Button state 81 | "probe_state": custom_data[5], # Probe state 82 | "battery_percentage": custom_data[6], # Battery percentage 83 | } 84 | live_info = data_storage.get_live_info(sensor_id) 85 | info["temperature"] = struct.unpack("h", custom_data[2:4])[0] # Temperature value 86 | 87 | # Check if the measurement ID has changed 88 | if measure_id != live_info.get("measure_id", -1): 89 | # Update the history data if measurement ID changed 90 | data_storage.set_sensor_history_data(sensor_id, info) 91 | 92 | # Check if the active effect needs to be displayed 93 | if info.get("btn_state", 0) == 1 and \ 94 | _display_active_effect_cb and \ 95 | live_info.get("btn_state", 0) == 0: 96 | asyncio.create_task(_display_active_effect_cb(sensor_id)) 97 | 98 | # Update the real-time data for the sensor 99 | data_storage.set_live_info(sensor_id, info) 100 | -------------------------------------------------------------------------------- /sensor_app/demo/ble_advertiser.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import asyncio 3 | import platform 4 | from bleson import get_provider, Advertisement, Advertiser 5 | 6 | class BLEAdvertiser: 7 | _instance = None 8 | _initialized = False 9 | 10 | def __new__(cls, *args, **kwargs): 11 | # Singleton pattern, ensure only one instance is created 12 | if cls._instance is None: 13 | cls._instance = super(BLEAdvertiser, cls).__new__(cls, *args, **kwargs) 14 | return cls._instance 15 | 16 | def __init__(self): 17 | # Avoid repeated initialization 18 | if self._initialized: return 19 | 20 | # Initialize BLE objects 21 | self.adapter = get_provider().get_adapter() 22 | self.advertiser = Advertiser(self.adapter) 23 | self.advertisement = Advertisement() 24 | self.advertiser.advertisement = self.advertisement 25 | 26 | # Initialize advertising information 27 | self.name = "SENSOR" 28 | self.measure_id = 0 29 | self.model_info = { 30 | "Windows": 0, 31 | "Linux": 1, 32 | "MAC": 2 33 | } 34 | self.model = self.model_info.get(platform.system(), 2) 35 | self.temperature = 0 36 | self.battery = 100 37 | self.btn_state = 0 38 | self.probe_state = 1 39 | self.measure_id = 0 40 | 41 | self._initialized = True 42 | 43 | def update_temperature(self, temperature): 44 | # Update temperature and auto-increment measure_id 45 | self.temperature = temperature 46 | self.measure_id += 1 47 | self.measure_id %= 256 48 | 49 | def update_battery(self, battery): 50 | # Update battery level 51 | self.battery = battery 52 | 53 | def update_btn_state(self, btn_state): 54 | # Update button state 55 | self.btn_state = btn_state 56 | 57 | def update_probe_state(self, probe_state): 58 | # Update probe state 59 | self.probe_state = probe_state 60 | 61 | def update_advertisement(self): 62 | # Construct BLE advertising data 63 | raw_data = b"\x02\x01\x06" 64 | raw_data += bytes([len(self.name) + 1]) + b"\x09" + self.name.encode() 65 | # [model: 1B, measure_id: 1B, temperature: 1h, btn_state: 1B, probe_state: 1B, battery: 1B] 66 | raw_data += b"\x08\xff" + struct.pack("2Bh3B", self.model, self.measure_id, self.temperature, self.btn_state, self.probe_state, self.battery) 67 | 68 | # Update BLE advertising data 69 | self.adapter.set_advertising_data(raw_data) 70 | 71 | def start(self): 72 | # Start BLE advertising 73 | self.advertiser.start() 74 | 75 | def stop(self): 76 | # Stop BLE advertising 77 | self.advertiser.stop() 78 | 79 | if __name__ == "__main__": 80 | async def run(): 81 | import random 82 | ble = BLEAdvertiser() 83 | ble.start() 84 | 85 | battery = 50 86 | update_time = 0 87 | temperature = random.randint(-3000, 10000) 88 | try: 89 | while True: 90 | # Update temperature, battery, and other states 91 | temperature += random.randint(-100, 100) 92 | btn_state = 1 if update_time % 50 == 0 else 0 93 | probe_state = 0 if update_time % 50 == 0 else 1 94 | battery = max(0, min(battery + random.randint(-1, 1), 100)) 95 | 96 | ble.update_battery(battery) 97 | ble.update_btn_state(btn_state) 98 | ble.update_temperature(temperature) 99 | ble.update_probe_state(probe_state) 100 | 101 | # Update BLE advertising data 102 | ble.update_advertisement() 103 | 104 | # Print current state 105 | print(f"measure_id: {ble.measure_id}, temperature: {ble.temperature}, battery: {ble.battery}, btn_state: {ble.btn_state}, probe_state: {ble.probe_state}") 106 | 107 | await asyncio.sleep(5) 108 | update_time += 1 109 | except KeyboardInterrupt: 110 | print("advertiser stop") 111 | ble.stop() 112 | 113 | asyncio.run(run()) 114 | -------------------------------------------------------------------------------- /sensor_app/ui_page/tips.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import lvgl as lv 3 | import peripherals 4 | from .. import base 5 | 6 | # Get the screen resolution from peripherals 7 | _SCR_WIDTH, _SCR_HEIGHT = peripherals.screen.screen_resolution 8 | 9 | # Global references for screen, app manager and main container 10 | _scr = None 11 | _app_mgr = None 12 | _container = None 13 | 14 | def event_handler(e): 15 | """ 16 | Handle LVGL events for the tips page. 17 | - ESC key: go back or exit the app. 18 | - FOCUSED: enable edit mode when the container is focused. 19 | """ 20 | e_code = e.get_code() 21 | if e_code == lv.EVENT.KEY: 22 | e_key = e.get_key() 23 | if e_key == lv.KEY.ESC: 24 | # If no sensor is selected, exit the app; otherwise switch to home page 25 | if not _app_mgr.config().get("selected", []): asyncio.create_task(_app_mgr.exit()) 26 | else: asyncio.create_task(base.switch_page(base._PAGE_HOME)) 27 | elif e_code == lv.EVENT.FOCUSED: 28 | lv_group = lv.group_get_default() 29 | # If focused object is not our container, ignore 30 | if lv_group.get_focused() != e.get_target_obj(): return 31 | # If the group is not in edit mode, enable edit mode 32 | if not lv_group.get_editing(): lv_group.set_editing(True) 33 | 34 | def display_tips(): 35 | """ 36 | Build and display the tips UI on the current screen. 37 | This includes a title, content message, navigation path, and a styled span note. 38 | """ 39 | global _container 40 | # If container already exists, clear its children 41 | if _container: _container.clean() 42 | 43 | # Create a full-screen container 44 | _container = lv.obj(_scr) 45 | _container.align(lv.ALIGN.TOP_LEFT, 0, 0) 46 | _container.set_size(_SCR_WIDTH, _SCR_HEIGHT) 47 | _container.set_style_radius(0, lv.PART.MAIN) 48 | _container.set_style_pad_all(0, lv.PART.MAIN) 49 | _container.remove_style(None, lv.PART.SCROLLBAR) 50 | _container.set_style_border_width(0, lv.PART.MAIN) 51 | _container.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) 52 | _container.add_event_cb(event_handler, lv.EVENT.ALL, None) 53 | _container.set_style_bg_color(lv.color_hex3(0x000), lv.PART.MAIN) 54 | 55 | # Title label at top center 56 | title = lv.label(_container) 57 | title.set_text("Tips") 58 | title.align(lv.ALIGN.TOP_MID, 0, 0) 59 | title.set_style_text_font(lv.font_ascii_bold_28, lv.PART.MAIN) 60 | title.set_style_text_color(lv.color_hex(0x007BFF), lv.PART.MAIN) 61 | 62 | # Main content message 63 | content = lv.label(_container) 64 | content.set_width(_SCR_WIDTH - 20) 65 | content.set_text("Please go to the application configuration page to select the Sensor.") 66 | content.align(lv.ALIGN.TOP_MID, 0, 40) 67 | 68 | # Path title label 69 | path_title = lv.label(_container) 70 | path_title.set_text("Path:") 71 | path_title.align_to(content, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 10) 72 | 73 | # Path detail label with highlighted color 74 | path = lv.label(_container) 75 | path.set_text("Settings App -> Application Settings") 76 | path.set_style_text_color(lv.color_hex(0x00B68A), lv.PART.MAIN) 77 | path.align_to(path_title, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 0) 78 | 79 | # Span group at bottom to show max selectable sensors note 80 | spangroup = lv.spangroup(_container) 81 | spangroup.align(lv.ALIGN.BOTTOM_MID, 0, 0) 82 | spangroup.set_size(_SCR_WIDTH - 10, _SCR_HEIGHT // 5) 83 | spangroup.set_style_text_font(lv.font_ascii_14, lv.PART.MAIN) 84 | spangroup.set_style_text_color(lv.color_hex(0x6C757D), lv.PART.MAIN) 85 | 86 | # Static text span 87 | span = spangroup.new_span() 88 | span.set_text("You can select a maximum of ") 89 | 90 | # Dynamic span for number of selectable sensors 91 | span = spangroup.new_span() 92 | span_style = span.get_style() 93 | span_style.set_text_font(lv.font_ascii_bold_18) 94 | span.set_text(f"{base._MAX_SELECTABLE} Sensors") 95 | span_style.set_text_color(lv.color_hex(0x007BFF)) 96 | span_style.set_text_decor(lv.TEXT_DECOR.UNDERLINE) 97 | 98 | # Closing text span 99 | span = spangroup.new_span() 100 | span.set_text(" at the same time.") 101 | 102 | # Add container to default group and enable editing mode 103 | lv.group_get_default().add_obj(_container) 104 | lv.group_get_default().set_editing(True) 105 | 106 | async def on_start(scr, app_mgr): 107 | """ 108 | Initialization when the tips page starts: 109 | - Store screen and app manager references. 110 | - Display the tips UI. 111 | """ 112 | global _scr, _app_mgr 113 | _scr = scr 114 | _app_mgr = app_mgr 115 | if app_mgr: app_mgr.leave_root_page() 116 | 117 | display_tips() 118 | 119 | async def on_stop(): 120 | """ 121 | Cleanup when the tips page stops: 122 | - Clear the screen and reset globals. 123 | """ 124 | global _scr, _container 125 | 126 | if _app_mgr: _app_mgr.enter_root_page() 127 | 128 | if _scr: 129 | _scr.clean() 130 | _scr = None 131 | _container = None 132 | -------------------------------------------------------------------------------- /sensor_app/ui_page/details.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import lvgl as lv 3 | import peripherals 4 | from .. import base 5 | from .. import product 6 | 7 | # Get the screen resolution from peripherals 8 | _SCR_WIDTH, _SCR_HEIGHT = peripherals.screen.screen_resolution 9 | 10 | _scr = None # main screen object 11 | _app_mgr = None # application manager instance 12 | _curr_data = {} # cached sensor data 13 | _sensor_id = None # currently selected sensor ID 14 | _container = None # container for detail page UI elements 15 | 16 | def get_sensor_info(sensor_id): 17 | """ 18 | Retrieve the configuration info for the given sensor ID. 19 | """ 20 | config = _app_mgr.config() 21 | selected_devices = config.get("selected", []) 22 | for dev in selected_devices: 23 | if dev["sensor_id"] == sensor_id: return dev 24 | return {} 25 | 26 | def event_handler(e): 27 | """ 28 | Event handler for the detail page container. 29 | - ESC: return to home page 30 | - ENTER: switch to history data page 31 | """ 32 | e_code = e.get_code() 33 | if e_code == lv.EVENT.KEY: 34 | e_key = e.get_key() 35 | if e_key == lv.KEY.ESC: 36 | # ESC: go back to home page 37 | asyncio.create_task(base.switch_page(base._PAGE_HOME)) 38 | elif e_key == lv.KEY.ENTER: 39 | # ENTER: switch to history page for current sensor 40 | asyncio.create_task(base.switch_page(base._PAGE_HISTORY, _sensor_id)) 41 | elif e_code == lv.EVENT.FOCUSED: 42 | lv_group = lv.group_get_default() 43 | # If focused object is not our container, ignore 44 | if lv_group.get_focused() != e.get_target_obj(): return 45 | # If the group is not in edit mode, enable edit mode 46 | if not lv_group.get_editing(): lv_group.set_editing(True) 47 | 48 | async def show_details(): 49 | """ 50 | Build and display the detail page UI for the current sensor. 51 | """ 52 | global _container, _curr_data 53 | if not _sensor_id: return 54 | sensor_info = get_sensor_info(_sensor_id) 55 | product_registry = product.get_product_registry() 56 | s_model = product_registry.get(sensor_info.get("product_name", None), None) 57 | if not s_model: return 58 | 59 | # Clean up existing container if any 60 | if _container: _container.clean() 61 | 62 | # Create a full‐screen container 63 | _container = lv.obj(_scr) 64 | _container.align(lv.ALIGN.TOP_LEFT, 0, 0) 65 | _container.set_size(_SCR_WIDTH, _SCR_HEIGHT) 66 | _container.set_style_radius(0, lv.PART.MAIN) 67 | _container.set_style_pad_all(0, lv.PART.MAIN) 68 | _container.remove_style(None, lv.PART.SCROLLBAR) 69 | _container.set_style_border_width(0, lv.PART.MAIN) 70 | _container.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) 71 | _container.add_event_cb(event_handler, lv.EVENT.ALL, None) 72 | _container.set_style_bg_color(lv.color_hex3(0x000), lv.PART.MAIN) 73 | 74 | # If the model defines a show_details function, invoke it 75 | if hasattr(s_model, "show_details"): s_model.show_details(_container, sensor_info) 76 | 77 | # Add container to default group and enable editing mode 78 | lv.group_get_default().add_obj(_container) 79 | lv.group_get_default().set_editing(True) 80 | 81 | # Fetch initial sensor data if available 82 | if hasattr(s_model, "get_sensor_data"): _curr_data = s_model.get_sensor_data(_sensor_id) 83 | 84 | async def on_start(scr, app_mgr, sensor_id): 85 | """ 86 | Called when the detail page is started. 87 | - scr: the lvgl screen object 88 | - app_mgr: the application manager instance 89 | - sensor_id: ID of the sensor to display 90 | """ 91 | global _scr, _app_mgr, _sensor_id, _container 92 | _scr = scr 93 | _app_mgr = app_mgr 94 | _sensor_id = sensor_id 95 | if app_mgr: app_mgr.leave_root_page() 96 | await show_details() 97 | 98 | async def on_stop(): 99 | """ 100 | Called when the detail page is stopped. 101 | """ 102 | global _scr, _sensor_id, _container 103 | 104 | if _app_mgr: _app_mgr.enter_root_page() 105 | 106 | if _scr: 107 | _scr.clean() 108 | _scr = None 109 | _sensor_id = None 110 | _container = None 111 | 112 | async def on_running_foreground(): 113 | """ 114 | Periodic update when the app is in the foreground. 115 | - Refresh detail UI if sensor data has changed. 116 | """ 117 | global _curr_data 118 | if not _sensor_id or not _container: return 119 | 120 | sensor_info = get_sensor_info(_sensor_id) 121 | product_registry = product.get_product_registry() 122 | s_model = product_registry.get(sensor_info.get("product_name", None), None) 123 | if not s_model: return 124 | 125 | # Fetch updated sensor data if available 126 | if not hasattr(s_model, "get_sensor_data"): tmp_data = {} 127 | else: tmp_data = s_model.get_sensor_data(_sensor_id) 128 | 129 | # If data hasn't changed, do nothing 130 | if _curr_data == tmp_data: return 131 | 132 | # Update the UI details if a method is provided 133 | if hasattr(s_model, "update_details"): s_model.update_details(sensor_info) 134 | _curr_data = tmp_data 135 | 136 | await asyncio.sleep_ms(100) 137 | -------------------------------------------------------------------------------- /sensor_app/product/virtual_sensor/ui_details.py: -------------------------------------------------------------------------------- 1 | import settings 2 | import clocktime 3 | import lvgl as lv 4 | from . import config 5 | from . import data_storage 6 | 7 | _title = None 8 | _parent = None 9 | _sensor_id = None 10 | _cur_details = {} 11 | 12 | def sync_details_info(info): 13 | # Get the model code from the device info 14 | model_code = info.get("dev_model", None) 15 | profile = config.getProfile(model_code) 16 | live_info = data_storage.get_live_info(_sensor_id) 17 | 18 | details_info = {} 19 | details_info["title"] = info.get("nickname", "") 20 | details_info["options"] = [] 21 | details_info["options"].append(["SN", _sensor_id]) 22 | details_info["options"].append(["Model", profile.get("model", "-")]) 23 | details_info["options"].append(["Probe", lv.SYMBOL.OK if live_info.get("probe_state", 1) == 1 else lv.SYMBOL.CLOSE]) 24 | details_info["options"].append(["Battery", f"{live_info.get('battery_percentage', '-')}%"]) 25 | details_info["options"].append(["Signal", f"{live_info.get("rssi", "-")} dBm"]) 26 | details_info["options"].append(["Product", info.get("product_name", "-")]) 27 | 28 | epoch = live_info.get("timestamp", None) 29 | if epoch is None: 30 | lastseen = "-/-/- -:-" 31 | else: 32 | tm = clocktime.datetime(epoch) 33 | # Decide whether to display 12-hour or 24-hour time based on settings 34 | if not settings.hour24(): 35 | if tm[3] < 12: 36 | hour = str(tm[3]) 37 | time_tip = "" 38 | elif tm[3] == 12: 39 | hour = str(tm[3]) 40 | time_tip = " PM" 41 | else: 42 | hour = str(tm[3] - 12) 43 | time_tip = " PM" 44 | else: 45 | time_tip = "" 46 | hour = f"{tm[3]:02d}" 47 | minute = f"{tm[4]:02d}" 48 | lastseen = "%s/%s/%d %s:%s%s" % (f"{tm[1]:02d}", f"{tm[2]:02d}", tm[0], hour, minute, time_tip) 49 | details_info["options"].append(["LastSeen", lastseen]) 50 | 51 | return details_info 52 | 53 | def show_one_data(parent, last_obj, name, content, is_last=False): 54 | # Display a single row of sensor detail (name and value) 55 | title_label = lv.label(parent) 56 | title_label.set_text(name + ":") 57 | title_label.set_style_text_font(lv.font_ascii_18, 0) 58 | if last_obj: 59 | title_label.align_to(last_obj, lv.ALIGN.OUT_BOTTOM_LEFT, 10, 6) 60 | else: 61 | title_label.align(lv.ALIGN.TOP_LEFT, 10, 6) 62 | title_label.set_style_text_color(lv.color_hex(0xFFFFFF), 0) 63 | 64 | content_label = lv.label(parent) 65 | content_label.set_text(content) 66 | content_label.set_style_text_font(lv.font_ascii_18, 0) 67 | if last_obj: 68 | content_label.align_to(last_obj, lv.ALIGN.OUT_BOTTOM_RIGHT, -10, 6) 69 | else: 70 | content_label.align(lv.ALIGN.TOP_RIGHT, -10, 6) 71 | content_label.set_style_text_color(lv.color_hex(0xFFFFFF), 0) 72 | 73 | # Draw a separator line unless this is the last data row 74 | if is_last: return title_label 75 | else: 76 | line_points = [{"x": 0, "y": 0}, {"x": 320, "y": 0}] 77 | line = lv.line(parent) 78 | line.set_points(line_points, len(line_points)) # Set the points 79 | line.align_to(title_label, lv.ALIGN.OUT_BOTTOM_LEFT, -10, 6) 80 | line.set_style_line_width(2, 0) 81 | line.set_style_line_color(lv.color_hex(0xBBBBBB), 0) 82 | return line 83 | 84 | def update_details(sensor_info): 85 | global _cur_details 86 | tmp_details = sync_details_info(sensor_info) 87 | # Update only the changed fields in the UI 88 | for index, data in enumerate(_cur_details["options"]): 89 | if _cur_details["options"][index][1] == tmp_details["options"][index][1]: continue 90 | obj = _parent.get_child((index + 1) * 3) 91 | last_obj = _parent.get_child((index + 1) * 3 - 2) if (index + 1) * 3 - 2 >= 0 else None 92 | obj.set_text(tmp_details["options"][index][1]) 93 | if last_obj is None: obj.align(lv.ALIGN.TOP_RIGHT, -10, 6) 94 | else: obj.align_to(last_obj, lv.ALIGN.OUT_BOTTOM_RIGHT, -10, 6) 95 | 96 | _cur_details = tmp_details 97 | 98 | def show_details(parent, sensor_info): 99 | global _parent, _sensor_id, _title, _cur_details 100 | _parent = parent 101 | _sensor_id = sensor_info.get("sensor_id", None) 102 | 103 | _cur_details = sync_details_info(sensor_info) 104 | 105 | if _parent: _parent.clean() 106 | try: 107 | # Create the title label 108 | _title = lv.label(parent) 109 | _title.set_text(_cur_details["title"]) 110 | _title.set_width(300) 111 | _title.set_style_text_font(lv.font_ascii_bold_28, 0) 112 | _title.set_long_mode(lv.label.LONG.SCROLL_CIRCULAR) 113 | _title.set_style_text_color(lv.color_hex(0xFFFFFF), 0) 114 | _title.align(lv.ALIGN.TOP_LEFT, 10, 1) 115 | 116 | # Draw a separator line under the title 117 | line_points = [{"x": 0, "y": 0}, {"x": 320, "y": 0}] 118 | line = lv.line(parent) 119 | line.set_points(line_points, len(line_points)) # Set the points 120 | line.align_to(_title, lv.ALIGN.OUT_BOTTOM_LEFT, -10, 3) 121 | line.set_style_line_width(2, 0) 122 | line.set_style_line_color(lv.color_hex(0xBBBBBB), 0) 123 | 124 | last_obj = line 125 | last_index = len(_cur_details["options"]) - 1 126 | # Display each sensor detail row 127 | for index, data in enumerate(_cur_details["options"]): 128 | last_obj = show_one_data(_parent, last_obj, data[0], data[1], index == last_index) 129 | 130 | except Exception as e: 131 | print(f"show_details error: {str(e)}") 132 | -------------------------------------------------------------------------------- /widgets_demo/__init__.py: -------------------------------------------------------------------------------- 1 | import lvgl as lv 2 | import peripherals 3 | 4 | NAME = "Widgets Demo" 5 | CAN_BE_AUTO_SWITCHED = True # Indicates if the app can be auto-switched 6 | _SCR_WIDTH = peripherals.screen.screen_resolution[0] # Get the screen width from peripherals 7 | 8 | _scr = None # Initialize screen variable 9 | _app_mgr = None # Initialize app manager variable 10 | 11 | async def show_ui(): 12 | """Display the user interface elements on the screen.""" 13 | if not _scr: return # Ensure the screen is initialized 14 | 15 | # Clear the current content displayed on the screen 16 | _scr.clean() 17 | lv.group_get_default().set_editing(False) # Disable editing mode for the default group 18 | 19 | # Initialize style for the container 20 | _container_style = lv.style_t() 21 | _container_style.init() # Initialize the style 22 | _container_style.set_pad_all(0) # Set padding to 0 23 | _container_style.set_border_width(0) # Set border width to 0 24 | _container_style.set_bg_color(lv.color_hex3(0x000)) # Set background color to black 25 | 26 | # Create a container for the slider 27 | _sl_container = lv.obj(_scr) 28 | _sl_container.set_size(_SCR_WIDTH, 40) # Set size of the container 29 | _sl_container.align(lv.ALIGN.TOP_LEFT, 0, 5) # Align the container to the top left 30 | _sl_container.add_style(_container_style, lv.PART.MAIN) # Add style to the container 31 | 32 | # Create a label for the slider 33 | _sl_label = lv.label(_sl_container) 34 | _sl_label.set_text("Slider") # Set label text 35 | _sl_label.align(lv.ALIGN.LEFT_MID, 10, 0) # Align the label 36 | 37 | # Create the slider 38 | _sl = lv.slider(_sl_container) 39 | _sl.set_size(_SCR_WIDTH // 2, 10) # Set size of the slider 40 | _sl.align_to(_sl_label, lv.ALIGN.OUT_RIGHT_MID, 50, 0) # Align the slider to the right of the label 41 | 42 | # Create a dropdown container 43 | _dw_container = lv.obj(_scr) 44 | _dw_container.set_size(_SCR_WIDTH, 50) # Set size of the dropdown container 45 | _dw_container.align_to(_sl_container, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 0) # Align below the slider container 46 | _dw_container.add_style(_container_style, lv.PART.MAIN) # Add style to the dropdown container 47 | 48 | # Create a label for the dropdown 49 | _dw_label = lv.label(_dw_container) 50 | _dw_label.set_text("Dropdown") # Set label text 51 | _dw_label.align(lv.ALIGN.LEFT_MID, 10, 0) # Align the label 52 | 53 | # Create the dropdown 54 | _dw1 = lv.dropdown(_dw_container) 55 | _dw1.set_size(_SCR_WIDTH // 2, 40) # Set size of the dropdown 56 | _dw1.set_options_static("\n".join(["Option 1", "Option 2", "Option 3"])) # Set options for the dropdown 57 | _dw1.align_to(_dw_label, lv.ALIGN.OUT_RIGHT_MID, 10, 0) # Align the dropdown to the right of the label 58 | 59 | # Create a switch container 60 | _sw_container = lv.obj(_scr) 61 | _sw_container.set_size(_SCR_WIDTH, 50) # Set size of the switch container 62 | _sw_container.align_to(_dw_container, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 0) # Align below the dropdown container 63 | _sw_container.add_style(_container_style, lv.PART.MAIN) # Add style to the switch container 64 | 65 | # Create a label for the switch 66 | _sw_label = lv.label(_sw_container) 67 | _sw_label.set_text("Switch") # Set label text 68 | _sw_label.align(lv.ALIGN.LEFT_MID, 10, 0) # Align the label 69 | 70 | # Create the switch 71 | _sw = lv.switch(_sw_container) 72 | _sw.align_to(_sw_label, lv.ALIGN.OUT_RIGHT_MID, 40, 0) # Align the switch to the right of the label 73 | 74 | # Create a checkbox container 75 | _cb_container = lv.obj(_scr) 76 | _cb_container.set_size(_SCR_WIDTH, 50) # Set size of the checkbox container 77 | _cb_container.align_to(_sw_container, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 0) # Align below the switch container 78 | _cb_container.add_style(_container_style, lv.PART.MAIN) # Add style to the checkbox container 79 | 80 | # Create the checkbox 81 | _cb = lv.checkbox(_cb_container) 82 | _cb.set_text("Checkbox") # Set checkbox text 83 | _cb.align(lv.ALIGN.LEFT_MID, 10, 0) # Align the checkbox 84 | 85 | # Create a button 86 | _btn = lv.button(_scr) 87 | _btn.set_size(100, 40) # Set size of the button 88 | _btn.align_to(_cb_container, lv.ALIGN.OUT_BOTTOM_MID, 0, 0) # Align below the checkbox container 89 | _btn_label = lv.label(_btn) # Create a label for the button 90 | _btn_label.set_text("Enter") # Set button label text 91 | _btn_label.align(lv.ALIGN.CENTER, 0, 1) # Center the button label 92 | 93 | async def on_start(): 94 | """Initialize the screen and load the UI when the app starts.""" 95 | global _scr 96 | if not _scr: 97 | _scr = lv.obj() # Create a new screen object 98 | _scr.set_style_bg_color(lv.color_hex3(0x000), lv.PART.MAIN) # Set background color to black 99 | _app_mgr.enter_root_page() # Enter the root page of the app manager 100 | lv.screen_load(_scr) # Load the screen 101 | 102 | await show_ui() # Show the user interface 103 | 104 | async def on_stop(): 105 | """Clean up the screen and leave the app when it stops.""" 106 | global _scr 107 | if _scr: 108 | _scr.clean() # Clean the screen 109 | _scr.delete_async() # Delete the screen asynchronously 110 | _scr = None # Reset the screen variable 111 | _app_mgr.leave_root_page() # Leave the root page of the app manager 112 | 113 | async def on_boot(apm): 114 | """Initialize the app manager on boot.""" 115 | global _app_mgr 116 | _app_mgr = apm # Set the app manager 117 | 118 | async def on_running_foreground(): 119 | """Handle actions when the app is running in the foreground.""" 120 | pass # Currently no actions needed -------------------------------------------------------------------------------- /sensor_app/product/virtual_sensor/data_storage.py: -------------------------------------------------------------------------------- 1 | import os 2 | import struct 3 | from . import config 4 | 5 | # Path to store sensor history data 6 | config.createFolder("/apps/sensor_app/history") 7 | _HISTORY_PATH = "/apps/sensor_app/history/virtual_sensor" 8 | 9 | _STRUCT_INFO = { 10 | "length": 7, # Length of the sensor data structure 11 | "struct":[("timestamp", "I"), ("measure_id", "B"), ("temperature", "h")] # Structure of the sensor data 12 | } 13 | 14 | _live_info = {} # Dictionary to store live sensor information 15 | _record_info = {} # Dictionary to store last record information 16 | _history_data = {} # Dictionary to store history data 17 | 18 | def set_live_info(sensor_id, info): 19 | # Update live sensor information 20 | _live_info[sensor_id] = info 21 | 22 | def get_live_info(sensor_id): 23 | # Get live sensor information 24 | return _live_info.get(sensor_id, {}) 25 | 26 | def remove_live_info(sensor_id): 27 | # Remove live sensor information 28 | if sensor_id not in _live_info: return 29 | del _live_info[sensor_id] 30 | 31 | def set_record_info(sensor_id, s_info, save_now=False): 32 | # Update record information 33 | _record_info[sensor_id] = s_info 34 | if save_now: save_sensor_history_data(sensor_id) 35 | 36 | def get_record_info(sensor_id): 37 | # Get record information 38 | return _record_info.get(sensor_id, {}) 39 | 40 | def clear_record_info(sensor_id): 41 | # Remove record information 42 | if sensor_id in _record_info: del _record_info[sensor_id] 43 | 44 | def set_sensor_history_data(sensor_id, s_info): 45 | # Add a new record to the sensor's history data 46 | if sensor_id not in _history_data: 47 | _history_data[sensor_id] = {"data": b"", "dev_model": s_info["dev_model"]} 48 | 49 | # Store data in little-endian format, specify endianness, otherwise struct will default to memory alignment 50 | # For example, _fmt: IBh, expected length is 7, aligned length is 8 51 | fmt = "<" 52 | data = [] 53 | record_data = {} 54 | 55 | # Generate the packing format and data, as well as the latest record information 56 | for attr_fmt in _STRUCT_INFO["struct"]: 57 | fmt += attr_fmt[1] 58 | data.append(s_info[attr_fmt[0]]) 59 | record_data[attr_fmt[0]] = s_info[attr_fmt[0]] 60 | 61 | # Pack history data 62 | byte_data = struct.pack(fmt, *data) 63 | temporary_data = _history_data[sensor_id]["data"] + byte_data 64 | max_len = config._MAX_HISTORY_MEASUREMENTS * _STRUCT_INFO["length"] 65 | curr_len = len(temporary_data) 66 | # A single node can store up to MAX_HISTORY_MEASUREMENTS data 67 | if curr_len > max_len: 68 | temporary_data = temporary_data[curr_len - max_len:] 69 | 70 | _history_data[sensor_id]["data"] = temporary_data 71 | 72 | # Update record data 73 | set_record_info(sensor_id, record_data, True) 74 | 75 | def get_sensor_history_data(sensor_id=None): 76 | # Get history data for one or all sensors 77 | res = {} 78 | # If sensor_id is None, get the history data of all sensors 79 | if sensor_id is None: data_source = _history_data 80 | else: data_source = {sensor_id: _history_data.get(sensor_id, {})} 81 | 82 | for s_id, s_info in data_source.items(): 83 | if not s_info: continue 84 | res[s_id] = [] 85 | fmt = "<" + "".join([i[1] for i in _STRUCT_INFO["struct"]]) 86 | byte_datas = s_info["data"] 87 | while byte_datas: 88 | # Parse single measurement data 89 | info = struct.unpack(fmt, byte_datas[: _STRUCT_INFO["length"]]) 90 | res[s_id].append({_STRUCT_INFO["struct"][i][0]: info[i] for i in range(len(info))}) 91 | byte_datas = byte_datas[_STRUCT_INFO["length"]:] 92 | 93 | return res 94 | 95 | def clear_sensor_history_data(sensor_id): 96 | # Remove all history data for a sensor 97 | if sensor_id in _history_data: del _history_data[sensor_id] 98 | config.remove(f"{_HISTORY_PATH}/{sensor_id}.data") 99 | 100 | def save_sensor_history_data(sensor_id=None): 101 | # Save sensor history data to file 102 | try: 103 | if sensor_id is None: data_source = _history_data 104 | else: data_source = {sensor_id: _history_data.get(sensor_id, {})} 105 | 106 | for s_id, s_info in data_source.items(): 107 | if not s_info: continue 108 | file_name = f"{_HISTORY_PATH}/{s_id}.data" 109 | 110 | try: 111 | buff = bytes([s_info["dev_model"]]) + s_info["data"] 112 | with open(file_name, "wb+") as f: f.write(buff) 113 | except Exception as e: 114 | print(f"save_sensor_history_data error: {str(e)}") 115 | # If file save fails, delete the oldest record 116 | s_info["data"] = s_info["data"][_STRUCT_INFO["length"]:] 117 | 118 | except Exception as e: 119 | print(f"save_sensor_history_data error: {str(e)}") 120 | 121 | def load_sensor_history_data(): 122 | # Load sensor history data from files 123 | global _history_data 124 | try: 125 | config.createFolder(_HISTORY_PATH) 126 | for resource in os.ilistdir(_HISTORY_PATH): 127 | # Filter out files/directories that do not meet the requirements 128 | if resource[1] != 0x8000 or not resource[0].endswith(".data"): continue 129 | with open(f"{_HISTORY_PATH}/{resource[0]}", "rb+") as f: data_bytes = f.read() 130 | 131 | s_id = resource[0].split(".")[0] 132 | s_info = _history_data.get(s_id, {}) 133 | s_info["dev_model"] = data_bytes[0] 134 | s_info["data"] = data_bytes[1:] + s_info.get("data", b"") 135 | 136 | if not s_info["data"]: return 137 | 138 | _history_data[s_id] = s_info 139 | set_record_info(s_id, get_sensor_history_data(s_id)[s_id][-1]) 140 | except Exception as e: 141 | print(f"load_sensor_history_data error: {str(e)}") 142 | 143 | def clear_cache(sensor_id): 144 | # Clear the cache for a given sensor ID 145 | clear_record_info(sensor_id) 146 | clear_sensor_history_data(sensor_id) 147 | -------------------------------------------------------------------------------- /sensor_app/ui_page/history.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import lvgl as lv 3 | import peripherals 4 | from .. import base 5 | from .. import product 6 | 7 | # Constants for screen resolution 8 | _SCR_WIDTH, _SCR_HEIGHT = peripherals.screen.screen_resolution 9 | 10 | _scr = None # The main LVGL screen object 11 | _app_mgr = None # Application manager instance 12 | _record_data = {} # Cache for history record data 13 | _sensor_id = None # Currently selected sensor ID 14 | _container = None # Container for history page UI 15 | 16 | def event_handler(e): 17 | """ 18 | Handle events for the history container: 19 | - ESC: return to home page 20 | - ENTER: try next history view, or go to details if none 21 | """ 22 | e_code = e.get_code() 23 | if e_code == lv.EVENT.KEY: 24 | e_key = e.get_key() 25 | if e_key == lv.KEY.ESC: 26 | # ESC: navigate back to home page 27 | asyncio.create_task(base.switch_page(base._PAGE_HOME)) 28 | elif e_key == lv.KEY.ENTER: 29 | # ENTER: attempt to show next history page 30 | res = None 31 | try: 32 | sensor_info = get_sensor_info(_sensor_id) 33 | product_registry = product.get_product_registry() 34 | s_model = product_registry.get(sensor_info.get("product_name", None), None) 35 | 36 | if not hasattr(s_model, "show_history"): res = None 37 | else: res = s_model.show_history(_container, _sensor_id, sensor_info["dev_model"]) 38 | except Exception as e: 39 | print(f"switch page fail.[{str(e)}]") 40 | 41 | # No more history views → go to details page 42 | if res is None: asyncio.create_task(base.switch_page(base._PAGE_DETAILS, _sensor_id)) 43 | elif e_code == lv.EVENT.FOCUSED: 44 | lv_group = lv.group_get_default() 45 | if lv_group.get_focused() != e.get_target_obj(): return 46 | # If focused and not in edit mode, enable edit mode 47 | if not lv_group.get_editing(): lv_group.set_editing(True) 48 | 49 | def get_sensor_info(sensor_id): 50 | """ 51 | Retrieve the configuration info for the given sensor ID. 52 | """ 53 | config = _app_mgr.config() 54 | selected_devices = config.get("selected", []) 55 | for dev in selected_devices: 56 | if dev["sensor_id"] == sensor_id: return dev 57 | return {} 58 | 59 | async def show_history_page(): 60 | """ 61 | Display the history UI for the current sensor. 62 | """ 63 | if not _sensor_id: return 64 | sensor_info = get_sensor_info(_sensor_id) 65 | product_registry = product.get_product_registry() 66 | s_model = product_registry.get(sensor_info.get("product_name", None), None) 67 | if not s_model: return 68 | 69 | global _container, _record_data 70 | if not _container: 71 | # Create full‐screen container for history 72 | _container = lv.obj(_scr) 73 | _container.remove_style(None, lv.PART.MAIN) 74 | _container.remove_style(None, lv.PART.SCROLLBAR) 75 | _container.set_size(_SCR_WIDTH, _SCR_HEIGHT) 76 | _container.set_style_border_width(0, lv.PART.MAIN) 77 | _container.align(lv.ALIGN.BOTTOM_MID, 0, 0) 78 | _container.remove_style(None, lv.PART.SCROLLBAR) 79 | _container.set_style_bg_opa(lv.OPA._0, lv.PART.MAIN) 80 | _container.set_scroll_snap_y(lv.SCROLL_SNAP.CENTER) 81 | _container.set_style_pad_all(0, lv.PART.MAIN) 82 | _container.add_event_cb(event_handler, lv.EVENT.ALL, None) 83 | 84 | # Add to default group and focus it 85 | lv.group_get_default().add_obj(_container) 86 | lv.group_focus_obj(_container) 87 | lv.group_get_default().set_editing(True) 88 | 89 | # Populate container via model.show_history 90 | if not hasattr(s_model, "show_history"): res = None 91 | else: res = s_model.show_history(_container, _sensor_id, sensor_info["dev_model"]) 92 | 93 | # Cache record info if available 94 | if hasattr(s_model, "get_record_info"): _record_data = s_model.get_record_info(_sensor_id) 95 | 96 | # If no history view was returned, switch to detail page 97 | if res is None: asyncio.create_task(base.switch_page(base._PAGE_DETAILS, _sensor_id)) 98 | 99 | async def on_start(scr, app_mgr, sensor_id): 100 | """ 101 | Called when the detail page is started. 102 | - scr: the lvgl screen object 103 | - app_mgr: the application manager instance 104 | - sensor_id: ID of the sensor to display 105 | """ 106 | global _scr, _app_mgr, _sensor_id 107 | _scr = scr 108 | _app_mgr = app_mgr 109 | _sensor_id = sensor_id 110 | 111 | if app_mgr: app_mgr.leave_root_page() 112 | await show_history_page() 113 | 114 | async def on_stop(): 115 | """ 116 | Called when history page stops: reset model and clean up UI. 117 | """ 118 | global _scr, _container 119 | 120 | sensor_info = get_sensor_info(_sensor_id) 121 | product_registry = product.get_product_registry() 122 | s_model = product_registry.get(sensor_info.get("product_name", None), None) 123 | if s_model: await s_model.reset_history_info() 124 | 125 | if _app_mgr: _app_mgr.enter_root_page() 126 | 127 | if _scr: 128 | _scr.clean() 129 | _scr = None 130 | _container = None 131 | 132 | async def on_running_foreground(): 133 | """ 134 | Refresh history data when app returns to foreground if data has changed. 135 | """ 136 | global _record_data 137 | if not _sensor_id or not _container: return 138 | 139 | sensor_info = get_sensor_info(_sensor_id) 140 | product_registry = product.get_product_registry() 141 | s_model = product_registry.get(sensor_info.get("product_name", None), None) 142 | if not s_model: return 143 | 144 | if not hasattr(s_model, "get_record_info"): tmp_data = {} 145 | else: tmp_data = s_model.get_record_info(_sensor_id) 146 | 147 | if _record_data == tmp_data: return 148 | 149 | # Data changed → invoke model.refresh_history 150 | if hasattr(s_model, "refresh_history"): s_model.refresh_history(_sensor_id) 151 | _record_data = tmp_data 152 | 153 | await asyncio.sleep_ms(100) -------------------------------------------------------------------------------- /sensor_app/base.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import lvgl as lv 3 | from . import product 4 | from . import ui_page 5 | from . import bluetooth 6 | 7 | _PAGE_HOME = 0 # Home page 8 | _PAGE_HISTORY = 1 # History data page 9 | _PAGE_DETAILS = 2 # Device details page 10 | _PAGE_TIPS = 3 # Tip page 11 | _MAX_SELECTABLE = 8 # Maximum selectable count 12 | 13 | _scr = None # Initialize screen variable 14 | _app_mgr = None # Initialize app manager variable 15 | _product_registry = {} # Store product models information 16 | _curr_page = _PAGE_HOME # Current displayed page 17 | _customize_font = {} # Custom font handles 18 | 19 | def load_font(): 20 | """Load custom binary fonts.""" 21 | try: 22 | _customize_font["probe_font"] = lv.binfont_create( 23 | "A:/apps/sensor_app/asset/font/font_probe_icon.bin" 24 | ) 25 | except Exception as e: 26 | print(f"load font failed: {str(e)}") 27 | 28 | def destroy_font(): 29 | """Destroy all loaded binary fonts.""" 30 | global _customize_font 31 | try: 32 | for font in _customize_font.values(): 33 | lv.binfont_destroy(font) 34 | _customize_font = {} 35 | except Exception as e: 36 | print(f"destory font failed: {str(e)}") 37 | 38 | async def init(): 39 | """Initialize BLE callbacks and start scanning.""" 40 | global _curr_page 41 | gap_name_callbacks = {} 42 | config = _app_mgr.config() 43 | selected_devices = config.get("selected", []) 44 | if not selected_devices: 45 | _curr_page = _PAGE_TIPS 46 | await page_access("on_start", _scr, _app_mgr) 47 | return 48 | 49 | # Collect and synchronize GAP name callbacks from each product model 50 | for p_name, p_model in _product_registry.items(): 51 | if hasattr(p_model, "get_gap_name_callbacks"): 52 | gap_name_callbacks.update(p_model.get_gap_name_callbacks()) 53 | 54 | if hasattr(p_model, "sync_selected_device"): 55 | p_model.sync_selected_device([dev["sensor_id"] for dev in selected_devices if "sensor_id" in dev and p_name == dev.get("product_name", "")]) 56 | 57 | bluetooth.set_gap_name_callbacks(gap_name_callbacks) 58 | await bluetooth.start_scan() 59 | # Trigger UI page on_start handler 60 | await page_access("on_start", _scr, _app_mgr) 61 | 62 | async def search_nearby_sensors(product_name, dev_model=None): 63 | """Scan nearby sensors for a given product.""" 64 | if product_name not in _product_registry: return [] 65 | 66 | p_model = _product_registry[product_name] 67 | if not hasattr(p_model, "get_sensor_found"): return [] 68 | 69 | if hasattr(p_model, "get_gap_name_callbacks"): 70 | bluetooth.set_gap_name_callbacks(p_model.get_gap_name_callbacks()) 71 | 72 | await bluetooth.start_scan(5000, 1) 73 | await bluetooth.wait_scan_complete() 74 | return p_model.get_sensor_found(dev_model) 75 | 76 | def get_page_module(page=None): 77 | """Return the UI page module based on page index.""" 78 | if page is None: page = _curr_page 79 | target = None 80 | if page == _PAGE_HOME: target = ui_page.home 81 | elif page == _PAGE_TIPS: target = ui_page.tips 82 | elif page == _PAGE_HISTORY: target = ui_page.history 83 | elif page == _PAGE_DETAILS: target = ui_page.details 84 | else: print(f"Unkown page: {page}") 85 | return target 86 | 87 | async def page_access(handler, *args, **kwargs): 88 | """Call the specified handler on the current UI page.""" 89 | try: 90 | page_module = get_page_module() 91 | if not page_module: return 92 | await getattr(page_module, handler)(*args, **kwargs) 93 | except AttributeError: pass # Handler not implemented for this page 94 | except Exception as e: 95 | print(f"page access failed: {str(e)}") 96 | 97 | async def switch_page(page, *args, **kwargs): 98 | """Stop current page, switch index, then start new page.""" 99 | global _curr_page 100 | try: 101 | await page_access("on_stop") 102 | _curr_page = page 103 | await page_access("on_start", _scr, _app_mgr, *args, **kwargs) 104 | except Exception as e: 105 | print(f"switch page failed: {str(e)}") 106 | 107 | async def on_start(): 108 | """App entry point: create screen and show loading UI.""" 109 | global _scr 110 | if not _scr: 111 | _scr = lv.obj() 112 | _scr.set_style_bg_color(lv.color_hex3(0x000), lv.PART.MAIN) 113 | _app_mgr.enter_root_page() 114 | lv.screen_load(_scr) 115 | 116 | # Draw initialization screen 117 | loading = lv.label(_scr) 118 | loading.set_text("Initializing...") 119 | loading.set_style_text_font(lv.font_ascii_22, lv.PART.MAIN) 120 | loading.center() 121 | 122 | load_font() 123 | # Kick off BLE and page init asynchronously 124 | asyncio.create_task(init()) 125 | 126 | async def on_stop(): 127 | """Tear down screen and BLE, then reset state.""" 128 | global _scr, _curr_page 129 | 130 | if _scr: _scr.clean() 131 | exiting = lv.label(_scr) 132 | exiting.set_text("Exiting...") 133 | exiting.set_style_text_font(lv.font_ascii_22, lv.PART.MAIN) 134 | exiting.center() 135 | 136 | # Disable BLE scanning 137 | await bluetooth.stop_scan() 138 | 139 | # Clean up screen object 140 | if _scr: 141 | _scr.clean() 142 | _scr.delete_async() 143 | _scr = None 144 | _app_mgr.leave_root_page() 145 | 146 | # Ensure page-specific on_stop is called 147 | await page_access("on_stop") 148 | _curr_page = _PAGE_HOME 149 | destroy_font() 150 | bluetooth.set_gap_name_callbacks({}) 151 | 152 | for p_name, p_model in _product_registry.items(): 153 | if hasattr(p_model, "sync_selected_device"): p_model.sync_selected_device([]) 154 | 155 | async def on_boot(apm): 156 | """Initialize app manager and load product info & history.""" 157 | global _app_mgr, _product_registry 158 | _app_mgr = apm 159 | await page_access("on_boot") 160 | 161 | _product_registry = product.get_product_registry() 162 | # Preload history data for each sensor model 163 | for p_name, p_model in _product_registry.items(): 164 | if hasattr(p_model, "load_sensor_history_data"): 165 | p_model.load_sensor_history_data() 166 | 167 | async def on_running_foreground(): 168 | """Handle actions when the app is running in the foreground.""" 169 | await page_access("on_running_foreground") 170 | -------------------------------------------------------------------------------- /calendar_view/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import clocktime 3 | import lvgl as lv 4 | import peripherals 5 | 6 | NAME = "Calendar View" 7 | CAN_BE_AUTO_SWITCHED = True # Indicates if the app can be auto-switched 8 | 9 | _INVALID_TIME = -1 # Invalid time 10 | _MAX_YEAR_DIFF = 5 # Maximum year difference 11 | _SCR_WIDTH, _SCR_HEIGHT = peripherals.screen.screen_resolution # Get the screen size from peripherals 12 | _MON_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] # Month abbreviations 13 | 14 | _scr = None # Initialize screen variable 15 | _app_mgr = None # Initialize app manager variable 16 | _cur_date = None # Current date 17 | _disp_date = None # Display date 18 | _is_cur_month = True # Flag to track if current month is displayed 19 | 20 | def get_settings_json(): 21 | return { 22 | "category": "Lifestyle", 23 | "form": [{ 24 | "type": "radio", 25 | "default": "Sun", 26 | "caption": "First Day of the Week", 27 | "name": "week_start_day", 28 | "tip": "Select the day to start the week for the calendar display.", 29 | "options":[("Monday", "Mon"), ("Sunday", "Sun")], 30 | }] 31 | } 32 | 33 | def check_current_time(): 34 | """ 35 | Checks if the current time is valid. 36 | Returns True if valid, otherwise shows an error and exits the app. 37 | """ 38 | if clocktime.now() != _INVALID_TIME: return True 39 | 40 | _app_mgr.error( \ 41 | "Invalid Time", \ 42 | "The current time is invalid.\nPlease wait until the time is valid before entering the app.", \ 43 | confirm=False, cb=lambda res: asyncio.create_task(_app_mgr.exit())) 44 | return False 45 | 46 | def _draw_event_cb(e): 47 | """ 48 | Callback for drawing events in the calendar. 49 | Handles custom styling for calendar items. 50 | """ 51 | obj = e.get_target_obj() 52 | draw_task = e.get_draw_task() 53 | dsc = lv.draw_dsc_base_t.__cast__(draw_task.get_draw_dsc()) 54 | # When the button matrix draws the buttons... 55 | if dsc.part != lv.PART.ITEMS: return 56 | if dsc.id1 < 7: return 57 | 58 | _label_dsc = draw_task.get_label_dsc() 59 | label_draw_dsc = lv.draw_label_dsc_t.__cast__(_label_dsc) 60 | 61 | if obj.has_button_ctrl(dsc.id1, lv.buttonmatrix.CTRL.CUSTOM_1): 62 | _fill_dsc = draw_task.get_fill_dsc() 63 | if not _fill_dsc: return 64 | 65 | # Modify the current date style 66 | draw_fill_dsc = lv.draw_fill_dsc_t.__cast__(_fill_dsc) 67 | draw_fill_dsc.init() 68 | draw_fill_dsc.radius = 5 69 | draw_fill_dsc.opa = lv.OPA._100 70 | draw_fill_dsc.color = lv.color_hex(0x0BB4ED) 71 | elif obj.has_button_ctrl(dsc.id1, lv.buttonmatrix.CTRL.DISABLED) and _label_dsc: 72 | # Adjust the style of dates that are not the current month 73 | label_draw_dsc.color = lv.color_hex(0x505050) 74 | 75 | def event_handler(e): 76 | e_code = e.get_code() 77 | if e_code == lv.EVENT.KEY: 78 | global _disp_date, _is_cur_month 79 | date = _disp_date.copy() 80 | e_key = e.get_key() 81 | if e_key == lv.KEY.LEFT: 82 | # Show the previous month 83 | if date[1] > 1: 84 | date[1] -= 1 85 | else: 86 | date[1] = 12 87 | date[0] -= 1 88 | 89 | elif e_key == lv.KEY.RIGHT: 90 | # Show the next month 91 | if date[1] < 12: 92 | date[1] += 1 93 | else: 94 | date[1] = 1 95 | date[0] += 1 96 | 97 | # If the year difference is greater than _MAX_YEAR_DIFF, do not update the date 98 | if abs(date[0] - _cur_date[0]) > _MAX_YEAR_DIFF: return 99 | 100 | # If the date is the same as the current date, update the today's date 101 | if date[:2] == list(_cur_date[:2]): 102 | _scr.get_child(2).set_today_date(*date[:3]) 103 | 104 | # Set and display the current date 105 | _scr.get_child(2).set_showed_date(*date[:2]) 106 | _scr.get_child(0).set_text(f"{_MON_ABBR[date[1] - 1]}, {date[0]}") 107 | _disp_date = date 108 | 109 | if date[:3] == list(_cur_date[:3]): 110 | _is_cur_month = True 111 | else: 112 | _is_cur_month = False 113 | 114 | elif e_code == lv.EVENT.FOCUSED: 115 | lv_group = lv.group_get_default() 116 | if lv_group.get_focused() != e.get_target_obj(): return 117 | # If the group is not in edit mode, set it to edit mode 118 | if not lv_group.get_editing(): lv_group.set_editing(True) 119 | 120 | async def create_calendar_view(): 121 | """ 122 | Creates and initializes the calendar view UI. 123 | Sets up the date label, divider line, and calendar widget. 124 | """ 125 | global _scr, _cur_date, _disp_date, _is_cur_month 126 | 127 | # Clean the screen if it exists 128 | if _scr: _scr.clean() 129 | 130 | # Get the start day of the week from the settings 131 | if not _app_mgr: start_day = "Sun" 132 | else: start_day = _app_mgr.config().get("week_start_day", "Sun") 133 | lv.calendar.set_week_starts_monday(start_day == "Mon") 134 | 135 | # Set the current date and display date 136 | _cur_date = clocktime.datetime() 137 | _disp_date = list(_cur_date) 138 | 139 | # Create and align the date label 140 | date_label = lv.label(_scr) 141 | date_label.align(lv.ALIGN.TOP_MID, 0, 2) 142 | date_label.set_style_text_font(lv.font_ascii_22, lv.PART.MAIN) 143 | date_label.set_text(f"{_MON_ABBR[_cur_date[1] - 1]}, {_cur_date[0]}") 144 | date_label.set_style_text_color(lv.color_hex(0x0BB4ED), lv.PART.MAIN) 145 | 146 | # Create and align the divider line 147 | points = [{"x":3, "y":65}, {"x":_SCR_WIDTH - 3, "y":65}] 148 | divider = lv.line(_scr) 149 | divider.set_points(points, len(points)) 150 | 151 | # Create and align the calendar widget 152 | cal = lv.calendar(_scr) 153 | cal.align(lv.ALIGN.BOTTOM_MID, 0, 0) 154 | cal.set_size(_SCR_WIDTH, _SCR_HEIGHT - 30) 155 | cal.set_style_border_width(0, lv.PART.MAIN) 156 | cal.set_style_bg_opa(lv.OPA._0, lv.PART.MAIN) 157 | cal.add_flag(lv.obj.FLAG.SEND_DRAW_TASK_EVENTS) 158 | cal.add_event_cb(event_handler, lv.EVENT.ALL, None) 159 | cal.get_btnmatrix().set_style_border_width(0, lv.PART.ITEMS) 160 | cal.get_btnmatrix().set_style_text_font(lv.font_ascii_22, 0) 161 | cal.get_btnmatrix().add_event_cb(_draw_event_cb, lv.EVENT.DRAW_TASK_ADDED, None) 162 | 163 | # Set the today's date and the displayed date 164 | cal.set_today_date(*_cur_date[:3]) 165 | cal.set_showed_date(*_cur_date[:2]) 166 | _is_cur_month = True 167 | 168 | # Remove the group from the calendar button matrix 169 | lv.group_remove_obj(cal.get_btnmatrix()) 170 | 171 | async def on_start(): 172 | """Initialize the screen and load the UI when the app starts.""" 173 | global _scr 174 | if not _scr: 175 | _scr = lv.obj() 176 | _scr.set_style_bg_color(lv.color_hex3(0x000), lv.PART.MAIN) 177 | _app_mgr.enter_root_page() 178 | lv.screen_load(_scr) 179 | 180 | # Check if the current time is valid 181 | if not check_current_time(): return 182 | 183 | # Create the calendar view 184 | await create_calendar_view() 185 | 186 | async def on_stop(): 187 | """Clean up the screen and leave the app when it stops.""" 188 | global _scr 189 | if _scr: 190 | _scr.clean() 191 | _scr.delete_async() 192 | _scr = None 193 | _app_mgr.leave_root_page() 194 | 195 | async def on_boot(apm): 196 | """Initialize the app manager on boot.""" 197 | global _app_mgr 198 | _app_mgr = apm 199 | 200 | async def on_running_foreground(): 201 | """ 202 | Handle actions when the app is running in the foreground. 203 | Updates the calendar if the date has changed. 204 | """ 205 | global _cur_date, _disp_date, _is_cur_month 206 | if not _scr: return 207 | if _scr.get_child_count() < 3: return 208 | 209 | date = clocktime.datetime() 210 | # If the date is the same as the current date, do not update the date 211 | if _cur_date[:3] == date[:3]: return 212 | 213 | # If the year difference is greater than _MAX_YEAR_DIFF + 1, update the date 214 | if abs(date[0] - _cur_date[0]) > _MAX_YEAR_DIFF + 1: 215 | _disp_date = list(date) 216 | _is_cur_month = True 217 | 218 | # If the current month is displayed, update the date label and calendar 219 | if _is_cur_month: 220 | if _cur_date[:2] != date[:2]: 221 | _scr.get_child(0).set_text(f"{_MON_ABBR[date[1] - 1]}, {date[0]}") 222 | 223 | _scr.get_child(2).set_today_date(*date[:3]) 224 | _scr.get_child(2).set_showed_date(*date[:2]) 225 | 226 | _cur_date = date 227 | -------------------------------------------------------------------------------- /Days Matter/base.py: -------------------------------------------------------------------------------- 1 | import utime 2 | import clocktime 3 | import peripherals 4 | from micropython import const 5 | 6 | REFRESH_INTERVAL = const(300) 7 | SECONDS_FROM_1970_TO_2000 = const(946684800) 8 | SCR_WIDTH = peripherals.screen.screen_resolution[0] 9 | SCR_HEIGHT = peripherals.screen.screen_resolution[1] 10 | 11 | PRESET_TARGET_ITEM = { 12 | "new_year": { 13 | "name": "New Year", 14 | "time_tuple": [0, 1, 1, 24, 0, 0, 0, 0] 15 | }, 16 | "halloween": { 17 | "name": "Halloween", 18 | "time_tuple": [0, 10, 31, 24, 0, 0, 0, 0] 19 | }, 20 | "christmas": { 21 | "name": "Christmas", 22 | "time_tuple": [0, 12, 25, 24, 0, 0, 0, 0] 23 | }, 24 | "brazil_independence_day": { 25 | "name": "Independence Day (Brazil)", 26 | "time_tuple": [0, 9, 7, 24, 0, 0, 0, 0] 27 | }, 28 | "italy_republic_day": { 29 | "name": "Republic Day (Italy)", 30 | "time_tuple": [0, 6, 2, 24, 0, 0, 0, 0] 31 | }, 32 | "mexico_independence_day": { 33 | "name": "Independence Day (Mexico)", 34 | "time_tuple": [0, 9, 16, 24, 0, 0, 0, 0] 35 | }, 36 | "national_day_china": { 37 | "name": "National Day (China)", 38 | "time_tuple": [0, 10, 1, 24, 0, 0, 0, 0] 39 | }, 40 | "canada_day": { 41 | "name": "Canada Day", 42 | "time_tuple": [0, 7, 1, 24, 0, 0, 0, 0] 43 | }, 44 | "german_unity_day": { 45 | "name": "German Unity Day", 46 | "time_tuple": [0, 10, 3, 24, 0, 0, 0, 0] 47 | }, 48 | "independence_day_usa": { 49 | "name": "Independence Day (USA)", 50 | "time_tuple": [0, 7, 4, 24, 0, 0, 0, 0] 51 | }, 52 | "australia_day": { 53 | "name": "Australia Day", 54 | "time_tuple": [0, 1, 26, 24, 0, 0, 0, 0] 55 | }, 56 | "bastille_day": { 57 | "name": "Bastille Day", 58 | "time_tuple": [0, 7, 14, 24, 0, 0, 0, 0] 59 | }, 60 | "boxing_day": { 61 | "name": "Boxing Day", 62 | "time_tuple": [0, 12, 26, 24, 0, 0, 0, 0] 63 | }, 64 | } 65 | 66 | PRESET_TARGET_LIST = [ 67 | ["new_year",PRESET_TARGET_ITEM["new_year"]["name"]], 68 | ["christmas",PRESET_TARGET_ITEM["christmas"]["name"]], 69 | ["halloween",PRESET_TARGET_ITEM["halloween"]["name"]], 70 | ["brazil_independence_day",PRESET_TARGET_ITEM["brazil_independence_day"]["name"]], 71 | ["national_day_china",PRESET_TARGET_ITEM["national_day_china"]["name"]], 72 | ["bastille_day",PRESET_TARGET_ITEM["bastille_day"]["name"]], 73 | ["boxing_day",PRESET_TARGET_ITEM["boxing_day"]["name"]], 74 | ["canada_day",PRESET_TARGET_ITEM["canada_day"]["name"]], 75 | ["german_unity_day",PRESET_TARGET_ITEM["german_unity_day"]["name"]], 76 | ["independence_day_usa",PRESET_TARGET_ITEM["independence_day_usa"]["name"]], 77 | ["mexico_independence_day",PRESET_TARGET_ITEM["mexico_independence_day"]["name"]], 78 | ["australia_day",PRESET_TARGET_ITEM["australia_day"]["name"]], 79 | ["italy_republic_day",PRESET_TARGET_ITEM["italy_republic_day"]["name"]], 80 | ] 81 | 82 | def time_tuple_to_timestamp(time_tuple): 83 | """Convert time tuple to timestamp""" 84 | return utime.mktime(time_tuple) + SECONDS_FROM_1970_TO_2000 85 | 86 | def is_leap_year(year): 87 | """Check if the given year is a leap year""" 88 | return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) 89 | 90 | def get_days_in_month(year, month): 91 | """Get the number of days in the specified year and month""" 92 | if month in [4, 6, 9, 11]: 93 | return 30 94 | elif month == 2: 95 | if is_leap_year(year): return 29 96 | return 28 97 | else: 98 | return 31 99 | 100 | def get_next_leap_year(year): 101 | """Get the next leap year after the given year""" 102 | year += 1 103 | while not is_leap_year(year): 104 | year += 1 105 | return year 106 | 107 | def adjust_date(time_tuple, year_repeat): 108 | """Adjust date to handle special month day counts""" 109 | year, month, day = time_tuple[0], time_tuple[1], time_tuple[2] 110 | 111 | if year_repeat and month == 2 and day == 29: 112 | # If it's yearly repeat and not a leap year 113 | if not is_leap_year(year): 114 | # Find the next leap year 115 | next_leap = get_next_leap_year(year) 116 | time_tuple[0] = next_leap 117 | return time_tuple 118 | days_in_month = get_days_in_month(year, month) 119 | 120 | # If current date exceeds the number of days in the month 121 | while day > days_in_month: 122 | month += 1 123 | if month > 12: 124 | month = 1 125 | year += 1 126 | days_in_month = get_days_in_month(year, month) 127 | 128 | time_tuple[0], time_tuple[1] = year, month 129 | return time_tuple 130 | 131 | def get_days_remaining(time_tuple): 132 | """Get the number of days remaining until the target time""" 133 | # time_tuple = (year, month, day, hour, minute, second, weekday, yearday) 134 | local_time = clocktime.datetime() 135 | now = time_tuple_to_timestamp(local_time) 136 | if time_tuple[0] == 0 or time_tuple[1] == 0: 137 | # If year or month is 0, use current year and month 138 | time_tuple[0] = local_time[0] 139 | if time_tuple[1] == 0: 140 | time_tuple[1] = local_time[1] 141 | if now - time_tuple_to_timestamp(time_tuple) > 86400: 142 | # If the day is greater than current day, add one month 143 | time_tuple[1] += 1 144 | if time_tuple[1] > 12: 145 | time_tuple[1] = 1 146 | time_tuple[0] += 1 147 | # Adjust the date 148 | time_tuple = adjust_date(time_tuple, False) 149 | else: 150 | if now - time_tuple_to_timestamp(time_tuple) > 86400: 151 | # If the day is greater than current day, add one year 152 | time_tuple[0] += 1 153 | # Adjust the date 154 | time_tuple = adjust_date(time_tuple, True) 155 | 156 | timestamp = time_tuple_to_timestamp(time_tuple) 157 | 158 | days_remaining = (timestamp - now) // 86400 159 | return days_remaining, time_tuple 160 | 161 | def updata_days_remaining(days_list): 162 | """Update the remaining days for all events in the list and sort them""" 163 | for days in days_list: 164 | days["days_remaining"], days["show_time_tuple"] = get_days_remaining(days["time_tuple"].copy()) 165 | # Sort by days_remaining 166 | days_list.sort(key=lambda x: x["days_remaining"]) 167 | print(days_list) 168 | return days_list 169 | 170 | def get_event_time(event_name, target_day, target_day_repeat): 171 | """Create an event object from event name, target day, and repeat settings""" 172 | if event_name and target_day: 173 | try: 174 | # Check if the date contains exactly two "-" characters 175 | if target_day.count("-") != 2: 176 | print(f"Invalid date format: {target_day}, expected format: YYYY-MM-DD") 177 | return {} 178 | 179 | target_day_str = target_day.split("-") 180 | # Check if there are exactly 3 parts after splitting 181 | if len(target_day_str) != 3: 182 | print(f"Invalid date format: {target_day}, expected format: YYYY-MM-DD") 183 | return {} 184 | 185 | # Convert to integers and validate date validity 186 | year = int(target_day_str[0]) 187 | month = int(target_day_str[1]) 188 | day = int(target_day_str[2]) 189 | 190 | # Validate month 191 | if month < 1 or month > 12: 192 | print(f"Invalid month: {month}, should be between 1 and 12") 193 | return {} 194 | 195 | # Get the number of days in the month 196 | days_in_month = get_days_in_month(year, month) 197 | 198 | # Validate day 199 | if day < 1 or day > days_in_month: 200 | print(f"Invalid day: {day}, should be between 1 and {days_in_month} for month {month}") 201 | return {} 202 | 203 | # Set target time based on repeat settings 204 | if target_day_repeat == "0": 205 | target_time = [year, month, day, 24, 0, 0, 0, 0] 206 | elif target_day_repeat == "1": 207 | target_time = [0, month, day, 24, 0, 0, 0, 0] 208 | elif target_day_repeat == "2": 209 | target_time = [0, 0, day, 24, 0, 0, 0, 0] 210 | else: 211 | target_time = [year, month, day, 24, 0, 0, 0, 0] 212 | 213 | return {"name": event_name, "time_tuple": target_time, "show_time_tuple": []} 214 | 215 | except (ValueError, IndexError) as e: 216 | print(f"Error parsing date {target_day}: {str(e)}") 217 | return {} 218 | return {} 219 | -------------------------------------------------------------------------------- /Days Matter/__init__.py: -------------------------------------------------------------------------------- 1 | import net 2 | import asyncio 3 | from . import ui 4 | import clocktime 5 | import lvgl as lv 6 | from . import base 7 | 8 | # App Name 9 | NAME = "Days Matter" 10 | 11 | CAN_BE_AUTO_SWITCHED = True 12 | 13 | # App Icon 14 | ICON = "A:apps/Days Matter/resources/icon.png" 15 | 16 | # Global variables for app state management 17 | scr = None # Main screen object 18 | app_mgr = None # App manager instance 19 | 20 | label = None # UI label reference 21 | ui_state = 0 # Current UI state: 0=list view, 1=detail view 22 | days_list = [] # List of formatted day strings for display 23 | last_index = 0 # Last selected item index 24 | last_data_time = 0 # Timestamp of last data update 25 | target_time_list = [] # List of target events with time calculations 26 | 27 | def get_settings_json(): 28 | return { 29 | "form": [{ 30 | "type": "group", 31 | "caption": "Event List", 32 | "name": "event_list", 33 | "template":{ 34 | "template_name_default": "New Event", 35 | "template_config": [ 36 | { 37 | "type": "input", 38 | "default": "", 39 | "caption": "Target Day", 40 | "name": "target_day", 41 | "tip": "Enter the target date in the format of YYYY-MM-DD", 42 | "attributes": {"placeholder": "e.g., YYYY-MM-DD"}, 43 | },{ 44 | "type": "select", 45 | "default": "0", 46 | "caption": "Repeat", 47 | "name": "target_day_repeat", 48 | "options":[("Never", "0"),("Yearly", "1"),("Monthly", "2")], 49 | "tip": "Select the frequency of the event. Never means the event will only happen once." 50 | } 51 | ]}, 52 | "attributes": {"maxLength": 10}, 53 | },{ 54 | "type": "checkbox", 55 | "default": ["new_year"], 56 | "caption": "Default Target Date", 57 | "name": "preset_target_date", 58 | "options":[(data[1], data[0]) for data in base.PRESET_TARGET_LIST], 59 | },{ 60 | "type": "select", 61 | "default": "Light", 62 | "caption": "UI style", 63 | "name": "style", 64 | "tip": "Select what color style you want", 65 | "options":[("Light", "light"), ("Dark", "dark")], 66 | }] 67 | } 68 | 69 | def get_target_time_list(): 70 | """ 71 | Get target time configuration and build the list of events to track. 72 | Handles both preset events and custom user-defined events. 73 | """ 74 | # Get target time configuration 75 | preset_target_list = app_mgr.config().get("preset_target_date", []) 76 | event_name = app_mgr.config().get("event_name", "") 77 | target_day = app_mgr.config().get("target_day", "") 78 | target_day_repeat = app_mgr.config().get("target_day_repeat", "0") 79 | event_list = app_mgr.config().get("event_list", []) 80 | 81 | if event_name and target_day: 82 | event_list.append({"template_name": event_name, "target_day": target_day, "target_day_repeat": target_day_repeat}) 83 | app_mgr.config()["event_name"] = "" 84 | app_mgr.config()["target_day"] = "" 85 | app_mgr.config()["target_day_repeat"] = "0" 86 | app_mgr.config()["event_list"] = event_list 87 | 88 | target_time_list = [] 89 | # Add preset events 90 | for key in preset_target_list: 91 | target_time_list.append(base.PRESET_TARGET_ITEM[key]) 92 | 93 | # Add custom events 94 | for event in event_list: 95 | event_time = base.get_event_time(event.get("template_name", ""), event.get("target_day", ""), event.get("target_day_repeat","")) 96 | if event_time: 97 | target_time_list.append(event_time) 98 | 99 | # Default to New Year if no events configured 100 | if not target_time_list: 101 | target_time_list = [base.PRESET_TARGET_ITEM["new_year"]] 102 | return target_time_list 103 | 104 | def draw_event_handler(e): 105 | """ 106 | Handle events in the detail view (when showing specific event). 107 | Currently handles ESC key to return to list view. 108 | """ 109 | global ui_state 110 | code = e.get_code() 111 | if code == lv.EVENT.KEY: 112 | e_key = e.get_key() 113 | if e_key == lv.KEY.ESC: 114 | ui_state = 0 115 | print("back to list") 116 | asyncio.create_task(ui.show_days_list(scr, last_index, days_list, handle_event_cb)) 117 | 118 | def handle_event_cb(e): 119 | """ 120 | Handle events in the list view. 121 | Manages item selection, focus, and navigation between views. 122 | """ 123 | global last_index, ui_state, app_mgr 124 | style = app_mgr.config().get("style", "light") 125 | code = e.get_code() 126 | target = e.get_target_obj() 127 | if code == lv.EVENT.CLICKED: 128 | # Switch to detail view when item is clicked 129 | index = e.get_target_obj().get_index() 130 | last_index = index 131 | ui_state = 1 132 | asyncio.create_task(ui.show_days_matter(scr, target_time_list[index]["name"], target_time_list[index]["days_remaining"], target_time_list[index]["show_time_tuple"], draw_event_handler)) 133 | elif code == lv.EVENT.FOCUSED: 134 | # Highlight focused item 135 | target.set_style_bg_color(lv.color_hex(ui.STYLES[style]["focused"]), 0) 136 | target.scroll_to_view(lv.ANIM.OFF) 137 | elif code == lv.EVENT.DEFOCUSED: 138 | # Remove highlight from unfocused item 139 | target.set_style_bg_color(lv.color_hex(ui.STYLES[style]["defocused"]), 0) 140 | target.scroll_to_view(lv.ANIM.OFF) 141 | 142 | async def update_ui(now): 143 | """ 144 | Update the UI with latest time calculations. 145 | """ 146 | global target_time_list, last_data_time, days_list 147 | local_time = clocktime.datetime() 148 | 149 | # Update if refresh interval passed or at midnight (for day changes) 150 | if now - last_data_time > base.REFRESH_INTERVAL or (local_time[3] == 0 and local_time[4] == 0 and local_time[5] == 5): 151 | target_time_list = get_target_time_list() 152 | new_days_list = base.updata_days_remaining(target_time_list) 153 | last_data_time = now 154 | 155 | # Only update UI if data actually changed 156 | if new_days_list != days_list: 157 | days_list = new_days_list 158 | print("update ui") 159 | if ui_state == 0: 160 | # Update list view 161 | asyncio.create_task(ui.show_days_list(scr, last_index, days_list, handle_event_cb)) 162 | else: 163 | # Update detail view 164 | asyncio.create_task(ui.show_days_matter(scr, target_time_list[last_index]["name"], target_time_list[last_index]["days_remaining"], target_time_list[last_index]["show_time_tuple"], draw_event_handler)) 165 | 166 | async def init(): 167 | """ 168 | Initialize the app and display initial UI. 169 | Shows error message if time is not synced. 170 | """ 171 | global target_time_list, days_list, ui_state 172 | if clocktime.now() != -1: 173 | # Time is synced, proceed with normal initialization 174 | target_time_list = get_target_time_list() 175 | days_list = base.updata_days_remaining(target_time_list) 176 | await ui.show_days_list(scr, last_index, days_list, handle_event_cb) 177 | else: 178 | # Time not synced, show error 179 | await ui.show_error_msg(scr, "Time not synced.") 180 | 181 | async def on_boot(apm): 182 | """ 183 | App lifecycle: Called when app is first loaded. 184 | """ 185 | global app_mgr 186 | app_mgr = apm 187 | await ui.on_boot(apm) 188 | 189 | async def on_stop(): 190 | """ 191 | App lifecycle: Called when user leaves this app. 192 | Cleans up resources and UI elements. 193 | """ 194 | global scr 195 | print('on stop') 196 | if scr: 197 | scr.clean() 198 | del scr 199 | scr = None 200 | 201 | async def on_start(): 202 | """ 203 | App lifecycle: Called when user enters this app. 204 | Sets up the main screen and initializes the UI. 205 | """ 206 | global scr, label 207 | app_mgr.enter_root_page() 208 | print('on start') 209 | 210 | # Create and configure main screen 211 | scr = lv.obj() 212 | scr.set_size(base.SCR_WIDTH, base.SCR_HEIGHT) 213 | 214 | # Load the LVGL widgets and initialize app 215 | lv.scr_load(scr) 216 | await init() 217 | 218 | async def on_running_foreground(): 219 | """ 220 | App lifecycle: Called periodically when app is in foreground. 221 | """ 222 | now = clocktime.now() 223 | if net.connected() and now != -1: 224 | # log.debug("@{}".format(now)) 225 | await update_ui(now) 226 | -------------------------------------------------------------------------------- /pomodoro/base.py: -------------------------------------------------------------------------------- 1 | from micropython import const 2 | 3 | _POMODORO_DONE = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00+\x00\x00\x00.\x08\x03\x00\x00\x00\x86N\x10A\x00\x00\x00\x84PLTEGpL\xf4DD\xffZZ\xfdUU\xffZZ\xfcSS\xf3BB\xf2AA\xf6RO\xfcSS\xfdUU\xfeXX\xfaPP\xffZZa\xb1Pa\xb1Pa\xb1P\xfbRR\xf9MMv\xa6Q\xaa\x89Ta\xb1Pa\xb1P\xa1\x91Ta\xb1Pa\xb1P\xf4EE\xc8xW\xef<<\xffZZa\xb1P\xf8LL\xff\xfd\xfd\xff{{\x8a\x9bR\xff\xd2\xd2\xffii\xff\x85\x85\xf1bY\xff\xba\xba\xfc^\\\xff\x9d\x9d\xe0kX\xff\xe3\xe3S\xea\x0b\x93\x00\x00\x00\x1ctRNS\x00\xdbD\x9b\xf3\xae\xec\xfd]\x0b\x1e\xe2\x82\xd4\xc7G\x1d3p\xf5\xfe1X\x1d\xe6~\xc6\x9f\xf7Ujk\x00\x00\x01wIDATx\xda\x8d\x92\xd9v\x820\x14E/S\x12\xa0P\xc1a\xa9\xcdE\x90:\xb5\xff\xff\x7fMLj\x10\xcd\xb0\x1f\xf0e\xaf\xb3\xb6\x17\xc0\xc7n\x03\x81,\xea.\xd4\xfd\xdcv\xdd:P\xed\x04\x8bp\xd5\xb8~5\xa8w}7\xeb\xb5{\xaf\xde\xc9\x0bl\xa5\xba\x03\x17\x9bZ5\xca\x9f\xadk\xb4`_\xcbN\xb0JW\xe2\xb9d\x05X\x88\x93\x12\x05\xd2\xea\xe4\xe3xE,\x93\x18^\xc8Y\x85\x9a\x1f1-U\x94H=\x9fmVh8/\'*\x1e8ma\xc2\x07>q>\xde\x034\x94\xf3\xfdc:\xcf\x10\xe7\xf2\x19\xa7.\'\xffr\x86/\xc8U\x13!\x88\x94\xda\xa2\x15\xe3\xf2\x0c\x04E*w\xc6\x93\xc7\xe5\x8dp\x13\x14\x0c\xfd\xe0ruE\x9e\xa2\xb7AQ\x00C\xc1\xf7\x80v\xa8v\x13\x950\x8eh\x87k"\xa8P\xd0_\xfc\t\x9c@*\x13\xfa[\x80\xcbA]\xe1\xe4I0\xee\xc9y\xb1p\x97N\xdcT7\xf8g9T\xfa\xbf\xf9g)d\xfaf\xfeY\xa2>\xf3\xf1\xd7z\x04\xc3\x1eb\x14\xdc\xfa\xc1V`\xf8\x80\\\xbd\x8c\x8b\xad\xc0\x90\x83\n\xbe\xf4\x83\xad\xc0\xe4\x82\x8a\xb8\x8e\x83\xad\xc0$\x08*\x94\xf8T\n\x92\xd8\xa7\x9aYY\xecW\t(\x8a\xd2r\x01\x03m@\xd3\xa4\x1e\x953x\x10\xa7N\x93\xb70\xa1)\x1d&e\xf0D\x9e\xdc\xbd\x03\xe5/D\x05\xcca\x84\xbf\x83\xb4\xf0\x0e\x16\xcdWi\xc4\xc0\n\xcb"B\x95F\xa2l&\xfe\x01tI\x95\xa8Jp|\x83\x00\x00\x00\x00IEND\xaeB`\x82' 4 | _POMODORO = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00,\x00\x00\x00/\x08\x03\x00\x00\x00\xaf\xce\xd8\x9d\x00\x00\x00`\xb3\xb1:\x1c\x05\x04\x9b\xefpDn\xe5g\xcc\x1d/\xffb7~\x0e\xb8\xed\xab\x109\x16O\xaeblOh\x07w\xf5\xd4g ?\xf4\xa1\x07\x9f\x06$\x91[\x00"%\x95_mCe,Q\xb0\xa3\x0et*X\x00\xd7p\x83\xc6\x01i\xd1\x11\xed\xd0\xcdcR\x858\x9f\x9a`\xc4\xab\xeb\xf89\x8bk\xc5\xe0+\xf8\xbf\xcbxs\xcf\xca\xf1tS\x912\xe4\xa7(\x9fsV4G\'9\xc5\x9a\xe7\x93\x86J_*\xc8\xd7\xa9\xe8e\xc9\x19\xe5\xbb\xc7BW\xf9\xeb\xcc\n\xe2\xab\xa6t2\xa8\x8b;\xee\xa8\xb7\xc0^\x8f\xfa\xb3\x91Im\x12E\x04\x1b\x1c\x10\xa91\x19\xc5\xedx\x8cjL\xb3X\xe12\t\xc9\n\'\x10\xa2\x15\x8e\x1f\xc2\xcb\xde\xc6\xfa(\xe0g\xa3\xeb\xc3\n\xf7~\x98\x1b5\x0bm\xd8l\xec\x0b\xb4\xfe\xac\xeb<\xfa\x16\x1b,\x8d\x88\x02\x92m\xd9\xe1\x95\x92-0w:\x14V1ld\x85\xaf\xd65\xfc\xc5\x06\xfd\x1e\x83\x1b\xe2\x9fY/\xe3\xf4\xcd\xfe\x91\xe5\xb3\x9b\x17\xd9\x1e\xd6!\xef\x95\xfdX\xc5\xdd\xa3\xae\xac\xe1\xafqU\xb2\xbbA\xb3h\xb9il_\xb3E\xf7F\xc66\xd7\xee\xefsT\x86\xf2^r\x8a1\xe5\xb2\xb3\x82\xab\x7f\xc8\x9d\xdb\x10]\xc0R\x19-_\xc8\x7f\x85\xe4\x10_\xf8T\x84\xdd\x00\x00\x00\x00IEND\xaeB`\x82' 5 | _PAUSE_ICON = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00A\x00\x00\x00A\x08\x03\x00\x00\x00\xb9)9w\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x009PLTEGpL\x08\x08\x08\x17\x17\x17000AAA[[[mmm~~~\x88\x88\x88\x99\x99\x99\xad\xad\xad\xb8\xb8\xb8\xc5\xc5\xc5\xd4\xd4\xd4\xdb\xdb\xdb\xeb\xeb\xeb\xf2\xf2\xf2\xf9\xf9\xf9\xff\xff\xff\xa2)\n\xf8\x00\x00\x00\x12tRNS\x00\x07\x15/A[m~\x88\x99\xad\xb8\xc5\xd4\xdb\xeb\xf2\xf9\xe7\xe1\x05s\x00\x00\x02\x03IDATx\xda\xb5X\xdb\x92\x83 \x0c\x05\x12\xc0"\x08\xf8\xff\x1f\xbb\xb3\x89\x0e\xab\x15[\xc9\xecy*4\x1cr\x03\x12U\x07\x1a\xd0\xbd\xe2\x92\xebZ\xf3\x12_\x0eA\xab\x07\xd0\xc6\x85T\xd6\xbf()8\xf3-\x89\xb1\xa1\xacW(\xc1\x9ao\xf6\xb7sY{(\xf3g\x0e\x9c\xd7{\xccxo\xc0\x94w\xc9\x9aS\xf0\x16\x11\x10\xad\x0f)\xd7uC\x9en\xd4\x80Xw\xb1\xe0P\xab\x06\x8d.\xe4\x9d;B\xd7\x03\xcb&\x92&0\x17\xf1\x9d\xd2\xb6\xc1b\xf5%\x81+\xbb\x9a\xd0\xd3q7\xb28}A\xe0\xcb\x16\xb2\x9b\xdc\xd1\x106)\xaf{\x1a\xe4\x13\xfb\xbb\\\xeeha\x99 \xa1\xfa\x04LLaO\x16\xb2\x13#\xa8\xcf\x80\xc8\xee<\xc8\x9a\xc8\x04\xfdH\x7f\x92\x9e*\x99\x00\xea;\x00\x19R\xa76\x83\x99\x9c\x88\xea\x1e\xfd\x05f\xe6\x00\x1dU5F\x9f\x07\r\x1c\xfay\xb7\xc3\x92Y\xe1 \x83\xb1.\xfe 0: return True 48 | elif self.curr_mode != self.MODE_FOCUS: return True 49 | elif self.remaining_time != self.mode_config[self.curr_mode]["duration"]: return True 50 | 51 | return False 52 | 53 | @property 54 | def is_paused(self): 55 | # Check if timer is currently paused 56 | return self.state == self.STATE_PAUSED 57 | 58 | def load_config(self): 59 | # Load application configuration from app manager 60 | pomodoro_cfg = self.app_mgr.config() 61 | focus = pomodoro_cfg.get("focus", None) 62 | short_break = pomodoro_cfg.get("break", None) 63 | long_break = pomodoro_cfg.get("long_break", None) 64 | 65 | if not focus or not short_break or not long_break: 66 | # If any config is missing, use default values 67 | focus = self.DEFAULT_FOCUS_DURATION 68 | short_break = self.DEFAULT_SHORT_BREAK_DURATION 69 | long_break = self.DEFAULT_LONG_BREAK_DURATION 70 | 71 | pomodoro_cfg["focus"] = focus 72 | pomodoro_cfg["break"] = short_break 73 | pomodoro_cfg["long_break"] = long_break 74 | self.app_mgr.config(pomodoro_cfg) 75 | 76 | tmp_mode_config = { 77 | self.MODE_FOCUS: {"label": "FOCUS", "duration": int(focus) * 60}, 78 | self.MODE_SHORT_BREAK: {"label": "BREAK", "duration": int(short_break) * 60}, 79 | self.MODE_LONG_BREAK: {"label": "BREAK", "duration": int(long_break) * 60}, 80 | } 81 | 82 | # Reset if configuration has changed 83 | need_reset = tmp_mode_config != self.mode_config 84 | self.mode_config = tmp_mode_config 85 | if need_reset: self.reset() 86 | 87 | def reset(self): 88 | # Reset all timer values 89 | self.recorded_time = 0 90 | self.work_sessions = 0 91 | self.state = self.STATE_PAUSED 92 | self.curr_mode = self.MODE_FOCUS 93 | self.remaining_time = self.mode_config[self.curr_mode]["duration"] 94 | 95 | def handle_mode_change(self): 96 | # Switch between modes based on current state 97 | if self.curr_mode == self.MODE_FOCUS: 98 | self.work_sessions += 1 99 | if self.work_sessions <= self.SHORT_LIMIT: 100 | self.curr_mode = self.MODE_SHORT_BREAK 101 | else: 102 | self.curr_mode = self.MODE_LONG_BREAK 103 | else: 104 | if self.curr_mode == self.MODE_LONG_BREAK: 105 | self.work_sessions = 0 106 | self.curr_mode = self.MODE_FOCUS 107 | 108 | self.remaining_time = self.mode_config[self.curr_mode]["duration"] 109 | self.toggle_state(self.STATE_PAUSED) 110 | 111 | return self.curr_mode 112 | 113 | def toggle_state(self, state): 114 | # Toggle timer state between paused and running 115 | if state not in (self.STATE_PAUSED, self.STATE_RUNNING): return False 116 | self.state = state 117 | -------------------------------------------------------------------------------- /pomodoro/__init__.py: -------------------------------------------------------------------------------- 1 | import time 2 | import asyncio 3 | import lvgl as lv 4 | import peripherals 5 | from . import base 6 | 7 | NAME = "Pomodoro Timer" 8 | CAN_BE_AUTO_SWITCHED = False # Whether the App supports auto-switching in carousel mode 9 | 10 | _SCR_WIDTH, _SCR_HEIGHT = peripherals.screen.screen_resolution # Get the screen size from peripherals 11 | 12 | _scr = None # Initialize screen variable 13 | _app_mgr = None # Initialize app manager variable 14 | _pomo_timer = None # pomodoroTimer object 15 | _pause_screen = None # Widget for pause screen 16 | 17 | 18 | def get_settings_json(): 19 | # Return settings configuration for the app 20 | return { 21 | "category": "Lifestyle", 22 | "form": [{ 23 | "type": "input", 24 | "default": "25", 25 | "caption": "Focus Time (min)", 26 | "name": "focus", 27 | "attributes": {"maxLength": 25, "placeholder": ""}, 28 | "tip": "", 29 | },{ 30 | "type": "input", 31 | "default": "5", 32 | "caption": "Short Break Time (min)", 33 | "name": "break", 34 | "attributes": {"maxLength": 25, "placeholder": ""}, 35 | "tip": "", 36 | },{ 37 | "type": "input", 38 | "default": "30", 39 | "caption": "Long Break Time (min)", 40 | "name": "long_break", 41 | "attributes": {"maxLength": 25, "placeholder": ""}, 42 | "tip": "After Work Cycle", 43 | }] 44 | } 45 | 46 | async def hints_of_completion(times): 47 | # Screen and buzzer execute 'times' cycles of notification [screen off + buzzer on -> screen on + buzzer off] 48 | buzzer = peripherals.buzzer 49 | screen = peripherals.screen 50 | # Get current screen brightness 51 | prev_light = screen.brightness() 52 | # Acquire buzzer control 53 | buzzer.acquire() 54 | for i in range(times): 55 | screen.brightness(0) 56 | if buzzer.enabled: buzzer.set_volume(100) 57 | await asyncio.sleep_ms(250) 58 | screen.brightness(prev_light) 59 | if buzzer.enabled: buzzer.set_volume(0) 60 | # No need to wait after the last cycle 61 | if i < times - 1: await asyncio.sleep_ms(250) 62 | # Release buzzer control 63 | buzzer.release() 64 | 65 | def update_pause_screen(pause, show_icon=True): 66 | # Show/remove pause screen 67 | global _pause_screen 68 | if pause: 69 | # Show pause screen 70 | if _pause_screen: _pause_screen.clean() 71 | 72 | _pause_screen = lv.obj(_scr) 73 | _pause_screen.set_size(_SCR_WIDTH, _SCR_HEIGHT) 74 | _pause_screen.set_style_border_width(0, lv.PART.MAIN) 75 | _pause_screen.set_style_bg_opa(lv.OPA._0, lv.PART.MAIN) 76 | _pause_screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) 77 | _pause_screen.set_style_bg_color(lv.color_hex3(0x000), lv.PART.MAIN) 78 | 79 | # Button guide icon 80 | _label = lv.label(_pause_screen) 81 | _label.set_text(lv.SYMBOL.PLAY) 82 | _label.align(lv.ALIGN.RIGHT_MID, 8, -70) 83 | 84 | _pomo_timer.toggle_state(_pomo_timer.STATE_PAUSED) 85 | # Check if pause icon should be displayed 86 | if not show_icon: return 87 | _pause_screen.set_style_bg_opa(lv.OPA._70, lv.PART.MAIN) 88 | 89 | icon_bytes_data = lv.image_dsc_t({'data_size': len(base._PAUSE_ICON), 'data': base._PAUSE_ICON}) 90 | lv_img = lv.image(_pause_screen) 91 | lv_img.set_src(icon_bytes_data) 92 | lv_img.center() 93 | 94 | else: 95 | # Remove pause screen 96 | if _pause_screen: 97 | _pause_screen.clean() 98 | _pause_screen.delete_async() 99 | _pause_screen = None 100 | 101 | _pomo_timer.toggle_state(_pomo_timer.STATE_RUNNING) 102 | 103 | def display_pomodoro_ui(): 104 | # Display pomodoro timer UI 105 | if not _scr: return 106 | _scr.clean() 107 | 108 | # Get current mode/remaining time/completed focus sessions 109 | done_times = _pomo_timer.work_sessions 110 | config = _pomo_timer.mode_config[_pomo_timer.curr_mode] 111 | minute, second = divmod(_pomo_timer.remaining_time, 60) 112 | 113 | # Display current mode 114 | mode_label = lv.label(_scr) 115 | mode_label.set_text(config["label"]) 116 | mode_label.align(lv.ALIGN.TOP_MID, 0, 2) 117 | mode_label.set_style_text_font(lv.font_ascii_22, lv.PART.MAIN) 118 | mode_label.set_style_text_color(lv.color_hex(0x0BB4ED), lv.PART.MAIN) 119 | 120 | # Display remaining time 121 | countdown = lv.label(_scr) 122 | countdown.align(lv.ALIGN.CENTER, 0, -20) 123 | countdown.set_text(f"{minute:02d}:{second:02d}") 124 | countdown.set_style_text_font(lv.font_numbers_92, lv.PART.MAIN) 125 | 126 | # Display tomato icons to show completed focus sessions 127 | icons_container = lv.obj(_scr) 128 | icons_container.set_size(260, 47) 129 | icons_container.align(lv.ALIGN.CENTER, 0, 68) 130 | icons_container.remove_flag(lv.obj.FLAG.SCROLLABLE) 131 | icons_container.set_style_pad_column(19, lv.PART.MAIN) 132 | icons_container.set_style_border_width(0, lv.PART.MAIN) 133 | icons_container.set_style_bg_opa(lv.OPA._0, lv.PART.MAIN) 134 | icons_container.set_style_layout(lv.LAYOUT.FLEX, lv.PART.MAIN) 135 | icons_container.set_style_flex_flow(lv.FLEX_FLOW.COLUMN_WRAP, lv.PART.MAIN) 136 | icons_container.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_EVENLY, lv.PART.MAIN) 137 | 138 | icon_bytes_data = lv.image_dsc_t({'data_size': len(base._POMODORO), 'data': base._POMODORO}) 139 | icon_done_bytes_data = lv.image_dsc_t({'data_size': len(base._POMODORO_DONE), 'data': base._POMODORO_DONE}) 140 | for i in range(done_times): lv.image(icons_container).set_src(icon_done_bytes_data) 141 | for i in range(_pomo_timer.SHORT_LIMIT + 1 - done_times): lv.image(icons_container).set_src(icon_bytes_data) 142 | 143 | def event_handler(e): 144 | # Event callback for _scr widget 145 | code = e.get_code() 146 | if code == lv.EVENT.CLICKED: 147 | # Pause/resume countdown 148 | if _pomo_timer.is_paused: _pomo_timer.recorded_time = time.ticks_ms() // 1000 149 | update_pause_screen(not _pomo_timer.is_paused, True) 150 | 151 | elif code == lv.EVENT.FOCUSED: 152 | lv_group = lv.group_get_default() 153 | if lv_group.get_focused() != e.get_target_obj(): return 154 | # If the group is not in edit mode, set it to edit mode 155 | if not lv_group.get_editing(): lv_group.set_editing(True) 156 | 157 | def choose_cb(res): 158 | # App notification event callback 159 | if res == lv.KEY.ENTER: 160 | # Keep previous info and continue countdown 161 | _pomo_timer.recorded_time = time.ticks_ms() // 1000 162 | display_pomodoro_ui() 163 | update_pause_screen(False) 164 | else: 165 | # Reset previous info and restart countdown 166 | _pomo_timer.reset() 167 | display_pomodoro_ui() 168 | update_pause_screen(True, False) 169 | 170 | async def on_start(): 171 | """Initialize the screen and load the UI when the app starts.""" 172 | global _scr 173 | if not _scr: 174 | _scr = lv.obj() # Create a new screen object 175 | _app_mgr.enter_root_page() # Enter the root page of the app manager 176 | lv.screen_load(_scr) # Load the screen 177 | 178 | _scr.add_event_cb(event_handler, lv.EVENT.ALL, None) 179 | _scr.set_style_bg_color(lv.color_hex3(0x000), lv.PART.MAIN) # Set background color to black 180 | 181 | lv.group_get_default().add_obj(_scr) 182 | lv.group_focus_obj(_scr) 183 | lv.group_get_default().set_editing(True) 184 | 185 | # Load app configuration 186 | _pomo_timer.load_config() 187 | if _pomo_timer.has_pending: 188 | # If there's previous session info, ask whether to continue 189 | _app_mgr.error("Unfinished Pomodoro", 190 | "Would you like to continue where you left off, or start a new cycle?", 191 | confirm = "Continue", cancel = "Reset", cb = choose_cb) 192 | 193 | else: 194 | # Display main page 195 | display_pomodoro_ui() 196 | update_pause_screen(True, False) 197 | 198 | async def on_stop(): 199 | """Clean up the screen and leave the app when it stops.""" 200 | global _scr, _pause_screen 201 | if _scr: 202 | _scr.clean() # Clean the screen 203 | _scr.delete_async() # Delete the screen asynchronously 204 | _scr = None # Reset the screen variable 205 | _app_mgr.leave_root_page() # Leave the root page of the app manager 206 | 207 | # Set current state to 'paused' 208 | _pause_screen = None 209 | _pomo_timer.toggle_state(_pomo_timer.STATE_PAUSED) 210 | 211 | async def on_boot(apm): 212 | """Initialize the app manager on boot.""" 213 | global _app_mgr, _pomo_timer 214 | _app_mgr = apm # Set the app manager 215 | _pomo_timer = base.pomodoroTimer(apm) # Instantiate pomodoroTimer class 216 | 217 | 218 | async def on_running_foreground(): 219 | """Handle actions when the app is running in the foreground.""" 220 | # If main interface is not displayed or timer is paused, don't update 221 | if _scr.get_child_count() < 3 or _pomo_timer.is_paused: return 222 | 223 | now = time.ticks_ms() // 1000 224 | elapsed_time = now - _pomo_timer.recorded_time 225 | if elapsed_time > 0: 226 | # Update countdown text and tomato icons 227 | _pomo_timer.remaining_time -= elapsed_time 228 | _pomo_timer.recorded_time = now # Update last recorded time 229 | if _pomo_timer.remaining_time < 0: _pomo_timer.remaining_time = 0 # If remaining time < 0, set to 0 230 | # Update remaining time 231 | minute, second = divmod(_pomo_timer.remaining_time, 60) 232 | _scr.get_child(1).set_text(f"{minute:02d}:{second:02d}") 233 | 234 | if _pomo_timer.remaining_time <= 0: 235 | # If remaining time <= 0, notify and switch to next mode 236 | asyncio.create_task(hints_of_completion(5)) 237 | _pomo_timer.handle_mode_change() 238 | display_pomodoro_ui() 239 | update_pause_screen(_pomo_timer.is_paused, False) 240 | -------------------------------------------------------------------------------- /sensor_app/routes.py: -------------------------------------------------------------------------------- 1 | import json 2 | import picoweb 3 | import settings 4 | import clocktime 5 | from . import base 6 | from . import product 7 | 8 | _app_mgr = None # Application manager instance 9 | _product_registry = None # Mapping of product name to its model 10 | 11 | def get_selected_sensors(): 12 | """ 13 | Retrieve a list of selected sensors with display info. 14 | Returns a list of dicts: { 15 | model, lastSeen, nickname, sensorId, brand 16 | } 17 | """ 18 | sensor_list = [] 19 | config = _app_mgr.config() 20 | selected_configs = config.get("selected", []) 21 | 22 | for sensor in selected_configs: 23 | p_model = _product_registry.get(sensor["product_name"], {}) 24 | if not p_model: continue 25 | 26 | # Get human-readable model name if available 27 | model_name = "-" 28 | if hasattr(p_model, "get_profile"): 29 | model_name = p_model.get_profile(sensor["dev_model"]).get("model", "-") 30 | 31 | # Try live data timestamp first, then record info 32 | timestamp = None 33 | if hasattr(p_model, "get_sensor_data"): 34 | timestamp = p_model.get_sensor_data(sensor["sensor_id"]).get("timestamp", None) 35 | if timestamp is None and hasattr(p_model, "get_record_info"): 36 | timestamp = p_model.get_record_info(sensor["sensor_id"]).get("timestamp", None) 37 | 38 | # Format last seen time or use placeholder 39 | if timestamp is None: 40 | last_seen = "-/-/- -:-" 41 | else: 42 | tm = clocktime.datetime(timestamp) 43 | # Decide whether to use 12-hour format based on settings 44 | if not settings.hour24(): 45 | if tm[3] < 12: 46 | hour = str(tm[3]) 47 | time_tip = "" 48 | elif tm[3] == 12: 49 | hour = str(tm[3]) 50 | time_tip = " PM" 51 | else: 52 | hour = str(tm[3] - 12) 53 | time_tip = " PM" 54 | else: 55 | time_tip = "" 56 | hour = f"{tm[3]:02d}" 57 | minute = f"{tm[4]:02d}" 58 | last_seen = "%s/%s/%d %s:%s%s" % (f"{tm[1]:02d}", f"{tm[2]:02d}", tm[0], hour, minute, time_tip) #"10/03/2024 15:59" 59 | 60 | sensor_list.append({ 61 | "model": model_name, 62 | "lastSeen": last_seen, 63 | "nickname": sensor["nickname"], 64 | "sensorId": sensor["sensor_id"], 65 | "brand": sensor["product_name"], 66 | }) 67 | 68 | return sensor_list 69 | 70 | async def get_max_selectable(req, resp): 71 | """ 72 | GET /sensor_app/get_max_selectable 73 | Return the maximum number of sensors that can be selected. 74 | """ 75 | res = {"code": "403"} 76 | if req.method == "GET": 77 | res["code"] = "200" 78 | res["maxSelectable"] = base._MAX_SELECTABLE 79 | await picoweb.start_response(resp, status=res["code"], content_type="application/json") 80 | await resp.awrite(json.dumps(res)) 81 | 82 | async def card_view(req, resp): 83 | """ 84 | POST /sensor_app/card_view 85 | - Set the number of display cards ("displayCount") in app configuration. 86 | GET /sensor_app/card_view 87 | - Retrieve the current display card count ("displayCount") from config. 88 | """ 89 | res = {"code": "403"} 90 | config = _app_mgr.config() 91 | 92 | if req.method == "POST": 93 | await req.read_json_data() 94 | if "displayCount" not in req.form: 95 | res["code"] = "422" 96 | res["msg"] = "displayCount is required" 97 | else: 98 | if config.get("display_mode", None) != req.form["displayCount"]: 99 | config["display_mode"] = req.form["displayCount"] 100 | _app_mgr.config(config) 101 | res = {"code": "200"} 102 | elif req.method == "GET": 103 | res = {"code": "200", "displayCount": config.get("display_mode", 1)} 104 | 105 | await picoweb.start_response(resp, status=res["code"], content_type="application/json") 106 | await resp.awrite(json.dumps(res)) 107 | 108 | async def clear_cache(req, resp): 109 | """ 110 | POST /sensor_app/clear_cache 111 | - Clear the cache for a given sensor ("sensorId") if it exists in selected list. 112 | """ 113 | res = {"code": "403"} 114 | config = _app_mgr.config() 115 | 116 | if req.method == "POST": 117 | await req.read_json_data() 118 | if "sensorId" not in req.form: 119 | res["code"] = "422" 120 | res["msg"] = "sensorId is required" 121 | else: 122 | selected_sensor = config.get("selected", []) 123 | sensor_ids = [dev["sensor_id"] for dev in selected_sensor] 124 | if req.form["sensorId"] in sensor_ids: 125 | sensor =selected_sensor[sensor_ids.index(req.form["sensorId"])] 126 | p_model = _product_registry.get(sensor["product_name"], None) 127 | if p_model and hasattr(p_model, "clear_cache"): 128 | p_model.clear_cache(sensor["sensor_id"]) 129 | res["code"] = "200" 130 | res["msg"] = "cache cleared" 131 | else: 132 | res["code"] = "404" 133 | res["msg"] = "sensorId not found" 134 | 135 | await picoweb.start_response(resp, status=res["code"], content_type="application/json") 136 | await resp.awrite(json.dumps(res)) 137 | 138 | async def delete_sensor(req, resp): 139 | """ 140 | DELETE /sensor_app/delete_sensor 141 | - Remove a sensor ("sensorId") from selected list and delete its data via product model. 142 | """ 143 | res = {"code": "403"} 144 | config = _app_mgr.config() 145 | 146 | if req.method == "DELETE": 147 | await req.read_json_data() 148 | if "sensorId" not in req.form: 149 | res["code"] = "422" 150 | res["msg"] = "sensorId is required" 151 | else: 152 | selected_sensor = config.get("selected", []) 153 | sensor_ids = [dev["sensor_id"] for dev in selected_sensor] 154 | if req.form["sensorId"] in sensor_ids: 155 | del_sensor =selected_sensor.pop(sensor_ids.index(req.form["sensorId"])) 156 | p_model = _product_registry.get(del_sensor["product_name"], None) 157 | if p_model and hasattr(p_model, "delete_sensor_data"): 158 | p_model.delete_sensor_data(del_sensor["sensor_id"]) 159 | 160 | config["selected"] = selected_sensor 161 | _app_mgr.config(config) 162 | res = {"code": "200"} 163 | else: 164 | res["code"] = "404" 165 | res["msg"] = "sensorId not found" 166 | 167 | await picoweb.start_response(resp, status=res["code"], content_type="application/json") 168 | await resp.awrite(json.dumps(res)) 169 | 170 | async def get_sensors(req, resp): 171 | """ 172 | GET /sensor_app/get_sensors 173 | - Return the list of currently selected sensors with display info. 174 | """ 175 | res = {"code": "403"} 176 | if req.method == "GET": 177 | res["code"] = "200" 178 | res["sensors"] = get_selected_sensors() 179 | 180 | await picoweb.start_response(resp, content_type="application/json") 181 | await resp.awrite(json.dumps(res)) 182 | 183 | async def nickname(req, resp): 184 | """ 185 | PUT /sensor_app/nickname 186 | - Update the nickname of a selected sensor ("sensorId") in app configuration. 187 | """ 188 | res = {"code": "403"} 189 | config = _app_mgr.config() 190 | 191 | if req.method == "PUT": 192 | await req.read_json_data() 193 | if "sensorId" not in req.form or "nickname" not in req.form: 194 | res["code"] = "422" 195 | res["msg"] = "sensorId and nickname are required" 196 | else: 197 | selected_sensor = config.get("selected", []) 198 | for sensor in selected_sensor: 199 | if sensor["sensor_id"] != req.form["sensorId"]: continue 200 | sensor["nickname"] = req.form["nickname"] 201 | 202 | config["selected"] = selected_sensor 203 | _app_mgr.config(config) 204 | res = {"code": "200"} 205 | 206 | await picoweb.start_response(resp, status=res["code"], content_type="application/json") 207 | await resp.awrite(json.dumps(res)) 208 | 209 | 210 | async def ble_scan(req, resp): 211 | """ 212 | POST /sensor_app/ble_scan 213 | - Perform a BLE scan for nearby sensors given "productName" and "modelId". 214 | """ 215 | res = {"code": "403"} 216 | 217 | if req.method == "POST": 218 | res = {"code": "200"} 219 | await req.read_json_data() 220 | if "productName" not in req.form or "modelId" not in req.form: 221 | res["code"] = "422" 222 | res["msg"] = "productName and modelId are required" 223 | else: 224 | res["sensors"] = await base.search_nearby_sensors(req.form["productName"], req.form["modelId"]) 225 | await picoweb.start_response(resp, status=res["code"], content_type="application/json") 226 | await resp.awrite(json.dumps(res)) 227 | 228 | async def add_sensors(req, resp): 229 | """ 230 | POST /sensor_app/add_sensors 231 | - Add one or more sensors ("sensorIds") to the selected list under given product/model. 232 | """ 233 | res = {"code": "403"} 234 | config = _app_mgr.config() 235 | 236 | if req.method == "POST": 237 | res = {"code": "200"} 238 | await req.read_json_data() 239 | data = req.form 240 | if "productName" not in data or "modelId" not in data or "sensorIds" not in data: 241 | res["code"] = "422" 242 | res["msg"] = "productName and modelId and sensorIds are required" 243 | else: 244 | selected_sensor = config.get("selected", []) 245 | sensor_ids = [dev["sensor_id"] for dev in selected_sensor] 246 | for sensor_id in data["sensorIds"]: 247 | if sensor_id in sensor_ids: continue 248 | selected_sensor.append({ 249 | "sensor_id": sensor_id, 250 | "nickname": sensor_id[-6:], 251 | "dev_model": data["modelId"], 252 | "product_name": data["productName"]}) 253 | 254 | config["selected"] = selected_sensor 255 | _app_mgr.config(config) 256 | res["code"] = "200" 257 | 258 | await picoweb.start_response(resp, status=res["code"], content_type="application/json") 259 | await resp.awrite(json.dumps(res)) 260 | 261 | async def get_product_info(req, resp): 262 | """ 263 | GET /sensor_app/get_product_info 264 | - Return mapping of each product name to its supported sensor models. 265 | """ 266 | if req.method != "GET": 267 | await picoweb.start_response(resp, status="403", content_type="application/json") 268 | return 269 | 270 | p_info = {} 271 | for p_name, p_model in _product_registry.items(): 272 | if hasattr(p_model, "get_sensor_models"): 273 | p_info[p_name] = p_model.get_sensor_models() 274 | else: 275 | p_info[p_name] = [] 276 | 277 | await picoweb.start_response(resp, content_type="application/json") 278 | await resp.awrite(json.dumps(p_info)) 279 | 280 | def get_routes(): 281 | return [ 282 | ("/sensor_app/get_max_selectable", get_max_selectable), 283 | ("/sensor_app/delete_sensor", delete_sensor), 284 | ("/sensor_app/get_sensors", get_sensors), 285 | ("/sensor_app/clear_cache", clear_cache), 286 | ("/sensor_app/card_view", card_view), 287 | ("/sensor_app/nickname", nickname), 288 | ("/sensor_app/ble_scan", ble_scan), 289 | ("/sensor_app/add_sensors", add_sensors), 290 | ("/sensor_app/get_product_info", get_product_info), 291 | ] 292 | 293 | def init(apm): 294 | global _app_mgr, _product_registry 295 | _app_mgr = apm 296 | _product_registry = product.get_product_registry() 297 | -------------------------------------------------------------------------------- /sensor_app/product/virtual_sensor/ui_home.py: -------------------------------------------------------------------------------- 1 | import settings 2 | import clocktime 3 | import lvgl as lv 4 | from . import config 5 | from . import ui_style 6 | from . import data_storage 7 | from ... import base as app_base 8 | 9 | def show_elapsed_time(parent, live_info, record_info, calibration): 10 | elapsed_text = "" 11 | if "temperature" in live_info: 12 | _offset = calibration.get("temperature", 0) 13 | last_tm = record_info.get("temperature", 0) + _offset 14 | curr_tm = live_info.get("temperature", 0) + _offset 15 | if settings.temp_unit() == 0: 16 | # Convert Celsius to Fahrenheit 17 | last_tm = int(config.celsius2Fahrenheit(last_tm / 100) * 100) 18 | curr_tm = int(config.celsius2Fahrenheit(curr_tm / 100) * 100) 19 | 20 | # Calculate the difference 21 | value = str(int((curr_tm - last_tm) / 10) / 10) 22 | elapsed_text += ("" if value.startswith("-") else "+") + f"{value}° " 23 | elapsed_text += "since last record" 24 | 25 | # Calculate time difference 26 | diff_time = clocktime.now() - record_info.get("timestamp", 0) 27 | # If the time difference is illogical, do not display 28 | if diff_time < 0: return None 29 | elif diff_time < 60: time_label = "<1m" 30 | elif diff_time < 3600: time_label = f"{diff_time // 60}m" 31 | elif diff_time < 86400: time_label = f"{diff_time // 3600}h" 32 | else: time_label = f"{diff_time // 68400}d" 33 | elapsed_text += f" {time_label} ago" 34 | 35 | # Display text 36 | elapsed = lv.label(parent) 37 | elapsed.set_text(elapsed_text) 38 | elapsed.align(lv.ALIGN.BOTTOM_RIGHT, 0, -1) 39 | elapsed.set_style_text_font(lv.font_ascii_18, 0) 40 | elapsed.set_style_text_color(lv.color_hex(0xFFFFFF), 0) 41 | 42 | return elapsed 43 | 44 | def convert_measurement(value, _type, calibration): 45 | # Convert the value and unit to be displayed according to the data type/configuration 46 | res = ["N/A", None] 47 | try: 48 | if _type == "temperature": 49 | value = value + calibration.get(_type, 0) 50 | if settings.temp_unit() == 1: 51 | res[1] = "°C" 52 | res[0] = str(int(value / 10) / 10) 53 | else: 54 | res[1] = "°F" 55 | res[0] = str(int(int(config.celsius2Fahrenheit(value/100) * 100) / 10) / 10) 56 | except Exception as e: 57 | print(f"convert measurement fail.[{str(e)}]") 58 | return res 59 | 60 | def show_measure(parent, info, calibration, card_type): 61 | # Display measurement data 62 | model_code = info.get("dev_model", None) 63 | _profile = config.getProfile(model_code) 64 | attach_info = tuple(_profile.get("attr", {}).get("display", {}).get("attachInfo", [])) 65 | if not attach_info: return 66 | 67 | data_style = None 68 | # Find the corresponding UI style according to the card type and measurement type 69 | for _attachs, _style in ui_style.DATA_STYLE.get(card_type, {}).items(): 70 | if attach_info in _attachs: 71 | data_style = _style 72 | break 73 | 74 | if not data_style: return 75 | 76 | for index, m_type in enumerate(attach_info): 77 | data_width = data_style.get("value_width", []) 78 | # Get the converted measurement value 79 | value, symbol = convert_measurement(info.get(m_type, None), m_type, calibration) 80 | 81 | # Create a hidden label for alarm use 82 | f_label = lv.label(parent) 83 | f_label.set_text(m_type) 84 | f_label.add_state(lv.STATE.USER_1) 85 | f_label.add_flag(lv.obj.FLAG.HIDDEN) 86 | 87 | # Display measurement value 88 | v_label = lv.label(parent) 89 | v_label.set_text(value) 90 | v_label.set_style_text_font(data_style["value_font"][index], 0) 91 | if data_width: # If data length is specified 92 | v_label.set_width(data_width[index]) 93 | v_label.set_style_text_line_space(-13, 0) 94 | v_label.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) 95 | 96 | # Display measurement unit 97 | if symbol: 98 | s_label = lv.label(parent) 99 | s_label.set_text(symbol) 100 | s_label.set_style_text_font(data_style["symbol_font"][index], 0) 101 | # Placement mode [0: s_label follows v_label; 1: v_label follows s_label] 102 | placement_mode = data_style.get("placement_mode", None) 103 | if not placement_mode: placement_mode = 0 104 | else: placement_mode = placement_mode[index] 105 | 106 | # Adjust layout 107 | if placement_mode == 1: 108 | s_label.align(*data_style["symbol_align"][index]) 109 | v_label.align_to(s_label, *data_style["value_align"][index]) 110 | else: 111 | v_label.align(*data_style["value_align"][index]) 112 | s_label.align_to(v_label, *data_style["symbol_align"][index]) 113 | 114 | else: # For measurement types without a symbol, value_align should not be lv.ALIGN.OUT_xxx 115 | v_label.align(*data_style["value_align"][index]) 116 | 117 | def show_battery(parent, align_obj, battery, card_style): 118 | if not parent: return align_obj 119 | if "battery" not in card_style["icon"]["type"]: return align_obj 120 | 121 | color = lv.color_hex3(0xFFF) 122 | if battery > 80: _icon = "\uf240" 123 | elif battery > 60: _icon = "\uf241" 124 | elif battery > 40: _icon = "\uf242" 125 | elif battery > 20: _icon = "\uf243" 126 | else: 127 | _icon = "\uf244" 128 | color = lv.color_hex(0xF5411C) 129 | battery_icon = lv.label(parent) 130 | battery_icon.set_text(_icon) 131 | battery_icon.set_style_text_font(lv.font_ascii_18, 0) 132 | battery_icon.set_style_text_color(color, 0) 133 | 134 | if not card_style["icon"]["cover"]: 135 | if align_obj is None: battery_icon.align(lv.ALIGN.TOP_RIGHT, -3, -2) 136 | else: battery_icon.align_to(align_obj, lv.ALIGN.OUT_LEFT_MID, -3, 0) 137 | else: 138 | if align_obj is None: battery_icon.align(lv.ALIGN.TOP_RIGHT, 7, 0) 139 | else: 140 | align_obj.delete_async() 141 | battery_icon.align(lv.ALIGN.TOP_RIGHT, 7, 0) 142 | 143 | return battery_icon 144 | 145 | def show_probe(parent, align_obj, probe_status, card_style): 146 | if probe_status != 1: 147 | probe_icon = lv.label(parent) 148 | probe_icon.set_text("\ue560") 149 | probe_icon.set_style_text_font(app_base._customize_font.get("probe_font", lv.font_ascii_22), 0) 150 | probe_icon.set_style_text_color(lv.color_hex(0xF5411C), 0) 151 | if not card_style["icon"]["cover"]: 152 | if align_obj is None: probe_icon.align(lv.ALIGN.TOP_RIGHT, 0, 0) 153 | else: probe_icon.align_to(align_obj, lv.ALIGN.OUT_LEFT_MID, -7, 0) 154 | else: 155 | probe_icon.set_style_text_font(app_base._customize_font.get("probe_font", lv.font_ascii_22), 0) 156 | if align_obj is None: probe_icon.align(lv.ALIGN.TOP_RIGHT, -7, 0) 157 | else: 158 | align_obj.delete_async() 159 | probe_icon.align(lv.ALIGN.TOP_RIGHT, -7, 0) 160 | return probe_icon 161 | return align_obj 162 | 163 | def show_signal(parent, align_obj, info, card_style): 164 | if not parent: return align_obj 165 | 166 | signal_icon = None 167 | 168 | if "signal" in card_style["icon"]["type"]: 169 | signal = abs(info["rssi"]) 170 | signal_icon = lv.obj(parent) 171 | signal_icon.set_size(28, 28) 172 | signal_icon.set_style_radius(0, 0) 173 | signal_icon.set_style_pad_all(0, 0) 174 | signal_icon.set_style_border_width(0, 0) 175 | signal_icon.set_style_bg_opa(lv.OPA._0, 0) 176 | signal_icon.set_style_bg_color(lv.color_hex3(0x000), 0) 177 | 178 | if signal < 50: grade = 5 179 | elif signal < 60: grade = 4 180 | elif signal < 70: grade = 3 181 | elif signal < 80: grade = 2 182 | else: grade = 1 183 | 184 | for index in range(5): 185 | if index < grade: 186 | line_points = [{"x": index * 4 + 4, "y": 21 - 4 * index}, {"x": index * 4 + 4, "y": 25}] 187 | else: 188 | line_points = [{"x": index * 4 + 4, "y": 23}, {"x": index * 4 + 4, "y": 25}] 189 | line = lv.line(signal_icon) 190 | line.set_points(line_points, len(line_points)) # Set the points 191 | line.set_style_line_width(3, 0) 192 | line.set_style_line_color(lv.color_hex3(0xFFF), 0) 193 | 194 | # If the signal icon is None, return align_obj 195 | if signal_icon is None: return align_obj 196 | 197 | if not card_style["icon"]["cover"]: 198 | if align_obj is None: signal_icon.align(lv.ALIGN.TOP_RIGHT, -3, -4) 199 | else: signal_icon.align_to(align_obj, lv.ALIGN.OUT_LEFT_MID, -3, -4) 200 | else: 201 | if align_obj is None: signal_icon.align(lv.ALIGN.TOP_RIGHT, 10, -4) 202 | else: 203 | align_obj.del_async() 204 | signal_icon.align(lv.ALIGN.TOP_RIGHT, 10, -4) 205 | 206 | return signal_icon 207 | 208 | async def show_card(parent, s_info, card_type): 209 | if not parent: return 210 | card_style = ui_style.CARD_STYLE.get(card_type, None) 211 | if not card_style: return 212 | parent.clean() 213 | 214 | # Sensor name [scroll if too long] 215 | sensor_name = lv.label(parent) 216 | sensor_name.align(lv.ALIGN.TOP_LEFT, 5, 0) 217 | sensor_name.set_style_anim_duration(5000, 0) 218 | sensor_name.set_long_mode(lv.label.LONG.SCROLL_CIRCULAR) 219 | sensor_name.set_width(card_style["sensor_name"]["width"]) 220 | sensor_name.set_style_text_color(lv.color_hex(0xFFFFFF), 0) 221 | sensor_name.set_text(s_info.get("nickname", s_info["sensor_id"][-6:])) 222 | sensor_name.set_style_text_font(card_style["sensor_name"]["font"], 0) 223 | 224 | # Divider line 225 | if card_style["partition"]: 226 | line_points = [{"x": 0, "y": 0}, {"x": 320, "y": 0}] 227 | line = lv.line(parent) 228 | line.set_style_line_width(2, 0) 229 | line.set_points(line_points, len(line_points)) 230 | line.set_style_line_color(lv.color_hex(0xBBBBBB), 0) 231 | line.align(lv.ALIGN.TOP_LEFT, 0, 35) 232 | 233 | live_info = data_storage.get_live_info(s_info["sensor_id"]) 234 | record_info = data_storage.get_record_info(s_info["sensor_id"]) 235 | calibration = {"temperature": 0, "humidity": 0} 236 | 237 | align_obj = None 238 | probe_state = live_info.get("probe_state", 1) 239 | battery = live_info.get("battery_percentage", None) 240 | 241 | if live_info: 242 | align_obj = show_battery(parent, align_obj, battery, card_style) 243 | align_obj = show_probe(parent, align_obj, probe_state, card_style) 244 | show_signal(parent, align_obj, live_info, card_style) 245 | 246 | # Time difference/measurement difference display 247 | if record_info and card_style["elapsed_time"]: 248 | show_elapsed_time(parent, live_info, record_info, calibration) 249 | 250 | # Data display 251 | # Create a container at the end of parent to store measurement controls 252 | m_obj = lv.obj(parent) 253 | m_obj.set_style_radius(0, 0) 254 | m_obj.set_style_pad_all(0, 0) 255 | m_obj.set_style_border_width(0, 0) 256 | m_obj.set_style_bg_opa(lv.OPA._0, 0) 257 | m_obj.set_size(*card_style["data"]["size"]) 258 | m_obj.align(*card_style["data"]["align"]) 259 | m_obj.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) 260 | 261 | # If the device is not scanned or the device status is abnormal or the device information is incomplete, display N/A 262 | if (not live_info) or (probe_state != 1): 263 | tip_label = lv.label(m_obj) 264 | tip_label.set_text("N/A") 265 | tip_label.set_style_text_color(lv.color_hex(0xFFFFFF), 0) 266 | tip_label.set_style_text_font(lv.font_ascii_bold_48, 0) 267 | tip_label.set_style_text_line_space(-20, 0) 268 | tip_label.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) 269 | tip_label.align(lv.ALIGN.CENTER, 0, 5) 270 | else: 271 | show_measure(m_obj, live_info, calibration, card_type) 272 | -------------------------------------------------------------------------------- /Days Matter/ui.py: -------------------------------------------------------------------------------- 1 | import lvgl as lv 2 | from . import base 3 | 4 | app_mgr = None 5 | 6 | STYLES = {"light": {"text": 0x111111, 7 | "bg": 0xFFFFFF, 8 | "tip":0xC0C0C0, 9 | "days": 0x303030, 10 | "selected": 0xCBCBCB, 11 | "list_container": 0xFFFFFF, 12 | "parent_bg": 0xEFEFEF, 13 | "focused": 0xCBCBCB, 14 | "defocused":0xFFFFFF, 15 | "list_line": 0x000000}, 16 | "dark": {"text": 0xFFFFFF, 17 | "bg": 0x111111, 18 | "tip":0xC0C0C0, 19 | "days": 0xE0E0E0, 20 | "selected": 0x404040, 21 | "list_container": 0x000000, 22 | "parent_bg": 0x1F1F1F, 23 | "focused": 0x404040, 24 | "defocused":0x1B1B1B, 25 | "list_line": 0xFFFFFF}} 26 | 27 | PAST_DAYS_COLOR = 0xFF8C00 28 | PAST_DAYS_COLOR_LIGHT = 0xFFA500 29 | CURRENT_DAYS_COLOR = 0x1E90FF 30 | CURRENT_DAYS_COLOR_LIGHT = 0x00A5FF 31 | 32 | 33 | async def show_days_matter(parent, name, days, time_tuple, handle_event_cb): 34 | """ 35 | Display detailed view for a single date event. 36 | 37 | Args: 38 | parent: Parent container 39 | name: Event name 40 | days: Days remaining/elapsed 41 | time_tuple: Date tuple 42 | handle_event_cb: Event handler callback 43 | """ 44 | if parent: parent.clean() 45 | app_mgr.leave_root_page() 46 | style = app_mgr.config().get("style", "light") 47 | 48 | # Determine display style based on whether event is past or future 49 | if days < 0: 50 | # Past event - orange theme 51 | name_obj_color = lv.color_hex(PAST_DAYS_COLOR) 52 | time_tuple_text = f'Start Date: {time_tuple[0]}-{time_tuple[1]}-{time_tuple[2]}' 53 | tip_text = f"It has been {abs(days)} days since the {name} started." 54 | days = abs(days) 55 | else: 56 | # Future event or today - blue theme 57 | name_obj_color = lv.color_hex(CURRENT_DAYS_COLOR) 58 | time_tuple_text = f'Target Day: {time_tuple[0]}-{time_tuple[1]}-{time_tuple[2]}' 59 | if days == 0: 60 | tip_text = f"Today is {name}." 61 | else: 62 | tip_text = f"There are {abs(days)} days left until {name}." 63 | 64 | # Main container setup 65 | container = lv.obj(parent) 66 | container.set_style_radius(0, 0) 67 | container.set_style_pad_all(0, 0) 68 | container.set_style_border_width(0, 0) 69 | container.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) 70 | container.set_size(base.SCR_WIDTH, base.SCR_HEIGHT) 71 | container.set_style_bg_color(lv.color_hex3(STYLES[style]["bg"]), 0) 72 | container.add_event_cb(handle_event_cb, lv.EVENT.ALL, None) 73 | 74 | name_obj = lv.obj(container) 75 | name_obj.set_style_radius(0, 0) 76 | name_obj.set_style_pad_all(0, 0) 77 | name_obj.set_style_border_width(0, 0) 78 | name_obj.set_size(base.SCR_WIDTH, 40) 79 | name_obj.set_style_bg_color(name_obj_color, 0) 80 | name_obj.align(lv.ALIGN.TOP_MID, 0, 0) 81 | 82 | name_label = lv.label(name_obj) 83 | name_label.set_text(name) 84 | name_label.set_size(base.SCR_WIDTH, 40) 85 | name_label.set_long_mode(lv.label.LONG.SCROLL_CIRCULAR) 86 | name_label.set_style_text_font(lv.font_ascii_bold_28, 0) 87 | name_label.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) 88 | name_label.set_style_text_color(lv.color_hex(STYLES[style]["text"]), 0) 89 | name_label.align(lv.ALIGN.CENTER, 0, 0) 90 | 91 | tip_label = lv.label(container) 92 | tip_label.set_text(tip_text) 93 | tip_label.set_size(base.SCR_WIDTH, 40) 94 | tip_label.set_style_text_font(lv.font_ascii_14, 0) 95 | tip_label.set_style_text_color(lv.color_hex(STYLES[style]["tip"]), 0) 96 | tip_label.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) 97 | tip_label.align_to(name_obj, lv.ALIGN.OUT_BOTTOM_MID, 0, 0) 98 | 99 | if days == 0: 100 | days_text = "Today" 101 | else: 102 | days_text = f'{days}' 103 | days_label = lv.label(container) 104 | days_label.set_text(days_text) 105 | if days_text == "Today": 106 | days_label.set_style_text_font(lv.font_ascii_bold_48, 0) 107 | else: 108 | days_label.set_style_text_font(lv.font_numbers_92, 0) 109 | days_label.set_style_text_color(lv.color_hex(STYLES[style]["days"]), 0) 110 | days_label.align(lv.ALIGN.CENTER, 0, 15) 111 | 112 | # Date information at bottom 113 | time_tuple_label = lv.label(container) 114 | time_tuple_label.set_text(time_tuple_text) 115 | time_tuple_label.set_style_text_font(lv.font_ascii_18, 0) 116 | time_tuple_label.set_style_text_color(lv.color_hex(STYLES[style]["tip"]), 0) 117 | time_tuple_label.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) 118 | time_tuple_label.align(lv.ALIGN.BOTTOM_MID, 0, -10) 119 | 120 | # Focus on the sub-page 121 | lv.group_get_default().add_obj(container) 122 | lv.group_get_default().set_editing(True) 123 | 124 | async def _show_one_days(parent, days_info, handle_event_cb): 125 | """ 126 | Create a single day item in the list view. 127 | 128 | Args: 129 | parent: Parent container 130 | days_info: Dictionary containing day information 131 | handle_event_cb: Event handler callback 132 | 133 | Returns: 134 | Button object representing the day item 135 | """ 136 | style = app_mgr.config().get("style", "light") 137 | print(style) 138 | # Main button container for the day item 139 | tasks_btn = lv.button(parent) 140 | tasks_btn.set_size(base.SCR_WIDTH, 40) 141 | tasks_btn.set_style_border_width(0, 0) 142 | tasks_btn.set_style_radius(0, 0) 143 | tasks_btn.remove_style(None, lv.STATE.PRESSED) 144 | tasks_btn.remove_style(None, lv.STATE.FOCUS_KEY) 145 | tasks_btn.set_style_bg_color(lv.color_hex(STYLES[style]["bg"]), 0) 146 | tasks_btn.add_event_cb(handle_event_cb, lv.EVENT.ALL, None) 147 | 148 | # Event name label 149 | lv_label = lv.label(tasks_btn) 150 | lv_label.set_text(days_info["name"]) 151 | lv_label.set_style_text_font(lv.font_ascii_18, 0) 152 | lv_label.set_style_text_color(lv.color_hex(STYLES[style]["text"]), lv.PART.MAIN) 153 | lv_label.align_to(tasks_btn, lv.ALIGN.LEFT_MID, 0, 0) 154 | lv_label.set_size(200, 40) 155 | lv_label.set_long_mode(lv.label.LONG.DOT) 156 | 157 | # Determine display style and colors based on days remaining 158 | if days_info["days_remaining"] < 0: 159 | # Past event - orange theme 160 | days_text = f"{abs(days_info['days_remaining'])}" 161 | days_color = lv.color_hex(PAST_DAYS_COLOR_LIGHT) 162 | days_text_color = lv.color_hex(PAST_DAYS_COLOR) 163 | days_text_width = 65 164 | elif days_info['days_remaining'] == 0: 165 | # Today - blue theme with special text 166 | days_text = "Today" 167 | days_color = lv.color_hex(CURRENT_DAYS_COLOR_LIGHT) 168 | days_text_color = lv.color_hex(CURRENT_DAYS_COLOR) 169 | days_text_width = 125 170 | else: 171 | # Future event - blue theme 172 | days_text = f"{days_info['days_remaining']}" 173 | days_color = lv.color_hex(CURRENT_DAYS_COLOR_LIGHT) 174 | days_text_color = lv.color_hex(CURRENT_DAYS_COLOR) 175 | days_text_width = 65 176 | 177 | 178 | 179 | # Create "Days" suffix label for numeric days 180 | days_text_obj = None 181 | if days_text != "Today": 182 | days_text_obj = lv.obj(tasks_btn) 183 | days_text_obj.set_size(60, 40) 184 | days_text_obj.set_style_bg_color(days_text_color, 0) 185 | days_text_obj.set_style_radius(0, 0) 186 | days_text_obj.set_style_border_width(0, 0) 187 | days_text_obj.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) 188 | days_text_obj.align(lv.ALIGN.RIGHT_MID, 13, 0) 189 | 190 | days_text_label = lv.label(days_text_obj) 191 | days_text_label.set_text("Day" if abs(days_info['days_remaining']) == 1 else "Days") 192 | days_text_label.set_style_text_font(lv.font_ascii_22, 0) 193 | days_text_label.set_style_text_color(lv.color_hex(STYLES[style]["text"]), 0) 194 | days_text_label.align(lv.ALIGN.CENTER, 0, 0) 195 | 196 | # Days number display 197 | days_obj = lv.obj(tasks_btn) 198 | days_obj.set_size(days_text_width, 40) 199 | days_obj.set_style_bg_color(days_color, 0) 200 | days_obj.set_style_radius(0, 0) 201 | days_obj.set_style_border_width(0, 0) 202 | days_obj.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) 203 | if days_text_obj: 204 | days_obj.align_to(days_text_obj, lv.ALIGN.OUT_LEFT_MID, 0, 0) 205 | else: 206 | days_obj.align(lv.ALIGN.RIGHT_MID, 13, 0) 207 | 208 | days_label = lv.label(days_obj) 209 | days_label.set_text(days_text) 210 | days_label.set_style_text_font(lv.font_ascii_bold_22, 0) 211 | days_label.set_style_text_color(lv.color_hex(STYLES[style]["text"]), 0) 212 | days_label.align(lv.ALIGN.CENTER, 0, 0) 213 | 214 | return tasks_btn 215 | 216 | async def show_days_list(parent, last_index, days_list, handle_event_cb): 217 | """ 218 | Display the main list view of all day events. 219 | 220 | Args: 221 | parent: Parent container 222 | last_index: Previously selected item index 223 | days_list: List of day event dictionaries 224 | handle_event_cb: Event handler callback 225 | """ 226 | if not parent: return 227 | parent.clean() 228 | app_mgr.enter_root_page() 229 | style = app_mgr.config().get("style", "light") 230 | 231 | # Set list background color 232 | parent.set_style_bg_color(lv.color_hex(STYLES[style]["parent_bg"]), 0) 233 | 234 | # Create header with title 235 | title_obj = lv.obj(parent) 236 | title_obj.set_size(base.SCR_WIDTH, 39) 237 | title_obj.set_style_radius(0, 0) 238 | title_obj.set_style_border_width(0, 0) 239 | title_obj.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) 240 | title_obj.set_style_bg_color(lv.color_hex(0x1E90FF), 0) 241 | title_obj.align(lv.ALIGN.TOP_LEFT, 0, 0) 242 | 243 | # Title text 244 | title = lv.label(title_obj) 245 | title.set_text("Days Matter") 246 | title.set_style_text_color(lv.color_hex(0xFFFFFF), 0) 247 | title.set_style_text_font(lv.font_ascii_bold_28, 0) 248 | title.align(lv.ALIGN.CENTER, 0, -2) 249 | 250 | # Header separator line 251 | line_points = [{"x": 0, "y": 0}, {"x": base.SCR_WIDTH, "y": 0}] 252 | line = lv.line(parent) 253 | line.set_style_line_color(lv.color_hex(STYLES[style]["list_line"]), 0) 254 | line.set_points(line_points, len(line_points)) # Set the points 255 | line.align(lv.ALIGN.TOP_LEFT, 0, 39) 256 | 257 | # Create container to store data, otherwise when focusing on later data, 258 | # the exit App prompt UI will shift upward 259 | list_container = lv.obj(parent) 260 | list_container.remove_style(None, 0) 261 | list_container.set_size(base.SCR_WIDTH, base.SCR_HEIGHT - 40) 262 | list_container.remove_style(None, lv.PART.SCROLLBAR) 263 | list_container.set_style_bg_color(lv.color_hex3(STYLES[style]["list_container"]), 0) 264 | list_container.align(lv.ALIGN.TOP_LEFT, 0, 40) 265 | 266 | _algin_target = parent 267 | 268 | # Show empty state message if no events 269 | if days_list == []: 270 | tips_label = lv.label(parent) 271 | tips_label.set_text("No news for today.") 272 | tips_label.set_style_text_font(lv.font_ascii_18, 0) 273 | tips_label.align_to(line, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5) 274 | 275 | # Create list items and separators 276 | for index, days_info in enumerate(days_list): 277 | day_obj = await _show_one_days(list_container, days_info, handle_event_cb) 278 | lv.group_get_default().add_obj(day_obj) 279 | 280 | # Select alignment target 281 | if index == 0: day_obj.align(lv.ALIGN.TOP_LEFT, 0, 0) 282 | else: day_obj.align_to(_algin_target, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 0) 283 | 284 | # Update alignment target for next item 285 | _algin_target = day_obj 286 | 287 | # Add separator line between items 288 | if index != len(days_list): 289 | line_points = [{"x": 0, "y": 0}, {"x": base.SCR_WIDTH, "y": 0}] 290 | line = lv.line(day_obj) 291 | line.set_style_line_width(1, 0) 292 | line.set_points(line_points, len(line_points)) # Set the points 293 | line.set_style_line_color(lv.color_hex(STYLES[style]["list_line"]), lv.PART.MAIN) 294 | line.align(lv.ALIGN.BOTTOM_LEFT, -12, 7) 295 | 296 | # Set focus and selection 297 | lv.group_get_default().set_editing(False) 298 | if last_index > len(days_list) - 1: 299 | last_index = 0 300 | lv.group_focus_obj(list_container.get_child(last_index)) 301 | list_container.get_child(last_index).scroll_to_view(lv.ANIM.OFF) 302 | list_container.get_child(last_index).set_style_bg_color(lv.color_hex(STYLES[style]["selected"]), 0) 303 | 304 | async def show_error_msg(parent, msg): 305 | """ 306 | Display error message in the center of the screen. 307 | 308 | Args: 309 | parent: Parent container 310 | msg: Error message text 311 | """ 312 | if not parent: return 313 | parent.clean() 314 | title = lv.label(parent) 315 | title.set_text(msg) 316 | title.set_style_text_color(lv.color_hex(0xFFFFFF), 0) 317 | title.set_style_text_font(lv.font_ascii_bold_28, 0) 318 | title.align(lv.ALIGN.CENTER, 0, 0) 319 | 320 | async def on_boot(apm): 321 | global app_mgr 322 | app_mgr = apm 323 | -------------------------------------------------------------------------------- /webcam/__init__.py: -------------------------------------------------------------------------------- 1 | import lvgl as lv 2 | import urequests 3 | import net 4 | import _thread 5 | import time 6 | 7 | # App Name 8 | NAME: str = "Webcam" 9 | 10 | # App Icon 11 | ICON: str = "A:apps/webcam/resources/icon.png" 12 | 13 | # LVGL widgets 14 | scr: lv.obj = None 15 | label: lv.obj = None 16 | 17 | # App manager 18 | app_mgr: Any = None 19 | task_running: bool = False 20 | task_running_lock = _thread.allocate_lock() 21 | 22 | # Constants 23 | DEFAULT_BG_COLOR = lv.color_hex3(0x000) 24 | 25 | # Current image index 26 | webcam_index: int = 0 27 | webcam_name: str = "" 28 | webcam_changed: bool = False 29 | 30 | DEBUG: bool = False 31 | 32 | 33 | def dprint(msg: str) -> None: 34 | """ 35 | Print a debug message to console, if in debug mode. 36 | 37 | Args: 38 | msg (str): The message to print 39 | """ 40 | if DEBUG: 41 | print(msg) 42 | 43 | 44 | def load_image_from_url(url: str) -> None: 45 | """ 46 | Actually load an image from a given URL. 47 | 48 | Args: 49 | url (str): The URL to load the image from 50 | 51 | Returns: 52 | LVGL Image description with the respective image. 53 | 54 | Raises: 55 | Exception, if something went wrong loading the image. 56 | """ 57 | global task_running 58 | 59 | if url.startswith("http"): 60 | if net.connected(): 61 | response = None 62 | if "@" in url and ":" in url: 63 | # we need to do basic auth 64 | start = url.index(":") + 3 65 | end = url.index("@") 66 | usernamepassword = url[start:end] 67 | url = url[:start] + url[(end + 1) :] 68 | if ":" in usernamepassword: 69 | sep_index = usernamepassword.index(":") 70 | username = usernamepassword[:sep_index] 71 | password = usernamepassword[(sep_index + 1) :] 72 | dprint( 73 | f"Calling {url} with Username '{username}' and given password" 74 | ) 75 | response = urequests.get(url, auth=(username, password)) 76 | else: 77 | dprint(f"Calling {url} without basic auth") 78 | response = urequests.get(url) 79 | 80 | status_code = 0 81 | if response is not None: 82 | dprint(f"Got image with response {response.status_code}") 83 | status_code = response.status_code 84 | 85 | if task_running: 86 | if response is not None and response.status_code == 200: 87 | image_description = lv.img_dsc_t( 88 | {"data_size": len(response.content), "data": response.content} 89 | ) 90 | 91 | response.close() 92 | 93 | return image_description 94 | else: 95 | if response is None: 96 | raise Exception(f"Error URL wrongly formatted") 97 | else: 98 | response.close() 99 | raise Exception(f"Error {status_code} while loading {url}") 100 | else: 101 | if response is not None: 102 | response.close() 103 | else: 104 | raise Exception(f"Wifi not connected") 105 | else: 106 | raise Exception(f"Please configure webcams in application settings") 107 | 108 | 109 | def load_webcam() -> None: 110 | """ 111 | Task to manage loading the webcam images and displaying them. 112 | 113 | There is especially some error handling in this method. 114 | """ 115 | global scr, label, webcam_index, task_running, task_running_lock, webcam_changed 116 | 117 | if scr is None: 118 | scr = lv.obj() 119 | lv.scr_load(scr) 120 | 121 | scr.set_style_bg_color(DEFAULT_BG_COLOR, lv.PART.MAIN) 122 | scr.set_style_bg_img_src("A:apps/webcam/resources/bg.png", lv.PART.MAIN) 123 | 124 | app_mgr_config = app_mgr.config() 125 | webcam_name = app_mgr_config.get(f"name{webcam_index + 1}", "") 126 | 127 | if label is None: 128 | label = lv.label(scr) 129 | label.center() 130 | label.set_text(f"Loading webcam {webcam_index + 1}...\n{webcam_name}") 131 | 132 | # Focus the key operation on the current screen and enable editing mode. 133 | lv.group_get_default().add_obj(scr) 134 | lv.group_focus_obj(scr) 135 | lv.group_get_default().set_editing(True) 136 | 137 | # Listen for keyboard events 138 | scr.add_event(event_handler, lv.EVENT.ALL, None) 139 | 140 | with task_running_lock: 141 | time.sleep_ms(800) # Allow other tasks to run 142 | try: 143 | while task_running: 144 | url = app_mgr_config.get(f"url{webcam_index + 1}", "Unknown") 145 | 146 | try: 147 | image_description = load_image_from_url(url) 148 | 149 | if scr and not webcam_changed: # can get None, if app was exited 150 | label.set_text("") 151 | scr.set_style_bg_img_src(image_description, lv.PART.MAIN) 152 | webcam_changed = False 153 | except Exception as error: 154 | dprint(f"Error: {error}") 155 | if scr: # can get None, if app was exited 156 | label.set_text(str(error)) 157 | scr.set_style_bg_color(DEFAULT_BG_COLOR, lv.PART.MAIN) 158 | time.sleep_ms(500) 159 | 160 | if task_running: 161 | time.sleep_ms(100) # Allow other tasks to run 162 | except Exception as err: 163 | print(f"Webcam thread had an exception: {err}") 164 | raise 165 | dprint("Webcam thread ended") 166 | 167 | 168 | def change_webcam(delta: int) -> None: 169 | """ 170 | Change the webcam. 171 | 172 | Also checks, if the settings have an entry for the new webcam. If not, the next camera is chosen. 173 | 174 | Args: 175 | delta (int): Get the next (+1) or previous (-1) camera 176 | """ 177 | 178 | global webcam_index, app_mgr, scr, label, webcam_changed 179 | 180 | app_mgr_config = app_mgr.config() 181 | webcam_changed = True 182 | 183 | while True: 184 | webcam_index = (webcam_index + delta) % 5 185 | 186 | # Check if URL is valid: 187 | url = app_mgr_config.get(f"url{webcam_index + 1}", "Unknown") 188 | webcam_name = app_mgr_config.get(f"name{webcam_index + 1}", "") 189 | if url.startswith("http") or webcam_index == 0: 190 | scr.set_style_bg_img_src("A:apps/webcam/resources/bg.png", lv.PART.MAIN) 191 | label.set_text(f"Loading webcam {webcam_index + 1}...\n{webcam_name}") 192 | break 193 | 194 | 195 | def event_handler(event) -> None: 196 | """ 197 | Code executed when an event is called. 198 | 199 | Note: 200 | - This can be some paint events as well. Therefore, check the event code! 201 | - Don't call a method using "await"! Otherwise, the whole function becomes async! 202 | 203 | See https://docs.lvgl.io/master/overview/event.html for possible events. 204 | """ 205 | global app_mgr 206 | e_code = event.get_code() 207 | 208 | if e_code == lv.EVENT.KEY: 209 | e_key = event.get_key() 210 | dprint(f"Got key {e_key}") 211 | if e_key == lv.KEY.RIGHT: 212 | change_webcam(1) 213 | elif e_key == lv.KEY.LEFT: 214 | change_webcam(-1) 215 | # Escape key == EXIT app is handled by the underlying OS 216 | elif e_code == lv.EVENT.FOCUSED: 217 | # If not in edit mode, set to edit mode. 218 | if not lv.group_get_default().get_editing(): 219 | lv.group_get_default().set_editing(True) 220 | 221 | 222 | async def on_boot(apm: Any) -> None: 223 | """ 224 | Code executed on boot. 225 | See https://dock.myvobot.com/developer/guides/app-design/ for clife cycle diagram 226 | """ 227 | global app_mgr 228 | app_mgr = apm 229 | 230 | 231 | async def on_resume() -> None: 232 | """ 233 | Code executed on resume. Essentially this starts the webcam thread. 234 | 235 | See https://dock.myvobot.com/developer/guides/app-design/ for clife cycle diagram 236 | """ 237 | dprint("on resume") 238 | global task_running, task_running_lock 239 | 240 | if task_running_lock.locked(): 241 | dprint("Waiting for lock to be released / previous thread to fininsh") 242 | while task_running_lock.locked(): 243 | time.sleep_ms(100) 244 | 245 | dprint("Starting new thread") 246 | task_running = True 247 | _thread.start_new_thread(load_webcam, ()) 248 | 249 | 250 | async def on_pause() -> None: 251 | """ 252 | Code executed on pause. This stops the webcam thread. 253 | 254 | See https://dock.myvobot.com/developer/guides/app-design/ for clife cycle diagram 255 | """ 256 | dprint("on pause") 257 | global task_running 258 | task_running = False 259 | 260 | 261 | async def on_stop() -> None: 262 | """ 263 | Code executed on stop. Make sure, everything is cleaned up nicely. 264 | 265 | See https://dock.myvobot.com/developer/guides/app-design/ for clife cycle diagram 266 | """ 267 | dprint("on stop") 268 | global scr, label, task_running, task_running_lock 269 | task_running = False 270 | scr.set_style_bg_img_src("A:apps/webcam/resources/bg.png", lv.PART.MAIN) 271 | label.set_text("Ending...") 272 | 273 | if task_running_lock.locked(): 274 | dprint("Waiting for lock to be released / previous thread to fininsh") 275 | while task_running_lock.locked(): 276 | time.sleep_ms(100) 277 | 278 | if scr is not None: 279 | scr.clean() 280 | scr.del_async() 281 | scr = None 282 | label = None 283 | 284 | 285 | async def on_start() -> None: 286 | """ 287 | Code executed on start. 288 | 289 | See https://dock.myvobot.com/developer/guides/app-design/ for clife cycle diagram 290 | """ 291 | dprint("on start") 292 | 293 | 294 | def get_settings_json() -> dict: 295 | """ 296 | App settings. 297 | 298 | The app is configured via the webbrowser. This json helps creating the app settings page. 299 | See https://dock.myvobot.com/developer/reference/web-page/ for reference 300 | """ 301 | return { 302 | "title": "Settings for Webcam app", 303 | "form": [ 304 | { 305 | "type": "input", 306 | "default": "", 307 | "caption": "URL for webcam 1:", 308 | "name": "url1", 309 | "tip": "Images need to have 320x240 pixels resolution. They cannot be scaled.", 310 | "attributes": {"placeholder": "http://my.domain/webcam.jpg"}, 311 | }, 312 | { 313 | "type": "input", 314 | "default": "", 315 | "caption": "Name for webcam 1", 316 | "name": "name1", 317 | "attributes": {"placeholder": "Frontdoor"}, 318 | }, 319 | { 320 | "type": "input", 321 | "default": "", 322 | "caption": "URL for webcam 2:", 323 | "name": "url2", 324 | "tip": "Use http://{USERNAME}:{PASSWORD}@my.domain/webcam.jpg for Basic Auth.", 325 | "attributes": {"placeholder": "http://my.domain/webcam.jpg"}, 326 | }, 327 | { 328 | "type": "input", 329 | "default": "", 330 | "caption": "Name for webcam 2", 331 | "name": "name2", 332 | "attributes": {"placeholder": "Frontdoor"}, 333 | }, 334 | { 335 | "type": "input", 336 | "default": "", 337 | "caption": "URL for webcam 3:", 338 | "name": "url3", 339 | "tip": "Leave empty, if not used.", 340 | "attributes": {"placeholder": "http://my.domain/webcam.jpg"}, 341 | }, 342 | { 343 | "type": "input", 344 | "default": "", 345 | "caption": "Name for webcam 3", 346 | "name": "name3", 347 | "attributes": {"placeholder": "Frontdoor"}, 348 | }, 349 | { 350 | "type": "input", 351 | "default": "", 352 | "caption": "URL for webcam 4:", 353 | "name": "url4", 354 | "tip": "Leave empty, if not used.", 355 | "attributes": {"placeholder": "http://my.domain/webcam.jpg"}, 356 | }, 357 | { 358 | "type": "input", 359 | "default": "", 360 | "caption": "Name for webcam 4", 361 | "name": "name4", 362 | "attributes": {"placeholder": "Frontdoor"}, 363 | }, 364 | { 365 | "type": "input", 366 | "default": "", 367 | "caption": "URL for webcam 5:", 368 | "name": "url5", 369 | "tip": "Leave empty, if not used.", 370 | "attributes": {"placeholder": "http://my.domain/webcam.jpg"}, 371 | }, 372 | { 373 | "type": "input", 374 | "default": "", 375 | "caption": "Name for webcam 5", 376 | "name": "name5", 377 | "attributes": {"placeholder": "Frontdoor"}, 378 | }, 379 | ], 380 | } 381 | -------------------------------------------------------------------------------- /stock_view/__init__.py: -------------------------------------------------------------------------------- 1 | import net 2 | import asyncio 3 | import clocktime 4 | import lvgl as lv 5 | import peripherals 6 | from micropython import const 7 | from .service import get_stock_details 8 | 9 | NAME = "Stock View" # App name 10 | CAN_BE_AUTO_SWITCHED = True # Whether the App supports auto-switching in carousel mode 11 | 12 | _MENU_ITEM_FOCUSED = lv.color_hex3(0x022) # Background color when a widget is focused 13 | _MENU_ITEM_DEFOCUSED = lv.color_hex3(0x000) # Background color when a widget is unfocused 14 | _STOCK_UPDATE_INTERVAL = const(900) # Time interval for updating stock information (in seconds) 15 | _SCR_WIDTH, _SCR_HEIGHT = peripherals.screen.screen_resolution # Get the screen size from peripherals 16 | _ITEM_WIDTH, _ITEM_HEIGHT = (_SCR_WIDTH, const(60)) # Size of a single stock information item 17 | 18 | _NO_SUBUNIT_CURRENCIES = ["JPY", "KRW", "VND", "CLP"] # Currencies that don't commonly use subunits (cents) 19 | _THREE_DECIMAL_CURRENCIES = ["KWD", "BHD", "OMR", "IQD", "JOD", "TND", "LYD"] # Currencies with three decimal places 20 | _DEFAULT_STOCK_SYMBOLS = ["MSFT:NASDAQ", "TSLA:NASDAQ", "NVDA:NASDAQ", "AAPL:NASDAQ", "GOOG:NASDAQ"] # Default stock configuration 21 | _CURRENCY_SYMBOLS= { 22 | "USD": "$", # US Dollar 23 | "EUR": "€", # Euro 24 | "GBP": "£", # British Pound 25 | "JPY": "¥", # Japanese Yen 26 | "INR": "₹", # Indian Rupee 27 | "KRW": "₩", # South Korean Won 28 | "RUB": "₽", # Russian Ruble 29 | "TRY": "₺", # Turkish Lira 30 | "NGN": "₦", # Nigerian Naira 31 | "THB": "THB", # Thai Baht 32 | "VND": "₫", # Vietnamese Dong 33 | "PLN": "zł", # Polish Zloty 34 | "BRL": "R$", # Brazilian Real 35 | "ILS": "₪", # Israeli New Shekel 36 | "AUD": "A$", # Australian Dollar 37 | "CAD": "C$", # Canadian Dollar 38 | "SGD": "S$", # Singapore Dollar 39 | "CHF": "CHF", # Swiss Franc 40 | # ... Add more as needed 41 | } 42 | 43 | _scr = None # Initialize screen variable 44 | _app_mgr = None # Initialize app manager variable 45 | _stock_count = 0 # Number of stock information items currently displayed 46 | _last_updated = 0 # Timestamp of the last stock information update 47 | _stock_details = [] # Current stock information for multiple stocks 48 | _stock_symbols = _DEFAULT_STOCK_SYMBOLS # Current stock configuration 49 | 50 | def get_settings_json(): 51 | return { 52 | "category": "Finance", 53 | "form": [{ 54 | "type": "input", 55 | "default": ",".join(_DEFAULT_STOCK_SYMBOLS), 56 | "caption": "Stocks Symbols", 57 | "name": "stocks", 58 | "validation": ":,\w+", 59 | "attributes": {"maxLength": 60, "placeholder": "e.g., AAPL:NASDAQ"}, 60 | "tip": "For multiple stocks, use commas, like 'AAPL:NASDAQ,GOOGL:NASDAQ'.", 61 | "hint": { 62 | "url": "https://google.com/finance/", 63 | "label": "Click here to search for stock codes." 64 | } 65 | }] 66 | } 67 | 68 | def _load_config(): 69 | # Load application configuration 70 | global _stock_symbols, _last_updated, _stock_details 71 | stock_cfg = _app_mgr.config() 72 | symbols = stock_cfg.get("stocks", None) 73 | if symbols: 74 | # Check if configuration has changed; if so, need to refresh stock information 75 | temp_symbols = [x.strip() for x in symbols.strip(",").split(",") ] 76 | if _stock_symbols != temp_symbols: 77 | _last_updated = 0 78 | _stock_details = [] 79 | _stock_symbols = temp_symbols 80 | print(f"{NAME}: {_stock_symbols}") 81 | return True 82 | elif symbols == "": 83 | # Stock configuration is empty, prompt user to configure 84 | _stock_symbols = [] 85 | _stock_details = [] 86 | _app_mgr.error( 87 | "Stocks View App Not Configured", 88 | "Please go to the application settings to configure stock information.", 89 | confirm = "OK", cancel=False, cb=lambda res: asyncio.create_task(_app_mgr.exit())) 90 | return False 91 | else: 92 | # First load? Save default configuration 93 | stock_cfg["stocks"] = ",".join(_DEFAULT_STOCK_SYMBOLS) 94 | _app_mgr.config(stock_cfg) 95 | return True 96 | 97 | async def display_single_stock(parent, price_info): 98 | # Display information for a single stock 99 | if ('symbol' not in price_info) or (price_info['symbol'] is None): return None 100 | 101 | # Create container 102 | menu_cont = lv.menu_cont(parent) 103 | menu_cont.set_size(_ITEM_WIDTH, _ITEM_HEIGHT) 104 | menu_cont.set_style_pad_all(0, lv.PART.MAIN) 105 | 106 | cont = lv.obj(menu_cont) 107 | cont.set_style_radius(0, lv.PART.MAIN) 108 | cont.remove_flag(lv.obj.FLAG.SCROLLABLE) 109 | cont.set_size(_ITEM_WIDTH, _ITEM_HEIGHT) 110 | cont.set_style_bg_color(lv.color_hex3(0x000), lv.PART.MAIN) 111 | cont.set_style_border_side(lv.BORDER_SIDE.BOTTOM, lv.PART.MAIN) 112 | cont.set_style_border_color(lv.color_hex3(0xFFF), lv.PART.MAIN) 113 | 114 | # Display symbol 115 | symbol = lv.label(cont) 116 | symbol.set_text(price_info["symbol"]) 117 | symbol.set_width(_ITEM_WIDTH // 3 + 5) 118 | symbol.align(lv.ALIGN.TOP_LEFT, -8, -12) 119 | symbol.set_long_mode(lv.label.LONG.SCROLL_CIRCULAR) 120 | symbol.set_style_text_font(lv.font_ascii_22, lv.PART.MAIN) 121 | symbol.set_style_text_align(lv.TEXT_ALIGN.LEFT, lv.PART.MAIN) 122 | symbol.set_style_text_color(lv.color_hex(0x0BB4ED), lv.PART.MAIN) 123 | 124 | # Display short name 125 | short_name = lv.label(cont) 126 | short_name.set_text(price_info["shortName"]) 127 | short_name.align(lv.ALIGN.TOP_RIGHT, 5, -12) 128 | short_name.set_width((_ITEM_WIDTH // 3) * 2 - 15) 129 | short_name.set_long_mode(lv.label.LONG.SCROLL_CIRCULAR) 130 | short_name.set_style_text_font(lv.font_ascii_18, lv.PART.MAIN) 131 | short_name.set_style_text_align(lv.TEXT_ALIGN.RIGHT, lv.PART.MAIN) 132 | short_name.set_style_text_color(lv.color_hex(0x888888), lv.PART.MAIN) 133 | 134 | curr_price = price_info["currentPrice"] 135 | prev_close = price_info["previousClose"] 136 | if curr_price is not None and prev_close != 0: 137 | # Choose currency symbol to display 138 | if price_info["currency"] is None: 139 | currency = _CURRENCY_SYMBOLS["USD"] 140 | elif price_info["currency"].upper() in _CURRENCY_SYMBOLS: 141 | currency = _CURRENCY_SYMBOLS[price_info["currency"].upper()] 142 | elif price_info["currency"] == "Unknown": 143 | currency = "" 144 | else: 145 | currency = price_info["currency"] 146 | 147 | diff_amount = curr_price - prev_close 148 | color = 0xA50E0E if diff_amount < 0 else 0x137333 149 | bgcolor = 0xFCE8E6 if diff_amount < 0 else 0xE6f4EA 150 | arrow = lv.SYMBOL.DOWN if diff_amount < 0 else lv.SYMBOL.UP 151 | prefix = "-" + currency if diff_amount < 0 else "+" + currency 152 | 153 | if diff_amount == 0: prefix = "" 154 | if diff_amount < 0: diff_amount = -diff_amount 155 | 156 | # Determine how many decimal places to display 157 | if price_info["currency"] in _NO_SUBUNIT_CURRENCIES: 158 | amount_number = "{:.0f}".format(round(curr_price, 0)) 159 | diff_amount_number = "{:.0f}".format(round(diff_amount, 0)) 160 | elif price_info["currency"] in _THREE_DECIMAL_CURRENCIES: 161 | amount_number = "{:.3f}".format(round(curr_price, 3)) 162 | diff_amount_number = "{:.3f}".format(round(diff_amount, 3)) 163 | else: 164 | amount_number = "{:.2f}".format(round(curr_price, 2)) 165 | diff_amount_number = "{:.2f}".format(round(diff_amount, 2)) 166 | 167 | amount_text = currency + amount_number 168 | diff_amount_text = prefix + diff_amount_number 169 | 170 | amount = lv.label(cont) 171 | amount.set_text(amount_text) 172 | amount.set_width(_ITEM_WIDTH // 3 + 5) 173 | amount.set_long_mode(lv.label.LONG.SCROLL_CIRCULAR) 174 | amount.set_style_text_font(lv.font_ascii_bold_22, 0) 175 | amount.set_style_text_color(lv.color_hex(0xFFFE66), 0) 176 | amount.align(lv.ALIGN.BOTTOM_LEFT, -12, 10) 177 | 178 | diff_amount = lv.label(cont) 179 | diff_amount.set_text(amount_text) 180 | diff_amount.set_width(_ITEM_WIDTH // 3 - 10) 181 | diff_amount.set_long_mode(lv.label.LONG.SCROLL_CIRCULAR) 182 | diff_amount.set_style_text_font(lv.font_ascii_bold_22, 0) 183 | diff_amount.set_text(diff_amount_text) 184 | diff_amount.set_style_text_color(lv.color_hex(color), 0) 185 | diff_amount.align(lv.ALIGN.BOTTOM_LEFT, 100, 10) 186 | 187 | ratio_obj = lv.obj(cont) 188 | ratio_obj.set_style_bg_color(lv.color_hex(bgcolor), 0) 189 | ratio_obj.set_size(110, 32) 190 | ratio_obj.set_style_pad_all(0, 0) 191 | ratio_obj.set_style_border_width(0, 0) 192 | ratio_obj.align(lv.ALIGN.BOTTOM_RIGHT, 10, 10) 193 | 194 | diff_ratio = 100 * (curr_price - prev_close) / prev_close 195 | if diff_ratio < 0: diff_ratio = -diff_ratio 196 | diff_ratio_label = lv.label(ratio_obj) 197 | diff_ratio_label.set_text("{:.2f}%".format(round(diff_ratio, 2))) 198 | diff_ratio_label.set_style_text_font(lv.font_ascii_bold_22, 0) 199 | diff_ratio_label.set_style_text_color(lv.color_hex(color), 0) 200 | diff_ratio_label.align(lv.ALIGN.CENTER, 10, 0) 201 | 202 | if prefix == "": arrow= " " 203 | arrow_text = lv.label(ratio_obj) 204 | arrow_text.set_text(arrow) 205 | arrow_text.set_style_text_color(lv.color_hex(color), 0) 206 | arrow_text.align_to(diff_ratio_label, lv.ALIGN.OUT_LEFT_MID, -4, 0) 207 | 208 | else: 209 | # No price information obtained, indicate data source not available 210 | tip_label = lv.label(cont) 211 | tip_label.set_text("Stock information not found.") 212 | tip_label.set_width(_ITEM_WIDTH) 213 | tip_label.align(lv.ALIGN.BOTTOM_MID, 0, 10) 214 | tip_label.set_long_mode(lv.label.LONG.SCROLL_CIRCULAR) 215 | tip_label.set_style_text_font(lv.font_ascii_18, lv.PART.MAIN) 216 | tip_label.set_style_text_align(lv.TEXT_ALIGN.CENTER, lv.PART.MAIN) 217 | tip_label.set_style_text_color(lv.color_hex(0x888888), lv.PART.MAIN) 218 | 219 | return menu_cont 220 | 221 | def menu_cont_event_handler(e): 222 | # Widget event callback for implementing widget switching 223 | global _stock_count 224 | e_code = e.get_code() 225 | target = e.get_target_obj() 226 | lv_group = lv.group_get_default() 227 | 228 | if lv_group.get_focused() == target and lv_group.get_editing(): 229 | lv_group.set_editing(False) 230 | 231 | if e_code == lv.EVENT.FOCUSED: 232 | target.scroll_to_view(lv.ANIM.OFF) 233 | if _stock_count > 4: target.get_child(0).set_style_bg_color(_MENU_ITEM_FOCUSED, 0) 234 | 235 | elif e_code == lv.EVENT.DEFOCUSED: 236 | if _stock_count > 4: target.get_child(0).set_style_bg_color(_MENU_ITEM_DEFOCUSED, 0) 237 | elif e_code == lv.EVENT.DELETE: 238 | # FIXME: When a widget is deleted, lv.EVENT.DELETE is triggered first, then lv.EVENT.DEFOCUSED, 239 | # causing an error when setting background color [widget already deleted] 240 | _stock_count = 0 241 | 242 | async def display_multiple_stocks(): 243 | global _stock_count 244 | 245 | if not _scr: return 246 | _scr.clean() 247 | 248 | # Create a menu object 249 | menu = lv.menu(_scr) 250 | menu.set_size(_SCR_WIDTH, _SCR_HEIGHT) 251 | menu.set_style_bg_color(lv.color_hex3(0x000), lv.PART.MAIN) 252 | menu.center() 253 | 254 | # Create a main page 255 | main_page = lv.menu_page(menu, None) 256 | # remove the scroll bar 257 | main_page.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) 258 | 259 | for price_info in _stock_details: 260 | # Try to render stock information 261 | try: 262 | menu_cont = await display_single_stock(main_page, price_info) 263 | if not menu_cont: continue 264 | menu_cont.add_event_cb(menu_cont_event_handler, lv.EVENT.ALL, None) 265 | lv.group_get_default().add_obj(menu_cont) 266 | _stock_count += 1 267 | except Exception as e: 268 | pass 269 | 270 | if _stock_count > 0: 271 | # For the last stock information widget, no need to add an underline 272 | main_page.get_child(-1).get_child(0).set_style_border_width(0, lv.PART.MAIN) 273 | menu.set_page(main_page) 274 | # Focus on the first stock information 275 | lv.group_focus_obj(main_page.get_child(0)) 276 | else: 277 | # No stock information? Display "No Data" message 278 | _scr.clean() 279 | tip_label = lv.label(_scr) 280 | tip_label.set_text("Not Data...") 281 | tip_label.set_style_text_font(lv.font_ascii_18, lv.PART.MAIN) 282 | tip_label.set_style_text_color(lv.color_hex(0xA7A7A7), lv.PART.MAIN) 283 | tip_label.center() 284 | 285 | async def on_start(): 286 | global _scr 287 | if not _scr: 288 | _scr = lv.obj() 289 | _scr.set_style_bg_color(lv.color_hex3(0x000), lv.PART.MAIN) 290 | _app_mgr.enter_root_page() 291 | lv.screen_load(_scr) 292 | 293 | # Check if network is connected 294 | if not net.connected(): 295 | _app_mgr.error( 296 | "Network Not Connected", 297 | "Please wait for the network to be fully connected before accessing the app.", 298 | confirm = False, cb=lambda res: asyncio.create_task(_app_mgr.exit())) 299 | return 300 | 301 | # Load App configuration 302 | if not _load_config(): return 303 | 304 | # Display "Loading stock information" message 305 | loading_label = lv.label(_scr) 306 | loading_label.set_text("Fetching Stocks...") 307 | loading_label.align(lv.ALIGN.BOTTOM_MID, 0, -20) 308 | loading_label.set_style_text_font(lv.font_ascii_22, lv.PART.MAIN) 309 | loading_label.set_style_text_color(lv.color_hex3(0xFFF), lv.PART.MAIN) 310 | 311 | # If stock information already exists, display it directly 312 | if _stock_details: await display_multiple_stocks() 313 | 314 | async def on_stop(): 315 | """Clean up the screen and leave the app when it stops.""" 316 | global _scr 317 | if _scr: 318 | _scr.clean() 319 | _scr.delete_async() 320 | _scr = None 321 | _app_mgr.leave_root_page() 322 | 323 | async def on_boot(apm): 324 | global _app_mgr 325 | _app_mgr = apm 326 | 327 | async def on_running_foreground(): 328 | """ 329 | Handle actions when the app is running in the foreground. 330 | """ 331 | global _last_updated, _stock_details 332 | # When no stock information is displayed, no need to update 333 | if _scr.get_child_count() < 1: return 334 | 335 | now = clocktime.now() 336 | # No need to update if network not connected / time not synchronized / no stock configuration 337 | if not net.connected() or now < 0 or not _stock_symbols: return 338 | # No need to update if time interval not reached 339 | if now - _last_updated < _STOCK_UPDATE_INTERVAL: return 340 | 341 | # Time interval reached, fetch stock information again 342 | _stock_details = await get_stock_details(_stock_symbols) 343 | # Redraw stock information 344 | await display_multiple_stocks() 345 | _last_updated = now 346 | -------------------------------------------------------------------------------- /sensor_app/ui_page/home.py: -------------------------------------------------------------------------------- 1 | import time 2 | import asyncio 3 | import lvgl as lv 4 | import peripherals 5 | from .. import base 6 | from .. import product 7 | from micropython import const 8 | 9 | # Display modes: single / dual / quad cards at once 10 | _MODE_SINGLE = const(1) 11 | _MODE_DUAL = const(2) 12 | _MODE_QUAD = const(4) 13 | 14 | # Duration (ms) to keep the focus style on a card after selection 15 | _FOCUS_DURATION = const(180000) 16 | 17 | # Screen resolution from peripherals 18 | _SCR_WIDTH, _SCR_HEIGHT = peripherals.screen.screen_resolution 19 | 20 | # Mapping of display mode to (card_size, alignments...) 21 | _CARD_MODE_STYLES = { 22 | _MODE_SINGLE: [(_SCR_WIDTH, _SCR_HEIGHT), [lv.ALIGN.CENTER]], 23 | _MODE_DUAL: [(_SCR_WIDTH, (_SCR_HEIGHT // 2) - 1), [lv.ALIGN.TOP_MID, lv.ALIGN.BOTTOM_MID]], 24 | _MODE_QUAD: [((_SCR_WIDTH // 2) - 1, (_SCR_HEIGHT // 2) - 1), [lv.ALIGN.TOP_LEFT, lv.ALIGN.TOP_RIGHT, lv.ALIGN.BOTTOM_LEFT, lv.ALIGN.BOTTOM_RIGHT]] 25 | } 26 | 27 | _scr = None # Main LVGL screen object 28 | _app_mgr = None # App manager instance 29 | _focus_time = 0 # Timestamp when a card was last focused 30 | _focus_index = 0 # Index of currently focused sensor 31 | _container = None # Container for sensor cards 32 | _devices_info = {} # {sensor_id: {"refresh": ts, "curr_info": {...}}} 33 | _selected_devices = [] # List of selected sensor configs 34 | _display_mode = _MODE_SINGLE # Number of cards shown at once 35 | 36 | def check_selected_device(): 37 | """ 38 | Ensure there is at least one sensor selected. 39 | Show an info window if the selection is empty. 40 | """ 41 | global _selected_devices 42 | config = _app_mgr.config() 43 | selected_devices = config.get("selected", []) 44 | if not selected_devices: 45 | asyncio.create_task(base.switch_page(base._PAGE_TIPS)) 46 | return False 47 | 48 | _selected_devices = selected_devices 49 | return True 50 | 51 | def sync_display_mode(): 52 | """ 53 | Synchronize `_display_mode` with app config. 54 | If not set, choose mode based on number of selected sensors. 55 | """ 56 | global _display_mode 57 | total = len(_selected_devices) 58 | config = _app_mgr.config() 59 | display_mode = config.get("display_mode", None) 60 | 61 | if display_mode not in _CARD_MODE_STYLES: 62 | # Choose single/dual/quad based on total count 63 | display_mode = _MODE_SINGLE if total < 2 else _MODE_QUAD if total > 2 else _MODE_DUAL 64 | if display_mode is not None: 65 | config["display_mode"] = display_mode 66 | _app_mgr.config(config) 67 | _display_mode = display_mode 68 | 69 | def get_display_sensors(): 70 | """ 71 | Get the sublist of sensors to render on current page, 72 | based on `_focus_index` and `_display_mode`. 73 | """ 74 | group_index = _focus_index % _display_mode 75 | start = _focus_index - group_index 76 | end = _focus_index + _display_mode - group_index 77 | return _selected_devices[start : end] 78 | 79 | def event_handler(e): 80 | """ 81 | Handle key and click events on the card container: 82 | - Left/Right: move focus and update styling 83 | - Click: switch to history page 84 | - Focused: ensure LVGL group is in editing mode 85 | """ 86 | global _focus_index, _focus_time 87 | e_code = e.get_code() 88 | if e_code == lv.EVENT.KEY: 89 | e_key = e.get_key() 90 | if e_key not in (lv.KEY.LEFT, lv.KEY.RIGHT): return 91 | # Remove previous focus style 92 | sensor_total = len(_selected_devices) 93 | last_group = _focus_index // _display_mode 94 | last_group_index = _focus_index % _display_mode 95 | _container.get_child(last_group_index).set_style_bg_color(lv.color_hex3(0x000), lv.PART.MAIN) 96 | # Compute new focus index 97 | _focus_index = (_focus_index + (-1 if e_key == lv.KEY.LEFT else 1)) % (sensor_total + 1) 98 | curr_group = _focus_index // _display_mode 99 | 100 | # If page changed, re-render all cards; else just update one 101 | if last_group != curr_group: asyncio.create_task(render_sensors()) 102 | else: _container.get_child(_focus_index % _display_mode).set_style_bg_color(lv.color_hex(0x5B5B5B), lv.PART.MAIN) 103 | _focus_time = time.ticks_ms() 104 | 105 | elif e_code == lv.EVENT.CLICKED: 106 | # On click: go to history page for focused sensor 107 | if _focus_index == len(_selected_devices): 108 | asyncio.create_task(base.switch_page(base._PAGE_TIPS)) 109 | else: 110 | sensor_id = _selected_devices[_focus_index]["sensor_id"] 111 | asyncio.create_task(base.switch_page(base._PAGE_HISTORY, sensor_id)) 112 | elif e_code == lv.EVENT.FOCUSED: 113 | lv_group = lv.group_get_default() 114 | if lv_group.get_focused() != e.get_target_obj(): return 115 | # If focused and not in edit mode, enable edit mode 116 | if not lv_group.get_editing(): lv_group.set_editing(True) 117 | 118 | async def flash_active_card(sensor_id): 119 | """ 120 | Temporarily highlight the card of the active sensor, 121 | then fade back after 2 seconds. 122 | """ 123 | global _focus_index 124 | if not _container: return 125 | 126 | selected_sensors = [s["sensor_id"] for s in _selected_devices] 127 | if sensor_id not in selected_sensors: return 128 | 129 | # Switch focus to the active sensor and re-render 130 | _focus_index = selected_sensors.index(sensor_id) 131 | await render_sensors() 132 | target = _container.get_child(_focus_index % _display_mode) 133 | target.set_style_bg_color(lv.color_hex(0xFF8C2E), 0) 134 | 135 | # Wait before restoring 136 | await asyncio.sleep(2) 137 | 138 | # Only clear highlight if still showing and unchanged 139 | display_sensors = [s["sensor_id"] for s in get_display_sensors()] 140 | if sensor_id not in display_sensors or not _container: return 141 | if not target.get_style_bg_color(lv.PART.MAIN).eq(lv.color_hex(0xFF8C2E)): return 142 | target.set_style_bg_color(lv.color_hex3(0x000), lv.PART.MAIN) 143 | 144 | async def render_sensors(focus_style=True): 145 | """ 146 | Render sensor cards in the container. 147 | - Create or clear cards based on `_display_mode`. 148 | - Call each product's `show_card` to populate content. 149 | """ 150 | global _focus_time, _devices_info 151 | if not _container: return 152 | 153 | # If number of children doesn't match display_mode, rebuild cards 154 | if _container.get_child_count() != _display_mode: 155 | _container.clean() 156 | 157 | obj_style = _CARD_MODE_STYLES[_display_mode] 158 | # Create empty card objects 159 | for index in range(_display_mode): 160 | s_card = lv.obj(_container) 161 | s_card.set_size(*obj_style[0]) 162 | s_card.set_scroll_dir(lv.DIR.VER) 163 | s_card.align(obj_style[1][index], 0, 0) 164 | s_card.set_style_radius(0, lv.PART.MAIN) 165 | s_card.set_style_pad_all(0, lv.PART.MAIN) 166 | s_card.remove_style(None, lv.STATE.PRESSED) 167 | s_card.remove_style(None, lv.STATE.FOCUS_KEY) 168 | s_card.set_style_border_width(0, lv.PART.MAIN) 169 | s_card.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) 170 | s_card.set_style_bg_color(lv.color_hex3(0x000), lv.PART.MAIN) 171 | 172 | 173 | # Reset device info map 174 | _devices_info = {} 175 | product_registry = product.get_product_registry() 176 | sensor_total = len(_selected_devices) 177 | group_index = _focus_index % _display_mode 178 | 179 | # Populate each card 180 | for index in range(_display_mode): 181 | s_card = _container.get_child(index) 182 | s_index = _focus_index + (index - group_index) 183 | 184 | # Apply focus background if needed 185 | bg_color = 0x5B5B5B if index == group_index and focus_style else 0x000000 186 | if _display_mode != _MODE_SINGLE: s_card.set_style_bg_color(lv.color_hex(bg_color), lv.PART.MAIN) 187 | 188 | # If beyond end, just clear the card 189 | if s_index == sensor_total: 190 | s_card.clean() 191 | tip_symbol = lv.label(s_card) 192 | tip_symbol.set_text("+") 193 | tip_symbol.set_style_text_font(lv.font_ascii_bold_48, lv.PART.MAIN) 194 | tip_symbol.set_style_text_align(lv.TEXT_ALIGN.CENTER, lv.PART.MAIN) 195 | 196 | tip_label = lv.label(s_card) 197 | tip_label.set_text("Add a new sensor") 198 | tip_label.set_width(_CARD_MODE_STYLES[_display_mode][0][0]) 199 | tip_label.set_style_text_font(lv.font_ascii_22, lv.PART.MAIN) 200 | tip_label.set_style_text_align(lv.TEXT_ALIGN.CENTER, lv.PART.MAIN) 201 | 202 | tip_label.align(lv.ALIGN.CENTER, 0, 22) 203 | tip_symbol.align_to(tip_label, lv.ALIGN.OUT_TOP_MID, 0, -5) 204 | continue 205 | 206 | elif s_index > sensor_total: 207 | s_card.clean() 208 | s_card.set_style_bg_color(lv.color_hex3(0x000), lv.PART.MAIN) 209 | continue 210 | 211 | try: 212 | sensor = _selected_devices[s_index] 213 | model = product_registry.get(sensor["product_name"], None) 214 | if not model: continue 215 | if not hasattr(model, "show_card"): continue 216 | 217 | # Record last refresh time and snapshot of current data 218 | _devices_info[sensor["sensor_id"]] = {"refresh": time.ticks_ms(), "curr_info": {}} 219 | if hasattr(model, "get_sensor_data"): _devices_info[sensor["sensor_id"]]["curr_info"] = model.get_sensor_data(sensor["sensor_id"]).copy() 220 | 221 | # Call the brand-specific drawing method 222 | await model.show_card(s_card, sensor, _display_mode) 223 | except Exception as e: 224 | print(f"show card fail.[{str(e)}]") 225 | 226 | # Update focus timestamp 227 | _focus_time = time.ticks_ms() 228 | 229 | async def show_home(): 230 | """ 231 | Build and display the home page: 232 | - Ensure there is at least one sensor. 233 | - Create the main container and optional dividing lines. 234 | - Render sensor cards without focus highlight initially. 235 | """ 236 | global _container, _focus_index 237 | total = len(_selected_devices) 238 | if total < 1: return 239 | 240 | # Clear previous screen if any 241 | if _scr: _scr.clean() 242 | 243 | # Reset focus index if out of bounds 244 | if _focus_index > total: _focus_index = 0 245 | 246 | # Sync display mode with settings 247 | sync_display_mode() 248 | 249 | # Create container for sensor cards 250 | _container = lv.obj(_scr) 251 | _container.remove_style(None, lv.PART.MAIN) 252 | _container.remove_style(None, lv.PART.SCROLLBAR) 253 | _container.set_size(_SCR_WIDTH, _SCR_HEIGHT) 254 | _container.set_style_border_width(0, lv.PART.MAIN) 255 | _container.set_scroll_snap_y(lv.SCROLL_SNAP.CENTER) 256 | _container.set_style_bg_opa(lv.OPA._0, lv.PART.MAIN) 257 | _container.align(lv.ALIGN.BOTTOM_MID, 0, lv.PART.MAIN) 258 | _container.add_event_cb(event_handler, lv.EVENT.ALL, None) 259 | 260 | # Draw horizontal divider for dual/quad modes 261 | if _display_mode % 2 == 0: 262 | line_points = [{"x": 0, "y": 119}, {"x": 320, "y": 119}] 263 | line = lv.line(_scr) 264 | line.set_style_line_width(2, 0) 265 | line.set_points(line_points, len(line_points)) # Set the points 266 | line.set_style_line_color(lv.color_hex(0xBBBBBB), 0) 267 | 268 | # Draw vertical divider for quad mode 269 | if _display_mode % 4 == 0: 270 | line_points = [{"x": 159, "y": 0}, {"x": 159, "y": 240}] 271 | line = lv.line(_scr) 272 | line.set_style_line_width(2, 0) 273 | line.set_points(line_points, len(line_points)) # Set the points 274 | line.set_style_line_color(lv.color_hex(0xBBBBBB), 0) 275 | 276 | # Initial render without focus highlight 277 | await render_sensors(False) 278 | 279 | # Add container to input group and enable edit mode 280 | lv.group_get_default().add_obj(_container) 281 | lv.group_focus_obj(_container) 282 | lv.group_get_default().set_editing(True) 283 | 284 | async def on_start(scr, app_mgr): 285 | """ 286 | Called when the app starts: 287 | - Store screen and app manager references. 288 | - Check for selected sensors. 289 | - Register flash callback for active state. 290 | - Show the home page. 291 | """ 292 | global _scr, _app_mgr 293 | _scr = scr 294 | _app_mgr = app_mgr 295 | 296 | # Ensure at least one sensor is selected 297 | if not check_selected_device(): return 298 | 299 | # Register callback to highlight active card 300 | for p_model in product.get_product_registry().values(): 301 | if hasattr(p_model, "set_active_state_callback"): 302 | p_model.set_active_state_callback(flash_active_card) 303 | 304 | await show_home() 305 | 306 | async def on_stop(): 307 | """ 308 | Called when the app stops: 309 | - Remove active-state callbacks. 310 | - Clean up screen and container. 311 | """ 312 | global _scr, _container 313 | 314 | for p_model in product.get_product_registry().values(): 315 | if hasattr(p_model, "set_active_state_callback"): 316 | p_model.set_active_state_callback(None) 317 | 318 | if _app_mgr: _app_mgr.leave_root_page() 319 | 320 | if _scr: 321 | _scr.clean() 322 | _scr = None 323 | _container = None 324 | 325 | async def on_running_foreground(): 326 | """ 327 | Periodically called when app is in foreground: 328 | - For each visible sensor card: 329 | * Check if data has changed or 60s passed. 330 | * If so, update the card via `show_card`. 331 | """ 332 | global _focus_time 333 | if not _container: return 334 | 335 | curr_time = time.ticks_ms() 336 | # Restore card background if focus highlight has expired 337 | if curr_time - _focus_time > _FOCUS_DURATION: 338 | d_color = lv.color_hex3(0x000) 339 | target = _container.get_child(_focus_index % _display_mode) 340 | if target.get_style_bg_color(lv.PART.MAIN) != d_color: target.set_style_bg_color(d_color, lv.PART.MAIN) 341 | _focus_time = curr_time 342 | 343 | # Update each displayed sensor card if needed 344 | for index, info in enumerate(get_display_sensors()): 345 | s_id = info["sensor_id"] 346 | s_info = _devices_info.get(s_id, {}) 347 | s_card = _container.get_child(index) 348 | s_model = product.get_product_registry().get(info["product_name"], None) 349 | if not s_info or not s_model or not s_card: continue 350 | 351 | try: 352 | # Prepare new data snapshot 353 | tmp_info = { 354 | "refresh": curr_time, 355 | "curr_info": {} 356 | } 357 | 358 | if hasattr(s_model, "get_sensor_data"): tmp_info["curr_info"] = s_model.get_sensor_data(s_id).copy() 359 | 360 | # Only re-render if data changed or 60s elapsed 361 | if tmp_info["curr_info"] == s_info["curr_info"]: 362 | if tmp_info["refresh"] - s_info["refresh"] < 60000: continue 363 | 364 | await s_model.show_card(s_card, info, _display_mode) 365 | _devices_info[s_id] = tmp_info 366 | except Exception as e: 367 | print(f"show card fail.[{str(e)}]") 368 | 369 | await asyncio.sleep_ms(300) 370 | --------------------------------------------------------------------------------