diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 1d3b367..c900688 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -20,7 +20,7 @@ jobs: with: python-version: '3.x' - name: Install dependencies - run: pip install mkdocs mkdocs-material + run: pip install mkdocs mkdocs-material markdown-include - name: Build site run: mkdocs build - name: Setup Pages diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e92c29d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# AGENTS.md + +This file provides guidance to agents when working with documentation in this repository. + +## Where the docs live + +This repository (`../docs/` relative to the MicroPythonOS code repo) is the public documentation site for MicroPythonOS. It is built with MkDocs. + +The source Markdown files are under `docs/`. `mkdocs.yml` at the repository root drives navigation and includes. + +## Included files + +Some Markdown files are intentionally included by other pages rather than listed directly in `mkdocs.yml`. Examples in `docs/os-development/`: + +- `compiling.md` is included by `linux.md` and `macos.md`. +- `running-on-desktop.md` is included by `linux.md` and `macos.md`. + +This is why `mkdocs build` warns "The following pages exist ... but are not included in the nav" for those files. Do not add them to `nav` unless explicitly requested. diff --git a/README.md b/README.md index 7417694..244a99e 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ These docs are generated (see build.sh or .github/workflows/deploy-docs.yml) and pushed to the gh-pages branch. + +https://docs.micropythonos.com/ diff --git a/build.sh b/build.sh index 1293a88..6328799 100755 --- a/build.sh +++ b/build.sh @@ -1,2 +1,2 @@ -pip3 install mkdocs mkdocs-material markdown-include +#pip3 install mkdocs mkdocs-material markdown-include mkdocs build diff --git a/docs/apps/app-lifecycle.md b/docs/apps/app-lifecycle.md new file mode 100644 index 0000000..fb52cf9 --- /dev/null +++ b/docs/apps/app-lifecycle.md @@ -0,0 +1,508 @@ +# App Lifecycle + +MicroPythonOS uses an Android-inspired Activity lifecycle model. Understanding this lifecycle is essential for building apps that behave correctly when users navigate between screens, switch apps, or return to the launcher. + +## Overview + +Every app in MicroPythonOS is built around **Activities**. An Activity represents a single screen with a user interface. The system manages a stack of activities and calls specific lifecycle methods as activities are created, started, paused, resumed, stopped, and destroyed. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Activity Lifecycle │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────┐ │ +│ │ onCreate │ ← Activity instantiated, build UI here │ +│ └────┬─────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────┐ │ +│ │ onStart │ ← Screen about to become visible │ +│ └────┬─────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────┐ │ +│ │ onResume │ ← Activity in foreground, user can interact │ +│ └────┬─────┘ │ +│ │ │ +│ │ ◄──────────────────────────────────────────┐ │ +│ │ │ │ +│ ▼ │ │ +│ ┌──────────┐ │ │ +│ │ onPause │ ← Another activity coming to front │ │ +│ └────┬─────┘ │ │ +│ │ │ │ +│ ▼ │ │ +│ ┌──────────┐ │ │ +│ │ onStop │ ← Activity no longer visible │ │ +│ └────┬─────┘ │ │ +│ │ │ │ +│ ├─────────────────────────────────────────────┘ │ +│ │ (user navigates back) │ +│ │ │ +│ ▼ │ +│ ┌───────────┐ │ +│ │ onDestroy │ ← Activity being removed from stack │ +│ └───────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Lifecycle Methods + +### onCreate() + +Called when the activity is first created. This is where you should: + +- Create your UI (LVGL widgets) +- Initialize instance variables +- Set up the content view with `setContentView()` + +```python +from mpos import Activity +import lvgl as lv + +class MyActivity(Activity): + def onCreate(self): + # Create the screen + screen = lv.obj() + + # Add UI elements + label = lv.label(screen) + label.set_text("Hello World!") + label.center() + + # Activate the screen + self.setContentView(screen) +``` + +**Important:** Always call `setContentView()` at the end of `onCreate()` to display your screen. + +### onStart(screen) + +Called when the activity is about to become visible. The `screen` parameter is the LVGL screen object you created. + +Use this for: + +- Preparing resources needed for display +- Starting animations that should play when the screen appears + +```python +def onStart(self, screen): + print("Activity is starting...") +``` + +### onResume(screen) + +Called when the activity enters the foreground and becomes interactive. This is called: + +- After `onStart()` when the activity first appears +- When returning to this activity from another activity (e.g., user presses back) + +Use this for: + +- Registering callbacks (network, sensors, etc.) +- Starting background tasks +- Refreshing data that may have changed + +```python +def onResume(self, screen): + super().onResume(screen) # Sets _has_foreground = True + + # Register for network changes + self.connectivity_manager = ConnectivityManager.get() + self.connectivity_manager.register_callback(self.on_network_change) + + # Refresh data + self.refresh_app_list() +``` + +**Important:** Call `super().onResume(screen)` to properly track foreground state. + +### onPause(screen) + +Called when the activity is about to lose focus (another activity is coming to the foreground). + +Use this for: + +- Unregistering callbacks +- Pausing ongoing operations +- Saving temporary state + +```python +def onPause(self, screen): + # Unregister callbacks + if self.connectivity_manager: + self.connectivity_manager.unregister_callback(self.on_network_change) + + super().onPause(screen) # Sets _has_foreground = False +``` + +**Important:** Call `super().onPause(screen)` to properly track foreground state. + +### onBackPressed(screen) + +Called when the user performs the back/close gesture (e.g. swiping from the left edge) **before** the activity is paused. This is the right place to ask the user for confirmation when there is unsaved work. + +Return `True` to consume the event and keep the activity in the foreground. In that case the activity is responsible for calling `finish()` later when it is ready to close. Return `False` (the default) to let the framework finish the activity normally. + +```python +def onBackPressed(self, screen): + if self._has_unsaved_changes(): + # Show a dialog; return True so the activity stays alive + self._show_exit_confirm() + return True + return False +``` + +In the dialog callback, call `self.finish()` to actually close the activity: + +```python +def _on_exit_confirmed(self, dialog): + dialog.close() + self._save_changes() + self.finish() +``` + +**Important:** `finish()` does **not** call `onBackPressed()` again, so your dialog callbacks can safely call it. + +### onStop(screen) + +Called when the activity is no longer visible (fully covered by another activity). + +Use this for: + +- Releasing resources that aren't needed while hidden +- Stopping expensive operations + +```python +def onStop(self, screen): + # Stop any expensive background work + self.cancel_pending_downloads() +``` + +### onDestroy(screen) + +Called when the activity is being removed from the activity stack (user navigated back past this activity). + +Use this for: + +- Final cleanup +- Releasing all resources + +```python +def onDestroy(self, screen): + # Clean up resources + self.cleanup_temp_files() +``` + +## Activity Stack + +MicroPythonOS maintains a stack of activities. When you start a new activity, it's pushed onto the stack. When the user navigates back, the top activity is popped and destroyed. + +``` +Activity Stack: +┌─────────────────┐ +│ SettingsDetail │ ← Top (visible, has focus) +├─────────────────┤ +│ Settings │ ← Paused, stopped +├─────────────────┤ +│ Launcher │ ← Paused, stopped +└─────────────────┘ +``` + +### Navigation Flow + +1. **Starting a new activity:** Current activity receives `onPause()` → `onStop()`, new activity receives `onCreate()` → `onStart()` → `onResume()` + +2. **Going back:** Framework first calls `onBackPressed()`. If it returns `True`, the activity stays foreground and must call `finish()` itself when ready. If it returns `False`, the current activity receives `onPause()` → `onStop()` → `onDestroy()`, and the previous activity receives `onResume()` + +## Starting Activities + +### Basic Navigation + +Use `startActivity()` to navigate to another activity: + +```python +from mpos import Intent + +class MyActivity(Activity): + def on_settings_click(self): + intent = Intent(activity_class=SettingsActivity) + self.startActivity(intent) +``` + +### Passing Data with Intents + +Use Intent extras to pass data between activities: + +```python +# Sending activity +def on_item_click(self, item_id): + intent = Intent(activity_class=DetailActivity) + intent.putExtra("item_id", item_id) + intent.putExtra("title", "Item Details") + self.startActivity(intent) + +# Receiving activity +class DetailActivity(Activity): + def onCreate(self): + intent = self.getIntent() + item_id = intent.extras.get("item_id") + title = intent.extras.get("title") + # Use the data... +``` + +### Getting Results from Activities + +Use `startActivityForResult()` when you need a result back: + +```python +class PhotoPickerActivity(Activity): + def on_gallery_click(self): + intent = Intent(activity_class=GalleryActivity) + self.startActivityForResult(intent, self.on_photo_selected) + + def on_photo_selected(self, result): + if result["result_code"] == "OK": + photo_path = result["data"].get("photo_path") + self.display_photo(photo_path) + +# In GalleryActivity +class GalleryActivity(Activity): + def on_photo_tap(self, photo_path): + self.setResult("OK", {"photo_path": photo_path}) + self.finish() +``` + +### Finishing an Activity + +Call `finish()` to close the current activity and return to the previous one: + +```python +def on_done_click(self): + # Optionally set a result first + self.setResult("OK", {"selected_item": self.selected}) + self.finish() +``` + +## Foreground State + +Activities track whether they're in the foreground. This is useful for: + +- Canceling operations when the user navigates away +- Avoiding UI updates when the activity isn't visible + +### Checking Foreground State + +```python +def on_data_loaded(self, data): + # Only update UI if we're still in the foreground + if self.has_foreground(): + self.update_list(data) +``` + +### Conditional Execution + +Use `if_foreground()` to execute code only when in foreground: + +```python +def on_download_complete(self, data): + self.if_foreground(self.show_download_result, data) +``` + +### Thread-Safe UI Updates + +When updating the UI from an asyncio task/coro, there's no need for special handling, as asyncio runs in the same thread as LVGL. +So that's the easiest way to do multitasking. + +But sometimes, you want to do an action in a completely new thread, created with, for example: + +``` + _thread.stack_size(TaskManager.good_stack_size()) + _thread.start_new_thread(self.background_task, (an_argument, another_one)) +``` + +Then, to update the LVGL UI from the other thread, use `update_ui_threadsafe_if_foreground()`: + +```python +def background_task(self): + # Running in a separate thread + result = self.fetch_data() + + # Safely update UI on main thread, only if in foreground + self.update_ui_threadsafe_if_foreground( + self.display_result, + result + ) +``` + +This will schedule the function `self.display_result(result)` to be executed from the LVGL thread, using `lv.async_call()`. +It will also check that the Activity is still running in the foreground, using `has_foreground()`. + +For this to work, the `onResume()` and `onPause()` functions need to run their super(), either implicitly (if you don't override them) or explicitly (if you do), as explained before. + +## Complete Example + +Here's a complete example showing proper lifecycle management: + +```python +import lvgl as lv +from mpos import Activity, ConnectivityManager, TaskManager + +class NetworkAwareActivity(Activity): + + def __init__(self): + super().__init__() + self.connectivity_manager = None + self.data = None + + def onCreate(self): + # Create UI + self.screen = lv.obj() + + self.status_label = lv.label(self.screen) + self.status_label.set_text("Loading...") + self.status_label.center() + + self.refresh_button = lv.button(self.screen) + self.refresh_button.align(lv.ALIGN.BOTTOM_MID, 0, -20) + self.refresh_button.add_event_cb( + lambda e: self.refresh_data(), + lv.EVENT.CLICKED, None + ) + btn_label = lv.label(self.refresh_button) + btn_label.set_text("Refresh") + + self.setContentView(self.screen) + + def onResume(self, screen): + super().onResume(screen) + + # Register for network changes + self.connectivity_manager = ConnectivityManager.get() + self.connectivity_manager.register_callback(self.on_network_change) + + # Initial data load + if self.connectivity_manager.is_online(): + self.refresh_data() + else: + self.status_label.set_text("Waiting for network...") + + def onPause(self, screen): + # Unregister callbacks + if self.connectivity_manager: + self.connectivity_manager.unregister_callback(self.on_network_change) + + super().onPause(screen) + + def on_network_change(self, online): + if online and self.has_foreground(): + self.refresh_data() + elif not online: + self.status_label.set_text("Network disconnected") + + def refresh_data(self): + self.status_label.set_text("Loading...") + TaskManager.create_task(self.fetch_data_async()) + + async def fetch_data_async(self): + try: + # Simulate network request + await TaskManager.sleep(1) + self.data = {"items": ["Item 1", "Item 2", "Item 3"]} + + # Update UI only if still in foreground + if self.has_foreground(): + self.status_label.set_text(f"Loaded {len(self.data['items'])} items") + except Exception as e: + if self.has_foreground(): + self.status_label.set_text(f"Error: {e}") +``` + +## Best Practices + +### Do's + +✅ Always call `super().__init__()` in your `__init__` method +✅ Call `super().onResume(screen)` and `super().onPause(screen)` to track foreground state +✅ Register callbacks in `onResume()` and unregister in `onPause()` +✅ Check `has_foreground()` before updating UI from async operations +✅ Use `setContentView()` at the end of `onCreate()` +✅ Clean up resources in `onDestroy()` +✅ Use `onBackPressed()` to ask before discarding unsaved changes + + +### Don'ts + +❌ Don't do heavy work in `onCreate()` - it blocks the UI +❌ Don't forget to unregister callbacks - causes memory leaks +❌ Don't update UI from background threads without `update_ui_threadsafe_if_foreground()` +❌ Don't assume the activity is still visible after async operations +❌ Don't store references to LVGL objects after `onDestroy()` +❌ Don't show back-navigation confirmation dialogs in `onPause()` - use `onBackPressed()` instead + + +## Services — Background Components + +Alongside Activities, MicroPythonOS provides **Services** for background work that runs without a user interface. Unlike Activities, Services have no screen lifecycle — they are created, started, and destroyed by intent actions (e.g., `boot_completed`). + +### Service Lifecycle + +``` +┌────────────┐ +│ onCreate │ ← One-time initialization +└─────┬──────┘ + │ + ▼ +┌────────────┐ +│ onStart │ ← Receive intent, begin work +└─────┬──────┘ + │ + ▼ +┌────────────┐ +│ onDestroy │ ← Cleanup +└────────────┘ +``` + +### Service vs. Activity Comparison + +| Method | Activity | Service | +|--------|----------|---------| +| `onCreate()` | Build UI, set up screen | Initialize resources | +| `onStart()` | Screen about to become visible | Start background work (receives Intent) | +| `onDestroy()` | Activity removed from stack | Stop threads, cancel tasks | + +### When to Use a Service + +- Starting WiFi auto-connect at boot +- Launching a background web server +- Running a periodic update check +- Starting an async REPL task + +Services are declared either programmatically (system services) or via `"services"` in the app's `MANIFEST.JSON`. See the [Service documentation](../frameworks/service.md) for a complete reference. + +## Lifecycle Comparison with Android + +| MicroPythonOS | Android | Notes | +|---------------|---------|-------| +| `onCreate()` | `onCreate()` | Same purpose | +| `onStart(screen)` | `onStart()` | MicroPythonOS passes screen | +| `onResume(screen)` | `onResume()` | MicroPythonOS passes screen | +| `onPause(screen)` | `onPause()` | MicroPythonOS passes screen | +| `onBackPressed(screen)` | `onBackPressed()` | MicroPythonOS passes screen; called before `onPause()` on back gesture | +| `onStop(screen)` | `onStop()` | MicroPythonOS passes screen | +| `onDestroy(screen)` | `onDestroy()` | MicroPythonOS passes screen | +| `finish()` | `finish()` | Same purpose | +| `startActivity(intent)` | `startActivity(intent)` | Same purpose | +| `startActivityForResult()` | `startActivityForResult()` | Callback-based in MicroPythonOS | +| `setResult()` | `setResult()` | Same purpose | +| `getIntent()` | `getIntent()` | Same purpose | + +## See Also + +- [Creating Apps](creating-apps.md) - Getting started with app development +- [AppManager](../frameworks/app-manager.md) - App management and launching +- [Intent System](../frameworks/app-manager.md#intent-resolution) - Intent-based navigation +- [TaskManager](../frameworks/task-manager.md) - Async task management +- [Service](../frameworks/service.md) - Background services for boot-time and long-running tasks diff --git a/docs/apps/appstore.md b/docs/apps/appstore.md index 55443f3..63cdf9f 100644 --- a/docs/apps/appstore.md +++ b/docs/apps/appstore.md @@ -2,14 +2,29 @@ The MicroPythonOS App Store allows users to download and install new apps to extend system functionality. -App discovery is currently done by downloading the app list from [apps.micropythonos.com](https://apps.micropythonos.com). +## Backends + +The AppStore app can pull apps from more than one source: + +- **BadgeHub.eu** — the community appstore for event badges and community-built apps at [badgehub.eu](https://badgehub.eu). On supported firmware builds this is the default backend. See [BadgeHub Apps](badgehub.md) for details on publishing there. +- **MicroPythonOS Apps** — the curated, manually reviewed app index at [apps.micropythonos.com](https://apps.micropythonos.com). + +Use the backend selector in the AppStore UI to switch between sources. ## Example Apps - **Hello World**: A sample app demonstrating basic functionality. - **Camera**: Captures images and scans QR codes. - **Image Viewer**: Displays images stored in `/data/images/`. -- **IMU**: Visualize data from the Intertial Measurement Unit, also known as the accellerometer. +- **IMU**: Visualize data from the Inertial Measurement Unit, also known as the accelerometer. +- **Nostr**: A decentralized chat client using the Nostr protocol, shipped as a Python package. +- **Sorter**: A puzzle game where you sort items into the right bins. +- **The Free Lantern Player**: A media player app. +- **Breakout**: A classic brick-breaking game that uses a native C extension module. + +## Image Viewer + +The **Image Viewer** app displays images stored in `/data/images/`. See [Supported File Formats](../other/supported-file-formats.md) for the list of image formats the OS can decode. ## Screenshots @@ -30,4 +45,4 @@ App discovery is currently done by downloading the app list from [apps.micropyth ## Developing Apps -Apps are written in MicroPython and installed in `/apps/`. See [Filesystem Layout](../architecture/filesystem.md) for the app directory structure. +Apps are written in MicroPython and installed in `/apps/`. See [Filesystem Layout](../architecture/filesystem.md) for the app directory structure, [Bundling Apps](bundling-apps.md) for packaging, and [Native C/C++ Apps](native-apps.md) for apps that need compiled extensions. diff --git a/docs/apps/badgehub.md b/docs/apps/badgehub.md new file mode 100644 index 0000000..4a45ee6 --- /dev/null +++ b/docs/apps/badgehub.md @@ -0,0 +1,56 @@ +# BadgeHub.eu Apps + +[BadgeHub.eu](https://badgehub.eu) is a community appstore for event badges and MicroPythonOS devices. MicroPythonOS ships with a BadgeHub backend in the AppStore app, so users can browse, install, and update BadgeHub-published apps directly on their device. + +## What is BadgeHub? + +BadgeHub hosts `.mpk` packages and exposes a JSON API that MicroPythonOS queries for app listings, project details, and downloadable releases. It is particularly useful for: + +- conference or camp badge apps +- community-built utilities +- distributing apps without going through the curated MicroPythonOS app index + +## Backend endpoints + +The BadgeHub backend uses API version 3: + +- `https://badgehub.eu/api/v3/project-summaries` — list of all published projects with summary metadata. +- `https://badgehub.eu/api/v3/projects/` — full project details, including releases, icons, and descriptions. + +Each project maps to one MicroPythonOS app package. The project's slug becomes the app identifier in the store. + +## Project metadata + +A project detail response contains fields such as: + +- `name` — display name in the AppStore. +- `description` / `long_description` — short and long descriptions. +- `icon_url` — URL to a 64×64 or larger app icon. +- `publisher` — author or organization. +- `releases` — list of published releases, each with a version and a download URL. +- `main_executable` — optional flag indicating the primary app executable for this project. + +The AppManager uses the latest release's download URL to fetch the `.mpk`. + +## Releasing an app on BadgeHub + +1. Build your `.mpk` following the [Bundling Apps](bundling-apps.md) guide. The top-level folder in the ZIP must match the app's `fullname` exactly. +2. Ensure the archive contains a valid `MANIFEST.JSON`. +3. If it's the first time, fill out the "Create Project" form on BadgeHub.eu after logging in +4. Make sure you select **`mpos_api_0`** under "Badge" to indicate your app is built for MicroPythonOS +5. Upload the `.mpk` to your BadgeHub project. +6. MicroPythonOS will detect the new release the next time the AppStore refreshes the BadgeHub backend. + +## Version handling + +Releases on BadgeHub should use [semantic versioning](https://semver.org/) strings (for example, `1.2.3`). The AppStore compares the installed version against the latest BadgeHub release and offers an update when the BadgeHub version is newer. + +## Switching backends in the AppStore app + +The AppStore app can switch between the curated MicroPythonOS backend and the BadgeHub backend. On supported firmware builds, BadgeHub is the default backend. Tap the backend selector in the AppStore UI to switch sources. + +## See also + +- [Bundling Apps](bundling-apps.md) — creating `.mpk` packages +- [Creating Apps](creating-apps.md) — writing an app from scratch +- [App Lifecycle](app-lifecycle.md) — `Activity` and `Service` lifecycle diff --git a/docs/apps/built-in-apps.md b/docs/apps/built-in-apps.md index 22255a1..0a13673 100644 --- a/docs/apps/built-in-apps.md +++ b/docs/apps/built-in-apps.md @@ -6,7 +6,8 @@ MicroPythonOS includes essential apps to bootstrap the system, located in [`/bui - **WiFi**: Configures WiFi connections. - **AppStore**: Downloads and installs new apps. - **OSUpdate**: Manages Over-The-Air (OTA) system updates. -- **Settings**: Configuration for MicroPythonOS +- **Settings**: Configuration for MicroPythonOS. +- **File Manager**: Browses the filesystem and opens files with the appropriate viewer. ## Screenshots diff --git a/docs/apps/bundling-apps.md b/docs/apps/bundling-apps.md index 62048e9..459463f 100644 --- a/docs/apps/bundling-apps.md +++ b/docs/apps/bundling-apps.md @@ -1,18 +1,79 @@ # Bundling Apps -To bundle your app in an .mpk file, just make an uncompressed "zip" file of it, without including the top-level `com.micropythonos.helloworld/` folder. +Apps are distributed as `.mpk` files. An `.mpk` is a ZIP archive (usually stored without compression to speed up installation on device) with a strict layout: -It's recommended to make the .mpk file deterministic by: -- setting the file timestamps to a fixed value -- sorting the files, so the order is fixed -- excluding extra file attributes and directories +- The **first entry** in the ZIP stream **must** be a top-level directory whose name matches the app's fullname exactly, followed by `/` (for example, `com.micropythonos.helloworld/`). +- That top-level directory **must** be the only top-level entry in the archive. +- All other files and subdirectories live under that single top-level directory. -For example: +So a valid archive looks like this in stream order: ``` -cd com.micropythonos.helloworld/ -find . -type f -exec touch -t 202501010000.00 {} \; # set fixed timestamp -find . -type f | sort | TZ=CET zip -X -r -0 /tmp/com.micropythonos.helloworld_0.0.2.mpk -@ # sort, -Xclude extra attributes, -recurse into directories and -0 compression +com.micropythonos.helloworld/ +com.micropythonos.helloworld/MANIFEST.JSON +com.micropythonos.helloworld/icon_64x64.png +com.micropythonos.helloworld/hello.py ``` -Take a look at [`scripts/bundleapps.sh`](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/scripts/bundleapps.sh) to see how the MicroPythonOS default apps are bundled. +MicroPythonOS validates this layout while extracting, and rejects packages that do not follow it. This ensures packages are unambiguous and can be streamed safely onto devices with limited storage. + +## Creating an .mpk + +From the parent directory that contains your app folder, create a ZIP that includes the top-level folder. +You can usually do that by just right-clicking the folder (like `com.example.yourapp`) in your file explorer and choosing "Add to .zip" or "Compress". + +Although you don't have to, it's recommended to make the `.mpk` deterministic by setting file timestamps to a fixed value, sorting entries, and excluding extra file attributes. That way, your .mpk will only change if the actual contents changed. Here's a script to do that: + +```bash +cd internal_filesystem/apps/ +find com.micropythonos.helloworld -exec touch -t 202501010000.00 {} \; +(find com.micropythonos.helloworld -type d; find com.micropythonos.helloworld -type f) | sort | TZ=CET zip -X -r -0 /tmp/com.micropythonos.helloworld_0.0.2.mpk -@ +``` + +This: + +- sorts directories before files +- uses `-0` for stored (uncompressed) entries +- uses `-X` to exclude extra file attributes +- places `com.micropythonos.helloworld/` as the first entry + +## Python packages + +An app can also be shipped as a proper Python package. This lets you split an app into many modules, use relative imports, and import shared code cleanly. + +To opt in, the app folder must contain an `__init__.py` file, and **every directory on the path to each entrypoint** must also contain an `__init__.py` (or compiled `__init__.mpy`). The AppManager then imports the entrypoint as part of the package. The module name becomes the package fullname dotted with the entrypoint path, for example `com_micropythonos_nostr.chat_list_activity`. + +Example `com_micropythonos_nostr` layout: + +``` +com_micropythonos_nostr/ +com_micropythonos_nostr/__init__.py +com_micropythonos_nostr/MANIFEST.JSON +com_micropythonos_nostr/icon_64x64.png +com_micropythonos_nostr/chat_list_activity.py +com_micropythonos_nostr/nostr_service.py +com_micropythonos_nostr/... +``` + +The `MANIFEST.JSON` still declares `chat_list_activity.py` as the entrypoint; the framework detects the `__init__.py` and loads it as a package automatically. + +Relative imports work as usual inside the package: + +```python +from . import nostr_service +from .nostr_service import NostrService +``` + +## Native modules + +Apps can include native C/C++ extension modules compiled as MicroPython `.native.mpy` files. Place the compiled `.mpy` in the app folder and import it like a normal module. For build instructions and an example, see [Native C/C++ Apps](native-apps.md). + +## AppStore bundling + +The apps at https://apps.MicroPythonOS.com are a curated, manually reviewed, vetted collection, often created and maintained by the MicroPythonOS core team. + +These are bundled into an [`app_index.json`](https://github.com/MicroPythonOS/apps/blob/main/app_index.json) using [`scripts/bundle_apps.sh`](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/scripts/bundle_apps.sh) and then pushed to the [apps repo](https://github.com/MicroPythonOS/apps). + +## BadgeHub.eu publishing + +For community and event-badge publishing, MicroPythonOS also supports the [BadgeHub.eu](https://badgehub.eu) appstore backend. See [BadgeHub Apps](badgehub.md) for how to publish an `.mpk` there. diff --git a/docs/apps/creating-apps.md b/docs/apps/creating-apps.md index 37e0eeb..f8763c3 100644 --- a/docs/apps/creating-apps.md +++ b/docs/apps/creating-apps.md @@ -12,21 +12,20 @@ Create the following file and folder structure: ``` com.micropythonos.helloworld/ -├── assets/ -│   └── hello.py -├── META-INF/ -│   └── MANIFEST.JSON -└── res/ - └── mipmap-mdpi/ - └── icon_64x64.png +├── MANIFEST.JSON +├── icon_64x64.png +└── hello.py ``` +This flat layout puts the required `MANIFEST.JSON` and app icon at the top level, alongside your Python files. Subfolders are still allowed if you want to organize larger apps, but **each directory costs about 8 KiB of storage in LittleFS**, so use them sparingly on device. + ## App code In `hello.py`, put: -``` -from mpos.apps import Activity +```python +import lvgl as lv +from mpos import Activity class Hello(Activity): @@ -44,18 +43,18 @@ The code above creates a new screen, adds a label, sets the label text, centers In `MANIFEST.JSON`, put: -``` +```json { -"name": "HelloWorld", -"publisher": "MicroPythonOS", -"short_description": "Minimal app", -"long_description": "Demonstrates the simplest app.", -"fullname": "com.micropythonos.helloworld", -"version": "0.0.2", -"category": "development", -"activities": [ + "name": "HelloWorld", + "publisher": "MicroPythonOS", + "short_description": "Minimal app", + "long_description": "Demonstrates the simplest app.", + "fullname": "com.micropythonos.helloworld", + "version": "0.0.2", + "category": "development", + "activities": [ { - "entrypoint": "assets/hello.py", + "entrypoint": "hello.py", "classname": "Hello", "intent_filters": [ { @@ -68,13 +67,96 @@ In `MANIFEST.JSON`, put: } ``` +### Services + +Apps can also declare **services** — background components that run at boot time with no user interface. Services are defined in the `"services"` array of the manifest: + +```json +"services": [ + { + "entrypoint": "my_boot_service.py", + "classname": "MyBootService", + "intent_filters": [ + { + "action": "boot_completed" + } + ] + } +] +``` + +Each service entry has: + +| Field | Description | +|-------|-------------| +| `entrypoint` | Path to the Python file (relative to the app root) | +| `classname` | Name of the `Service` subclass in that file | +| `intent_filters` | Array of `{ "action": "..." }` objects. Use `"boot_completed"` to run at startup | + +Services that subscribe to `"boot_completed"` are started automatically during system boot, after the launcher is displayed. See the [Service documentation](../frameworks/service.md) for details on writing and using services. + +## Handling the view action + +Apps can register themselves as file viewers by declaring an `intent_filter` with `action: "view"` and a `pathPattern` list in `MANIFEST.JSON`. This lets other apps (and the built-in `FileExplorerActivity`) open files with your app. + +### Manifest example + +```json +{ + "fullname": "com.example.imageviewer", + "name": "Image Viewer", + "version": "1.0.0", + "activities": [ + { + "entrypoint": "imageview.py", + "classname": "ImageView", + "intent_filters": [ + { "action": "main", "category": "launcher" }, + { "action": "view", "mimeType": "image/*", "pathPattern": [".png", ".jpg", ".jpeg"] } + ] + } + ] +} +``` + +`pathPattern` entries are matched case-insensitively against the file extension. A leading `*` is optional. `mimeType` is recorded for documentation but is not used for matching. + +### Receiving the file + +When the system opens your activity via the `view` action, the file path is available in `intent.data` and also in the `filename` extra: + +```python +from mpos import Activity + +class ImageView(Activity): + def onResume(self, screen): + super().onResume(screen) + path = self.getIntent().extras.get("filename") or self.getIntent().data + if path: + self.open_image(path) +``` + +### Opening a file from your app + +To open a file in whatever app is registered for it, send a `view` intent: + +```python +from mpos import Intent, Activity + +class MyActivity(Activity): + def on_file_click(self, path): + self.startActivity(Intent(action="view", data=path)) +``` + +If multiple apps can handle the file type, MicroPythonOS automatically shows an "Open with" chooser. + ## Icon -The icon is a [simple 64x64 pixel PNG image](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/builtin/apps/com.micropythonos.launcher/res/mipmap-mdpi/icon_64x64.png), which you can create with any tool, such as GIMP. +The icon is a simple 64x64 pixel PNG image named `icon_64x64.png` in the app root, which you can create with any tool, such as GIMP. It's recommended to keep it as small as possible by setting compression level to 9 and not storing any metadata such as background color, resolution, creation time, comments, Exif data, XMP data, thumbnail or color profile. -The size will be somewhere between 3 and 7KB. +The size will probably be somewhere between 3 and 10KB. ## Installing the App @@ -82,17 +164,18 @@ The app can be installed by copying the top-level folder `com.micropythonos.hell ### On Desktop -You probably already have a clone of the [internal_filesystem](https://github.com/MicroPythonOS/MicroPythonOS/tree/main/internal_filesystem) that you're using to run MicroPythonOS on desktop. - -Just copy or move your the top-level folder `com.micropythonos.helloworld/` (and its contents) to `internal_filesystem/apps/` and you're good to go! +If you are [running MicroPythonOS on desktop](../os-development/running-on-desktop.md) from a source checkout, copy or move the top-level folder `com.micropythonos.helloworld/` (and its contents) to `internal_filesystem/apps/` and you're good to go. ### On ESP32 On the ESP32, you can use MicroPython tools such as [mpremote.py](https://github.com/micropython/micropython/tree/master/tools/mpremote) to copy files and folders from-to your device using the MicroPython REPL. +It's also included in the MicroPythonOS repo at `./lvgl_micropython/lib/micropython/tools/mpremote/mpremote.py` + Then connect your device with a cable and install your app using: ``` +/path/to/mpremote.py mkdir :/apps /path/to/mpremote.py fs cp -r com.micropythonos.helloworld/ :/apps/ ``` @@ -106,8 +189,13 @@ If the app is installed into the /apps/ folder, it should show up in the launche You can also launch it manually by typing this in the MicroPython REPL: +```python +from mpos import AppManager +AppManager.start_app('com.micropythonos.helloworld') ``` -import mpos.apps; mpos.apps.start_app('apps/com.micropythonos.helloworld/') -``` +## Further reading + +Now that your first app works, you could start building apps using standard LVGL for MicroPython calls for the UI and the [MicroPythonOS frameworks](../architecture/frameworks.md). +But before you do, you might want to check out the [app lifecycle](app-lifecycle.md) to understand why we added an onCreate() and what other lifecycle functions are available. diff --git a/docs/apps/index.md b/docs/apps/index.md index eb75db6..fb2a926 100644 --- a/docs/apps/index.md +++ b/docs/apps/index.md @@ -3,4 +3,9 @@ MicroPythonOS is built around an app-centric model, with built-in apps for core functionality and an App Store for additional apps. - [Built-in Apps](built-in-apps.md): Core apps included with the OS. -- [App Store](appstore.md): Download and install new apps. +- [App Store](appstore.md): Download and install new apps, including BadgeHub.eu community apps. +- [Creating Apps](creating-apps.md): Write an app from scratch. +- [App Lifecycle](app-lifecycle.md): Activity and Service lifecycle. +- [Bundling Apps](bundling-apps.md): Package apps as `.mpk` files, including Python packages. +- [Native C/C++ Apps](native-apps.md): Add compiled native modules to an app. +- [BadgeHub Apps](badgehub.md): Publish apps on BadgeHub.eu. diff --git a/docs/apps/native-apps.md b/docs/apps/native-apps.md new file mode 100644 index 0000000..d1d2c47 --- /dev/null +++ b/docs/apps/native-apps.md @@ -0,0 +1,93 @@ +# Native C/C++ Apps + +Most MicroPythonOS apps are written in MicroPython, but apps can also include native extension modules written in C or C++. These modules are compiled to MicroPython `.native.mpy` files and bundled inside the app's `.mpk` like any other module. + +## When to use native code + +Use a native module when: + +- You need more CPU performance than pure MicroPython can provide (for example, a game loop or signal processing). +- You need to call C libraries that have no MicroPython equivalent. +- You want to reuse existing C/C++ code. + +For everything else, prefer plain MicroPython — native modules add build complexity and are architecture-specific. + +## How native modules work + +MicroPython supports [native machine code modules](https://docs.micropython.org/en/latest/develop/natmod.html) (often called *natmods*). A natmod is a self-contained `.mpy` file that contains compiled machine code and a MicroPython module descriptor. At runtime it is imported just like a `.py` file: + +```python +import breakout +breakout.start() +``` + +MicroPythonOS places the `.mpy` file in the app folder when the `.mpk` is installed. The AppManager's import path then finds it automatically. + +## Example: `com.micropythonos.breakout` + +The built-in **Breakout** game uses a native C module for the game logic. Its source lives in `c_mpos/breakout/` in the main repository. + +Project layout: + +``` +c_mpos/breakout/ +├── Makefile +├── build.sh +└── breakout.c +``` + +`Makefile`: + +```makefile +MPY_DIR = ../../lvgl_micropython/lib/micropython/ + +MOD = breakout +SRC = breakout.c +LINK_RUNTIME = 1 +ARCHES = x64 xtensawin + +ifeq ($(ARCH),) +.PHONY: all $(ARCHES) + +all: $(ARCHES) + +$(ARCHES): + $(MAKE) -f $(lastword $(MAKEFILE_LIST)) ARCH=$@ MOD=$(MOD)_$@ +else +include $(MPY_DIR)/py/dynruntime.mk +endif +``` + +This builds two artifacts: + +- `breakout_x64.native.mpy` — for the desktop simulator. +- `breakout_xtensawin.native.mpy` — for ESP32-S3 devices. + +The `build.sh` script sets up the Espressif toolchain and then copies the resulting `.mpy` files into the app's folder at `internal_filesystem/apps/com.micropythonos.breakout/`. + +## Bundling a native module + +1. Write your C/C++ source and a `Makefile` based on the MicroPython `dynruntime.mk` rules. +2. Build the module for every architecture you want to support. +3. Rename or place the output `.mpy` file in the app folder so its import name matches what your MicroPython code imports. For Breakout, the `xtensawin` build is named `breakout.mpy` inside the app folder. +4. Build the `.mpk` normally. The native `.mpy` is packaged alongside the Python files. + +``` +com.micropythonos.breakout/ +├── MANIFEST.JSON +├── icon_64x64.png +├── main.py +└── breakout.mpy # native module, imported by main.py +``` + +## Limitations + +- Native modules must be compiled separately for each target architecture (for example, `x64` for desktop and `xtensawin` for ESP32). +- The MicroPython natmod ABI can change between releases, so rebuild modules when the `lvgl_micropython` submodule is updated. +- Native code has the same filesystem and memory constraints as the rest of the app. + +## See also + +- [Bundling Apps](bundling-apps.md) — packaging apps as `.mpk` files +- [MicroPython Native Module Documentation](https://docs.micropython.org/en/latest/develop/natmod.html) +- [Creating Apps](creating-apps.md) — writing an app from scratch diff --git a/docs/architecture/boot-sequence.md b/docs/architecture/boot-sequence.md new file mode 100644 index 0000000..5d5010d --- /dev/null +++ b/docs/architecture/boot-sequence.md @@ -0,0 +1,27 @@ +# Boot sequence + +MicroPythonOS consists of several core components that initialize and manage the system. + +- **lvgl_micropython/lib/micropython/ports/esp32/modules/_boot.py**: attempts to mount the internal storage partition to / and if it fails, formats it + +- **internal_filesystem/main.py**: Hands off execution to /lib/mpos/main.py by importing it + +- **/lib/mpos/main.py**: + - Detects the hardware board + - Initializes the filesystem driver + - Mounts the freezefs into /builtin/ + - Loads the com.micropythonos.settings + - Initializes the user interface. + - Launches the `launcher` app that shows the icons + - Runs auto-start apps (`auto_start_app_early`, `auto_start_app`) + - Starts **boot services** — all services that subscribe to the `boot_completed` intent are instantiated and receive `onStart()`: + - `WifiBootService` — auto-connects WiFi in a background thread + - `WebServerBootService` — starts the HTTP web server + - `AIOReplService` — starts the asyncio REPL task + - App-specific services (e.g., `OSUpdateService`) + - Marks the current boot as successful (cancel rollback) + - Starts the TaskManager (asyncio event loop) + +See [Filesystem Layout](filesystem.md) for where apps and data are stored. + +See [Service](../frameworks/service.md) for details on writing boot services. diff --git a/docs/architecture/filesystem.md b/docs/architecture/filesystem.md index 3c7cffe..48fc7a6 100644 --- a/docs/architecture/filesystem.md +++ b/docs/architecture/filesystem.md @@ -2,15 +2,15 @@ MicroPythonOS uses a structured filesystem to organize apps, data, and resources. -- **/apps/**: Directory for downloaded and installed apps. +- **apps/**: Directory for downloaded and installed apps. - **com.micropythonos.helloworld/**: Installation directory for HelloWorld App. See [Creating Apps](../apps/creating-apps.md). -- **/builtin/**: Read-only filesystem compiled into the OS, mounted at boot by `main.py`. - - **apps/**: See [Built-in Apps](../apps/built-in-apps.md). +- **builtin/**: Read-only filesystem compiled into the OS, mounted at boot by `main.py`. + - **apps/**: See [Built-in Apps](../apps/built-in-apps.md). - **res/mipmap-mdpi/default_icon_64x64.png**: Default icon for apps without one. - **lib/**: Libraries and frameworks - **mpos/**: MicroPythonOS libraries and frameworks - **ui/**: MicroPythonOS User Interface libraries and frameworks -- **/data/**: Storage for app data. +- **data/**: Storage for app data. - **com.micropythonos.helloworld/**: App-specific storage (e.g., `config.json`) - **com.micropythonos.settings/**: Storage used by the built-in Settings App - **com.micropythonos.wifi/**: Storage used by the built-in WiFi App diff --git a/docs/architecture/frameworks.md b/docs/architecture/frameworks.md new file mode 100644 index 0000000..ce3c2fb --- /dev/null +++ b/docs/architecture/frameworks.md @@ -0,0 +1,518 @@ +# Frameworks + +MicroPythonOS provides a unified framework architecture for accessing system services. All frameworks follow a consistent, simple pattern that makes them easy to discover, use, and extend. + +## Importing from mpos + +**Apps should only import from the `mpos` module directly.** Importing from submodules (such as `mpos.ui`, `mpos.content`, etc.) is not necessary and should be avoided. + +All frameworks and utilities you need are available through the main `mpos` module. If you find yourself needing to import from a submodule, or if you would like a new framework to be created, please [create a GitHub issue](https://github.com/MicroPythonOS/MicroPythonOS/issues) describing your use case. + +### Correct Import Style + +Import directly from `mpos`: + +```python +from mpos import DisplayMetrics + +DisplayMetrics.width() +``` + +Or import the module and use it: + +```python +import mpos + +mpos.DisplayMetrics.width() +``` + +### Avoid Submodule Imports + +Do not import from submodules: + +```python +# ❌ Don't do this +from mpos.ui import DisplayMetrics +from mpos.content import AppManager +``` + +Instead, use the main `mpos` module which re-exports everything you need: + +```python +# ✅ Do this instead +from mpos import DisplayMetrics, AppManager +``` + +## Overview + +Frameworks are centralized services that provide access to system capabilities like audio, networking, camera, sensors, and task management. They follow a **singleton class pattern with class methods**, ensuring a predictable and discoverable API across the entire system. + +### Design Philosophy + +- **Simple**: Single pattern, no `.get()` calls, clear imports +- **Functional**: Supports all use cases (state management, async operations, callbacks) +- **Harmonized**: All frameworks work identically +- **Discoverable**: IDE autocomplete shows all available methods + +## Unified Framework Pattern + +All frameworks follow a **singleton pattern with class method delegation**. This provides a clean API where you call class methods directly without needing to instantiate or call `.get()`. + +```python +class MyFramework: + """Centralized service for [purpose].""" + + _instance = None # Singleton instance + + def __init__(self): + """Initialize singleton instance (called once).""" + if MyFramework._instance: + return + MyFramework._instance = self + # initialization logic + + @classmethod + def get(cls): + """Get or create the singleton instance.""" + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def method_name(self, *args, **kwargs): + """Instance methods (implementation).""" + # implementation + return result + +# Class method delegation (at module level) +_original_methods = {} +_methods_to_delegate = ['method_name'] + +for method_name in _methods_to_delegate: + _original_methods[method_name] = getattr(MyFramework, method_name) + +def _make_class_method(method_name): + """Create a class method that delegates to the singleton instance.""" + original_method = _original_methods[method_name] + + @classmethod + def class_method(cls, *args, **kwargs): + instance = cls.get() + return original_method(instance, *args, **kwargs) + + return class_method + +for method_name in _methods_to_delegate: + setattr(MyFramework, method_name, _make_class_method(method_name)) +``` + +### Key Characteristics + +| Aspect | Details | +|--------|---------| +| **Instantiation** | Singleton pattern - single instance created on first use | +| **Initialization** | Call `Framework.init()` once at startup (or auto-initialize on first use) | +| **State Management** | Instance variables stored in singleton instance | +| **API Access** | `Framework.method_name(...)` - class methods delegate to singleton | +| **Async Support** | Instance methods can be async (`async def`) | +| **Testing** | Easy to mock by replacing `_instance` | + +## Available Frameworks + +### AppManager +Manages app discovery, installation, launching, and version management. + +```python +from mpos import AppManager + +# Get all installed apps +apps = AppManager.get_app_list() + +# Start an app +AppManager.start_app("com.example.myapp") + +# Install from .mpk file +AppManager.install_mpk("/tmp/app.mpk", "apps/com.example.newapp") + +# Check for updates +if AppManager.is_update_available("com.example.myapp", "2.0.0"): + print("Update available") +``` + +### AppearanceManager +Manages visual appearance: light/dark mode, theme colors, UI dimensions, and LVGL styling. + +```python +from mpos import AppearanceManager + +# Initialize at startup +AppearanceManager.init(prefs) + +# Use anywhere +if AppearanceManager.is_light_mode(): + print("Light mode enabled") + +bar_height = AppearanceManager.get_notification_bar_height() +primary_color = AppearanceManager.get_primary_color() +``` + +### AudioManager +Manages audio playback and recording with a device registry. + +```python +from mpos import AudioManager + +# Register devices at startup +AudioManager.add(AudioManager.Output(name="speaker", kind="i2s", i2s_pins={"ws": 47, "sd": 16})) +AudioManager.add(AudioManager.Input(name="mic", kind="i2s", i2s_pins={"ws": 47, "sd_in": 15})) + +# Use anywhere +player = AudioManager.player(file_path="path/to/audio.wav") +player.start() +AudioManager.stop() +``` + +### DownloadManager +Handles HTTP downloads with session management. + +```python +from mpos import DownloadManager + +# Initialize at startup +DownloadManager.init() + +# Use anywhere +await DownloadManager.download_url("https://example.com/file.bin", "local_path") +``` + +### ConnectivityManager +Monitors network connectivity status. + +```python +from mpos import ConnectivityManager + +# Initialize at startup +ConnectivityManager.init() + +# Check connectivity +if ConnectivityManager.is_online(): + print("Connected to network") +``` + +### FileExplorerActivity +Reusable file browser and picker activity. Can browse the filesystem or return selected files to the caller via `startActivityForResult`. + +```python +from mpos import Intent, FileExplorerActivity, Activity + +class MyActivity(Activity): + def pick_file(self): + intent = Intent(action="pick_file") + intent.putExtra("path_pattern", [".wav"]) + self.startActivityForResult(intent, self.on_file_picked) +``` + +See [FileExplorerActivity](../frameworks/file-explorer-activity.md) for details. + +### Focus Highlight +Provides a single helper, `add_focus_highlight`, for drawing a focus border or background tint around any widget. It replaces the duplicated `FOCUSED`/`DEFOCUSED` handlers that used to exist in many apps. + +```python +from mpos import add_focus_highlight + +# Border mode (default) +add_focus_highlight(button, width=2) + +# Background mode (keep existing border intact) +add_focus_highlight(button, mode="bg") +``` + +See [Focus Highlight](../frameworks/focus.md) for details. + +### LightsManager +Controls NeoPixel RGB LEDs on supported hardware. Provides buffered LED control, predefined notification colors, and frame-based animation support. + +```python +from mpos import LightsManager + +LightsManager.set_all(0, 0, 255) +LightsManager.write() +``` + +See [LightsManager](../frameworks/lights-manager.md) for details. + +### CameraManager +Provides access to camera hardware. + +```python +from mpos import CameraManager + +# Initialize at startup +CameraManager.init() + +# Capture image +image_data = CameraManager.capture_photo() +``` + +### SensorManager +Manages sensor access (accelerometer, gyroscope, magnetometer, temperature). + +```python +from mpos import SensorManager + +# Initialize at startup +SensorManager.init() + +# Read sensor data +accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) +accel_data = SensorManager.read_sensor(accel) +``` + +### TaskManager +Manages async task creation and lifecycle. + +```python +from mpos import TaskManager + +# Create and track tasks +task = TaskManager.create_task(my_coroutine()) +TaskManager.sleep(seconds) +``` + +### SharedPreferences +Per-app configuration storage (exception to the pattern - instance-based). + +```python +from mpos import SharedPreferences + +# Create instance per app +prefs = SharedPreferences("com.example.myapp") + +# Store and retrieve values +prefs.set_string("key", "value") +value = prefs.get_string("key", default="default") +``` + +## Framework Initialization + +Frameworks should be initialized once at system startup in the board initialization file: + +```python +# In board/your_board.py +from mpos import AppearanceManager, AudioManager, DownloadManager, ConnectivityManager, CameraManager, SensorManager, TaskManager, AppManager + +def init_frameworks(): + """Initialize all frameworks.""" + AppManager.refresh_apps() # Discover all installed apps + AppearanceManager.init(prefs) # Requires SharedPreferences + # Register AudioManager devices for this board + AudioManager.add(AudioManager.Output(name="speaker", kind="i2s", i2s_pins={"ws": 47, "sd": 16})) + AudioManager.add(AudioManager.Input(name="mic", kind="i2s", i2s_pins={"ws": 47, "sd_in": 15})) + DownloadManager.init() + ConnectivityManager.init() + CameraManager.init() + SensorManager.init() + TaskManager.init() +``` + +**Note:** AppManager doesn't require explicit initialization - call `refresh_apps()` to discover apps at startup. + +## Creating New Frameworks + +When creating a new framework, follow this template: + +```python +""" +MyFramework - [Brief description of what this framework does] +""" + +class MyFramework: + """Centralized service for [purpose].""" + + _instance = None # Singleton instance + + def __init__(self): + """Initialize singleton instance.""" + if MyFramework._instance: + return + MyFramework._instance = self + # initialization logic + + @classmethod + def get(cls): + """Get or create the singleton instance.""" + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def init(self, **kwargs): + """ + Initialize the framework. + + Args: + **kwargs: Configuration options + + Returns: + bool: True if initialization successful + """ + # Perform initialization + self._config = kwargs + return True + + def is_available(self): + """Check if framework is available.""" + return hasattr(self, '_config') + + def your_method(self, arg1, arg2): + """ + Do something. + + Args: + arg1: First argument + arg2: Second argument + + Returns: + Result of operation + """ + if not self.is_available(): + raise RuntimeError("MyFramework not initialized") + + # Implementation + return result + +# Class method delegation (at module level) +_original_methods = {} +_methods_to_delegate = ['init', 'is_available', 'your_method'] + +for method_name in _methods_to_delegate: + _original_methods[method_name] = getattr(MyFramework, method_name) + +def _make_class_method(method_name): + """Create a class method that delegates to the singleton instance.""" + original_method = _original_methods[method_name] + + @classmethod + def class_method(cls, *args, **kwargs): + instance = cls.get() + return original_method(instance, *args, **kwargs) + + return class_method + +for method_name in _methods_to_delegate: + setattr(MyFramework, method_name, _make_class_method(method_name)) +``` + +### Checklist for New Frameworks + +- [ ] Implement singleton pattern with `_instance` class variable +- [ ] Implement `__init__()` to initialize singleton +- [ ] Implement `get()` classmethod to get/create singleton +- [ ] Implement `init()` instance method for initialization +- [ ] Implement `is_available()` instance method to check status +- [ ] All public methods are instance methods +- [ ] Add class method delegation at module level +- [ ] Add docstrings to all methods +- [ ] Import in `mpos/__init__.py` +- [ ] Add to board initialization files +- [ ] Write tests in `MicroPythonOS/tests/` + +## Import Pattern + +All frameworks are imported consistently as classes from the main `mpos` module: + +```python +from mpos import AppearanceManager, AudioManager, CameraManager, ConnectivityManager, DownloadManager, SensorManager, SharedPreferences, TaskManager, WifiService, AppManager + +# Then use class methods directly (no .get() needed) +AppearanceManager.init(prefs) +player = AudioManager.player(file_path="music.wav") +player.start() +accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) +SensorManager.read_sensor(accel) +``` + +**Note:** Some frameworks like `AudioManager` and `SensorManager` use singleton patterns internally, but the API is the same - call class methods directly without needing to call `.get()`. + +## Benefits of Harmonization + +| Aspect | Before | After | +|--------|--------|-------| +| **API Consistency** | 5 different patterns | 1 unified pattern | +| **Learning Curve** | High (multiple patterns) | Low (single pattern) | +| **Import Clarity** | Mixed class/module imports | All consistent class imports | +| **IDE Support** | Partial autocomplete | Full autocomplete | +| **Testing** | Varies by framework | Consistent mocking approach | +| **Documentation** | Per-framework docs | Single pattern reference | +| **Onboarding** | Confusing for new developers | Clear and straightforward | + +## Exception: SharedPreferences + +`SharedPreferences` is intentionally instance-based rather than a singleton class method framework because: + +1. **Per-app isolation**: Each app needs its own configuration namespace +2. **Multiple instances**: Different apps may need different preference stores +3. **Flexibility**: Apps can create multiple preference instances if needed + +```python +# Each app creates its own instance +prefs = SharedPreferences("com.example.myapp") +prefs.set_string("theme", "dark") +``` + +## Best Practices + +### Do's + +✅ Call `Framework.init()` once at system startup +✅ Use class methods directly (they delegate to singleton) +✅ Check `is_available()` before using framework +✅ Store state in singleton instance variables +✅ Document initialization requirements +✅ Write tests for framework behavior +✅ Use class method delegation pattern for clean API + +### Don'ts + +❌ Create multiple instances of framework classes +❌ Call `.get()` directly in app code (it's internal) +❌ Mix instance methods with class methods in public API +❌ Store framework state in global variables +❌ Skip initialization in board files +❌ Use different patterns for different frameworks + +## Testing Frameworks + +Frameworks are easy to test due to their singleton pattern: + +```python +import unittest +from mpos import MyFramework + +class TestMyFramework(unittest.TestCase): + def setUp(self): + """Reset framework state before each test.""" + MyFramework._instance = None # Reset singleton + + def test_initialization(self): + """Test framework initialization.""" + self.assertFalse(MyFramework.is_available()) + MyFramework.init() + self.assertTrue(MyFramework.is_available()) + + def test_method(self): + """Test framework method.""" + MyFramework.init() + result = MyFramework.your_method("arg1", "arg2") + self.assertEqual(result, expected_value) + + def tearDown(self): + """Clean up after each test.""" + MyFramework._instance = None # Reset singleton +``` + +See [Automated Testing](../os-development/automated-testing.md) for more info. + +## See Also + +- [AppManager](../frameworks/app-manager.md): App discovery, installation, and launching +- [Architecture Overview](overview.md): High-level design principles +- [App Development Guide](../apps/index.md): How to use frameworks in apps diff --git a/docs/architecture/index.md b/docs/architecture/index.md deleted file mode 100644 index 1ae661f..0000000 --- a/docs/architecture/index.md +++ /dev/null @@ -1,7 +0,0 @@ -# Architecture - -Learn about the design and structure of MicroPythonOS, inspired by Android’s "thin" OS model. - -- [Overview](overview.md): High-level architecture and design principles. -- [System Components](system-components.md): Key files and their roles. -- [Filesystem Layout](filesystem.md): Directory structure for apps and data. diff --git a/docs/architecture/intents.md b/docs/architecture/intents.md new file mode 100644 index 0000000..516ca7d --- /dev/null +++ b/docs/architecture/intents.md @@ -0,0 +1,1034 @@ +# Intents in MicroPythonOS + +## Overview + +Intents are a core messaging mechanism in MicroPythonOS that enable inter-activity communication and app navigation. Inspired by Android's Intent system, they provide a flexible, decoupled way for activities to communicate and launch other activities without direct dependencies. + +An Intent is essentially a request to perform an action. It can be: + +- **Explicit**: Directly targeting a specific activity class +- **Implicit**: Specifying an action that the system resolves to one or more activities + +Intents are the primary mechanism for: + +- Navigating between activities +- Passing data between activities +- Receiving results from launched activities +- Enabling app extensibility through action-based routing + +## Intent Types + +### Explicit Intents + +An explicit intent directly specifies the target activity class. Use explicit intents when you know exactly which activity should handle the request. + +**Characteristics:** + +- Direct activity targeting +- Predictable behavior +- Used for internal app navigation +- No ambiguity in routing + +**Example:** + +```python +from mpos import Intent, Activity + +class HomeActivity(Activity): + def on_settings_click(self): + # Create explicit intent targeting SettingsActivity + intent = Intent(activity_class=SettingsActivity) + self.startActivity(intent) + +class SettingsActivity(Activity): + def onCreate(self): + screen = lv.obj() + label = lv.label(screen) + label.set_text("Settings") + self.setContentView(screen) +``` + +### Implicit Intents + +An implicit intent specifies an action without naming a specific activity. The system resolves which activity (or activities) can handle the action. + +**Characteristics:** + +- Action-based routing +- Extensible across apps +- Multiple handlers possible +- Automatic chooser UI for multiple handlers + +**Example:** + +```python +from mpos import Intent, AppManager + +# App 1: Register handler for SEND action +AppManager.register_activity("android.intent.action.SEND", ShareActivity) + +# App 2: Register another handler for SEND action +AppManager.register_activity("android.intent.action.SEND", EmailActivity) + +# Sending activity: Create implicit intent +class HomeActivity(Activity): + def on_share_click(self): + intent = Intent(action="android.intent.action.SEND") + intent.putExtra("content", "Check this out!") + self.startActivity(intent) + # System shows ChooserActivity if multiple handlers exist + # Or launches directly if only one handler +``` + +### File-type intents and the view action + +A common implicit intent is the `view` action, which asks the system to open a file with the most appropriate app. Apps declare which file types they can open by adding an `intent_filter` with `action: "view"` and a `pathPattern` list to their manifest. `pathPattern` entries are matched case-insensitively against the file extension; a leading `*` is optional. + +**Example manifest declaring an image viewer:** + +```json +{ + "fullname": "com.example.imageviewer", + "name": "Image Viewer", + "version": "1.0.0", + "activities": [ + { + "entrypoint": "imageview.py", + "classname": "ImageView", + "intent_filters": [ + { "action": "main", "category": "launcher" }, + { "action": "view", "mimeType": "image/*", "pathPattern": [".png", ".jpg", ".jpeg", ".raw"] } + ] + } + ] +} +``` + +**Opening a file from another app:** + +```python +from mpos import Intent, Activity + +class GalleryActivity(Activity): + def on_photo_selected(self, path): + self.startActivity(Intent(action="view", data=path)) +``` + +**Receiving a file in the target app:** + +```python +from mpos import Activity + +class ImageView(Activity): + def onResume(self, screen): + path = self.getIntent().extras.get("filename") or self.getIntent().data + if path: + self.load_image(path) +``` + +When a `view` intent is fired: + +1. `AppManager.resolve_activity()` looks for installed apps whose manifest `pathPattern` matches the file path. +2. If one or more specific handlers match, those handlers are returned. +3. If multiple handlers match, `ChooserActivity` shows an "Open with" dialog. +4. If no specific handler matches, the system falls back to generic handlers registered for `view`, such as the framework's built-in `ViewActivity`. + +The framework `ViewActivity` provides a last-resort preview for unknown files by reading and displaying the first 512 bytes as text. + +## Intent Class Reference + +### Constructor + +```python +Intent(activity_class=None, action=None, data=None, extras=None) +``` + +**Parameters:** + +- `activity_class` (class, optional): Target activity class for explicit intents +- `action` (str, optional): Action identifier for implicit intents (e.g., `"android.intent.action.SEND"`) +- `data` (any, optional): Primary data payload (single item) +- `extras` (dict, optional): Additional key-value data + +**Example:** + +```python +# Explicit intent +intent1 = Intent(activity_class=DetailActivity) + +# Implicit intent +intent2 = Intent(action="android.intent.action.SEND") + +# With data +intent3 = Intent(activity_class=DetailActivity, data="file.txt") + +# With extras +intent4 = Intent(activity_class=DetailActivity, extras={"id": 123}) +``` + +### Attributes + +| Attribute | Type | Purpose | Example | +|-----------|------|---------|---------| +| `activity_class` | class or None | Target activity for explicit intents | `SettingsActivity` | +| `action` | str or None | Action identifier for implicit intents | `"android.intent.action.SEND"` | +| `data` | any or None | Primary data payload | URL, file path, or any object | +| `extras` | dict | Additional key-value data | `{"item_id": 42, "title": "Details"}` | +| `flags` | dict | Navigation behavior modifiers | `{"clear_top": True, "no_animation": False}` | + +### Methods + +#### `putExtra(key, value)` + +Add a key-value pair to the extras dictionary. Supports method chaining. + +**Parameters:** + +- `key` (str): Key name +- `value` (any): Value (any Python object) + +**Returns:** `self` (for method chaining) + +**Example:** + +```python +intent = Intent(activity_class=DetailActivity) +intent.putExtra("item_id", 123) +intent.putExtra("title", "Item Details") +intent.putExtra("data", {"nested": "object"}) + +# Or use method chaining +intent = Intent(activity_class=DetailActivity) \ + .putExtra("item_id", 123) \ + .putExtra("title", "Item Details") +``` + +#### `addFlag(flag, value=True)` + +Add or modify navigation flags. Supports method chaining. + +**Parameters:** + +- `flag` (str): Flag name +- `value` (bool, optional): Flag value (default: True) + +**Returns:** `self` (for method chaining) + +**Supported Flags:** + +- `"clear_top"`: Clear all activities above the target +- `"no_history"`: Don't add to activity stack +- `"no_animation"`: Disable transition animation + +**Example:** + +```python +intent = Intent(activity_class=DetailActivity) +intent.addFlag("clear_top", True) +intent.addFlag("no_animation", False) + +# Or use method chaining +intent = Intent(activity_class=DetailActivity) \ + .addFlag("clear_top") \ + .addFlag("no_animation", False) +``` + +## Intent Lifecycle + +### Complete Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Intent Execution Flow │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Create Intent │ +│ ├─ Explicit: Intent(activity_class=TargetActivity) │ +│ └─ Implicit: Intent(action="com.example.ACTION") │ +│ │ +│ 2. Add Data (Optional) │ +│ ├─ putExtra("key", value) │ +│ ├─ addFlag("flag_name", value) │ +│ └─ Set data attribute │ +│ │ +│ 3. Start Activity │ +│ ├─ startActivity(intent) → Fire and forget │ +│ └─ startActivityForResult(intent, callback) → Expect result│ +│ │ +│ 4. ActivityNavigator Processing │ +│ ├─ Validate Intent type │ +│ ├─ If implicit: Resolve activity via AppManager │ +│ │ ├─ Single handler: Launch directly │ +│ │ ├─ Multiple handlers: Show ChooserActivity │ +│ │ └─ No handlers: Print warning, return │ +│ └─ If explicit: Launch directly │ +│ │ +│ 5. Activity Launch │ +│ ├─ Instantiate activity class │ +│ ├─ Set activity.intent = intent │ +│ ├─ Set activity._result_callback (if startActivityForResult) +│ ├─ Call activity.onCreate() │ +│ └─ Return activity instance │ +│ │ +│ 6. Receiving Activity │ +│ ├─ getIntent() → Retrieve Intent object │ +│ ├─ intent.extras.get("key") → Access data │ +│ └─ setResult() + finish() → Return result (if needed) │ +│ │ +│ 7. Result Callback (if startActivityForResult) │ +│ ├─ Activity calls finish() │ +│ ├─ Result callback invoked with result dict │ +│ └─ Callback receives: {"result_code": code, "data": dict} │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Activity Stack Evolution + +``` +Initial State: +┌─────────────┐ +│ Launcher │ ← Foreground +└─────────────┘ + +After startActivity(SettingsActivity): +┌─────────────┐ +│ Settings │ ← Foreground (onCreate, onStart, onResume called) +├─────────────┤ +│ Launcher │ ← Background (onPause, onStop called) +└─────────────┘ + +After startActivity(DetailActivity): +┌─────────────┐ +│ Detail │ ← Foreground (onCreate, onStart, onResume called) +├─────────────┤ +│ Settings │ ← Background (onPause, onStop called) +├─────────────┤ +│ Launcher │ ← Background +└─────────────┘ + +After finish() in DetailActivity: +┌─────────────┐ +│ Settings │ ← Foreground (onResume called) +├─────────────┤ +│ Launcher │ ← Background +└─────────────┘ +(DetailActivity destroyed, onDestroy called) +``` + +## ActivityNavigator - Intent Routing Engine + +The `ActivityNavigator` is the core routing engine that processes intents and launches activities. It handles both explicit and implicit intent resolution. + +**Location:** [`MicroPythonOS/internal_filesystem/lib/mpos/activity_navigator.py`](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/lib/mpos/activity_navigator.py) + +### `startActivity(intent)` + +Launch an activity without expecting a result. + +**Parameters:** + +- `intent` (Intent): The intent to process + +**Behavior:** + +- Validates intent type +- For implicit intents: resolves handlers via AppManager +- For explicit intents: launches directly +- Shows ChooserActivity if multiple handlers exist + +**Example:** + +```python +from mpos import Intent, ActivityNavigator + +intent = Intent(activity_class=SettingsActivity) +ActivityNavigator.startActivity(intent) + +# Or from within an activity +class HomeActivity(Activity): + def on_settings_click(self): + intent = Intent(activity_class=SettingsActivity) + self.startActivity(intent) # Delegates to ActivityNavigator +``` + +### `startActivityForResult(intent, result_callback)` + +Launch an activity and receive a result via callback. + +**Parameters:** + +- `intent` (Intent): The intent to process +- `result_callback` (callable): Function to invoke when activity finishes + +**Callback Signature:** + +```python +def result_callback(result): + # result is a dict with: + # - "result_code": str (e.g., "OK", "CANCEL") + # - "data": dict (result data) + pass +``` + +**Example:** + +```python +from mpos import Intent, ActivityNavigator + +def on_photo_selected(result): + if result["result_code"] == "OK": + photo_path = result["data"].get("photo_path") + print(f"Selected photo: {photo_path}") + +intent = Intent(activity_class=GalleryActivity) +ActivityNavigator.startActivityForResult(intent, on_photo_selected) + +# Or from within an activity +class HomeActivity(Activity): + def on_gallery_click(self): + intent = Intent(activity_class=GalleryActivity) + self.startActivityForResult(intent, self.on_photo_selected) + + def on_photo_selected(self, result): + if result["result_code"] == "OK": + photo_path = result["data"].get("photo_path") +``` + +## AppManager Intent Resolution + +The `AppManager` maintains a registry of activities and their associated actions, enabling implicit intent resolution. Resolution works for both programmatically registered handlers and handlers declared in app manifests. + +**Location:** [`MicroPythonOS/internal_filesystem/lib/mpos/content/app_manager.py`](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/lib/mpos/content/app_manager.py) + +### HandlerInfo + +`resolve_activity()` returns a list of `HandlerInfo` objects rather than raw activity classes: + +| Attribute | Type | Purpose | +|-----------|------|---------| +| `activity_class` | `type` | The `Activity` subclass that can handle the intent. | +| `app_fullname` | `str` or `None` | The owning app's fullname for manifest-declared handlers, or `None` for framework handlers. | + +Use `handler.activity_class` when inspecting a resolved handler and `handler.app_fullname` when you need to know which installed app provided it. + +### `register_activity(action, activity_cls)` + +Register an activity programmatically to handle a specific action. This is useful for framework activities and dynamic handlers. + +**Parameters:** + +- `action` (str): Action identifier (e.g., `"android.intent.action.SEND"`) +- `activity_cls` (type): Activity class + +**Example:** + +```python +from mpos import AppManager + +# Register handlers for SEND action +AppManager.register_activity("android.intent.action.SEND", ShareActivity) +AppManager.register_activity("android.intent.action.SEND", EmailActivity) +``` + +### Manifest-based file handlers + +Apps declare file-type handlers in `MANIFEST.JSON`. When an implicit intent carries a string `data` payload (usually a file path), MicroPythonOS preferentially returns handlers whose `pathPattern` matches the path. + +```json +{ + "activities": [ + { + "entrypoint": "player.py", + "classname": "Player", + "intent_filters": [ + { "action": "view", "mimeType": "audio/wav", "pathPattern": [".wav"] } + ] + } + ] +} +``` + +- `pathPattern` may be a single string or a list of strings. +- Matching is case-insensitive and suffix-based; `"*.wav"` and `".wav"` are equivalent. +- `mimeType` is recorded but is not currently used for matching. + +### `resolve_activity(intent)` + +Find all handlers that can handle an intent's action, with file-type preference when `intent.data` is a path. + +**Parameters:** + +- `intent` (Intent): Intent with `action` attribute + +**Returns:** List of `HandlerInfo` objects (empty if no handlers) + +**Resolution order:** + +1. If `intent.data` is a string and one or more manifest file handlers match its extension, return only those handlers. +2. If no file handler matches, return all generic handlers registered for the action. +3. If the list contains multiple handlers, the framework shows `ChooserActivity`. + +**Example:** + +```python +from mpos import Intent, AppManager + +intent = Intent(action="view", data="/data/audio/song.wav") +handlers = AppManager.resolve_activity(intent) + +for handler in handlers: + print(f"Handler: {handler.activity_class.__name__}") + if handler.app_fullname: + print(f" from app: {handler.app_fullname}") +``` + +### `query_intent_activities(intent)` + +Android-compatible alias for `resolve_activity()`. Identical behavior. + +**Parameters:** + +- `intent` (Intent): Intent object with `action` attribute + +**Returns:** List of `HandlerInfo` objects + +**Example:** + +```python +from mpos import Intent, AppManager + +intent = Intent(action="view", data="/data/notes.txt") +handlers = AppManager.query_intent_activities(intent) +``` + +## Common Patterns + +### Pattern 1: Simple Navigation + +Navigate to a known activity within the app using an explicit intent. + +```python +from mpos import Intent, Activity + +class HomeActivity(Activity): + def on_settings_click(self): + intent = Intent(activity_class=SettingsActivity) + self.startActivity(intent) + +class SettingsActivity(Activity): + def onCreate(self): + screen = lv.obj() + label = lv.label(screen) + label.set_text("Settings") + self.setContentView(screen) +``` + +**Key Points:** + +- Direct activity targeting +- No data passing needed +- Fire-and-forget navigation + +--- + +### Pattern 2: Data Passing with Extras + +Pass data to another activity using intent extras. + +```python +from mpos import Intent, Activity + +class ListActivity(Activity): + def on_item_click(self, item_id, title): + intent = Intent(activity_class=DetailActivity) + intent.putExtra("item_id", item_id) + intent.putExtra("title", title) + self.startActivity(intent) + +class DetailActivity(Activity): + def onCreate(self): + intent = self.getIntent() + item_id = intent.extras.get("item_id") + title = intent.extras.get("title") + + screen = lv.obj() + label = lv.label(screen) + label.set_text(f"{title} (ID: {item_id})") + self.setContentView(screen) +``` + +**Key Points:** +- Use `putExtra()` for method chaining +- Access via `intent.extras.get(key)` +- Supports any Python object + +--- + +### Pattern 3: Result Handling with Callbacks + +Get data back from another activity using `startActivityForResult()`. + +```python +from mpos import Intent, Activity + +class PhotoEditorActivity(Activity): + def on_gallery_click(self): + intent = Intent(activity_class=GalleryActivity) + self.startActivityForResult(intent, self.on_photo_selected) + + def on_photo_selected(self, result): + if result["result_code"] == "OK": + photo_path = result["data"].get("photo_path") + self.display_photo(photo_path) + elif result["result_code"] == "CANCEL": + print("User cancelled photo selection") + + def display_photo(self, photo_path): + # Display the selected photo + pass + +class GalleryActivity(Activity): + def on_photo_tap(self, photo_path): + self.setResult("OK", {"photo_path": photo_path}) + self.finish() + + def on_cancel_click(self): + self.setResult("CANCEL", {}) + self.finish() +``` + +**Key Points:** +- Callback receives `{"result_code": code, "data": dict}` +- Must call `setResult()` before `finish()` +- Callback only invoked if result is set + +--- + +### Pattern 4: Implicit Intent with Action + +Allow multiple apps to handle an action using implicit intents. + +```python +from mpos import Intent, AppManager, Activity + +# In App 1: Register handler +AppManager.register_activity("android.intent.action.SEND", ShareActivity) + +# In App 2: Register handler +AppManager.register_activity("android.intent.action.SEND", EmailActivity) + +# Sending activity +class HomeActivity(Activity): + def on_share_click(self): + intent = Intent(action="android.intent.action.SEND") + intent.putExtra("content", "Check this out!") + self.startActivity(intent) + # If multiple handlers: ChooserActivity shown + # If single handler: Launched directly + # If no handlers: Warning printed +``` + +**Key Points:** +- Action-based routing +- Automatic handler resolution +- ChooserActivity for multiple handlers +- Extensible across apps + +--- + +### Pattern 5: Open a File with the View Action + +Use the `view` action to open a file in the app that is registered for its type. If more than one app can handle the type, the system shows an "Open with" chooser. + +```python +from mpos import Intent, Activity + +class FileListActivity(Activity): + def on_file_click(self, path): + self.startActivity(Intent(action="view", data=path)) +``` + +In the receiving app, read the file path from `intent.data` or from the `filename` extra: + +```python +class ImageView(Activity): + def onResume(self, screen): + path = self.getIntent().extras.get("filename") or self.getIntent().data + if path: + self.load_image(path) +``` + +**Key Points:** +- The file path is passed in `intent.data`. +- Apps declare supported extensions in `MANIFEST.JSON` with `action: "view"` and `pathPattern`. +- `ChooserActivity` is shown automatically when multiple handlers exist. +- The framework provides a fallback `ViewActivity` for unhandled file types. + +--- + +### Pattern 6: Method Chaining + +Use fluent API for readable intent construction. + +```python +from mpos import Intent, Activity + +class HomeActivity(Activity): + def on_detail_click(self, item_id): + intent = Intent(activity_class=DetailActivity) \ + .putExtra("id", item_id) \ + .putExtra("title", "Item Details") \ + .addFlag("no_animation", True) + + self.startActivity(intent) +``` + +**Key Points:** + +- Both `putExtra()` and `addFlag()` return `self` +- Enables readable, chainable API +- Reduces boilerplate + +--- + +### Pattern 7: Complex Data Passing + +Pass complex objects and references between activities. + +```python +from mpos import Intent, Activity + +class AppStoreActivity(Activity): + def show_app_detail(self, app): + intent = Intent(activity_class=AppDetailActivity) + intent.putExtra("app", app) + intent.putExtra("appstore", self) + self.startActivity(intent) + +class AppDetailActivity(Activity): + def onCreate(self): + intent = self.getIntent() + self.app = intent.extras.get("app") + self.appstore = intent.extras.get("appstore") + + # Use app and appstore objects + screen = lv.obj() + label = lv.label(screen) + label.set_text(f"App: {self.app.name}") + self.setContentView(screen) +``` + +**Key Points:** + +- Complex data structures supported +- Can pass activity references +- Useful for passing configuration objects + +## Real-World Examples + +### Example 1: Settings App Navigation + +From [`MicroPythonOS/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py`](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py): + +```python +from mpos import Intent, Activity + +class SettingsActivity(Activity): + def navigate_to_calibration(self): + from calibrate_imu import CalibrateIMUActivity + + intent = Intent(activity_class=CalibrateIMUActivity) + self.startActivity(intent) + + def reset_to_bootloader(self): + from reset import ResetIntoBootloader + + intent = Intent(activity_class=ResetIntoBootloader) + self.startActivity(intent) +``` + +**Pattern:** Explicit intents for internal navigation within settings app. + +--- + +### Example 2: WiFi App with Result Handling + +From [`MicroPythonOS/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py`](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py): + +```python +from mpos import Intent, Activity + +class WiFiActivity(Activity): + def add_network_callback(self): + intent = Intent(activity_class=EditNetwork) + intent.putExtra("selected_ssid", None) + self.startActivityForResult(intent, self.edit_network_result_callback) + + def select_ssid_cb(self, ssid): + intent = Intent(activity_class=EditNetwork) + intent.putExtra("selected_ssid", ssid) + intent.putExtra("known_password", WifiService.get_network_password(ssid)) + intent.putExtra("hidden", WifiService.get_network_hidden(ssid)) + self.startActivityForResult(intent, self.edit_network_result_callback) + + def edit_network_result_callback(self, result): + if result["result_code"] == "OK": + network_config = result["data"].get("network_config") + self.save_network(network_config) + +class EditNetwork(Activity): + def onCreate(self): + intent = self.getIntent() + self.selected_ssid = intent.extras.get("selected_ssid") + self.known_password = intent.extras.get("known_password") + self.known_hidden = intent.extras.get("hidden", False) + + # Build UI with pre-filled values +``` + +**Pattern:** Data passing with `startActivityForResult()` for configuration workflows. + +--- + +### Example 3: Camera QR Scanning + +From [`MicroPythonOS/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py`](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py): + +```python +from mpos import Intent, Activity + +class WiFiActivity(Activity): + def open_camera_for_qr(self): + self.startActivityForResult( + Intent(activity_class=CameraActivity) + .putExtra("scanqr_intent", True), + self.gotqr_result_callback + ) + + def gotqr_result_callback(self, result): + if result["result_code"] == "OK": + qr_data = result["data"].get("qr_data") + self.process_qr_code(qr_data) +``` + +**Pattern:** Method chaining with `startActivityForResult()` for specialized camera modes. + +## Best Practices + +### 1. Choose the Right Intent Type + +**Use Explicit Intents when:** + +- Navigating within your app +- You know the exact target activity +- You want predictable, direct navigation + +**Use Implicit Intents when:** + +- You want to enable app extensibility +- Multiple apps might handle the action +- You want to decouple from specific implementations + +```python +# Good: Explicit for internal navigation +intent = Intent(activity_class=SettingsActivity) +self.startActivity(intent) + +# Good: Implicit for extensible actions +intent = Intent(action="android.intent.action.SEND") +self.startActivity(intent) +``` + +--- + +### 2. Always Check Result Codes + +When using `startActivityForResult()`, always check the result code before accessing data. + +```python +# Good: Check result code first +def on_result(self, result): + if result["result_code"] == "OK": + data = result["data"].get("key") + elif result["result_code"] == "CANCEL": + print("User cancelled") + +# Avoid: Assuming success +def on_result(self, result): + data = result["data"].get("key") # May fail if cancelled +``` + +--- + +### 3. Use Meaningful Action Names + +For implicit intents, use descriptive action names that clearly indicate the operation. + +```python +# Good: Clear, descriptive action names +Intent(action="android.intent.action.SEND") +Intent(action="android.intent.action.VIEW") +Intent(action="com.example.app.EDIT_PROFILE") + +# Avoid: Vague action names +Intent(action="do_something") +Intent(action="action1") +``` + +--- + +### 4. Pass Only Serializable Data + +While intents can pass any Python object, prefer simple, serializable data types. + +```python +# Good: Simple, serializable data +intent.putExtra("item_id", 123) +intent.putExtra("title", "Item Title") +intent.putExtra("config", {"key": "value"}) + +# Acceptable: Complex objects when necessary +intent.putExtra("app", app_object) + +# Avoid: Large objects or circular references +intent.putExtra("large_data", huge_list) +``` + +--- + +### 5. Handle Missing Extras Gracefully + +Always use `.get()` with default values when accessing extras. + +```python +# Good: Use get() with defaults +item_id = intent.extras.get("item_id", 0) +title = intent.extras.get("title", "Untitled") + +# Avoid: Direct access without defaults +item_id = intent.extras["item_id"] # KeyError if missing +``` + +--- + +### 6. Register Activities Early + +Register implicit intent handlers during app initialization. + +**Programmatic registration** is useful for framework activities and dynamic handlers: + +```python +# In app initialization +from mpos import AppManager + +AppManager.register_activity("android.intent.action.SEND", ShareActivity) +``` + +**Manifest registration** is preferred for file-type handlers. Add the `intent_filters` to `MANIFEST.JSON` so `AppManager.refresh_apps()` discovers the handler automatically: + +```json +{ + "activities": [ + { + "entrypoint": "player.py", + "classname": "Player", + "intent_filters": [ + { "action": "view", "pathPattern": [".wav"] } + ] + } + ] +} +``` + +--- + +### 7. Use Method Chaining for Readability + +Leverage method chaining to make intent construction more readable. + +```python +# Good: Method chaining +intent = Intent(activity_class=DetailActivity) \ + .putExtra("id", 123) \ + .putExtra("title", "Details") \ + .addFlag("no_animation", True) + +# Less readable: Multiple statements +intent = Intent(activity_class=DetailActivity) +intent.putExtra("id", 123) +intent.putExtra("title", "Details") +intent.addFlag("no_animation", True) +``` + +--- + +### 8. Clean Up Result Callbacks + +Result callbacks are automatically cleaned up after invocation, but ensure your callback doesn't hold unnecessary references. + +```python +# Good: Callback doesn't hold unnecessary state +def on_result(self, result): + if result["result_code"] == "OK": + self.process_data(result["data"]) + +# Avoid: Callback holding large state +def on_result(self, result): + self.large_cache = result["data"] # Unnecessary retention +``` + +## Comparison with Android + +MicroPythonOS Intents are inspired by Android's Intent system but simplified for embedded environments: + +| Feature | MicroPythonOS | Android | Notes | +|---------|---------------|---------|-------| +| **Explicit Intents** | ✅ Supported | ✅ Supported | Direct activity targeting | +| **Implicit Intents** | ✅ Supported | ✅ Supported | Action-based routing | +| **Intent Extras** | ✅ Dict-based | ✅ Bundle-based | MicroPythonOS simpler | +| **Intent Flags** | ⚠️ Partial | ✅ Full | Limited flag support | +| **Intent Filters** | ✅ Manifest + programmatic | ✅ In manifest | File-type filters in `MANIFEST.JSON`, generic handlers via code | +| **Categories** | ❌ Not supported | ✅ Supported | Simplified routing | +| **Data Types** | ⚠️ Path patterns | ✅ MIME types | `pathPattern` suffix matching; `mimeType` is stored but not used for matching | +| **URI Schemes** | ❌ Not matched | ✅ Supported | No scheme filtering | +| **Chooser UI** | ✅ ChooserActivity | ✅ Intent chooser | Custom implementation | +| **Result Callbacks** | ✅ Callback-based | ✅ onActivityResult() | Different mechanism | +| **Method Chaining** | ✅ putExtra() returns self | ❌ No chaining | MicroPythonOS advantage | + +### Key Differences + +**Simplified Intent Filters:** + +- MicroPythonOS supports both programmatic registration via `AppManager.register_activity()` and manifest-based file-type filters in `MANIFEST.JSON` +- Android uses manifest-based intent filters exclusively +- MicroPythonOS approach is more flexible for dynamic app loading while still allowing apps to declare file handlers declaratively + +**Callback-Based Results:** + +- MicroPythonOS uses callbacks: `startActivityForResult(intent, callback)` +- Android uses `onActivityResult()` lifecycle method +- MicroPythonOS approach fits better with async/event-driven model + +**Method Chaining:** + +- MicroPythonOS `putExtra()` and `addFlag()` return `self` +- Android methods don't support chaining +- MicroPythonOS provides more fluent API + +## Source Files + +The Intent system is implemented across these core files: + +- **Intent Class:** [`MicroPythonOS/internal_filesystem/lib/mpos/content/intent.py`](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/lib/mpos/content/intent.py) +- **ActivityNavigator:** [`MicroPythonOS/internal_filesystem/lib/mpos/activity_navigator.py`](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/lib/mpos/activity_navigator.py) +- **AppManager:** [`MicroPythonOS/internal_filesystem/lib/mpos/content/app_manager.py`](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/lib/mpos/content/app_manager.py) +- **Activity Integration:** [`MicroPythonOS/internal_filesystem/lib/mpos/app/activity.py`](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/lib/mpos/app/activity.py) + +## Related Documentation + +- [App Lifecycle](../apps/app-lifecycle.md) - Activity lifecycle and Intent basics +- [AppManager](../frameworks/app-manager.md) - Intent resolution and activity registration +- [FileExplorerActivity](../frameworks/file-explorer-activity.md) - Browsing files and sending `view` intents +- [Focus Borders](../frameworks/focus.md) - Reusable focus highlighting for intent-driven UIs +- [SettingActivity](../frameworks/setting-activity.md) - Intent extras for settings configuration diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 4371dc2..37ada86 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -2,6 +2,13 @@ MicroPythonOS is designed as a lightweight, app-centric operating system inspired by Android. Written entirely in MicroPython, it provides a minimal core with facilities for apps, making it easy to develop and deploy applications. +## Architecture + +- Apps (AppStore, Launcher, OSUpdate, Settings, WiFi) +- Frameworks: MicroPython (AppManager, AudioManager, SensorManager) and C (QRDecoder) +- Drivers: Micropython (Display, TouchInput) and C (Camera) +- Kernel: FreeRTOS and Linux (low level bus drivers) + ## Design Principles - **Thin OS**: The core OS handles hardware initialization, multitasking, and UI, leaving most functionality to apps. @@ -15,4 +22,4 @@ MicroPythonOS is designed as a lightweight, app-centric operating system inspire - **App Ecosystem**: Built-in apps and an App Store for extensibility. - **OTA Updates**: Seamless system and app updates. -See [System Components](system-components.md) for details on key files and [Filesystem Layout](filesystem.md) for the directory structure. +See [Filesystem Layout](filesystem.md) for the directory structure. diff --git a/docs/architecture/system-components.md b/docs/architecture/system-components.md deleted file mode 100644 index 954bfd2..0000000 --- a/docs/architecture/system-components.md +++ /dev/null @@ -1,22 +0,0 @@ -# System Components - -MicroPythonOS consists of several core components that initialize and manage the system. - -- **boot.py**: Initializes hardware on ESP32 microcontrollers. -- **boot_unix.py**: Initializes hardware on Linux desktops (and potentially MacOS). -- **main.py**: - - Sets up the user interface. - - Provides helper functions for apps. - - Launches the `launcher` app to start the system. - -These components work together to bootstrap the OS and provide a foundation for apps. See [Filesystem Layout](filesystem.md) for where apps and data are stored. - -Additionally, the following concepts are also used throughout the code: - -- **Activity** -- **ActivityNavigator** -- **Intent** -- **SharedPreferences** -- **WidgetAnimator** -- **WiFiService** - diff --git a/docs/building/index.md b/docs/building/index.md deleted file mode 100644 index a204773..0000000 --- a/docs/building/index.md +++ /dev/null @@ -1,7 +0,0 @@ -# Building MicroPythonOS - -Build MicroPythonOS for ESP32 microcontrollers or desktop environments. - -- [For ESP32](esp32.md): Build and flash for ESP32 devices. -- [For Desktop](desktop.md): Build and run on Linux or MacOS. -- [Release Checklist](release-checklist.md): Steps for creating a new release. diff --git a/docs/frameworks.md b/docs/frameworks.md new file mode 100644 index 0000000..07f4ef6 --- /dev/null +++ b/docs/frameworks.md @@ -0,0 +1,3 @@ +MicroPythonOS includes many frameworks that make your life easy when creating apps, take over most of the hard work, and allow you to interface with the hardware in a device-independent way. + +Chose a framework from the menu to read more. diff --git a/docs/frameworks/app-manager.md b/docs/frameworks/app-manager.md new file mode 100644 index 0000000..05a6b4a --- /dev/null +++ b/docs/frameworks/app-manager.md @@ -0,0 +1,846 @@ +# AppManager + +MicroPythonOS provides a centralized app management service called **AppManager**. It handles app discovery, installation, uninstallation, launching, and version management across the system. + +## Overview + +AppManager provides: + +- **App discovery** - Automatically finds and catalogs all installed apps +- **App registry** - Maintains a sorted list of apps with metadata (name, version, icon, etc.) +- **App installation** - Installs apps from `.mpk` (MicroPython Package) ZIP files +- **App uninstallation** - Removes user-installed apps (preserves built-in apps) +- **App launching** - Starts apps with proper environment setup and activity management +- **Version management** - Compares versions and detects available updates +- **Intent resolution** - Resolves activities that handle specific intents, including manifest-declared file-type handlers (Android-inspired) +- **Launcher management** - Finds and restarts the system launcher +- **Service management** - Registers, resolves, and starts boot services + +## Quick Start + +### Initialization + +AppManager is initialized at system startup and automatically discovers all apps: + +```python +from mpos import AppManager + +# Get list of all installed apps (auto-discovers on first call) +apps = AppManager.get_app_list() + +for app in apps: + print(f"{app.name} v{app.version}") +``` + +### Listing Apps + +```python +from mpos import AppManager + +# Get all apps (sorted by name) +all_apps = AppManager.get_app_list() + +# Get specific app by fullname +app = AppManager.get("com.example.myapp") + +# Access app by fullname (raises KeyError if not found) +try: + app = AppManager["com.example.myapp"] +except KeyError: + print("App not found") +``` + +### Launching Apps + +```python +from mpos import AppManager + +# Start an app by fullname +success = AppManager.start_app("com.example.myapp") + +if success: + print("App started successfully") +else: + print("Failed to start app") +``` + +### Installing Apps + +```python +from mpos import AppManager + +# Install an app from a .mpk file +# .mpk files are ZIP archives containing the app structure +AppManager.install_mpk( + temp_zip_path="/tmp/myapp.mpk", + dest_folder="apps/com.example.myapp" +) + +# AppManager automatically refreshes the app list after installation +``` + +### Uninstalling Apps + +```python +from mpos import AppManager + +# Uninstall a user-installed app +AppManager.uninstall_app("com.example.myapp") + +# Built-in apps cannot be uninstalled +# AppManager automatically refreshes the app list after uninstallation +``` + +## App Discovery + +AppManager discovers apps by scanning two directories: + +1. **`builtin/apps/`** - System-provided apps (cannot be uninstalled) +2. **`apps/`** - User-installed apps (can be uninstalled) + +User-installed apps override built-in apps with the same fullname. + +### Discovery Process + +```python +from mpos import AppManager + +# Manually trigger app discovery (called automatically on first use) +AppManager.refresh_apps() + +# Clear cached app list +AppManager.clear() + +# Refresh will repopulate the cache +AppManager.get_app_list() +``` + +### App Structure + +Each app must have this directory structure: + +``` +apps/com.example.myapp/ +├── MANIFEST.JSON # App metadata +├── icon_64x64.png # App icon +├── main.py # Main activity entry point +└── ... # Other files and (optional) subfolders +``` + +This flat layout keeps `MANIFEST.JSON` and `icon_64x64.png` at the app root. Subfolders are still allowed for larger apps, but remember that **each directory uses roughly 8 KiB of storage in LittleFS**, so use them sparingly on device. + +The `MANIFEST.JSON` file contains app metadata: + +```json +{ + "fullname": "com.example.myapp", + "name": "My App", + "version": "1.0.0", + "description": "A sample app", + "activities": [ + { + "entrypoint": "main.py", + "classname": "Main", + "intent_filters": [ + { "action": "main", "category": "launcher" } + ] + } + ] +} +``` + +## App Information + +Each app object provides metadata: + +```python +from mpos.content.app_manager import AppManager + +app = AppManager.get("com.example.myapp") + +# Access app properties +print(f"Name: {app.name}") +print(f"Fullname: {app.fullname}") +print(f"Version: {app.version}") +print(f"Description: {app.description}") +print(f"Installed path: {app.installed_path}") +print(f"Icon: {app.icon}") +print(f"Main activity: {app.main_launcher_activity}") +``` + +## Version Management + +AppManager provides version comparison utilities: + +### Comparing Versions + +```python +from mpos import AppManager + +# Compare two version strings +# Returns True if ver1 > ver2, False otherwise +is_newer = AppManager.compare_versions("2.0.0", "1.5.0") +# Returns: True + +is_newer = AppManager.compare_versions("1.5.0", "2.0.0") +# Returns: False + +is_newer = AppManager.compare_versions("1.0.0", "1.0.0") +# Returns: False (equal versions) +``` + +### Checking for Updates + +```python +from mpos import AppManager + +# Check if a newer version is available +has_update = AppManager.is_update_available("com.example.myapp", "2.0.0") + +if has_update: + print("Update available!") +``` + +## Installation Management + +### Installing Apps + +```python +from mpos import AppManager + +# Install from a .mpk file (ZIP archive) +AppManager.install_mpk( + temp_zip_path="/tmp/downloaded_app.mpk", + dest_folder="apps/com.example.newapp" +) + +# After installation, the app is immediately available +app = AppManager.get("com.example.newapp") +``` + +### Checking Installation Status + +```python +from mpos import AppManager + +# Check if app is installed (by name) +is_installed = AppManager.is_installed_by_name("com.example.myapp") + +# Check if app is installed at specific path +is_installed = AppManager.is_installed_by_path("apps/com.example.myapp") + +# Check if app is built-in +is_builtin = AppManager.is_builtin_app("com.example.myapp") + +# Check if app is an override of a built-in app +is_override = AppManager.is_overridden_builtin_app("com.example.myapp") +``` + +### Uninstalling Apps + +```python +from mpos import AppManager + +# Uninstall a user-installed app +AppManager.uninstall_app("com.example.myapp") + +# Built-in apps cannot be uninstalled +# Attempting to uninstall a built-in app will fail silently +``` + +## Intent Resolution + +AppManager implements Android-inspired intent resolution for activity discovery. It supports both programmatic registration and manifest-declared file-type handlers. + +### HandlerInfo + +`resolve_activity()` and `query_intent_activities()` return a list of `HandlerInfo` objects: + +```python +class HandlerInfo: + activity_class # Activity subclass that can handle the intent + app_fullname # Owning app fullname, or None for framework handlers +``` + +### Registering Activities + +Activities can be registered programmatically. This is commonly used by framework components: + +```python +from mpos import AppManager +from mpos import Activity + +class ShareActivity(Activity): + pass + +# Register activity to handle "android.intent.action.SEND" intent +AppManager.register_activity("android.intent.action.SEND", ShareActivity) +``` + +Apps can also declare handlers in `MANIFEST.JSON` so they are discovered during `refresh_apps()`: + +```json +{ + "activities": [ + { + "entrypoint": "imageview.py", + "classname": "ImageView", + "intent_filters": [ + { "action": "view", "pathPattern": [".png", ".jpg", ".jpeg"] } + ] + } + ] +} +``` + +### Resolving Intents + +```python +from mpos import AppManager, Intent + +# Generic action: find all registered handlers +intent = Intent(action="android.intent.action.SEND") +handlers = AppManager.resolve_activity(intent) + +for handler in handlers: + print(f"Handler: {handler.activity_class.__name__}") + if handler.app_fullname: + print(f" from app: {handler.app_fullname}") +``` + +When `intent.data` is a string path, MicroPythonOS preferentially returns manifest handlers whose `pathPattern` matches the file extension. If no file handler matches, it falls back to generic handlers registered for that action. + +```python +from mpos import AppManager, Intent + +# File-type intent: resolved to matching manifest handler(s) +intent = Intent(action="view", data="/data/photo.jpg") +handlers = AppManager.resolve_activity(intent) + +if not handlers: + print("No handler for this file") +elif len(handlers) == 1: + print(f"Opening with {handlers[0].activity_class.__name__}") +else: + print(f"Multiple handlers; ChooserActivity will be shown") +``` + +### pathPattern matching + +`pathPattern` may be a string or a list of strings. Matching is case-insensitive and suffix-based. A leading `*` is optional, so `".wav"` and `"*.wav"` are equivalent. + +## Launcher Management + +AppManager provides utilities for launcher discovery and restart: + +### Finding the Launcher + +```python +from mpos import AppManager + +# Get the system launcher app +launcher = AppManager.get_launcher() + +if launcher: + print(f"Launcher: {launcher.fullname}") + print(f"Name: {launcher.name}") +``` + +### Restarting the Launcher + +```python +from mpos import AppManager + +# Stop all activities and restart the launcher +AppManager.restart_launcher() + +# This is useful when: +# - Exiting an app to return to home screen +# - Recovering from a crash +# - Refreshing the app list after installation/uninstallation +``` + +## App Execution + +AppManager handles the low-level execution of app scripts: + +### Starting Apps + +```python +from mpos import AppManager + +# Start an app (high-level interface) +success = AppManager.start_app("com.example.myapp") + +if success: + print("App started") +else: + print("Failed to start app") +``` + +### Script Execution + +AppManager can execute an app entrypoint module with proper environment setup: + +```python +from mpos import AppManager + +# Execute a script file +success = AppManager.execute_script( + script_source="main.py", + classname="Main", + cwd="apps/com.example.myapp/" +) +``` + +### Execution Details + +When executing a script, AppManager: + +1. **Adds app assets path** to the front of `sys.path` (if `cwd` provided) +2. **Imports the module** derived from `script_source` filename +3. **Lets MicroPython choose** `.py` first, then `.mpy` in the same directory +4. **Finds the main activity class** by name +5. **Starts the activity** using the Activity framework +6. **Restores sys.path** and module cache entry (`sys.modules`) to clean up + +If a `cwd` is provided, it's temporarily placed first in `sys.path` for import resolution. + +## Complete Example: App Store Integration + +Here's how the App Store uses AppManager: + +```python +from mpos import Activity, AppManager +import lvgl as lv + +class AppStoreActivity(Activity): + def onCreate(self): + self.screen = lv.obj() + + # List all installed apps + apps = AppManager.get_app_list() + + # Create a list view + list_view = lv.list(self.screen) + list_view.set_size(lv.pct(100), lv.pct(100)) + + for app in apps: + # Create button for each app + btn = list_view.add_button(lv.SYMBOL.DOWNLOAD, app.name) + btn.add_event_cb( + lambda e, a=app: self.on_app_selected(a), + lv.EVENT.CLICKED, + None + ) + + self.setContentView(self.screen) + + def on_app_selected(self, app): + # Check if update is available + if AppManager.is_update_available(app.fullname, "2.0.0"): + print(f"Update available for {app.name}") + # Download and install update + # AppManager.install_mpk(...) + else: + # Launch the app + AppManager.start_app(app.fullname) +``` + +## API Reference + +### App Discovery + +**`get_app_list()`** + +Get list of all installed apps (sorted by name). + +- **Returns:** `list[App]` - List of App objects + +**`get(fullname)`** + +Get app by fullname. + +- **Parameters:** + - `fullname` (str): App fullname (e.g., `"com.example.myapp"`) + +- **Returns:** `App` or `None` - App object or None if not found + +**`__getitem__(fullname)`** + +Get app by fullname (raises KeyError if not found). + +- **Parameters:** + - `fullname` (str): App fullname + +- **Returns:** `App` - App object + +- **Raises:** `KeyError` - If app not found + +**`refresh_apps()`** + +Scan directories and rebuild app list. + +- **Behavior:** Clears cache and rediscovers all apps + +**`clear()`** + +Clear cached app list. + +- **Behavior:** Empties internal caches; call `get_app_list()` to repopulate + +### App Installation + +**`install_mpk(temp_zip_path, dest_folder)`** + +Install app from .mpk (ZIP) file. + +- **Parameters:** + - `temp_zip_path` (str): Path to temporary .mpk file + - `dest_folder` (str): Destination folder (e.g., `"apps/com.example.myapp"`) + +- **Behavior:** Extracts ZIP, removes temp file, refreshes app list + +**`uninstall_app(app_fullname)`** + +Uninstall a user-installed app. + +- **Parameters:** + - `app_fullname` (str): App fullname + +- **Behavior:** Removes app folder from `apps/`, refreshes app list + +- **Note:** Built-in apps cannot be uninstalled + +### Version Management + +**`compare_versions(ver1, ver2)`** + +Compare two version strings. + +- **Parameters:** + - `ver1` (str): First version (e.g., `"1.2.3"`) + - `ver2` (str): Second version (e.g., `"1.0.0"`) + +- **Returns:** `bool` - `True` if ver1 > ver2, `False` otherwise + +- **Behavior:** Parses versions as dot-separated integers, compares component-wise + +**`is_update_available(app_fullname, new_version)`** + +Check if newer version is available. + +- **Parameters:** + - `app_fullname` (str): App fullname + - `new_version` (str): New version to check + +- **Returns:** `bool` - `True` if new_version > installed version + +### Installation Status + +**`is_installed_by_name(app_fullname)`** + +Check if app is installed (user or built-in). + +- **Parameters:** + - `app_fullname` (str): App fullname + +- **Returns:** `bool` - `True` if installed + +**`is_installed_by_path(dir_path)`** + +Check if app is installed at specific path. + +- **Parameters:** + - `dir_path` (str): Directory path (e.g., `"apps/com.example.myapp"`) + +- **Returns:** `bool` - `True` if directory exists and has valid manifest + +**`is_builtin_app(app_fullname)`** + +Check if app is built-in (in `builtin/apps/`). + +- **Parameters:** + - `app_fullname` (str): App fullname + +- **Returns:** `bool` - `True` if built-in + +**`is_overridden_builtin_app(app_fullname)`** + +Check if user app overrides a built-in app. + +- **Parameters:** + - `app_fullname` (str): App fullname + +- **Returns:** `bool` - `True` if both user and built-in versions exist + +### App Execution + +**`start_app(fullname, intent=None)`** + +Start an app by fullname. + +- **Parameters:** + - `fullname` (str): App fullname + - `intent` (Intent, optional): Intent to deliver to the app's main launcher activity + +- **Returns:** `bool` - `True` if successful + +- **Behavior:** + 1. Sets app as foreground + 2. Loads app metadata + 3. Executes main activity script with the provided intent + 4. Shows/hides top menu bar based on app type + +**`execute_script(script_source, classname, cwd=None)`** + +Execute a Python script with proper environment. + +- **Parameters:** + - `script_source` (str): Script path used to derive module name (e.g., `main.py` -> `main`) + - `classname` (str): Name of main activity class to instantiate + - `cwd` (str, optional): Working directory to add to sys.path + +- **Returns:** `bool` - `True` if successful + +- **Behavior:** + 1. Temporarily prioritizes `cwd` in `sys.path` (if provided) + 2. Imports module name derived from `script_source` + 3. Uses normal MicroPython import precedence (`.py` then `.mpy`) + 4. Finds and instantiates main activity class + 5. Starts activity using Activity framework + 6. Restores `sys.path` and the previous `sys.modules` entry for that module name + +### Intent Resolution + +**`register_activity(action, activity_cls)`** + +Register an activity programmatically to handle an intent action. + +- **Parameters:** + - `action` (str): Intent action (e.g., `"android.intent.action.SEND"`) + - `activity_cls` (type): Activity class + +**`resolve_activity(intent)`** + +Find handlers that can handle the intent. + +- **Parameters:** + - `intent` (Intent): Intent object with `action` attribute + +- **Returns:** `list[HandlerInfo]` - List of handler descriptors + +- **Behavior:** + 1. If `intent.data` is a string path, searches installed app manifests for matching `pathPattern` filters. + 2. Returns matching manifest handlers if any are found. + 3. Otherwise returns all generic handlers registered for the action. + +**`query_intent_activities(intent)`** + +Same as `resolve_activity()` (Android-like naming). + +- **Parameters:** + - `intent` (Intent): Intent object + +- **Returns:** `list[HandlerInfo]` - List of handler descriptors + +**`get_handler_display_name(activity_class)`** + +Return a human-readable name for a resolved handler class. For manifest handlers this is the owning app's display name; for framework handlers it is the class name. + +- **Parameters:** + - `activity_class` (type): Activity class from a `HandlerInfo` + +- **Returns:** `str` - Display name + +### Launcher Management + +**`get_launcher()`** + +Find the system launcher app. + +- **Returns:** `App` or `None` - Launcher app object + +**`restart_launcher()`** + +Stop all activities and restart launcher. + +- **Behavior:** + 1. Removes and stops all activities + 2. Starts launcher app + 3. Useful for returning to home screen + +### Service Management + +**`register_service(action, service_cls, fullname=None)`** + +Register a service class to handle an intent action (programmatic registration, used by system services). + +- **Parameters:** + - `action` (str): Intent action (e.g., `"boot_completed"`) + - `service_cls` (type): Service subclass + - `fullname` (str, optional): App fullname to associate with the service + +- **Example:** + ```python + from mpos.content.app_manager import AppManager + from mpos import Service + + class MyBootService(Service): + def onStart(self, intent): + print("Boot service started") + + AppManager.register_service("boot_completed", MyBootService, fullname="com.example.myapp") + ``` + +**`get_services_for_action(action)`** + +Resolve all services (manifest-declared and programmatically registered) that handle a given intent action. + +- **Parameters:** + - `action` (str): Intent action (e.g., `"boot_completed"`) + +- **Returns:** `list[(str, type)]` - List of `(app_fullname, ServiceClass)` tuples + +- **Behavior:** + 1. Scans all installed apps' manifests for matching service entries + 2. Imports each service module and extracts the class + 3. Merges with programmatically registered services from `_service_registry` + +**`start_boot_services()`** + +Start all services that subscribe to the `boot_completed` intent. Called once during system boot after the launcher is displayed. + +- **Behavior:** + 1. Calls `get_services_for_action("boot_completed")` to discover all boot services + 2. For each service: instantiate, set `appFullName`, call `onCreate()`, then `onStart(boot_intent)` + 3. Handles errors per-service so one failing service does not block others + +- **Called by:** `main.py` during system boot sequence + +## Best Practices + +### Do's + +✅ Call `AppManager.refresh_apps()` after installing/uninstalling apps +✅ Check `AppManager.get(fullname)` before starting an app +✅ Use `AppManager.start_app()` for user-initiated app launches +✅ Handle version comparison for update checks +✅ Use intent resolution for extensible app interactions + +### Don'ts + +❌ Directly manipulate app directories (use `install_mpk()` and `uninstall_app()`) +❌ Assume app is installed without checking +❌ Start apps without verifying they exist +❌ Modify app metadata directly (read-only) +❌ Uninstall built-in apps (will fail) + +## Troubleshooting + +### App Not Found + +**Symptom**: `AppManager.get(fullname)` returns `None` + +**Causes:** +- App not installed +- Fullname is incorrect +- App list not refreshed after installation + +**Solution:** +```python +from mpos import AppManager + +# Refresh app list +AppManager.refresh_apps() + +# Check if app exists +app = AppManager.get("com.example.myapp") +if app: + print(f"Found: {app.name}") +else: + print("App not found") + # List all apps to verify + for app in AppManager.get_app_list(): + print(f" - {app.fullname}") +``` + +### App Fails to Start + +**Symptom**: `start_app()` returns `False` + +**Possible causes:** +- App not installed +- Missing or invalid manifest +- Main activity class not found +- Script execution error + +**Solution:** +```python +from mpos import AppManager + +# Verify app exists +app = AppManager.get("com.example.myapp") +if not app: + print("App not installed") + return + +# Check app has installed path +if not app.installed_path: + print("App has no installed path") + return + +# Check main activity is defined +if not app.main_launcher_activity: + print("App has no main launcher activity") + return + +# Try to start +success = AppManager.start_app("com.example.myapp") +if not success: + print("Failed to start - check console for errors") +``` + +### Installation Fails + +**Symptom**: `install_mpk()` doesn't create app + +**Possible causes:** +- Invalid ZIP file +- Destination folder already exists +- Insufficient permissions +- Missing manifest in ZIP + +**Solution:** +```python +from mpos import AppManager + +# Verify ZIP file is valid +import zipfile +try: + with zipfile.ZipFile("/tmp/app.mpk", "r") as z: + print("ZIP contents:", z.namelist()) +except Exception as e: + print(f"Invalid ZIP: {e}") + return + +# Install with error handling +try: + AppManager.install_mpk("/tmp/app.mpk", "apps/com.example.newapp") + print("Installation successful") +except Exception as e: + print(f"Installation failed: {e}") + +# Verify installation +app = AppManager.get("com.example.newapp") +if app: + print(f"App installed: {app.name}") +``` + +## See Also + +- [Creating Apps](../apps/creating-apps.md) - How to create MicroPythonOS apps +- [Activity Framework](../apps/app-lifecycle.md) - Activity lifecycle and management +- [Intent System](../architecture/intents.md) - Intent-based app communication +- [Frameworks Overview](../architecture/frameworks.md) - All available frameworks +- [Service](../frameworks/service.md) - Background services and boot-time execution diff --git a/docs/frameworks/appearance-manager.md b/docs/frameworks/appearance-manager.md new file mode 100644 index 0000000..193539f --- /dev/null +++ b/docs/frameworks/appearance-manager.md @@ -0,0 +1,400 @@ +# AppearanceManager + +AppearanceManager is an Android-inspired singleton framework that provides a unified, clean API for managing all aspects of the app's visual appearance: light/dark mode, theme colors, UI dimensions, and LVGL styling. + +## Overview + +Instead of scattered functions and constants, AppearanceManager centralizes all appearance-related settings in a single class with class methods. This provides: + +- **Unified API** - Single class for all appearance settings +- **Clean Namespace** - No scattered functions or constants cluttering imports +- **Testable** - AppearanceManager can be tested independently +- **Android-Inspired** - Follows Android's Configuration and Resources pattern +- **Elegant Initialization** - Single `init()` call from `main.py` + +## Architecture + +AppearanceManager is implemented as a singleton using class variables and class methods: + +```python +class AppearanceManager: + """Android-inspired appearance management singleton.""" + + # UI Dimensions (constants) + NOTIFICATION_BAR_HEIGHT = 24 + + # Private state (class variables) + _is_light_mode = True + _primary_color = None + + @classmethod + def init(cls, prefs): + """Initialize from SharedPreferences.""" + pass + + @classmethod + def is_light_mode(cls): + """Check if light mode is enabled.""" + return cls._is_light_mode +``` + +No instance creation is needed - all methods are class methods that operate on class variables. + +## Quick Start + +### Basic Usage + +```python +from mpos import AppearanceManager + +# Check light/dark mode +if AppearanceManager.is_light_mode(): + print("Light mode enabled") +else: + print("Dark mode enabled") + +# Get UI dimensions +bar_height = AppearanceManager.get_notification_bar_height() + +# Get theme colors +primary_color = AppearanceManager.get_primary_color() +``` + +### Initialization + +AppearanceManager is automatically initialized during system startup in `main.py`: + +```python +from mpos.ui.appearance_manager import AppearanceManager +import mpos.config + +prefs = mpos.config.SharedPreferences("com.micropythonos.settings") +AppearanceManager.init(prefs) # Called once at boot +``` + +## API Reference + +### Class Constants + +#### `NOTIFICATION_BAR_HEIGHT` +Height of the notification bar in pixels. + +**Value:** 24 (pixels) + +**Example:** +```python +from mpos import AppearanceManager + +bar_height = AppearanceManager.NOTIFICATION_BAR_HEIGHT # 24 +``` + +### Class Methods + +#### `init(prefs)` +Initialize AppearanceManager from SharedPreferences. + +Called once during system startup to load theme settings and initialize the LVGL theme. + +**Parameters:** +- `prefs` (SharedPreferences): Preferences object containing theme settings + - `"theme_light_dark"`: "light" or "dark" (default: "light") + - `"theme_primary_color"`: hex color string like "0xFF5722" or "#FF5722" + +**Example:** +```python +from mpos.ui.appearance_manager import AppearanceManager +import mpos.config + +prefs = mpos.config.SharedPreferences("com.micropythonos.settings") +AppearanceManager.init(prefs) +``` + +#### `is_light_mode()` +Check if light mode is currently enabled. + +**Returns:** bool - True if light mode, False if dark mode + +**Example:** +```python +from mpos import AppearanceManager + +if AppearanceManager.is_light_mode(): + print("Using light theme") +else: + print("Using dark theme") +``` + +#### `set_light_mode(is_light, prefs=None)` +Set light/dark mode and update the theme. + +**Parameters:** +- `is_light` (bool): True for light mode, False for dark mode +- `prefs` (SharedPreferences, optional): If provided, saves the setting + +**Example:** +```python +from mpos import AppearanceManager + +# Switch to dark mode +AppearanceManager.set_light_mode(False) + +# Switch to light mode and save preference +AppearanceManager.set_light_mode(True, prefs) +``` + +#### `get_primary_color()` +Get the primary theme color. + +**Returns:** lv.color_t or None - The primary color + +**Example:** +```python +from mpos import AppearanceManager +import lvgl as lv + +color = AppearanceManager.get_primary_color() +if color: + button.set_style_bg_color(color, 0) +``` + +#### `set_primary_color(color, prefs=None)` +Set the primary theme color. + +**Parameters:** +- `color` (lv.color_t or int): The new primary color +- `prefs` (SharedPreferences, optional): If provided, saves the setting + +**Example:** +```python +from mpos import AppearanceManager +import lvgl as lv + +AppearanceManager.set_primary_color(lv.color_hex(0xFF5722)) +``` + +#### `get_notification_bar_height()` +Get the height of the notification bar. + +The notification bar is the top bar that displays system information (time, battery, signal, etc.). + +**Returns:** int - Height in pixels (default: 24) + +**Example:** +```python +from mpos import AppearanceManager + +bar_height = AppearanceManager.get_notification_bar_height() +content_y = bar_height # Position content below the bar +``` + +#### `get_keyboard_button_fix_style()` +Get the keyboard button fix style for light mode. + +The LVGL default theme applies white background to keyboard buttons, which makes them invisible in light mode. This method returns a custom style to override that. + +**Returns:** lv.style_t or None - Style to apply, or None if not needed + +**Note:** This is a workaround for an LVGL/MicroPython issue. Only applies in light mode. + +**Example:** +```python +from mpos import AppearanceManager +import lvgl as lv + +style = AppearanceManager.get_keyboard_button_fix_style() +if style: + keyboard.add_style(style, lv.PART.ITEMS) +``` + +#### `apply_keyboard_fix(keyboard)` +Apply keyboard button visibility fix to a keyboard instance. + +Call this after creating a keyboard to ensure buttons are visible in light mode. + +**Parameters:** +- `keyboard`: The lv.keyboard instance to fix + +**Example:** +```python +from mpos import AppearanceManager +import lvgl as lv + +keyboard = lv.keyboard(screen) +AppearanceManager.apply_keyboard_fix(keyboard) +``` + +## Practical Examples + +### Responsive Layout Based on Theme + +```python +from mpos import Activity, AppearanceManager, DisplayMetrics +import lvgl as lv + +class MyApp(Activity): + def onCreate(self): + screen = lv.obj() + + # Create a button with theme-aware colors + button = lv.button(screen) + button.set_width(DisplayMetrics.pct_of_width(50)) + button.set_height(DisplayMetrics.pct_of_height(20)) + + # Use primary color from theme + primary_color = AppearanceManager.get_primary_color() + if primary_color: + button.set_style_bg_color(primary_color, 0) + + button.center() + self.setContentView(screen) +``` + +### Positioning Below Notification Bar + +```python +from mpos import AppearanceManager, DisplayMetrics +import lvgl as lv + +def create_content_area(parent): + """Create content area positioned below notification bar.""" + content = lv.obj(parent) + + # Position below notification bar + bar_height = AppearanceManager.get_notification_bar_height() + content.set_pos(0, bar_height) + + # Fill remaining space + content.set_size( + DisplayMetrics.width(), + DisplayMetrics.height() - bar_height + ) + + return content +``` + +### Theme-Aware Keyboard + +```python +from mpos import AppearanceManager +import lvgl as lv + +def create_keyboard(parent): + """Create a keyboard with proper styling in light mode.""" + keyboard = lv.keyboard(parent) + + # Apply light mode fix if needed + AppearanceManager.apply_keyboard_fix(keyboard) + + return keyboard +``` + +### Checking Theme at Runtime + +```python +from mpos import AppearanceManager +import lvgl as lv + +def update_ui_for_theme(): + """Update UI colors based on current theme.""" + if AppearanceManager.is_light_mode(): + # Light mode: use light colors + bg_color = lv.color_hex(0xFFFFFF) + text_color = lv.color_hex(0x000000) + else: + # Dark mode: use dark colors + bg_color = lv.color_hex(0x1E1E1E) + text_color = lv.color_hex(0xFFFFFF) + + screen = lv.screen_active() + screen.set_style_bg_color(bg_color, 0) + screen.set_style_text_color(text_color, 0) +``` + +## Implementation Details + +### File Structure + +``` +mpos/ui/ +├── appearance_manager.py # Core AppearanceManager class +├── topmenu.py # Uses AppearanceManager +├── gesture_navigation.py # Uses AppearanceManager +└── __init__.py # Exports AppearanceManager +``` + +### Initialization Flow + +1. **System Startup** - `main.py` is executed +2. **Create Preferences** - `SharedPreferences("com.micropythonos.settings")` is created +3. **Initialize AppearanceManager** - `AppearanceManager.init(prefs)` is called +4. **Load Settings** - Theme mode and colors are loaded from preferences +5. **Initialize LVGL** - `lv.theme_default_init()` is called with loaded settings +6. **Ready to Use** - Apps can now call AppearanceManager methods + +```python +# In main.py +prefs = mpos.config.SharedPreferences("com.micropythonos.settings") +AppearanceManager.init(prefs) # Initialize appearance +init_rootscreen() # Initialize display +``` + +## Design Patterns + +### Singleton Pattern + +AppearanceManager uses the singleton pattern with class variables and class methods: + +```python +class AppearanceManager: + _is_light_mode = True # Shared across all "instances" + + @classmethod + def is_light_mode(cls): + return cls._is_light_mode +``` + +This avoids the need for instance creation while maintaining a single source of truth. + +### Class Method Delegation + +All methods are class methods, so no instance is needed: + +```python +# No need for AppearanceManager() or AppearanceManager.get() +is_light = AppearanceManager.is_light_mode() # Direct class method call +``` + +## Preferences Storage + +AppearanceManager reads and writes to SharedPreferences: + +**Preference Keys:** +- `"theme_light_dark"` - "light" or "dark" (default: "light") +- `"theme_primary_color"` - hex color string like "0xFF5722" + +**Example:** +```python +import mpos.config + +prefs = mpos.config.SharedPreferences("com.micropythonos.settings") + +# Read current theme +theme = prefs.get_string("theme_light_dark", "light") + +# Write new theme +editor = prefs.edit() +editor.put_string("theme_light_dark", "dark") +editor.commit() +``` + +## Related Frameworks + +- **[DisplayMetrics](display-metrics.md)** - Display properties (width, height, DPI) +- **[SensorManager](sensor-manager.md)** - Sensor access +- **[SharedPreferences](preferences.md)** - Persistent app data + +## See Also + +- [Architecture Overview](../architecture/overview.md) +- [Frameworks](../architecture/frameworks.md) +- [Creating Apps](../apps/creating-apps.md) diff --git a/docs/frameworks/audiomanager.md b/docs/frameworks/audiomanager.md new file mode 100644 index 0000000..8128618 --- /dev/null +++ b/docs/frameworks/audiomanager.md @@ -0,0 +1,292 @@ +# AudioManager + +MicroPythonOS provides a centralized audio service called **AudioManager**, inspired by Android's architecture. It manages audio playback and recording across different hardware outputs with priority-based audio focus control and a device registry for routing. + +## Overview + +AudioManager provides: + +- **Priority-based audio focus** - Higher priority streams interrupt lower priority ones +- **Device registry** - Register I2S outputs, PWM buzzers, and microphone inputs +- **Session control** - Player/Recorder sessions with start/stop/pause/resume +- **WAV file support** - 8/16/24/32-bit PCM, mono/stereo, auto-upsampling +- **WAV recording** - 16-bit mono PCM from I2S or ADC microphone +- **RTTTL ringtone support** - Full Ring Tone Text Transfer Language parser +- **Thread-safe** - Safe for concurrent access +- **Hardware-agnostic** - Apps work across all platforms without changes + +## Supported Audio Devices + +- **I2S Output**: Digital audio output for WAV file playback +- **I2S Input**: Microphone recording (I2S mic) +- **ADC Input**: Analog microphone recording via ADC (Fri3d 2026) +- **Buzzer**: PWM-based tone/ringtone playback + +## Quick Start + +### Registering Devices + +AudioManager uses a registry of device descriptors instead of direct hardware initialization. +Register devices once at startup (typically in your board init file): + +```python +from mpos import AudioManager + +# I2S speaker +AudioManager.add( + AudioManager.Output( + name="speaker", + kind="i2s", + channels=2, + i2s_pins={"sck": 2, "ws": 47, "sd": 16, "mck": 2}, + preferred_sample_rate=44100, + ) +) + +# I2S microphone +AudioManager.add( + AudioManager.Input( + name="mic", + kind="i2s", + i2s_pins={"sck_in": 17, "ws": 47, "sd_in": 15}, + preferred_sample_rate=16000, + ) +) + +# Buzzer for RTTTL +AudioManager.add( + AudioManager.Output( + name="buzzer", + kind="buzzer", + buzzer_pin=46, + ) +) +``` + +### Playing WAV Files + +```python +from mpos import AudioManager + +player = AudioManager.player( + file_path="M:/sdcard/music/song.wav", + stream_type=AudioManager.STREAM_MUSIC, + volume=80, + on_complete=lambda msg: print(msg), +) +player.start() +``` + +**Supported formats:** +- **Encoding**: PCM (8/16/24/32-bit) +- **Channels**: Mono or stereo +- **Sample rate**: Any rate (auto-upsampled to ≥8000 Hz) + +### Playing RTTTL Ringtones + +```python +from mpos import AudioManager + +rtttl = "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#,8c#6,8b,d,8p,8b,8a,8c#,8e" +player = AudioManager.rtttl_player( + rtttl, + stream_type=AudioManager.STREAM_NOTIFICATION, +) +player.start() +``` + +### Recording Audio (I2S Mic) + +```python +from mpos import AudioManager + +recorder = AudioManager.recorder( + file_path="data/my_recording.wav", + duration_ms=10000, + sample_rate=16000, +) +recorder.start() +``` + +### Recording Audio (ADC Mic) + +```python +from mpos import AudioManager + +AudioManager.record_wav_adc( + file_path="data/adc_recording.wav", + duration_ms=5000, + sample_rate=16000, + adc_pin=1, +) +``` + +### Desktop WAV Playback + +On Linux and macOS desktop builds, WAV playback uses an external audio player subprocess instead of I2S hardware. `AudioManager` automatically detects one of the following players in order of preference: + +1. `ffplay` (FFmpeg) +2. `aplay` (ALSA) +3. `paplay` (PulseAudio) +4. `afplay` (macOS) + +If none is installed, the player still runs in "timing simulation" mode so apps can test playback flow without sound. + +```bash +# Ubuntu/Debian +sudo apt install ffmpeg + +# macOS +# afplay is pre-installed +``` + +Priority and volume controls apply conceptually on desktop, although the external player handles the actual output. + +## Audio Focus Priority + +AudioManager implements a 3-tier priority-based audio focus system inspired by Android: + +| Stream Type | Priority | Use Case | Behavior | +|-------------|----------|----------|----------| +| **STREAM_ALARM** | 2 (Highest) | Alarms, alerts | Interrupts all other streams | +| **STREAM_NOTIFICATION** | 1 (Medium) | Notifications, UI sounds | Interrupts music, rejected by alarms | +| **STREAM_MUSIC** | 0 (Lowest) | Music, podcasts | Interrupted by everything | + +### Priority Rules + +1. **Higher priority interrupts lower priority**: ALARM > NOTIFICATION > MUSIC +2. **Equal priority is rejected**: Can't play two alarms simultaneously +3. **Lower priority is rejected**: Can't start music while alarm is playing + +## Device Routing and Conflicts + +AudioManager prevents conflicts by tracking pin usage and sample rates: + +- **Shared clocks**: If two I2S sessions share `ws`/`sck`, they must use the same sample rate. +- **Conflicts**: Starting a new session stops conflicting sessions automatically. + +## Volume Control + +```python +from mpos import AudioManager + +AudioManager.set_volume(70) +volume = AudioManager.get_volume() +print(f"Current volume: {volume}") +``` + +Volume changes affect active sessions when the stream supports volume adjustment. + +## API Reference + +### Device Registration + +**`AudioManager.add(device)`** + +Register a device descriptor. + +- **Parameters:** + - `device` - `AudioManager.Output` or `AudioManager.Input` + +**`AudioManager.get_outputs()`** / **`AudioManager.get_inputs()`** + +Return a list of registered devices. + +**`AudioManager.get_default_output()`** / **`AudioManager.get_default_input()`** + +Return the default device for playback or recording. + +**`AudioManager.set_default_output(output)`** / **`AudioManager.set_default_input(input_device)`** + +Set the default device used when a session does not specify one. + +### Playback Sessions + +**`AudioManager.player(file_path=None, rtttl=None, stream_type=None, on_complete=None, output=None, sample_rate=None, volume=None)`** + +Create a playback session. For I2S playback use `file_path`. For RTTTL use `rtttl`. + +- **Returns:** `Player` + +**`AudioManager.rtttl_player(rtttl, **kwargs)`** + +Shortcut for creating an RTTTL player. + +**`AudioManager.get_active_player(stream_type=None, file_path=None)`** + +Return the active `Player` matching optional filters. + +**`AudioManager.get_active_track(stream_type=None)`** + +Return file path for the active track (if any). + +### Recording Sessions + +**`AudioManager.recorder(file_path, input=None, sample_rate=None, on_complete=None, duration_ms=None, **adc_config)`** + +Create a recording session. Use an `AudioManager.Input` descriptor for I2S or ADC input. + +- **Returns:** `Recorder` + +**`AudioManager.record_wav_adc(file_path, duration_ms=None, sample_rate=None, adc_pin=None, on_complete=None, **adc_config)`** + +Start an ADC recording session immediately. + +### Global Control + +**`AudioManager.stop()`** + +Stop all active playback and recording sessions. + +**`AudioManager.set_volume(volume)`** / **`AudioManager.get_volume()`** + +Set or get the global volume (0–100). + +### `Player` Session Methods + +- `start()` / `stop()` / `pause()` / `resume()` +- `is_playing()` / `is_active()` +- `get_progress_percent()` / `get_progress_ms()` / `get_duration_ms()` + +### `Recorder` Session Methods + +- `start()` / `stop()` / `pause()` / `resume()` +- `is_recording()` / `is_active()` +- `get_duration_ms()` + +## Hardware Support Matrix + +| Board | I2S Output | I2S Mic | ADC Mic | Buzzer | Notes | +|-------|------------|--------|---------|--------|-------| +| **Fri3d 2024 Badge** | ✅ | ✅ | ❌ | ✅ | Full audio support | +| **Fri3d 2026 Badge** | ✅ | ❌ | ✅ | ✅ | ADC mic via `adc_mic` | +| **Waveshare ESP32-S3** | ✅ | ❌ | ❌ | ❌ | I2S output only | +| **Linux/macOS** | ✅ (external player) | ✅ (simulated) | ✅ (simulated) | ❌ | WAV playback via `ffplay`/`aplay`/`paplay`/`afplay` | + +## Troubleshooting + +### Playback Rejected + +**Symptom**: New playback is rejected or interrupted. + +**Cause**: Higher-priority stream is active or device/pin conflict. + +**Solution**: +1. Use `AudioManager.get_active_player()` to inspect active streams +2. Stop the active session with `AudioManager.stop()` if appropriate +3. Use a higher priority stream type + +### WAV File Not Playing + +**Requirements:** +- **Encoding**: PCM only (not MP3, AAC) +- **Bit depth**: 8, 16, 24, or 32-bit +- **Channels**: Mono or stereo +- **Sample rate**: Any (auto-upsampled to ≥8000 Hz) + +## See Also + +- [Creating Apps](../apps/creating-apps.md) - Learn how to create MicroPythonOS apps +- [WidgetAnimator](widget-animator.md) - Smooth UI animations +- [LightsManager](lights-manager.md) - LED control for visual feedback +- [SharedPreferences](preferences.md) - Store audio preferences diff --git a/docs/frameworks/battery-manager.md b/docs/frameworks/battery-manager.md new file mode 100644 index 0000000..276c2d9 --- /dev/null +++ b/docs/frameworks/battery-manager.md @@ -0,0 +1,283 @@ +# BatteryManager + +The `BatteryManager` provides an Android-inspired API for querying battery and power information. It handles ADC readings, voltage calculations, percentage calculations, and intelligent caching with WiFi coordination on ESP32-S3. + +## Overview + +`BatteryManager` is a static class that provides direct access to battery voltage, charge percentage, and raw ADC values. It automatically handles: + +- **ADC1/ADC2 Detection** - Identifies which ADC bank the pin uses +- **Adaptive Caching** - Different cache durations based on WiFi impact +- **WiFi Coordination** - Temporarily disables WiFi for ADC2 reads on ESP32-S3 +- **Desktop Simulation** - Returns random values in typical range when ADC unavailable + +## Import + +```python +from mpos import BatteryManager +``` + +## Initialization + +Before using battery readings, initialize the ADC with a conversion function: + +```python +def adc_to_voltage(adc_value): + """Convert raw ADC value (0-4095) to battery voltage in volts.""" + return adc_value * 0.001651 + 0.08709 + +BatteryManager.init_adc(pin=13, adc_to_voltage_func=adc_to_voltage) +``` + +### Parameters + +- **pin** (int): GPIO pin number for battery voltage measurement + - ADC1 pins: GPIO1-10 (no WiFi interference) + - ADC2 pins: GPIO11-20 (requires WiFi disable on ESP32-S3) +- **adc_to_voltage_func** (callable): Function that converts raw ADC value (0-4095) to voltage in volts + +### Important Notes + +- **ADC2 on ESP32-S3**: WiFi will be temporarily disabled during readings to avoid conflicts +- **Desktop Mode**: If ADC is unavailable, returns simulated random values +- **Calibration**: The conversion function should be calibrated for your specific hardware + +## API Methods + +### read_battery_voltage() + +Get the current battery voltage in volts. + +```python +voltage = BatteryManager.read_battery_voltage() +print(f"Battery: {voltage:.2f}V") +``` + +**Parameters:** +- `force_refresh` (bool, optional): Bypass cache and force fresh reading. Default: `False` +- `raw_adc_value` (float, optional): Pre-computed raw ADC value (for testing). Default: `None` + +**Returns:** float - Battery voltage in volts + +**Raises:** RuntimeError - If ADC2 is used and WiFi is already busy + +### get_battery_percentage() + +Get the current battery charge as a percentage (0-100). + +```python +percentage = BatteryManager.get_battery_percentage() +print(f"Battery: {percentage:.1f}%") +``` + +**Parameters:** +- `raw_adc_value` (float, optional): Pre-computed raw ADC value (for testing). Default: `None` + +**Returns:** float - Battery percentage (0-100), clamped to valid range + +### read_raw_adc() + +Get the raw ADC value (0-4095) with averaging. + +```python +raw = BatteryManager.read_raw_adc() +print(f"Raw ADC: {raw}") +``` + +**Parameters:** +- `force_refresh` (bool, optional): Bypass cache and force fresh reading. Default: `False` + +**Returns:** float - Raw ADC value (average of 10 samples) + +**Raises:** RuntimeError - If ADC2 is used and WiFi is already busy + +### clear_cache() + +Clear the cached battery reading to force a fresh read on the next call. + +```python +BatteryManager.clear_cache() +voltage = BatteryManager.read_battery_voltage() # Fresh read +``` + +## Caching Strategy + +BatteryManager uses adaptive caching to balance accuracy with performance: + +| ADC Bank | Cache Duration | Reason | +|----------|----------------|--------| +| ADC1 (GPIO1-10) | 30 seconds | No WiFi interference | +| ADC2 (GPIO11-20) | 10 minutes | Expensive WiFi disable/enable | + +To override cache duration at runtime: + +```python +import mpos.battery_manager +mpos.battery_manager.CACHE_DURATION_ADC1_MS = 60000 # 60 seconds +mpos.battery_manager.CACHE_DURATION_ADC2_MS = 300000 # 5 minutes +``` + +## WiFi Coordination (ESP32-S3) + +On ESP32-S3, ADC2 (GPIO11-20) cannot be read while WiFi is active. BatteryManager automatically: + +1. Checks if WiFi is busy (connecting/scanning) +2. Temporarily disables WiFi if needed +3. Reads the ADC value +4. Restores WiFi to its previous state + +**Error Handling:** + +```python +try: + voltage = BatteryManager.read_battery_voltage(force_refresh=True) +except RuntimeError as e: + print(f"Cannot read battery: {e}") + # WiFi is busy, use cached value instead + voltage = BatteryManager.read_battery_voltage() +``` + +## Constants + +```python +from mpos.battery_manager import MIN_VOLTAGE, MAX_VOLTAGE + +MIN_VOLTAGE = 3.15 # Minimum battery voltage (0%) +MAX_VOLTAGE = 4.15 # Maximum battery voltage (100%) +``` + +These constants define the voltage range used for percentage calculation. Adjust based on your battery chemistry. + +## Example: Battery Status Display + +```python +from mpos import BatteryManager +import time + +# Initialize with calibration for your hardware +def adc_to_voltage(adc_value): + return adc_value * 0.001651 + 0.08709 + +BatteryManager.init_adc(pin=13, adc_to_voltage_func=adc_to_voltage) + +# Display battery status +while True: + voltage = BatteryManager.read_battery_voltage() + percentage = BatteryManager.get_battery_percentage() + + if percentage > 80: + status = "🔋 Full" + elif percentage > 50: + status = "🔋 Good" + elif percentage > 20: + status = "⚠️ Low" + else: + status = "🔴 Critical" + + print(f"{status}: {voltage:.2f}V ({percentage:.0f}%)") + time.sleep(5) +``` + +## Example: Real-time Battery Monitoring in UI + +```python +from mpos import BatteryManager, Activity +import lvgl as lv + +class BatteryMonitor(Activity): + def onCreate(self): + screen = lv.obj() + self.battery_label = lv.label(screen) + self.battery_label.set_text("Battery: --V") + self.setContentView(screen) + + def onResume(self, screen): + super().onResume(screen) + + def update_battery(timer): + voltage = BatteryManager.read_battery_voltage() + percentage = BatteryManager.get_battery_percentage() + text = f"Battery: {voltage:.2f}V ({percentage:.0f}%)" + self.update_ui_threadsafe_if_foreground( + self.battery_label.set_text, text + ) + + # Update every 15 seconds + lv.timer_create(update_battery, 15000, None) +``` + +## Board-Specific Configuration + +### Fri3d 2024 + +```python +# GPIO13 (ADC2) with calibrated conversion function +def adc_to_voltage(adc_value): + return adc_value * 0.001651 + 0.08709 + +BatteryManager.init_adc(13, adc_to_voltage) +``` + +### Waveshare ESP32-S3 Touch LCD + +```python +# GPIO5 (ADC1) - no WiFi interference +def adc_to_voltage(adc_value): + return adc_value * 0.001651 + 0.08709 + +BatteryManager.init_adc(5, adc_to_voltage) +``` + +### Desktop (Linux) + +```python +# Simulated ADC (returns random values) +def adc_to_voltage(adc_value): + return adc_value * 0.001651 + 0.08709 + +BatteryManager.init_adc(999, adc_to_voltage) +``` + +## Testing + +BatteryManager includes comprehensive unit tests: + +```bash +./MicroPythonOS/tests/unittest.sh MicroPythonOS/tests/test_battery_voltage.py +``` + +Tests cover: +- ADC1/ADC2 pin detection +- Caching behavior +- WiFi coordination +- Voltage calculations +- Desktop mode simulation + +## Troubleshooting + +### "RuntimeError: WifiService is already busy" + +This occurs when trying to read ADC2 while WiFi is connecting or scanning. Solutions: + +1. Use ADC1 pins (GPIO1-10) instead if possible +2. Wait for WiFi to finish connecting before reading +3. Use cached value: `BatteryManager.read_battery_voltage()` (without force_refresh) + +### Inaccurate voltage readings + +1. Verify your ADC conversion function is calibrated correctly +2. Check that the voltage divider is properly connected +3. Ensure ADC attenuation is set to 11dB (0-3.3V range) +4. Use `force_refresh=True` to bypass cache and get fresh reading + +### Battery percentage always 0% or 100% + +1. Check MIN_VOLTAGE and MAX_VOLTAGE constants match your battery +2. Verify ADC conversion function is correct +3. Test with known voltage: `BatteryManager.get_battery_percentage(raw_adc_value=2048)` + +## See Also + +- [`SensorManager`](sensor-manager.md) - For temperature and IMU sensors +- [`WifiService`](wifi-service.md) - For WiFi connectivity management +- [`DeviceInfo`](device-info.md) - For device information diff --git a/docs/frameworks/build-info.md b/docs/frameworks/build-info.md new file mode 100644 index 0000000..47faebc --- /dev/null +++ b/docs/frameworks/build-info.md @@ -0,0 +1,320 @@ +# BuildInfo + +BuildInfo is a singleton class that provides access to OS version and build information. It stores the release version and API level (SDK version) that identifies the MicroPythonOS build. + +## Overview + +BuildInfo centralizes OS version and build information in a single class with nested version attributes. This provides: + +- **Version Information** - Access the human-readable release version +- **API Level** - Get the SDK API level for compatibility checks +- **Build Identification** - Identify the exact OS build +- **Android-Inspired** - Follows Android's Build.VERSION pattern +- **Singleton Pattern** - Single source of truth for OS version + +## Architecture + +BuildInfo is implemented as a singleton with a nested `version` class containing version attributes: + +```python +class BuildInfo: + class version: + """Version information.""" + release = "0.7.0" # Human-readable version + sdk_int = 0 # API level +``` + +All version information is accessed through class attributes - no instance creation is needed. + +## Usage + +### Getting OS Version + +```python +from mpos import BuildInfo + +# Get human-readable version +version = BuildInfo.version.release +# Returns: "0.7.0" + +# Get API level +api_level = BuildInfo.version.sdk_int +# Returns: 0 +``` + +### Version Checking + +```python +from mpos import BuildInfo + +# Check if running specific version +if BuildInfo.version.release == "0.7.0": + print("Running MicroPythonOS 0.7.0") + +# Check API level +if BuildInfo.version.sdk_int >= 1: + print("API level 1 or higher") +``` + +### Version Comparison + +```python +from mpos import BuildInfo + +def parse_version(version_str): + """Parse version string into tuple.""" + return tuple(map(int, version_str.split('.'))) + +# Compare versions +current = parse_version(BuildInfo.version.release) +required = parse_version("0.7.0") + +if current >= required: + print("OS version meets requirements") +else: + print("OS version too old") +``` + +## API Reference + +### Class Attributes + +#### `BuildInfo.version.release` +Human-readable OS version string. + +**Type:** str + +**Example:** +```python +version = BuildInfo.version.release +# Returns: "0.7.0" +``` + +#### `BuildInfo.version.sdk_int` +API level (SDK version) as an integer. + +**Type:** int + +**Example:** +```python +api_level = BuildInfo.version.sdk_int +# Returns: 0 +``` + +## Practical Examples + +### Version Display in About Screen + +```python +from mpos import Activity, BuildInfo +import lvgl as lv + +class AboutActivity(Activity): + def onCreate(self): + screen = lv.obj() + + # Display version information + version_label = lv.label(screen) + version_label.set_text(f"Version: {BuildInfo.version.release}") + + api_label = lv.label(screen) + api_label.set_text(f"API Level: {BuildInfo.version.sdk_int}") + + self.setContentView(screen) +``` + +### Feature Availability Based on API Level + +```python +from mpos import BuildInfo + +class FeatureManager: + @staticmethod + def supports_feature(feature_name): + """Check if feature is available in current API level.""" + api_level = BuildInfo.version.sdk_int + + features = { + "camera": 1, + "wifi": 0, + "bluetooth": 2, + "nfc": 3 + } + + required_api = features.get(feature_name, float('inf')) + return api_level >= required_api + +# Usage +if FeatureManager.supports_feature("camera"): + # Enable camera features + pass +``` + +### Version-Specific Behavior + +```python +from mpos import BuildInfo + +def get_ui_style(): + """Get UI style based on OS version.""" + version = BuildInfo.version.release + + if version.startswith("0.7"): + return "modern" + elif version.startswith("0.6"): + return "classic" + else: + return "legacy" +``` + +### Compatibility Checking + +```python +from mpos import BuildInfo + +class AppCompatibility: + MIN_VERSION = "0.7.0" + MIN_API_LEVEL = 0 + + @classmethod + def is_compatible(cls): + """Check if app is compatible with current OS.""" + version = BuildInfo.version.release + api_level = BuildInfo.version.sdk_int + + # Check API level + if api_level < cls.MIN_API_LEVEL: + return False + + # Check version + current = tuple(map(int, version.split('.'))) + required = tuple(map(int, cls.MIN_VERSION.split('.'))) + + return current >= required + +# Usage +if AppCompatibility.is_compatible(): + print("App is compatible with this OS") +else: + print("App requires newer OS version") +``` + +### Build Information Logging + +```python +from mpos import BuildInfo +import logging + +def setup_logging(): + """Setup logging with build information.""" + logger = logging.getLogger(__name__) + + # Log build information + logger.info(f"MicroPythonOS {BuildInfo.version.release} (API {BuildInfo.version.sdk_int})") + + return logger +``` + +### Version-Specific Workarounds + +```python +from mpos import BuildInfo + +def apply_workarounds(): + """Apply version-specific workarounds.""" + version = BuildInfo.version.release + + if version == "0.7.0": + # Workaround for known issue in 0.7.0 + import gc + gc.collect() + + if BuildInfo.version.sdk_int < 1: + # Workaround for older API + print("Using legacy API compatibility mode") +``` + +### Telemetry with Version Info + +```python +from mpos import BuildInfo +import time + +class Telemetry: + @staticmethod + def get_system_context(): + """Get system context for telemetry.""" + return { + "os_version": BuildInfo.version.release, + "api_level": BuildInfo.version.sdk_int, + "timestamp": time.time() + } +``` + +## Implementation Details + +### File Structure + +``` +mpos/ +├── build_info.py # Core BuildInfo class +└── __init__.py # Exports BuildInfo +``` + +### Version Attributes + +The `BuildInfo.version` class contains: + +```python +class BuildInfo: + class version: + release = "0.7.0" # Human-readable version: "major.minor.patch" + sdk_int = 0 # API level: integer for compatibility checks +``` + +### Version Numbering + +- **release**: Semantic versioning (e.g., "0.7.0", "1.0.0") + - Major: Breaking changes + - Minor: New features + - Patch: Bug fixes + +- **sdk_int**: API level for feature availability + - Incremented when new APIs are added + - Used for compatibility checks + +## Design Patterns + +### Nested Class Pattern + +BuildInfo uses a nested `version` class to organize version-related attributes: + +```python +class BuildInfo: + class version: + release = "0.7.0" + sdk_int = 0 +``` + +This pattern groups related information and provides a clear namespace. + +### Class Attribute Access + +All attributes are class attributes, accessed directly without instantiation: + +```python +# No need for BuildInfo() or BuildInfo.get() +version = BuildInfo.version.release # Direct class attribute access +``` + +## Related Frameworks + +- **[DeviceInfo](device-info.md)** - Device hardware information +- **[TimeZone](time-zone.md)** - Timezone conversion and management +- **[DisplayMetrics](display-metrics.md)** - Display properties and metrics + +## See Also + +- [Architecture Overview](../architecture/overview.md) +- [Frameworks](../architecture/frameworks.md) +- [Creating Apps](../apps/creating-apps.md) diff --git a/docs/frameworks/connectivity-manager.md b/docs/frameworks/connectivity-manager.md new file mode 100644 index 0000000..9fa56a9 --- /dev/null +++ b/docs/frameworks/connectivity-manager.md @@ -0,0 +1,462 @@ +# ConnectivityManager + +MicroPythonOS provides a connectivity monitoring service called **ConnectivityManager** that tracks network status and notifies apps when connectivity changes. It's inspired by Android's ConnectivityManager API. + +## Overview + +ConnectivityManager provides: + +- **Connectivity monitoring** - Periodic checks of network status +- **Callback notifications** - Get notified when connectivity changes +- **Singleton pattern** - Single instance shared across all apps +- **Platform-agnostic** - Works on ESP32 and desktop +- **Android-like API** - Familiar patterns for Android developers + +## Quick Start + +### Checking Connectivity + +```python +from mpos import ConnectivityManager + +# Get the singleton instance +cm = ConnectivityManager.get() + +# Check if online (has internet) +if cm.is_online(): + print("Connected to internet") +else: + print("No internet connection") + +# Check if WiFi is connected (local network) +if cm.is_wifi_connected(): + print("WiFi connected") +``` + +### Registering for Connectivity Changes + +```python +from mpos import Activity, ConnectivityManager + +class MyActivity(Activity): + def onCreate(self): + self.cm = ConnectivityManager.get() + + def onResume(self, screen): + super().onResume(screen) + # Register for connectivity changes + self.cm.register_callback(self.on_connectivity_changed) + + # Update UI with current status + self.update_status(self.cm.is_online()) + + def onPause(self, screen): + super().onPause(screen) + # Unregister when activity is paused + self.cm.unregister_callback(self.on_connectivity_changed) + + def on_connectivity_changed(self, is_online): + """Called when connectivity status changes.""" + if is_online: + print("Now online!") + self.fetch_data() + else: + print("Gone offline") + self.show_offline_message() + + def update_status(self, is_online): + status = "Online" if is_online else "Offline" + self.status_label.set_text(status) +``` + +### Waiting for Connectivity + +```python +from mpos import ConnectivityManager + +def download_with_wait(): + cm = ConnectivityManager.get() + + # Wait up to 30 seconds for connectivity + if cm.wait_until_online(timeout=30): + print("Connected! Starting download...") + # Proceed with download + else: + print("Timeout waiting for connection") +``` + +## Common Patterns + +### Network-Aware App + +```python +from mpos import Activity, ConnectivityManager, TaskManager, DownloadManager +import lvgl as lv + +class NewsReaderActivity(Activity): + def onCreate(self): + self.screen = lv.obj() + self.cm = ConnectivityManager.get() + + # Status indicator + self.status_icon = lv.label(self.screen) + self.status_icon.align(lv.ALIGN.TOP_RIGHT, -10, 10) + + # Content area + self.content = lv.label(self.screen) + self.content.set_width(280) + self.content.align(lv.ALIGN.CENTER, 0, 0) + + self.setContentView(self.screen) + + def onResume(self, screen): + super().onResume(screen) + self.cm.register_callback(self.on_connectivity_changed) + self.update_ui() + + def onPause(self, screen): + super().onPause(screen) + self.cm.unregister_callback(self.on_connectivity_changed) + + def on_connectivity_changed(self, is_online): + self.update_ui() + if is_online: + TaskManager.create_task(self.fetch_news()) + + def update_ui(self): + if self.cm.is_online(): + self.status_icon.set_text(lv.SYMBOL.WIFI) + else: + self.status_icon.set_text(lv.SYMBOL.WARNING) + self.content.set_text("No internet connection") + + async def fetch_news(self): + data = await DownloadManager.download_url( + "https://api.example.com/news" + ) + if data: + self.content.set_text(data.decode()) +``` + +### Retry on Reconnect + +```python +from mpos import ConnectivityManager, TaskManager, DownloadManager + +class SyncManager: + def __init__(self): + self.cm = ConnectivityManager.get() + self.pending_sync = False + self.cm.register_callback(self.on_connectivity_changed) + + def sync_data(self): + if self.cm.is_online(): + TaskManager.create_task(self._do_sync()) + else: + # Mark for retry when online + self.pending_sync = True + print("Offline - sync queued") + + def on_connectivity_changed(self, is_online): + if is_online and self.pending_sync: + print("Back online - retrying sync") + self.pending_sync = False + TaskManager.create_task(self._do_sync()) + + async def _do_sync(self): + success = await DownloadManager.download_url( + "https://api.example.com/sync", + # ... sync data + ) + if not success: + self.pending_sync = True +``` + +### Conditional Feature Loading + +```python +from mpos import ConnectivityManager + +class AppStoreActivity(Activity): + def onCreate(self): + self.cm = ConnectivityManager.get() + + if self.cm.is_online(): + # Load full app store with remote data + self.load_remote_apps() + else: + # Show only installed apps + self.load_local_apps() + self.show_offline_banner() +``` + +## API Reference + +### Getting the Instance + +#### `ConnectivityManager.get()` + +Get the singleton ConnectivityManager instance. + +**Returns:** +- `ConnectivityManager` - The singleton instance + +**Example:** +```python +cm = ConnectivityManager.get() +``` + +**Note:** The first call initializes the manager and starts periodic connectivity checks. + +### Connectivity Status + +#### `is_online()` + +Check if the device has internet connectivity. + +**Returns:** +- `bool` - `True` if online, `False` otherwise + +**Example:** +```python +if cm.is_online(): + print("Internet available") +``` + +--- + +#### `is_wifi_connected()` + +Check if WiFi is connected (local network). + +**Returns:** +- `bool` - `True` if WiFi connected, `False` otherwise + +**Note:** A device can be WiFi-connected but not online (e.g., no internet on the network). + +**Example:** +```python +if cm.is_wifi_connected(): + print("WiFi connected") +``` + +--- + +#### `wait_until_online(timeout=60)` + +Block until the device is online or timeout expires. + +**Parameters:** +- `timeout` (int) - Maximum seconds to wait (default: 60) + +**Returns:** +- `bool` - `True` if online, `False` if timeout expired + +**Example:** +```python +if cm.wait_until_online(timeout=30): + print("Connected!") +else: + print("Timeout - still offline") +``` + +### Callback Management + +#### `register_callback(callback)` + +Register a callback to be notified of connectivity changes. + +**Parameters:** +- `callback` (callable) - Function that takes one boolean parameter (`is_online`) + +**Example:** +```python +def on_change(is_online): + print(f"Connectivity: {'online' if is_online else 'offline'}") + +cm.register_callback(on_change) +``` + +--- + +#### `unregister_callback(callback)` + +Unregister a previously registered callback. + +**Parameters:** +- `callback` (callable) - The callback to remove + +**Example:** +```python +cm.unregister_callback(on_change) +``` + +**Important:** Always unregister callbacks when your activity is paused or destroyed to prevent memory leaks and stale references. + +## Monitoring Behavior + +ConnectivityManager uses a periodic timer to check connectivity: + +| Setting | Value | +|---------|-------| +| Check interval | 8 seconds | +| Timer ID | 1 (Timer 0 is used by task_handler) | +| Check method | `wlan.isconnected()` | + +### How Connectivity is Determined + +1. **ESP32/Hardware:** + - Uses `network.WLAN(network.STA_IF).isconnected()` + - Returns `True` if WiFi is connected to an access point + +2. **Desktop/Linux:** + - No network module available + - Always reports as "connected" for testing + +### Callback Timing + +- Callbacks are only called when status **changes** +- Initial status is checked at startup (no callback) +- Callbacks receive `True` when going online, `False` when going offline + +```python +# Example callback sequence: +# Boot: offline (no callback - initial state) +# WiFi connects: callback(True) +# WiFi disconnects: callback(False) +# WiFi reconnects: callback(True) +``` + +## Platform Differences + +| Platform | Behavior | +|----------|----------| +| **ESP32** | Real WiFi monitoring via `network` module | +| **Desktop** | Always reports online (no network module) | + +### Desktop Testing + +On desktop, ConnectivityManager always reports as connected: + +```python +cm = ConnectivityManager.get() +print(cm.is_online()) # Always True on desktop +print(cm.is_wifi_connected()) # Always True on desktop +``` + +This allows apps to be tested without actual network connectivity. + +## Troubleshooting + +### Callbacks Not Being Called + +**Symptom:** Registered callback never fires + +**Possible causes:** +1. Connectivity never changes +2. Callback was unregistered +3. Callback raises exception (silently caught) + +**Solution:** +```python +# Check current status +print(f"Current status: {cm.is_online()}") + +# Verify callback is registered +print(f"Callbacks: {len(cm.callbacks)}") + +# Ensure callback doesn't raise exceptions +def safe_callback(is_online): + try: + # Your logic here + pass + except Exception as e: + print(f"Callback error: {e}") + +cm.register_callback(safe_callback) +``` + +### Memory Leak from Callbacks + +**Symptom:** Memory usage grows over time + +**Cause:** Callbacks not unregistered when activity destroyed + +**Solution:** +```python +class MyActivity(Activity): + def onResume(self, screen): + super().onResume(screen) + self.cm = ConnectivityManager.get() + self.cm.register_callback(self.on_change) + + def onPause(self, screen): + super().onPause(screen) + # Always unregister! + self.cm.unregister_callback(self.on_change) +``` + +### Status Always Shows Online + +**Symptom:** `is_online()` returns `True` even when disconnected + +**Cause:** Running on desktop (no network module) + +**Solution:** This is expected behavior on desktop. Test on real hardware for accurate connectivity status. + +### Timer Conflict + +**Symptom:** Strange behavior with other timers + +**Cause:** ConnectivityManager uses Timer(1) + +**Solution:** Use different timer IDs in your app: +```python +from machine import Timer + +# ConnectivityManager uses Timer(1) +# task_handler uses Timer(0) +# Use Timer(2) or higher for your app +my_timer = Timer(2) +``` + +## Integration with WifiService + +ConnectivityManager works alongside WifiService: + +- **WifiService** - Manages WiFi connections (connect, disconnect, scan) +- **ConnectivityManager** - Monitors connection status and notifies apps + +```python +from mpos import WifiService, ConnectivityManager + +# WifiService for connection management +WifiService.attempt_connecting("MyNetwork", "password") + +# ConnectivityManager for status monitoring +cm = ConnectivityManager.get() +cm.register_callback(lambda online: print(f"Status: {online}")) +``` + +## Implementation Details + +**Location:** `MicroPythonOS/internal_filesystem/lib/mpos/net/connectivity_manager.py` + +**Pattern:** Singleton with periodic timer + +**Key features:** +- `_instance` - Class-level singleton instance +- `callbacks` - List of registered callback functions +- `_check_timer` - Machine Timer for periodic checks +- `_is_online` - Cached online status + +**Dependencies:** +- `network` module (MicroPython, optional) +- `machine.Timer` - Periodic connectivity checks +- `requests` / `usocket` - Imported but not currently used for connectivity checks + +## See Also + +- [WifiService](wifi-service.md) - WiFi connection management +- [DownloadManager](download-manager.md) - HTTP downloads +- [TaskManager](task-manager.md) - Async task management diff --git a/docs/frameworks/device-info.md b/docs/frameworks/device-info.md new file mode 100644 index 0000000..b5540c0 --- /dev/null +++ b/docs/frameworks/device-info.md @@ -0,0 +1,276 @@ +# DeviceInfo + +DeviceInfo is a singleton class that provides access to device hardware information. It stores and retrieves the hardware identifier that uniquely identifies the device. + +## Overview + +DeviceInfo centralizes device hardware information in a single class with class methods. This provides: + +- **Hardware Identification** - Access the unique device/hardware identifier +- **Unified API** - Single class for all device information +- **Boot Integration** - Set during system initialization +- **Android-Inspired** - Follows Android's DeviceInfo pattern +- **Singleton Pattern** - Single source of truth for device identity + +## Architecture + +DeviceInfo is implemented as a singleton using class variables and class methods: + +```python +class DeviceInfo: + hardware_id = "missing-hardware-info" + + @classmethod + def set_hardware_id(cls, device_id): + """Set the device/hardware identifier (called during boot).""" + cls.hardware_id = device_id + + @classmethod + def get_hardware_id(cls): + """Get the hardware identifier.""" + return cls.hardware_id +``` + +No instance creation is needed - all methods are class methods that operate on class variables. + +## Usage + +### Getting Device Hardware ID + +```python +from mpos import DeviceInfo + +# Get the hardware identifier +hw_id = DeviceInfo.get_hardware_id() +# Returns: e.g., "esp32s3-12345678" or "desktop-build" +``` + +### Checking Device Identity + +```python +from mpos import DeviceInfo + +# Check if running on specific hardware +hw_id = DeviceInfo.get_hardware_id() + +if hw_id.startswith("esp32s3"): + print("Running on ESP32-S3 hardware") +elif hw_id.startswith("desktop"): + print("Running on desktop") +else: + print(f"Unknown hardware: {hw_id}") +``` + +### Device-Specific Configuration + +```python +from mpos import DeviceInfo + +def get_device_config(): + """Get configuration based on device hardware.""" + hw_id = DeviceInfo.get_hardware_id() + + if "esp32s3" in hw_id: + return { + "max_memory": 8 * 1024 * 1024, # 8MB PSRAM + "has_camera": True, + "has_battery": True + } + else: + return { + "max_memory": 512 * 1024 * 1024, # 512MB + "has_camera": False, + "has_battery": False + } +``` + +## API Reference + +### Class Methods + +#### `set_hardware_id(device_id)` +Set the device/hardware identifier. Called during system boot. + +**Parameters:** +- `device_id` (str): The hardware identifier string (e.g., "esp32s3-12345678") + +**Example:** +```python +DeviceInfo.set_hardware_id("esp32s3-abcdef123456") +``` + +#### `get_hardware_id()` +Get the hardware identifier. + +**Returns:** str - The hardware identifier + +**Example:** +```python +hw_id = DeviceInfo.get_hardware_id() +# Returns: "esp32s3-abcdef123456" +``` + +## Practical Examples + +### Device Detection in Apps + +```python +from mpos import Activity, DeviceInfo +import lvgl as lv + +class MyApp(Activity): + def onCreate(self): + screen = lv.obj() + + # Show device info + hw_id = DeviceInfo.get_hardware_id() + label = lv.label(screen) + label.set_text(f"Device: {hw_id}") + + self.setContentView(screen) +``` + +### Hardware-Specific Feature Detection + +```python +from mpos import DeviceInfo + +class FeatureDetector: + @staticmethod + def has_camera(): + """Check if device has camera.""" + hw_id = DeviceInfo.get_hardware_id() + return "esp32s3" in hw_id + + @staticmethod + def has_battery(): + """Check if device has battery.""" + hw_id = DeviceInfo.get_hardware_id() + return "esp32s3" in hw_id + + @staticmethod + def is_desktop(): + """Check if running on desktop.""" + hw_id = DeviceInfo.get_hardware_id() + return "desktop" in hw_id + +# Usage +if FeatureDetector.has_camera(): + # Enable camera features + pass +``` + +### Device Logging + +```python +from mpos import DeviceInfo +import logging + +def setup_logging(): + """Setup logging with device information.""" + hw_id = DeviceInfo.get_hardware_id() + logger = logging.getLogger(__name__) + + # Include device ID in log messages + logger.info(f"App started on device: {hw_id}") + + return logger +``` + +### Device-Specific Debugging + +```python +from mpos import DeviceInfo + +def debug_info(): + """Print device debug information.""" + hw_id = DeviceInfo.get_hardware_id() + + print(f"Hardware ID: {hw_id}") + print(f"Device Type: {'ESP32-S3' if 'esp32s3' in hw_id else 'Desktop'}") + + # Can be used for conditional debugging + if "esp32s3" in hw_id: + print("Running on embedded hardware - memory constrained") + else: + print("Running on desktop - full resources available") +``` + +### Device Telemetry + +```python +from mpos import DeviceInfo + +class Telemetry: + @staticmethod + def get_device_context(): + """Get device context for telemetry.""" + hw_id = DeviceInfo.get_hardware_id() + + return { + "device_id": hw_id, + "device_type": "esp32s3" if "esp32s3" in hw_id else "desktop", + "timestamp": time.time() + } +``` + +## Implementation Details + +### File Structure + +``` +mpos/ +├── device_info.py # Core DeviceInfo class +└── __init__.py # Exports DeviceInfo +``` + +### Initialization Flow + +1. **System Boot** - MicroPythonOS initializes +2. **Hardware Detection** - System detects hardware type +3. **Set Device ID** - Calls `DeviceInfo.set_hardware_id()` with detected hardware ID +4. **Ready to Use** - Apps can now call `DeviceInfo.get_hardware_id()` + +```python +# During system initialization +hw_id = detect_hardware() # e.g., "esp32s3-12345678" +DeviceInfo.set_hardware_id(hw_id) +``` + +## Design Patterns + +### Singleton Pattern + +DeviceInfo uses the singleton pattern with class variables and class methods: + +```python +class DeviceInfo: + hardware_id = "missing-hardware-info" # Shared across all "instances" + + @classmethod + def get_hardware_id(cls): + return cls.hardware_id +``` + +This ensures a single source of truth for device identity. + +### Class Method Delegation + +All methods are class methods, so no instance is needed: + +```python +# No need for DeviceInfo() or DeviceInfo.get() +hw_id = DeviceInfo.get_hardware_id() # Direct class method call +``` + +## Related Frameworks + +- **[BuildInfo](build-info.md)** - OS version and build information +- **[TimeZone](time-zone.md)** - Timezone conversion and management +- **[DisplayMetrics](display-metrics.md)** - Display properties and metrics + +## See Also + +- [Architecture Overview](../architecture/overview.md) +- [Frameworks](../architecture/frameworks.md) +- [Creating Apps](../apps/creating-apps.md) diff --git a/docs/frameworks/display-metrics.md b/docs/frameworks/display-metrics.md new file mode 100644 index 0000000..1d61d85 --- /dev/null +++ b/docs/frameworks/display-metrics.md @@ -0,0 +1,329 @@ +# DisplayMetrics + +DisplayMetrics is an Android-inspired singleton class that provides a unified, clean API for accessing display properties like width, height, DPI, and pointer coordinates. + +## Overview + +Instead of scattered module-level functions, DisplayMetrics centralizes all display-related metrics in a single class with class methods. This provides: + +- **Unified API** - Single class for all display metrics +- **Clean Namespace** - No scattered functions cluttering imports +- **Testable** - DisplayMetrics can be tested independently +- **Android-Inspired** - Follows Android's DisplayMetrics pattern +- **Elegant Initialization** - Setters called from `init_rootscreen()` + +## Architecture + +DisplayMetrics is implemented as a singleton using class variables and class methods: + +```python +class DisplayMetrics: + _width = None + _height = None + _dpi = None + + @classmethod + def set_resolution(cls, width, height): + """Set the display resolution (called by init_rootscreen).""" + cls._width = width + cls._height = height + + @classmethod + def width(cls): + """Get display width in pixels.""" + return cls._width + + # ... other methods +``` + +No instance creation is needed - all methods are class methods that operate on class variables. + +## Usage + +### Basic Display Metrics + +```python +from mpos import DisplayMetrics + +# Get display dimensions +width = DisplayMetrics.width() # e.g., 320 +height = DisplayMetrics.height() # e.g., 240 +dpi = DisplayMetrics.dpi() # e.g., 130 +``` + +### Percentage-Based Calculations + +```python +from mpos import DisplayMetrics + +# Get percentage of display width/height +half_width = DisplayMetrics.pct_of_width(50) # 50% of width +quarter_height = DisplayMetrics.pct_of_height(25) # 25% of height +``` + +### Dimension Queries + +```python +from mpos import DisplayMetrics + +# Get min/max dimensions +min_dim = DisplayMetrics.min_dimension() # min(width, height) +max_dim = DisplayMetrics.max_dimension() # max(width, height) +``` + +### Pointer/Touch Coordinates + +```python +from mpos import DisplayMetrics + +# Get current pointer/touch position +x, y = DisplayMetrics.pointer_xy() +if x == -1 and y == -1: + print("No active input device") +else: + print(f"Pointer at ({x}, {y})") +``` + +## API Reference + +### Class Methods + +#### `set_resolution(width, height)` +Set the display resolution. Called by `init_rootscreen()` during initialization. + +**Parameters:** +- `width` (int): Display width in pixels +- `height` (int): Display height in pixels + +**Example:** +```python +DisplayMetrics.set_resolution(320, 240) +``` + +#### `set_dpi(dpi)` +Set the display DPI. Called by `init_rootscreen()` during initialization. + +**Parameters:** +- `dpi` (int): Display DPI (dots per inch) + +**Example:** +```python +DisplayMetrics.set_dpi(130) +``` + +#### `width()` +Get display width in pixels. + +**Returns:** int - Display width + +**Example:** +```python +w = DisplayMetrics.width() # 320 +``` + +#### `height()` +Get display height in pixels. + +**Returns:** int - Display height + +**Example:** +```python +h = DisplayMetrics.height() # 240 +``` + +#### `dpi()` +Get display DPI (dots per inch). + +**Returns:** int - Display DPI + +**Example:** +```python +dpi = DisplayMetrics.dpi() # 130 +``` + +#### `pct_of_width(pct)` +Get percentage of display width. + +**Parameters:** +- `pct` (int): Percentage (0-100) + +**Returns:** int - Pixel value + +**Example:** +```python +half_width = DisplayMetrics.pct_of_width(50) # 160 (50% of 320) +``` + +#### `pct_of_height(pct)` +Get percentage of display height. + +**Parameters:** +- `pct` (int): Percentage (0-100) + +**Returns:** int - Pixel value + +**Example:** +```python +quarter_height = DisplayMetrics.pct_of_height(25) # 60 (25% of 240) +``` + +#### `min_dimension()` +Get minimum dimension (width or height). + +**Returns:** int - Minimum of width and height + +**Example:** +```python +min_dim = DisplayMetrics.min_dimension() # 240 +``` + +#### `max_dimension()` +Get maximum dimension (width or height). + +**Returns:** int - Maximum of width and height + +**Example:** +```python +max_dim = DisplayMetrics.max_dimension() # 320 +``` + +#### `pointer_xy()` +Get current pointer/touch coordinates. + +**Returns:** tuple - (x, y) coordinates, or (-1, -1) if no active input device + +**Example:** +```python +x, y = DisplayMetrics.pointer_xy() +if x != -1: + print(f"Touch at ({x}, {y})") +``` + +## Practical Examples + +### Responsive Layout + +```python +from mpos import Activity, DisplayMetrics +import lvgl as lv + +class MyApp(Activity): + def onCreate(self): + screen = lv.obj() + + # Create a button that's 50% of display width + button = lv.button(screen) + button.set_width(DisplayMetrics.pct_of_width(50)) + button.set_height(DisplayMetrics.pct_of_height(20)) + button.center() + + self.setContentView(screen) +``` + +### Adaptive UI Based on Screen Size + +```python +from mpos import DisplayMetrics + +def get_font_size(): + """Return appropriate font size based on display size.""" + min_dim = DisplayMetrics.min_dimension() + + if min_dim < 200: + return lv.font_montserrat_12 + elif min_dim < 300: + return lv.font_montserrat_16 + else: + return lv.font_montserrat_20 +``` + +### Touch Event Handling + +```python +from mpos import DisplayMetrics +import lvgl as lv + +def handle_scroll(event): + """Handle scroll event with pointer coordinates.""" + x, y = DisplayMetrics.pointer_xy() + + if x == -1: + print("No active input device") + return + + # Check if touch is in specific region + if x < DisplayMetrics.pct_of_width(50): + print("Touch in left half") + else: + print("Touch in right half") +``` + +## Implementation Details + +### File Structure + +``` +mpos/ui/ +├── display_metrics.py # Core DisplayMetrics class +├── display.py # Initialization (init_rootscreen) +└── __init__.py # Exports DisplayMetrics +``` + +### Initialization Flow + +1. **System Startup** - `init_rootscreen()` is called +2. **Query Display** - Gets resolution and DPI from LVGL +3. **Set Metrics** - Calls `DisplayMetrics.set_resolution()` and `DisplayMetrics.set_dpi()` +4. **Ready to Use** - Apps can now call DisplayMetrics methods + +```python +# In display.py +def init_rootscreen(): + screen = lv.screen_active() + disp = screen.get_display() + width = disp.get_horizontal_resolution() + height = disp.get_vertical_resolution() + dpi = disp.get_dpi() + + # Initialize DisplayMetrics + DisplayMetrics.set_resolution(width, height) + DisplayMetrics.set_dpi(dpi) +``` + +## Design Patterns + +### Singleton Pattern + +DisplayMetrics uses the singleton pattern with class variables and class methods: + +```python +class DisplayMetrics: + _width = None # Shared across all "instances" + + @classmethod + def width(cls): + return cls._width +``` + +This avoids the need for instance creation while maintaining a single source of truth. + +### Class Method Delegation + +All methods are class methods, so no instance is needed: + +```python +# No need for DisplayMetrics() or DisplayMetrics.get() +width = DisplayMetrics.width() # Direct class method call +``` + +## Related Frameworks + +- **[AudioManager](audiomanager.md)** - Audio management (similar singleton pattern) +- **[ConnectivityManager](connectivity-manager.md)** - Network connectivity +- **[SensorManager](sensor-manager.md)** - Sensor access + +## See Also + +- [Architecture Overview](../architecture/overview.md) +- [Frameworks](../architecture/frameworks.md) +- [Creating Apps](../apps/creating-apps.md) diff --git a/docs/frameworks/download-manager.md b/docs/frameworks/download-manager.md new file mode 100644 index 0000000..ab9b0ab --- /dev/null +++ b/docs/frameworks/download-manager.md @@ -0,0 +1,336 @@ +# DownloadManager + +Centralized HTTP download service with flexible output modes. + +## Overview + +- **Three output modes** - Download to memory, file, or stream with callbacks +- **Retry logic** - Automatic retry on chunk failures (3 attempts) +- **Progress tracking** - Real-time download progress callbacks +- **Resume support** - HTTP Range headers for partial downloads +- **Memory efficient** - Chunked downloads (1KB chunks) + +## Quick Start + +### Async Download to Memory + +```python +from mpos import DownloadManager + +data = await DownloadManager.download_url("https://api.example.com/data.json") +if data: + import json + parsed = json.loads(data) +``` + +### Sync Download to Memory + +```python +from mpos import DownloadManager + +# Synchronous usage - no await needed +data = DownloadManager.download_url("https://api.example.com/data.json") +if data: + import json + parsed = json.loads(data) +``` + +### Download to File + +```python +success = await DownloadManager.download_url( + "https://example.com/file.bin", + outfile="/sdcard/file.bin" +) +``` + +### Download with Progress + +```python +async def update_progress(percent): + print(f"Downloaded: {percent}%") + +success = await DownloadManager.download_url( + "https://example.com/large_file.bin", + outfile="/sdcard/large_file.bin", + progress_callback=update_progress +) +``` + +### Stream Processing + +```python +async def process_chunk(chunk): + print(f"Got {len(chunk)} bytes") + +success = await DownloadManager.download_url( + "https://example.com/stream", + chunk_callback=process_chunk +) +``` + +## Sync/Async Compatibility + +The `DownloadManager.download_url()` method automatically detects whether it's called from an async or sync context: + +- **From async context**: Returns a coroutine that can be awaited +- **From sync context**: Runs synchronously and returns the result directly + +This means you can use the same API in both async and sync code without any wrapper functions. + +## API Reference + +### `DownloadManager.download_url()` + +```python +def download_url(url, outfile=None, total_size=None, + progress_callback=None, chunk_callback=None, + headers=None, speed_callback=None) +``` + +**Note:** This method works in both async and sync contexts. When called from an async function, it returns a coroutine. When called from a sync function, it runs synchronously. + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `url` | str | URL to download (required) | +| `outfile` | str | Path to write file (optional) | +| `total_size` | int | Expected size in bytes for progress tracking (optional) | +| `progress_callback` | async function | Callback for progress updates (optional) | +| `chunk_callback` | async function | Callback for streaming chunks (optional) | +| `headers` | dict | Custom HTTP headers (optional) | +| `speed_callback` | async function | Callback for download speed (optional) | + +**Returns:** +- **Memory mode**: `bytes` on success +- **File mode**: `True` on success +- **Stream mode**: `True` on success + +**Raises:** +- `ValueError` - If both `outfile` and `chunk_callback` are provided +- `OSError` - On network or file I/O errors + +### `DownloadManager.is_network_error(exception)` + +Check if an exception is a recoverable network error. + +```python +try: + await DownloadManager.download_url(url) +except Exception as e: + if DownloadManager.is_network_error(e): + # Network error - retry + await asyncio.sleep(2) + else: + # Fatal error + raise +``` + +**Detected errors:** +- Error codes: `-110` (ETIMEDOUT), `-113` (ECONNABORTED), `-104` (ECONNRESET), `-118` (EHOSTUNREACH), `-202` (DNS error) +- Error messages: "connection reset", "connection aborted", "broken pipe", "network unreachable", "host unreachable" + +### `DownloadManager.get_resume_position(outfile)` + +Get the current size of a partially downloaded file. + +```python +resume_from = DownloadManager.get_resume_position("/sdcard/file.bin") +if resume_from > 0: + headers = {'Range': f'bytes={resume_from}-'} + await DownloadManager.download_url(url, outfile=outfile, headers=headers) +else: + await DownloadManager.download_url(url, outfile=outfile) +``` + +## Common Patterns + +### Download with Timeout + +```python +from mpos import TaskManager, DownloadManager + +async def download_with_timeout(url, timeout=10): + try: + data = await TaskManager.wait_for( + DownloadManager.download_url(url), + timeout=timeout + ) + return data + except asyncio.TimeoutError: + print(f"Download timed out after {timeout}s") + return None +``` + +### Download Multiple Files Concurrently + +```python +async def download_icons(apps): + for app in apps: + if not app.icon_data: + try: + app.icon_data = await TaskManager.wait_for( + DownloadManager.download_url(app.icon_url), + timeout=5 + ) + except Exception as e: + print(f"Icon download failed: {e}") +``` + +### Resume Partial Download + +```python +async def resume_download(url, outfile): + bytes_written = DownloadManager.get_resume_position(outfile) + + if bytes_written > 0: + headers = {'Range': f'bytes={bytes_written}-'} + else: + headers = None + + success = await DownloadManager.download_url( + url, + outfile=outfile, + headers=headers + ) + return success +``` + +### Error Handling with Retry + +```python +async def robust_download(url, outfile, max_retries=3): + for attempt in range(max_retries): + try: + success = await DownloadManager.download_url(url, outfile=outfile) + if success: + return True + except Exception as e: + if DownloadManager.is_network_error(e): + if attempt < max_retries - 1: + await asyncio.sleep(2) + continue + else: + raise + return False +``` + +## Performance Considerations + +### Memory Usage + +- Chunk buffer: 1KB +- Progress callback overhead: ~50 bytes +- Total per download: ~1-2KB + +### Concurrent Downloads + +Limit to 5-10 concurrent downloads. Use timeouts to prevent stuck downloads. + +```python +async def download_batch(urls, max_concurrent=5): + for i in range(0, len(urls), max_concurrent): + batch = urls[i:i+max_concurrent] + for url in batch: + try: + data = await TaskManager.wait_for( + DownloadManager.download_url(url), + timeout=10 + ) + except Exception as e: + print(f"Download failed: {e}") +``` + +### Large Files + +Always download large files to disk, not memory: + +```python +# Bad: OOM risk +data = await DownloadManager.download_url(large_url) + +# Good: Streams to disk +success = await DownloadManager.download_url( + large_url, + outfile="/sdcard/large.bin" +) +``` + +## Troubleshooting + +### Download Fails (Raises Exception) + +**Possible causes:** +1. Network not connected +2. Invalid URL +3. HTTP error (404, 500, etc.) +4. Server timeout +5. SSL/TLS error + +**Solution:** +```python +# Check network first +try: + import network + if not network.WLAN(network.STA_IF).isconnected(): + print("WiFi not connected!") + return +except ImportError: + pass # Desktop mode + +# Download with error handling +try: + data = await DownloadManager.download_url(url) +except OSError as e: + print(f"Download failed: {e}") +``` + +### Progress Callback Not Called + +**Possible causes:** +1. Server doesn't send Content-Length +2. total_size not provided +3. Download too fast (single chunk) + +**Solution:** +```python +# Provide explicit size +await DownloadManager.download_url( + url, + total_size=expected_size, + progress_callback=callback +) +``` + +### ValueError Exception + +**Cause:** Both `outfile` and `chunk_callback` provided + +**Solution:** +```python +# Choose one output mode +await DownloadManager.download_url(url, outfile="file.bin") +# Or: +await DownloadManager.download_url(url, chunk_callback=process) +``` + +## Implementation + +**Location:** `MicroPythonOS/internal_filesystem/lib/mpos/net/download_manager.py` + +**Key features:** +- Per-request aiohttp sessions +- Automatic retry on chunk failures +- Thread-safe for concurrent downloads +- Graceful degradation on desktop if aiohttp unavailable + +**Dependencies:** +- `aiohttp` - HTTP client library +- `mpos.TaskManager` - For timeout handling + +## See Also + +- [TaskManager](task-manager.md) - Async task management +- [ConnectivityManager](connectivity-manager.md) - Network connectivity monitoring +- [SharedPreferences](preferences.md) - Persistent storage diff --git a/docs/frameworks/emoji_frequency_canonical.html b/docs/frameworks/emoji_frequency_canonical.html new file mode 100644 index 0000000..d0758b3 --- /dev/null +++ b/docs/frameworks/emoji_frequency_canonical.html @@ -0,0 +1,1507 @@ + + + + + + Emoji Frequency (Canonical) + + + +
+

Emoji Frequency (Canonical)

+

1429 rows

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
rankfrequency_groupposition_in_grouphtml_entityemojiunicode_codepoint_hexpromotedcanonical_groupgroup_typegroup_sizegroup_membersdropped_similar
0637&amp;#9889…26A1yessingle::⚡single1
0731&amp;#x1f4…👎1F44Eyessingle::👎single1👎
00🥳🥳1F973yessynthetic::🥳single1🥳
101&amp;#x1f6…😂1F602nomanual::tears_laughingmanual2😂 🤣🤣
202&amp;#1008…❤️2764-FE0Fnomanual::heartsmanual16❤️ 💕 ♥️ 💔 💖 💙 💜 💞 💗 💚 💛 💓 🖤 ❣️ 💘 💝💕 ♥️ 💔 💖 💙 💜 💞 💗 💚 💛 💓 🖤 ❣️ 💘 💝
311&amp;#x1f6…😍1F60Dnomanual::smile_lovemanual4😍 😻 😈 😹😻 😈 😹
421&amp;#x1f6…😊1F60Anosingle::😊single1😊
522&amp;#x1f6…🙏1F64Fnosingle::🙏single1🙏
624&amp;#x1f6…😭1F62Dnosingle::😭single1😭
725&amp;#x1f6…😘1F618nosingle::😘single1😘
831&amp;#x1f4…👍1F44Dnomanual::good_handmanual4👍 👏 👌 🙌👏 👌 🙌
932&amp;#x1f6…😅1F605nosingle::😅single1😅
1034&amp;#x1f6…😁1F601nomanual::smile_beammanual2😁 😄😄
1136&amp;#x1f5…🔥1F525nosingle::🔥single1🔥
12310&amp;#x1f6…😢1F622nomanual::sad_crymanual4😢 😪 😥 😓😪 😥 😓
13311&amp;#x1f9…🤔1F914nosingle::🤔single1🤔
14312&amp;#x1f6…😆1F606nosingle::😆single1😆
15313&amp;#x1f6…🙄1F644nosingle::🙄single1🙄
16314&amp;#x1f4…💪1F4AAnosingle::💪single1💪
17315&amp;#x1f6…😉1F609nosingle::😉single1😉
18316&amp;#9786…☺️263A-FE0Fnosingle::☺️single1☺️
19318&amp;#x1f9…🤗1F917nosingle::🤗single1🤗
2042&amp;#x1f6…😔1F614nosingle::😔single1😔
2143&amp;#x1f6…😎1F60Enosingle::😎single1😎
2244&amp;#x1f6…😇1F607nosingle::😇single1😇
2345&amp;#x1f3…🌹1F339nomanual::flower_groupmanual7🌹 🌸 🌷 🎊 🌺 🌻 🌼🌸 🌷 🎊 🌺 🌻 🌼
2446&amp;#x1f9…🤦1F926nosingle::🤦single1🤦
2547&amp;#x1f3…🎉1F389nosingle::🎉single1🎉
2648&amp;#8252…‼️203C-FE0Fnomanual::exclamationmanual2‼️ ❗
27410&amp;#9996…✌️270C-FE0Fnosingle::✌️single1✌️
28411&amp;#1002…2728nosingle::✨single1
29412&amp;#x1f9…🤷1F937nosingle::🤷single1🤷
30413&amp;#x1f6…😱1F631nosingle::😱single1😱
31414&amp;#x1f6…😌1F60Cnosingle::😌single1😌
32417&amp;#x1f6…😋1F60Bnomanual::tongue_groupmanual2😋 😛😛
33420&amp;#x1f6…😏1F60Fnosingle::😏single1😏
34422&amp;#x1f6…🙂1F642nosingle::🙂single1🙂
35424&amp;#x1f9…🤩1F929nosingle::🤩single1🤩
36426&amp;#x1f6…😀1F600nomanual::smile_grinmanual2😀 😃😃
37429&amp;#x1f4…💯1F4AFnosingle::💯single1💯
38430&amp;#x1f6…🙈1F648nosingle::🙈single1🙈
39431&amp;#x1f4…👇1F447nosingle::👇single1👇
40432&amp;#x1f3…🎶1F3B6nomanual::music_groupmanual2🎶 🎵🎵
41433&amp;#x1f6…😒1F612nosingle::😒single1😒
42434&amp;#x1f9…🤭1F92Dnosingle::🤭single1🤭
4352&amp;#x1f6…😜1F61Cnosingle::😜single1😜
4453&amp;#x1f4…💋1F48Bnosingle::💋single1💋
4554&amp;#x1f4…👀1F440nosingle::👀single1👀
4656&amp;#x1f6…😑1F611nosingle::😑single1😑
4757&amp;#x1f4…💥1F4A5nosingle::💥single1💥
4858&amp;#x1f6…🙋1F64Bnosingle::🙋single1🙋
4959&amp;#x1f6…😞1F61Enosingle::😞single1😞
50510&amp;#x1f6…😩1F629nosingle::😩single1😩
51511&amp;#x1f6…😡1F621nomanual::angrymanual3😡 🔴 😤🔴 😤
52512&amp;#x1f9…🤪1F92Anosingle::🤪single1🤪
53513&amp;#x1f4…👊1F44Anosingle::👊single1👊
54514&amp;#9728…☀️2600-FE0Fnosingle::☀️single1☀️
55516&amp;#x1f9…🤤1F924nosingle::🤤single1🤤
56517&amp;#x1f4…👉1F449nosingle::👉single1👉
57518&amp;#x1f4…💃1F483nosingle::💃single1💃
58519&amp;#x1f6…😳1F633nosingle::😳single1😳
59520&amp;#9995…270Bnosingle::✋single1
60521&amp;#x1f6…😚1F61Anosingle::😚single1😚
61522&amp;#x1f6…😝1F61Dnosingle::😝single1😝
62523&amp;#x1f6…😴1F634nosingle::😴single1😴
63524&amp;#x1f3…🌟1F31Fnosingle::🌟single1🌟
64525&amp;#x1f6…😬1F62Cnosingle::😬single1😬
65526&amp;#x1f6…🙃1F643nosingle::🙃single1🙃
66527&amp;#x1f3…🍀1F340nosingle::🍀single1🍀
67531&amp;#1108…2B50nosingle::⭐single1
68532&amp;#9989…2705nomanual::checkmarksmanual2✅ ✔️✔️
69533&amp;#x1f3…🌈1F308nosingle::🌈single1🌈
70535&amp;#x1f9…🤘1F918nosingle::🤘single1🤘
7161&amp;#x1f4…💦1F4A6nosingle::💦single1💦
7263&amp;#x1f6…😣1F623nosingle::😣single1😣
7364&amp;#x1f3…🏃1F3C3nosingle::🏃single1🏃
7465&amp;#x1f4…💐1F490nosingle::💐single1💐
7566&amp;#9785…☹️2639-FE0Fnosingle::☹️single1☹️
7669&amp;#x1f6…😠1F620nosingle::😠single1😠
77610&amp;#9757…☝️261D-FE0Fnosingle::☝️single1☝️
78611&amp;#x1f6…😕1F615nosingle::😕single1😕
79613&amp;#x1f3…🎂1F382nomanual::birthday_cakesmanual2🎂 👑👑
80615&amp;#x1f6…😐1F610nosingle::😐single1😐
81616&amp;#x1f5…🖕1F595nosingle::🖕single1🖕
82618&amp;#x1f6…🙊1F64Anosingle::🙊single1🙊
83620&amp;#x1f5…🗣️1F5E3-FE0Fnosingle::🗣️single1🗣️
84621&amp;#x1f4…💫1F4ABnosingle::💫single1💫
85622&amp;#x1f4…💀1F480nosingle::💀single1💀
86625&amp;#x1f9…🤞1F91Enosingle::🤞single1🤞
87630&amp;#x1f6…😫1F62Bnosingle::😫single1😫
88631&amp;#9917…26BDnosingle::⚽single1
89632&amp;#x1f9…🤙1F919nosingle::🤙single1🤙
90633&amp;#9749…2615nosingle::☕single1
91634&amp;#x1f3…🏆1F3C6nosingle::🏆single1🏆
92635&amp;#x1f9…🧡1F9E1nosingle::🧡single1🧡
93636&amp;#x1f3…🎁1F381nosingle::🎁single1🎁
94638&amp;#x1f3…🌞1F31Enosingle::🌞single1🌞
95639&amp;#x1f3…🎈1F388nosingle::🎈single1🎈
96640&amp;#1006…274Cnosingle::❌single1
97641&amp;#9994…270Anosingle::✊single1
98642&amp;#x1f4…👋1F44Bnosingle::👋single1👋
99643&amp;#x1f6…😲1F632nosingle::😲single1😲
100644&amp;#x1f3…🌿1F33Fnosingle::🌿single1🌿
101645&amp;#x1f9…🤫1F92Bnosingle::🤫single1🤫
102646&amp;#x1f4…👈1F448nosingle::👈single1👈
103647&amp;#x1f6…😮1F62Enosingle::😮single1😮
104648&amp;#x1f6…🙆1F646nosingle::🙆single1🙆
105649&amp;#x1f3…🍻1F37Bnosingle::🍻single1🍻
106650&amp;#x1f3…🍃1F343nosingle::🍃single1🍃
107651&amp;#x1f4…🐶1F436nosingle::🐶single1🐶
108652&amp;#x1f4…💁1F481nosingle::💁single1💁
109653&amp;#x1f6…😰1F630nosingle::😰single1😰
110654&amp;#x1f9…🤨1F928nosingle::🤨single1🤨
111655&amp;#x1f6…😶1F636nosingle::😶single1😶
112656&amp;#x1f9…🤝1F91Dnosingle::🤝single1🤝
113657&amp;#x1f6…🚶1F6B6nosingle::🚶single1🚶
114658&amp;#x1f4…💰1F4B0nosingle::💰single1💰
115659&amp;#x1f3…🍓1F353nosingle::🍓single1🍓
116660&amp;#x1f4…💢1F4A2nosingle::💢single1💢
11771&amp;#x1f1…🇺🇸1F1FA-1F1F8nosingle::🇺🇸single1🇺🇸
11872&amp;#x1f9…🤟1F91Fnosingle::🤟single1🤟
11973&amp;#x1f6…🙁1F641nosingle::🙁single1🙁
12074&amp;#x1f6…🚨1F6A8nosingle::🚨single1🚨
12175&amp;#x1f4…💨1F4A8nosingle::💨single1💨
12276&amp;#x1f9…🤬1F92Cnosingle::🤬single1🤬
12377&amp;#9992…✈️2708-FE0Fnosingle::✈️single1✈️
12478&amp;#x1f3…🎀1F380nosingle::🎀single1🎀
12579&amp;#x1f3…🍺1F37Anosingle::🍺single1🍺
126710&amp;#x1f9…🤓1F913nosingle::🤓single1🤓
127711&amp;#x1f6…😙1F619nosingle::😙single1😙
128712&amp;#x1f4…💟1F49Fnosingle::💟single1💟
129713&amp;#x1f3…🌱1F331nosingle::🌱single1🌱
130714&amp;#x1f6…😖1F616nosingle::😖single1😖
131715&amp;#x1f4…👶1F476nosingle::👶single1👶
132716&amp;#9654…▶️25B6-FE0Fnosingle::▶️single1▶️
133717&amp;#1014…➡️27A1-FE0Fnosingle::➡️single1➡️
134718&amp;#1006…2753nosingle::❓single1
135719&amp;#x1f4…💎1F48Enosingle::💎single1💎
136720&amp;#x1f4…💸1F4B8nosingle::💸single1💸
137721&amp;#1101…⬇️2B07-FE0Fnosingle::⬇️single1⬇️
138722&amp;#x1f6…😨1F628nosingle::😨single1😨
139723&amp;#x1f3…🌚1F31Anosingle::🌚single1🌚
140724&amp;#x1f9…🦋1F98Bnosingle::🦋single1🦋
141725&amp;#x1f6…😷1F637nosingle::😷single1😷
142726&amp;#x1f5…🕺1F57Anosingle::🕺single1🕺
143727&amp;#9888…⚠️26A0-FE0Fnosingle::⚠️single1⚠️
144728&amp;#x1f6…🙅1F645nosingle::🙅single1🙅
145729&amp;#x1f6…😟1F61Fnosingle::😟single1😟
146730&amp;#x1f6…😵1F635nosingle::😵single1😵
147732&amp;#x1f9…🤲1F932nosingle::🤲single1🤲
148733&amp;#x1f9…🤠1F920nosingle::🤠single1🤠
149734&amp;#x1f9…🤧1F927nosingle::🤧single1🤧
150735&amp;#x1f4…📌1F4CCnosingle::📌single1📌
151736&amp;#x1f5…🔵1F535nosingle::🔵single1🔵
152737&amp;#x1f4…💅1F485nosingle::💅single1💅
153738&amp;#x1f9…🧐1F9D0nosingle::🧐single1🧐
154739&amp;#x1f4…🐾1F43Enosingle::🐾single1🐾
155740&amp;#x1f3…🍒1F352nosingle::🍒single1🍒
156741&amp;#x1f6…😗1F617nosingle::😗single1😗
157742&amp;#x1f9…🤑1F911nosingle::🤑single1🤑
158743&amp;#x1f6…🚀1F680nosingle::🚀single1🚀
159744&amp;#x1f3…🌊1F30Anosingle::🌊single1🌊
160745&amp;#x1f9…🤯1F92Fnosingle::🤯single1🤯
161746&amp;#x1f4…🐷1F437nosingle::🐷single1🐷
162747&amp;#9742…☎️260E-FE0Fnosingle::☎️single1☎️
163748&amp;#x1f4…💧1F4A7nosingle::💧single1💧
164749&amp;#x1f6…😯1F62Fnosingle::😯single1😯
165750&amp;#x1f4…💆1F486nosingle::💆single1💆
166751&amp;#x1f4…👆1F446nosingle::👆single1👆
167752&amp;#x1f3…🎤1F3A4nosingle::🎤single1🎤
168753&amp;#x1f6…🙇1F647nosingle::🙇single1🙇
169754&amp;#x1f3…🍑1F351nosingle::🍑single1🍑
170755&amp;#1005…❄️2744-FE0Fnosingle::❄️single1❄️
171756&amp;#x1f3…🌴1F334nosingle::🌴single1🌴
172757&amp;#x1f1…🇧🇷1F1E7-1F1F7nosingle::🇧🇷single1🇧🇷
173758&amp;#x1f4…💣1F4A3nosingle::💣single1💣
174759&amp;#x1f4…🐸1F438nosingle::🐸single1🐸
175760&amp;#x1f4…💌1F48Cnosingle::💌single1💌
176761&amp;#x1f4…📍1F4CDnosingle::📍single1📍
177762&amp;#x1f9…🥀1F940nosingle::🥀single1🥀
178763&amp;#x1f9…🤢1F922nosingle::🤢single1🤢
179764&amp;#x1f4…👅1F445nosingle::👅single1👅
180765&amp;#x1f4…💡1F4A1nosingle::💡single1💡
181766&amp;#x1f4…💩1F4A9nosingle::💩single1💩
182767&amp;#8265…⁉️2049-FE0Fnosingle::⁉️single1⁉️
183768&amp;#x1f4…👐1F450nosingle::👐single1👐
184769&amp;#x1f4…📸1F4F8nosingle::📸single1📸
185770&amp;#x1f4…👻1F47Bnosingle::👻single1👻
186771&amp;#x1f9…🤐1F910nosingle::🤐single1🤐
187772&amp;#x1f9…🤮1F92Enosingle::🤮single1🤮
188773&amp;#x1f3…🎼1F3BCnosingle::🎼single1🎼
189774&amp;#9997…✍️270D-FE0Fnosingle::✍️single1✍️
190775&amp;#x1f6…🚩1F6A9nosingle::🚩single1🚩
191776&amp;#x1f3…🍎1F34Enosingle::🍎single1🍎
192777&amp;#x1f3…🍊1F34Anosingle::🍊single1🍊
193778&amp;#x1f4…👼1F47Cnosingle::👼single1👼
194779&amp;#x1f4…💍1F48Dnosingle::💍single1💍
195780&amp;#x1f4…📣1F4E3nosingle::📣single1📣
196781&amp;#x1f9…🥂1F942nosingle::🥂single1🥂
197782&amp;#1054…⤵️2935-FE0Fnosingle::⤵️single1⤵️
198783&amp;#x1f4…📱1F4F1nosingle::📱single1📱
199784&amp;#9748…2614nosingle::☔single1
200785&amp;#x1f3…🌙1F319nosingle::🌙single1🌙
20181&amp;#x1f3…🍾1F37Enosingle::🍾single1🍾
20282&amp;#x1f3…🎧1F3A7nosingle::🎧single1🎧
20383&amp;#x1f3…🍁1F341nosingle::🍁single1🍁
20484&amp;#1109…2B55nosingle::⭕single1
20585&amp;#x1f3…🏀1F3C0nosingle::🏀single1🏀
20686&amp;#9760…☠️2620-FE0Fnosingle::☠️single1☠️
20787&amp;#9899…26ABnosingle::⚫single1
20888&amp;#x1f5…🖐️1F590-FE0Fnosingle::🖐️single1🖐️
20989&amp;#x1f6…😧1F627nosingle::😧single1😧
210810&amp;#x1f3…🎯1F3AFnosingle::🎯single1🎯
211811&amp;#x1f4…📲1F4F2nosingle::📲single1📲
212812&amp;#9752…☘️2618-FE0Fnosingle::☘️single1☘️
213813&amp;#x1f4…👁️1F441-FE0Fnosingle::👁️single1👁️
214814&amp;#x1f3…🍷1F377nosingle::🍷single1🍷
215815&amp;#x1f4…👄1F444nosingle::👄single1👄
216816&amp;#x1f4…🐟1F41Fnosingle::🐟single1🐟
217817&amp;#x1f3…🍰1F370nosingle::🍰single1🍰
218818&amp;#x1f4…💤1F4A4nosingle::💤single1💤
219819&amp;#x1f5…🕊️1F54A-FE0Fnosingle::🕊️single1🕊️
220820&amp;#x1f4…📺1F4FAnosingle::📺single1📺
221821&amp;#x1f4…💭1F4ADnosingle::💭single1💭
222822&amp;#x1f4…🐱1F431nosingle::🐱single1🐱
223823&amp;#x1f4…🐝1F41Dnosingle::🐝single1🐝
224824&amp;#x1f1…🇲🇽1F1F2-1F1FDnosingle::🇲🇽single1🇲🇽
225825&amp;#x1f9…🧚1F9DAnosingle::🧚single1🧚
226826&amp;#x1f5…🔝1F51Dnosingle::🔝single1🔝
227827&amp;#x1f4…📢1F4E2nosingle::📢single1📢
228828&amp;#x1f4…📷1F4F7nosingle::📷single1📷
229829&amp;#x1f4…🐕1F415nosingle::🐕single1🐕
230830&amp;#x1f3…🎸1F3B8nosingle::🎸single1🎸
231831&amp;#x1f5…🔫1F52Bnosingle::🔫single1🔫
232832&amp;#x1f9…🤚1F91Anosingle::🤚single1🤚
233833&amp;#x1f3…🍭1F36Dnosingle::🍭single1🍭
234834&amp;#x1f3…🍆1F346nosingle::🍆single1🍆
235835&amp;#x1f4…💉1F489nosingle::💉single1💉
236836&amp;#x1f3…🌎1F30Enosingle::🌎single1🌎
237837&amp;#x1f6…😦1F626nosingle::😦single1😦
238838&amp;#x1f3…🌀1F300nosingle::🌀single1🌀
239839&amp;#x1f4…👿1F47Fnosingle::👿single1👿
240840&amp;#9745…☑️2611-FE0Fnosingle::☑️single1☑️
241841&amp;#x1f3…🎥1F3A5nosingle::🎥single1🎥
242842&amp;#x1f3…🌧️1F327-FE0Fnosingle::🌧️single1🌧️
243843&amp;#x1f4…👽1F47Dnosingle::👽single1👽
244844&amp;#x1f3…🍋1F34Bnosingle::🍋single1🍋
245845&amp;#x1f9…🤒1F912nosingle::🤒single1🤒
246846&amp;#x1f9…🤡1F921nosingle::🤡single1🤡
247847&amp;#x1f3…🍫1F36Bnosingle::🍫single1🍫
248848&amp;#x1f4…📚1F4DAnosingle::📚single1📚
249849&amp;#x1f3…🏁1F3C1nosingle::🏁single1🏁
250850&amp;#x1f9…🤕1F915nosingle::🤕single1🤕
251851&amp;#x1f9…🦄1F984nosingle::🦄single1🦄
252852&amp;#x1f3…🍅1F345nosingle::🍅single1🍅
253853&amp;#x1f6…🚗1F697nosingle::🚗single1🚗
254854&amp;#x1f6…🚫1F6ABnosingle::🚫single1🚫
255855&amp;#x1f4…💵1F4B5nosingle::💵single1💵
256856&amp;#9918…26BEnosingle::⚾single1
257857&amp;#x1f5…🔪1F52Anosingle::🔪single1🔪
258858&amp;#x1f5…🔔1F514nosingle::🔔single1🔔
259859&amp;#9832…♨️2668-FE0Fnosingle::♨️single1♨️
260860&amp;#x1f3…🌳1F333nosingle::🌳single1🌳
261861&amp;#x1f5…🔊1F50Anosingle::🔊single1🔊
262862&amp;#x1f3…🍬1F36Cnosingle::🍬single1🍬
263863&amp;#x1f4…💏1F48Fnosingle::💏single1💏
264864&amp;#x1f3…🍼1F37Cnosingle::🍼single1🍼
265865&amp;#x1f3…🍜1F35Cnosingle::🍜single1🍜
266866&amp;#x1f4…🐼1F43Cnosingle::🐼single1🐼
267867&amp;#x1f6…🙉1F649nosingle::🙉single1🙉
268868&amp;#x1f4…🐈1F408nosingle::🐈single1🐈
269869&amp;#x1f4…🐻1F43Bnosingle::🐻single1🐻
270870&amp;#x1f9…🤸1F938nosingle::🤸single1🤸
271871&amp;#x1f3…🌝1F31Dnosingle::🌝single1🌝
272872&amp;#x1f4…👸1F478nosingle::👸single1👸
273873&amp;#x1f3…🍕1F355nosingle::🍕single1🍕
274874&amp;#x1f3…🍌1F34Cnosingle::🍌single1🍌
275875&amp;#x1f3…🍦1F366nosingle::🍦single1🍦
276876&amp;#9898…26AAnosingle::⚪single1
277877&amp;#x1f4…👩1F469nosingle::👩single1👩
278878&amp;#x1f6…😿1F63Fnosingle::😿single1😿
279879&amp;#x1f3…🍂1F342nosingle::🍂single1🍂
280880&amp;#x1f4…📞1F4DEnosingle::📞single1📞
281881&amp;#9200…23F0nosingle::⏰single1
282882&amp;#x1f5…🔞1F51Enosingle::🔞single1🔞
283883&amp;#x1f3…🌍1F30Dnosingle::🌍single1🌍
284884&amp;#x1f3…🌠1F320nosingle::🌠single1🌠
285885&amp;#x1f6…🙀1F640nosingle::🙀single1🙀
286886&amp;#9642…▪️25AA-FE0Fnosingle::▪️single1▪️
287887&amp;#9729…☁️2601-FE0Fnosingle::☁️single1☁️
288888&amp;#x1f4…👹1F479nosingle::👹single1👹
289889&amp;#x1f3…🍉1F349nosingle::🍉single1🍉
290890&amp;#x1f4…🐥1F425nosingle::🐥single1🐥
291891&amp;#x1f3…🌶️1F336-FE0Fnosingle::🌶️single1🌶️
2928921&amp;#650…1️⃣31-FE0F-20E3nosingle::1️⃣single11️⃣
293893&amp;#x1f3…🌵1F335nosingle::🌵single1🌵
294894&amp;#x1f1…🇮🇳1F1EE-1F1F3nosingle::🇮🇳single1🇮🇳
295895&amp;#x1f4…👧1F467nosingle::👧single1👧
296896&amp;#x1f3…🍄1F344nosingle::🍄single1🍄
297897&amp;#x1f4…👮1F46Enosingle::👮single1👮
298898&amp;#x1f4…💮1F4AEnosingle::💮single1💮
299899&amp;#x1f4…🐰1F430nosingle::🐰single1🐰
3008100&amp;#x1f5…🔷1F537nosingle::🔷single1🔷
3018101&amp;#x1f3…🌾1F33Enosingle::🌾single1🌾
3028102&amp;#x1f5…🔹1F539nosingle::🔹single1🔹
3038103&amp;#x1f1…🇹🇷1F1F9-1F1F7nosingle::🇹🇷single1🇹🇷
3048104&amp;#x1f9…🥇1F947nosingle::🥇single1🥇
3058105&amp;#x1f1…🇮🇹1F1EE-1F1F9nosingle::🇮🇹single1🇮🇹
30691&amp;#x1f3…🍪1F36Anosingle::🍪single1🍪
30792&amp;#x1f1…🇦🇷1F1E6-1F1F7nosingle::🇦🇷single1🇦🇷
30893&amp;#x1f6…🛑1F6D1nosingle::🛑single1🛑
30994&amp;#x1f4…🐍1F40Dnosingle::🐍single1🐍
31095&amp;#x1f3…🎓1F393nosingle::🎓single1🎓
31196&amp;#x1f1…🇨🇦1F1E8-1F1E6nosingle::🇨🇦single1🇨🇦
31297&amp;#x1f3…🍏1F34Fnosingle::🍏single1🍏
31398&amp;#x1f9…🦁1F981nosingle::🦁single1🦁
31499&amp;#x1f6…😽1F63Dnosingle::😽single1😽
315910&amp;#x1f6…🚬1F6ACnosingle::🚬single1🚬
316911&amp;#x1f3…🍖1F356nosingle::🍖single1🍖
317912&amp;#x1f3…🍴1F374nosingle::🍴single1🍴
318913&amp;#x1f1…🆘1F198nosingle::🆘single1🆘
319914&amp;#x1f9…🤜1F91Cnosingle::🤜single1🤜
320915&amp;#x1f3…🍿1F37Fnosingle::🍿single1🍿
321916&amp;#x1f3…🍔1F354nosingle::🍔single1🍔
322917&amp;#x1f4…📝1F4DDnosingle::📝single1📝
323918&amp;#x1f1…🇯🇵1F1EF-1F1F5nosingle::🇯🇵single1🇯🇵
324919&amp;#x1f3…🍮1F36Enosingle::🍮single1🍮
325920&amp;#x1f3…🍇1F347nosingle::🍇single1🍇
3269212&amp;#650…2️⃣32-FE0F-20E3nosingle::2️⃣single12️⃣
327922&amp;#x1f3…🏠1F3E0nosingle::🏠single1🏠
328923&amp;#x1f9…🤰1F930nosingle::🤰single1🤰
329924&amp;#x1f4…🐣1F423nosingle::🐣single1🐣
330925&amp;#x1f4…🐒1F412nosingle::🐒single1🐒
331926&amp;#x1f4…👦1F466nosingle::👦single1👦
332927&amp;#x1f3…🍩1F369nosingle::🍩single1🍩
333928&amp;#x1f3…🍣1F363nosingle::🍣single1🍣
334929&amp;#x1f9…🤛1F91Bnosingle::🤛single1🤛
335930&amp;#x1f4…👯1F46Fnosingle::👯single1👯
336931&amp;#x1f3…🏳️‍🌈1F3F3-FE0F-200D-1F308nosingle::🏳️‍🌈single1🏳️‍🌈
337932&amp;spade…♠️2660-FE0Fnosingle::♠️single1♠️
338933&amp;#x1f3…🌲1F332nosingle::🌲single1🌲
339934&amp;#x1f4…🐴1F434nosingle::🐴single1🐴
340935&amp;#x1f3…🍛1F35Bnosingle::🍛single1🍛
341936&amp;#x1f3…🎆1F386nosingle::🎆single1🎆
342937&amp;#x1f4…💑1F491nosingle::💑single1💑
343938&amp;#x1f3…🍞1F35Enosingle::🍞single1🍞
344939&amp;#x1f3…🍯1F36Fnosingle::🍯single1🍯
345940&amp;#9732…☄️2604-FE0Fnosingle::☄️single1☄️
346941&amp;#x1f6…😸1F638nosingle::😸single1😸
347942&amp;#x1f3…🍚1F35Anosingle::🍚single1🍚
348943&amp;#x1f3…🎬1F3ACnosingle::🎬single1🎬
349944&amp;#x1f3…🎙️1F399-FE0Fnosingle::🎙️single1🎙️
350945&amp;#x1f1…🇨🇴1F1E8-1F1F4nosingle::🇨🇴single1🇨🇴
351946&amp;#x1f4…🐳1F433nosingle::🐳single1🐳
352947&amp;#x1f9…🦀1F980nosingle::🦀single1🦀
353948&amp;#x1f9…🥃1F943nosingle::🥃single1🥃
354949&amp;#x1f5…🔸1F538nosingle::🔸single1🔸
355950&amp;#x1f4…💊1F48Anosingle::💊single1💊
356951&amp;#x1f4…🐎1F40Enosingle::🐎single1🐎
357952&amp;#x1f3…🍹1F379nosingle::🍹single1🍹
358953&amp;diams…♦️2666-FE0Fnosingle::♦️single1♦️
359954&amp;#x1f5…🔮1F52Enosingle::🔮single1🔮
360955&amp;#x1f4…👨1F468nosingle::👨single1👨
361956&amp;#x1f3…🍸1F378nosingle::🍸single1🍸
362957&amp;#x1f3…🌏1F30Fnosingle::🌏single1🌏
363958&amp;#x1f4…👴1F474nosingle::👴single1👴
364959&amp;#x1f9…🧢1F9E2nosingle::🧢single1🧢
365960&amp;#x1f4…🐽1F43Dnosingle::🐽single1🐽
366961&amp;#x1f4…🐔1F414nosingle::🐔single1🐔
367962&amp;#x1f3…🎻1F3BBnosingle::🎻single1🎻
368963&amp;#1101…⬆️2B06-FE0Fnosingle::⬆️single1⬆️
369964&amp;#9986…✂️2702-FE0Fnosingle::✂️single1✂️
370965&amp;#x1f4…👫1F46Bnosingle::👫single1👫
371966&amp;#x1f4…👣1F463nosingle::👣single1👣
372967&amp;#x1f4…🐯1F42Fnosingle::🐯single1🐯
373968&amp;#x1f3…🎮1F3AEnosingle::🎮single1🎮
374969&amp;#x1f3…🍵1F375nosingle::🍵single1🍵
375970&amp;#x1f4…🐦1F426nosingle::🐦single1🐦
376971&amp;#x1f1…🇬🇧1F1EC-1F1E7nosingle::🇬🇧single1🇬🇧
377972&amp;#1233…〰️3030-FE0Fnosingle::〰️single1〰️
378973&amp;#x1f4…👭1F46Dnosingle::👭single1👭
379974&amp;#x1f4…🐬1F42Cnosingle::🐬single1🐬
380975&amp;#x1f3…🍟1F35Fnosingle::🍟single1🍟
381976&amp;#x1f4…👙1F459nosingle::👙single1👙
382977&amp;#1000…✖️2716-FE0Fnosingle::✖️single1✖️
383978&amp;#x1f4…📩1F4E9nosingle::📩single1📩
384979&amp;#x1f4…👵1F475nosingle::👵single1👵
385980&amp;#x1f3…🍨1F368nosingle::🍨single1🍨
386981&amp;#x1f1…🇫🇷1F1EB-1F1F7nosingle::🇫🇷single1🇫🇷
387982&amp;#x1f4…🐖1F416nosingle::🐖single1🐖
388983&amp;#1001…✝️271D-FE0Fnosingle::✝️single1✝️
389984&amp;#9851…♻️267B-FE0Fnosingle::♻️single1♻️
390985&amp;#x1f9…🥊1F94Anosingle::🥊single1🥊
391986&amp;#x1f9…🦅1F985nosingle::🦅single1🦅
392987&amp;#x1f4…💬1F4ACnosingle::💬single1💬
393988&amp;#x1f1…🇨🇱1F1E8-1F1F1nosingle::🇨🇱single1🇨🇱
394989&amp;#x1f4…🐢1F422nosingle::🐢single1🐢
395990&amp;#x1f5…🔰1F530nosingle::🔰single1🔰
396991&amp;#x1f5…🔶1F536nosingle::🔶single1🔶
397992&amp;#x1f3…🎗️1F397-FE0Fnosingle::🎗️single1🎗️
398993&amp;#x1f4…💄1F484nosingle::💄single1💄
399994&amp;#x1f4…👠1F460nosingle::👠single1👠
400995&amp;#x1f9…🥕1F955nosingle::🥕single1🥕
401996&amp;#1013…2796nosingle::➖single1
402997&amp;#x1f4…🐺1F43Anosingle::🐺single1🐺
403998&amp;#x1f4…📖1F4D6nosingle::📖single1📖
404999&amp;#x1f3…🍍1F34Dnosingle::🍍single1🍍
4059100&amp;#x1f3…🌃1F303nosingle::🌃single1🌃
4069101&amp;#1003…✴️2734-FE0Fnosingle::✴️single1✴️
4079102&amp;#x1f3…🌌1F30Cnosingle::🌌single1🌌
4089103&amp;#x1f4…🐓1F413nosingle::🐓single1🐓
4099104&amp;#x1f4…👂1F442nosingle::👂single1👂
4109105&amp;#x1f3…🍤1F364nosingle::🍤single1🍤
4119106&amp;#x1f4…🐐1F410nosingle::🐐single1🐐
4129107&amp;#x1f5…🔻1F53Bnosingle::🔻single1🔻
4139108&amp;#x1f4…💻1F4BBnosingle::💻single1💻
4149109&amp;#x1f9…🦐1F990nosingle::🦐single1🦐
4159110&amp;#x1f1…🇩🇪1F1E9-1F1EAnosingle::🇩🇪single1🇩🇪
4169111&amp;#x1f3…🌛1F31Bnosingle::🌛single1🌛
4179112&amp;#8600…↘️2198-FE0Fnosingle::↘️single1↘️
4189113&amp;#9999…✏️270F-FE0Fnosingle::✏️single1✏️
4199114&amp;#x1f9…🧘1F9D8nosingle::🧘single1🧘
4209115&amp;#x1f9…🥁1F941nosingle::🥁single1🥁
4219116&amp;#x1f3…🏖️1F3D6-FE0Fnosingle::🏖️single1🏖️
4229117&amp;#9884…⚜️269C-FE0Fnosingle::⚜️single1⚜️
4239118&amp;#1006…2755nosingle::❕single1
4249119&amp;#x1f1…🅰️1F170-FE0Fnosingle::🅰️single1🅰️
4259120&amp;#x1f6…🚴1F6B4nosingle::🚴single1🚴
4269121&amp;#x1f4…💠1F4A0nosingle::💠single1💠
4279122&amp;#1295…㊗️3297-FE0Fnosingle::㊗️single1㊗️
4289123&amp;#x1f4…🐙1F419nosingle::🐙single1🐙
4299124&amp;clubs…♣️2663-FE0Fnosingle::♣️single1♣️
4309125&amp;#x1f3…🍡1F361nosingle::🍡single1🍡
4319126&amp;#9193…23E9nosingle::⏩single1
4329127&amp;#x1f3…🎨1F3A8nosingle::🎨single1🎨
4339128&amp;#x1f4…🐠1F420nosingle::🐠single1🐠
4349129&amp;#x1f1…🇰🇷1F1F0-1F1F7nosingle::🇰🇷single1🇰🇷
4359130&amp;#x1f3…🍗1F357nosingle::🍗single1🍗
4369131&amp;#x1f6…🚮1F6AEnosingle::🚮single1🚮
4379132&amp;#9643…▫️25AB-FE0Fnosingle::▫️single1▫️
4389133&amp;#x1f3…🌪️1F32A-FE0Fnosingle::🌪️single1🌪️
4399134&amp;#x1f6…😼1F63Cnosingle::😼single1😼
4409135&amp;#x1f4…👤1F464nosingle::👤single1👤
4419136&amp;#x1f3…🏊1F3CAnosingle::🏊single1🏊
4429137&amp;#x1f3…🌽1F33Dnosingle::🌽single1🌽
4439138&amp;#x1f3…🎩1F3A9nosingle::🎩single1🎩
4449139&amp;#x1f1…🇪🇸1F1EA-1F1F8nosingle::🇪🇸single1🇪🇸
4459140&amp;#x1f3…🎹1F3B9nosingle::🎹single1🎹
4469141&amp;#x1f3…🍈1F348nosingle::🍈single1🍈
4479142&amp;#9664…◀️25C0-FE0Fnosingle::◀️single1◀️
4489143&amp;harr;…↔️2194-FE0Fnosingle::↔️single1↔️
4499144&amp;#x1f3…🏡1F3E1nosingle::🏡single1🏡
4509145&amp;#x1f1…🇵🇰1F1F5-1F1F0nosingle::🇵🇰single1🇵🇰
4519146&amp;#x1f3…🎇1F387nosingle::🎇single1🎇
4529147&amp;#x1f9…🥩1F969nosingle::🥩single1🥩
4539148&amp;#x1f4…🐞1F41Enosingle::🐞single1🐞
45491493&amp;#650…3️⃣33-FE0F-20E3nosingle::3️⃣single13️⃣
4559150&amp;#1101…⬅️2B05-FE0Fnosingle::⬅️single1⬅️
4569151&amp;#x1f3…🌐1F310nosingle::🌐single1🌐
4579152&amp;#8599…↗️2197-FE0Fnosingle::↗️single1↗️
4589153&amp;#x1f3…🍽️1F37D-FE0Fnosingle::🍽️single1🍽️
4599154&amp;#x1f9…🧀1F9C0nosingle::🧀single1🧀
4609155&amp;#x1f9…🥦1F966nosingle::🥦single1🥦
4619156&amp;#x1f4…🐜1F41Cnosingle::🐜single1🐜
4629157&amp;#9876…⚔️2694-FE0Fnosingle::⚔️single1⚔️
463101&amp;#x1f6…😺1F63Anosingle::😺single1😺
464102&amp;#x1f9…🥞1F95Enosingle::🥞single1🥞
465103&amp;#x1f3…🏄1F3C4nosingle::🏄single1🏄
466104&amp;#x1f5…🔨1F528nosingle::🔨single1🔨
467105&amp;#x1f3…🏝️1F3DD-FE0Fnosingle::🏝️single1🏝️
468106&amp;#x1f5…🔆1F506nosingle::🔆single1🔆
469107&amp;#x1f4…👥1F465nosingle::👥single1👥
470108&amp;#x1f4…👓1F453nosingle::👓single1👓
471109&amp;#x1f9…🥒1F952nosingle::🥒single1🥒
4721010&amp;#x1f3…🏈1F3C8nosingle::🏈single1🏈
4731011&amp;#x1f1…🇵🇭1F1F5-1F1EDnosingle::🇵🇭single1🇵🇭
4741012&amp;#x1f3…🏋️1F3CB-FE0Fnosingle::🏋️single1🏋️
47510130&amp;#650…0️⃣30-FE0F-20E3nosingle::0️⃣single10️⃣
4761014&amp;#x1f6…🚘1F698nosingle::🚘single1🚘
4771015&amp;#x1f9…🦖1F996nosingle::🦖single1🦖
4781016&amp;#x1f3…🌕1F315nosingle::🌕single1🌕
4791017&amp;#x1f3…🎭1F3ADnosingle::🎭single1🎭
4801018&amp;#x1f4…👾1F47Enosingle::👾single1👾
4811019&amp;#x1f3…🍳1F373nosingle::🍳single1🍳
4821020&amp;#x1f3…🏵️1F3F5-FE0Fnosingle::🏵️single1🏵️
4831021&amp;#x1f3…🍧1F367nosingle::🍧single1🍧
4841022&amp;#x1f5…🔗1F517nosingle::🔗single1🔗
4851023&amp;#x1f5…🕋1F54Bnosingle::🕋single1🕋
4861024&amp;#9731…☃️2603-FE0Fnosingle::☃️single1☃️
4871025&amp;#x1f3…🌅1F305nosingle::🌅single1🌅
4881026&amp;#x1f9…🤴1F934nosingle::🤴single1🤴
4891027&amp;#x1f5…🖖1F596nosingle::🖖single1🖖
4901028&amp;#x1f4…🐊1F40Anosingle::🐊single1🐊
4911029&amp;#x1f4…🐘1F418nosingle::🐘single1🐘
4921030&amp;#x1f3…🌤️1F324-FE0Fnosingle::🌤️single1🌤️
4931031&amp;#x1f9…🥑1F951nosingle::🥑single1🥑
4941032&amp;#x1f9…🥚1F95Anosingle::🥚single1🥚
4951033&amp;#9928…⛈️26C8-FE0Fnosingle::⛈️single1⛈️
4961034&amp;#x1f4…🐵1F435nosingle::🐵single1🐵
4971035&amp;#x1f5…🔜1F51Cnosingle::🔜single1🔜
4981036&amp;#x1f3…🍶1F376nosingle::🍶single1🍶
4991037&amp;#x1f4…🐄1F404nosingle::🐄single1🐄
5001038&amp;#x1f1…🇻🇪1F1FB-1F1EAnosingle::🇻🇪single1🇻🇪
5011039&amp;#x1f4…🐮1F42Enosingle::🐮single1🐮
5021040&amp;#x1f9…🦈1F988nosingle::🦈single1🦈
5031041&amp;#x1f6…🚲1F6B2nosingle::🚲single1🚲
5041042&amp;#9940…26D4nosingle::⛔single1
5051043&amp;#x1f5…🕯️1F56F-FE0Fnosingle::🕯️single1🕯️
5061044&amp;#1013…2795nosingle::➕single1
5071045&amp;#x1f5…🔺1F53Anosingle::🔺single1🔺
5081046&amp;#x1f4…💇1F487nosingle::💇single1💇
5091047&amp;#x1f9…🧠1F9E0nosingle::🧠single1🧠
5101048&amp;#x1f4…📻1F4FBnosingle::📻single1📻
5111049&amp;#x1f9…🥤1F964nosingle::🥤single1🥤
5121050&amp;#x1f3…🍝1F35Dnosingle::🍝single1🍝
5131051&amp;#x1f3…🍥1F365nosingle::🍥single1🍥
5141052&amp;#x1f4…💴1F4B4nosingle::💴single1💴
5151053&amp;#x1f3…🌬️1F32C-FE0Fnosingle::🌬️single1🌬️
5161054&amp;#x1f9…🥓1F953nosingle::🥓single1🥓
5171055&amp;#x1f6…🙍1F64Dnosingle::🙍single1🙍
5181056&amp;#9875…2693nosingle::⚓single1
5191057&amp;#x1f4…👰1F470nosingle::👰single1👰
5201058&amp;#x1f4…🐂1F402nosingle::🐂single1🐂
5211059&amp;#x1f4…📽️1F4FD-FE0Fnosingle::📽️single1📽️
5221060&amp;#x1f3…🏅1F3C5nosingle::🏅single1🏅
5231061&amp;#9925…26C5nosingle::⛅single1
5241062&amp;#x1f1…🇦🇪1F1E6-1F1EAnosingle::🇦🇪single1🇦🇪
5251063&amp;#x1f1…🇵🇪1F1F5-1F1EAnosingle::🇵🇪single1🇵🇪
5261064&amp;#x1f9…🧜1F9DCnosingle::🧜single1🧜
5271065&amp;#x1f4…📮1F4EEnosingle::📮single1📮
5281066&amp;#9971…26F3nosingle::⛳single1
5291067&amp;#x1f5…🔽1F53Dnosingle::🔽single1🔽
5301068&amp;#x1f6…🚂1F682nosingle::🚂single1🚂
5311069&amp;#x1f3…🏌️1F3CC-FE0Fnosingle::🏌️single1🏌️
5321070&amp;#x1f4…🐇1F407nosingle::🐇single1🐇
5331071&amp;#x1f3…🏍️1F3CD-FE0Fnosingle::🏍️single1🏍️
5341072&amp;#x1f3…🎲1F3B2nosingle::🎲single1🎲
5351073&amp;#x1f9…🥛1F95Bnosingle::🥛single1🥛
5361074&amp;#x1f3…🎣1F3A3nosingle::🎣single1🎣
5371075&amp;#x1f4…👱1F471nosingle::👱single1👱
5381076&amp;#x1f3…🎏1F38Fnosingle::🎏single1🎏
5391077&amp;#x1f5…🕷️1F577-FE0Fnosingle::🕷️single1🕷️
5401078&amp;#x1f9…🦍1F98Dnosingle::🦍single1🦍
5411079&amp;#x1f5…🔘1F518nosingle::🔘single1🔘
5421080&amp;#x1f4…🐅1F405nosingle::🐅single1🐅
5431081&amp;#x1f3…🏇1F3C7nosingle::🏇single1🏇
5441082&amp;#x1f5…🔐1F510nosingle::🔐single1🔐
5451083&amp;#x1f3…🏩1F3E9nosingle::🏩single1🏩
5461084&amp;#x1f4…👺1F47Anosingle::👺single1👺
5471085&amp;#x1f1…🅱️1F171-FE0Fnosingle::🅱️single1🅱️
5481086&amp;#x1f6…🚙1F699nosingle::🚙single1🚙
5491087&amp;#x1f4…🐧1F427nosingle::🐧single1🐧
5501088&amp;#9878…⚖️2696-FE0Fnosingle::⚖️single1⚖️
5511089&amp;#x1f3…🎃1F383nosingle::🎃single1🎃
5521090&amp;#x1f3…🌄1F304nosingle::🌄single1🌄
5531091&amp;#x1f3…🎾1F3BEnosingle::🎾single1🎾
5541092&amp;#x1f4…🐚1F41Anosingle::🐚single1🐚
5551093&amp;#x1f3…🎺1F3BAnosingle::🎺single1🎺
5561094&amp;#1005…❇️2747-FE0Fnosingle::❇️single1❇️
5571095&amp;#x1f3…🎫1F3ABnosingle::🎫single1🎫
5581096&amp;#8986…231Anosingle::⌚single1
5591097&amp;#x1f3…🌋1F30Bnosingle::🌋single1🌋
5601098&amp;#x1f4…💒1F492nosingle::💒single1💒
5611099&amp;#x1f4…👳1F473nosingle::👳single1👳
56210100&amp;#1006…274Enosingle::❎single1
56310101&amp;#x1f4…👟1F45Fnosingle::👟single1👟
56410102&amp;#x1f4…👃1F443nosingle::👃single1👃
56510103&amp;#x1f6…🛌1F6CCnosingle::🛌single1🛌
56610104&amp;#x1f6…🚓1F693nosingle::🚓single1🚓
56710105&amp;#9196…23ECnosingle::⏬single1
56810106&amp;#x1f4…📈1F4C8nosingle::📈single1📈
56910107&amp;#9924…26C4nosingle::⛄single1
57010108&amp;#9201…⏱️23F1-FE0Fnosingle::⏱️single1⏱️
57110109&amp;#x1f6…😾1F63Enosingle::😾single1😾
57210110&amp;#x1f6…🛫1F6EBnosingle::🛫single1🛫
57310111&amp;#x1f9…🤱1F931nosingle::🤱single1🤱
57410112&amp;#x1f3…🍐1F350nosingle::🍐single1🍐
57510113&amp;#9774…☮️262E-FE0Fnosingle::☮️single1☮️
57610114&amp;#x1f6…🚃1F683nosingle::🚃single1🚃
57710115&amp;#9203…23F3nosingle::⏳single1
57810116&amp;#x1f3…🌜1F31Cnosingle::🌜single1🌜
57910117&amp;#x1f4…📹1F4F9nosingle::📹single1📹
58010118&amp;#x1f4…🐛1F41Bnosingle::🐛single1🐛
58110119&amp;#x1f4…👔1F454nosingle::👔single1👔
58210120&amp;#x1f4…👗1F457nosingle::👗single1👗
58310121&amp;#x1f4…🐌1F40Cnosingle::🐌single1🐌
58410122&amp;#x1f3…🎱1F3B1nosingle::🎱single1🎱
58510123&amp;#x1f3…🌰1F330nosingle::🌰single1🌰
58610124&amp;#x1f3…🌮1F32Enosingle::🌮single1🌮
58710125&amp;#x1f5…🕵️1F575-FE0Fnosingle::🕵️single1🕵️
58810126&amp;#x1f5…🔅1F505nosingle::🔅single1🔅
58910127&amp;#9993…✉️2709-FE0Fnosingle::✉️single1✉️
59010128&amp;#x1f1…🇪🇬1F1EA-1F1ECnosingle::🇪🇬single1🇪🇬
59110129&amp;#x1f6…🚑1F691nosingle::🚑single1🚑
59210130&amp;#x1f4…📦1F4E6nosingle::📦single1📦
59310131&amp;#x1f9…🤥1F925nosingle::🤥single1🤥
59410132&amp;#x1f5…🔄1F504nosingle::🔄single1🔄
59510133&amp;#x1f9…🤳1F933nosingle::🤳single1🤳
59610134&amp;#x1f4…💲1F4B2nosingle::💲single1💲
59710135&amp;#x1f3…🎋1F38Bnosingle::🎋single1🎋
59810136&amp;#x1f5…🗓️1F5D3-FE0Fnosingle::🗓️single1🗓️
59910137&amp;#x1f9…🤖1F916nosingle::🤖single1🤖
60010138&amp;#x1f9…🥔1F954nosingle::🥔single1🥔
60110139&amp;#x1f1…🆗1F197nosingle::🆗single1🆗
60210140&amp;#x1f5…🔑1F511nosingle::🔑single1🔑
60310141&amp;#x1f1…🇨🇳1F1E8-1F1F3nosingle::🇨🇳single1🇨🇳
60410142&amp;#x1f4…🐤1F424nosingle::🐤single1🐤
605101434&amp;#650…4️⃣34-FE0F-20E3nosingle::4️⃣single14️⃣
60610144&amp;#x1f4…🐑1F411nosingle::🐑single1🐑
60710145&amp;#1016…27B0nosingle::➰single1
60810146&amp;#x1f4…👩‍🎓1F469-200D-1F393nosingle::👩‍🎓single1👩‍🎓
60910147&amp;#9730…☂️2602-FE0Fnosingle::☂️single1☂️
61010148&amp;#x1f1…🇦🇹1F1E6-1F1F9nosingle::🇦🇹single1🇦🇹
61110149&amp;#x1f9…🦆1F986nosingle::🦆single1🦆
61210150&amp;#x1f6…🚌1F68Cnosingle::🚌single1🚌
61310151&amp;#x1f4…💿1F4BFnosingle::💿single1💿
61410152&amp;#x1f3…🏥1F3E5nosingle::🏥single1🏥
61510153&amp;#x1f4…🐋1F40Bnosingle::🐋single1🐋
61610154&amp;#x1f6…🚒1F692nosingle::🚒single1🚒
61710155&amp;#x1f3…🏐1F3D0nosingle::🏐single1🏐
61810156&amp;#x1f1…🇪🇨1F1EA-1F1E8nosingle::🇪🇨single1🇪🇨
61910157&amp;#x1f9…🥐1F950nosingle::🥐single1🥐
62010158&amp;#x1f3…🎷1F3B7nosingle::🎷single1🎷
62110159&amp;#x1f5…🗽1F5FDnosingle::🗽single1🗽
62210160&amp;#x1f5…🗡️1F5E1-FE0Fnosingle::🗡️single1🗡️
62310161&amp;#x1f3…🏏1F3CFnosingle::🏏single1🏏
62410162&amp;#x1f4…🐭1F42Dnosingle::🐭single1🐭
62510163&amp;#x1f6…🙎1F64Enosingle::🙎single1🙎
62610164&amp;#x1f3…🌑1F311nosingle::🌑single1🌑
62710165&amp;#x1f6…🚔1F694nosingle::🚔single1🚔
62810166&amp;#x1f1…🇮🇩1F1EE-1F1E9nosingle::🇮🇩single1🇮🇩
62910167&amp;#x1f6…🚿1F6BFnosingle::🚿single1🚿
63010168&amp;#x1f9…🥝1F95Dnosingle::🥝single1🥝
63110169&amp;#x1f5…🕌1F54Cnosingle::🕌single1🕌
63210170&amp;#x1f4…🐀1F400nosingle::🐀single1🐀
63310171&amp;#x1f6…🛡️1F6E1-FE0Fnosingle::🛡️single1🛡️
63410172&amp;#x1f5…🔒1F512nosingle::🔒single1🔒
63510173&amp;#1003…✳️2733-FE0Fnosingle::✳️single1✳️
63610174&amp;#x1f5…🕶️1F576-FE0Fnosingle::🕶️single1🕶️
63710175&amp;#x1f4…👩‍❤️‍💋‍👩1F469-200D-2764-FE0F-200D-1F48B-200D-1F469nosingle::👩‍❤️‍💋‍👩single1👩‍❤️‍💋‍👩
63810176&amp;#x1f3…🎟️1F39F-FE0Fnosingle::🎟️single1🎟️
63910177&amp;#x1f4…🐉1F409nosingle::🐉single1🐉
64010178&amp;#x1f5…🔱1F531nosingle::🔱single1🔱
64110179&amp;#x1f5…🔎1F50Enosingle::🔎single1🔎
64210180&amp;#x1f1…🇦🇺1F1E6-1F1FAnosingle::🇦🇺single1🇦🇺
64310181&amp;#9904…⚰️26B0-FE0Fnosingle::⚰️single1⚰️
64410182&amp;#x1f4…🐩1F429nosingle::🐩single1🐩
64510183&amp;#x1f9…🦑1F991nosingle::🦑single1🦑
64610184&amp;#x1f9…🧟1F9DFnosingle::🧟single1🧟
64710185&amp;#x1f1…🆕1F195nosingle::🆕single1🆕
64810186&amp;#x1f9…🦊1F98Anosingle::🦊single1🦊
64910187&amp;#x1f4…👕1F455nosingle::👕single1👕
65010188&amp;#x1f3…🏹1F3F9nosingle::🏹single1🏹
65110189&amp;#x1f1…🇩🇿1F1E9-1F1FFnosingle::🇩🇿single1🇩🇿
65210190&amp;#x1f4…👬1F46Cnosingle::👬single1👬
65310191&amp;#x1f3…🍱1F371nosingle::🍱single1🍱
65410192&amp;#x1f4…📰1F4F0nosingle::📰single1📰
65510193&amp;#x1f9…🥋1F94Bnosingle::🥋single1🥋
65610194&amp;#x1f6…🚤1F6A4nosingle::🚤single1🚤
65710195&amp;#x1f3…🏰1F3F0nosingle::🏰single1🏰
658101965&amp;#650…5️⃣35-FE0F-20E3nosingle::5️⃣single15️⃣
65910197&amp;#x1f9…🦉1F989nosingle::🦉single1🦉
66010198&amp;#x1f6…🚢1F6A2nosingle::🚢single1🚢
66110199&amp;#x1f3…🌨️1F328-FE0Fnosingle::🌨️single1🌨️
66210200&amp;#x1f4…📆1F4C6nosingle::📆single1📆
66310201&amp;#x1f5…🗝️1F5DD-FE0Fnosingle::🗝️single1🗝️
66410202&amp;#x1f3…🎌1F38Cnosingle::🎌single1🎌
66510203&amp;#x1f9…🧔1F9D4nosingle::🧔single1🧔
66610204&amp;#x1f4…💳1F4B3nosingle::💳single1💳
66710205&amp;#x1f1…🇺🇾1F1FA-1F1FEnosingle::🇺🇾single1🇺🇾
66810206&amp;#x1f9…🥗1F957nosingle::🥗single1🥗
66910207&amp;#9775…☯️262F-FE0Fnosingle::☯️single1☯️
67010208&amp;#9881…⚙️2699-FE0Fnosingle::⚙️single1⚙️
67110209&amp;#x1f4…💶1F4B6nosingle::💶single1💶
67210210&amp;#9961…⛩️26E9-FE0Fnosingle::⛩️single1⛩️
67310211&amp;#x1f5…🗻1F5FBnosingle::🗻single1🗻
67410212&amp;#1000…✒️2712-FE0Fnosingle::✒️single1✒️
67510213&amp;#x1f1…🇺🇲1F1FA-1F1F2nosingle::🇺🇲single1🇺🇲
67610214&amp;#x1f1…🇵🇹1F1F5-1F1F9nosingle::🇵🇹single1🇵🇹
67710215&amp;#x1f3…🍠1F360nosingle::🍠single1🍠
678111&amp;#x1f4…👷1F477nosingle::👷single1👷
679112&amp;#x1f6…🛍️1F6CD-FE0Fnosingle::🛍️single1🛍️
680113&amp;#x1f3…🏎️1F3CE-FE0Fnosingle::🏎️single1🏎️
681114&amp;#x1f5…🔛1F51Bnosingle::🔛single1🔛
682115&amp;#x1f5…🔙1F519nosingle::🔙single1🔙
683116&amp;#x1f5…🗼1F5FCnosingle::🗼single1🗼
684117&amp;#x1f3…🎖️1F396-FE0Fnosingle::🎖️single1🎖️
685118&amp;#x1f6…🚺1F6BAnosingle::🚺single1🚺
686119&amp;#x1f4…🐹1F439nosingle::🐹single1🐹
6871110&amp;#x1f9…🥖1F956nosingle::🥖single1🥖
6881111&amp;#x1f1…🇵🇷1F1F5-1F1F7nosingle::🇵🇷single1🇵🇷
6891112&amp;#x1f3…🎡1F3A1nosingle::🎡single1🎡
6901113&amp;#x1f5…🔍1F50Dnosingle::🔍single1🔍
6911114&amp;#x1f3…🏟️1F3DF-FE0Fnosingle::🏟️single1🏟️
6921115&amp;#x1f5…🖥️1F5A5-FE0Fnosingle::🖥️single1🖥️
6931116&amp;#x1f3…🌭1F32Dnosingle::🌭single1🌭
6941117&amp;#x1f1…🇹🇭1F1F9-1F1EDnosingle::🇹🇭single1🇹🇭
6951118&amp;#1001…✡️2721-FE0Fnosingle::✡️single1✡️
6961119&amp;#x1f3…🏒1F3D2nosingle::🏒single1🏒
6971120&amp;#x1f6…🛀1F6C0nosingle::🛀single1🛀
6981121&amp;#x1f4…📅1F4C5nosingle::📅single1📅
6991122&amp;#x1f5…🖋️1F58B-FE0Fnosingle::🖋️single1🖋️
7001123&amp;#x1f1…🇸🇪1F1F8-1F1EAnosingle::🇸🇪single1🇸🇪
7011124&amp;#x1f3…🎞️1F39E-FE0Fnosingle::🎞️single1🎞️
7021125&amp;#x1f3…🎪1F3AAnosingle::🎪single1🎪
7031126&amp;#x1f3…🍙1F359nosingle::🍙single1🍙
7041127&amp;#x1f6…🛁1F6C1nosingle::🛁single1🛁
7051128&amp;#x1f4…📕1F4D5nosingle::📕single1📕
7061129&amp;#x1f4…👒1F452nosingle::👒single1👒
7071130&amp;#x1f5…🖇️1F587-FE0Fnosingle::🖇️single1🖇️
7081131&amp;#1006…2754nosingle::❔single1
7091132&amp;#x1f3…🎄1F384nosingle::🎄single1🎄
7101133&amp;#x1f5…🕸️1F578-FE0Fnosingle::🕸️single1🕸️
7111134&amp;#x1f9…🥜1F95Cnosingle::🥜single1🥜
7121135&amp;#x1f5…🕴️1F574-FE0Fnosingle::🕴️single1🕴️
7131136&amp;#x1f9…🥟1F95Fnosingle::🥟single1🥟
7141137&amp;#8601…↙️2199-FE0Fnosingle::↙️single1↙️
7151138&amp;#9726…25FEnosingle::◾single1
7161139&amp;#x1f4…🐲1F432nosingle::🐲single1🐲
7171140&amp;#x1f3…🏴1F3F4nosingle::🏴single1🏴
7181141&amp;#x1f9…🦌1F98Cnosingle::🦌single1🦌
7191142&amp;#x1f1…🆔1F194nosingle::🆔single1🆔
7201143&amp;#x1f4…👛1F45Bnosingle::👛single1👛
7211144&amp;#x1f6…🚚1F69Anosingle::🚚single1🚚
7221145&amp;#8618…↪️21AA-FE0Fnosingle::↪️single1↪️
7231146&amp;#x1f1…🇬🇷1F1EC-1F1F7nosingle::🇬🇷single1🇬🇷
7241147&amp;#x1f4…📿1F4FFnosingle::📿single1📿
7251148&amp;#x1f3…🌫️1F32B-FE0Fnosingle::🌫️single1🌫️
7261149&amp;#x1f3…🌂1F302nosingle::🌂single1🌂
7271150&amp;#x1f5…🕉️1F549-FE0Fnosingle::🕉️single1🕉️
7281151&amp;#x1f1…🆚1F19Anosingle::🆚single1🆚
7291152&amp;#x1f4…📉1F4C9nosingle::📉single1📉
7301153&amp;#x1f5…🗨️1F5E8-FE0Fnosingle::🗨️single1🗨️
7311154&amp;#x1f6…🛒1F6D2nosingle::🛒single1🛒
7321155&amp;#x1f4…📛1F4DBnosingle::📛single1📛
7331156&amp;#x1f1…🇳🇱1F1F3-1F1F1nosingle::🇳🇱single1🇳🇱
7341157&amp;#x1f4…🐿️1F43F-FE0Fnosingle::🐿️single1🐿️
7351158&amp;#x1f1…🅾️1F17E-FE0Fnosingle::🅾️single1🅾️
7361159&amp;#9762…☢️2622-FE0Fnosingle::☢️single1☢️
7371160&amp;#x1f1…🇲🇦1F1F2-1F1E6nosingle::🇲🇦single1🇲🇦
7381161&amp;#x1f4…🐃1F403nosingle::🐃single1🐃
7391162&amp;#9981…26FDnosingle::⛽single1
7401163&amp;#x1f1…🅿️1F17F-FE0Fnosingle::🅿️single1🅿️
7411164&amp;#x1f9…🦇1F987nosingle::🦇single1🦇
7421165&amp;#1054…⤴️2934-FE0Fnosingle::⤴️single1⤴️
7431166&amp;#9973…26F5nosingle::⛵single1
7441167&amp;#x1f4…👞1F45Enosingle::👞single1👞
7451168&amp;#x1f5…🔓1F513nosingle::🔓single1🔓
7461169&amp;#x1f9…🧞1F9DEnosingle::🧞single1🧞
7471170&amp;#9968…⛰️26F0-FE0Fnosingle::⛰️single1⛰️
7481171&amp;#x1f4…📎1F4CEnosingle::📎single1📎
7491172&amp;#x1f9…🦎1F98Enosingle::🦎single1🦎
7501173&amp;#x1f5…🔌1F50Cnosingle::🔌single1🔌
7511174&amp;#x1f3…🏳️1F3F3-FE0Fnosingle::🏳️single1🏳️
7521175&amp;#x1f6…🚽1F6BDnosingle::🚽single1🚽
7531176&amp;#x1f3…🍲1F372nosingle::🍲single1🍲
7541177&amp;#9874…⚒️2692-FE0Fnosingle::⚒️single1⚒️
7551178&amp;#9802…264Anosingle::♊single1
7561179&amp;#x1f4…👢1F462nosingle::👢single1👢
7571180&amp;#x1f9…🧙1F9D9nosingle::🧙single1🧙
7581181&amp;#x1f1…🇵🇱1F1F5-1F1F1nosingle::🇵🇱single1🇵🇱
7591182&amp;#9962…26EAnosingle::⛪single1
7601183&amp;#x1f1…🇧🇪1F1E7-1F1EAnosingle::🇧🇪single1🇧🇪
7611184&amp;#x1f3…🎎1F38Enosingle::🎎single1🎎
7621185&amp;#x1f5…🔖1F516nosingle::🔖single1🔖
7631186&amp;#x1f5…🔋1F50Bnosingle::🔋single1🔋
7641187&amp;#x1f4…📡1F4E1nosingle::📡single1📡
7651188&amp;#x1f3…🌇1F307nosingle::🌇single1🌇
7661189&amp;#x1f3…🏉1F3C9nosingle::🏉single1🏉
7671190&amp;#x1f4…👪1F46Anosingle::👪single1👪
7681191&amp;#x1f4…👖1F456nosingle::👖single1👖
7691192&amp;#x1f4…📊1F4CAnosingle::📊single1📊
7701193&amp;#x1f4…🐆1F406nosingle::🐆single1🐆
7711194&amp;#x1f5…🔁1F501nosingle::🔁single1🔁
7721195&amp;#x1f6…🛩️1F6E9-FE0Fnosingle::🛩️single1🛩️
7731196&amp;#x1f4…🐡1F421nosingle::🐡single1🐡
7741197&amp;#x1f0…🃏1F0CFnosingle::🃏single1🃏
7751198&amp;#x1f4…👩‍💻1F469-200D-1F4BBnosingle::👩‍💻single1👩‍💻
7761199&amp;#x1f3…🌩️1F329-FE0Fnosingle::🌩️single1🌩️
77711100&amp;#x1f1…🇳🇬1F1F3-1F1ECnosingle::🇳🇬single1🇳🇬
77811101&amp;#x1f4…🐏1F40Fnosingle::🐏single1🐏
779111026&amp;#650…6️⃣36-FE0F-20E3nosingle::6️⃣single16️⃣
78011103&amp;#9977…⛹️26F9-FE0Fnosingle::⛹️single1⛹️
78111104&amp;#x1f5…🗯️1F5EF-FE0Fnosingle::🗯️single1🗯️
78211105&amp;#x1f1…🇨🇭1F1E8-1F1EDnosingle::🇨🇭single1🇨🇭
78311106&amp;#x1f3…🍢1F362nosingle::🍢single1🍢
78411107&amp;#x1f5…🗺️1F5FA-FE0Fnosingle::🗺️single1🗺️
78511108&amp;#x1f6…🚕1F695nosingle::🚕single1🚕
78611109&amp;#x1f1…🇷🇺1F1F7-1F1FAnosingle::🇷🇺single1🇷🇺
78711110&amp;#9410…Ⓜ️24C2-FE0Fnosingle::Ⓜ️single1Ⓜ️
78811111&amp;#9969…⛱️26F1-FE0Fnosingle::⛱️single1⛱️
78911112&amp;#x1f3…🏫1F3EBnosingle::🏫single1🏫
79011113&amp;#x1f4…📀1F4C0nosingle::📀single1📀
79111114&amp;#x1f1…🆙1F199nosingle::🆙single1🆙
79211115&amp;#x1f9…🤼1F93Cnosingle::🤼single1🤼
79311116&amp;#x1f4…🐨1F428nosingle::🐨single1🐨
79411117&amp;#x1f9…🦂1F982nosingle::🦂single1🦂
79511118&amp;#x1f4…💷1F4B7nosingle::💷single1💷
79611119&amp;#x1f9…🥈1F948nosingle::🥈single1🥈
79711120&amp;#x1f4…👨‍💻1F468-200D-1F4BBnosingle::👨‍💻single1👨‍💻
79811121&amp;#x1f6…🛏️1F6CF-FE0Fnosingle::🛏️single1🛏️
79911122&amp;#9724…◼️25FC-FE0Fnosingle::◼️single1◼️
80011123&amp;#x1f6…🚄1F684nosingle::🚄single1🚄
80111124&amp;#x1f9…🧖1F9D6nosingle::🧖single1🧖
80211125&amp;#x1f3…🎠1F3A0nosingle::🎠single1🎠
80311126&amp;#x1f4…🐁1F401nosingle::🐁single1🐁
80411127&amp;#x1f5…🗳️1F5F3-FE0Fnosingle::🗳️single1🗳️
80511128&amp;#x1f3…🏮1F3EEnosingle::🏮single1🏮
80611129&amp;#x1f9…🥪1F96Anosingle::🥪single1🥪
80711130&amp;#x1f3…🌥️1F325-FE0Fnosingle::🌥️single1🌥️
80811131&amp;#x1f6…🚪1F6AAnosingle::🚪single1🚪
80911132&amp;#1295…㊙️3299-FE0Fnosingle::㊙️single1㊙️
81011133&amp;#x1f5…🗞️1F5DE-FE0Fnosingle::🗞️single1🗞️
81111134&amp;#x1f4…👨‍⚕️1F468-200D-2695-FE0Fnosingle::👨‍⚕️single1👨‍⚕️
81211135&amp;#x1f5…🖊️1F58A-FE0Fnosingle::🖊️single1🖊️
81311136&amp;#1103…2B1Bnosingle::⬛single1
81411137&amp;#x1f1…🆒1F192nosingle::🆒single1🆒
81511138&amp;#x1f1…🇨🇺1F1E8-1F1FAnosingle::🇨🇺single1🇨🇺
81611139&amp;#x1f9…🥧1F967nosingle::🥧single1🥧
81711140&amp;#x1f4…👩‍⚕️1F469-200D-2695-FE0Fnosingle::👩‍⚕️single1👩‍⚕️
81811141&amp;#x1f1…🇳🇮1F1F3-1F1EEnosingle::🇳🇮single1🇳🇮
81911142&amp;#x1f4…👨‍🎓1F468-200D-1F393nosingle::👨‍🎓single1👨‍🎓
82011143&amp;#x1f6…🚧1F6A7nosingle::🚧single1🚧
82111144&amp;#9939…⛓️26D3-FE0Fnosingle::⛓️single1⛓️
82211145&amp;#x1f6…🛵1F6F5nosingle::🛵single1🛵
82311146&amp;#x1f9…🥅1F945nosingle::🥅single1🥅
82411147&amp;#x1f6…🚅1F685nosingle::🚅single1🚅
82511148&amp;#x1f5…🖼️1F5BC-FE0Fnosingle::🖼️single1🖼️
82611149&amp;#9855…267Fnosingle::♿single1
82711150&amp;#x1f4…📏1F4CFnosingle::📏single1📏
82811151&amp;#x1f0…🀄1F004nosingle::🀄single1🀄
82911152&amp;#x1f5…🖌️1F58C-FE0Fnosingle::🖌️single1🖌️
83011153&amp;#x1f2…🉐1F250nosingle::🉐single1🉐
83111154&amp;#x1f4…📬1F4ECnosingle::📬single1📬
83211155&amp;#x1f6…🚁1F681nosingle::🚁single1🚁
83311156&amp;#x1f5…🗑️1F5D1-FE0Fnosingle::🗑️single1🗑️
83411157&amp;#x1f9…🤵1F935nosingle::🤵single1🤵
83511158&amp;#x1f3…🏷️1F3F7-FE0Fnosingle::🏷️single1🏷️
83611159&amp;#x1f3…🌡️1F321-FE0Fnosingle::🌡️single1🌡️
83711160&amp;#x1f3…🎐1F390nosingle::🎐single1🎐
83811161&amp;#x1f1…🇯🇲1F1EF-1F1F2nosingle::🇯🇲single1🇯🇲
83911162&amp;#x1f9…🦒1F992nosingle::🦒single1🦒
84011163&amp;#x1f3…🎒1F392nosingle::🎒single1🎒
84111164&amp;#x1f1…🇵🇾1F1F5-1F1FEnosingle::🇵🇾single1🇵🇾
84211165&amp;#x1f1…🇺🇦1F1FA-1F1E6nosingle::🇺🇦single1🇺🇦
84311166&amp;#x1f1…🇲🇨1F1F2-1F1E8nosingle::🇲🇨single1🇲🇨
84411167&amp;#x1f9…🥉1F949nosingle::🥉single1🥉
845111687&amp;#650…7️⃣37-FE0F-20E3nosingle::7️⃣single17️⃣
84611169&amp;#8987…231Bnosingle::⌛single1
84711170&amp;#x1f3…🌒1F312nosingle::🌒single1🌒
84811171&amp;#x1f9…🥥1F965nosingle::🥥single1🥥
84911172&amp;#x1f3…🎅1F385nosingle::🎅single1🎅
85011173&amp;#x1f9…🧕1F9D5nosingle::🧕single1🧕
85111174&amp;#x1f6…🛐1F6D0nosingle::🛐single1🛐
85211175&amp;#9803…264Bnosingle::♋single1
85311176&amp;#x1f9…🧑1F9D1nosingle::🧑single1🧑
85411177&amp;#x1f1…🇩🇴1F1E9-1F1F4nosingle::🇩🇴single1🇩🇴
85511178&amp;#x1f1…🇮🇪1F1EE-1F1EAnosingle::🇮🇪single1🇮🇪
85611179&amp;#x1f6…🛸1F6F8nosingle::🛸single1🛸
85711180&amp;#x1f9…🧒1F9D2nosingle::🧒single1🧒
85811181&amp;#x1f4…📧1F4E7nosingle::📧single1📧
85911182&amp;#x1f9…🥢1F962nosingle::🥢single1🥢
86011183&amp;#x1f3…🎢1F3A2nosingle::🎢single1🎢
86111184&amp;#x1f4…🐪1F42Anosingle::🐪single1🐪
86211185&amp;#x1f1…🇮🇱1F1EE-1F1F1nosingle::🇮🇱single1🇮🇱
86311186&amp;#x1f4…👨‍🍳1F468-200D-1F373nosingle::👨‍🍳single1👨‍🍳
86411187&amp;#x1f6…🚛1F69Bnosingle::🚛single1🚛
86511188&amp;#x1f9…🥄1F944nosingle::🥄single1🥄
86611189&amp;#x1f9…🤹1F939nosingle::🤹single1🤹
86711190&amp;#x1f3…🏢1F3E2nosingle::🏢single1🏢
86811191&amp;#x1f4…💈1F488nosingle::💈single1💈
86911192&amp;#x1f4…👘1F458nosingle::👘single1👘
87011193&amp;#x1f1…🇳🇴1F1F3-1F1F4nosingle::🇳🇴single1🇳🇴
87111194&amp;#x1f1…🇬🇭1F1EC-1F1EDnosingle::🇬🇭single1🇬🇭
87211195&amp;#x1f3…🏔️1F3D4-FE0Fnosingle::🏔️single1🏔️
87311196&amp;#1234…〽️303D-FE0Fnosingle::〽️single1〽️
87411197&amp;#x1f6…🚦1F6A6nosingle::🚦single1🚦
87511198&amp;#x1f1…🇿🇦1F1FF-1F1E6nosingle::🇿🇦single1🇿🇦
87611199&amp;#x1f1…🇹🇳1F1F9-1F1F3nosingle::🇹🇳single1🇹🇳
87711200&amp;#x1f4…💼1F4BCnosingle::💼single1💼
87811201&amp;#9800…2648nosingle::♈single1
87911202&amp;#x1f9…🦓1F993nosingle::🦓single1🦓
88011203&amp;#x1f6…🚻1F6BBnosingle::🚻single1🚻
88111204&amp;#x1f3…🎰1F3B0nosingle::🎰single1🎰
88211205&amp;#9725…25FDnosingle::◽single1
88311206&amp;#x1f9…🦗1F997nosingle::🦗single1🦗
88411207&amp;#x1f9…🤾1F93Enosingle::🤾single1🤾
88511208&amp;#x1f4…👚1F45Anosingle::👚single1👚
88611209&amp;#x1f4…👡1F461nosingle::👡single1👡
88711210&amp;#9935…⛏️26CF-FE0Fnosingle::⛏️single1⛏️
888112119&amp;#650…9️⃣39-FE0F-20E3nosingle::9️⃣single19️⃣
88911212&amp;#x1f6…🛎️1F6CE-FE0Fnosingle::🛎️single1🛎️
89011213&amp;#x1f4…🐗1F417nosingle::🐗single1🐗
89111214&amp;#x1f3…🌦️1F326-FE0Fnosingle::🌦️single1🌦️
89211215&amp;#x1f1…🇸🇦1F1F8-1F1E6nosingle::🇸🇦single1🇸🇦
89311216&amp;#x1f6…🚹1F6B9nosingle::🚹single1🚹
89411217&amp;#x1f1…🇾🇪1F1FE-1F1EAnosingle::🇾🇪single1🇾🇪
89511218&amp;#9770…☪️262A-FE0Fnosingle::☪️single1☪️
89611219&amp;#x1f9…🤺1F93Anosingle::🤺single1🤺
89711220&amp;#x1f6…🚣1F6A3nosingle::🚣single1🚣
89811221&amp;#x1f5…🗿1F5FFnosingle::🗿single1🗿
89911222&amp;#x1f9…🥘1F958nosingle::🥘single1🥘
90011223&amp;#x1f1…🇩🇰1F1E9-1F1F0nosingle::🇩🇰single1🇩🇰
90111224&amp;#x1f9…🦕1F995nosingle::🦕single1🦕
90211225&amp;#x1f1…🇱🇷1F1F1-1F1F7nosingle::🇱🇷single1🇱🇷
90311226&amp;#x1f3…🏕️1F3D5-FE0Fnosingle::🏕️single1🏕️
90411227&amp;#9763…☣️2623-FE0Fnosingle::☣️single1☣️
90511228&amp;#x1f3…🌆1F306nosingle::🌆single1🌆
90611229&amp;#8597…↕️2195-FE0Fnosingle::↕️single1↕️
90711230&amp;#x1f4…📨1F4E8nosingle::📨single1📨
90811231&amp;#x1f3…🏨1F3E8nosingle::🏨single1🏨
90911232&amp;#x1f5…🕹️1F579-FE0Fnosingle::🕹️single1🕹️
91011233&amp;#x1f4…👩‍🍳1F469-200D-1F373nosingle::👩‍🍳single1👩‍🍳
91111234&amp;#x1f1…🇨🇮1F1E8-1F1EEnosingle::🇨🇮single1🇨🇮
912121&amp;#x1f4…📯1F4EFnosingle::📯single1📯
913122&amp;#x1f4…👜1F45Cnosingle::👜single1👜
914123&amp;#x1f3…🏘️1F3D8-FE0Fnosingle::🏘️single1🏘️
915124&amp;#x1f4…🐫1F42Bnosingle::🐫single1🐫
916125&amp;#9978…26FAnosingle::⛺single1
917126&amp;#x1f4…👩‍🏫1F469-200D-1F3EBnosingle::👩‍🏫single1👩‍🏫
918127&amp;#x1f5…🕳️1F573-FE0Fnosingle::🕳️single1🕳️
919128&amp;#x1f3…🏯1F3EFnosingle::🏯single1🏯
920129&amp;#x1f3…🌓1F313nosingle::🌓single1🌓
9211210&amp;#x1f3…🌉1F309nosingle::🌉single1🌉
92212118&amp;#650…8️⃣38-FE0F-20E3nosingle::8️⃣single18️⃣
9231212&amp;#x1f4…💂1F482nosingle::💂single1💂
9241213&amp;#x1f5…🔉1F509nosingle::🔉single1🔉
9251214&amp;#x1f4…👨‍👩‍👧‍👦1F468-200D-1F469-200D-1F467-200D-1F466nosingle::👨‍👩‍👧‍👦single1👨‍👩‍👧‍👦
9261215&amp;#x1f1…🇪🇺1F1EA-1F1FAnosingle::🇪🇺single1🇪🇺
9271216&amp;#x1f6…🚜1F69Cnosingle::🚜single1🚜
9281217&amp;#x1f5…🔕1F515nosingle::🔕single1🔕
9291218&amp;#x1f4…📥1F4E5nosingle::📥single1📥
9301219&amp;#x1f3…🌔1F314nosingle::🌔single1🌔
9311220&amp;#x1f3…🏙️1F3D9-FE0Fnosingle::🏙️single1🏙️
9321221&amp;#x1f9…🥨1F968nosingle::🥨single1🥨
9331222&amp;#x1f1…🇲🇾1F1F2-1F1FEnosingle::🇲🇾single1🇲🇾
9341223&amp;#x1f4…📘1F4D8nosingle::📘single1📘
9351224&amp;#x1f4…📗1F4D7nosingle::📗single1📗
9361225&amp;#x1f4…📜1F4DCnosingle::📜single1📜
9371226&amp;#9807…264Fnosingle::♏single1
9381227&amp;#x1f1…🇧🇴1F1E7-1F1F4nosingle::🇧🇴single1🇧🇴
9391228&amp;#x1f3…🏜️1F3DC-FE0Fnosingle::🏜️single1🏜️
9401229&amp;#x1f3…🍘1F358nosingle::🍘single1🍘
9411230&amp;#x1f6…🛬1F6ECnosingle::🛬single1🛬
9421231&amp;#x1f3…🌯1F32Fnosingle::🌯single1🌯
9431232&amp;#x1f3…🏗️1F3D7-FE0Fnosingle::🏗️single1🏗️
9441233&amp;#x1f3…🏴󠁧󠁢󠁥󠁮󠁧󠁿1F3F4-E0067-E0062-E0065-E006E-E0067-E007Fnosingle::🏴󠁧󠁢󠁥󠁮󠁧󠁿single1🏴󠁧󠁢󠁥󠁮󠁧󠁿
9451234&amp;#x1f6…🚼1F6BCnosingle::🚼single1🚼
9461235&amp;#x1f3…🏞️1F3DE-FE0Fnosingle::🏞️single1🏞️
9471236&amp;#x1f5…🔈1F508nosingle::🔈single1🔈
9481237&amp;#x1f5…🕰️1F570-FE0Fnosingle::🕰️single1🕰️
9491238&amp;#x1f3…🏓1F3D3nosingle::🏓single1🏓
9501239&amp;#x1f1…🇨🇷1F1E8-1F1F7nosingle::🇨🇷single1🇨🇷
9511240&amp;#x1f4…👨‍🌾1F468-200D-1F33Enosingle::👨‍🌾single1👨‍🌾
9521241&amp;#x1f9…🦃1F983nosingle::🦃single1🦃
9531242&amp;#x1f5…🔦1F526nosingle::🔦single1🔦
9541243&amp;#x1f5…🔃1F503nosingle::🔃single1🔃
9551244&amp;#x1f1…🇸🇻1F1F8-1F1FBnosingle::🇸🇻single1🇸🇻
9561245&amp;#x1f6…🛳️1F6F3-FE0Fnosingle::🛳️single1🛳️
9571246&amp;#x1f3…🌘1F318nosingle::🌘single1🌘
9581247&amp;#x1f1…🇧🇩1F1E7-1F1E9nosingle::🇧🇩single1🇧🇩
9591248&amp;#x1f9…🧛1F9DBnosingle::🧛single1🧛
9601249&amp;#x1f4…👩‍❤️‍👩1F469-200D-2764-FE0F-200D-1F469nosingle::👩‍❤️‍👩single1👩‍❤️‍👩
9611250&amp;#x1f5…🔚1F51Anosingle::🔚single1🔚
9621251&amp;#x1f5…🔟1F51Fnosingle::🔟single1🔟
9631252&amp;#8505…ℹ️2139-FE0Fnosingle::ℹ️single1ℹ️
9641253&amp;#x1f6…🛢️1F6E2-FE0Fnosingle::🛢️single1🛢️
9651254&amp;#x1f3…🏴󠁧󠁢󠁳󠁣󠁴󠁿1F3F4-E0067-E0062-E0073-E0063-E0074-E007Fnosingle::🏴󠁧󠁢󠁳󠁣󠁴󠁿single1🏴󠁧󠁢󠁳󠁣󠁴󠁿
9661255&amp;#x1f1…🇸🇩1F1F8-1F1E9nosingle::🇸🇩single1🇸🇩
9671256&amp;#x1f5…🔭1F52Dnosingle::🔭single1🔭
9681257&amp;#x1f1…🇮🇶1F1EE-1F1F6nosingle::🇮🇶single1🇮🇶
9691258&amp;#x1f3…🎍1F38Dnosingle::🎍single1🎍
9701259&amp;#x1f9…🥣1F963nosingle::🥣single1🥣
9711260&amp;#x1f3…🌗1F317nosingle::🌗single1🌗
9721261&amp;#8598…↖️2196-FE0Fnosingle::↖️single1↖️
9731262&amp;#x1f3…🎳1F3B3nosingle::🎳single1🎳
9741263&amp;#x1f4…👨‍❤️‍💋‍👨1F468-200D-2764-FE0F-200D-1F48B-200D-1F468nosingle::👨‍❤️‍💋‍👨single1👨‍❤️‍💋‍👨
9751264&amp;#x1f4…👨‍🏫1F468-200D-1F3EBnosingle::👨‍🏫single1👨‍🏫
9761265&amp;#x1f4…📄1F4C4nosingle::📄single1📄
9771266&amp;#x1f1…🇶🇦1F1F6-1F1E6nosingle::🇶🇦single1🇶🇦
9781267&amp;#x1f4…👨‍👩‍👧1F468-200D-1F469-200D-1F467nosingle::👨‍👩‍👧single1👨‍👩‍👧
9791268&amp;#x1f3…🎚️1F39A-FE0Fnosingle::🎚️single1🎚️
9801269&amp;#x1f6…🛠️1F6E0-FE0Fnosingle::🛠️single1🛠️
9811270&amp;#x1f1…🆓1F193nosingle::🆓single1🆓
9821271&amp;#x1f1…🇵🇸1F1F5-1F1F8nosingle::🇵🇸single1🇵🇸
9831272&amp;#x1f3…🏛️1F3DB-FE0Fnosingle::🏛️single1🏛️
9841273&amp;#9808…2650nosingle::♐single1
9851274&amp;#x1f9…🧦1F9E6nosingle::🧦single1🧦
9861275&amp;#x1f5…🗾1F5FEnosingle::🗾single1🗾
9871276&amp;#x1f4…👩‍🌾1F469-200D-1F33Enosingle::👩‍🌾single1👩‍🌾
9881277&amp;#x1f3…🎑1F391nosingle::🎑single1🎑
9891278&amp;#x1f1…🇬🇹1F1EC-1F1F9nosingle::🇬🇹single1🇬🇹
9901279&amp;#x1f9…🧤1F9E4nosingle::🧤single1🧤
9911280&amp;#x1f9…🧝1F9DDnosingle::🧝single1🧝
9921281&amp;#1017…27BFnosingle::➿single1
9931282&amp;#x1f1…🇻🇳1F1FB-1F1F3nosingle::🇻🇳single1🇻🇳
9941283&amp;#x1f4…📒1F4D2nosingle::📒single1📒
9951284&amp;#9801…2649nosingle::♉single1
9961285&amp;#9811…2653nosingle::♓single1
9971286&amp;#x1f6…🚭1F6ADnosingle::🚭single1🚭
9981287&amp;#x1f5…🔧1F527nosingle::🔧single1🔧
9991288&amp;#9804…264Cnosingle::♌single1
10001289&amp;#8617…↩️21A9-FE0Fnosingle::↩️single1↩️
10011290&amp;#9810…2652nosingle::♒single1
10021291&amp;#x1f3…🏦1F3E6nosingle::🏦single1🏦
10031292&amp;#x1f2…🈵1F235nosingle::🈵single1🈵
10041293&amp;#x1f9…🥫1F96Bnosingle::🥫single1🥫
10051294&amp;#x1f3…🌖1F316nosingle::🌖single1🌖
10061295&amp;#x1f6…🛴1F6F4nosingle::🛴single1🛴
10071296&amp;#x1f6…🚖1F696nosingle::🚖single1🚖
10081297&amp;#x1f9…🥙1F959nosingle::🥙single1🥙
10091298&amp;#x1f6…🛰️1F6F0-FE0Fnosingle::🛰️single1🛰️
10101299&amp;#x1f9…🦏1F98Fnosingle::🦏single1🦏
101112100&amp;#x1f1…🇸🇬1F1F8-1F1ECnosingle::🇸🇬single1🇸🇬
101212101&amp;#9210…⏺️23FA-FE0Fnosingle::⏺️single1⏺️
101312102&amp;#x1f5…🖍️1F58D-FE0Fnosingle::🖍️single1🖍️
101412103&amp;#9976…⛸️26F8-FE0Fnosingle::⛸️single1⛸️
101512104&amp;#x1f4…📙1F4D9nosingle::📙single1📙
101612105&amp;#x1f4…👲1F472nosingle::👲single1👲
101712106&amp;#x1f3…🏂1F3C2nosingle::🏂single1🏂
101812107*&amp;#650…*️⃣2A-FE0F-20E3nosingle::*️⃣single1*️⃣
101912108&amp;#x1f5…🔲1F532nosingle::🔲single1🔲
102012109&amp;#x1f1…🇭🇺1F1ED-1F1FAnosingle::🇭🇺single1🇭🇺
102112110&amp;#x1f6…🚐1F690nosingle::🚐single1🚐
102212111&amp;#x1f4…👨‍❤️‍👨1F468-200D-2764-FE0F-200D-1F468nosingle::👨‍❤️‍👨single1👨‍❤️‍👨
102312112&amp;#x1f5…🔇1F507nosingle::🔇single1🔇
102412113&amp;#x1f9…🦔1F994nosingle::🦔single1🦔
102512114&amp;#x1f5…🔯1F52Fnosingle::🔯single1🔯
102612115&amp;#x1f6…🚵1F6B5nosingle::🚵single1🚵
102712116&amp;#x1f6…🚯1F6AFnosingle::🚯single1🚯
102812117&amp;#x1f5…🕛1F55Bnosingle::🕛single1🕛
102912118&amp;#x1f4…📼1F4FCnosingle::📼single1📼
103012119&amp;#x1f9…🧓1F9D3nosingle::🧓single1🧓
103112120&amp;#x1f3…🎛️1F39B-FE0Fnosingle::🎛️single1🎛️
103212121&amp;#x1f1…🇨🇿1F1E8-1F1FFnosingle::🇨🇿single1🇨🇿
103312122&amp;#x1f1…🇭🇰1F1ED-1F1F0nosingle::🇭🇰single1🇭🇰
103412123&amp;#x1f4…💺1F4BAnosingle::💺single1💺
103512124&amp;#x1f4…👩‍🎤1F469-200D-1F3A4nosingle::👩‍🎤single1👩‍🎤
103612125&amp;#9970…26F2nosingle::⛲single1
103712126&amp;#9883…⚛️269B-FE0Fnosingle::⚛️single1⚛️
103812127&amp;#x1f5…🕒1F552nosingle::🕒single1🕒
103912128&amp;#x1f4…📶1F4F6nosingle::📶single1📶
104012129&amp;#x1f6…🚋1F68Bnosingle::🚋single1🚋
104112130&amp;#x1f1…🇱🇧1F1F1-1F1E7nosingle::🇱🇧single1🇱🇧
104212131&amp;#x1f4…📠1F4E0nosingle::📠single1📠
104312132&amp;#x1f6…🚇1F687nosingle::🚇single1🚇
104412133&amp;#9202…⏲️23F2-FE0Fnosingle::⏲️single1⏲️
104512134&amp;#9194…23EAnosingle::⏪single1
104612135&amp;#x1f1…🇰🇼1F1F0-1F1FCnosingle::🇰🇼single1🇰🇼
104712136&amp;#x1f6…🛃1F6C3nosingle::🛃single1🛃
104812137&amp;#x1f1…🇭🇳1F1ED-1F1F3nosingle::🇭🇳single1🇭🇳
104912138&amp;#x1f4…👨‍✈️1F468-200D-2708-FE0Fnosingle::👨‍✈️single1👨‍✈️
105012139&amp;#x1f4…📓1F4D3nosingle::📓single1📓
105112140&amp;#x1f4…👩‍👧1F469-200D-1F467nosingle::👩‍👧single1👩‍👧
105212141&amp;#9806…264Enosingle::♎single1
105312142&amp;#x1f3…🏑1F3D1nosingle::🏑single1🏑
105412143&amp;#x1f4…📋1F4CBnosingle::📋single1📋
105512144&amp;#x1f5…🔼1F53Cnosingle::🔼single1🔼
105612145&amp;#x1f1…🇨🇵1F1E8-1F1F5nosingle::🇨🇵single1🇨🇵
105712146&amp;#x1f1…🇯🇴1F1EF-1F1F4nosingle::🇯🇴single1🇯🇴
105812147&amp;#x1f4…📃1F4C3nosingle::📃single1📃
105912148&amp;#x1f5…🔬1F52Cnosingle::🔬single1🔬
106012149&amp;#x1f1…🇫🇮1F1EB-1F1EEnosingle::🇫🇮single1🇫🇮
106112150&amp;#x1f6…🚥1F6A5nosingle::🚥single1🚥
106212151&amp;#x1f1…🇱🇰1F1F1-1F1F0nosingle::🇱🇰single1🇱🇰
106312152&amp;#9805…264Dnosingle::♍single1
106412153&amp;#x1f1…🇵🇦1F1F5-1F1E6nosingle::🇵🇦single1🇵🇦
106512154&amp;#x1f1…🇸🇾1F1F8-1F1FEnosingle::🇸🇾single1🇸🇾
106612155&amp;#x1f6…🚾1F6BEnosingle::🚾single1🚾
106712156&amp;#x1f1…🇭🇷1F1ED-1F1F7nosingle::🇭🇷single1🇭🇷
106812157&amp;#x1f1…🇳🇿1F1F3-1F1FFnosingle::🇳🇿single1🇳🇿
106912158&amp;#x1f6…🚍1F68Dnosingle::🚍single1🚍
107012159&amp;#x1f3…🏸1F3F8nosingle::🏸single1🏸
107112160&amp;#x1f5…🔳1F533nosingle::🔳single1🔳
107212161&amp;#x1f6…🛂1F6C2nosingle::🛂single1🛂
107312162&amp;#x1f4…📵1F4F5nosingle::📵single1📵
107412163&amp;#9937…⛑️26D1-FE0Fnosingle::⛑️single1⛑️
107512164&amp;#x1f3…🏚️1F3DA-FE0Fnosingle::🏚️single1🏚️
107612165&amp;#x1f3…🎽1F3BDnosingle::🎽single1🎽
107712166&amp;#x1f6…🚰1F6B0nosingle::🚰single1🚰
107812167&amp;#x1f3…🏪1F3EAnosingle::🏪single1🏪
107912168&amp;#9975…⛷️26F7-FE0Fnosingle::⛷️single1⛷️
108012169&amp;#x1f4…👩‍💼1F469-200D-1F4BCnosingle::👩‍💼single1👩‍💼
108112170&amp;#9809…2651nosingle::♑single1
108212171&amp;#x1f1…🇨🇲1F1E8-1F1F2nosingle::🇨🇲single1🇨🇲
108312172&amp;#x1f1…🇱🇾1F1F1-1F1FEnosingle::🇱🇾single1🇱🇾
108412173&amp;#9195…23EBnosingle::⏫single1
108512174&amp;#x1f3…🏣1F3E3nosingle::🏣single1🏣
108612175&amp;#x1f6…🚉1F689nosingle::🚉single1🚉
108712176&amp;#x1f6…🛅1F6C5nosingle::🛅single1🛅
108812177&amp;#9877…⚕️2695-FE0Fnosingle::⚕️single1⚕️
108912178&amp;#9972…⛴️26F4-FE0Fnosingle::⛴️single1⛴️
109012179&amp;#x1f4…👁️‍🗨️1F441-FE0F-200D-1F5E8-FE0Fnosingle::👁️‍🗨️single1👁️‍🗨️
109112180&amp;#x1f3…🏧1F3E7nosingle::🏧single1🏧
109212181&amp;#9723…◻️25FB-FE0Fnosingle::◻️single1◻️
109312182&amp;#x1f4…👩‍🔬1F469-200D-1F52Cnosingle::👩‍🔬single1👩‍🔬
109412183&amp;#9209…⏹️23F9-FE0Fnosingle::⏹️single1⏹️
109512184&amp;#x1f1…🇷🇴1F1F7-1F1F4nosingle::🇷🇴single1🇷🇴
109612185&amp;#x1f4…👩‍👦1F469-200D-1F466nosingle::👩‍👦single1👩‍👦
109712186&amp;#1103…2B1Cnosingle::⬜single1
1098131#&amp;#650…#️⃣23-FE0F-20E3nosingle::#️⃣single1#️⃣
1099132&amp;#x1f1…🇦🇱1F1E6-1F1F1nosingle::🇦🇱single1🇦🇱
1100133&amp;#x1f1…🇹🇼1F1F9-1F1FCnosingle::🇹🇼single1🇹🇼
1101134&amp;#x1f6…🛣️1F6E3-FE0Fnosingle::🛣️single1🛣️
1102135&amp;#x1f3…🏤1F3E4nosingle::🏤single1🏤
1103136&amp;#x1f6…🛄1F6C4nosingle::🛄single1🛄
1104137&amp;#x1f1…🇮🇸1F1EE-1F1F8nosingle::🇮🇸single1🇮🇸
1105138&amp;#x1f3…🏬1F3ECnosingle::🏬single1🏬
1106139&amp;#x1f4…👨‍🎤1F468-200D-1F3A4nosingle::👨‍🎤single1👨‍🎤
11071310&amp;#x1f5…🕐1F550nosingle::🕐single1🕐
11081311&amp;#x1f3…🎦1F3A6nosingle::🎦single1🎦
11091312&amp;#x1f4…👨‍💼1F468-200D-1F4BCnosingle::👨‍💼single1👨‍💼
11101313&amp;#x1f4…💹1F4B9nosingle::💹single1💹
11111314&amp;#9784…☸️2638-FE0Fnosingle::☸️single1☸️
11121315&amp;#x1f4…📴1F4F4nosingle::📴single1📴
11131316&amp;#x1f4…📐1F4D0nosingle::📐single1📐
11141317&amp;#x1f5…🖱️1F5B1-FE0Fnosingle::🖱️single1🖱️
11151318&amp;#x1f1…🇭🇹1F1ED-1F1F9nosingle::🇭🇹single1🇭🇹
11161319&amp;#x1f1…🇸🇳1F1F8-1F1F3nosingle::🇸🇳single1🇸🇳
11171320&amp;#x1f5…🔩1F529nosingle::🔩single1🔩
11181321&amp;#x1f2…🈲1F232nosingle::🈲single1🈲
11191322&amp;#x1f3…🎴1F3B4nosingle::🎴single1🎴
11201323&amp;#x1f4…📳1F4F3nosingle::📳single1📳
11211324&amp;#9766…☦️2626-FE0Fnosingle::☦️single1☦️
11221325&amp;#x1f6…🚆1F686nosingle::🚆single1🚆
11231326&amp;#x1f4…📁1F4C1nosingle::📁single1📁
11241327&amp;#x1f4…📔1F4D4nosingle::📔single1📔
11251328&amp;#x1f1…🇦🇫1F1E6-1F1EBnosingle::🇦🇫single1🇦🇫
11261329&amp;#x1f4…👩‍🎨1F469-200D-1F3A8nosingle::👩‍🎨single1👩‍🎨
11271330&amp;#x1f5…🕑1F551nosingle::🕑single1🕑
11281331&amp;#x1f6…🚸1F6B8nosingle::🚸single1🚸
11291332&amp;#x1f9…🧣1F9E3nosingle::🧣single1🧣
11301333&amp;#x1f4…👨‍🚀1F468-200D-1F680nosingle::👨‍🚀single1👨‍🚀
11311334&amp;#x1f1…🇧🇭1F1E7-1F1EDnosingle::🇧🇭single1🇧🇭
11321335&amp;#x1f3…🏭1F3EDnosingle::🏭single1🏭
11331336&amp;#x1f3…🏺1F3FAnosingle::🏺single1🏺
11341337&amp;#x1f1…🇬🇳1F1EC-1F1F3nosingle::🇬🇳single1🇬🇳
11351338&amp;#x1f9…🤽1F93Dnosingle::🤽single1🤽
11361339&amp;#x1f6…🛥️1F6E5-FE0Fnosingle::🛥️single1🛥️
11371340&amp;#x1f1…🇲🇱1F1F2-1F1F1nosingle::🇲🇱single1🇲🇱
11381341&amp;#x1f3…🌁1F301nosingle::🌁single1🌁
11391342&amp;#x1f9…🤶1F936nosingle::🤶single1🤶
11401343&amp;#x1f1…🇦🇿1F1E6-1F1FFnosingle::🇦🇿single1🇦🇿
11411344&amp;#x1f4…💽1F4BDnosingle::💽single1💽
11421345&amp;#x1f1…🇦🇴1F1E6-1F1F4nosingle::🇦🇴single1🇦🇴
11431346&amp;#x1f6…🚎1F68Enosingle::🚎single1🚎
11441347&amp;#x1f1…🇮🇷1F1EE-1F1F7nosingle::🇮🇷single1🇮🇷
11451348&amp;#x1f1…🇳🇵1F1F3-1F1F5nosingle::🇳🇵single1🇳🇵
11461349&amp;#x1f5…🗒️1F5D2-FE0Fnosingle::🗒️single1🗒️
11471350&amp;#x1f1…🇨🇩1F1E8-1F1E9nosingle::🇨🇩single1🇨🇩
11481351&amp;#x1f6…🛋️1F6CB-FE0Fnosingle::🛋️single1🛋️
11491352&amp;#9000…⌨️2328-FE0Fnosingle::⌨️single1⌨️
11501353&amp;#x1f1…🆎1F18Enosingle::🆎single1🆎
11511354&amp;#x1f9…🧗1F9D7nosingle::🧗single1🧗
11521355&amp;#x1f9…🧥1F9E5nosingle::🧥single1🧥
11531356&amp;#x1f1…🇰🇪1F1F0-1F1EAnosingle::🇰🇪single1🇰🇪
11541357&amp;#x1f6…🛶1F6F6nosingle::🛶single1🛶
11551358&amp;#x1f4…📑1F4D1nosingle::📑single1📑
11561359&amp;#x1f4…👝1F45Dnosingle::👝single1👝
11571360&amp;#x1f1…🇦🇲1F1E6-1F1F2nosingle::🇦🇲single1🇦🇲
11581361&amp;#9199…⏯️23EF-FE0Fnosingle::⏯️single1⏯️
11591362&amp;#x1f1…🇬🇪1F1EC-1F1EAnosingle::🇬🇪single1🇬🇪
11601363&amp;#x1f4…💱1F4B1nosingle::💱single1💱
11611364&amp;#x1f4…👨‍🎨1F468-200D-1F3A8nosingle::👨‍🎨single1👨‍🎨
11621365&amp;#x1f1…🇧🇬1F1E7-1F1ECnosingle::🇧🇬single1🇧🇬
11631366&amp;#x1f1…🇪🇦1F1EA-1F1E6nosingle::🇪🇦single1🇪🇦
11641367&amp;#x1f4…👩‍⚖️1F469-200D-2696-FE0Fnosingle::👩‍⚖️single1👩‍⚖️
11651368&amp;#x1f5…🕓1F553nosingle::🕓single1🕓
11661369&amp;#x1f4…👩‍🚀1F469-200D-1F680nosingle::👩‍🚀single1👩‍🚀
11671370&amp;#x1f6…🚞1F69Enosingle::🚞single1🚞
11681371&amp;#x1f4…👨‍🚒1F468-200D-1F692nosingle::👨‍🚒single1👨‍🚒
11691372&amp;#x1f1…🆖1F196nosingle::🆖single1🆖
11701373&amp;#x1f5…🔀1F500nosingle::🔀single1🔀
11711374&amp;#x1f4…👨‍🔬1F468-200D-1F52Cnosingle::👨‍🔬single1👨‍🔬
11721375&amp;#x1f4…👨‍⚖️1F468-200D-2696-FE0Fnosingle::👨‍⚖️single1👨‍⚖️
11731376&amp;#x1f4…👨‍👩‍👦‍👦1F468-200D-1F469-200D-1F466-200D-1F466nosingle::👨‍👩‍👦‍👦single1👨‍👩‍👦‍👦
11741377&amp;#x1f3…🏴󠁧󠁢󠁷󠁬󠁳󠁿1F3F4-E0067-E0062-E0077-E006C-E0073-E007Fnosingle::🏴󠁧󠁢󠁷󠁬󠁳󠁿single1🏴󠁧󠁢󠁷󠁬󠁳󠁿
11751378&amp;#x1f9…🥠1F960nosingle::🥠single1🥠
11761379&amp;#x1f6…🚳1F6B3nosingle::🚳single1🚳
11771380&amp;#x1f1…🇧🇯1F1E7-1F1EFnosingle::🇧🇯single1🇧🇯
11781381&amp;#x1f1…🇲🇬1F1F2-1F1ECnosingle::🇲🇬single1🇲🇬
11791382&amp;#x1f1…🇪🇪1F1EA-1F1EAnosingle::🇪🇪single1🇪🇪
11801383&amp;#9197…⏭️23ED-FE0Fnosingle::⏭️single1⏭️
11811384&amp;#x1f5…🕎1F54Enosingle::🕎single1🕎
11821385&amp;#x1f2…🈚1F21Anosingle::🈚single1🈚
11831386&amp;#x1f6…🚡1F6A1nosingle::🚡single1🚡
11841387&amp;#x1f5…🕕1F555nosingle::🕕single1🕕
11851388&amp;#x1f1…🇷🇸1F1F7-1F1F8nosingle::🇷🇸single1🇷🇸
11861389&amp;#x1f2…🉑1F251nosingle::🉑single1🉑
11871390&amp;#x1f4…📂1F4C2nosingle::📂single1📂
11881391&amp;#x1f1…🇹🇿1F1F9-1F1FFnosingle::🇹🇿single1🇹🇿
11891392&amp;#x1f4…📫1F4EBnosingle::📫single1📫
11901393&amp;#x1f5…🕘1F558nosingle::🕘single1🕘
11911394&amp;#x1f6…🚊1F68Anosingle::🚊single1🚊
11921395&amp;#x1f6…🚈1F688nosingle::🚈single1🚈
11931396&amp;#x1f5…🔂1F502nosingle::🔂single1🔂
11941397&amp;#1013…2797nosingle::➗single1
11951398&amp;#x1f5…🕗1F557nosingle::🕗single1🕗
11961399&amp;#x1f4…👨‍👩‍👧‍👧1F468-200D-1F469-200D-1F467-200D-1F467nosingle::👨‍👩‍👧‍👧single1👨‍👩‍👧‍👧
119713100&amp;#x1f2…🈹1F239nosingle::🈹single1🈹
119813101&amp;#x1f1…🇱🇺1F1F1-1F1FAnosingle::🇱🇺single1🇱🇺
119913102&amp;#x1f6…🛤️1F6E4-FE0Fnosingle::🛤️single1🛤️
120013103&amp;#x1f1…🇺🇬1F1FA-1F1ECnosingle::🇺🇬single1🇺🇬
120113104&amp;#x1f5…🕖1F556nosingle::🕖single1🕖
120213105&amp;#x1f2…🈴1F234nosingle::🈴single1🈴
120313106&amp;#x1f4…👨‍🔧1F468-200D-1F527nosingle::👨‍🔧single1👨‍🔧
120413107&amp;#x1f5…🔏1F50Fnosingle::🔏single1🔏
120513108&amp;#x1f1…🇧🇼1F1E7-1F1FCnosingle::🇧🇼single1🇧🇼
120613109&amp;#x1f6…🚷1F6B7nosingle::🚷single1🚷
120713110&amp;#x1f1…🇲🇰1F1F2-1F1F0nosingle::🇲🇰single1🇲🇰
120813111&amp;#x1f1…🇱🇻1F1F1-1F1FBnosingle::🇱🇻single1🇱🇻
120913112&amp;#x1f5…🕙1F559nosingle::🕙single1🕙
121013113&amp;#x1f1…🇹🇹1F1F9-1F1F9nosingle::🇹🇹single1🇹🇹
121113114&amp;#x1f1…🇱🇹1F1F1-1F1F9nosingle::🇱🇹single1🇱🇹
121213115&amp;#x1f4…👩‍❤️‍💋‍👨1F469-200D-2764-FE0F-200D-1F48B-200D-1F468nosingle::👩‍❤️‍💋‍👨single1👩‍❤️‍💋‍👨
121313116&amp;#x1f4…👨‍👧1F468-200D-1F467nosingle::👨‍👧single1👨‍👧
121413117&amp;#x1f4…📟1F4DFnosingle::📟single1📟
121513118&amp;#x1f4…👩‍✈️1F469-200D-2708-FE0Fnosingle::👩‍✈️single1👩‍✈️
121613119&amp;#9208…⏸️23F8-FE0Fnosingle::⏸️single1⏸️
121713120&amp;#x1f4…👩‍🚒1F469-200D-1F692nosingle::👩‍🚒single1👩‍🚒
121813121&amp;#x1f1…🇿🇼1F1FF-1F1FCnosingle::🇿🇼single1🇿🇼
121913122&amp;#x1f9…🥡1F961nosingle::🥡single1🥡
122013123&amp;#x1f1…🇸🇰1F1F8-1F1F0nosingle::🇸🇰single1🇸🇰
122113124&amp;#x1f5…🕔1F554nosingle::🕔single1🕔
1222141&amp;#x1f1…🇴🇲1F1F4-1F1F2nosingle::🇴🇲single1🇴🇲
1223142&amp;#x1f4…💾1F4BEnosingle::💾single1💾
1224143&amp;#x1f4…📤1F4E4nosingle::📤single1📤
1225144&amp;#9879…⚗️2697-FE0Fnosingle::⚗️single1⚗️
1226145&amp;#x1f5…🔠1F520nosingle::🔠single1🔠
1227146&amp;#x1f6…🚝1F69Dnosingle::🚝single1🚝
1228147&amp;#x1f1…🆑1F191nosingle::🆑single1🆑
1229148&amp;#x1f1…🇲🇲1F1F2-1F1F2nosingle::🇲🇲single1🇲🇲
1230149&amp;#x1f4…👨‍👦1F468-200D-1F466nosingle::👨‍👦single1👨‍👦
12311410&amp;#x1f1…🇲🇹1F1F2-1F1F9nosingle::🇲🇹single1🇲🇹
12321411&amp;#x1f5…🕚1F55Anosingle::🕚single1🕚
12331412&amp;#x1f1…🇧🇦1F1E7-1F1E6nosingle::🇧🇦single1🇧🇦
12341413&amp;#x1f4…👩‍👧‍👦1F469-200D-1F467-200D-1F466nosingle::👩‍👧‍👦single1👩‍👧‍👦
12351414&amp;#x1f3…🎿1F3BFnosingle::🎿single1🎿
12361415&amp;#x1f5…🔤1F524nosingle::🔤single1🔤
12371416&amp;#x1f2…🈶1F236nosingle::🈶single1🈶
12381417&amp;#x1f6…🚱1F6B1nosingle::🚱single1🚱
12391418&amp;#x1f6…🚏1F68Fnosingle::🚏single1🚏
12401419&amp;#x1f1…🇳🇪1F1F3-1F1EAnosingle::🇳🇪single1🇳🇪
12411420&amp;#x1f4…👩‍🔧1F469-200D-1F527nosingle::👩‍🔧single1👩‍🔧
12421421&amp;#x1f1…🇧🇸1F1E7-1F1F8nosingle::🇧🇸single1🇧🇸
12431422&amp;#x1f5…🕍1F54Dnosingle::🕍single1🕍
12441423&amp;#x1f1…🇰🇭1F1F0-1F1EDnosingle::🇰🇭single1🇰🇭
12451424&amp;#x1f5…🔢1F522nosingle::🔢single1🔢
12461425&amp;#x1f1…🇨🇾1F1E8-1F1FEnosingle::🇨🇾single1🇨🇾
12471426&amp;#x1f4…📭1F4EDnosingle::📭single1📭
12481427&amp;#x1f4…📪1F4EAnosingle::📪single1📪
12491428&amp;#x1f2…🈳1F233nosingle::🈳single1🈳
12501429&amp;#x1f1…🇲🇷1F1F2-1F1F7nosingle::🇲🇷single1🇲🇷
12511430&amp;#x1f4…👩‍❤️‍👨1F469-200D-2764-FE0F-200D-1F468nosingle::👩‍❤️‍👨single1👩‍❤️‍👨
12521431&amp;#x1f4…👩‍👩‍👧1F469-200D-1F469-200D-1F467nosingle::👩‍👩‍👧single1👩‍👩‍👧
12531432&amp;#x1f1…🇰🇿1F1F0-1F1FFnosingle::🇰🇿single1🇰🇿
12541433&amp;#x1f5…🖨️1F5A8-FE0Fnosingle::🖨️single1🖨️
12551434&amp;#9198…⏮️23EE-FE0Fnosingle::⏮️single1⏮️
12561435&amp;#x1f1…🇬🇾1F1EC-1F1FEnosingle::🇬🇾single1🇬🇾
12571436&amp;#x1f1…🇸🇮1F1F8-1F1EEnosingle::🇸🇮single1🇸🇮
12581437&amp;#x1f1…🇧🇾1F1E7-1F1FEnosingle::🇧🇾single1🇧🇾
12591438&amp;#x1f1…🇸🇴1F1F8-1F1F4nosingle::🇸🇴single1🇸🇴
12601439&amp;#x1f5…🕠1F560nosingle::🕠single1🕠
12611440&amp;#x1f1…🇦🇼1F1E6-1F1FCnosingle::🇦🇼single1🇦🇼
12621441&amp;#x1f5…🖲️1F5B2-FE0Fnosingle::🖲️single1🖲️
12631442&amp;#9905…⚱️26B1-FE0Fnosingle::⚱️single1⚱️
12641443&amp;#x1f1…🇲🇪1F1F2-1F1EAnosingle::🇲🇪single1🇲🇪
12651444&amp;#x1f1…🇿🇲1F1FF-1F1F2nosingle::🇿🇲single1🇿🇲
12661445&amp;#x1f1…🇧🇧1F1E7-1F1E7nosingle::🇧🇧single1🇧🇧
12671446&amp;#x1f1…🇹🇯1F1F9-1F1EFnosingle::🇹🇯single1🇹🇯
12681447&amp;#x1f1…🇨🇬1F1E8-1F1ECnosingle::🇨🇬single1🇨🇬
12691448&amp;#x1f1…🇺🇿1F1FA-1F1FFnosingle::🇺🇿single1🇺🇿
12701449&amp;#x1f1…🇸🇱1F1F8-1F1F1nosingle::🇸🇱single1🇸🇱
12711450&amp;#x1f1…🇳🇦1F1F3-1F1E6nosingle::🇳🇦single1🇳🇦
12721451&amp;#x1f6…🚟1F69Fnosingle::🚟single1🚟
12731452&amp;#x1f1…🇧🇮1F1E7-1F1EEnosingle::🇧🇮single1🇧🇮
12741453&amp;#x1f1…🇽🇰1F1FD-1F1F0nosingle::🇽🇰single1🇽🇰
12751454&amp;#x1f1…🇦🇮1F1E6-1F1EEnosingle::🇦🇮single1🇦🇮
12761455&amp;#x1f1…🇪🇹1F1EA-1F1F9nosingle::🇪🇹single1🇪🇹
12771456&amp;#x1f4…👩‍👩‍👧‍👧1F469-200D-1F469-200D-1F467-200D-1F467nosingle::👩‍👩‍👧‍👧single1👩‍👩‍👧‍👧
12781457&amp;#x1f1…🇦🇬1F1E6-1F1ECnosingle::🇦🇬single1🇦🇬
12791458&amp;#x1f4…👨‍🏭1F468-200D-1F3EDnosingle::👨‍🏭single1👨‍🏭
12801459&amp;#x1f4…👩‍👧‍👧1F469-200D-1F467-200D-1F467nosingle::👩‍👧‍👧single1👩‍👧‍👧
12811460&amp;#x1f4…👨‍👨‍👧‍👧1F468-200D-1F468-200D-1F467-200D-1F467nosingle::👨‍👨‍👧‍👧single1👨‍👨‍👧‍👧
12821461&amp;#x1f2…🈯1F22Fnosingle::🈯single1🈯
12831462&amp;#x1f6…🚠1F6A0nosingle::🚠single1🚠
12841463&amp;#x1f5…🕟1F55Fnosingle::🕟single1🕟
12851464&amp;#x1f4…👨‍👨‍👦‍👦1F468-200D-1F468-200D-1F466-200D-1F466nosingle::👨‍👨‍👦‍👦single1👨‍👨‍👦‍👦
12861465&amp;#x1f4…👩‍👩‍👧‍👦1F469-200D-1F469-200D-1F467-200D-1F466nosingle::👩‍👩‍👧‍👦single1👩‍👩‍👧‍👦
12871466&amp;#x1f1…🇷🇼1F1F7-1F1FCnosingle::🇷🇼single1🇷🇼
12881467&amp;#x1f1…🇦🇽1F1E6-1F1FDnosingle::🇦🇽single1🇦🇽
12891468&amp;#x1f4…👩‍👦‍👦1F469-200D-1F466-200D-1F466nosingle::👩‍👦‍👦single1👩‍👦‍👦
12901469&amp;#x1f1…🇫🇯1F1EB-1F1EFnosingle::🇫🇯single1🇫🇯
12911470&amp;#x1f9…🥌1F94Cnosingle::🥌single1🥌
12921471&amp;#x1f1…🇦🇸1F1E6-1F1F8nosingle::🇦🇸single1🇦🇸
12931472&amp;#x1f1…🇹🇩1F1F9-1F1E9nosingle::🇹🇩single1🇹🇩
12941473&amp;#x1f4…👨‍👧‍👦1F468-200D-1F467-200D-1F466nosingle::👨‍👧‍👦single1👨‍👧‍👦
12951474&amp;#x1f1…🇦🇩1F1E6-1F1E9nosingle::🇦🇩single1🇦🇩
12961475&amp;#x1f1…🇬🇲1F1EC-1F1F2nosingle::🇬🇲single1🇬🇲
12971476&amp;#x1f1…🇬🇦1F1EC-1F1E6nosingle::🇬🇦single1🇬🇦
12981477&amp;#x1f5…🗂️1F5C2-FE0Fnosingle::🗂️single1🗂️
12991478&amp;#x1f1…🇧🇲1F1E7-1F1F2nosingle::🇧🇲single1🇧🇲
13001479&amp;#x1f4…👩‍🏭1F469-200D-1F3EDnosingle::👩‍🏭single1👩‍🏭
1301151&amp;#x1f5…🕝1F55Dnosingle::🕝single1🕝
1302152&amp;#x1f5…🕦1F566nosingle::🕦single1🕦
1303153&amp;#x1f5…🕢1F562nosingle::🕢single1🕢
1304154&amp;#x1f5…🕜1F55Cnosingle::🕜single1🕜
1305155&amp;#x1f1…🇲🇳1F1F2-1F1F3nosingle::🇲🇳single1🇲🇳
1306156&amp;#x1f1…🇹🇬1F1F9-1F1ECnosingle::🇹🇬single1🇹🇬
1307157&amp;#x1f5…🕤1F564nosingle::🕤single1🕤
1308158&amp;#x1f1…🇲🇿1F1F2-1F1FFnosingle::🇲🇿single1🇲🇿
1309159&amp;#x1f4…👨‍👨‍👦1F468-200D-1F468-200D-1F466nosingle::👨‍👨‍👦single1👨‍👨‍👦
13101510&amp;#x1f2…🈂️1F202-FE0Fnosingle::🈂️single1🈂️
13111511&amp;#x1f1…🇬🇼1F1EC-1F1FCnosingle::🇬🇼single1🇬🇼
13121512&amp;#x1f1…🇧🇳1F1E7-1F1F3nosingle::🇧🇳single1🇧🇳
13131513&amp;#x1f1…🇲🇻1F1F2-1F1FBnosingle::🇲🇻single1🇲🇻
13141514&amp;#x1f5…🕧1F567nosingle::🕧single1🕧
13151515&amp;#x1f1…🇲🇴1F1F2-1F1F4nosingle::🇲🇴single1🇲🇴
13161516&amp;#x1f5…🕡1F561nosingle::🕡single1🕡
13171517&amp;#9934…26CEnosingle::⛎single1
13181518&amp;#x1f1…🇹🇴1F1F9-1F1F4nosingle::🇹🇴single1🇹🇴
13191519&amp;#x1f5…🕣1F563nosingle::🕣single1🕣
13201520&amp;#x1f4…👨‍👨‍👧‍👦1F468-200D-1F468-200D-1F467-200D-1F466nosingle::👨‍👨‍👧‍👦single1👨‍👨‍👧‍👦
13211521&amp;#x1f1…🇪🇷1F1EA-1F1F7nosingle::🇪🇷single1🇪🇷
13221522&amp;#x1f1…🇦🇨1F1E6-1F1E8nosingle::🇦🇨single1🇦🇨
13231523&amp;#x1f1…🇰🇬1F1F0-1F1ECnosingle::🇰🇬single1🇰🇬
13241524&amp;#x1f1…🇦🇶1F1E6-1F1F6nosingle::🇦🇶single1🇦🇶
13251525&amp;#x1f1…🇨🇻1F1E8-1F1FBnosingle::🇨🇻single1🇨🇻
13261526&amp;#x1f1…🇧🇫1F1E7-1F1EBnosingle::🇧🇫single1🇧🇫
13271527&amp;#x1f1…🇰🇵1F1F0-1F1F5nosingle::🇰🇵single1🇰🇵
13281528&amp;#x1f4…👨‍👦‍👦1F468-200D-1F466-200D-1F466nosingle::👨‍👦‍👦single1👨‍👦‍👦
13291529&amp;#x1f5…🕥1F565nosingle::🕥single1🕥
13301530&amp;#x1f1…🇲🇩1F1F2-1F1E9nosingle::🇲🇩single1🇲🇩
13311531&amp;#x1f1…🇮🇨1F1EE-1F1E8nosingle::🇮🇨single1🇮🇨
13321532&amp;#x1f1…🇻🇦1F1FB-1F1E6nosingle::🇻🇦single1🇻🇦
13331533&amp;#x1f1…🇬🇩1F1EC-1F1E9nosingle::🇬🇩single1🇬🇩
13341534&amp;#x1f1…🇮🇲1F1EE-1F1F2nosingle::🇮🇲single1🇮🇲
13351535&amp;#x1f4…👩‍👩‍👦‍👦1F469-200D-1F469-200D-1F466-200D-1F466nosingle::👩‍👩‍👦‍👦single1👩‍👩‍👦‍👦
13361536&amp;#x1f1…🇲🇫1F1F2-1F1EBnosingle::🇲🇫single1🇲🇫
13371537&amp;#x1f4…📇1F4C7nosingle::📇single1📇
13381538&amp;#x1f2…🈺1F23Anosingle::🈺single1🈺
13391539&amp;#x1f1…🇱🇦1F1F1-1F1E6nosingle::🇱🇦single1🇱🇦
13401540&amp;#x1f6…🛷1F6F7nosingle::🛷single1🛷
13411541&amp;#x1f5…🕞1F55Enosingle::🕞single1🕞
13421542&amp;#x1f1…🇲🇼1F1F2-1F1FCnosingle::🇲🇼single1🇲🇼
13431543&amp;#x1f4…👩‍👩‍👦1F469-200D-1F469-200D-1F466nosingle::👩‍👩‍👦single1👩‍👩‍👦
13441544&amp;#x1f4…👨‍👨‍👧1F468-200D-1F468-200D-1F467nosingle::👨‍👨‍👧single1👨‍👨‍👧
13451545&amp;#x1f2…🈷️1F237-FE0Fnosingle::🈷️single1🈷️
13461546&amp;#x1f4…👨‍👧‍👧1F468-200D-1F467-200D-1F467nosingle::👨‍👧‍👧single1👨‍👧‍👧
13471547&amp;#x1f1…🇺🇳1F1FA-1F1F3nosingle::🇺🇳single1🇺🇳
13481548&amp;#x1f5…🗃️1F5C3-FE0Fnosingle::🗃️single1🗃️
13491549&amp;#x1f1…🇱🇸1F1F1-1F1F8nosingle::🇱🇸single1🇱🇸
13501550&amp;#x1f5…🗜️1F5DC-FE0Fnosingle::🗜️single1🗜️
13511551&amp;#x1f1…🇬🇺1F1EC-1F1FAnosingle::🇬🇺single1🇬🇺
13521552&amp;#x1f1…🇧🇻1F1E7-1F1FBnosingle::🇧🇻single1🇧🇻
13531553&amp;#x1f5…🔡1F521nosingle::🔡single1🔡
13541554&amp;#x1f1…🇫🇴1F1EB-1F1F4nosingle::🇫🇴single1🇫🇴
13551555&amp;#x1f1…🇲🇺1F1F2-1F1FAnosingle::🇲🇺single1🇲🇺
13561556&amp;#x1f1…🇵🇬1F1F5-1F1ECnosingle::🇵🇬single1🇵🇬
13571557&amp;#x1f1…🇨🇼1F1E8-1F1FCnosingle::🇨🇼single1🇨🇼
13581558&amp;#x1f1…🇬🇬1F1EC-1F1ECnosingle::🇬🇬single1🇬🇬
13591559&amp;#x1f2…🈁1F201nosingle::🈁single1🈁
13601560&amp;#x1f5…🔣1F523nosingle::🔣single1🔣
13611561&amp;#x1f1…🇼🇸1F1FC-1F1F8nosingle::🇼🇸single1🇼🇸
13621562&amp;#x1f1…🇸🇷1F1F8-1F1F7nosingle::🇸🇷single1🇸🇷
13631563&amp;#x1f1…🇹🇲1F1F9-1F1F2nosingle::🇹🇲single1🇹🇲
13641564&amp;#x1f1…🇸🇸1F1F8-1F1F8nosingle::🇸🇸single1🇸🇸
13651565&amp;#x1f5…🗄️1F5C4-FE0Fnosingle::🗄️single1🗄️
13661566&amp;#x1f1…🇧🇹1F1E7-1F1F9nosingle::🇧🇹single1🇧🇹
13671567&amp;#x1f1…🇸🇽1F1F8-1F1FDnosingle::🇸🇽single1🇸🇽
13681568&amp;#x1f1…🇰🇲1F1F0-1F1F2nosingle::🇰🇲single1🇰🇲
13691569&amp;#x1f2…🈸1F238nosingle::🈸single1🈸
13701570&amp;#x1f1…🇩🇲1F1E9-1F1F2nosingle::🇩🇲single1🇩🇲
13711571&amp;#x1f1…🇪🇭1F1EA-1F1EDnosingle::🇪🇭single1🇪🇭
13721572&amp;#x1f1…🇧🇿1F1E7-1F1FFnosingle::🇧🇿single1🇧🇿
13731573&amp;#x1f1…🇻🇮1F1FB-1F1EEnosingle::🇻🇮single1🇻🇮
13741574&amp;#x1f1…🇬🇫1F1EC-1F1EBnosingle::🇬🇫single1🇬🇫
13751575&amp;#x1f1…🇸🇲1F1F8-1F1F2nosingle::🇸🇲single1🇸🇲
13761576&amp;#x1f1…🇩🇯1F1E9-1F1EFnosingle::🇩🇯single1🇩🇯
13771577&amp;#x1f1…🇹🇨1F1F9-1F1E8nosingle::🇹🇨single1🇹🇨
1378161&amp;#x1f1…🇱🇨1F1F1-1F1E8nosingle::🇱🇨single1🇱🇨
1379162&amp;#x1f1…🇻🇺1F1FB-1F1FAnosingle::🇻🇺single1🇻🇺
1380163&amp;#x1f1…🇵🇫1F1F5-1F1EBnosingle::🇵🇫single1🇵🇫
1381164&amp;#x1f1…🇬🇮1F1EC-1F1EEnosingle::🇬🇮single1🇬🇮
1382165&amp;#x1f1…🇸🇨1F1F8-1F1E8nosingle::🇸🇨single1🇸🇨
1383166&amp;#x1f1…🇰🇳1F1F0-1F1F3nosingle::🇰🇳single1🇰🇳
1384167&amp;#x1f1…🇯🇪1F1EF-1F1EAnosingle::🇯🇪single1🇯🇪
1385168&amp;#x1f1…🇲🇶1F1F2-1F1F6nosingle::🇲🇶single1🇲🇶
1386169&amp;#x1f4…👨‍👩‍👦1F468-200D-1F469-200D-1F466nosingle::👨‍👩‍👦single1👨‍👩‍👦
13871610&amp;#x1f1…🇷🇪1F1F7-1F1EAnosingle::🇷🇪single1🇷🇪
13881611&amp;#x1f1…🇸🇿1F1F8-1F1FFnosingle::🇸🇿single1🇸🇿
13891612&amp;#x1f1…🇰🇾1F1F0-1F1FEnosingle::🇰🇾single1🇰🇾
13901613&amp;#9167…⏏️23CF-FE0Fnosingle::⏏️single1⏏️
13911614&amp;#x1f1…🇻🇨1F1FB-1F1E8nosingle::🇻🇨single1🇻🇨
13921615&amp;#x1f1…🇻🇬1F1FB-1F1ECnosingle::🇻🇬single1🇻🇬
13931616&amp;#x1f1…🇱🇮1F1F1-1F1EEnosingle::🇱🇮single1🇱🇮
13941617&amp;#x1f1…🇨🇨1F1E8-1F1E8nosingle::🇨🇨single1🇨🇨
13951618&amp;#x1f1…🇨🇫1F1E8-1F1EBnosingle::🇨🇫single1🇨🇫
13961619&amp;#x1f1…🇸🇹1F1F8-1F1F9nosingle::🇸🇹single1🇸🇹
13971620&amp;#x1f1…🇬🇱1F1EC-1F1F1nosingle::🇬🇱single1🇬🇱
13981621&amp;#x1f1…🇵🇼1F1F5-1F1FCnosingle::🇵🇼single1🇵🇼
13991622&amp;#x1f1…🇬🇵1F1EC-1F1F5nosingle::🇬🇵single1🇬🇵
14001623&amp;#x1f1…🇭🇲1F1ED-1F1F2nosingle::🇭🇲single1🇭🇲
14011624&amp;#x1f1…🇨🇰1F1E8-1F1F0nosingle::🇨🇰single1🇨🇰
14021625&amp;#x1f1…🇫🇰1F1EB-1F1F0nosingle::🇫🇰single1🇫🇰
14031626&amp;#x1f1…🇵🇳1F1F5-1F1F3nosingle::🇵🇳single1🇵🇳
14041627&amp;#x1f1…🇳🇫1F1F3-1F1EBnosingle::🇳🇫single1🇳🇫
14051628&amp;#x1f1…🇸🇧1F1F8-1F1E7nosingle::🇸🇧single1🇸🇧
14061629&amp;#x1f1…🇹🇱1F1F9-1F1F1nosingle::🇹🇱single1🇹🇱
14071630&amp;#x1f1…🇫🇲1F1EB-1F1F2nosingle::🇫🇲single1🇫🇲
14081631&amp;#x1f1…🇮🇴1F1EE-1F1F4nosingle::🇮🇴single1🇮🇴
14091632&amp;#x1f1…🇲🇭1F1F2-1F1EDnosingle::🇲🇭single1🇲🇭
14101633&amp;#x1f1…🇬🇶1F1EC-1F1F6nosingle::🇬🇶single1🇬🇶
14111634&amp;#x1f1…🇹🇰1F1F9-1F1F0nosingle::🇹🇰single1🇹🇰
14121635&amp;helli…2026nosingle::…single1
141317+1&amp;#x1f1…🇧🇶1F1E7-1F1F6nosingle::🇧🇶single1🇧🇶
141417+2&amp;#x1f1…🇲🇵1F1F2-1F1F5nosingle::🇲🇵single1🇲🇵
141517+3&amp;#x1f1…🇨🇽1F1E8-1F1FDnosingle::🇨🇽single1🇨🇽
141617+4&amp;#x1f1…🇰🇮1F1F0-1F1EEnosingle::🇰🇮single1🇰🇮
141717+5&amp;#x1f1…🇳🇺1F1F3-1F1FAnosingle::🇳🇺single1🇳🇺
141817+6&amp;#x1f1…🇸🇯1F1F8-1F1EFnosingle::🇸🇯single1🇸🇯
141917+7&amp;#x1f1…🇲🇸1F1F2-1F1F8nosingle::🇲🇸single1🇲🇸
142017+8&amp;#x1f1…🇸🇭1F1F8-1F1EDnosingle::🇸🇭single1🇸🇭
142117+9&amp;#x1f1…🇹🇻1F1F9-1F1FBnosingle::🇹🇻single1🇹🇻
142217+10&amp;#x1f1…🇳🇨1F1F3-1F1E8nosingle::🇳🇨single1🇳🇨
142317+11&amp;#x1f1…🇳🇷1F1F3-1F1F7nosingle::🇳🇷single1🇳🇷
142417+12&amp;#x1f1…🇧🇱1F1E7-1F1F1nosingle::🇧🇱single1🇧🇱
142517+13&amp;#x1f1…🇼🇫1F1FC-1F1EBnosingle::🇼🇫single1🇼🇫
142617+14&amp;#x1f1…🇬🇸…1F1EC-1F1F8-2026nosingle::🇬🇸…single1🇬🇸…
+
+
+ + diff --git a/docs/frameworks/file-explorer-activity.md b/docs/frameworks/file-explorer-activity.md new file mode 100644 index 0000000..0a34e1c --- /dev/null +++ b/docs/frameworks/file-explorer-activity.md @@ -0,0 +1,140 @@ +# FileExplorerActivity + +`FileExplorerActivity` is a reusable file browser built into MicroPythonOS. It can browse the local filesystem or let the user pick one or more files, and it integrates with the [view action](../architecture/intents.md#file-type-intents-and-the-view-action) to open files in the appropriate app. + +## Overview + +The activity is registered for the `pick_file` action, so any app can ask the user to choose files without implementing its own file picker. It is also used directly as the built-in **File Manager** app. + +Two modes are supported: + +- **`browse`** (default): navigate directories, open files, rename or delete them. +- **`pick`**: select one or more files and return them to the caller. The `pick_file` action automatically uses pick mode, so callers do not need to set the `"mode"` extra. + +## Launching + +### Pick files with `startActivityForResult` + +Using the `pick_file` action automatically opens `FileExplorerActivity` in pick mode, so you do **not** need to set the `"mode"` extra. + +```python +from mpos import Intent, Activity + +class MyActivity(Activity): + def _open_file_clicked(self, event): + intent = Intent(action="pick_file") + intent.putExtra("start_dir", "/data/audio") + intent.putExtra("path_pattern", [".wav"]) + self.startActivityForResult(intent, self._on_file_picked) + + def _on_file_picked(self, result): + if result and result.get("result_code"): + paths = result.get("data", {}).get("paths", []) + for path in paths: + print("Selected:", path) +``` + +### Browse the filesystem + +```python +from mpos import Intent, FileExplorerActivity + +class MyActivity(Activity): + def _browse_files(self): + intent = Intent(activity_class=FileExplorerActivity) + intent.putExtra("start_dir", "/sdcard") + self.startActivity(intent) +``` + +## Intent extras + +| Extra | Type | Default | Description | +|-------|------|---------|-------------| +| `mode` | `str` | `"browse"` (or `"pick"` when action is `"pick_file"`) | `"browse"` or `"pick"`. Only needed when launching `FileExplorerActivity` directly; the `"pick_file"` action implies picker mode automatically. | +| `start_dir` | `str` | `"."` | Directory to open. Non-existent paths are walked up to the first existing parent, falling back to `"/"`. | +| `path_pattern` | `str` or `list` | `[]` | File extensions to accept in pick mode, e.g. `[".png", ".jpg"]`. Strings may include a leading `*` (`"*.wav"`). An empty list accepts all files. | + +## Pick mode result + +When the user confirms the selection, the result callback receives: + +```python +{ + "result_code": True, + "data": { + "paths": ["/path/to/file1.wav", "/path/to/file2.wav"] + } +} +``` + +If no file is selected, the current directory path is returned instead: + +```python +{ + "result_code": True, + "data": { + "paths": ["/data/audio/"] + } +} +``` + +When the user cancels, the result is: + +```python +{ + "result_code": False, + "data": {} +} +``` + +## Browse mode behavior + +In browse mode, tapping a directory navigates into it. Tapping a file sends a `view` intent: + +```python +self.startActivity(Intent(action="view", data=path)) +``` + +The system then resolves the file to the appropriate viewer using the manifest-declared `pathPattern` of installed apps, or falls back to the framework's generic `ViewActivity`. + +Long-pressing a file or folder opens an action bar with **Delete**, **Rename**, and **Cancel** options. Delete shows a confirmation dialog; Rename launches `RenameActivity`. + +### Deleting directories + +Deleting a folder removes it recursively, including all files and subdirectories inside it. A confirmation dialog is shown before any deletion begins. + +!!! warning + Directory deletion cannot be undone. Use the confirmation dialog carefully, especially when deleting paths under `/data/` or `/apps/`. + +## Example: image picker + +```python +import lvgl as lv +from mpos import Activity, Intent + +class GalleryLauncher(Activity): + def onCreate(self): + screen = lv.obj() + btn = lv.button(screen) + lv.label(btn).set_text("Choose image") + btn.add_event_cb(self._choose_image, lv.EVENT.CLICKED, None) + self.setContentView(screen) + + def _choose_image(self, event): + intent = Intent(action="pick_file") + intent.putExtra("start_dir", "/data/images") + intent.putExtra("path_pattern", [".png", ".jpg", ".jpeg", ".raw"]) + self.startActivityForResult(intent, self._on_image_picked) + + def _on_image_picked(self, result): + if result and result.get("result_code"): + paths = result.get("data", {}).get("paths", []) + if paths and not paths[0].endswith("/"): + print("Opening image:", paths[0]) +``` + +## See Also + +- [Intents](../architecture/intents.md) - How implicit intents and the `view` action work +- [AppManager](app-manager.md) - How file-type handlers are resolved +- [Creating Apps](../apps/creating-apps.md) - Declaring `view` handlers in an app manifest diff --git a/docs/frameworks/focus.md b/docs/frameworks/focus.md new file mode 100644 index 0000000..44f4e2f --- /dev/null +++ b/docs/frameworks/focus.md @@ -0,0 +1,127 @@ +# Focus Highlight + +MicroPythonOS provides a single, reusable helper for focus highlighting so that apps and framework screens do not have to duplicate the same `FOCUSED`/`DEFOCUSED` event handlers. + +## Overview + +`add_focus_highlight` registers two LVGL event callbacks on a widget: + +- **`FOCUSED`**: draw a border around the widget (or tint its background) and scroll it into view. +- **`DEFOCUSED`**: remove the highlight again. + +The helper is re-exported from the top-level `mpos` module, so apps can import it directly. + +Two highlight modes are available: + +| Mode | Effect | +|------|--------| +| `"border"` (default) | Draws a border around the widget on focus. | +| `"bg"` | Tints the widget's background on focus, leaving border styles intact. | + +## API + +```python +from mpos import add_focus_highlight + +# Border mode (default) +add_focus_highlight(widget) +add_focus_highlight(widget, width=2) +add_focus_highlight(widget, width=2, color=lv.color_hex(0xFFFFFF)) +add_focus_highlight(widget, width=2, opacity=lv.OPA._50, radius=5) + +# Background mode +add_focus_highlight(widget, mode="bg") +add_focus_highlight(widget, mode="bg", color=lv.color_hex(0x444444)) +``` + +### Parameters + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `widget` | required | The LVGL object that should receive focus highlighting. | +| `mode` | `"border"` | Highlight mode: `"border"` draws a border, `"bg"` tints the background. | +| `width` | `1` | Border width in pixels when focused (ignored in `"bg"` mode). | +| `color` | theme primary color | Highlight color. Falls back to `lv.theme_get_color_primary(None)` when omitted. | +| `opacity` | `None` | Optional `lv.OPA.*` value for the focused border (ignored in `"bg"` mode). | +| `radius` | `None` | Optional corner radius for the focused border (ignored in `"bg"` mode). | + +### Widget and focus group + +`add_focus_highlight` adds the widget to the default LVGL focus group automatically. You do not need to call `focusgroup.add_obj()` manually. + +### Touch-aware behavior + +On devices that are primarily controlled with a touchscreen, the focus highlight is intentionally hidden until the user first navigates with a directional input (keyboard, hardware buttons, or a rotary encoder). This keeps touch-only screens clean while still making focused elements obvious once physical navigation begins. + +If you want a widget to reveal the focus highlight immediately, focus it programmatically: + +```python +focusgroup = lv.group_get_default() +if focusgroup: + focusgroup.focus_obj(btn) +``` + +## Examples + +### Basic usage (border mode) + +```python +import lvgl as lv +from mpos import Activity, add_focus_highlight + +class MyActivity(Activity): + def onCreate(self): + screen = lv.obj() + screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + + btn = lv.button(screen) + btn_label = lv.label(btn) + btn_label.set_text("Focus me") + + add_focus_highlight(btn) + + self.setContentView(screen) +``` + +### Background mode + +Use `mode="bg"` when the widget already has a persistent border that shouldn't be overwritten: + +```python +btn = lv.button(screen) +btn.set_style_border_width(2, lv.PART.MAIN) +btn.set_style_border_color(lv.color_hex(0x333333), lv.PART.MAIN) +add_focus_highlight(btn, mode="bg") +``` + +### Thicker, semi-transparent border + +```python +add_focus_highlight(btn, width=3, opacity=lv.OPA._50, radius=5) +``` + +### Focus highlight on a label + +Labels are not focusable by default, but they can be made focusable when they act as list rows: + +```python +row = lv.label(screen) +row.set_text("Setting row") +row.add_flag(lv.obj.FLAG.CLICKABLE) +add_focus_highlight(row, width=2) +``` + +## Compatibility + +`add_focus_border` is kept as a compatibility wrapper for the pre‑0.15.0 API. It delegates to `add_focus_highlight` with `mode="border"`. New code should use `add_focus_highlight` directly. + +## When to use it + +Use `add_focus_highlight` whenever you previously wrote a pair of `FOCUSED`/`DEFOCUSED` callbacks whose only job was to show or hide a border or background tint. It is used by the framework itself in places such as the launcher, settings screens, the top-menu drawer, `ImageView`, `MusicPlayer`, `ShowFonts`, `Connect4`, `About`, `HowTo`, and `WiFi Settings`. + +If you need additional behavior on focus (for example, starting a timer or loading data), keep your own callbacks and add `add_focus_highlight` alongside them. + +## See Also + +- [InputManager](input-manager.md) - Pointer and focus-group utilities +- [App Lifecycle](../apps/app-lifecycle.md) - Activity lifecycle and screen construction diff --git a/docs/frameworks/font-manager.md b/docs/frameworks/font-manager.md new file mode 100644 index 0000000..984a9b5 --- /dev/null +++ b/docs/frameworks/font-manager.md @@ -0,0 +1,243 @@ +# FontManager + +FontManager is a singleton framework for loading, caching, and composing LVGL fonts — including built-in bitmap fonts, TrueType fonts, and emoji image fonts. It uses LVGL's imgfont fallback mechanism to render 32×32 emoji PNGs inline with text, with nearest-neighbour scaling to match any font size. + +## Overview + +FontManager centralizes all font concerns in a single class: + +- **Unified API** - One call (`getFont`) to get any font, with or without emoji support +- **Emoji Compositing** - Transparently layers 32×32 emoji PNGs via LVGL's imgfont fallback, with nearest-neighbour scaling to match any font size +- **Lazy Caching** - Fonts and scaled image descriptors are cached on first use; no redundant work on subsequent calls +- **Android-Inspired** - Follows the same singleton/class-method pattern as other MicroPythonOS frameworks + +## Quick Start + +```python +from mpos import FontManager + +# Get a built-in Montserrat font at 16px (emoji disabled by default) +font = FontManager.getFont(size=16, family="Montserrat") + +# Get the same font with emoji support +emoji_font = FontManager.getFont(size=16, family="Montserrat", emoji=True) + +# Load a TrueType font from a file +ttf_font = FontManager.getFont(size=42, ttf="M:apps/com.myapp/assets/MyFont.ttf") + +# List all available built-in fonts (pass emojis=True for composed fonts) +for info in FontManager.listFonts(): + print(info["name"], info["size"]) + +# Get all available emoji codepoints (base codepoints only) +for cp in FontManager.getEmojiCodepoints(): + print(hex(cp)) + +# Get all available emoji strings (full sequences, including flag pairs) +for s in FontManager.getEmojiStrings(): + print(s) +``` + +## Architecture + +FontManager is implemented as a singleton using class variables and class methods. No instance creation is needed. + +### Font composition + +When `emoji=True`, `getFont()` wraps the requested base font in an LVGL imgfont that renders emoji as scaled PNG images. The imgfont's `fallback` is set to the base font, so LVGL automatically falls through to the base font for any codepoint that is not an emoji. + +``` +getFont(size=16, emoji=True) + └── base_font = font_montserrat_16 (builtin bitmap) + └── emoji_font = lv.imgfont_create(...) (PNG-based imgfont) + └── emoji_font.fallback = base_font + └── returns emoji_font +``` + +### Emoji size tiers + +Emoji PNGs are stored in `builtin/res/emojis/`: + +| Directory | Used for | +|-----------|----------| +| `32x32/` | All fonts — emoji are rendered at 32×32 px and nearest-neighbour scaled up or down by LVGL as needed | + +`_imgfont_path_cb` receives the rendering font's pixel height and returns the 32×32 emoji source. LVGL's software renderer performs nearest-neighbour scaling to fit the target font size. + +### Caching layers + +| Cache | Key | Value | +|-------|-----|-------| +| `_composed_font_cache` | `(font_id, emoji_size)` | Composed imgfont object | +| `_ttf_font_cache` | `(path, size)` | `lv.tiny_ttf_create_file` result | +| `_emoji_map` | (none) | `{codepoint: src_path}` dict | +| `_emoji_strings` | (none) | Sorted list of complete emoji strings | +| `_imgfont_scaled_src_cache` | `(src, target_height)` | Scaled `lv.image_dsc_t` or original src | +| `_imgfont_source_size_cache` | `src` | `(width, height)` tuple | +| `_imgfont_empty_src_cache` | `target_height` | 1×h transparent `lv.image_dsc_t` | + +## API Reference + +### `getFont(size=None, ttf=None, family=None, emoji=False)` + +Return a font object suitable for use with `set_style_text_font()`. + +**Parameters:** + +- `size` (int, optional): Target pixel size. Snapped to the nearest available builtin size. Defaults to 12. +- `ttf` (str, optional): Path to a `.ttf` file (e.g. `"M:apps/myapp/assets/MyFont.ttf"`). When provided, `family` is ignored. +- `family` (str, optional): Font family name — `"Montserrat"` (default) or `"Unscii"`. +- `emoji` (bool, optional): If `True`, the returned font transparently renders emoji via a PNG imgfont fallback. Defaults to `False`. Pass `True` to enable inline emoji rendering (adds a small rendering cost per emoji glyph). + +**Returns:** `lv.font_t` — the requested font, possibly wrapped with emoji support. + +If the requested `family` is not available, FontManager falls back to Montserrat, then to the first available built-in font, and finally to `lv.font_montserrat_12`. + +**Example:** + +```python +from mpos import FontManager +import lvgl as lv + +font = FontManager.getFont(size=24, family="Montserrat", emoji=True) +label = lv.label(screen) +label.set_style_text_font(font, lv.PART.MAIN) +label.set_text("Hello ❤️ 😀") +``` + +--- + +### `listFonts(emojis=False)` + +Return a list of all available built-in fonts. By default returns raw base fonts; pass `emojis=True` to wrap each font with emoji composition. + +**Returns:** list of dicts, each with keys: + +- `"name"` (str): Human-readable name, e.g. `"Montserrat 16"` +- `"family"` (str): Family name, e.g. `"Montserrat"` +- `"size"` (int): Nominal point size +- `"font"` (lv.font_t): Composed font (with emoji) +- `"base_font"` (lv.font_t): Raw base font (without emoji) + +**Example:** + +```python +from mpos import FontManager +import lvgl as lv + +for info in FontManager.listFonts(emojis=True): + label = lv.label(screen) + label.set_style_text_font(info["font"], lv.PART.MAIN) + label.set_text(info["name"] + ": ABC 😀 ❤️") +``` + +--- + +### `getEmojiCodepoints()` + +Return a sorted list of the base emoji codepoints available in the emoji map. + +For multi-codepoint emoji such as flag sequences (e.g. `"🇸🇻"`), only the first codepoint is returned. Use `getEmojiStrings()` when you need complete, renderable emoji sequences. + +**Returns:** list of int + +**Example:** + +```python +from mpos import FontManager + +for cp in FontManager.getEmojiCodepoints(): + print(hex(cp), chr(cp)) +``` + +--- + +### `getEmojiStrings()` + +Return a sorted list of all available, complete emoji strings. + +Unlike `getEmojiCodepoints()`, this returns full sequences: flag emoji include both regional indicators, and emoji with variation selectors keep their trailing selector. This is the preferred API for building a visual list of every supported emoji. + +**Returns:** list of str + +**Example:** + +```python +from mpos import FontManager + +for s in FontManager.getEmojiStrings(): + print(s) +``` + +--- + +### `normalizeEmojiText(text)` + +Strip Unicode variation selectors (U+FE0E text selector, U+FE0F emoji selector) from a string. Useful before storing or comparing text that may have been pasted from a source that appends these codepoints. + +**Parameters:** + +- `text` (str): Input string + +**Returns:** str — cleaned string + +**Example:** + +```python +from mpos import FontManager + +clean = FontManager.normalizeEmojiText("❤️") # removes U+FE0F after ❤ +``` + +## Emoji Assets + +To keep firmware image size small, the OS bundles ~50 of the most frequently-used emoji codepoints. Individual apps can bundle additional emoji PNGs in their own assets directory. Emoji PNGs are stored in `internal_filesystem/builtin/res/emojis/` and are included in the firmware image at `/builtin/res/emojis/` on the device filesystem: + +``` +builtin/res/emojis/ +└── 32x32/ # Pre-rendered at 32×32 px + ├── 1F600.png + ├── 1F3CE-FE0F.png + ├── 1F1F8-1F1FB.png + └── ... +``` + +Files are named by their Unicode codepoint(s) in uppercase hex, with multiple codepoints joined by `-` (e.g. `1F600.png` for 😀, `1F3CE-FE0F.png` for 🏎️, `1F1F8-1F1FB.png` for 🇸🇻). FontManager scans the directory at runtime and builds a `{codepoint: path}` map. + +### Missing emoji fallback + +If the exact PNG for an emoji is not bundled, FontManager tries visually similar emoji before giving up. For example, several kissing-face variants can substitute for each other. This keeps text readable even when only a small emoji set is included in the firmware. + +### Adding new emoji + +1. Add a PNG named `.png` to `32x32/`: + +```bash +cp original.png 32x32/CODEPOINT.png +``` + +2. The new emoji will be picked up automatically on next boot — no code changes needed. + +### Emoji reference + +For a canonical frequency-ordered listing of all available emoji codepoints, see [emoji_frequency_canonical.html](emoji_frequency_canonical.html). + +## File Structure + +``` +internal_filesystem/ +├── lib/mpos/ui/ +│ └── font_manager.py # FontManager class +└── builtin/res/emojis/ + └── 32x32/ # 32×32 px emoji PNGs +``` + +## Related Frameworks + +- **[AppearanceManager](appearance-manager.md)** - Theme colors and light/dark mode +- **[DisplayMetrics](display-metrics.md)** - Display width, height, DPI + +## See Also + +- [Architecture Overview](../architecture/overview.md) +- [Creating Apps](../apps/creating-apps.md) diff --git a/docs/frameworks/input-activity.md b/docs/frameworks/input-activity.md new file mode 100644 index 0000000..7005bea --- /dev/null +++ b/docs/frameworks/input-activity.md @@ -0,0 +1,158 @@ +# InputActivity + +`InputActivity` is a generic, reusable single-value input screen. It handles the UI and user input for one setting, then returns the value to the caller via `startActivityForResult`. It does **not** persist anything itself. + +## Overview + +`InputActivity` is useful any time you need to ask the user for a single value: a text string, a choice from radio buttons or a dropdown, or an integer from a slider. It supports the same input widgets as `SettingActivity`, but is independent of `SharedPreferences`. + +`SettingActivity` is a thin wrapper around `InputActivity` that adds `SharedPreferences` persistence, row-label updates, and `changed_callback` handling. Apps that only need the input UI can use `InputActivity` directly. + +## Basic Usage + +```python +from mpos import Activity, Intent, InputActivity + +class MyApp(Activity): + def ask_for_password(self): + intent = Intent(activity_class=InputActivity) + intent.putExtra("setting", { + "title": "WiFi Password", + "placeholder": "Enter password", + "ui": "textarea" + }) + intent.putExtra("value", "") + self.startActivityForResult(intent, self.on_password_result) + + def on_password_result(self, result): + if result and result.get("result_code"): + password = result["data"]["value"] + print("Password entered:", password) +``` + +## Intent Extras + +### Required extras + +- **`setting`** (dict): Metadata describing the input. + +### Optional extras + +- **`value`** (string): Initial value to display. If omitted, `setting["default_value"]` is used, falling back to `""`. + +## Setting Dictionary Structure + +The `setting` dict has the same shape as the one used by `SettingActivity` and `SettingsActivity`: + +### Required properties + +- **`title`** (string): Display name shown at the top of the input screen + +### Optional properties + +- **`key`** (string): Identifier returned in the result. Not used by `InputActivity` itself, but helpful for callers. +- **`ui`** (string): UI type. Options: `"textarea"` (default), `"radiobuttons"`, `"dropdown"`, `"slider"`, `"activity"` +- **`ui_options`** (list): List of `(label, value)` tuples for `radiobuttons` and `dropdown` +- **`placeholder`** (string): Placeholder text for `textarea` +- **`default_value`** (string): Default value if no `value` extra is provided +- **`note`** (string): Short informational text shown below the input widget +- **`min`** (int): Minimum value for `"slider"` (default: `0`) +- **`max`** (int): Maximum value for `"slider"` (default: `100`) +- **`allow_deselect`** (bool): For `radiobuttons`, allow the user to un-select the active option (default: `False`) + +## Result Contract + +`InputActivity` returns a result dict with the standard `Activity` result shape: + +```python +{ + "result_code": True, # True on Save, False on Cancel/back + "data": { + "value": "", + "key": "", + "ui": "" + } +} +``` + +The caller is responsible for persisting or acting on the returned `value`. + +## UI Types + +### Textarea (default) + +Text input with an on-screen keyboard. If the device has a camera, a "Scan data from QR code" button is also shown. + +```python +{ + "title": "API Key", + "key": "api_key", + "placeholder": "Enter your API key" +} +``` + +### Radio Buttons + +Mutually exclusive selection from a small list of options. + +```python +{ + "title": "Theme", + "key": "theme", + "ui": "radiobuttons", + "ui_options": [ + ("Light", "light"), + ("Dark", "dark"), + ("Auto", "auto") + ] +} +``` + +### Dropdown + +Compact selection from a larger list of options. + +```python +{ + "title": "Language", + "key": "language", + "ui": "dropdown", + "ui_options": [ + ("English", "en"), + ("German", "de"), + ("French", "fr"), + ("Spanish", "es") + ] +} +``` + +### Slider + +Integer selection within a range. + +```python +{ + "title": "Volume", + "key": "volume", + "ui": "slider", + "default_value": "50", + "min": 0, + "max": 100 +} +``` + +## Cancel and Back Handling + +Pressing the **Cancel** button or swiping back returns: + +```python +{"result_code": False, "data": {"value": "...", "key": "...", "ui": "..."}} +``` + +Callers should check `result.get("result_code")` before using the value. + +## See Also + +- [`SettingActivity`](setting-activity.md) - Wrapper that persists the input value to `SharedPreferences` +- [`SettingsActivity`](settings-activity.md) - List of multiple settings, each edited through `SettingActivity`/`InputActivity` +- [`SharedPreferences`](preferences.md) - For persisting values yourself diff --git a/docs/frameworks/input-manager.md b/docs/frameworks/input-manager.md new file mode 100644 index 0000000..bdb63e1 --- /dev/null +++ b/docs/frameworks/input-manager.md @@ -0,0 +1,111 @@ +# InputManager + +InputManager is a singleton framework that provides a unified API for managing input device interactions, including pointer/touch coordinates, focus management, and input device registration. + +## Overview + +InputManager centralizes all input-related operations in a single class with class methods. This provides: + +- **Unified API** - Single class for all input management +- **Clean Namespace** - No scattered functions cluttering imports +- **Testable** - InputManager can be tested independently +- **Pointer Access** - Get current touch/pointer coordinates +- **Device Registration** - Register and query available input devices by type + +## Architecture + +InputManager is implemented as a singleton using class variables and class methods: + +```python +class InputManager: + _registered_indevs = [] # List of registered input devices + + @classmethod + def pointer_xy(cls): + """Get current pointer/touch coordinates.""" + import lvgl as lv + indev = lv.indev_active() + if indev: + p = lv.point_t() + indev.get_point(p) + return p.x, p.y + return -1, -1 + + @classmethod + def has_pointer(cls): + """Check if a pointer device is registered.""" + return cls.has_indev_type(lv.INDEV_TYPE.POINTER) +``` + +No instance creation is needed - all methods are class methods that operate on class variables. + +## Usage + +### Getting Pointer Coordinates + +```python +from mpos import InputManager + +x, y = InputManager.pointer_xy() +if x == -1 and y == -1: + print("No active input device") +else: + print(f"Pointer at ({x}, {y})") +``` + +### Checking for Pointer Devices + +```python +from mpos import InputManager + +if InputManager.has_pointer(): + print("Touch or pointer input is available") +``` + +### Managing Focus + +```python +import lvgl as lv + +focusgroup = lv.group_get_default() +if focusgroup: + lv.group_focus_obj(my_button) +``` + +## API Reference + +### Class Methods + +#### `pointer_xy()` +Get current pointer/touch coordinates from the active input device. + +**Returns:** tuple - (x, y) coordinates, or (-1, -1) if no active input device + +#### `has_pointer()` +Check if any registered input device is a pointer/touch device. + +**Returns:** bool - True if a pointer device is registered + +#### `emulate_focus_obj(focusgroup, target)` +Deprecated compatibility shim. Use `lv.group_focus_obj(target)` directly. + +#### `register_indev(indev)` +Register an input device for later querying by type. + +#### `list_indevs()` +Get list of all registered input devices. + +#### `has_indev_type(indev_type)` +Check if any registered input device has the specified type. + +## Related Frameworks + +- **[DisplayMetrics](display-metrics.md)** - Display properties and pointer coordinates (uses InputManager) +- **[WidgetAnimator](widget-animator.md)** - UI animation framework + +## See Also + +- [Architecture Overview](../architecture/overview.md) +- [Frameworks](../architecture/frameworks.md) +- [Creating Apps](../apps/creating-apps.md) +- [LVGL Documentation](https://docs.lvgl.io/) diff --git a/docs/frameworks/lights-manager.md b/docs/frameworks/lights-manager.md new file mode 100644 index 0000000..92e02df --- /dev/null +++ b/docs/frameworks/lights-manager.md @@ -0,0 +1,676 @@ +# LightsManager + +MicroPythonOS provides a simple LED control service called **LightsManager** for NeoPixel RGB LEDs on supported hardware. + +## Overview + +LightsManager provides: + +- **One-shot LED control** - Direct control of individual or all LEDs +- **Buffered updates** - Set multiple LEDs then apply all changes at once +- **Hardware abstraction** - Same API works across all boards +- **Predefined colors** - Quick access to common notification colors +- **Frame-based animations** - Integrate with TaskHandler for smooth animations +- **Low overhead** - Singleton class with direct LED control + +⚠️ **Note**: LightsManager provides primitives for LED control, not built-in animations. Apps implement custom animations using the `update_frame()` pattern. + +## Import + +```python +from mpos import LightsManager +``` + +All examples below use the recommended `from mpos import LightsManager` style. + +## Hardware Support + +| Board | LEDs | GPIO | Notes | +|-------|------|------|-------| +| **Fri3d 2024 Badge** | ✅ 5 NeoPixel RGB | GPIO 12 | Full support | +| **Waveshare ESP32-S3** | ❌ None | N/A | No LED hardware | +| **Desktop/Linux** | ❌ None | N/A | Functions return `False` | + +## Quick Start + +### Check Availability + +```python +from mpos import Activity, LightsManager + +class MyActivity(Activity): + def onCreate(self): + if LightsManager.is_available(): + print(f"LEDs available: {LightsManager.get_led_count()}") + else: + print("No LED hardware on this device") +``` + +### Control Individual LEDs + +```python +from mpos import LightsManager + +# Set LED 0 to red (buffered) +LightsManager.set_led(0, 255, 0, 0) + +# Set LED 1 to green (buffered) +LightsManager.set_led(1, 0, 255, 0) + +# Set LED 2 to blue (buffered) +LightsManager.set_led(2, 0, 0, 255) + +# Apply all changes to hardware +LightsManager.write() +``` + +**Important**: LEDs are **buffered**. Changes won't appear until you call `write()`. + +### Control All LEDs + +```python +from mpos import LightsManager + +# Set all LEDs to blue +LightsManager.set_all(0, 0, 255) +LightsManager.write() + +# Turn off all LEDs +LightsManager.clear() +LightsManager.write() +``` + +### Notification Colors + +Quick shortcuts for common colors: + +```python +from mpos import LightsManager + +# Convenience method (sets all LEDs + calls write()) +LightsManager.set_notification_color("red") # Error +LightsManager.set_notification_color("green") # Success +LightsManager.set_notification_color("blue") # Info +LightsManager.set_notification_color("yellow") # Warning +LightsManager.set_notification_color("orange") # Alert +LightsManager.set_notification_color("purple") # Special +LightsManager.set_notification_color("white") # General +``` + +## Custom Animations + +LightsManager provides one-shot control - apps create animations by updating LEDs over time. + +### Blink Pattern + +Simple on/off blinking: + +```python +import time +from mpos import LightsManager + +def blink_pattern(): + """Blink all LEDs red 5 times.""" + for _ in range(5): + # Turn on + LightsManager.set_all(255, 0, 0) + LightsManager.write() + time.sleep_ms(200) + + # Turn off + LightsManager.clear() + LightsManager.write() + time.sleep_ms(200) +``` + +### Rainbow Cycle + +Display a rainbow pattern across all LEDs: + +```python +from mpos import LightsManager + +def rainbow_cycle(): + """Set each LED to a different rainbow color.""" + colors = [ + (255, 0, 0), # Red + (255, 128, 0), # Orange + (255, 255, 0), # Yellow + (0, 255, 0), # Green + (0, 0, 255), # Blue + ] + + for i, color in enumerate(colors): + LightsManager.set_led(i, *color) + + LightsManager.write() +``` + +### Chase Effect + +LEDs light up in sequence: + +```python +import time +from mpos import LightsManager + +def chase_effect(color=(0, 255, 0), delay_ms=100, loops=3): + """Light up LEDs in sequence.""" + led_count = LightsManager.get_led_count() + + for _ in range(loops): + for i in range(led_count): + # Clear all + LightsManager.clear() + + # Light current LED + LightsManager.set_led(i, *color) + LightsManager.write() + + time.sleep_ms(delay_ms) + + # Clear after animation + LightsManager.clear() + LightsManager.write() +``` + +### Pulse/Breathing Effect + +Fade LEDs in and out smoothly: + +```python +import time +from mpos import LightsManager + +def pulse_effect(color=(0, 0, 255), duration_ms=2000): + """Pulse LEDs with breathing effect.""" + steps = 20 # Number of brightness steps + delay = duration_ms // (steps * 2) # Time per step + + # Fade in + for i in range(steps): + brightness = i / steps + r = int(color[0] * brightness) + g = int(color[1] * brightness) + b = int(color[2] * brightness) + + LightsManager.set_all(r, g, b) + LightsManager.write() + time.sleep_ms(delay) + + # Fade out + for i in range(steps, 0, -1): + brightness = i / steps + r = int(color[0] * brightness) + g = int(color[1] * brightness) + b = int(color[2] * brightness) + + LightsManager.set_all(r, g, b) + LightsManager.write() + time.sleep_ms(delay) + + # Clear + LightsManager.clear() + LightsManager.write() +``` + +### Random Sparkle + +Random LEDs flash briefly: + +```python +import time +import random +from mpos import LightsManager + +def sparkle_effect(duration_ms=5000): + """Random LEDs sparkle.""" + led_count = LightsManager.get_led_count() + start_time = time.ticks_ms() + + while time.ticks_diff(time.ticks_ms(), start_time) < duration_ms: + # Pick random LED + led = random.randint(0, led_count - 1) + + # Random color + r = random.randint(0, 255) + g = random.randint(0, 255) + b = random.randint(0, 255) + + # Flash on + LightsManager.clear() + LightsManager.set_led(led, r, g, b) + LightsManager.write() + time.sleep_ms(100) + + # Flash off + LightsManager.clear() + LightsManager.write() + time.sleep_ms(50) + + # Clear after animation + LightsManager.clear() + LightsManager.write() +``` + +## Frame-Based LED Animations + +For smooth, real-time animations that integrate with the game loop, use the **TaskHandler event system**: + +```python +from mpos import Activity, LightsManager +import mpos.ui +import time + +class LEDAnimationActivity(Activity): + def onCreate(self): + self.screen = lv.obj() + self.last_time = 0 + self.led_index = 0 + self.setContentView(self.screen) + + def onResume(self, screen): + """Start animation when app resumes.""" + self.last_time = time.ticks_ms() + mpos.ui.task_handler.add_event_cb(self.update_frame, 1) + + def onPause(self, screen): + """Stop animation and clear LEDs when app pauses.""" + mpos.ui.task_handler.remove_event_cb(self.update_frame) + LightsManager.clear() + LightsManager.write() + + def update_frame(self, a, b): + """Called every frame - update animation.""" + current_time = time.ticks_ms() + delta_time = time.ticks_diff(current_time, self.last_time) / 1000.0 + + # Update every 0.5 seconds (2 Hz) + if delta_time > 0.5: + self.last_time = current_time + + # Clear all LEDs + LightsManager.clear() + + # Light up current LED + LightsManager.set_led(self.led_index, 0, 255, 0) + LightsManager.write() + + # Move to next LED + self.led_index = (self.led_index + 1) % LightsManager.get_led_count() +``` + +### Smooth Color Cycle Animation + +```python +import math +from mpos import LightsManager + +class ColorCycleActivity(Activity): + def onCreate(self): + self.screen = lv.obj() + self.hue = 0.0 # Color hue (0-360) + self.last_time = time.ticks_ms() + self.setContentView(self.screen) + + def onResume(self, screen): + self.last_time = time.ticks_ms() + mpos.ui.task_handler.add_event_cb(self.update_frame, 1) + + def onPause(self, screen): + mpos.ui.task_handler.remove_event_cb(self.update_frame) + LightsManager.clear() + LightsManager.write() + + def update_frame(self, a, b): + current_time = time.ticks_ms() + delta_time = time.ticks_diff(current_time, self.last_time) / 1000.0 + self.last_time = current_time + + # Rotate hue + self.hue += 120 * delta_time # 120 degrees per second + if self.hue >= 360: + self.hue -= 360 + + # Convert HSV to RGB + r, g, b = self.hsv_to_rgb(self.hue, 1.0, 1.0) + + # Update all LEDs + LightsManager.set_all(r, g, b) + LightsManager.write() + + def hsv_to_rgb(self, h, s, v): + """Convert HSV to RGB (h: 0-360, s: 0-1, v: 0-1).""" + h = h / 60.0 + i = int(h) + f = h - i + p = v * (1 - s) + q = v * (1 - s * f) + t = v * (1 - s * (1 - f)) + + if i == 0: + r, g, b = v, t, p + elif i == 1: + r, g, b = q, v, p + elif i == 2: + r, g, b = p, v, t + elif i == 3: + r, g, b = p, q, v + elif i == 4: + r, g, b = t, p, v + else: + r, g, b = v, p, q + + return int(r * 255), int(g * 255), int(b * 255) +``` + +## API Reference + +### Functions + +**`is_available()`** + +Check if LED hardware is available. + +- **Returns:** `bool` - `True` if LEDs are available, `False` otherwise + +**`get_led_count()`** + +Get the number of available LEDs. + +- **Returns:** `int` - Number of LEDs (5 on Fri3d badge, 0 elsewhere) + +**`set_led(index, r, g, b)`** + +Set a single LED color (buffered). + +- **Parameters:** + - `index` (int): LED index (0 to led_count-1) + - `r` (int): Red component (0-255) + - `g` (int): Green component (0-255) + - `b` (int): Blue component (0-255) + +**`set_all(r, g, b)`** + +Set all LEDs to the same color (buffered). + +- **Parameters:** + - `r` (int): Red component (0-255) + - `g` (int): Green component (0-255) + - `b` (int): Blue component (0-255) + +**`clear()`** + +Turn off all LEDs (buffered). Equivalent to `set_all(0, 0, 0)`. + +**`write()`** + +Apply buffered LED changes to hardware. **Required** - changes won't appear until you call this. + +**`set_notification_color(color_name)`** + +Set all LEDs to a predefined color and immediately apply (convenience method). + +- **Parameters:** + - `color_name` (str): Color name ("red", "green", "blue", "yellow", "orange", "purple", "white") + +### Predefined Colors + +| Color Name | RGB Value | Use Case | +|------------|-----------|----------| +| `"red"` | (255, 0, 0) | Error, alert | +| `"green"` | (0, 255, 0) | Success, ready | +| `"blue"` | (0, 0, 255) | Info, processing | +| `"yellow"` | (255, 255, 0) | Warning, caution | +| `"orange"` | (255, 128, 0) | Alert, attention | +| `"purple"` | (255, 0, 255) | Special, unique | +| `"white"` | (255, 255, 255) | General, neutral | + +## Performance Tips + +### Buffering for Efficiency + +**❌ Bad** - Calling `write()` after each LED: +```python +from mpos import LightsManager + +for i in range(5): + LightsManager.set_led(i, 255, 0, 0) + LightsManager.write() # 5 hardware updates! +``` + +**✅ Good** - Set all LEDs then write once: +```python +from mpos import LightsManager + +for i in range(5): + LightsManager.set_led(i, 255, 0, 0) + +LightsManager.write() # 1 hardware update +``` + +### Update Rate Recommendations + +- **Static displays**: Update once, no frame loop needed +- **Notifications**: Update when event occurs +- **Smooth animations**: 20-30 Hz (every 33-50ms) +- **Fast effects**: Up to 60 Hz (but uses more power) + +**Example with rate limiting:** +```python +UPDATE_INTERVAL = 0.05 # 20 Hz (50ms) + +def update_frame(self, a, b): + current_time = time.ticks_ms() + delta_time = time.ticks_diff(current_time, self.last_time) / 1000.0 + + if delta_time >= UPDATE_INTERVAL: + self.last_time = current_time + # Update LEDs here + LightsManager.write() +``` + +### Cleanup in onPause() + +Always clear LEDs when your app exits: + +```python +def onPause(self, screen): + # Stop animations + mpos.ui.task_handler.remove_event_cb(self.update_frame) + + # Clear LEDs + LightsManager.clear() + LightsManager.write() +``` + +This prevents LEDs from staying lit after your app exits. + +## Troubleshooting + +### LEDs Not Updating + +**Symptom**: Called `set_led()` or `set_all()` but LEDs don't change + +**Cause**: Forgot to call `write()` to apply changes + +**Solution**: Always call `write()` after setting LED colors + +```python +# ❌ Wrong - no write() +LightsManager.set_all(255, 0, 0) + +# ✅ Correct +LightsManager.set_all(255, 0, 0) +LightsManager.write() +``` + +### Flickering LEDs + +**Symptom**: LEDs flicker or show inconsistent colors during animation + +**Possible causes:** + +1. **Update rate too high** + ```python + # ❌ Bad - updating every frame (60 Hz) + def update_frame(self, a, b): + LightsManager.set_all(random_color()) + LightsManager.write() # Too frequent! + + # ✅ Good - rate limited to 20 Hz + def update_frame(self, a, b): + if delta_time >= 0.05: # 50ms = 20 Hz + LightsManager.set_all(random_color()) + LightsManager.write() + ``` + +2. **Inconsistent timing** + ```python + # ✅ Use delta time for smooth animations + delta_time = time.ticks_diff(current_time, self.last_time) / 1000.0 + ``` + +3. **Race condition with multiple updates** + - Only update LEDs from one place (e.g., single update_frame callback) + - Avoid updating from multiple threads + +### LEDs Stay On After App Exits + +**Symptom**: LEDs remain lit when you leave the app + +**Cause**: Didn't clear LEDs in `onPause()` + +**Solution**: Always implement cleanup in `onPause()` + +```python +def onPause(self, screen): + # Stop animation callback + mpos.ui.task_handler.remove_event_cb(self.update_frame) + + # Clear LEDs + LightsManager.clear() + LightsManager.write() +``` + +### No LEDs on Waveshare Board + +**Symptom**: `is_available()` returns `False` on Waveshare + +**Cause**: Waveshare ESP32-S3-Touch-LCD-2 has no NeoPixel LEDs + +**Solution**: +- Check hardware before using LEDs: + ```python + from mpos import LightsManager + + if LightsManager.is_available(): + # Use LEDs + else: + # Fallback to screen indicators or sounds + ``` + +### Colors Look Wrong + +**Symptom**: LEDs show unexpected colors + +**Possible causes:** + +1. **RGB order confusion** - Make sure you're using (R, G, B) order: + ```python + LightsManager.set_led(0, 255, 0, 0) # Red (R=255, G=0, B=0) + LightsManager.set_led(1, 0, 255, 0) # Green (R=0, G=255, B=0) + LightsManager.set_led(2, 0, 0, 255) # Blue (R=0, G=0, B=255) + ``` + +2. **Value out of range** - RGB values must be 0-255: + ```python + # ❌ Wrong - values > 255 wrap around + LightsManager.set_led(0, 300, 0, 0) + + # ✅ Correct - clamp to 0-255 + LightsManager.set_led(0, min(300, 255), 0, 0) + ``` + +## Complete Example: LED Status Indicator + +```python +from mpos import Activity, LightsManager +import lvgl as lv + +class StatusIndicatorActivity(Activity): + def onCreate(self): + self.screen = lv.obj() + + # Buttons for different statuses + success_btn = lv.button(self.screen) + success_btn.set_size(100, 50) + success_btn.set_pos(10, 10) + lv.label(success_btn).set_text("Success") + success_btn.add_event_cb( + lambda e: self.show_status("success"), + lv.EVENT.CLICKED, + None + ) + + error_btn = lv.button(self.screen) + error_btn.set_size(100, 50) + error_btn.set_pos(120, 10) + lv.label(error_btn).set_text("Error") + error_btn.add_event_cb( + lambda e: self.show_status("error"), + lv.EVENT.CLICKED, + None + ) + + warning_btn = lv.button(self.screen) + warning_btn.set_size(100, 50) + warning_btn.set_pos(10, 70) + lv.label(warning_btn).set_text("Warning") + warning_btn.add_event_cb( + lambda e: self.show_status("warning"), + lv.EVENT.CLICKED, + None + ) + + clear_btn = lv.button(self.screen) + clear_btn.set_size(100, 50) + clear_btn.set_pos(120, 70) + lv.label(clear_btn).set_text("Clear") + clear_btn.add_event_cb( + lambda e: self.show_status("clear"), + lv.EVENT.CLICKED, + None + ) + + self.setContentView(self.screen) + + def show_status(self, status_type): + """Show visual status with LEDs.""" + if not LightsManager.is_available(): + print("No LEDs available") + return + + if status_type == "success": + LightsManager.set_notification_color("green") + elif status_type == "error": + LightsManager.set_notification_color("red") + elif status_type == "warning": + LightsManager.set_notification_color("yellow") + elif status_type == "clear": + LightsManager.clear() + LightsManager.write() + + def onPause(self, screen): + # Clear LEDs when leaving app + if LightsManager.is_available(): + LightsManager.clear() + LightsManager.write() +``` + +## See Also + +- [Creating Apps](../apps/creating-apps.md) - Learn how to create MicroPythonOS apps +- [WidgetAnimator](widget-animator.md) - Smooth UI animations +- [AudioManager](audiomanager.md) - Audio control for sound feedback +- [SensorManager](sensor-manager.md) - Sensor integration for interactive effects diff --git a/docs/frameworks/notification-manager.md b/docs/frameworks/notification-manager.md new file mode 100644 index 0000000..787f25c --- /dev/null +++ b/docs/frameworks/notification-manager.md @@ -0,0 +1,207 @@ +# NotificationManager + +`NotificationManager` is a system-wide framework for posting, displaying, and dispatching notifications. It is inspired by Android's notification system, but tailored for resource-constrained devices. + +Notifications appear in the top notification bar and in the pull-down drawer. Tapping a notification dispatches its attached `Intent`, which can open an activity or start an app. + +## Overview + +- Apps post `Notification` objects through `NotificationManager.notify()`. +- The system UI listens for changes and updates the notification bar and drawer automatically. +- Notifications are persisted to storage (with a debounced write) so they survive reboots. +- Notifications can be cancelled individually, in bulk, or automatically when tapped. + +## Basic Usage + +```python +from mpos import NotificationManager, Notification, Intent + +notification = Notification( + notification_id="myapp.event", + icon=lv.SYMBOL.BELL, + title="Event", + text="Something happened!", + intent=Intent(action="main", app_fullname="com.example.myapp"), + auto_cancel=True, +) + +NotificationManager.notify(notification) +``` + +To remove a notification: + +```python +NotificationManager.cancel("myapp.event") +``` + +## Notification Object + +`Notification` is a plain data object that describes a single notification. + +### Constructor Arguments + +| Argument | Type | Description | +|----------|------|-------------| +| `notification_id` | `str` | Unique identifier. Also accepts `uniqueidString` for compatibility. Required. | +| `icon` | `str` or `lv.image_dsc_t` | Icon shown in the bar/drawer. Can be an `lv.SYMBOL.*` string, another string, or an LVGL image descriptor. | +| `title` | `str` | Short title. | +| `text` | `str` | Longer body text. | +| `priority` | `int` | One of `Notification.PRIORITY_MIN`, `PRIORITY_LOW`, `PRIORITY_DEFAULT`, `PRIORITY_HIGH`, `PRIORITY_MAX`. Higher priority notifications are shown first. | +| `intent` | `Intent` | `Intent` to dispatch when the user taps the notification. | +| `auto_cancel` | `bool` | If `True` (default), the notification is cancelled automatically after it is tapped. | +| `app_fullname` | `str` | App that owns the notification. Used as a fallback target if the intent has no explicit target. | + +### Priority Levels + +```python +Notification.PRIORITY_MIN = -1 +Notification.PRIORITY_LOW = 0 +Notification.PRIORITY_DEFAULT = 1 +Notification.PRIORITY_HIGH = 2 +Notification.PRIORITY_MAX = 3 +``` + +The drawer sorts notifications by priority, then by most recent update time. + +## Notification sounds + +When a new notification arrives, the system can play a short RTTTL ringtone on the device's buzzer output. The sound is stored as an RTTTL string in the Settings app's `SharedPreferences` under the key `notification_sound`. + +Available sounds: + +| Label | RTTTL string | +|-------|--------------| +| `None` | (empty string, no sound) | +| `Coin` | `coin:d=8,o=6,b=200:16b5,e6` | +| `Scale up` | `scale_up:d=32,o=5,b=100:c,c#,d#,e,f#,g#,a#,b` | +| `Superhappy` | `superhappy:d=8,o=5,b=635:c,e,g,c,e,g,c,e,g,c6,e6,g6,c6,e6,g6,c7,e7,g7,c7,e7,g7,c7,e7,g7` | + +The default sound is `Coin`. If no buzzer output is available, the notification posts silently. Apps can change the sound programmatically: + +```python +from mpos import SharedPreferences + +prefs = SharedPreferences("com.micropythonos.settings") +prefs.put_string("notification_sound", "coin:d=8,o=6,b=200:16b5,e6") +prefs.commit() +``` + +## NotificationManager API + +All methods are class methods. `NotificationManager` initializes itself lazily on first use. + +### `notify(notification)` + +Post or update a notification. + +- If a notification with the same ID already exists, its content is updated in place without a new persistence flash. +- If it is new, it is added, the list is trimmed to `MAX_NOTIFICATIONS` (20), and persistence is scheduled. + +```python +NotificationManager.notify(Notification( + notification_id="osupdate.available", + icon=lv.SYMBOL.DOWNLOAD, + title="Update available", + text="MicroPythonOS 1.2.3 is ready to install", + priority=Notification.PRIORITY_HIGH, +)) +``` + +### `cancel(notification_id)` + +Remove a single notification. Writes immediately so the notification does not reappear after reboot. + +```python +NotificationManager.cancel("osupdate.available") +``` + +### `cancel_all()` + +Remove all notifications. + +```python +NotificationManager.cancel_all() +``` + +### `get_notifications()` + +Return a sorted list of active notifications, highest priority first. + +```python +for n in NotificationManager.get_notifications(): + print(n.title, n.text) +``` + +### `get_notification(notification_id)` + +Return a single notification by ID, or `None` if it doesn't exist. + +```python +n = NotificationManager.get_notification("osupdate.available") +``` + +### `trigger(notification_id_or_object)` + +Dispatch the notification's intent. This is what the system calls when the user taps a notification in the drawer. + +- If the intent has an explicit `activity_class` or `action`, `ActivityNavigator.startActivity()` is used. +- Otherwise, the owning `app_fullname` is started via `AppManager.start_app()`. +- If `auto_cancel` is enabled and the dispatch succeeds, the notification is cancelled. + +```python +NotificationManager.trigger("osupdate.available") +``` + +### `register_listener(callback, notify_immediately=True)` / `unregister_listener(callback)` + +Register a function to be called whenever notifications change. The top menu/drawer uses this to refresh the UI. + +```python +def on_notifications_changed(): + print("Notifications updated") + +NotificationManager.register_listener(on_notifications_changed) +``` + +## Complete Example + +```python +import lvgl as lv +from mpos import Activity, NotificationManager, Notification, Intent + +class MyApp(Activity): + + def show_notification(self): + intent = Intent(action="main", app_fullname="com.example.myapp") + notification = Notification( + notification_id="com.example.myapp.done", + icon=lv.SYMBOL.OK, + title="Done", + text="The operation finished successfully.", + intent=intent, + auto_cancel=True, + app_fullname="com.example.myapp", + ) + NotificationManager.notify(notification) +``` + +## Best Practices + +### Do's + +✅ Use a stable, unique `notification_id` so the same event doesn't create duplicate notifications +✅ Set a meaningful `app_fullname` so tapping the notification can fall back to launching your app +✅ Cancel notifications when they are no longer relevant +✅ Use `auto_cancel=True` for one-shot notifications that should disappear after being tapped + +### Don'ts + +❌ Don't rely on `lv.image_dsc_t` icons surviving a reboot — only string icons are persisted +❌ Don't post large amounts of text; storage and display space are limited +❌ Don't use more than `MAX_NOTIFICATIONS` (20) active notifications + +## See Also + +- [Service](service.md) — Background services that often post notifications at boot +- [SettingActivity](setting-activity.md) — Per-app settings storage, useful with notification preferences +- [App Lifecycle](../apps/app-lifecycle.md) — Foreground handling when notifications launch your app diff --git a/docs/frameworks/number-format.md b/docs/frameworks/number-format.md new file mode 100644 index 0000000..98fcee3 --- /dev/null +++ b/docs/frameworks/number-format.md @@ -0,0 +1,60 @@ +# NumberFormat + +The NumberFormat framework provides a small utility for formatting numbers using the system's number format preference stored in SharedPreferences. + +## Preferences + +The format preference is stored in `SharedPreferences` under: + +- App ID: `com.micropythonos.settings` +- Key: `number_format` + +If the key is missing, the default format is `comma_dot`. + +## Supported formats + +The available formats are: + +- `comma_dot`: `1,234.56` (US/UK) +- `dot_comma`: `1.234,56` (Europe) +- `space_comma`: `1 234,56` (French) +- `apos_dot`: `1'234.56` (Swiss) +- `under_dot`: `1_234.56` (Tech) +- `none_dot`: `1234.56` (no thousands separator) +- `none_comma`: `1234,56` (no thousands separator) + +## API + +### `NumberFormat.refresh_preference()` + +Reloads the preference from SharedPreferences. + +### `NumberFormat.get_separators()` + +Returns a tuple `(decimal_sep, thousands_sep)` for the current preference. + +### `NumberFormat.format_number(value, decimals=None)` + +Formats a number using the current preference. + +- `value`: `int` or `float` +- `decimals`: number of decimal places + - `None` (default): + - `int` values are formatted without decimals + - `float` values are formatted to 2 decimals, then trailing zeros are stripped + +### `NumberFormat.get_format_options()` + +Returns a list of `(label, key)` tuples for use in settings dropdowns. + +## Example + +```python +from mpos import NumberFormat + +NumberFormat.refresh_preference() + +print(NumberFormat.format_number(1234567)) # 1,234,567 (depends on preference) +print(NumberFormat.format_number(1234.5)) # 1,234.5 (depends on preference) +print(NumberFormat.format_number(1234.5, 3)) # 1,234.500 (depends on preference) +``` diff --git a/docs/frameworks/preferences.md b/docs/frameworks/preferences.md new file mode 100644 index 0000000..41279ad --- /dev/null +++ b/docs/frameworks/preferences.md @@ -0,0 +1,124 @@ +MicroPythonOS provides a simple way to load and save preferences, similar to Android's "SharedPreferences" framework. + +Here's a simple example of how to add it to your app, taken from [QuasiNametag](https://github.com/QuasiKili/MPOS-QuasiNametag): + +
+```
+--- quasinametag.py.orig        2025-10-29 12:24:27.494193748 +0100
++++ quasinametag.py     2025-10-29 12:07:59.357264302 +0100
+@@ -1,4 +1,5 @@
+ from mpos import Activity
++import mpos.config
+ import mpos.ui
+ import mpos.ui.focus_direction
+@@ -42,6 +43,12 @@
+         # Add key event handler to container to catch all key events
+         container.add_event_cb(self.global_key_handler, lv.EVENT.KEY, None)
+ 
++        print("Loading preferences...")
++        prefs = mpos.config.SharedPreferences("com.quasikili.quasinametag")
++        self.name_text = prefs.get_string("name_text", self.name_text)
++        self.fg_color = prefs.get_int("fg_color", self.fg_color)
++        self.bg_color = prefs.get_int("bg_color", self.bg_color)
++
+         # Create both screens as children of the container
+         self.create_edit_screen(container)
+         self.create_display_screen(container)
+@@ -263,6 +270,13 @@
+         if focusgroup:
+             lv.group_focus_obj(self.display_screen)
+ 
++        print("Saving preferences...")
++        editor = mpos.config.SharedPreferences("com.quasikili.quasinametag").edit()
++        editor.put_string("name_text", self.name_text)
++        editor.put_int("fg_color", self.fg_color)
++        editor.put_int("bg_color", self.bg_color)
++        editor.commit()
++
+     def update_display_screen(self):
+         # Set background color
+         self.display_screen.set_style_bg_color(lv.color_hex(self.bg_color), 0)
+```
+
+ +Here's a more complete example: + +
+```
+# Example usage with access_points as a dictionary
+def main():
+    # Initialize SharedPreferences
+    prefs = SharedPreferences("com.example.test_shared_prefs")
+
+    # Save some simple settings and a dictionary-based access_points
+    editor = prefs.edit()
+    editor.put_string("someconfig", "somevalue")
+    editor.put_int("othervalue", 54321)
+    editor.put_dict("access_points", {
+        "example_ssid1": {"password": "examplepass1", "detail": "yes please", "numericalconf": 1234},
+        "example_ssid2": {"password": "examplepass2", "detail": "no please", "numericalconf": 9875}
+    })
+    editor.apply()
+
+    # Read back the settings
+    print("Simple settings:")
+    print("someconfig:", prefs.get_string("someconfig", "default_value"))
+    print("othervalue:", prefs.get_int("othervalue", 0))
+
+    print("\nAccess points (dictionary-based):")
+    ssids = prefs.get_dict_keys("access_points")
+    for ssid in ssids:
+        print(f"Access Point SSID: {ssid}")
+        print(f"  Password: {prefs.get_dict_item_field('access_points', ssid, 'password', 'N/A')}")
+        print(f"  Detail: {prefs.get_dict_item_field('access_points', ssid, 'detail', 'N/A')}")
+        print(f"  Numerical Conf: {prefs.get_dict_item_field('access_points', ssid, 'numericalconf', 0)}")
+        print(f"  Full config: {prefs.get_dict_item('access_points', ssid)}")
+
+    # Add a new access point
+    editor = prefs.edit()
+    editor.put_dict_item("access_points", "example_ssid3", {
+        "password": "examplepass3",
+        "detail": "maybe",
+        "numericalconf": 5555
+    })
+    editor.commit()
+
+    # Update an existing access point
+    editor = prefs.edit()
+    editor.put_dict_item("access_points", "example_ssid1", {
+        "password": "newpass1",
+        "detail": "updated please",
+        "numericalconf": 4321
+    })
+    editor.commit()
+
+    # Remove an access point
+    editor = prefs.edit()
+    editor.remove_dict_item("access_points", "example_ssid2")
+    editor.commit()
+
+    # Read updated access points
+    print("\nUpdated access points (dictionary-based):")
+    ssids = prefs.get_dict_keys("access_points")
+    for ssid in ssids:
+        print(f"Access Point SSID: {ssid}: {prefs.get_dict_item('access_points', ssid)}")
+
+    # Demonstrate compatibility with list-based configs
+    editor = prefs.edit()
+    editor.put_list("somelist", [
+        {"a": "ok", "numericalconf": 1111},
+        {"a": "not ok", "numericalconf": 2222}
+    ])
+    editor.apply()
+
+    print("\List-based config:")
+    somelist = prefs.get_list("somelist")
+    for i, ap in enumerate(somelist):
+        print(f"List item {i}:")
+        print(f"  a: {prefs.get_list_item('somelist', i, 'a', 'N/A')}")
+        print(f"  Full dict: {prefs.get_list_item_dict('somelist', i)}")
+
+if __name__ == '__main__':
+    main()
+```
+
diff --git a/docs/frameworks/sensor-manager.md b/docs/frameworks/sensor-manager.md new file mode 100644 index 0000000..c034aaa --- /dev/null +++ b/docs/frameworks/sensor-manager.md @@ -0,0 +1,165 @@ +# SensorManager + +MicroPythonOS provides a unified sensor framework called **SensorManager**, inspired by Android's SensorManager API. It provides easy access to motion sensors (accelerometer, gyroscope, magnetometer) and temperature sensors across different hardware platforms. + +## Overview + +SensorManager automatically detects available sensors on your device: + +- **QMI8658 IMU** (Waveshare ESP32-S3-Touch-LCD-2) +- **WSEN_ISDS / LSM6DSO IMU** (Fri3d Camp 2024/2026) +- **MPU6886 IMU** (M5Stack Fire) +- **Linux IIO** (Desktop builds with IIO sensors) +- **ESP32 MCU Temperature** (All ESP32 boards) + +The framework handles: + +- **Auto-detection** - Identifies which IMU is present +- **Unit normalization** - Returns standard SI units (m/s², deg/s, °C, μT) +- **Persistent calibration** - Calibrate once, saved across reboots +- **Thread-safe** - Safe for concurrent access +- **Hardware-agnostic** - Apps work on all platforms without changes + +## Sensor Types + +```python +from mpos import SensorManager + +# Motion sensors +SensorManager.TYPE_ACCELEROMETER # m/s² (meters per second squared) +SensorManager.TYPE_GYROSCOPE # deg/s (degrees per second) +SensorManager.TYPE_MAGNETIC_FIELD # μT (micro teslas) + +# Temperature sensors +SensorManager.TYPE_SOC_TEMPERATURE # °C (MCU internal temperature) +SensorManager.TYPE_IMU_TEMPERATURE # °C (IMU chip temperature) +``` + +## Quick Start + +### Basic Usage + +```python +from mpos import Activity, SensorManager + +class MyActivity(Activity): + def onCreate(self): + if not SensorManager.is_available(): + print("No sensors available") + return + + self.accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + self.gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + + accel_data = SensorManager.read_sensor(self.accel) + gyro_data = SensorManager.read_sensor(self.gyro) +``` + +### Reading Temperature + +```python +# Get MCU internal temperature (most stable) +temp_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_SOC_TEMPERATURE) +temperature = SensorManager.read_sensor(temp_sensor) + +# Get IMU chip temperature +imu_temp_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_IMU_TEMPERATURE) +imu_temperature = SensorManager.read_sensor(imu_temp_sensor) +``` + +### Desktop/Linux IIO Support + +On desktop builds, SensorManager can use Linux IIO sensors: + +```python +from mpos import SensorManager + +SensorManager.init_iio() +mag = SensorManager.get_default_sensor(SensorManager.TYPE_MAGNETIC_FIELD) +print(SensorManager.read_sensor(mag)) +``` + +## Calibration + +Calibration removes sensor drift and improves accuracy. The device must be **stationary on a flat surface** during calibration. + +### Manual Calibration (Programmatic) + +```python +accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) +gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + +accel_offsets = SensorManager.calibrate_sensor(accel, samples=100) +gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=100) +``` + +Calibration is saved to `imu_calibration.json` under `com.micropythonos.settings`. + +## List Available Sensors + +```python +sensors = SensorManager.get_sensor_list() +for sensor in sensors: + print(sensor.name, sensor.type, sensor.vendor) +``` + +## API Reference + +### Functions + +**`init(i2c_bus, address=0x6B, mounted_position=FACING_SKY)`** + +Initialize SensorManager. Returns `True` if initialization succeeded. + +**`init_iio()`** + +Initialize Linux IIO sensors (desktop builds). + +**`is_available()`** + +Returns `True` if sensors are available. + +**`get_sensor_list()`** + +Returns list of all available `Sensor` objects. + +**`get_default_sensor(sensor_type)`** + +Returns the default `Sensor` for the given type, or `None` if not available. + +**`read_sensor(sensor)`** + +Reads sensor data. Returns `(x, y, z)` tuple for motion sensors, single value for temperature, or `None` on error. + +**`read_sensor_once(sensor)`** + +One-shot sensor read with automatic initialization (used internally by calibration helpers). + +**`calibrate_sensor(sensor, samples=100)`** + +Calibrates the sensor (device must be stationary). Returns calibration offsets and saves them to disk. + +**`check_calibration_quality(samples=50)`** + +Return a dict containing mean/variance statistics and a quality score. + +**`check_stationarity(samples=30, variance_threshold_accel=0.5, variance_threshold_gyro=5.0)`** + +Return `True` if the device appears stationary based on variance thresholds. + +### Sensor Object + +Properties: + +- `name` - Human-readable sensor name +- `type` - Sensor type constant +- `vendor` - Manufacturer name +- `version` - Driver version +- `max_range` - Maximum measurement range +- `resolution` - Measurement resolution +- `power` - Power consumption in mA + +## See Also + +- [Creating Apps](../apps/creating-apps.md) - Learn how to create MicroPythonOS apps +- [SharedPreferences](preferences.md) - Persist app data diff --git a/docs/frameworks/service.md b/docs/frameworks/service.md new file mode 100644 index 0000000..9562d9a --- /dev/null +++ b/docs/frameworks/service.md @@ -0,0 +1,269 @@ +# Service + +MicroPythonOS provides a **Service** framework for background operations that run independently of the UI, inspired by Android's Service model. Services are ideal for WiFi auto-connect, web server startup, async REPL tasks, periodic update checks, posting notifications, and similar boot-time or long-running background work. + +## Overview + +Services differ from Activities in key ways: + +- **No user interface** — A Service has no screen, no LVGL objects, and no lifecycle tied to visibility. +- **Independent lifecycle** — Services are created, started, and destroyed by the system without user interaction. +- **Boot-time execution** — Services can subscribe to the `boot_completed` intent to run automatically at system startup. This is the primary way to register a "start at boot" receiver. +- **Long-running** — A Service can run indefinitely (e.g., polling for updates) or terminate after completing a task. + +``` +┌───────────────────────────────────────────┐ +│ Service Lifecycle │ +├───────────────────────────────────────────┤ +│ │ +│ ┌────────────┐ │ +│ │ onCreate │ ← Initialize resources │ +│ └─────┬──────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────┐ │ +│ │ onStart │ ← Receive intent, work │ +│ └─────┬──────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────┐ │ +│ │ onDestroy │ ← Cleanup resources │ +│ └────────────┘ │ +│ │ +└───────────────────────────────────────────┘ +``` + +## Service Base Class + +The `Service` class is defined in `mpos.app.service` and re-exported from `mpos`: + +```python +from mpos import Service + +class MyService(Service): + + def __init__(self): + super().__init__() + + def onCreate(self): + # One-time initialization + pass + + def onStart(self, intent=None): + # Service is starting — do the work + pass + + def onDestroy(self): + # Clean up resources + pass +``` + +### Lifecycle Methods + +| Method | When Called | Purpose | +|--------|-------------|---------| +| `onCreate()` | After instantiation, before `onStart()` | One-time setup (allocating resources, opening files) | +| `onStart(intent)` | After `onCreate()` when the service is started | Perform the service's work; `intent` contains the action that triggered it | +| `onDestroy()` | When the service is being shut down | Release all resources, stop threads, cancel tasks | + +### Properties + +- `self.appFullName` — Set automatically by the system before `onCreate()`. Contains the fullname of the app that owns this service (e.g., `"com.micropythonos.osupdate"`). Use it instead of hard-coding the package name. + +## Declaring Services + +Services can be registered in two ways: + +### 1. Programmatic Registration (System Services) + +System services register themselves directly with `AppManager.register_service()` at module level. This is how the OS itself registers receivers for the `boot_completed` intent. + +```python +from mpos import Service +from mpos.content.app_manager import AppManager + +class WifiBootService(Service): + + def onStart(self, intent): + import _thread + from mpos import WifiService, TaskManager + _thread.stack_size(TaskManager.good_stack_size()) + _thread.start_new_thread(WifiService.auto_connect, ()) + +# Register at module level +AppManager.register_service("boot_completed", WifiBootService, fullname="com.micropythonos.system") +``` + +Parameters for `register_service()`: + +- `action` (str) — Intent action string. Use `"boot_completed"` for boot-time services. +- `service_cls` (type) — The Service subclass. +- `fullname` (str, optional) — App fullname to associate with the service. Defaults to `None`. + +This pattern is the "boot service receiver" mechanism: any `Service` class registered for `boot_completed` will be instantiated and started once the system has finished launching the launcher. + +### 2. Manifest-Declared Services (App Services) + +Apps declare services in their `MANIFEST.JSON` under a `"services"` array: + +```json +{ + "name": "OSUpdate", + "fullname": "com.micropythonos.osupdate", + "version": "0.1.5", + "activities": [ + { + "entrypoint": "osupdate.py", + "classname": "OSUpdate", + "intent_filters": [ + { "action": "main", "category": "launcher" } + ] + } + ], + "services": [ + { + "entrypoint": "osupdate_boot_service.py", + "classname": "OSUpdateService", + "intent_filters": [ + { "action": "boot_completed" } + ] + } + ] +} +``` + +Each service entry requires: + +| Field | Description | +|-------|-------------| +| `entrypoint` | Path to the Python file (relative to the app root) | +| `classname` | Name of the Service subclass in that file | +| `intent_filters` | Array of `{ "action": "..." }` objects. `"boot_completed"` triggers the service at startup | + +The corresponding service code: + +```python +# osupdate_boot_service.py +from mpos import Service, ConnectivityManager, TaskManager + +class OSUpdateService(Service): + + def __init__(self): + super().__init__() + self._running = False + + def onStart(self, intent): + self._running = True + TaskManager.create_task(self._boot_loop()) + + def onDestroy(self): + self._running = False + + async def _boot_loop(self): + cm = ConnectivityManager.get() + while self._running: + await TaskManager.sleep(30) + if cm.is_online(): + print("OSUpdateService: network connected") + else: + print("OSUpdateService: network not connected") +``` + +## Boot Services + +Services that subscribe to the `boot_completed` intent are started automatically during system boot. This applies both to system services registered programmatically with `AppManager.register_service()` and to services declared in an app's `MANIFEST.JSON`. + +The current boot-time service set includes: + +| Service | App | Purpose | +|---------|-----|---------| +| `WifiBootService` | `com.micropythonos.system` | Auto-connects WiFi in a background thread | +| `WebServerBootService` | `com.micropythonos.system` | Starts the HTTP web server if enabled | +| `AIOReplService` | `com.micropythonos.system` | Starts the asyncio REPL task for interactive debugging | +| `OSUpdateService` | `com.micropythonos.osupdate` | Periodically checks for OTA updates | + +### Boot Order + +Services are started after the launcher is displayed, in this sequence: + +1. Launcher Activity is created and displayed +2. `auto_start_app_early()` runs (early-start apps) +3. `auto_start_app()` runs (normal auto-start apps) +4. **Boot services are started** — all `boot_completed` services are instantiated and `onStart()` is called +5. `TaskManager.start()` runs the asyncio event loop + +The order among boot services is non-deterministic. Each service is isolated: a failure in one service does not prevent other services from starting. + +## Complete Example + +### System Service + +```python +# my_service.py +from mpos import Service +from mpos.content.app_manager import AppManager + +class BootLogger(Service): + + def __init__(self): + super().__init__() + + def onCreate(self): + print(f"BootLogger: initialized from {self.appFullName}") + + def onStart(self, intent): + print(f"BootLogger: received intent action={intent.action}") + print("BootLogger: system boot is complete") + + def onDestroy(self): + print("BootLogger: shutting down") + +AppManager.register_service("boot_completed", BootLogger, fullname="com.example.myservice") +``` + +### App Service (manifest-declared) + +``` +com.example.myapp/ +├── MANIFEST.JSON # Contains "services" array +├── icon_64x64.png +├── main.py # Activity entry point +└── my_service.py # Service entry point +``` + +## Service vs. Activity + +| Aspect | Activity | Service | +|--------|----------|---------| +| UI | Has a screen with LVGL widgets | No UI | +| Lifecycle triggers | User navigation, back button | Intent actions (e.g., `boot_completed`) | +| Persistence | Exists only while in the activity stack | Can run indefinitely after boot | +| Foreground state | Tracked via `has_foreground()` | Not applicable | +| Thread usage | UI runs on main LVGL thread | May spawn threads or use asyncio tasks | +| Typical use | Interactive screens | Boot receivers, background work, notifications | + +## Best Practices + +### Do's + +✅ Use `self.appFullName` instead of hard-coding the package name +✅ Call `super().__init__()` in your `__init__` method +✅ Clean up threads and tasks in `onDestroy()` +✅ Use `TaskManager.create_task()` for async operations in services +✅ Keep `onCreate()` lightweight — do heavy work in `onStart()` + +### Don'ts + +❌ Don't create LVGL UI objects in a Service (no screen context) +❌ Don't block `onStart()` with synchronous waiting — use asyncio or threads +❌ Don't forget to stop background tasks when the service is destroyed +❌ Don't assume boot services run in a specific order + +## See Also + +- [AppManager](../frameworks/app-manager.md) — Service registration and boot management +- [App Lifecycle](../apps/app-lifecycle.md) — Activity lifecycle for UI apps +- [Boot Sequence](../architecture/boot-sequence.md) — Detailed boot flow +- [TaskManager](../frameworks/task-manager.md) — Async task management for services +- [NotificationManager](notification-manager.md) — Posting notifications from services +- [Creating Apps](../apps/creating-apps.md) — How to add services to your app's manifest diff --git a/docs/frameworks/setting-activity.md b/docs/frameworks/setting-activity.md new file mode 100644 index 0000000..d3ed6aa --- /dev/null +++ b/docs/frameworks/setting-activity.md @@ -0,0 +1,408 @@ +# SettingActivity + +`SettingActivity` is used to edit a single setting with various UI options. It provides a flexible framework for collecting user input with support for text input, radio buttons, dropdowns, sliders, and custom Activity implementations. + +## Overview + +`SettingActivity` is a thin wrapper around [`InputActivity`](input-activity.md). It passes the setting metadata and current value to `InputActivity`, waits for the user to save or cancel, then persists the returned value to `SharedPreferences` (unless `dont_persist` is set to true). It also updates the value label in `SettingsActivity` and fires `changed_callback` when the value changes. + +If you only need the input UI without persistence (for example, a WiFi password prompt), use [`InputActivity`](input-activity.md) directly. + +## Basic Usage + +```python +from mpos import Activity, Intent, SettingActivity, SharedPreferences + +class MyApp(Activity): + def onCreate(self): + self.prefs = SharedPreferences("com.example.myapp") + # ... create UI ... + + def open_setting(self): + intent = Intent(activity_class=SettingActivity) + intent.putExtra("prefs", self.prefs) + intent.putExtra("setting", { + "title": "Your Name", + "key": "user_name", + "placeholder": "Enter your name" + }) + self.startActivity(intent) +``` + +## Setting Dictionary Structure + +Each setting is defined as a dictionary with the following properties: + +### Required Properties +- **`title`** (string): Display name of the setting shown at the top +- **`key`** (string): Unique identifier used as the SharedPreferences key + +### Optional Properties +- **`ui`** (string): UI type to use for editing. Options: `"textarea"` (default), `"radiobuttons"`, `"dropdown"`, `"slider"`, `"activity"` +- **`ui_options`** (list): Options for `radiobuttons` and `dropdown` UI types +- **`placeholder`** (string): Placeholder text for textarea input +- **`default_value`** (string): Default value to select or fill in +- **`changed_callback`** (function): Callback function called when the setting value changes +- **`should_show`** (function): Function to determine if this setting should be displayed +- **`dont_persist`** (bool): If `True`, the setting won't be saved to SharedPreferences +- **`min`** (int): Minimum value for `"slider"` UI (default: `0`) +- **`max`** (int): Maximum value for `"slider"` UI (default: `100`) +- **`activity_class`** (class): Custom Activity class for `"activity"` UI type +- **`note`** (string): Optional short informational text shown below the input widget (useful for context, caveats, or privacy nudges) +- **`value_label`** (widget): Internal reference to the value label (set by SettingsActivity) +- **`cont`** (widget): Internal reference to the container (set by SettingsActivity) + +## UI Types + +### 1. Textarea (Default) + +Text input with an on-screen keyboard and optional QR code scanner. + +**When to use:** For text input like URLs, API keys, names, etc. + +**Example:** +```python +{ + "title": "API Key", + "key": "api_key", + "placeholder": "Enter your API key", + "ui": "textarea" # or omit this, textarea is default +} +``` + +**Features:** +- On-screen keyboard for text input +- QR code scanner button (for scanning data from QR codes) +- Placeholder text support +- Single-line input + +### 2. Radio Buttons + +Mutually exclusive selection from a list of options. + +**When to use:** For choosing one option from a small set of choices. + +**Format for `ui_options`:** +```python +ui_options = [ + ("Display Label 1", "value1"), + ("Display Label 2", "value2"), + ("Display Label 3", "value3") +] +``` + +**Example:** +```python +{ + "title": "Theme", + "key": "theme", + "ui": "radiobuttons", + "ui_options": [ + ("Light", "light"), + ("Dark", "dark"), + ("Auto", "auto") + ] +} +``` + +**Features:** +- Circular radio button indicators +- Only one option can be selected at a time +- Displays both label and value (if different) + +### 3. Dropdown + +Dropdown selection from a list of options. + +**When to use:** For choosing one option from a larger set of choices. + +**Format for `ui_options`:** +```python +ui_options = [ + ("Display Label 1", "value1"), + ("Display Label 2", "value2"), + ("Display Label 3", "value3") +] +``` + +**Example:** +```python +{ + "title": "Language", + "key": "language", + "ui": "dropdown", + "ui_options": [ + ("English", "en"), + ("German", "de"), + ("French", "fr"), + ("Spanish", "es") + ] +} +``` + +**Features:** +- Compact dropdown menu +- Shows label and value if different +- Automatically selects the current value + +### 4. Slider + +Slider widget for integer values with real-time value display. + +**When to use:** For numeric settings like volume, brightness, speed, or any integer within a range. + +**Keys:** +- **`min`** (int, default `0`): Minimum value (leftmost position) +- **`max`** (int, default `100`): Maximum value (rightmost position) + +**Example:** +```python +{ + "title": "Volume", + "key": "volume", + "ui": "slider", + "default_value": "50", + "min": 0, + "max": 100, + "changed_callback": self.on_volume_changed +} +``` + +**Features:** +- Large label showing the current value above the slider +- Real-time value updates as the user drags +- Automatic clamping to `[min, max]` range +- Value is persisted as a string (e.g. `"50"`) + +### 5. Custom Activity + +Use a custom Activity class for advanced UI implementations. + +**When to use:** For complex settings that need custom UI beyond the built-in options. + +**Example:** +```python +class ColorPickerActivity(Activity): + def onCreate(self): + # Custom color picker UI + pass + +setting = { + "title": "Pick a Color", + "key": "color", + "ui": "activity", + "activity_class": ColorPickerActivity +} +``` + +**Requirements:** +- Must provide `activity_class` parameter +- The custom Activity receives the setting and prefs via Intent extras +- Must call `self.finish()` to return to the previous screen + +## Adding an informational note + +Use the optional `note` field to display a short explanation below the input widget. This is useful for privacy nudges, hardware caveats, or any context that doesn't fit in the title or placeholder. + +**Example:** +```python +setting = { + "title": "Wallet Type", + "key": "wallet_type", + "ui": "radiobuttons", + "ui_options": [ + ("On-chain xpub", "xpub"), + ("Single address", "address") + ], + "note": "Tip: reusing one address erodes on-chain privacy." +} +``` + +The note is rendered as small, muted, wrapping text below the radio buttons, dropdown, slider, or textarea and above the Cancel/Save row. Settings without a `note` look identical to before. + +## Callbacks and Advanced Features + +### changed_callback + +Called when the setting value changes (after saving). Useful for triggering UI updates or reloading data. + +**Example:** +```python +def on_theme_changed(new_value): + print(f"Theme changed to: {new_value}") + # Reload UI with new theme + self.apply_theme(new_value) + +setting = { + "title": "Theme", + "key": "theme", + "ui": "radiobuttons", + "ui_options": [("Light", "light"), ("Dark", "dark")], + "changed_callback": on_theme_changed +} +``` + +**Important:** The callback is only called if the value actually changed (old value != new value). + +### should_show + +Function to conditionally show/hide a setting. Used primarily in SettingsActivity. + +**Example:** +```python +def should_show_advanced_options(setting): + prefs = SharedPreferences("com.example.app") + return prefs.get_string("mode") == "advanced" + +setting = { + "title": "Advanced Option", + "key": "advanced_setting", + "should_show": should_show_advanced_options +} +``` + +### dont_persist + +Prevent the setting from being saved to SharedPreferences. + +**Example:** +```python +setting = { + "title": "Temporary Value", + "key": "temp_value", + "dont_persist": True +} +``` + +## Complete Example: Simple Settings App + +Here's a minimal app that uses SettingActivity to edit a single setting: + +```python +import lvgl as lv +from mpos import Activity, Intent, SettingActivity, SharedPreferences + +class SimpleSettingsApp(Activity): + def onCreate(self): + self.prefs = SharedPreferences("com.example.simplesettings") + + # Create main screen + self.main_screen = lv.obj() + self.main_screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + + # Display current value + self.value_label = lv.label(self.main_screen) + self.update_value_label() + + # Settings button + settings_btn = lv.button(self.main_screen) + settings_btn.set_size(lv.pct(80), lv.SIZE_CONTENT) + settings_label = lv.label(settings_btn) + settings_label.set_text("Edit Setting") + settings_label.center() + settings_btn.add_event_cb(self.open_settings, lv.EVENT.CLICKED, None) + + self.setContentView(self.main_screen) + + def onResume(self, screen): + super().onResume(screen) + self.update_value_label() + + def update_value_label(self): + value = self.prefs.get_string("my_setting", "(not set)") + self.value_label.set_text(f"Current value: {value}") + + def open_settings(self, event): + intent = Intent(activity_class=SettingActivity) + intent.putExtra("prefs", self.prefs) + intent.putExtra("setting", { + "title": "My Setting", + "key": "my_setting", + "placeholder": "Enter a value", + "changed_callback": self.on_setting_changed + }) + self.startActivity(intent) + + def on_setting_changed(self, new_value): + print(f"Setting changed to: {new_value}") + self.update_value_label() +``` + +## Real-World Example: AppStore Backend Selection + +This example is inspired by the AppStore app, showing how to use SettingActivity with radio buttons and a callback: + +```python +import lvgl as lv +from mpos import Activity, Intent, SettingActivity, SharedPreferences + +class AppStore(Activity): + BACKENDS = [ + ("MPOS GitHub", "github,https://apps.micropythonos.com/app_index.json"), + ("BadgeHub Test", "badgehub,https://badgehub.p1m.nl/api/v3"), + ("BadgeHub Prod", "badgehub,https://badge.why2025.org/api/v3") + ] + + def onCreate(self): + self.prefs = SharedPreferences("com.micropythonos.appstore") + + self.main_screen = lv.obj() + self.main_screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + + # Settings button + settings_btn = lv.button(self.main_screen) + settings_btn.set_size(lv.pct(20), lv.pct(25)) + settings_btn.align(lv.ALIGN.TOP_RIGHT, 0, 0) + settings_label = lv.label(settings_btn) + settings_label.set_text(lv.SYMBOL.SETTINGS) + settings_label.center() + settings_btn.add_event_cb(self.open_backend_settings, lv.EVENT.CLICKED, None) + + self.setContentView(self.main_screen) + + def open_backend_settings(self, event): + intent = Intent(activity_class=SettingActivity) + intent.putExtra("prefs", self.prefs) + intent.putExtra("setting", { + "title": "AppStore Backend", + "key": "backend", + "ui": "radiobuttons", + "ui_options": self.BACKENDS, + "changed_callback": self.backend_changed + }) + self.startActivity(intent) + + def backend_changed(self, new_value): + print(f"Backend changed to {new_value}, refreshing app list...") + # Trigger app list refresh with new backend + self.refresh_app_list() + + def refresh_app_list(self): + # Implementation to refresh the app list + pass +``` + +## Tips and Best Practices + +1. **Load prefs once in onCreate()**: Load SharedPreferences in your main Activity's `onCreate()` and pass it to SettingActivity. This is faster than loading it multiple times. + +2. **Use changed_callback for side effects**: If changing a setting requires reloading data or updating the UI, use `changed_callback` instead of checking the value on resume. + +3. **Validate input**: For textarea inputs, consider validating the input in your `changed_callback` before using it. + +4. **Use radio buttons for small sets**: Use radio buttons for 2-5 options, dropdown for larger lists. + +5. **Provide meaningful placeholders**: Help users understand what format is expected with clear placeholder text. + +6. **Consider conditional visibility**: Use `should_show` in SettingsActivity to hide settings that don't apply based on other settings. + +## How it works + +When `SettingActivity` is launched, it: + +1. Reads the current value from `SharedPreferences`. +2. Starts [`InputActivity`](input-activity.md) with the setting metadata and current value. +3. Receives the new value when the user saves. +4. Persists the value, updates the settings row label, and calls `changed_callback`. + +The `SettingActivity` API visible to apps is unchanged; existing settings code continues to work exactly as before. diff --git a/docs/frameworks/settings-activity.md b/docs/frameworks/settings-activity.md new file mode 100644 index 0000000..12e39c8 --- /dev/null +++ b/docs/frameworks/settings-activity.md @@ -0,0 +1,400 @@ +# SettingsActivity + +`SettingsActivity` is used to display and edit a list of multiple settings. It provides a scrollable list interface where users can tap on each setting to edit it individually. Each setting can have different UI types and optional conditional visibility. + +## Overview + +`SettingsActivity` displays a list of settings that users can browse and edit. When a user taps on a setting, it opens a [`SettingActivity`](setting-activity.md) for editing that individual setting. `SettingActivity` internally uses [`InputActivity`](input-activity.md) for the actual input UI, then persists the returned value to `SharedPreferences` and updates the list. + +## Basic Usage + +```python +from mpos import Activity, Intent, SettingsActivity, SharedPreferences + +class MyApp(Activity): + def onCreate(self): + self.prefs = SharedPreferences("com.example.myapp") + # ... create UI ... + + def open_settings(self): + intent = Intent(activity_class=SettingsActivity) + intent.putExtra("prefs", self.prefs) + intent.putExtra("settings", [ + {"title": "Your Name", "key": "user_name", "placeholder": "Enter your name"}, + {"title": "Theme", "key": "theme", "ui": "radiobuttons", "ui_options": [("Light", "light"), ("Dark", "dark")]}, + ]) + self.startActivity(intent) +``` + +## Settings List Structure + +`SettingsActivity` expects a list of setting dictionaries. Each setting dictionary has the same structure as in [`SettingActivity`](setting-activity.md): + +### Required Properties +- **`title`** (string): Display name of the setting shown in the list +- **`key`** (string): Unique identifier used as the SharedPreferences key + +### Optional Properties +- **`ui`** (string): UI type for editing. Options: `"textarea"` (default), `"radiobuttons"`, `"dropdown"`, `"slider"`, `"activity"` +- **`ui_options`** (list): Options for `radiobuttons` and `dropdown` UI types +- **`placeholder`** (string): Placeholder text for textarea input +- **`changed_callback`** (function): Callback function called when the setting value changes +- **`should_show`** (function): Boolean or function to determine if this setting should be displayed in the list +- **`dont_persist`** (bool): If `True`, the setting won't be saved to SharedPreferences +- **`min`** (int): Minimum value for `"slider"` UI (default: `0`) +- **`max`** (int): Maximum value for `"slider"` UI (default: `100`) +- **`activity_class`** (class): Custom Activity class for `"activity"` UI type +- **`note`** (string): Optional short informational text shown when editing the individual setting (see [SettingActivity](setting-activity.md#adding-an-informational-note)) +- **`value_label`** (widget): Internal reference to the value label (set by SettingsActivity) +- **`cont`** (widget): Internal reference to the container (set by SettingsActivity) + +## UI Types + +All UI types available in [`SettingActivity`](setting-activity.md) are supported: + +### 1. Textarea (Default) + +Text input with on-screen keyboard and optional QR code scanner. + +```python +{ + "title": "API Key", + "key": "api_key", + "placeholder": "Enter your API key" +} +``` + +### 2. Radio Buttons + +Mutually exclusive selection from a list of options. + +```python +{ + "title": "Wallet Type", + "key": "wallet_type", + "ui": "radiobuttons", + "ui_options": [ + ("LNBits", "lnbits"), + ("Nostr Wallet Connect", "nwc") + ] +} +``` + +### 3. Dropdown + +Dropdown selection from a list of options. + +```python +{ + "title": "Language", + "key": "language", + "ui": "dropdown", + "ui_options": [ + ("English", "en"), + ("German", "de"), + ("French", "fr") + ] +} +``` + +### 4. Slider + +Slider widget for integer values with real-time value display. + +```python +{ + "title": "Volume", + "key": "volume", + "ui": "slider", + "default_value": "50", + "min": 0, + "max": 100, + "changed_callback": self.on_volume_changed +} +``` + +**Keys:** +- **`min`** (int, default `0`): Minimum value +- **`max`** (int, default `100`): Maximum value + +See [`SettingActivity`](setting-activity.md) for full details. + +### 5. Custom Activity + +Use a custom Activity class for advanced UI implementations. + +```python +{ + "title": "Pick a Color", + "key": "color", + "ui": "activity", + "activity_class": ColorPickerActivity +} +``` + +## Advanced Features + +### Conditional Visibility with should_show + +The `should_show` boolean or callable function allows you to conditionally display settings based on other settings or app state. + +**Function signature:** +```python +def should_show(setting): + # Return True to show, False to hide + return condition +``` + +**Example:** +```python +def should_show_lnbits_settings(setting): + prefs = SharedPreferences("com.example.wallet") + wallet_type = prefs.get_string("wallet_type") + return wallet_type == "lnbits" + +settings = [ + {"title": "Wallet Type", "key": "wallet_type", "ui": "radiobuttons", + "ui_options": [("LNBits", "lnbits"), ("NWC", "nwc")]}, + {"title": "LNBits URL", "key": "lnbits_url", "placeholder": "https://...", + "should_show": should_show_lnbits_settings}, + {"title": "LNBits Read Key", "key": "lnbits_readkey", + "should_show": should_show_lnbits_settings}, +] +``` + +### Callbacks on Value Change + +The `changed_callback` is called when a setting value changes. Useful for triggering UI updates or reloading data. + +```python +def on_wallet_type_changed(new_value): + print(f"Wallet type changed to: {new_value}") + # Reconnect to new wallet backend + reconnect_wallet(new_value) + +settings = [ + {"title": "Wallet Type", "key": "wallet_type", "ui": "radiobuttons", + "ui_options": [("LNBits", "lnbits"), ("NWC", "nwc")], + "changed_callback": on_wallet_type_changed}, +] +``` + +### Preventing Persistence + +Use `dont_persist` to prevent a setting from being saved to SharedPreferences. + +```python +{ + "title": "Temporary Value", + "key": "temp_value", + "dont_persist": True +} +``` + +## Complete Example: Simple Settings List + +Here's a minimal app that uses SettingsActivity to edit multiple settings: + +```python +import lvgl as lv +from mpos import Activity, Intent, SettingsActivity, SharedPreferences + +class SimpleSettingsApp(Activity): + def onCreate(self): + self.prefs = SharedPreferences("com.example.simplesettings") + + # Create main screen + self.main_screen = lv.obj() + self.main_screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + + # Settings button + settings_btn = lv.button(self.main_screen) + settings_btn.set_size(lv.pct(80), lv.SIZE_CONTENT) + settings_label = lv.label(settings_btn) + settings_label.set_text("Open Settings") + settings_label.center() + settings_btn.add_event_cb(self.open_settings, lv.EVENT.CLICKED, None) + + self.setContentView(self.main_screen) + + def open_settings(self, event): + intent = Intent(activity_class=SettingsActivity) + intent.putExtra("prefs", self.prefs) + intent.putExtra("settings", [ + { + "title": "Your Name", + "key": "user_name", + "placeholder": "Enter your name" + }, + { + "title": "Theme", + "key": "theme", + "ui": "radiobuttons", + "ui_options": [ + ("Light", "light"), + ("Dark", "dark") + ] + }, + { + "title": "Language", + "key": "language", + "ui": "dropdown", + "ui_options": [ + ("English", "en"), + ("German", "de"), + ("French", "fr") + ] + } + ]) + self.startActivity(intent) +``` + +## Real-World Example: Lightning Piggy Wallet + +This example is inspired by the Lightning Piggy DisplayWallet app, showing how to use SettingsActivity with conditional visibility and callbacks: + +```python +import lvgl as lv +from mpos import Activity, Intent, SettingsActivity, SharedPreferences + +class DisplayWallet(Activity): + def onCreate(self): + self.prefs = SharedPreferences("com.lightningpiggy.displaywallet") + + # Create main screen + self.main_screen = lv.obj() + + # Settings button + settings_button = lv.button(self.main_screen) + settings_button.set_size(lv.pct(20), lv.pct(25)) + settings_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) + settings_button.add_event_cb(self.settings_button_tap, lv.EVENT.CLICKED, None) + settings_label = lv.label(settings_button) + settings_label.set_text(lv.SYMBOL.SETTINGS) + settings_label.center() + + self.setContentView(self.main_screen) + + def should_show_lnbits_settings(self, setting): + """Only show LNBits settings if LNBits is selected""" + wallet_type = self.prefs.get_string("wallet_type") + return wallet_type == "lnbits" + + def should_show_nwc_settings(self, setting): + """Only show NWC settings if NWC is selected""" + wallet_type = self.prefs.get_string("wallet_type") + return wallet_type == "nwc" + + def on_wallet_type_changed(self, new_value): + """Called when wallet type changes""" + print(f"Wallet type changed to: {new_value}") + # Reconnect to new wallet backend + self.reconnect_wallet(new_value) + + def settings_button_tap(self, event): + intent = Intent(activity_class=SettingsActivity) + intent.putExtra("prefs", self.prefs) + intent.putExtra("settings", [ + { + "title": "Wallet Type", + "key": "wallet_type", + "ui": "radiobuttons", + "ui_options": [ + ("LNBits", "lnbits"), + ("Nostr Wallet Connect", "nwc") + ], + "changed_callback": self.on_wallet_type_changed + }, + { + "title": "LNBits URL", + "key": "lnbits_url", + "placeholder": "https://demo.lnpiggy.com", + "should_show": self.should_show_lnbits_settings + }, + { + "title": "LNBits Read Key", + "key": "lnbits_readkey", + "placeholder": "fd92e3f8168ba314dc22e54182784045", + "should_show": self.should_show_lnbits_settings + }, + { + "title": "Optional LN Address", + "key": "lnbits_static_receive_code", + "placeholder": "Will be fetched if empty.", + "should_show": self.should_show_lnbits_settings + }, + { + "title": "Nostr Wallet Connect", + "key": "nwc_url", + "placeholder": "nostr+walletconnect://69effe7b...", + "should_show": self.should_show_nwc_settings + }, + { + "title": "Optional LN Address", + "key": "nwc_static_receive_code", + "placeholder": "Optional if present in NWC URL.", + "should_show": self.should_show_nwc_settings + } + ]) + self.startActivity(intent) + + def reconnect_wallet(self, wallet_type): + """Reconnect to the selected wallet backend""" + if wallet_type == "lnbits": + url = self.prefs.get_string("lnbits_url") + readkey = self.prefs.get_string("lnbits_readkey") + # Initialize LNBits wallet + print(f"Connecting to LNBits at {url}") + elif wallet_type == "nwc": + nwc_url = self.prefs.get_string("nwc_url") + # Initialize NWC wallet + print(f"Connecting to NWC at {nwc_url}") +``` + +## UI Layout + +SettingsActivity displays settings in a scrollable list with the following layout for each setting: + +``` +┌─────────────────────────────────┐ +│ Setting Title │ +│ Current Value (smaller text) │ +└─────────────────────────────────┘ +``` + +- **Title**: Bold, larger font (montserrat_16) +- **Value**: Smaller font (montserrat_12), gray color +- **Container**: Full width, clickable, with border on focus +- **Scrollable**: Settings list scrolls if it exceeds screen height + +## Tips and Best Practices + +1. **Load prefs once in onCreate()**: Load SharedPreferences in your main Activity's `onCreate()` and pass it to SettingsActivity. This is faster than loading it multiple times. + +2. **Use should_show for conditional settings**: Hide settings that don't apply based on other settings using the `should_show` boolean/function. This keeps the UI clean and prevents confusion. + +3. **Use changed_callback for side effects**: If changing a setting requires reloading data or updating the UI, use `changed_callback` instead of checking the value on resume. + +4. **Group related settings**: Organize settings logically in the list so related options are near each other. + +5. **Provide meaningful placeholders**: Help users understand what format is expected with clear placeholder text. + +6. **Use radio buttons for small sets**: Use radio buttons for 2-5 options, dropdown for larger lists. + +7. **Consider performance**: If you have many settings with complex `should_show` functions, the list rebuild might be slow. Optimize your condition checks. + +## Comparison with SettingActivity + +| Feature | SettingActivity | SettingsActivity | +|---------|-----------------|------------------| +| **Purpose** | Edit a single setting | Edit multiple settings | +| **UI** | Full-screen editor | Scrollable list | +| **Use case** | Detailed editing of one setting | Browse and edit multiple settings | +| **Callbacks** | `changed_callback` | `changed_callback` + `should_show` | +| **Best for** | Settings that need focus | Quick access to many settings | + +## See Also + +- [`SettingActivity`](setting-activity.md) - For editing a single setting +- [`InputActivity`](input-activity.md) - Generic input UI used by `SettingActivity` +- [`SharedPreferences`](preferences.md) - For persisting settings diff --git a/docs/frameworks/task-manager.md b/docs/frameworks/task-manager.md new file mode 100644 index 0000000..f6b8eb0 --- /dev/null +++ b/docs/frameworks/task-manager.md @@ -0,0 +1,490 @@ +# TaskManager + +MicroPythonOS provides a centralized task management service called **TaskManager** that wraps MicroPython's `uasyncio` for managing asynchronous operations. It enables apps to run background tasks, schedule delayed operations, and coordinate concurrent activities. + +## Overview + +TaskManager provides: + +- **Simplified async interface** - Easy wrappers around uasyncio primitives +- **Task creation** - Launch background coroutines without boilerplate +- **Sleep operations** - Async delays in seconds or milliseconds +- **Timeouts** - Wait for operations with automatic timeout handling +- **Event notifications** - Simple async event signaling +- **Centralized management** - Single point of control for all async operations + +## Quick Start + +### Creating Background Tasks + +```python +from mpos import Activity, TaskManager + +class MyActivity(Activity): + def onCreate(self): + # Launch a background task + TaskManager.create_task(self.download_data()) + + async def download_data(self): + print("Starting download...") + # Simulate network operation + await TaskManager.sleep(2) + print("Download complete!") +``` + +**Key points:** +- Use `TaskManager.create_task()` to launch coroutines +- Tasks run concurrently with UI operations +- Tasks continue until completion or app termination + +### Delayed Operations + +```python +async def delayed_operation(self): + # Wait 3 seconds + await TaskManager.sleep(3) + print("3 seconds later...") + + # Wait 500 milliseconds + await TaskManager.sleep_ms(500) + print("Half a second later...") +``` + +**Available sleep methods:** +- `sleep(seconds)` - Sleep for specified seconds (float) +- `sleep_ms(milliseconds)` - Sleep for specified milliseconds (int) + +### Timeout Operations + +```python +from mpos import TaskManager, DownloadManager + +async def download_with_timeout(self): + try: + # Wait max 10 seconds for download + data = await TaskManager.wait_for( + DownloadManager.download_url("https://example.com/data.json"), + timeout=10 + ) + print(f"Downloaded {len(data)} bytes") + except Exception as e: + print(f"Download timed out or failed: {e}") +``` + +**Timeout behavior:** +- If operation completes within timeout: returns result +- If operation exceeds timeout: raises `asyncio.TimeoutError` +- Timeout is in seconds (float) + +### Event Notifications + +```python +async def wait_for_event(self): + # Create an event + event = await TaskManager.notify_event() + + # Wait for the event to be signaled + await event.wait() + print("Event occurred!") +``` + +## Common Patterns + +### Downloading Data + +```python +from mpos import TaskManager, DownloadManager + +class AppStoreActivity(Activity): + def onResume(self, screen): + super().onResume(screen) + # Download app index in background + TaskManager.create_task(self.download_app_index()) + + async def download_app_index(self): + try: + data = await DownloadManager.download_url( + "https://apps.micropythonos.com/app_index.json" + ) + if data: + parsed = json.loads(data) + self.update_ui(parsed) + except Exception as e: + print(f"Download failed: {e}") +``` + +### Periodic Tasks + +```python +async def monitor_sensor(self): + """Check sensor every second""" + while self.monitoring: + value = sensor_manager.read_accelerometer() + self.update_display(value) + await TaskManager.sleep(1) + +def onCreate(self): + self.monitoring = True + TaskManager.create_task(self.monitor_sensor()) + +def onDestroy(self, screen): + self.monitoring = False # Stop monitoring loop +``` + +### Download with Progress + +```python +async def download_large_file(self): + progress_label = lv.label(self.screen) + + async def update_progress(percent): + progress_label.set_text(f"Downloading: {percent}%") + + success = await DownloadManager.download_url( + "https://example.com/large.bin", + outfile="/sdcard/large.bin", + progress_callback=update_progress + ) + + if success: + progress_label.set_text("Download complete!") +``` + +### Concurrent Downloads + +```python +async def download_multiple_icons(self, apps): + """Download multiple icons concurrently""" + tasks = [] + for app in apps: + task = DownloadManager.download_url(app.icon_url) + tasks.append(task) + + # Wait for all downloads (with timeout per icon) + results = [] + for i, task in enumerate(tasks): + try: + data = await TaskManager.wait_for(task, timeout=5) + results.append(data) + except Exception as e: + print(f"Icon {i} failed: {e}") + results.append(None) + + return results +``` + +## API Reference + +### Task Creation + +#### `TaskManager.create_task(coroutine)` + +Create and schedule a background task. + +**Parameters:** +- `coroutine` - Coroutine object to execute (must be async def) + +**Returns:** +- Task object (usually not needed) + +**Example:** +```python +TaskManager.create_task(self.background_work()) +``` + +### Sleep Operations + +#### `TaskManager.sleep(seconds)` + +Async sleep for specified seconds. + +**Parameters:** +- `seconds` (float) - Time to sleep in seconds + +**Returns:** +- None (awaitable) + +**Example:** +```python +await TaskManager.sleep(2.5) # Sleep 2.5 seconds +``` + +#### `TaskManager.sleep_ms(milliseconds)` + +Async sleep for specified milliseconds. + +**Parameters:** +- `milliseconds` (int) - Time to sleep in milliseconds + +**Returns:** +- None (awaitable) + +**Example:** +```python +await TaskManager.sleep_ms(500) # Sleep 500ms +``` + +### Timeout Operations + +#### `TaskManager.wait_for(awaitable, timeout)` + +Wait for an operation with timeout. + +**Parameters:** +- `awaitable` - Coroutine or awaitable object +- `timeout` (float) - Maximum time to wait in seconds + +**Returns:** +- Result of the awaitable if completed in time + +**Raises:** +- `asyncio.TimeoutError` - If operation exceeds timeout + +**Example:** +```python +try: + result = await TaskManager.wait_for( + download_operation(), + timeout=10 + ) +except asyncio.TimeoutError: + print("Operation timed out") +``` + +### Event Notifications + +#### `TaskManager.notify_event()` + +Create an async event for coordination. + +**Returns:** +- asyncio.Event object + +**Example:** +```python +event = await TaskManager.notify_event() +await event.wait() # Wait for event +event.set() # Signal event +``` + +## Integration with UI + +### Safe UI Updates from Async Tasks + +LVGL operations must run on the main thread. Use UI operations sparingly from async tasks: + +```python +async def background_task(self): + # Do async work + data = await fetch_data() + + # Update UI (safe - LVGL thread-safe) + self.label.set_text(f"Got {len(data)} items") +``` + +For complex UI updates, consider using callbacks or state variables: + +```python +async def download_task(self): + self.download_complete = False + data = await DownloadManager.download_url(url) + self.data = data + self.download_complete = True + +def onCreate(self): + TaskManager.create_task(self.download_task()) + # Check download_complete flag periodically +``` + +### Activity Lifecycle + +Tasks continue running even when activity is paused. Clean up tasks in `onDestroy()`: + +```python +def onCreate(self): + self.running = True + TaskManager.create_task(self.monitor_loop()) + +async def monitor_loop(self): + while self.running: + await self.check_status() + await TaskManager.sleep(1) + +def onDestroy(self, screen): + self.running = False # Stop task loop +``` + +## Common Use Cases + +### Network Operations + +```python +# Download JSON data +data = await DownloadManager.download_url("https://api.example.com/data") +parsed = json.loads(data) + +# Download to file +success = await DownloadManager.download_url( + "https://example.com/file.bin", + outfile="/sdcard/file.bin" +) + +# Stream processing +async def process_chunk(chunk): + # Process each chunk as it arrives + pass + +await DownloadManager.download_url( + "https://example.com/stream", + chunk_callback=process_chunk +) +``` + +### WebSocket Communication + +```python +from mpos import TaskManager +import websocket + +async def websocket_listener(self): + ws = websocket.WebSocketApp(url, callbacks) + await ws.run_forever() + +def onCreate(self): + TaskManager.create_task(self.websocket_listener()) +``` + +### Sensor Polling + +```python +from mpos import SensorManager, TaskManager + +async def poll_sensors(self): + while self.active: + accel_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + gyro_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + accel = SensorManager.read_sensor(accel_sensor) + gyro = SensorManager.read_sensor(gyro_sensor) + + self.update_display(accel, gyro) + await TaskManager.sleep(0.1) # 100ms = 10 Hz +``` + +## Performance Considerations + +### Task Overhead + +- **Minimal overhead**: TaskManager is a thin wrapper around uasyncio +- **Concurrent tasks**: Dozens of tasks can run simultaneously +- **Memory**: Each task uses ~1-2KB of RAM +- **Context switches**: Tasks yield automatically during await + +### Best Practices + +1. **Use async for I/O**: Network, file operations, sleep +2. **Avoid blocking**: Don't use time.sleep() in async functions +3. **Clean up tasks**: Set flags to stop loops in onDestroy() +4. **Handle exceptions**: Always wrap operations in try/except +5. **Limit concurrency**: Don't create unbounded task lists + +### Example: Bounded Concurrency + +```python +async def download_with_limit(self, urls, max_concurrent=5): + """Download URLs with max concurrent downloads""" + results = [] + for i in range(0, len(urls), max_concurrent): + batch = urls[i:i+max_concurrent] + batch_results = [] + + for url in batch: + task = DownloadManager.download_url(url) + try: + data = await TaskManager.wait_for(task, timeout=10) + batch_results.append(data) + except Exception as e: + batch_results.append(None) + + results.extend(batch_results) + + return results +``` + +## Troubleshooting + +### Task Never Completes + +**Problem:** Task hangs indefinitely + +**Solution:** Add timeout: +```python +try: + result = await TaskManager.wait_for(task, timeout=30) +except asyncio.TimeoutError: + print("Task timed out") +``` + +### Memory Leak + +**Problem:** Memory usage grows over time + +**Solution:** Ensure task loops exit: +```python +async def loop_task(self): + while self.running: # Check flag + await work() + await TaskManager.sleep(1) + +def onDestroy(self, screen): + self.running = False # Stop loop +``` + +### UI Not Updating + +**Problem:** UI doesn't reflect async task results + +**Solution:** Ensure UI updates happen on main thread (LVGL is thread-safe in MicroPythonOS): +```python +async def task(self): + data = await fetch() + # Direct UI update is safe + self.label.set_text(str(data)) +``` + +### Exception Not Caught + +**Problem:** Exception crashes app + +**Solution:** Wrap task in try/except: +```python +TaskManager.create_task(self.safe_task()) + +async def safe_task(self): + try: + await risky_operation() + except Exception as e: + print(f"Task failed: {e}") +``` + +## Implementation Details + +**Location**: `/home/user/MicroPythonOS/internal_filesystem/lib/mpos/task_manager.py` + +**Pattern**: Wrapper around `uasyncio` module + +**Key features:** +- `create_task()` - Wraps `asyncio.create_task()` +- `sleep()` / `sleep_ms()` - Wrap `asyncio.sleep()` +- `wait_for()` - Wraps `asyncio.wait_for()` with timeout handling +- `notify_event()` - Creates `asyncio.Event()` objects + +**Thread model:** +- All async tasks run on main asyncio event loop +- No separate threads created (unless using `_thread` module separately) +- Tasks cooperatively multitask via await points + +## See Also + +- [DownloadManager](download-manager.md) - HTTP download utilities +- [AudioManager](audiomanager.md) - Audio playback (uses TaskManager internally) +- [SensorManager](sensor-manager.md) - Sensor data access diff --git a/docs/frameworks/time-zone.md b/docs/frameworks/time-zone.md new file mode 100644 index 0000000..820e568 --- /dev/null +++ b/docs/frameworks/time-zone.md @@ -0,0 +1,221 @@ +# TimeZone + +TimeZone is a utility class that provides timezone conversion and management functionality. It allows apps to convert between timezone names and POSIX timezone strings, and access the complete list of supported timezones. + +## Overview + +TimeZone centralizes timezone-related operations in a single class with static methods. This provides: + +- **Timezone Conversion** - Convert timezone names to POSIX timezone strings +- **Timezone Listing** - Access all available timezones +- **Simple API** - Static methods for easy access without instantiation +- **Comprehensive Support** - Supports all major world timezones + +## Architecture + +TimeZone is implemented as a utility class using static methods: + +```python +class TimeZone: + @staticmethod + def timezone_to_posix_time_zone(timezone): + """Convert timezone name to POSIX timezone string.""" + # Implementation + + @staticmethod + def get_timezones(): + """Get list of all available timezone names.""" + # Implementation +``` + +No instance creation is needed - all methods are static and operate independently. + +## Usage + +### Basic Timezone Conversion + +```python +from mpos import TimeZone + +# Convert timezone name to POSIX string +posix_tz = TimeZone.timezone_to_posix_time_zone('Europe/Berlin') +# Returns: 'CET-1CEST,M3.5.0,M10.5.0' + +# Handle unknown timezone +posix_tz = TimeZone.timezone_to_posix_time_zone('Invalid/Zone') +# Returns: 'GMT0' (default fallback) +``` + +### Getting Available Timezones + +```python +from mpos import TimeZone + +# Get sorted list of all available timezones +timezones = TimeZone.get_timezones() +# Returns: ['Africa/Abidjan', 'Africa/Accra', ..., 'UTC', ...] + +# Use in a timezone selector +for tz in timezones: + print(tz) +``` + +### Handling None Values + +```python +from mpos import TimeZone + +# None timezone defaults to GMT0 +posix_tz = TimeZone.timezone_to_posix_time_zone(None) +# Returns: 'GMT0' +``` + +## API Reference + +### Static Methods + +#### `timezone_to_posix_time_zone(timezone)` +Convert a timezone name to its POSIX timezone string. + +**Parameters:** +- `timezone` (str or None): Timezone name (e.g., 'Africa/Abidjan', 'Europe/Berlin') or None + +**Returns:** str - POSIX timezone string (e.g., 'GMT0', 'CET-1CEST,M3.5.0,M10.5.0'). Returns 'GMT0' if timezone is None or not found. + +**Example:** +```python +posix_tz = TimeZone.timezone_to_posix_time_zone('Europe/Berlin') +# Returns: 'CET-1CEST,M3.5.0,M10.5.0' +``` + +#### `get_timezones()` +Get a sorted list of all available timezone names. + +**Returns:** list - Sorted list of timezone names (e.g., ['Africa/Abidjan', 'Africa/Accra', ...]) + +**Example:** +```python +all_timezones = TimeZone.get_timezones() +print(f"Available timezones: {len(all_timezones)}") +``` + +## Practical Examples + +### Settings Screen with Timezone Selector + +```python +from mpos import Activity, TimeZone +import lvgl as lv + +class SettingsActivity(Activity): + def onCreate(self): + screen = lv.obj() + + # Create dropdown with all timezones + dropdown = lv.dropdown(screen) + dropdown.set_options('\n'.join(TimeZone.get_timezones())) + dropdown.set_width(300) + + self.setContentView(screen) +``` + +### Timezone Validation + +```python +from mpos import TimeZone + +def validate_timezone(tz_name): + """Check if a timezone is valid.""" + available = TimeZone.get_timezones() + return tz_name in available + +# Usage +if validate_timezone('Europe/Berlin'): + posix_tz = TimeZone.timezone_to_posix_time_zone('Europe/Berlin') +else: + posix_tz = TimeZone.timezone_to_posix_time_zone(None) # Fallback to GMT0 +``` + +### Timezone Search + +```python +from mpos import TimeZone + +def find_timezones(region): + """Find all timezones in a region.""" + all_tz = TimeZone.get_timezones() + return [tz for tz in all_tz if tz.startswith(region)] + +# Usage +european_timezones = find_timezones('Europe') +# Returns: ['Europe/Amsterdam', 'Europe/Andorra', ..., 'Europe/Zurich'] +``` + +### Safe Timezone Conversion + +```python +from mpos import TimeZone + +def get_posix_timezone(tz_name): + """Get POSIX timezone with validation.""" + available = TimeZone.get_timezones() + + if tz_name not in available: + print(f"Unknown timezone: {tz_name}, using GMT0") + return TimeZone.timezone_to_posix_time_zone(None) + + return TimeZone.timezone_to_posix_time_zone(tz_name) +``` + +## Implementation Details + +### File Structure + +``` +mpos/ +├── time_zone.py # Core TimeZone class +├── time_zones.py # TIME_ZONE_MAP constant +└── __init__.py # Exports TimeZone +``` + +### Timezone Map + +The `TIME_ZONE_MAP` is a dictionary mapping timezone names to POSIX timezone strings: + +```python +TIME_ZONE_MAP = { + 'Africa/Abidjan': 'GMT0', + 'Africa/Accra': 'GMT0', + 'Europe/Berlin': 'CET-1CEST,M3.5.0,M10.5.0', + 'Europe/London': 'GMT0BST,M3.5.0/1,M10.5.0', + # ... many more timezones +} +``` + +## Design Patterns + +### Static Method Utility Class + +TimeZone uses static methods for a simple, functional API: + +```python +class TimeZone: + @staticmethod + def timezone_to_posix_time_zone(timezone): + # No instance state needed + return TIME_ZONE_MAP.get(timezone, "GMT0") +``` + +This pattern is ideal for stateless utility functions that don't require instance data. + +## Related Frameworks + +- **[DeviceInfo](device-info.md)** - Device hardware information +- **[BuildInfo](build-info.md)** - OS version and build information +- **[Preferences](preferences.md)** - Persistent user preferences (for storing timezone selection) + +## See Also + +- [Architecture Overview](../architecture/overview.md) +- [Frameworks](../architecture/frameworks.md) +- [Creating Apps](../apps/creating-apps.md) diff --git a/docs/frameworks/webserver.md b/docs/frameworks/webserver.md new file mode 100644 index 0000000..65fb37a --- /dev/null +++ b/docs/frameworks/webserver.md @@ -0,0 +1,73 @@ +# WebServer + +MicroPythonOS provides a **WebServer** helper that wraps WebREPL-based HTTP serving with saved settings. It exposes a simple class API for starting/stopping the service and inspecting configuration. + +## Overview + +WebServer provides: + +- **Saved settings** - Port, password, and autostart are stored in SharedPreferences +- **Simple control API** - Start/stop/status helpers +- **WebREPL HTTP bridge** - Uses the WebREPL HTTP accept handler + +## Quick Start + +```python +from mpos import WebServer + +# Start server using saved settings +WebServer.start() + +# Check status +print(WebServer.status()) + +# Stop server +WebServer.stop() +``` + +## Settings + +Settings live in SharedPreferences under `com.micropythonos.settings.webserver`: + +- `autostart` (string: "True"/"False") +- `port` (string: "7890") +- `password` (string, max 9 chars) + +## API Reference + +### `WebServer.status()` + +Return a dict with: + +- `state` ("started" or "stopped") +- `started` (bool) +- `port` (int) +- `password` (string) +- `autostart` (bool) +- `last_error` (exception or None) + +### `WebServer.start()` + +Load settings and start the WebREPL HTTP server. + +### `WebServer.stop()` + +Stop the server if running. + +### `WebServer.apply_settings(restart_if_running=True)` + +Reload settings and restart the server if it was already running. + +### `WebServer.auto_start()` + +Start the server only if `autostart` is enabled in preferences. + +## Implementation Details + +- **Location:** `MicroPythonOS/internal_filesystem/lib/mpos/webserver/webserver.py` +- **Exports:** `from mpos import WebServer` + +## See Also + +- [WifiService](wifi-service.md) - Network connectivity +- [ConnectivityManager](connectivity-manager.md) - Connectivity state diff --git a/docs/frameworks/widget-animator.md b/docs/frameworks/widget-animator.md new file mode 100644 index 0000000..3459435 --- /dev/null +++ b/docs/frameworks/widget-animator.md @@ -0,0 +1,564 @@ +# WidgetAnimator + +MicroPythonOS provides a **WidgetAnimator** utility for creating smooth, non-blocking animations on LVGL widgets. It handles common animation patterns like fade-in/fade-out, slide transitions, and value interpolation with automatic cleanup and error handling. + +## Overview + +WidgetAnimator provides: + +- **Fade animations** - Smooth opacity transitions (fade-in/fade-out) +- **Slide animations** - Smooth position transitions (slide-up/slide-down) +- **Value interpolation** - Animate numeric values with custom display callbacks +- **Non-blocking** - Animations run asynchronously without blocking the UI +- **Safe widget access** - Automatically handles deleted widgets without crashes +- **Automatic cleanup** - Stops previous animations to prevent visual glitches +- **Easing functions** - Built-in ease-in-out path for smooth motion + +## Quick Start + +### Fade In/Out + +```python +from mpos import WidgetAnimator +import lvgl as lv + +# Create a widget +my_widget = lv.label(screen) +my_widget.set_text("Hello") + +# Fade in (500ms) +WidgetAnimator.show_widget(my_widget, anim_type="fade", duration=500) + +# Fade out (500ms) +WidgetAnimator.hide_widget(my_widget, anim_type="fade", duration=500) +``` + +### Convenience Methods + +For the most common use case (fade animations), use the convenience methods: + +```python +from mpos import WidgetAnimator + +# Fade in with default 500ms duration +WidgetAnimator.smooth_show(my_widget) + +# Fade out with default 500ms duration +WidgetAnimator.smooth_hide(my_widget) + +# Fade out without hiding the widget (just opacity) +WidgetAnimator.smooth_hide(my_widget, hide=False) +``` + +### Slide Animations + +```python +from mpos import WidgetAnimator + +# Slide down from above +WidgetAnimator.show_widget(my_widget, anim_type="slide_down", duration=500) + +# Slide up from below +WidgetAnimator.show_widget(my_widget, anim_type="slide_up", duration=500) + +# Hide by sliding down +WidgetAnimator.hide_widget(my_widget, anim_type="slide_down", duration=500) + +# Hide by sliding up +WidgetAnimator.hide_widget(my_widget, anim_type="slide_up", duration=500) +``` + +### Value Interpolation + +Animate numeric values with a custom display callback: + +```python +from mpos import WidgetAnimator + +# Animate balance from 1000 to 1500 over 5 seconds +def display_balance(value): + balance_label.set_text(f"{int(value)} sats") + +WidgetAnimator.change_widget( + balance_label, + anim_type="interpolate", + duration=5000, + begin_value=1000, + end_value=1500, + display_change=display_balance +) +``` + +## API Reference + +### show_widget() + +Show a widget with an animation. + +```python +WidgetAnimator.show_widget(widget, anim_type="fade", duration=500, delay=0) +``` + +**Parameters:** +- `widget` (lv.obj): The widget to show +- `anim_type` (str): Animation type - `"fade"`, `"slide_down"`, or `"slide_up"` (default: `"fade"`) +- `duration` (int): Animation duration in milliseconds (default: 500) +- `delay` (int): Animation delay in milliseconds (default: 0) + +**Returns:** The animation object + +**Behavior:** +- Removes the `HIDDEN` flag at the start of animation +- Animates opacity (fade) or position (slide) +- Resets final state after animation completes + +### hide_widget() + +Hide a widget with an animation. + +```python +WidgetAnimator.hide_widget(widget, anim_type="fade", duration=500, delay=0, hide=True) +``` + +**Parameters:** +- `widget` (lv.obj): The widget to hide +- `anim_type` (str): Animation type - `"fade"`, `"slide_down"`, or `"slide_up"` (default: `"fade"`) +- `duration` (int): Animation duration in milliseconds (default: 500) +- `delay` (int): Animation delay in milliseconds (default: 0) +- `hide` (bool): If `True`, adds `HIDDEN` flag after animation. If `False`, only animates opacity/position (default: `True`) + +**Returns:** The animation object + +**Behavior:** +- Animates opacity (fade) or position (slide) +- Adds `HIDDEN` flag after animation (if `hide=True`) +- Restores original position after slide animations + +### change_widget() + +Animate a numeric value with a custom display callback. + +```python +WidgetAnimator.change_widget( + widget, + anim_type="interpolate", + duration=5000, + delay=0, + begin_value=0, + end_value=100, + display_change=None +) +``` + +**Parameters:** +- `widget` (lv.obj): The widget being animated (used for cleanup) +- `anim_type` (str): Animation type - currently only `"interpolate"` is supported +- `duration` (int): Animation duration in milliseconds (default: 5000) +- `delay` (int): Animation delay in milliseconds (default: 0) +- `begin_value` (int/float): Starting value for interpolation (default: 0) +- `end_value` (int/float): Ending value for interpolation (default: 100) +- `display_change` (callable, optional): Callback function called with each interpolated value. If `None`, calls `widget.set_text(str(value))` + +**Returns:** The animation object + +**Behavior:** +- Interpolates between `begin_value` and `end_value` +- Calls `display_change(value)` for each frame +- Ensures final value is set after animation completes +- Uses ease-in-out path for smooth motion + +### Convenience Methods + +**`WidgetAnimator.smooth_show(widget, duration=500, delay=0)`** + +Fade in a widget (shorthand for `show_widget()` with fade animation). + +**`WidgetAnimator.smooth_hide(widget, hide=True, duration=500, delay=0)`** + +Fade out a widget (shorthand for `hide_widget()` with fade animation). + +## Animation Types + +### Fade + +Animates widget opacity from 0 to 255 (show) or 255 to 0 (hide). + +```python +# Fade in +WidgetAnimator.show_widget(widget, anim_type="fade", duration=500) + +# Fade out +WidgetAnimator.hide_widget(widget, anim_type="fade", duration=500) +``` + +**Use cases:** +- Smooth appearance/disappearance of UI elements +- Transitioning between screens +- Highlighting important information + +### Slide Down + +Animates widget position from above the screen to its original position (show) or from original position to below (hide). + +```python +# Slide down from above +WidgetAnimator.show_widget(widget, anim_type="slide_down", duration=500) + +# Slide down to below +WidgetAnimator.hide_widget(widget, anim_type="slide_down", duration=500) +``` + +**Use cases:** +- Drawer-like panels sliding down +- Notifications appearing from top +- Menu items sliding in + +### Slide Up + +Animates widget position from below the screen to its original position (show) or from original position to above (hide). + +```python +# Slide up from below +WidgetAnimator.show_widget(widget, anim_type="slide_up", duration=500) + +# Slide up to above +WidgetAnimator.hide_widget(widget, anim_type="slide_up", duration=500) +``` + +**Use cases:** +- Keyboard sliding up from bottom +- Bottom sheets appearing +- Menu items sliding in from bottom + +### Interpolate + +Animates numeric values with smooth easing. + +```python +WidgetAnimator.change_widget( + widget, + anim_type="interpolate", + duration=5000, + begin_value=0, + end_value=100, + display_change=lambda v: label.set_text(f"{int(v)}") +) +``` + +**Use cases:** +- Animating balance updates +- Progress indicators +- Counters +- Numeric displays + +## Complete Examples + +### Image Viewer with Fade Transitions + +```python +from mpos import Activity +from mpos import WidgetAnimator +import lvgl as lv + +class ImageViewerActivity(Activity): + def onCreate(self): + self.screen = lv.obj() + + # Image widget + self.image = lv.image(self.screen) + self.image.center() + self.image.add_flag(lv.obj.FLAG.HIDDEN) # Start hidden + + # Navigation buttons + prev_btn = lv.button(self.screen) + prev_btn.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) + prev_btn.add_event_cb(lambda e: self.show_prev_image(), lv.EVENT.CLICKED, None) + + next_btn = lv.button(self.screen) + next_btn.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) + next_btn.add_event_cb(lambda e: self.show_next_image(), lv.EVENT.CLICKED, None) + + self.setContentView(self.screen) + + def show_prev_image(self): + # Fade out current image + WidgetAnimator.smooth_hide(self.image) + # Load and fade in next image + self.load_image("prev_image.png") + WidgetAnimator.smooth_show(self.image) + + def show_next_image(self): + # Fade out current image + WidgetAnimator.smooth_hide(self.image) + # Load and fade in next image + self.load_image("next_image.png") + WidgetAnimator.smooth_show(self.image) + + def load_image(self, path): + self.image.set_src(f"M:{path}") +``` + +### Wallet Balance Animation + +```python +from mpos import Activity +from mpos import WidgetAnimator +import lvgl as lv + +class WalletActivity(Activity): + def onCreate(self): + self.screen = lv.obj() + + self.balance_label = lv.label(self.screen) + self.balance_label.set_text("0 sats") + self.balance_label.align(lv.ALIGN.TOP_MID, 0, 20) + + self.setContentView(self.screen) + + def on_balance_received(self, old_balance, new_balance, sats_added): + """Animate balance update when payment received.""" + def display_balance(value): + self.balance_label.set_text(f"{int(value)} sats") + + # Animate from old balance to new balance over 3 seconds + WidgetAnimator.change_widget( + self.balance_label, + anim_type="interpolate", + duration=3000, + begin_value=old_balance, + end_value=new_balance, + display_change=display_balance + ) +``` + +### Fullscreen Mode Toggle with Slide Animations + +```python +from mpos import Activity +from mpos import WidgetAnimator +import lvgl as lv + +class ImageViewerActivity(Activity): + def onCreate(self): + self.screen = lv.obj() + self.fullscreen = False + + # Image + self.image = lv.image(self.screen) + self.image.center() + + # UI controls + self.controls = lv.obj(self.screen) + self.controls.set_size(lv.pct(100), 50) + self.controls.align(lv.ALIGN.BOTTOM_MID, 0, 0) + + prev_btn = lv.button(self.controls) + prev_btn.align(lv.ALIGN.LEFT, 0, 0) + + next_btn = lv.button(self.controls) + next_btn.align(lv.ALIGN.RIGHT, 0, 0) + + self.image.add_event_cb(lambda e: self.toggle_fullscreen(), lv.EVENT.CLICKED, None) + + self.setContentView(self.screen) + + def toggle_fullscreen(self): + if self.fullscreen: + # Exit fullscreen - slide controls back up + self.fullscreen = False + WidgetAnimator.show_widget(self.controls, anim_type="slide_up", duration=300) + else: + # Enter fullscreen - slide controls down + self.fullscreen = True + WidgetAnimator.hide_widget(self.controls, anim_type="slide_down", duration=300, hide=False) +``` + +## Best Practices + +### 1. Use Convenience Methods for Simple Cases + +```python +# Good - simple and readable +WidgetAnimator.smooth_show(widget) +WidgetAnimator.smooth_hide(widget) + +# Less readable - more verbose +WidgetAnimator.show_widget(widget, anim_type="fade", duration=500, delay=0) +``` + +### 2. Provide Custom Display Callbacks for Value Animations + +```python +# Good - custom formatting +def display_balance(value): + balance_label.set_text(f"{int(value):,} sats") + +WidgetAnimator.change_widget( + balance_label, + anim_type="interpolate", + duration=3000, + begin_value=old_balance, + end_value=new_balance, + display_change=display_balance +) + +# Less good - default string conversion +WidgetAnimator.change_widget( + balance_label, + anim_type="interpolate", + duration=3000, + begin_value=old_balance, + end_value=new_balance +) +``` + +### 3. Use Appropriate Durations + +```python +# Quick feedback (UI elements) +WidgetAnimator.smooth_show(button, duration=200) + +# Standard transitions (screens, panels) +WidgetAnimator.smooth_hide(panel, duration=500) + +# Slow animations (value updates, important transitions) +WidgetAnimator.change_widget( + balance_label, + anim_type="interpolate", + duration=3000, + begin_value=old, + end_value=new, + display_change=display_fn +) +``` + +### 4. Handle Widget Deletion Gracefully + +WidgetAnimator automatically handles deleted widgets through internal safe widget access. No special handling needed: + +```python +# Safe - animation continues even if widget is deleted +WidgetAnimator.show_widget(widget, duration=500) +# ... widget might be deleted here ... +# Animation completes silently without crashing +``` + +### 5. Combine Animations for Complex Transitions + +```python +# Fade out old content +WidgetAnimator.smooth_hide(old_content, duration=300) + +# After fade completes, show new content +# (Use delay to sequence animations) +WidgetAnimator.smooth_show(new_content, duration=300, delay=300) +``` + +## Performance Tips + +### 1. Use Shorter Durations for Frequent Animations + +```python +# Good - quick feedback for user interactions +WidgetAnimator.smooth_show(button, duration=200) + +# Avoid - slow animations for frequent events +WidgetAnimator.smooth_show(button, duration=2000) +``` + +### 2. Limit Simultaneous Animations + +WidgetAnimator automatically stops previous animations on the same widget: + +```python +# Safe - previous animation is stopped +WidgetAnimator.show_widget(widget, duration=500) +WidgetAnimator.show_widget(widget, duration=500) # Previous animation stops +``` + +### 3. Use Appropriate Easing + +All animations use `path_ease_in_out` for smooth, natural motion. This is optimal for most use cases. + +## Troubleshooting + +### Animation Not Visible + +**Symptom**: Animation runs but widget doesn't appear to move/fade + +**Possible causes:** +1. Widget is already in the target state +2. Widget is outside the visible area +3. Animation duration is too short to notice + +**Solution:** +```python +# Ensure widget is in correct initial state +widget.remove_flag(lv.obj.FLAG.HIDDEN) # For show animations +widget.set_style_opa(255, 0) # For fade animations + +# Use longer duration for testing +WidgetAnimator.smooth_show(widget, duration=1000) +``` + +### Widget Disappears After Animation + +**Symptom**: Widget fades out but doesn't reappear + +**Possible cause**: Using `hide=True` (default) which adds `HIDDEN` flag + +**Solution:** +```python +# If you want to keep widget visible but transparent +WidgetAnimator.smooth_hide(widget, hide=False, duration=500) + +# If you want to hide it completely +WidgetAnimator.smooth_hide(widget, hide=True, duration=500) +``` + +### Animation Stutters or Jitters + +**Symptom**: Animation is not smooth, has visual glitches + +**Possible causes:** +1. Too many simultaneous animations +2. Heavy processing during animation +3. Widget being modified during animation + +**Solution:** +```python +# Avoid modifying widget during animation +# Stop any previous animations first +lv.anim_delete(widget, None) + +# Then start new animation +WidgetAnimator.smooth_show(widget, duration=500) +``` + +### Value Animation Shows Wrong Final Value + +**Symptom**: Animation completes but final value is incorrect + +**Possible cause**: `display_change` callback not handling final value correctly + +**Solution:** +```python +# Ensure callback handles all values correctly +def display_balance(value): + # Handle both intermediate and final values + self.balance_label.set_text(f"{int(value)} sats") + +WidgetAnimator.change_widget( + balance_label, + anim_type="interpolate", + duration=3000, + begin_value=old_balance, + end_value=new_balance, + display_change=display_balance +) +``` + +## See Also + +- [Creating Apps](../apps/creating-apps.md) - Learn how to create MicroPythonOS apps +- [LVGL Documentation](https://docs.lvgl.io/) - LVGL animation API reference diff --git a/docs/frameworks/wifi-service.md b/docs/frameworks/wifi-service.md new file mode 100644 index 0000000..3aad006 --- /dev/null +++ b/docs/frameworks/wifi-service.md @@ -0,0 +1,336 @@ +# WifiService + +MicroPythonOS provides a centralized WiFi management service called **WifiService** that handles WiFi connections, hotspot mode, network scanning, and credential storage. It's designed to work alongside ConnectivityManager for comprehensive network management. + +## Overview + +WifiService provides: + +- **Auto-connect on boot** - Automatically connects to saved networks when device starts +- **Hotspot mode** - Enable/disable AP mode with saved settings +- **Network scanning** - Scan for available WiFi networks +- **Credential management** - Save, retrieve, and forget network passwords +- **Hidden network support** - Connect to networks that don't broadcast SSID +- **Concurrent access locking** - Thread-safe WiFi operations +- **Desktop simulation** - Mock WiFi for desktop testing +- **ADC2 compatibility** - Temporarily disable WiFi for ESP32-S3 ADC2 operations +- **IP helpers** - Get IPv4 address, netmask, and gateway + +## Quick Start + +### Checking Connection Status + +```python +from mpos import WifiService + +if WifiService.is_connected(): + ssid = WifiService.get_current_ssid() + print(f"Connected to: {ssid}") +else: + print("Not connected to WiFi") +``` + +### Scanning for Networks + +```python +from mpos import WifiService + +networks = WifiService.scan_networks() +for ssid in networks: + print(f"Found network: {ssid}") +``` + +### Connecting to a Network + +```python +from mpos import WifiService + +WifiService.save_network("MyNetwork", "password123") + +success = WifiService.attempt_connecting("MyNetwork", "password123") +if success: + print("Connected!") +else: + print("Connection failed") +``` + +### Managing Saved Networks + +```python +from mpos import WifiService + +WifiService.save_network("HomeWiFi", "mypassword") +WifiService.save_network("HiddenNetwork", "secret", hidden=True) + +saved = WifiService.get_saved_networks() +print(f"Saved networks: {saved}") + +password = WifiService.get_network_password("HomeWiFi") + +WifiService.forget_network("OldNetwork") +``` + +## Hotspot Mode + +WifiService supports Access Point mode using settings stored in SharedPreferences: + +- Namespace: `com.micropythonos.settings.hotspot` +- Keys: `enabled`, `ssid`, `password`, `authmode` + +```python +from mpos import WifiService + +# Enable hotspot +WifiService.enable_hotspot() + +# Disable hotspot +WifiService.disable_hotspot() + +# Check hotspot state +if WifiService.is_hotspot_enabled(): + print("Hotspot is active") +``` + +### Auto-connect vs Hotspot + +When hotspot is enabled in preferences, `auto_connect()` skips STA mode and starts the hotspot instead. + +## Common Patterns + +### WiFi Settings Screen + +```python +from mpos import Activity, WifiService +import lvgl as lv + +class WifiSettingsActivity(Activity): + def onCreate(self): + self.screen = lv.obj() + + self.status = lv.label(self.screen) + self.update_status() + self.status.align(lv.ALIGN.TOP_MID, 0, 10) + + scan_btn = lv.button(self.screen) + scan_btn.set_size(120, 40) + scan_btn.align(lv.ALIGN.TOP_MID, 0, 50) + scan_label = lv.label(scan_btn) + scan_label.set_text("Scan") + scan_label.center() + scan_btn.add_event_cb(self.on_scan, lv.EVENT.CLICKED, None) + + self.network_list = lv.list(self.screen) + self.network_list.set_size(280, 150) + self.network_list.align(lv.ALIGN.CENTER, 0, 30) + + self.setContentView(self.screen) + + def update_status(self): + if WifiService.is_connected(): + ssid = WifiService.get_current_ssid() + self.status.set_text(f"Connected: {ssid}") + elif WifiService.is_hotspot_enabled(): + self.status.set_text("Hotspot active") + else: + self.status.set_text("Not connected") + + def on_scan(self, event): + if WifiService.is_busy(): + print("WiFi is busy") + return + + self.network_list.clean() + networks = WifiService.scan_networks() + saved = WifiService.get_saved_networks() + + for ssid in networks: + btn = self.network_list.add_button(None, ssid) + if ssid in saved: + btn.set_style_bg_color(lv.color_hex(0x4CAF50), 0) + btn.add_event_cb( + lambda e, s=ssid: self.on_network_selected(s), + lv.EVENT.CLICKED, + None + ) + + def on_network_selected(self, ssid): + password = WifiService.get_network_password(ssid) + if password: + success = WifiService.attempt_connecting(ssid, password) + if success: + self.update_status() +``` + +### Background Auto-Connect + +```python +import _thread +from mpos import WifiService, TaskManager + +_thread.stack_size(TaskManager.good_stack_size()) +_thread.start_new_thread(WifiService.auto_connect, ()) +``` + +**Auto-connect behavior:** +1. Loads saved networks from SharedPreferences +2. Checks if WiFi is busy before proceeding +3. Tries saved networks in order of signal strength +4. Also tries hidden networks +5. Syncs time via NTP on success +6. Disables WiFi if no networks found (power saving) +7. Restores hotspot if it was active before attempting STA mode + +### Temporarily Disabling WiFi for ADC2 + +On ESP32-S3, ADC2 pins (GPIO11-20) don't work when WiFi is active: + +```python +from mpos import WifiService + +try: + was_connected = WifiService.temporarily_disable() + # ADC2 operations here +finally: + WifiService.temporarily_enable(was_connected) +``` + +## IP Address Helpers + +```python +from mpos import WifiService + +print(WifiService.get_ipv4_address()) +print(WifiService.get_ipv4_netmask()) +print(WifiService.get_ipv4_gateway()) +``` + +## API Reference + +### Connection Functions + +#### `WifiService.connect(network_module=None, time_module=None)` + +Scan for available networks and connect to the first saved network found. + +**Parameters:** +- `network_module` - Network module for dependency injection (testing) +- `time_module` - Time module for dependency injection (testing) + +**Returns:** +- `bool` - `True` if successfully connected, `False` otherwise + +--- + +#### `WifiService.attempt_connecting(ssid, password, network_module=None, time_module=None)` + +Attempt to connect to a specific WiFi network. + +**Parameters:** +- `ssid` (str) - Network SSID to connect to +- `password` (str) - Network password +- `network_module` - Network module for dependency injection (testing) +- `time_module` - Time module for dependency injection (testing) + +**Returns:** +- `bool` - `True` if successfully connected, `False` otherwise + +--- + +#### `WifiService.auto_connect(network_module=None, time_module=None)` + +Auto-connect to a saved WiFi network on boot. + +**Parameters:** +- `network_module` - Network module for dependency injection (testing) +- `time_module` - Time module for dependency injection (testing) + +--- + +#### `WifiService.disconnect(network_module=None)` + +Disconnect from current WiFi network and disable WiFi (also disables hotspot). + +--- + +#### `WifiService.is_connected(network_module=None)` + +Check if WiFi is currently connected. Returns `True` for hotspot mode when AP is active. + +### Hotspot Functions + +#### `WifiService.enable_hotspot(network_module=None)` + +Enable hotspot mode using settings stored in `com.micropythonos.settings.hotspot`. + +#### `WifiService.disable_hotspot(network_module=None)` + +Disable hotspot mode. + +#### `WifiService.is_hotspot_enabled(network_module=None)` + +Check if hotspot mode is currently enabled. + +### IP Helpers + +#### `WifiService.get_ipv4_address(network_module=None)` + +Return IPv4 address for STA or AP mode. + +#### `WifiService.get_ipv4_netmask(network_module=None)` + +Return IPv4 netmask for STA or AP mode. + +#### `WifiService.get_ipv4_gateway(network_module=None)` + +Return IPv4 gateway for STA or AP mode. + +### ADC2 Compatibility + +#### `WifiService.temporarily_disable(network_module=None)` + +Temporarily disable WiFi for operations that require it (e.g., ESP32-S3 ADC2). + +#### `WifiService.temporarily_enable(was_connected, network_module=None)` + +Re-enable WiFi after temporary disable operation. Restores hotspot if it was active before. + +## Desktop Mode + +On desktop (Linux/macOS), WifiService provides simulated behavior for testing: + +| Method | Desktop Behavior | +|--------|------------------| +| `is_connected()` | Always returns `True` | +| `scan_networks()` | Returns mock SSIDs | +| `attempt_connecting()` | Simulates 2-second connection delay, always succeeds | +| `get_current_ssid()` | Returns simulated connected SSID | +| `disconnect()` | Prints message, no-op | +| `enable_hotspot()` | Simulated hotspot state | + +## Credential Storage + +Network credentials are stored using SharedPreferences: + +- **Preference name:** `com.micropythonos.system.wifiservice` +- **Key:** `access_points` +- **Format:** Dictionary `{ssid: {password: "...", hidden: bool}}` + +**Storage location:** `data/com.micropythonos.system.wifiservice/prefs.json` + +## Implementation Details + +**Location:** `MicroPythonOS/internal_filesystem/lib/mpos/net/wifi_service.py` + +**Pattern:** Static class with class-level state + +**Key features:** +- `wifi_busy` - Class-level lock for concurrent access +- `access_points` - Cached dictionary of saved networks +- `_desktop_connected_ssid` - Simulated SSID for desktop mode +- `hotspot_enabled` - Hotspot state flag + +## See Also + +- [ConnectivityManager](connectivity-manager.md) - Network connectivity monitoring +- [DownloadManager](download-manager.md) - HTTP downloads +- [SharedPreferences](preferences.md) - Persistent storage diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 97011a8..f8881fa 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -2,5 +2,5 @@ Get up and running with MicroPythonOS. This section covers installation and supported hardware to help you deploy the OS on your device or desktop. -- [Installation](installation.md): Step-by-step guide to install MicroPythonOS. +- [Running](running.md): Step-by-step guide to install and run MicroPythonOS. - [Supported Hardware](supported-hardware.md): Compatible devices and platforms. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md deleted file mode 100644 index 08298fa..0000000 --- a/docs/getting-started/installation.md +++ /dev/null @@ -1,38 +0,0 @@ -# Installation - -MicroPythonOS can be installed on supported microcontrollers (e.g., ESP32) and on desktop systems (Linux, Raspberry Pi, MacOS, etc). - -If you're a developer, you can [build it yourself and install from source](../building/index.md) - -To simply install prebuilt software, read on! - -## Installing on ESP32 - -Just use the [WebSerial installer at install.micropythonos.com](https://install.micropythonos.com). - -## Installing on Desktop (Linux/MacOS) - -Download the [latest release for desktop](https://github.com/MicroPythonOS/MicroPythonOS/releases). - -Here we'll assume you saved it in /tmp/MicroPythonOS_amd64_Linux_0.0.8 - -Get the internal_filesystem files: - -``` -git clone https://github.com/MicroPythonOS/MicroPythonOS.git -cd MicroPythonOS/ -cd internal_filesystem/ -``` - -Now run it by starting the entry points, boot_unix.py and main.py: - -``` -/tmp/MicroPythonOS_amd64_Linux_0.0.8 -X heapsize=32M -v -i -c "$(cat boot_unix.py main.py)" -``` - -You can also check out `scripts/run_desktop.sh` for more examples, such as immediately starting an app or starting fullscreen. - -## Next Steps - -- Check [Supported Hardware](supported-hardware.md) for compatible devices. -- Explore [Built-in Apps](../apps/built-in-apps.md) to get started with the system. diff --git a/docs/getting-started/running.md b/docs/getting-started/running.md new file mode 100644 index 0000000..0d1c420 --- /dev/null +++ b/docs/getting-started/running.md @@ -0,0 +1,29 @@ +# Running it + +MicroPythonOS can run on supported microcontrollers (e.g., ESP32), on desktop systems (Linux, Raspberry Pi, MacOS, Windows), and in a web browser via WebAssembly. + +To install prebuilt software, read on! + +To modify low level things under the hood, consider heading to [OS Development](../os-development/index.md). + +## Running on ESP32 + +Just use the [WebSerial installer at install.micropythonos.com](https://install.micropythonos.com). + +For advanced usage, such as installing development builds without any files, see [Installing on ESP32](../os-development/installing-on-esp32.md). + +## Running on desktop + +On desktop you can choose how deep you want to go: run a pre-built binary, develop apps using a pre-built binary with a source checkout, or build the operating system from source. + +See [Running on Desktop](../os-development/running-on-desktop.md) for detailed steps. + +## Running in the browser + +Open **[web.micropythonos.com](https://web.micropythonos.com/)** for the latest release, or **[micropythonos.github.io/MicroPythonOS](https://micropythonos.github.io/MicroPythonOS/)** for the bleeding-edge main branch. No install required. + +See [Using the Web Port](../web-port/using.md) for details and limitations. + +## Next Steps + +- Explore [Built-in Apps](../apps/built-in-apps.md) to get started with the system. diff --git a/docs/getting-started/supported-hardware.md b/docs/getting-started/supported-hardware.md index b71daf1..64ac8e0 100644 --- a/docs/getting-started/supported-hardware.md +++ b/docs/getting-started/supported-hardware.md @@ -4,19 +4,43 @@ MicroPythonOS runs on a variety of platforms, from microcontrollers to desktops. ## ESP32 Microcontrollers +- [M5Stack Fire](https://docs.m5stack.com/en/core/fire): Fully Supported +- [M5Stack Core2](https://docs.m5stack.com/en/core/core2): Fully Supported +- [HardKernel ODROID-GO](https://wiki.odroid.com/odroid_go/odroid_go): Fully Supported +- [LilyGo T4 V1.3](https://github.com/Xinyuan-LilyGO/LilyGo_Txx): Fully Supported +- [unPhone 9](https://unphone.net/): Fully Supported + +## ESP32-S3 Microcontrollers + +- [DFRobot UniHiker K10](https://www.dfrobot.com/product-2676.html): Fully Supported +- [Fri3d Camp 2024 Badge](https://fri3d.be/badge/2024/): Fully Supported (including Communicator Add-On) +- [Fri3d Camp 2026 Badge](https://fri3d.be/badge/2026/): Fully Supported (including Communicator Add-On) +- [Freenove ESP32-S3 Display](https://github.com/Freenove/Freenove_ESP32_S3_Display): Fully Supported +- [LilyGo T-Display S3](https://lilygo.cc/products/t-display-s3): Fully Supported +- [LilyGo T-HMI](https://lilygo.cc/en-us/products/t-hmi): Fully Supported +- [LilyGo T-Watch S3 Plus](https://lilygo.cc/products/t-watch-s3-plus): Fully Supported +- [Makerfabs MaTouch ESP32-S3 SPI IPS 2.8" with Camera OV3660](https://www.makerfabs.com/matouch-esp32-s3-spi-ips-2-8-with-camera-ov3660.html): Fully Supported +- [SQUiXL](https://squixl.io): Fully Supported - [Waveshare ESP32-S3-Touch-LCD-2](https://www.waveshare.com/wiki/ESP32-S3-Touch-LCD-2): Fully Supported -- [Fri3d Camp 2024 Badge](https://fri3d.be/badge/2024/): Good support but missing a bit of hardware support (IMU, Buzzer, Multi-Color LEDs, Communicator Add-On) + +## WebAssembly (Browser) + +- **Latest release**: [web.micropythonos.com](https://web.micropythonos.com/) +- **Main branch CI**: [micropythonos.github.io/MicroPythonOS](https://micropythonos.github.io/MicroPythonOS/) +- Runs entirely in a modern desktop browser — no install, no flash. +- Built from source with `./scripts/build_mpos.sh web` (requires [Emscripten SDK](../web-port/developer/#prerequisites)). +- See [CI config](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/.github/workflows/web-pages.yml) for automated build details. ## Desktop Computers - **Linux**: Supported using SDL for display handling. -- **MacOS**: Should work but untested. +- **MacOS**: Supported as well. +- **Windows**: [Users report](https://github.com/MicroPythonOS/MicroPythonOS/issues/31) that the Linux desktop version runs fine under WSL2 on Windows 11. ## Raspberry Pi -- **Raspbian/Linux-based**: Should work, especially with a Linux desktop. Untested. +- **Raspbian and other Linux-based**: Should work! -## Notes +## Adding a new board -- Ensure your hardware supports touch screens, IMUs, or cameras for full feature compatibility. -- Check [Installation](installation.md) for setup instructions. +If your device is not listed, see the [Porting Guide](../os-development/porting-guide.md). diff --git a/docs/index.md b/docs/index.md index 2802810..e321708 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@ Welcome to the official documentation for **MicroPythonOS**, a lightweight and versatile operating system built entirely in MicroPython. Designed for microcontrollers like the ESP32 and desktop systems, MicroPythonOS offers a modern, Android-inspired interface with an app ecosystem, App Store, and Over-The-Air (OTA) updates. -![Launcher Screenshot](assets/images/launcher.png) +To see MicroPythonOS in action, check out the screenshots and screen video's on the [main website](https://MicroPythonOS.com). ## About MicroPythonOS @@ -16,17 +16,14 @@ MicroPythonOS is tailored for developers and innovators building IoT devices, sm This documentation provides everything you need to get started, understand the architecture, build the system, and develop apps. -## Who This Is For +## Target Audience -- **Developers**: Learn how to install, build, and extend MicroPythonOS with new apps and features. -- **Managers**: Explore the system’s capabilities, supported hardware, and ready-to-use features for prototyping and production. - -## Getting Started +MicroPythonOS is for everyone, from kids to seniors! -New to MicroPythonOS? Start here: +This documentation specifically is written for: -- [Installation](getting-started/installation.md) -- [Supported Hardware](getting-started/supported-hardware.md) +- **Developers**: Learn how to install, build, and extend MicroPythonOS with new apps and features. +- **Managers**: Explore the system’s capabilities, supported hardware, and ready-to-use features for prototyping and production. ## Contributing diff --git a/docs/os-development/automated-testing.md b/docs/os-development/automated-testing.md new file mode 100644 index 0000000..3d8a99b --- /dev/null +++ b/docs/os-development/automated-testing.md @@ -0,0 +1,66 @@ +By default, the unit tests will run automatically when the [GitHub workflows](https://github.com/MicroPythonOS/MicroPythonOS/tree/main/.github/workflows) are triggered. + +But when you're working on a unit test, or investigating a failure, you probably want to run it locally for quick iteration. + +The tests are stored in [tests/](https://github.com/MicroPythonOS/MicroPythonOS/tree/main/tests) + +Simple (non-graphical) test filenames start with `test_` and ends with `.py`. + +Graphical tests start with `test_graphical_` and also end with `.py`. + +# On Desktop + +The following assumes you have MicroPythonOS running on desktop, meaning this works: + +``` +./scripts/run_desktop.sh +``` + +Then you can run a specific unit test by providing its file as an argument: + +``` +./tests/unittest.sh tests/test_package_manager.py +``` + +To run all unit tests, do: + +``` +./tests/unittest.sh +``` + +This takes some time, during which you'll see the MicroPythonOS window pop up often. + +There's also a syntax checker: + +``` +./tests/syntax.sh +``` + +# On Device + +The unit tests can also run on a physical device, like an ESP32 that's connected with a USB cable, which is more representative and can help to test features that are not available on desktop. + +This takes quite some time, because it restarts the device before each test, to make sure there's no leftover state from a previous test lingering. + +Note that, since these tests are currently not automatically checked, some of these might have accidentally been broken without it being noticed. +If you see something you can fix, feel free to do so, but you're not expected to clean up someone else's mess if you didn't break it yourself. + +The following assumes you have the MicroPythonOS REPL shell show up when you run mpremote.py, meaning this works: + +``` +./lvgl_micropython/lib/micropython/tools/mpremote/mpremote.py ls +``` + +Then you can run a specific unit test by adding the --ondevice option and providing its file as an argument: + +``` +./tests/unittest.sh --ondevice tests/test_package_manager.py +``` + +To run all unit tests, do: + +``` +./tests/unittest.sh --ondevice # this takes a long time +``` + + diff --git a/docs/os-development/compile-and-run.md b/docs/os-development/compile-and-run.md deleted file mode 100644 index b06019e..0000000 --- a/docs/os-development/compile-and-run.md +++ /dev/null @@ -1,67 +0,0 @@ -## Compile the code - -1. **Make sure you're in the main repository**: - - ``` - cd MicroPythonOS - ``` - -2. **Start the Compilation** - - Usage: - -
-    ```
-    ./scripts/build_lvgl_micropython.sh   [optional target device]
-    ```
-    
- - **Target systems**: esp32, unix (= Linux) and macOS - - **Build types**: - - - A "prod" build includes the complete filesystem that's "frozen" into the build, so it's fast and all ready to go but the files in /lib and /builtin will be read-only. - - A "dev" build comes without a filesystem, so it's perfect for power users that want to work on MicroPythonOS internals. There's a simple script that will copy all the necessary files over later, and these will be writeable. - - _Note_: for unix and macOS systems, only "dev" has been tested. The "prod" builds might have issues but should be made to work soon. - - **Target devices**: waveshare-esp32-s3-touch-lcd-2 or fri3d-2024 - - **Examples**: - -
-    ```
-    ./scripts/build_lvgl_micropython.sh esp32 prod fri3d-2024
-    ./scripts/build_lvgl_micropython.sh esp32 dev waveshare-esp32-s3-touch-lcd-2
-    ./scripts/build_lvgl_micropython.sh esp32 unix dev
-    ./scripts/build_lvgl_micropython.sh esp32 macOS dev
-    ```
-    
- - The resulting build file will be in `lvgl_micropython/build/`, for example: - - - lvgl_micropython/build/lvgl_micropy_unix - - lvgl_micropython/build/lvgl_micropy_macOS - - lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin - -## Running on Linux or MacOS - -1. Download a release binary (e.g., `MicroPythonOS_amd64_Linux`, `MicroPythonOS_amd64_MacOS`) or build your own [on MacOS](macos.md) or [Linux](linux.md). -2. Run the application: - -
-    ```
-    cd internal_filesystem/ # make sure you're in the right place to find the filesystem
-    /path/to/release_binary -X heapsize=32M -v -i -c "$(cat boot_unix.py main.py)"
-    ```
-    
- - There's also a convenient `./scripts/run_desktop.sh` script that will attempt to start the latest build that you compiled yourself. - -### Modifying files - -You'll notice that, whenever you change a file on your local system, the changes are immediately visible whenever you reload the file. - -This results in a very quick coding cycle. - -Give this a try by editing `internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py` and then restarting the "About" app. Powerful stuff! diff --git a/docs/os-development/compiling.md b/docs/os-development/compiling.md new file mode 100644 index 0000000..eff418b --- /dev/null +++ b/docs/os-development/compiling.md @@ -0,0 +1,51 @@ +## Download the code + +Clone the repositories: + +``` +git clone --recurse-submodules --depth 1 --shallow-submodules https://github.com/MicroPythonOS/MicroPythonOS.git +cd MicroPythonOS/ +``` + +That will take a while, because it recursively clones MicroPython, LVGL, ESP-IDF and all their dependencies. + +#### Optional: updating the code + +If you already have an old clone and you want to update it, the easiest is to just delete it and re-clone. + +But if you need to save on bandwidth and time, you can instead do the following, which will *throw away all local modifications*: + +``` +cd MicroPythonOS/ +git submodule foreach --recursive 'git clean -f; git checkout .' +git pull --depth 1 +git submodule update --init --depth 1 +``` + +## Compile the code + +Use the build_mpos.sh script for convenience. + +Usage: + +``` +./scripts/build_mpos.sh +``` + +**Target systems**: `esp32`, `esp32s3`, `unix` (= Linux), `macOS` and `web` (= WebAssembly) + +**Examples**: + +``` +./scripts/build_mpos.sh esp32 +./scripts/build_mpos.sh esp32s3 +./scripts/build_mpos.sh unix +./scripts/build_mpos.sh macOS +``` + +The resulting build file will be in `lvgl_micropython/build/`, for example: + +- `lvgl_micropython/build/lvgl_micropy_unix` +- `lvgl_micropython/build/lvgl_micropy_macOS` +- `lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin` + diff --git a/docs/os-development/emulating-esp32-on-desktop.md b/docs/os-development/emulating-esp32-on-desktop.md new file mode 100644 index 0000000..cecaf62 --- /dev/null +++ b/docs/os-development/emulating-esp32-on-desktop.md @@ -0,0 +1,110 @@ +## Running in a QEMU ESP32 emulator on desktop + +For development and automated testing, it can be very useful to run MicroPythonOS on an emulated ESP32 device. + +Compared to running on desktop, the emulated ESP32 device offers more capabitlities: + +- emulated WiFi scanning (finds a hard-coded list of access points) +- emulated WiFi connection (doesn't support encryption, so leave password blank) +- emulated WiFi Access Point (AP) mode +- emulated storage, which enables LittleFS2 filesystem and Over-The-Air update testing +- emulated Ultra Low Processor and DeepSleep +- emulated GPIO buttons +- emulated capacitive touch buttons +- emulated ST7789V display + +In the future, more features might land: + +- [emulated Bluetooth](https://github.com/Ebiroll/qemu_esp32) - currently only on ESP32 but maybe [someday on ESP32S3](https://github.com/Ebiroll/calib/issues/1). + +To this end, we're using the amazing a159x36 QEMU emulator, which can emulate the LilyGo T-Display (ESP32) and LilyGo T-Display-S3 (ESP32S3), with [a few improvements](https://github.com/a159x36/qemu/pulls?q=is%3Apr) waiting to be merged upstream. + +To get it running, follow these steps: + +1. Compile the ESP32 QEMU +2. Pad a MicroPythonOS release image to 16MB (otherwise QEMU refuses it) +3. Start QEMU with the padded MicroPython release image + +There are also [GitHub workflows](https://github.com/MicroPythonOS/qemu/tree/esp-develop-9.2/.github/workflows) that compile the ESP32 QEMU for Linux, Windows, MacOS (intel) and MacOS (arm) so you might be able to download a prebuilt version from the [GitHub actions](https://github.com/MicroPythonOS/qemu/actions) artifacts. You need to be logged in to download them. + +## 1. Compile QEMU + +Get the prerequisites: + +``` +sudo apt-get install -y -q --no-install-recommends build-essential libgcrypt-dev libglib2.0-dev libpixman-1-dev libsdl2-dev libslirp-dev ninja-build python3-pip libvte-2.91-dev wget zlib1g-dev +``` + +Clone it: + +``` +git clone https://github.com/MicroPythonOS/qemu/ +cd qemu +``` + +Configure it: + +``` +./configure --target-list=xtensa-softmmu --enable-gcrypt --enable-slirp --enable-debug --enable-vte --enable-stack-protector +``` + +Compile it: + +`make -j4 # 4 is the number of cores` + +## 2. Pad a MicroPythonOS build + +Download the [latest release](https://github.com/MicroPythonOS/MicroPythonOS/releases) or use one you built yourself. + +The ESP32 QEMU refuses it if it's not a supported size (2, 4, 8 or 16MB) so just pad it with zeroes. + +A simple way to do this on Linux is: + +``` +dd if=MicroPythonOS_esp32_0.8.0.bin of=MicroPythonOS_esp32_0.8.0.bin.padded bs=1M count=16 oflag=append conv=notrunc +``` + +## 3. Start QEMU + +``` +./build/qemu-system-xtensa -machine esp32s3 -m 8M -drive file=MicroPythonOS_esp32_0.8.0.bin.padded,if=mtd,format=raw -nic user,model=esp32_wifi,hostfwd=tcp:127.0.0.1:10080-192.168.4.15:80,net=192.168.4.0/24,host=192.168.4.2,hostfwd=tcp:127.0.0.1:10081-192.168.4.1:80,hostfwd=tcp:127.0.0.1:7890-192.168.4.15:7890,hostfwd=tcp:127.0.0.1:7891-192.168.4.1:7890 -display default,show-cursor=on -parallel none -monitor none -serial stdio +``` + +The hostfwd lines forward: + +- `127.0.0.1:7890 to 192.168.4.15:7890` for webrepl webserver on port 7890 of the device in Station (WiFi client) mode +- `127.0.0.1:7891 to 192.168.4.1:7890` for webrepl webrepl on port 7890 of the ESP32 in Access Point (Hotspot) mode + +Also these are forwarded, although currently unused: + +- `127.0.0.1:10080 to 192.168.4.15:80` when the ESP32 is in Station (WiFi client) mode +- `127.0.0.1:10081 to 192.168.4.1:80` when the ESP32 is in Access Point (Hotspot) mode + +**Note**: QEMU will only log to stdout if you start it from a normal shell because then stdin is be set. If you're starting it from a script or other tool that doesn't set stdin, consider adding the `unbuffer` tool as a prefix at the start of the command to make sure stdin is set, otherwise you won't see much logging. + +## Controls + +To navigate around in the emulated T-Display S3: + +- press `r` for the reset button +- press `1` for the GPIO0 button - mapped to the "previous" action +- press `2` for the GPIO14 button - mapped to the "next" action +- press both for the "enter" action +- long press `1` for the "back" action + +You can also click the buttons on the picture of the board if you like. + +Also, currently unused: + +- 7, 8, 9 and 0 are bound to capacitive touch input pins 0, 1, 11 and 12 which correspond to GPIO01, GPIO02, GPIO12 and GPIO13 + +The code for these controls is in QEMU's `hw/xtensa/esp32s3.c` and `hw/display/st7789v.c`. + +## Building an image with a filesystem + +For quick development, rather than doing a full rebuild, you could also just rebuild the filesystem based on `internal_filesystem/` and bundle that as a LittleFS2 filesystem in the image. + +Run `./scripts/make_image.sh` from the [MicroPythonOS repo](https://github.com/MicroPythonOS/MicroPythonOS) to do so. + +It will call `./scripts/mklittlefs.sh` to build the filesystem and bundle it with all the other requisites, giving you a bootable image in one second. + diff --git a/docs/os-development/index.md b/docs/os-development/index.md new file mode 100644 index 0000000..43f668c --- /dev/null +++ b/docs/os-development/index.md @@ -0,0 +1,5 @@ +# OS Development + +Most users will just want to run MicroPythonOS and built apps for it. + +But if you want to work on stuff that's "under the hood", then choose one of the entries under "OS Development" in the menu. diff --git a/docs/os-development/installing-on-esp32.md b/docs/os-development/installing-on-esp32.md index 9d216fc..b446649 100644 --- a/docs/os-development/installing-on-esp32.md +++ b/docs/os-development/installing-on-esp32.md @@ -4,22 +4,23 @@ But if you need to install a version that's not available there, or you built yo 1. **Get the firmware** - - Download a release binary (e.g., `MicroPythonOS_fri3d-2024_prod_0.2.1.bin`, `MicroPythonOS_waveshare-esp32-s3-touch-lcd-2_prod_0.2.1.bin`, etc.) - - Or build your own [on MacOS](macos.md) or [Linux](linux.md) + - Download a release binary (e.g., `MicroPythonOS_esp32_0.5.0.bin`) + - Or build your own [on MacOS](../os-development/macos.md) or [Linux](../os-development/macos.md) 2. **Put the ESP32 in Bootloader Mode** If you're already in MicroPythonOS: go to Settings - Restart to Bootloader - Bootloader - Save. - Otherwise, physically keep the "BOOT" (sometimes labeled "START") button pressed while briefly pressing the "RESET" button. + Otherwise, physically keep the "BOOT" (sometimes labeled "START") button pressed while powering up the board. + This is explained in more detail at [the webinstaller](https://install.micropythonos.com/) 3. **Flash the firmware** ``` - ~/.espressif/python_env/idf5.2_py3.9_env/bin/python -m esptool --chip esp32s3 0x0 firmware_file.bin + ~/.espressif/python_env/idf5.2_py3.9_env/bin/python -m esptool --chip esp32s3 write_flash 0 firmware_file.bin ``` - Add --erase-all if you want to erase the entire flash memory, so that no old files or apps will remain. + Add the `--erase-all` option if you want to erase the entire flash memory, so that no old files or apps will remain. There's also a convenient `./scripts/flash_over_usb.sh` script that will attempt to flash the latest firmware that you compiled yourself. @@ -30,36 +31,42 @@ But if you need to install a version that's not available there, or you built yo Any serial client will do, but it's convenient to use the `mpremote.py` tool that's shipped with lvgl_micropython: ``` - lvgl_micropython/lib/micropython/tools/mpremote/mpremote.py + ./lvgl_micropython/lib/micropython/tools/mpremote/mpremote.py ``` -5. **Populate the filesystem** (only for "dev" builds) +5. **Populate the filesystem** (only for development) + + In development, you probably want to override the "frozen" libraries and apps that are compiled in, and replace them with source files, which you can edit. + + This makes MicroPythonOS startup a lot slower, as the Python scripts have to be compiled at runtime instead of at build time. + But once MicroPythonOS and the code you're testing has loaded, the speed will be normal again. - The "dev" builds come without a filesystem so you probably want to copy the whole internal_filesystem/ folder over, as well as one of the device-specific boot*.py files and main.py. - There's a convenient script that will do this for you. Usage:
     ```
-    ./scripts/install.sh 
+    ./scripts/install.sh
     ```
     
- - **Target devices**: waveshare-esp32-s3-touch-lcd-2 or fri3d-2024 - - Examples:
     ```
-    ./scripts/install.sh fri3d-2024
-    ./scripts/install.sh waveshare-esp32-s3-touch-lcd-2
+    ./scripts/install.sh com.micropythonos.about # to install one single app
     ```
     
+ On MacOS, the install.sh script needs: `brew install --cask serial` + + If you need to frequently update a small number of files, you can also update them manually, for example: + +
+    ```
+    ./lvgl_micropython/lib/micropython/tools/mpremote/mpremote.py cp internal_filesystem/lib/mpos/device_info.py :/lib/mpos
+    ```
+    
## Notes -- A "dev" build without frozen files is quite a bit slower when starting apps because all the libraries need to be compiled at runtime. - Ensure your ESP32 is compatible (see [Supported Hardware](../getting-started/supported-hardware.md)). If it's not, then you might need the [Porting Guide](../os-development/porting-guide.md). diff --git a/docs/os-development/linux.md b/docs/os-development/linux.md index c125ead..dc3e34f 100644 --- a/docs/os-development/linux.md +++ b/docs/os-development/linux.md @@ -1,26 +1,19 @@ # OS Development on Linux -Most users can just use a pre-built binary from the [releases page](https://github.com/MicroPythonOS/MicroPythonOS/releases) and install it manually or using the [web installer](https://install.MicroPythonOS.com). +Most users can simply use a pre-built binary from the [releases page](https://github.com/MicroPythonOS/MicroPythonOS/releases) and install it [manually](installing-on-esp32.md) or using the [web installer](https://install.MicroPythonOS.com). -But if for some reason that one doesn't work, or you really want to modify things under the hood, you're in the right place here! +But if you want to modify things under the hood, you're in the right place! ## Get the prerequisites -Clone the repositories: - -``` -git clone --recurse-submodules https://github.com/MicroPythonOS/MicroPythonOS.git -``` - -That will take a while, because it recursively clones MicroPython, LVGL, ESP-IDF and all their dependencies. - -While that's going on, make sure you have everything installed to compile code: +Make sure you have everything installed to compile code: ``` sudo apt update -sudo apt-get install -y build-essential libffi-dev pkg-config cmake ninja-build gnome-desktop-testing libasound2-dev libpulse-dev libaudio-dev libjack-dev libsndio-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxkbcommon-dev libdrm-dev libgbm-dev libgl1-mesa-dev libgles2-mesa-dev libegl1-mesa-dev libdbus-1-dev libibus-1.0-dev libudev-dev fcitx-libs-dev libpipewire-0.3-dev libwayland-dev libdecor-0-dev libv4l-dev +sudo apt-get install -y build-essential libffi-dev pkg-config cmake ninja-build gnome-desktop-testing libasound2-dev libpulse-dev libaudio-dev libjack-dev libsndio-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxkbcommon-dev libdrm-dev libgbm-dev libgl1-mesa-dev libgles2-mesa-dev libegl1-mesa-dev libdbus-1-dev libibus-1.0-dev libudev-dev fcitx-libs-dev libpipewire-0.3-dev libwayland-dev libdecor-0-dev libv4l-dev librlottie-dev ``` +{!os-development/compiling.md!} -{!os-development/compile-and-run.md!} +{!os-development/running-on-desktop.md!} diff --git a/docs/os-development/macos-allow-anyway.png b/docs/os-development/macos-allow-anyway.png new file mode 100644 index 0000000..247cf45 Binary files /dev/null and b/docs/os-development/macos-allow-anyway.png differ diff --git a/docs/os-development/macos.md b/docs/os-development/macos.md index 2a9010b..aadfaec 100644 --- a/docs/os-development/macos.md +++ b/docs/os-development/macos.md @@ -1,25 +1,18 @@ # OS Development on MacOS -Most users can just use a pre-built binary from the [releases page](https://github.com/MicroPythonOS/MicroPythonOS/releases) and install it manually or using the [web installer](https://install.MicroPythonOS.com). +Most users can simply use a pre-built binary from the [releases page](https://github.com/MicroPythonOS/MicroPythonOS/releases) and install it [manually](installing-on-esp32.md) or using the [web installer](https://install.MicroPythonOS.com). -But if for some reason that one doesn't work, or you really want to modify things under the hood, you're in the right place here! +But if you want to modify things under the hood, you're in the right place! ## Get the prerequisites -Clone the repositories: +Make sure you have everything installed to compile code: ``` -git clone --recurse-submodules https://github.com/MicroPythonOS/MicroPythonOS.git -``` - -That will take a while, because it recursively clones MicroPython, LVGL, ESP-IDF and all their dependencies. - -While that's going on, make sure you have everything installed to compile code: - -``` -xcode-select --install || true # already installed on github +xcode-select --install brew install pkg-config libffi ninja make SDL2 ``` +{!os-development/compiling.md!} -{!os-development/compile-and-run.md!} +{!os-development/running-on-desktop.md!} diff --git a/docs/os-development/porting-guide.md b/docs/os-development/porting-guide.md index 03286bc..a6a311b 100644 --- a/docs/os-development/porting-guide.md +++ b/docs/os-development/porting-guide.md @@ -10,7 +10,8 @@ If you prefer to have the porting work done for you and you're open to making a ## What to write -By design, the only device-specific code for MicroPythonOS is found in the ```internal_filesystem/boot*.py``` files. +By design, the only device-specific code for MicroPythonOS is found in the ```internal_filesystem/lib/mpos/board/.py``` files. + ## Steps to port to a new device @@ -18,15 +19,17 @@ By design, the only device-specific code for MicroPythonOS is found in the ```in The goal is to have it boot and show a MicroPython REPL shell on the serial line. - Take a look at our [build_lvgl_micropython.sh](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/scripts/build_lvgl_micropython.sh) script. A "dev" build (without any "frozen" filesystem) is preferred as this will still change a lot. + Take a look at our [build_mpos.sh](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/scripts/build_mpos.sh) script. Also go over the [official lvgl_micropython documentation](https://github.com/lvgl-micropython/lvgl_micropython/blob/main/README.md) for porting instructions. If you're in luck, your device is already listed in the esp32 BOARD list. Otherwise use a generic one like `BOARD=ESP32_GENERIC` with `BOARD_VARIANT=SPIRAM` or `BOARD=ESP32_GENERIC_S3` with `BOARD_VARIANT=SPIRAM_OCT` if it has an SPIRAM. 2. Figure out how to initialize the display for the new device - Use the MicroPython REPL shell on the serial port to type or paste (CTRL-E) MicroPython code. + Use the MicroPython REPL shell on the serial port to type or paste (CTRL-E) MicroPython code manually at first to see what works. - Check out how it's done for the [Waveshare 2 inch Touch Screen](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/boot.py) and for the [Fri3d Camp 2024 Badge](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/boot_fri3d-2024.py). You essentially need to set the correct pins to which the display is connected (like `LCD_SCLK`, `LCD_MOSI`, `LCD_MOSI` etc.) and also set the resolution of the display (`TFT_HOR_RES`, `TFT_VER_RE`S). + Take a look at [```waveshare_esp32_s3_touch_lcd_2.py```](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py) or [```fri3d_2024.py```](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/lib/mpos/board/fri3d_2024.py). + + You essentially need to set the correct pins to which the display is connected (like `LCD_SCLK`, `LCD_MOSI`, `LCD_MOSI` etc.) and also set the resolution of the display (`TFT_HOR_RES`, `TFT_VER_RE`S). After a failed attempt, reset the device to make sure the hardware is in a known initial state again. @@ -47,17 +50,16 @@ By design, the only device-specific code for MicroPythonOS is found in the ```in ``` -3. Put the initialization code in a custom boot_...py file for your device +3. Put the initialization code in a custom ```.py``` file for your device -4. Copy the custom boot_...py file and the generic MicroPythonOS files to your device (see [install.sh](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/scripts/install.sh) +4. Copy the custom ```.py``` file and the generic MicroPythonOS files to your device (see [install.sh](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/scripts/install.sh) - After reset, your custom boot_...py file should initialize the display and then MicroPythonOS should start, run the launcher, which shows the icons etc. + After reset, your custom ```.py``` file should initialize the display and then MicroPythonOS should start, run the launcher, which shows the icons etc. 5. Add more hardware support - If your device has a touch screen, check out how it's initialized for the [Waveshare 2 inch Touch Screen](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/boot.py). - If it has buttons for input, check out the KeyPad code for the [Fri3d Camp 2024 Badge](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/boot_fri3d-2024.py). + If your device has a touch screen, check out how it's initialized for the [Waveshare 2 inch Touch Screen](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py). If it has buttons for input, check out the KeyPad code for the [Fri3d Camp 2024 Badge](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/internal_filesystem/lib/mpos/board/fri3d_2024.py). Now you should be able to control the device, connect to WiFi and install more apps from the AppStore. - This would be a good time to create a pull-request to merge your boot_...py file into the main codebase so the support becomes official! + This would be a good time to create a pull-request to merge your ```.py``` file into the main codebase so the support becomes official! diff --git a/docs/os-development/running-on-desktop.md b/docs/os-development/running-on-desktop.md new file mode 100644 index 0000000..808cc34 --- /dev/null +++ b/docs/os-development/running-on-desktop.md @@ -0,0 +1,120 @@ +## Running on desktop + +MicroPythonOS runs on Linux, macOS and Raspberry Pi desktops. The desktop build is a single executable that contains the whole OS, so you usually do not need to compile anything. + +Pick the level that matches what you want to do. + +--- + +### Level 1: Just want to run it? + +Grab a pre-built binary and launch it. + +1. Download the binary for your platform from the [releases page](https://github.com/MicroPythonOS/MicroPythonOS/releases): + - **Linux, Raspberry Pi, WSL2 on Windows:** `lvgl_micropy_unix` + - **macOS (Apple Silicon or Intel):** `lvgl_micropy_macOS` +2. Make it executable: + +```bash +chmod +x lvgl_micropy_unix +``` + +3. Run it. From a terminal: + +```bash +./lvgl_micropy_unix +``` + +Or double-click it in your file manager. You may want to rename it to `MicroPythonOS` first. + +--- + +### Level 2: Want to develop an app but use a prebuilt OS? + +You can use a downloaded OS binary with a full source checkout so you can edit apps and see changes immediately. + +1. Clone the repository: + +```bash +git clone --recurse-submodules https://github.com/MicroPythonOS/MicroPythonOS.git +cd MicroPythonOS +``` + +2. Download the binary for your platform from the [releases page](https://github.com/MicroPythonOS/MicroPythonOS/releases). Rename it and put it where the runner script expects it: + - Linux / Raspberry Pi / WSL2 → `lvgl_micropython/build/lvgl_micropy_unix` + - macOS → `lvgl_micropython/build/lvgl_micropy_macOS` + +```bash +mkdir -p lvgl_micropython/build +cp /path/to/downloaded/binary lvgl_micropython/build/lvgl_micropy_unix +chmod +x lvgl_micropython/build/lvgl_micropy_unix +``` + +3. Run it with the helper script: + +```bash +./scripts/run_desktop.sh +``` + +`scripts/run_desktop.sh` launches the OS using the Python files in `internal_filesystem/` directly, so any edit you make there appears the next time you restart the app. No rebuild is needed. + +**Try it:** + +1. Edit `internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py` +2. Run `./scripts/run_desktop.sh` +3. Open the About app +4. See your change immediately + +Once your app works on desktop, deploy it to a physical device with [Installing on ESP32](installing-on-esp32.md). + +--- + +### Level 3: Want to build the OS yourself? + +If you need to change the OS itself, add C extensions, modify MicroPython/LVGL bindings, or run the very latest code, build from source. The binary will already land in `lvgl_micropython/build/lvgl_micropy_XXX` where `XXX` is `unix` or `macOS`. + +```bash +./scripts/build_mpos.sh unix # Linux, Raspberry Pi, WSL2 +./scripts/build_mpos.sh macOS # macOS +``` + +See [Compiling](compiling.md) for the full instructions, including cloning and dependencies. + +--- + +## Known issues and fixes + +### Linux: "No such file or directory" when running the binary + +Make sure it is executable: + +```bash +chmod +x lvgl_micropy_unix +``` + +### macOS: missing `libffi.8.dylib` + +Install libffi: + +```bash +brew install libffi +``` + +### macOS: "cannot be opened because the developer cannot be verified" + +The prebuilt binary is not signed. Open **System Settings → Privacy & Security** and click **Allow Anyway** next to the blocked item, then run it again. + +![Allow Anyway on MacOS](/os-development/macos-allow-anyway.png) + +### Windows + +Native Windows builds are not supported. [Users report](https://github.com/MicroPythonOS/MicroPythonOS/issues/31) that the Linux desktop binary works under WSL2 on Windows 11. Alternatively, you can try the [web port](../web-port/using.md), which runs a desktop build in the browser. + +--- + +## Deploying to hardware + +Once your app works on desktop, install it on a supported ESP32 device. + +{!os-development/installing-on-esp32.md!} + diff --git a/docs/os-development/windows.md b/docs/os-development/windows.md index a03a434..490021a 100644 --- a/docs/os-development/windows.md +++ b/docs/os-development/windows.md @@ -1,9 +1,13 @@ # Building for Windows -As the main dependency ([lvgl_micropython](https://github.com/lvgl-micropython/lvgl_micropython), which bundles LVGL and MicroPython) doesn't support Windows, MicroPythonOS also doesn't support it. +As the main dependency ([lvgl_micropython](https://github.com/lvgl-micropython/lvgl_micropython), which bundles LVGL and MicroPython) doesn't support Windows, MicroPythonOS also doesn't officially support it. -But this is only necessary for OS development. +But [users report](https://github.com/MicroPythonOS/MicroPythonOS/issues/31) that the Linux desktop version runs just fine under WSL2 on Windows 11. +So you can use that to create and test apps on desktop, which is very convenient. -You can still participate in the bulk of the fun: creating cool apps! +Compiling MicroPythonOS from source is only necessary for deep, low-level OS development like modifying the underlying MicroPython, LVGL or C bindings, so you might not need that. +If you do, and really want to use Windows, then you might be able to get it to build using WSL2 on Windows 11 after trying the [Linux instructions](linux.md). -To do so, install a pre-built firmware on a [supported device](../getting-started/supported-hardware.md) and then hopping over to [Creating Apps](../apps/creating-apps.md). +Even without self-compiling everything, you can still participate in the bulk of the fun: creating cool apps! + +To do so, run it on desktop, or install a pre-built firmware on a [supported device](../getting-started/supported-hardware.md) and then over to [Creating Apps](../apps/creating-apps.md). diff --git a/docs/other/merge-checklist.md b/docs/other/merge-checklist.md new file mode 100644 index 0000000..7a23c10 --- /dev/null +++ b/docs/other/merge-checklist.md @@ -0,0 +1,18 @@ +Before merging a pull request, we should consider the following: + +Making sure to update related things: + +- does the "Future release (next version)" section at the top of [CHANGELOG.md](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/CHANGELOG.md) need to be expanded? +- does an App's version number (MANIFEST.JSON) need incrementing? Normally only when you modify an App. +- does the [documentation](https://GitHub.com/MicroPythonOS/docs) need updating? Usually when you modify or add a Framework, but can also be for other things. Always good! +- does [MAINTAINERS.md](https://github.com/MicroPythonOS/MicroPythonOS/blob/main/MAINTAINERS.md) need to be updated? Usually only when adding a new board that you'll be maintaining. + +Making sure the logic is sound: + +- if it changes a Setting's definition, will old settings be properly migrated to the new setting? +- did you add or expand one or more [unit tests to make sure your code works](../os-development/automated-testing.md), in all edge cases, and future regressions are caught? + +Making sure it's easy to review: + +- are non-functional changes that result in a big diff done in a separate commit (or ideally, separate pull request) from functional changes? + diff --git a/docs/other/release-checklist.md b/docs/other/release-checklist.md index 83ed26b..dd8a845 100644 --- a/docs/other/release-checklist.md +++ b/docs/other/release-checklist.md @@ -2,22 +2,46 @@ Follow these steps to create a new release of MicroPythonOS. -**Update Changelog**: - -Document changes in `CHANGELOG.md`. - **Update Version Numbers**: - - Increment `CURRENT_OS_VERSION` in `internal_filesystem/lib/mpos/info.py`. + - Increment `CURRENT_OS_VERSION` in `internal_filesystem/lib/mpos/build_info.py` - Update version numbers for modified apps: ``` -git diff --stat 0.0.4 internal_filesystem/ # Check changes since last release -git diff 0.0.4 -- internal_filesystem/apps/*/META-INF/* # Check app manifests -git diff 0.0.4 -- internal_filesystem/builtin/apps/*/META-INF/* # Check built-in app manifests +git diff --stat 0.6.0 internal_filesystem/ # Check changes since last release, make sure each app change is accompanied by a MANIFEST.json change ``` -**Commit and push** all changes, also in external repositories (e.g., [LightningPiggy](https://github.com/LightningPiggy/LightningPiggyApp)): +**Update Changelog**: + +- Compare MicroPythonOS/CHANGELOG.md to the "git log" or "git log -p" or "git diff 0.6.0" to see if anything is missing since the last release tag +- Document changes in `CHANGELOG.md` +- Run `./scripts/changelog_to_json.sh` to make sure the CHANGELOG.md is json-friendly + +**Commit and push** all changes, also in external repositories (e.g., [LightningPiggy](https://github.com/LightningPiggy/LightningPiggyApp)). + +This will trigger the GitHub builds at https://github.com/MicroPythonOS/MicroPythonOS/actions + +**Download the builds** + +When finished, download and extract the builds as artifacts from the [GitHub actions](https://github.com/MicroPythonOS/MicroPythonOS/actions). + +**Release to Over-The-Air update** + +- Copy `MicroPythonOS_esp32_0.6.0.ota` to the [updates repository](https://github.com/MicroPythonOS/updates) +- Update the `osupdate*.json` metadata files with the new file and the output from `./scripts/changelog_to_json.sh` + +**Release to the web installer** + +- Copy `MicroPythonOS_esp32_0.6.0.bin` file to the [web installer](https://github.com/MicroPythonOS/install) +- Update the [manifest.json metadata file](https://github.com/MicroPythonOS/install/blob/master/manifests/esp32/MicroPythonOS_esp32_0.6.x.json) +- Update `index.html` if necessary (for example, if you added a new metadata.json you need to update the 2 references) + +**Release to GitHub** + +- Upload the builds a [new GitHub release](https://github.com/MicroPythonOS/MicroPythonOS/releases/new) +- Fill in the new tag (e.g. 0.6.0) +- Copy-paste the list from `CHANGELOG.md` in it +- Add the "What to do" section at the bottom **Bundle and publish apps**: @@ -29,27 +53,15 @@ git commit -a git push ``` -**Build for all supported devices** - -``` -./scripts/build_all.sh -``` - -**Release to GitHub** - -- Upload ``` ../build_outputs/ ``` to a [new GitHub release](https://github.com/MicroPythonOS/MicroPythonOS/releases/new) -- Add the CHANGELOG.md -- Tag the code with the new release +** Announce the release ** -**Copy the builds to the [install](https://github.com/MicroPythonOS/install) and [updates](https://github.com/MicroPythonOS/updates) repositories**: - -This is a manual action, but check out these old scripts for inspiration: -``` -scripts/release_to_updates.sh -scripts/release_to_install.sh -``` +- On the community https://chat.MicroPythonOS.com +- In the LightningPiggy chat +- On Nostr +- On Twitter +- In the Press ## Notes - Ensure all repositories are pushed before tagging. -- Verify builds on target hardware (see [Building for ESP32](esp32.md)). +- Verify builds on target hardware diff --git a/docs/other/supported-file-formats.md b/docs/other/supported-file-formats.md new file mode 100644 index 0000000..454ea6f --- /dev/null +++ b/docs/other/supported-file-formats.md @@ -0,0 +1,32 @@ +# Supported File Formats + +MicroPythonOS uses LVGL's built-in image decoders and its own audio stack. The formats below are supported out of the box for apps such as the Image Viewer and Music Player. + +## Image formats + +| Format | Extensions | Notes | +|--------|------------|-------| +| BMP | `.bmp` | Supports RGB565 and RGB888 | +| PNG | `.png` | Fully supported via LodePNG. | +| Baseline JPEG | `.jpg`, `.jpeg` | Fully supported via TJpgD. | +| Progressive JPEG | `.jpg`, `.jpeg` | **Not supported.** Files will decode as `0x0` and appear blank. | +| RAW | `.raw` | Supported by the Image Viewer app if named as `_x_RGB565.raw` or `_x_GRAY.raw`. | + +## Audio formats + +| Format | Extensions | Notes | +|--------|------------|-------| +| Plain PCM WAV | `.wav` | Standard RIFF/WAVE with `WAVE_FORMAT_PCM` (0x0001) or `WAVE_FORMAT_EXTENSIBLE` (0xFFFE) and 16-bit samples. | +| IMA ADPCM WAV | `.wav` | RIFF/WAVE with `WAVE_FORMAT_ADPCM` (0x0011), decoded by the built-in `adpcm_ima` module. Must have 4 or 16 bits per sample. | + +### JPEG conversion + +The built-in JPEG decoder only supports baseline JPEGs. The file extension is also treated case-sensitively, so `.JPG` or `.JPEG` will **not** be recognised. + +To convert an image to a supported baseline JPEG: + +```bash +convert input.jpg -interlace none output.jpg +``` + +`input.jpg` can be any image format that ImageMagick supports (including PNG), and `-interlace none` forces a non-progressive JPEG. diff --git a/docs/overrides/main.html b/docs/overrides/main.html index e6bf5cb..e17c762 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -1,7 +1,15 @@ {% extends "base.html" %} {% block styles %} - {{ super() }} +{{ super() }} + + + + + + + +