diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..b0ff8112 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +README.md merge=ours diff --git a/.github/actions/setup-build-environment/action.yml b/.github/actions/setup-build-environment/action.yml index 2ba7617e..7703c1ef 100644 --- a/.github/actions/setup-build-environment/action.yml +++ b/.github/actions/setup-build-environment/action.yml @@ -22,8 +22,10 @@ runs: pip install --upgrade platformio # a git tag of "room-server-v1.2.3" should set "v1.2.3" as GIT_TAG_VERSION + # a git tag of "wio-tracker-v1.15-plus.1.3" should set "v1.15-plus.1.3" as GIT_TAG_VERSION + # Strip the shortest prefix ending with "-v" so hyphens in the version are preserved. - name: Extract Version from Git Tag shell: bash run: | GIT_TAG_NAME="${GITHUB_REF#refs/tags/}" - echo "GIT_TAG_VERSION=${GIT_TAG_NAME##*-}" >> $GITHUB_ENV + echo "GIT_TAG_VERSION=v${GIT_TAG_NAME#*-v}" >> $GITHUB_ENV diff --git a/.github/workflows/build-companion-firmwares.yml b/.github/workflows/build-companion-firmwares.yml index 721076a1..4213c8e5 100644 --- a/.github/workflows/build-companion-firmwares.yml +++ b/.github/workflows/build-companion-firmwares.yml @@ -9,6 +9,9 @@ on: tags: - 'companion-*' +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build: diff --git a/.github/workflows/build-repeater-firmwares.yml b/.github/workflows/build-repeater-firmwares.yml index f12bd829..0af173b5 100644 --- a/.github/workflows/build-repeater-firmwares.yml +++ b/.github/workflows/build-repeater-firmwares.yml @@ -9,6 +9,9 @@ on: tags: - 'repeater-*' +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build: diff --git a/.github/workflows/build-room-server-firmwares.yml b/.github/workflows/build-room-server-firmwares.yml index a488af6a..e4306d6c 100644 --- a/.github/workflows/build-room-server-firmwares.yml +++ b/.github/workflows/build-room-server-firmwares.yml @@ -9,6 +9,9 @@ on: tags: - 'room-server-*' +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build: diff --git a/.github/workflows/build-wio-tracker-l1-firmwares.yml b/.github/workflows/build-wio-tracker-l1-firmwares.yml new file mode 100644 index 00000000..caf5545d --- /dev/null +++ b/.github/workflows/build-wio-tracker-l1-firmwares.yml @@ -0,0 +1,90 @@ +name: Build Wio Tracker L1 Firmwares + +permissions: + contents: write + +on: + workflow_dispatch: + push: + tags: + - 'wio-tracker-v*' + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + + build-oled: + runs-on: ubuntu-latest + steps: + + - name: Clone Repo + uses: actions/checkout@v4 + with: + ref: wio-tracker-l1-improvements + + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + + - name: Build Dual OLED Firmware + env: + FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} + run: /usr/bin/env bash build.sh build-wio-tracker-l1-firmwares + + - name: Upload OLED Firmware + uses: actions/upload-artifact@v4 + with: + name: wio-tracker-l1-oled-firmware + path: out + + build-eink: + runs-on: ubuntu-latest + steps: + + - name: Clone Repo + uses: actions/checkout@v4 + with: + ref: eink-ui + + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + + - name: Build Dual E-ink Firmware + env: + FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} + run: /usr/bin/env bash build.sh build-firmware WioTrackerL1Eink_companion_radio_dual + + - name: Upload E-ink Firmware + uses: actions/upload-artifact@v4 + with: + name: wio-tracker-l1-eink-firmware + path: out + + release: + needs: [build-oled, build-eink] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + + - name: Extract Version from Git Tag + run: echo "GIT_TAG_VERSION=v${GITHUB_REF_NAME#*-v}" >> $GITHUB_ENV + + - name: Download OLED Firmware + uses: actions/download-artifact@v4 + with: + name: wio-tracker-l1-oled-firmware + path: out + + - name: Download E-ink Firmware + uses: actions/download-artifact@v4 + with: + name: wio-tracker-l1-eink-firmware + path: out + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + name: Wio Tracker L1 Firmware ${{ env.GIT_TAG_VERSION }} + body: "" + draft: true + files: out/*.uf2 diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 5fd2734b..7a899a36 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -9,6 +9,9 @@ on: permissions: contents: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: github-pages: runs-on: ubuntu-latest diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index 37f3701b..476954fb 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -18,6 +18,9 @@ on: - 'platformio.ini' - '.github/workflows/pr-build-check.yml' +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build: runs-on: ubuntu-latest diff --git a/README.md b/README.md index f8b9e5e0..e86f031d 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,205 @@ -## About MeshCore +# Wio Tracker L1 — Extended Companion Radio Firmware -MeshCore is a lightweight, portable C++ library that enables multi-hop packet routing for embedded projects using LoRa and other packet radios. It is designed for developers who want to create resilient, decentralized communication networks that work without the internet. +This branch extends the official MeshCore companion radio firmware for the **Seeed Wio Tracker L1**. -## 🔍 What is MeshCore? +Join the discussion on offical MeshCore discord: https://discord.gg/sdhYArU2jr -MeshCore now supports a range of LoRa devices, allowing for easy flashing without the need to compile firmware manually. Users can flash a pre-built binary using tools like Adafruit ESPTool and interact with the network through a serial console. -MeshCore provides the ability to create wireless mesh networks, similar to Meshtastic and Reticulum but with a focus on lightweight multi-hop packet routing for embedded projects. Unlike Meshtastic, which is tailored for casual LoRa communication, or Reticulum, which offers advanced networking, MeshCore balances simplicity with scalability, making it ideal for custom embedded solutions., where devices (nodes) can communicate over long distances by relaying messages through intermediate nodes. This is especially useful in off-grid, emergency, or tactical situations where traditional communication infrastructure is unavailable. +## New Features -## ⚡ Key Features +### Messages Screen -* Multi-Hop Packet Routing - * Devices can forward messages across multiple nodes, extending range beyond a single radio's reach. - * Supports up to a configurable number of hops to balance network efficiency and prevent excessive traffic. - * Nodes use fixed roles where "Companion" nodes are not repeating messages at all to prevent adverse routing paths from being used. -* Supports LoRa Radios – Works with Heltec, RAK Wireless, and other LoRa-based hardware. -* Decentralized & Resilient – No central server or internet required; the network is self-healing. -* Low Power Consumption – Ideal for battery-powered or solar-powered devices. -* Simple to Deploy – Pre-built example applications make it easy to get started. +View and send messages using the on-screen keyboard or predefined quick replies. The keyboard supports placeholders that insert live sensor data — `{time}` and `{loc}` are always available; additional placeholders (`{temp}`, `{hum}`, `{pres}`, `{batt}`, `{alt}`, `{lux}`, `{co2}`) appear automatically for sensors that are connected and returning data. -## 🎯 What Can You Use MeshCore For? +Each message in the history list shows the sender name and a compact age indicator (`3m`, `2h`, `>1d`) in the top-right corner of the entry. -* Off-Grid Communication: Stay connected even in remote areas. -* Emergency Response & Disaster Recovery: Set up instant networks where infrastructure is down. -* Outdoor Activities: Hiking, camping, and adventure racing communication. -* Tactical & Security Applications: Military, law enforcement, and private security use cases. -* IoT & Sensor Networks: Collect data from remote sensors and relay it back to a central location. +``` +╔══════════════════════════════╗ +║ #general ║ +╠══════════════════════════════╣ +║▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ║ ← selected +║ Alice 3m ║ +║ Hey, let's meet tomorrow ║ +║┌───────────────────────────┐ ║ +║│▓▓▓▓▓▓▓▓▓ Bob 1h ▓▓▓▓ │ ║ +║│ Sure, what time works? │ ║ +║└───────────────────────────┘ ║ +║[+ send] ║ +╚══════════════════════════════╝ +``` -## 🚀 How to Get Started +Press Enter on a message to open it in fullscreen. Navigate between messages with left (newer) and right (older). If the message is a reply addressed to someone (`@[nick]`), a **To: nick** bar is shown below the sender name and the body is displayed without the address prefix. -- Watch the [MeshCore Intro Video](https://www.youtube.com/watch?v=t1qne8uJBAc) by Andy Kirby. -- Watch the [MeshCore Technical Presentation](https://www.youtube.com/watch?v=OwmkVkZQTf4) by Liam Cottle. -- Read through our [Frequently Asked Questions](./docs/faq.md) and [Documentation](https://docs.meshcore.io). -- Flash the MeshCore firmware on a supported device. -- Connect with a supported client. +``` +╔══════════════════════════════╗ +║▓▓ Alice ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ║ ← sender +║▓▓ To: Bob ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ║ ← recipient +╠══════════════════════════════╣ +║ Hey Bob, let's meet up ║ +║ tomorrow at 6pm downtown. ║ +║ Will you make it? ║ +║ ║ +║< newer older >║ +╚══════════════════════════════╝ +``` -For developers; +Hold Enter on a message to open a context menu. From the list or fullscreen view, select **Reply** to pre-fill the keyboard or a quick message with `@[nick]` so the recipient is clearly addressed. The reply picker title shows the recipient's name. -- Install [PlatformIO](https://docs.platformio.org) in [Visual Studio Code](https://code.visualstudio.com). -- Clone and open the MeshCore repository in Visual Studio Code. -- See the example applications you can modify and run: - - [Companion Radio](./examples/companion_radio) - For use with an external chat app, over BLE, USB or WiFi. - - [KISS Modem](./examples/kiss_modem) - Serial KISS protocol bridge for host applications. ([protocol docs](./docs/kiss_modem_protocol.md)) - - [Simple Repeater](./examples/simple_repeater) - Extends network coverage by relaying messages. - - [Simple Room Server](./examples/simple_room_server) - A simple BBS server for shared Posts. - - [Simple Secure Chat](./examples/simple_secure_chat) - Secure terminal based text communication between devices. - - [Simple Sensor](./examples/simple_sensor) - Remote sensor node with telemetry and alerting. +``` +╔══════════════════════════════╗ +║ RE:Alice ║ +╠══════════════════════════════╣ +║> Custom message... ║ +║ OK, I understand ║ +║ On my way ║ +║ Be right there ║ +╚══════════════════════════════╝ +``` -The Simple Secure Chat example can be interacted with through the Serial Monitor in Visual Studio Code, or with a Serial USB Terminal on Android. +On channel or contact list entries, the context menu also lets you change per-channel notification settings (mute, follow global, or force-on), per-channel melody override (follow global, Melody 1, or Melody 2), or mark messages as read. -## ⚡️ MeshCore Flasher +### Settings Screen -We have prebuilt firmware ready to flash on supported devices. +All settings are saved to flash and restored on next boot. -- Launch https://meshcore.io/flasher -- Select a supported device -- Flash one of the firmware types: - - Companion, Repeater or Room Server -- Once flashing is complete, you can connect with one of the MeshCore clients below. +- **Display** + - Brightness + - Auto-off timeout + - Battery display mode (icon, %, V) + - Clock seconds (show/hide — hiding reduces display refresh from 1 s to 60 s) + - Font — **Default** (5×7 Adafruit, ASCII + transliteration) or **Lemon** (native Unicode, available in the `font-switcher` build only) +- **Sound** + - Buzzer: On / Off / **Auto** — Auto mode silences the device while connected via Bluetooth, and re-enables sound when the connection drops + - Volume (1–5; preview tone plays on each change) + - DM Melody — notification sound for incoming private messages: built-in, Melody 1, or Melody 2 + - Channel Melody — notification sound for incoming channel messages: built-in, Melody 1, or Melody 2 +- **Home Pages** — toggle visibility of individual home screen pages +- **Radio** + - TX power +- **System** + - Timezone (UTC offset in hours) + - Low battery shutdown threshold + - Auto-lock — automatically locks the device when the display turns off +- **GPS** + - Position broadcast interval +- **Contacts** + - Show all DMs or favourites only + - Show all room servers or favourites only +- **Messages** + - Edit up to 10 quick reply templates (Q1–Q10) -## 📱 MeshCore Clients +### Clock Screen -**Companion Firmware** +A dedicated clock page on the home screen shows the current time and date, synchronized from GPS or via Bluetooth. Timezone offset is applied from Settings. -The companion firmware can be connected to via BLE, USB or WiFi depending on the firmware type you flashed. +Up to three configurable data fields are displayed below the clock. Available fields: battery voltage, temperature, humidity, pressure, GPS coordinates, altitude, luminosity, CO₂, contact count, and total unread message count. -- Web: https://app.meshcore.nz -- Android: https://play.google.com/store/apps/details?id=com.liamcottle.meshcore.android -- iOS: https://apps.apple.com/us/app/meshcore/id6742354151?platform=iphone -- NodeJS: https://github.com/liamcottle/meshcore.js -- Python: https://github.com/fdlamotte/meshcore-cli +### Screen Lock -**Repeater and Room Server Firmware** +Hold **Back** and press **Enter** three times to lock or unlock the device. While locked: -The repeater and room server firmwares can be setup via USB in the web config tool. +- The display turns off and ignores incoming keypresses +- A brief button press shows the lock screen: current time, date, and up to two sensor values (reuses the Dashboard Config fields) +- A hint popup guides through the unlock sequence +- **Auto-lock** (configurable in Settings → System) locks automatically when the display turns off -- https://config.meshcore.io +### Nearby Nodes -They can also be managed via LoRa in the mobile app by using the Remote Management feature. +Browse nodes that have recently advertised on the mesh. Filter by category (Favourites, All, Companion, Repeater, Room, Sensor). Select a node to see its coordinates, distance, bearing with cardinal direction, type, and last-heard time. -## 🛠 Hardware Compatibility +``` +╔══════════════════════════════╗ +║▓▓▓▓▓ WioTracker-Alice ▓▓▓▓▓▓ ║ ← node name +╠══════════════════════════════╣ +║ Lat: 50.06190 ║ +║ Lon: 19.94090 ║ +║ Dist: 2.3km ║ +║ Az: 145d (SE) ║ +║ Type: Companion ║ +║ Seen: 5m ago ║ +╚══════════════════════════════╝ +``` -MeshCore is designed for devices listed in the [MeshCore Flasher](https://meshcore.io/flasher) +Use **Send my advert** (hold Enter → context menu) to broadcast your position and prompt nearby nodes to respond with theirs. -## 📜 License +#### Active Discovery -MeshCore is open-source software released under the MIT License. You are free to use, modify, and distribute it for personal and commercial projects. +Press **Enter** from the Nearby screen to send a live `NODE_DISCOVER_REQ` ping. All reachable repeaters, sensors and room servers within zero-hop range respond immediately with their name, type and signal data. Results appear as 2-line boxed cards with RSSI, SNR and the remote SNR (signal quality at the responder's end). -## Contributing +``` +╔═══════════════════════════════╗ +║▓▓▓▓▓▓▓ DISCOVER (2 found) ▓▓ ║ +╠═══════════════════════════════╣ +║▓▓▓▓▓▓▓▓▓▓▓▓▓ Rptr-A Rpt ▓▓▓▓║ ← selected +║▓▓▓▓▓▓▓▓ RSSI:-79 SNR:9 ▓▓▓▓▓▓▓║ +║┌────────────────────────────┐ ║ +║│▓▓▓▓▓▓▓▓▓▓▓▓ Sensor-B Snsr │ ║ +║│ RSSI:-94 SNR:3 │ ║ +║└────────────────────────────┘ ║ +╚═══════════════════════════════╝ +``` -Please submit PR's using 'dev' as the base branch! -For minor changes just submit your PR and we'll try to review it, but for anything more 'impactful' please open an Issue first and start a discussion. Is better to sound out what it is you want to achieve first, and try to come to a consensus on what the best approach is, especially when it impacts the structure or architecture of this codebase. +Navigate with **UP/DOWN**. Press **Enter** on a node to open a full-screen detail view showing the public key, RSSI, SNR, remote SNR and whether the node is already in your contacts. -Here are some general principals you should try to adhere to: -* Keep it simple. Please, don't think like a high-level lang programmer. Think embedded, and keep code concise, without any unnecessary layers. -* No dynamic memory allocation, except during setup/begin functions. -* Use the same brace and indenting style that's in the core source modules. (A .clang-format is prob going to be added soon, but please do NOT retroactively re-format existing code. This just creates unnecessary diffs that make finding problems harder) +``` +╔══════════════════════════════╗ +║▓▓▓▓▓▓▓▓▓▓▓▓▓ Rptr-A ▓▓▓▓▓▓▓ ║ +╠══════════════════════════════╣ +║ Key: ABCdef12XYZ... ║ +║ RSSI: -79 dBm ║ +║ SNR: 9 dB ║ +║ Rem: 6 dB ║ +║ Status: known ║ +╚══════════════════════════════╝ +``` -Help us prioritize! Please react with thumbs-up to issues/PRs you care about most. We look at reaction counts when planning work. +Hold **Enter** from the discovery list to rescan. Press **Cancel** or **Back** to return. -## Road-Map / To-Do +### Tools Screen -There are a number of fairly major features in the pipeline, with no particular time-frames attached yet. In very rough chronological order: -- [X] Companion radio: UI redesign -- [X] Repeater + Room Server: add ACL's (like Sensor Node has) -- [X] Standardise Bridge mode for repeaters -- [ ] Repeater/Bridge: Standardise the Transport Codes for zoning/filtering -- [X] Core + Repeater: enhanced zero-hop neighbour discovery -- [ ] Core: round-trip manual path support -- [ ] Companion + Apps: support for multiple sub-meshes (and 'off-grid' client repeat mode) -- [ ] Core + Apps: support for LZW message compression -- [ ] Core: dynamic CR (Coding Rate) for weak vs strong hops -- [ ] Core: new framework for hosting multiple virtual nodes on one physical device -- [ ] V2 protocol spec: discussion and consensus around V2 packet protocol, including path hashes, new encryption specs, etc +#### Auto-Advert -## 📞 Get Support +Periodically broadcasts a 0-hop advert with your GPS position so nearby nodes can track your location automatically. Configurable interval: off / 1 min / 2 min / 5 min / 10 min / 30 min / 1 h. -- Report bugs and request features on the [GitHub Issues](https://github.com/ripplebiz/MeshCore/issues) page. -- Find additional guides and components on [my site](https://buymeacoffee.com/ripplebiz). -- Join [MeshCore Discord](https://meshcore.gg) to chat with the developers and get help from the community. +A blinking **A** indicator appears in the status bar while Auto-Advert is active. + +#### Ringtone Editor + +A step sequencer for composing custom notification melodies stored on the device. Two independent melody slots are available — **Melody 1** and **Melody 2** — switchable from within the editor. Each melody supports up to 32 notes with adjustable pitch (C–B + pause), octave (4–7), duration (whole / half / quarter / eighth), and BPM (60 / 90 / 120 / 150 / 180). Playback preview is available directly from the editor. + +Melodies can be assigned as notification sounds per message type (DM / channel) in Settings, and individually overridden per channel or per contact from the message screen context menu. + +#### Auto-Reply Bot + +Automatically replies to incoming messages that contain a configured trigger word (case-insensitive). + +- **DM mode** — when enabled, the bot listens to all incoming private messages and replies with the DM reply text. +- **Channel mode** — optionally, select a channel for the bot to monitor. When a trigger is matched, it replies with a separate channel reply text. +- Both modes can be active simultaneously and share the same trigger word but use independent reply texts. +- Replies support placeholders (`{time}`, `{loc}`). +- A 10-second cooldown prevents repeated replies in quick succession. + +--- + +## Font Switcher Build + +An alternative firmware build is available (`font-switcher` in the filename). It includes both the default 5×7 Adafruit font and the [Lemon bitmap font](https://github.com/cmvnd/fonts), switchable at runtime in **Settings → Display → Font** without reflashing. The selected font is saved to flash and takes effect immediately. + +The Lemon font offers: + +- **Native Unicode rendering** — Latin Extended, Greek, and Cyrillic characters (U+0020–U+04FF) are displayed directly without transliteration, covering Polish, Czech, Slovak, German, French, Scandinavian, Hungarian, Romanian, Croatian, Turkish, Baltic, Russian, and Greek scripts. +- **Pixel-accurate word wrap** — text wraps based on actual glyph widths rather than a fixed character count. +- **Slightly taller glyphs** — the font uses a 10 px line height compared to 8 px for the default font. + +Prebuilt `.uf2` files for the font-switcher variant are included in each release alongside the standard builds and are identified by `font-switcher` in the filename. + +--- + +Feel free to explore, share feedback and feature requests! + +## Development + +This fork tracks the upstream [MeshCore](https://github.com/ripplebiz/MeshCore) repository. To prevent upstream changes from overwriting this README during merges, `README.md` is protected via `.gitattributes`. After cloning, run once: + +```sh +git config merge.ours.driver true +``` diff --git a/WIOTRACKER_L1.md b/WIOTRACKER_L1.md new file mode 100644 index 00000000..87d47ab0 --- /dev/null +++ b/WIOTRACKER_L1.md @@ -0,0 +1,71 @@ +# Wio Tracker L1 — Extended Companion Radio Firmware + +This branch extends the official MeshCore companion radio firmware for the **Seeed Wio Tracker L1** (NRF52840 + SX1262 + SH1106 OLED 128×64 + GPS + buzzer + 5-way joystick). + +## New Build Variants + +In addition to the standard `_companion_radio_usb` and `_companion_radio_ble` builds, two new variants are available with a full **settings screen**: + +| Environment | Interface | +|---|---| +| `WioTrackerL1_companion_radio_usb_settings` | USB serial | +| `WioTrackerL1_companion_radio_ble_settings` | Bluetooth LE | + +The settings screen is accessed by navigating to the last page of the home screen and pressing Enter. + +## New Features + +### Settings Screen + +All settings are saved to flash and restored on next boot. + +| Setting | Options | Default | +|---|---|---| +| Display brightness | 5 levels (very dim → max) | Medium | +| Buzzer | ON / OFF | ON | +| TX Power | 2 – 22 dBm | Board default | +| Auto-off delay | 5 s / 15 s / 30 s / 60 s / never | 15 s | +| GPS update interval | off / 30 s / 1 min / 5 min / 15 min / 30 min | off | +| Timezone | UTC−12 .. UTC+14 | UTC | +| Low battery shutdown | off / 3.0 V – 3.5 V | 3.4 V | +| Battery display | icon / % / voltage | icon | + +### Clock Screen + +A dedicated clock page on the home screen shows the current time and date, synchronized from GPS. Timezone is applied from the settings above. + +### Battery Display Modes + +The top-right battery indicator can be switched between: +- **Icon** — classic fill bar +- **%** — percentage, where 0% = low battery shutdown threshold (if set), otherwise 3.2 V +- **V** — live voltage (e.g. `3.84V`) + +Battery voltage is filtered with an exponential moving average (α = 0.2) to smooth out ADC noise from uneven load. + +### Low Battery Auto-Shutdown + +When the filtered battery voltage drops below the configured threshold, the device displays a warning on screen for 2 seconds and then shuts down gracefully. The threshold is configurable from the settings screen (3.0 V – 3.5 V) or can be disabled. + +## Bug Fixes + +- **Buzzer mute at startup** — the saved mute preference is now respected during the startup sound; previously the buzzer was always unmuted on boot regardless of the saved setting. +- **Brightness change delay** — brightness changes are now applied instantly without triggering a flash write on every keypress; flash is written only when exiting the settings screen. + +## Build & Release + +Firmware is built automatically via GitHub Actions on every `wio-tracker-*` tag push. +Pre-built `.uf2` files are published in the [Releases](../../releases) section. + +To flash: copy the `.uf2` file to the device while it is in bootloader mode (double-press reset). + +## Syncing with Upstream + +This branch tracks [meshcore-dev/MeshCore](https://github.com/meshcore-dev/MeshCore). +To pull upstream updates: + +```bash +git fetch upstream +git rebase upstream/main +git push origin wio-tracker-l1-improvements --force-with-lease +``` diff --git a/build.sh b/build.sh index 313c4c47..d34877cc 100755 --- a/build.sh +++ b/build.sh @@ -225,9 +225,15 @@ build_companion_firmwares() { # build all companion firmwares build_all_firmwares_by_suffix "_companion_radio_usb" build_all_firmwares_by_suffix "_companion_radio_ble" + build_all_firmwares_by_suffix "_companion_radio_usb_settings" + build_all_firmwares_by_suffix "_companion_radio_ble_settings" } +build_wio_tracker_l1_firmwares() { + build_firmware "WioTrackerL1_companion_radio_dual_settings" +} + build_room_server_firmwares() { # # build specific room server firmwares @@ -273,6 +279,8 @@ elif [[ $1 == "build-companion-firmwares" ]]; then build_companion_firmwares elif [[ $1 == "build-repeater-firmwares" ]]; then build_repeater_firmwares +elif [[ $1 == "build-wio-tracker-l1-firmwares" ]]; then + build_wio_tracker_l1_firmwares elif [[ $1 == "build-room-server-firmwares" ]]; then build_room_server_firmwares fi diff --git a/create-uf2.py b/create-uf2.py index 10ec0ed6..226f6f7e 100644 --- a/create-uf2.py +++ b/create-uf2.py @@ -21,6 +21,8 @@ def create_uf2_action(source, target, env): ) env.Execute(uf2_cmd) +env.AddPostAction(firmware_hex, create_uf2_action) + env.AddCustomTarget( name="create_uf2", dependencies=firmware_hex, diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index 0eee45ae..105f00a1 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -40,7 +40,9 @@ public: void enableSerial() { _serial->enable(); } void disableSerial() { _serial->disable(); } virtual void msgRead(int msgcount) = 0; - virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) = 0; + virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type = 0, const uint8_t* pub_key = nullptr) = 0; virtual void notify(UIEventType t = UIEventType::none) = 0; + virtual void addChannelMsg(uint8_t channel_idx, const char* text) {} + virtual void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) {} virtual void loop() = 0; }; diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index c7988bb3..78722ee9 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -204,35 +204,122 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no if (file) { uint8_t pad[8]; - file.read((uint8_t *)&_prefs.airtime_factor, sizeof(float)); // 0 - file.read((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name)); // 4 - file.read(pad, 4); // 36 - file.read((uint8_t *)&node_lat, sizeof(node_lat)); // 40 - file.read((uint8_t *)&node_lon, sizeof(node_lon)); // 48 - file.read((uint8_t *)&_prefs.freq, sizeof(_prefs.freq)); // 56 - file.read((uint8_t *)&_prefs.sf, sizeof(_prefs.sf)); // 60 - file.read((uint8_t *)&_prefs.cr, sizeof(_prefs.cr)); // 61 - file.read((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat)); // 62 - file.read((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts)); // 63 - file.read((uint8_t *)&_prefs.bw, sizeof(_prefs.bw)); // 64 - file.read((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm)); // 68 - file.read((uint8_t *)&_prefs.telemetry_mode_base, sizeof(_prefs.telemetry_mode_base)); // 69 - file.read((uint8_t *)&_prefs.telemetry_mode_loc, sizeof(_prefs.telemetry_mode_loc)); // 70 - file.read((uint8_t *)&_prefs.telemetry_mode_env, sizeof(_prefs.telemetry_mode_env)); // 71 - file.read((uint8_t *)&_prefs.rx_delay_base, sizeof(_prefs.rx_delay_base)); // 72 - file.read((uint8_t *)&_prefs.advert_loc_policy, sizeof(_prefs.advert_loc_policy)); // 76 - file.read((uint8_t *)&_prefs.multi_acks, sizeof(_prefs.multi_acks)); // 77 - file.read((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); // 78 - file.read(pad, 1); // 79 - file.read((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin)); // 80 - file.read((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet)); // 84 - file.read((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); // 85 - file.read((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86 - file.read((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87 - file.read((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 88 - file.read((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 89 - file.read((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 90 - file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 121 + file.read((uint8_t *)&_prefs.airtime_factor, sizeof(float)); + file.read((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name)); + file.read(pad, 4); + file.read((uint8_t *)&node_lat, sizeof(node_lat)); + file.read((uint8_t *)&node_lon, sizeof(node_lon)); + file.read((uint8_t *)&_prefs.freq, sizeof(_prefs.freq)); + file.read((uint8_t *)&_prefs.sf, sizeof(_prefs.sf)); + file.read((uint8_t *)&_prefs.cr, sizeof(_prefs.cr)); + file.read((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat)); + file.read((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts)); + file.read((uint8_t *)&_prefs.bw, sizeof(_prefs.bw)); + file.read((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm)); + file.read((uint8_t *)&_prefs.telemetry_mode_base, sizeof(_prefs.telemetry_mode_base)); + file.read((uint8_t *)&_prefs.telemetry_mode_loc, sizeof(_prefs.telemetry_mode_loc)); + file.read((uint8_t *)&_prefs.telemetry_mode_env, sizeof(_prefs.telemetry_mode_env)); + file.read((uint8_t *)&_prefs.rx_delay_base, sizeof(_prefs.rx_delay_base)); + file.read((uint8_t *)&_prefs.advert_loc_policy, sizeof(_prefs.advert_loc_policy)); + file.read((uint8_t *)&_prefs.multi_acks, sizeof(_prefs.multi_acks)); + file.read((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); + file.read(pad, 1); + file.read((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin)); + file.read((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet)); + file.read((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); + file.read((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); + file.read((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); + file.read((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); + file.read((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); + file.read((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); + file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); + file.read((uint8_t *)&_prefs.display_brightness, sizeof(_prefs.display_brightness)); + file.read((uint8_t *)&_prefs.auto_off_secs, sizeof(_prefs.auto_off_secs)); + file.read((uint8_t *)&_prefs.tz_offset_hours, sizeof(_prefs.tz_offset_hours)); + file.read((uint8_t *)&_prefs.low_batt_mv, sizeof(_prefs.low_batt_mv)); + file.read((uint8_t *)&_prefs.batt_display_mode, sizeof(_prefs.batt_display_mode)); + if (file.available()) { + file.read((uint8_t *)_prefs.custom_msgs, sizeof(_prefs.custom_msgs)); + file.read((uint8_t *)&_prefs.ch_notif_override, sizeof(_prefs.ch_notif_override)); + file.read((uint8_t *)&_prefs.ch_notif_muted, sizeof(_prefs.ch_notif_muted)); + file.read((uint8_t *)&_prefs.dm_show_all, sizeof(_prefs.dm_show_all)); + file.read((uint8_t *)&_prefs.room_fav_only, sizeof(_prefs.room_fav_only)); + if (file.available()) { + file.read((uint8_t *)&_prefs.buzzer_volume, sizeof(_prefs.buzzer_volume)); + if (file.available()) { + file.read((uint8_t *)&_prefs.ringtone_bpm_idx, sizeof(_prefs.ringtone_bpm_idx)); + file.read((uint8_t *)&_prefs.ringtone_len, sizeof(_prefs.ringtone_len)); + if (_prefs.ringtone_len > 32) _prefs.ringtone_len = 0; + file.read((uint8_t *)_prefs.ringtone_notes, sizeof(_prefs.ringtone_notes)); + if (file.available()) { + file.read((uint8_t *)&_prefs.home_pages_mask, sizeof(_prefs.home_pages_mask)); + if (file.available()) { + file.read((uint8_t *)&_prefs.bot_enabled, sizeof(_prefs.bot_enabled)); + file.read((uint8_t *)&_prefs.bot_channel_enabled, sizeof(_prefs.bot_channel_enabled)); + file.read((uint8_t *)&_prefs.bot_channel_idx, sizeof(_prefs.bot_channel_idx)); + file.read((uint8_t *)_prefs.bot_trigger, sizeof(_prefs.bot_trigger)); + file.read((uint8_t *)_prefs.bot_reply_dm, sizeof(_prefs.bot_reply_dm)); + file.read((uint8_t *)_prefs.bot_reply_ch, sizeof(_prefs.bot_reply_ch)); + if (file.available()) { + file.read((uint8_t *)&_prefs.clock_hide_seconds, sizeof(_prefs.clock_hide_seconds)); + if (file.available()) { + file.read((uint8_t *)&_prefs.buzzer_auto, sizeof(_prefs.buzzer_auto)); + if (file.available()) { + file.read((uint8_t *)_prefs.dm_notif, sizeof(_prefs.dm_notif)); + if (file.available()) { + file.read((uint8_t *)_prefs.dashboard_fields, sizeof(_prefs.dashboard_fields)); + if (file.available()) { + file.read((uint8_t *)&_prefs.advert_auto_interval_sec, sizeof(_prefs.advert_auto_interval_sec)); + if (file.available()) { + file.read((uint8_t *)&_prefs.ringtone2_bpm_idx, sizeof(_prefs.ringtone2_bpm_idx)); + file.read((uint8_t *)&_prefs.ringtone2_len, sizeof(_prefs.ringtone2_len)); + if (_prefs.ringtone2_len > 32) _prefs.ringtone2_len = 0; + file.read((uint8_t *)_prefs.ringtone2_notes, sizeof(_prefs.ringtone2_notes)); + if (file.available()) { + file.read((uint8_t *)&_prefs.notif_melody_dm, sizeof(_prefs.notif_melody_dm)); + file.read((uint8_t *)&_prefs.notif_melody_ch, sizeof(_prefs.notif_melody_ch)); + if (file.available()) { + file.read((uint8_t *)&_prefs.ch_notif_melody_set, sizeof(_prefs.ch_notif_melody_set)); + file.read((uint8_t *)&_prefs.ch_notif_melody_2, sizeof(_prefs.ch_notif_melody_2)); + if (file.available()) { + file.read((uint8_t *)_prefs.dm_melody, sizeof(_prefs.dm_melody)); + if (file.available()) { + file.read((uint8_t *)&_prefs.auto_lock, sizeof(_prefs.auto_lock)); + if (file.available()) { + file.read((uint8_t *)&_prefs.clock_12h, sizeof(_prefs.clock_12h)); + if (file.available()) { + file.read((uint8_t *)&_prefs.use_lemon_font, sizeof(_prefs.use_lemon_font)); + if (file.available()) { + file.read((uint8_t *)&_prefs.display_rotation, sizeof(_prefs.display_rotation)); + if (file.available()) { + file.read((uint8_t *)_prefs.page_order, sizeof(_prefs.page_order)); + } + } else { +#ifdef DISPLAY_ROTATION + _prefs.display_rotation = DISPLAY_ROTATION; +#endif + } + } + } else { +#ifdef DISPLAY_ROTATION + _prefs.display_rotation = DISPLAY_ROTATION; +#endif + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } file.close(); } @@ -244,40 +331,99 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ uint8_t pad[8]; memset(pad, 0, sizeof(pad)); - file.write((uint8_t *)&_prefs.airtime_factor, sizeof(float)); // 0 - file.write((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name)); // 4 - file.write(pad, 4); // 36 - file.write((uint8_t *)&node_lat, sizeof(node_lat)); // 40 - file.write((uint8_t *)&node_lon, sizeof(node_lon)); // 48 - file.write((uint8_t *)&_prefs.freq, sizeof(_prefs.freq)); // 56 - file.write((uint8_t *)&_prefs.sf, sizeof(_prefs.sf)); // 60 - file.write((uint8_t *)&_prefs.cr, sizeof(_prefs.cr)); // 61 - file.write((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat)); // 62 - file.write((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts)); // 63 - file.write((uint8_t *)&_prefs.bw, sizeof(_prefs.bw)); // 64 - file.write((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm)); // 68 - file.write((uint8_t *)&_prefs.telemetry_mode_base, sizeof(_prefs.telemetry_mode_base)); // 69 - file.write((uint8_t *)&_prefs.telemetry_mode_loc, sizeof(_prefs.telemetry_mode_loc)); // 70 - file.write((uint8_t *)&_prefs.telemetry_mode_env, sizeof(_prefs.telemetry_mode_env)); // 71 - file.write((uint8_t *)&_prefs.rx_delay_base, sizeof(_prefs.rx_delay_base)); // 72 - file.write((uint8_t *)&_prefs.advert_loc_policy, sizeof(_prefs.advert_loc_policy)); // 76 - file.write((uint8_t *)&_prefs.multi_acks, sizeof(_prefs.multi_acks)); // 77 - file.write((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); // 78 - file.write(pad, 1); // 79 - file.write((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin)); // 80 - file.write((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet)); // 84 - file.write((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); // 85 - file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86 - file.write((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87 - file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 88 - file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 89 - file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 90 - file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 121 + file.write((uint8_t *)&_prefs.airtime_factor, sizeof(float)); + file.write((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name)); + file.write(pad, 4); + file.write((uint8_t *)&node_lat, sizeof(node_lat)); + file.write((uint8_t *)&node_lon, sizeof(node_lon)); + file.write((uint8_t *)&_prefs.freq, sizeof(_prefs.freq)); + file.write((uint8_t *)&_prefs.sf, sizeof(_prefs.sf)); + file.write((uint8_t *)&_prefs.cr, sizeof(_prefs.cr)); + file.write((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat)); + file.write((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts)); + file.write((uint8_t *)&_prefs.bw, sizeof(_prefs.bw)); + file.write((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm)); + file.write((uint8_t *)&_prefs.telemetry_mode_base, sizeof(_prefs.telemetry_mode_base)); + file.write((uint8_t *)&_prefs.telemetry_mode_loc, sizeof(_prefs.telemetry_mode_loc)); + file.write((uint8_t *)&_prefs.telemetry_mode_env, sizeof(_prefs.telemetry_mode_env)); + file.write((uint8_t *)&_prefs.rx_delay_base, sizeof(_prefs.rx_delay_base)); + file.write((uint8_t *)&_prefs.advert_loc_policy, sizeof(_prefs.advert_loc_policy)); + file.write((uint8_t *)&_prefs.multi_acks, sizeof(_prefs.multi_acks)); + file.write((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); + file.write(pad, 1); + file.write((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin)); + file.write((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet)); + file.write((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); + file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); + file.write((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); + file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); + file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); + file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); + file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); + file.write((uint8_t *)&_prefs.display_brightness, sizeof(_prefs.display_brightness)); + file.write((uint8_t *)&_prefs.auto_off_secs, sizeof(_prefs.auto_off_secs)); + file.write((uint8_t *)&_prefs.tz_offset_hours, sizeof(_prefs.tz_offset_hours)); + file.write((uint8_t *)&_prefs.low_batt_mv, sizeof(_prefs.low_batt_mv)); + file.write((uint8_t *)&_prefs.batt_display_mode, sizeof(_prefs.batt_display_mode)); + file.write((uint8_t *)_prefs.custom_msgs, sizeof(_prefs.custom_msgs)); + file.write((uint8_t *)&_prefs.ch_notif_override, sizeof(_prefs.ch_notif_override)); + file.write((uint8_t *)&_prefs.ch_notif_muted, sizeof(_prefs.ch_notif_muted)); + file.write((uint8_t *)&_prefs.dm_show_all, sizeof(_prefs.dm_show_all)); + file.write((uint8_t *)&_prefs.room_fav_only, sizeof(_prefs.room_fav_only)); + file.write((uint8_t *)&_prefs.buzzer_volume, sizeof(_prefs.buzzer_volume)); + file.write((uint8_t *)&_prefs.ringtone_bpm_idx, sizeof(_prefs.ringtone_bpm_idx)); + file.write((uint8_t *)&_prefs.ringtone_len, sizeof(_prefs.ringtone_len)); + file.write((uint8_t *)_prefs.ringtone_notes, sizeof(_prefs.ringtone_notes)); + file.write((uint8_t *)&_prefs.home_pages_mask, sizeof(_prefs.home_pages_mask)); + file.write((uint8_t *)&_prefs.bot_enabled, sizeof(_prefs.bot_enabled)); + file.write((uint8_t *)&_prefs.bot_channel_enabled, sizeof(_prefs.bot_channel_enabled)); + file.write((uint8_t *)&_prefs.bot_channel_idx, sizeof(_prefs.bot_channel_idx)); + file.write((uint8_t *)_prefs.bot_trigger, sizeof(_prefs.bot_trigger)); + file.write((uint8_t *)_prefs.bot_reply_dm, sizeof(_prefs.bot_reply_dm)); + file.write((uint8_t *)_prefs.bot_reply_ch, sizeof(_prefs.bot_reply_ch)); + file.write((uint8_t *)&_prefs.clock_hide_seconds, sizeof(_prefs.clock_hide_seconds)); + file.write((uint8_t *)&_prefs.buzzer_auto, sizeof(_prefs.buzzer_auto)); + file.write((uint8_t *)_prefs.dm_notif, sizeof(_prefs.dm_notif)); + file.write((uint8_t *)_prefs.dashboard_fields, sizeof(_prefs.dashboard_fields)); + file.write((uint8_t *)&_prefs.advert_auto_interval_sec, sizeof(_prefs.advert_auto_interval_sec)); + file.write((uint8_t *)&_prefs.ringtone2_bpm_idx, sizeof(_prefs.ringtone2_bpm_idx)); + file.write((uint8_t *)&_prefs.ringtone2_len, sizeof(_prefs.ringtone2_len)); + file.write((uint8_t *)_prefs.ringtone2_notes, sizeof(_prefs.ringtone2_notes)); + file.write((uint8_t *)&_prefs.notif_melody_dm, sizeof(_prefs.notif_melody_dm)); + file.write((uint8_t *)&_prefs.notif_melody_ch, sizeof(_prefs.notif_melody_ch)); + file.write((uint8_t *)&_prefs.ch_notif_melody_set, sizeof(_prefs.ch_notif_melody_set)); + file.write((uint8_t *)&_prefs.ch_notif_melody_2, sizeof(_prefs.ch_notif_melody_2)); + file.write((uint8_t *)_prefs.dm_melody, sizeof(_prefs.dm_melody)); + file.write((uint8_t *)&_prefs.auto_lock, sizeof(_prefs.auto_lock)); + file.write((uint8_t *)&_prefs.clock_12h, sizeof(_prefs.clock_12h)); + file.write((uint8_t *)&_prefs.use_lemon_font, sizeof(_prefs.use_lemon_font)); + file.write((uint8_t *)&_prefs.display_rotation, sizeof(_prefs.display_rotation)); + file.write((uint8_t *)_prefs.page_order, sizeof(_prefs.page_order)); file.close(); } } +void DataStore::saveRTCTime() { + uint32_t t = _clock->getCurrentTime(); + if (t < 1000000000UL) return; // don't save if time not yet synced + File file = openWrite(_fs, "/rtc_save"); + if (file) { + file.write((uint8_t *)&t, sizeof(t)); + file.close(); + } +} + +void DataStore::restoreRTCTime() { + File file = openRead(_fs, "/rtc_save"); + if (file) { + uint32_t t = 0; + file.read((uint8_t *)&t, sizeof(t)); + file.close(); + if (t > 1000000000UL) _clock->setCurrentTime(t); + } +} + void DataStore::loadContacts(DataStoreHost* host) { File file = openRead(_getContactsChannelsFS(), "/contacts3"); if (file) { diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 58b4d5d2..1b69e190 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -49,6 +49,8 @@ public: bool removeFile(FILESYSTEM* fs, const char* filename); uint32_t getStorageUsedKb() const; uint32_t getStorageTotalKb() const; + void saveRTCTime(); + void restoreRTCTime(); private: FILESYSTEM* _getContactsChannelsFS() const { if (_fsExtra) return _fsExtra; return _fs;}; diff --git a/examples/companion_radio/MsgExpand.h b/examples/companion_radio/MsgExpand.h new file mode 100644 index 00000000..24e9d91e --- /dev/null +++ b/examples/companion_radio/MsgExpand.h @@ -0,0 +1,132 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +// Expands placeholders in tmpl into out (out_len bytes). +// {loc} — GPS coordinates (lat/lon) or "no GPS" +// {time} — local time HH:MM from RTC +// {temp} — temperature in °C (requires sm) +// {hum} — relative humidity % (requires sm) +// {pres} — barometric pressure hPa (requires sm) +// {batt} — battery voltage V (batt_volts >= 0 or LPP_VOLTAGE from sm) +// {alt} — altitude m (requires sm) +// {lux} — luminosity lux (requires sm) +// {dist} — distance m (requires sm) +// {co2} — CO2 concentration ppm (requires sm) +inline void expandMsg(const char* tmpl, char* out, int out_len, + double lat, double lon, bool gps_valid, + uint32_t utc_ts, int8_t tz_hours, + SensorManager* sm = nullptr, + float batt_volts = -1.0f) { + // sv indices: 0=temp 1=hum 2=pres 3=batt 4=alt 5=lux 6=dist 7=co2 + float sv[8] = {}; + bool sv_ok[8] = {}; + + if (sm) { + CayenneLPP lpp(100); + sm->querySensors(0xFF, lpp); + LPPReader r(lpp.getBuffer(), lpp.getSize()); + uint8_t ch, type; + while (r.readHeader(ch, type)) { + float tmp; + switch (type) { + case LPP_TEMPERATURE: + r.readTemperature(tmp); + if (!sv_ok[0]) { sv[0] = tmp; sv_ok[0] = true; } break; + case LPP_RELATIVE_HUMIDITY: + r.readRelativeHumidity(tmp); + if (!sv_ok[1]) { sv[1] = tmp; sv_ok[1] = true; } break; + case LPP_BAROMETRIC_PRESSURE: + r.readPressure(tmp); + if (!sv_ok[2]) { sv[2] = tmp; sv_ok[2] = true; } break; + case LPP_VOLTAGE: + r.readVoltage(tmp); + if (!sv_ok[3]) { sv[3] = tmp; sv_ok[3] = true; } break; + case LPP_ALTITUDE: + r.readAltitude(tmp); + if (!sv_ok[4]) { sv[4] = tmp; sv_ok[4] = true; } break; + case LPP_LUMINOSITY: + r.readLuminosity(tmp); + if (!sv_ok[5]) { sv[5] = tmp; sv_ok[5] = true; } break; + case LPP_DISTANCE: + r.readDistance(tmp); + if (!sv_ok[6]) { sv[6] = tmp; sv_ok[6] = true; } break; + case LPP_CONCENTRATION: + r.readConcentration(tmp); + if (!sv_ok[7]) { sv[7] = tmp; sv_ok[7] = true; } break; + default: + r.skipData(type); break; + } + } + } + // board battery takes precedence over INA sensor voltage + if (batt_volts >= 0.0f) { sv[3] = batt_volts; sv_ok[3] = true; } + + int oi = 0; + const char* p = tmpl; + while (*p && oi < out_len - 1) { + // helper macro: append a buffer whose length is already known + #define APPEND(s, slen) do { \ + int _l = (slen); \ + if (oi + _l < out_len) { memcpy(out + oi, (s), _l); oi += _l; } \ + } while(0) + + if (strncmp(p, "{loc}", 5) == 0) { + char lb[32]; + if (gps_valid) snprintf(lb, sizeof(lb), "%.5f,%.5f", lat, lon); + else strcpy(lb, "no GPS"); + APPEND(lb, strlen(lb)); p += 5; + } else if (strncmp(p, "{time}", 6) == 0) { + if (utc_ts > 1000000000UL) { + uint32_t local_ts = utc_ts + (int32_t)tz_hours * 3600; + time_t t = (time_t)local_ts; + struct tm* ti = gmtime(&t); + char tb[8]; + snprintf(tb, sizeof(tb), "%02d:%02d", ti->tm_hour, ti->tm_min); + APPEND(tb, strlen(tb)); + } + p += 6; + } else if (strncmp(p, "{temp}", 6) == 0) { + if (sv_ok[0]) { char b[10]; snprintf(b,sizeof(b),"%.1fC",sv[0]); APPEND(b,strlen(b)); } + else { APPEND("{temp}", 6); } + p += 6; + } else if (strncmp(p, "{hum}", 5) == 0) { + if (sv_ok[1]) { char b[8]; snprintf(b,sizeof(b),"%.0f%%",sv[1]); APPEND(b,strlen(b)); } + else { APPEND("{hum}", 5); } + p += 5; + } else if (strncmp(p, "{pres}", 6) == 0) { + if (sv_ok[2]) { char b[12]; snprintf(b,sizeof(b),"%.0fhPa",sv[2]); APPEND(b,strlen(b)); } + else { APPEND("{pres}", 6); } + p += 6; + } else if (strncmp(p, "{batt}", 6) == 0) { + if (sv_ok[3]) { char b[8]; snprintf(b,sizeof(b),"%.2fV",sv[3]); APPEND(b,strlen(b)); } + else { APPEND("{batt}", 6); } + p += 6; + } else if (strncmp(p, "{alt}", 5) == 0) { + if (sv_ok[4]) { char b[10]; snprintf(b,sizeof(b),"%.0fm",sv[4]); APPEND(b,strlen(b)); } + else { APPEND("{alt}", 5); } + p += 5; + } else if (strncmp(p, "{lux}", 5) == 0) { + if (sv_ok[5]) { char b[10]; snprintf(b,sizeof(b),"%.0flux",sv[5]); APPEND(b,strlen(b)); } + else { APPEND("{lux}", 5); } + p += 5; + } else if (strncmp(p, "{dist}", 6) == 0) { + if (sv_ok[6]) { char b[12]; snprintf(b,sizeof(b),"%.2fm",sv[6]); APPEND(b,strlen(b)); } + else { APPEND("{dist}", 6); } + p += 6; + } else if (strncmp(p, "{co2}", 5) == 0) { + if (sv_ok[7]) { char b[12]; snprintf(b,sizeof(b),"%.0fppm",sv[7]); APPEND(b,strlen(b)); } + else { APPEND("{co2}", 5); } + p += 5; + } else { + out[oi++] = *p++; + } + + #undef APPEND + } + out[oi] = '\0'; +} diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index e8c1914b..f168bdce 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,4 +1,5 @@ #include "MyMesh.h" +#include "MsgExpand.h" #include // needed for PlatformIO #include @@ -464,10 +465,10 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe // we only want to show text messages on display, not cli data bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN; if (should_display && _ui) { - _ui->newMsg(path_len, from.name, text, offline_queue_len); - if (!_serial->isConnected()) { - _ui->notify(UIEventType::contactMessage); - } + _ui->newMsg(path_len, from.name, text, offline_queue_len, from.type, from.id.pub_key); + _ui->notify(UIEventType::contactMessage); + if (from.type == ADV_TYPE_CHAT) + _ui->addDMMsg(from.id.pub_key, false, text); } #endif } @@ -510,10 +511,13 @@ void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk sendFloodScoped(*scope, pkt, delay_millis); } + void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) { markConnectionActive(from); // in case this is from a server, and we have a connection queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text); + + tryBotReplyDM(from, text); } void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, @@ -561,20 +565,19 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe uint8_t frame[1]; frame[0] = PUSH_CODE_MSG_WAITING; // send push 'tickle' _serial->writeFrame(frame, 1); - } else { -#ifdef DISPLAY_CLASS - if (_ui) _ui->notify(UIEventType::channelMessage); -#endif } #ifdef DISPLAY_CLASS - // Get the channel name from the channel index + if (_ui) _ui->addChannelMsg(channel_idx, text); + if (_ui) _ui->notify(UIEventType::channelMessage); const char *channel_name = "Unknown"; ChannelDetails channel_details; if (getChannel(channel_idx, channel_details)) { channel_name = channel_details.name; } - if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len); + if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len, 0); #endif + + tryBotReplyChannel(channel_idx, text); } void MyMesh::onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint16_t data_type, @@ -761,7 +764,67 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i return BaseChatMesh::onContactPathRecv(contact, in_path, in_path_len, out_path, out_path_len, extra_type, extra, extra_len); } +#define CTL_TYPE_NODE_DISCOVER_REQ 0x80 +#define CTL_TYPE_NODE_DISCOVER_RESP 0x90 + +void MyMesh::sendNodeDiscoverReq() { + uint8_t data[10]; + data[0] = CTL_TYPE_NODE_DISCOVER_REQ; + data[1] = (1 << ADV_TYPE_REPEATER) | (1 << ADV_TYPE_SENSOR) | (1 << ADV_TYPE_ROOM); + getRNG()->random(&data[2], 4); + memcpy(&_pending_node_discover_tag, &data[2], 4); + _pending_node_discover_until = futureMillis(8000); + _discover_count = 0; + uint32_t since = 0; + memcpy(&data[6], &since, 4); + auto pkt = createControlData(data, sizeof(data)); + if (pkt) sendZeroHop(pkt); +} + +int MyMesh::getDiscoverResults(DiscoverResult dest[], int max_count) { + int n = min(_discover_count, max_count); + memcpy(dest, _discover_results, n * sizeof(DiscoverResult)); + return n; +} + void MyMesh::onControlDataRecv(mesh::Packet *packet) { + // If we have an active standalone discover, check if this is a matching response. + // Tag matching provides isolation — no isBLEConnected check needed. + if ((packet->payload[0] & 0xF0) == CTL_TYPE_NODE_DISCOVER_RESP && + packet->payload_len >= 6 + PUB_KEY_SIZE && + _pending_node_discover_tag != 0 && + !millisHasNowPassed(_pending_node_discover_until)) { + uint32_t tag; + memcpy(&tag, &packet->payload[2], 4); + if (tag == _pending_node_discover_tag) { + uint8_t node_type = packet->payload[0] & 0x0F; + const uint8_t* pub_key = &packet->payload[6]; + ContactInfo* known = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (known) { + known->lastmod = getRTCClock()->getCurrentTime(); + } + if (_discover_count < DISCOVER_RESULTS_MAX) { + DiscoverResult& r = _discover_results[_discover_count++]; + if (known) { + strncpy(r.name, known->name, sizeof(r.name) - 1); + r.name[sizeof(r.name) - 1] = '\0'; + r.is_known = true; + } else { + r.name[0] = '\0'; + r.is_known = false; + } + r.type = node_type; + r.rssi = (int8_t)_radio->getLastRSSI(); + r.snr_x4 = (int8_t)(_radio->getLastSNR() * 4); + r.remote_snr_x4 = (int8_t)packet->payload[1]; + memcpy(r.pub_key, pub_key, PUB_KEY_SIZE); + r.timestamp = getRTCClock()->getCurrentTime(); + } + if (_ui) _ui->notify(UIEventType::newContactMessage); + return; // our discover — don't forward to BLE app + } + } + if (packet->payload_len + 4 > sizeof(out_frame)) { MESH_DEBUG_PRINTLN("onControlDataRecv(), payload_len too long: %d", packet->payload_len); return; @@ -850,12 +913,18 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _cli_rescue = false; offline_queue_len = 0; app_target_ver = 0; + _bot_last_dm_reply_ms = 0; + _bot_last_ch_reply_ms = 0; + _next_auto_advert_ms = 0; clearPendingReqs(); next_ack_idx = 0; sign_data = NULL; dirty_contacts_expiry = 0; memset(advert_paths, 0, sizeof(advert_paths)); memset(send_scope.key, 0, sizeof(send_scope.key)); + _discover_count = 0; + _pending_node_discover_tag = 0; + _pending_node_discover_until = 0; // defaults memset(&_prefs, 0, sizeof(_prefs)); @@ -868,6 +937,27 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.tx_power_dbm = LORA_TX_POWER; _prefs.gps_enabled = 0; // GPS disabled by default _prefs.gps_interval = 0; // No automatic GPS updates by default + _prefs.display_brightness = 2; // medium brightness by default + _prefs.buzzer_volume = 4; // max volume by default + _prefs.ringtone_bpm_idx = 2; // 120 bpm default + _prefs.ringtone_len = 0; // no custom ringtone by default + _prefs.ringtone2_bpm_idx = 2; // 120 bpm default + _prefs.home_pages_mask = 0x01FF; // all pages visible + _prefs.bot_enabled = 0; + _prefs.bot_channel_enabled = 0; + _prefs.bot_channel_idx = 0; + _prefs.bot_trigger[0] = '\0'; + _prefs.bot_reply_dm[0] = '\0'; + _prefs.bot_reply_ch[0] = '\0'; + _prefs.dm_show_all = 1; // show all contacts by default + memset(_prefs.dm_notif, 0, sizeof(_prefs.dm_notif)); + _prefs.auto_off_secs = 15; // 15 seconds auto-off by default +#ifdef EINK_DISPLAY_MODEL + _prefs.clock_hide_seconds = 1; // e-ink: seconds cause a panel refresh every second +#endif + _prefs.tz_offset_hours = 0; // UTC by default + _prefs.low_batt_mv = 3400; // auto-shutdown at 3.4V by default + _prefs.batt_display_mode = 0; // icon by default //_prefs.rx_delay_base = 10.0f; enable once new algo fixed #if defined(USE_SX1262) || defined(USE_SX1268) #ifdef SX126X_RX_BOOSTED_GAIN @@ -1255,7 +1345,9 @@ void MyMesh::handleCmdFrame(size_t len) { ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); uint32_t last_mod = getRTCClock()->getCurrentTime(); // fallback value if not present in cmd_frame if (recipient) { + uint8_t saved_type = recipient->type; // type is authoritative from advert, not app updateContactFromFrame(*recipient, last_mod, cmd_frame, len); + if (saved_type != ADV_TYPE_NONE) recipient->type = saved_type; recipient->lastmod = last_mod; dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); writeOKFrame(); @@ -1673,13 +1765,22 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_NOT_FOUND); } } else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 32) { - writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // not supported (yet) + uint8_t channel_idx = cmd_frame[1]; + ChannelDetails channel; + StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32); + memcpy(channel.channel.secret, &cmd_frame[2 + 32], 32); // 256-bit key + if (setChannel(channel_idx, channel)) { + saveChannels(); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx + } } else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 16) { uint8_t channel_idx = cmd_frame[1]; ChannelDetails channel; StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32); memset(channel.channel.secret, 0, sizeof(channel.channel.secret)); - memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // NOTE: only 128-bit supported + memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // 128-bit key if (setChannel(channel_idx, channel)) { saveChannels(); writeOKFrame(); @@ -2172,8 +2273,16 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } + if (_prefs.advert_auto_interval_sec > 0 && millisHasNowPassed(_next_auto_advert_ms)) { + mesh::Packet* pkt = (sensors.node_lat != 0 || sensors.node_lon != 0) + ? createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon) + : createSelfAdvert(_prefs.node_name); + if (pkt) sendZeroHop(pkt); + _next_auto_advert_ms = futureMillis(_prefs.advert_auto_interval_sec * 1000UL); + } + #ifdef DISPLAY_CLASS - if (_ui) _ui->setHasConnection(_serial->isConnected()); + if (_ui) _ui->setHasConnection(_serial->isBLEConnected()); #endif } @@ -2191,3 +2300,5 @@ bool MyMesh::advert() { return false; } } + +#include "MyMeshBot.h" diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index aeff591c..1d6b8373 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -8,11 +8,12 @@ #define FIRMWARE_VER_CODE 11 #ifndef FIRMWARE_BUILD_DATE -#define FIRMWARE_BUILD_DATE "19 Apr 2026" +#define FIRMWARE_BUILD_DATE "12 May 2026" #endif +// Versioning: vX.Y = upstream base, plus.N = fork revision #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "v1.15.0" +#define FIRMWARE_VERSION "v1.15-plus.1" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) @@ -84,6 +85,19 @@ struct AdvertPath { uint8_t path[MAX_PATH_SIZE]; }; +struct DiscoverResult { + char name[32]; // contact name if known, "" if unknown (use type label) + uint8_t type; // ADV_TYPE_REPEATER / ADV_TYPE_SENSOR / ADV_TYPE_ROOM + bool is_known; // true = in contacts[], false = new unknown node + int8_t rssi; // RSSI of the response as received by us (dBm) + int8_t snr_x4; // SNR of the response as received by us (dB × 4) + int8_t remote_snr_x4; // SNR at which responder heard our request (dB × 4) + uint8_t pub_key[PUB_KEY_SIZE]; + uint32_t timestamp; +}; + +#define EXPECTED_ACK_TABLE_SIZE 8 + class MyMesh : public BaseChatMesh, public DataStoreHost { public: MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui=NULL); @@ -98,9 +112,11 @@ public: void loop(); void handleCmdFrame(size_t len); bool advert(); + void sendNodeDiscoverReq(); void enterCLIRescue(); int getRecentlyHeard(AdvertPath dest[], int max_num); + int getDiscoverResults(DiscoverResult dest[], int max_count); protected: float getAirtimeBudgetFactor() const override; @@ -165,6 +181,14 @@ protected: public: void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); } + void saveRTCTime() { _store->saveRTCTime(); } + + bool isAckPending(uint32_t expected_ack) const { + for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) + if (expected_ack_table[i].ack == expected_ack) return true; + return false; + } + #if ENV_INCLUDE_GPS == 1 void applyGpsPrefs() { @@ -178,6 +202,9 @@ public: #endif private: + void tryBotReplyDM(const ContactInfo& from, const char* text); + void tryBotReplyChannel(uint8_t channel_idx, const char* text); + void writeOKFrame(); void writeErrFrame(uint8_t err_code); void writeDisabledFrame(); @@ -220,6 +247,9 @@ private: uint8_t *sign_data; uint32_t sign_data_len; unsigned long dirty_contacts_expiry; + unsigned long _bot_last_dm_reply_ms; + unsigned long _bot_last_ch_reply_ms; + unsigned long _next_auto_advert_ms; TransportKey send_scope; @@ -241,12 +271,17 @@ private: uint32_t ack; ContactInfo* contact; }; - #define EXPECTED_ACK_TABLE_SIZE 8 AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table int next_ack_idx; #define ADVERT_PATH_TABLE_SIZE 16 AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table + + #define DISCOVER_RESULTS_MAX 16 + DiscoverResult _discover_results[DISCOVER_RESULTS_MAX]; + int _discover_count; + uint32_t _pending_node_discover_tag; + unsigned long _pending_node_discover_until; }; extern MyMesh the_mesh; diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h new file mode 100644 index 00000000..d75af84e --- /dev/null +++ b/examples/companion_radio/MyMeshBot.h @@ -0,0 +1,79 @@ +#pragma once +// Bot logic — not part of upstream MyMesh.cpp. +// Included at the bottom of MyMesh.cpp after all class definitions. + +void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) { + if (from.type != ADV_TYPE_CHAT) return; + if (!(_prefs.bot_enabled && _prefs.bot_trigger[0] && _prefs.bot_reply_dm[0] && + millis() - _bot_last_dm_reply_ms > 10000UL)) + return; + + const char* tr = _prefs.bot_trigger; + int tlen = strlen(tr); + bool matched = false; + for (const char* t = text; *t && !matched; t++) { + int i = 0; + while (i < tlen && tolower((uint8_t)t[i]) == tolower((uint8_t)tr[i])) i++; + if (i == tlen) matched = true; + } + if (!matched) return; + + uint32_t ts = getRTCClock()->getCurrentTime(); + char expanded[200]; + expandMsg(_prefs.bot_reply_dm, expanded, sizeof(expanded), + sensors.node_lat, sensors.node_lon, + sensors.node_lat != 0.0 || sensors.node_lon != 0.0, + ts, _prefs.tz_offset_hours, + &sensors, (float)board.getBattMilliVolts() / 1000.0f); + uint32_t expected_ack, est_timeout; + if (sendMessage(from, ts, 0, expanded, expected_ack, est_timeout) != MSG_SEND_FAILED) { + _bot_last_dm_reply_ms = millis(); +#ifdef DISPLAY_CLASS + if (_ui) _ui->addDMMsg(from.id.pub_key, true, expanded); +#endif + } +} + +void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { + if (!(_prefs.bot_enabled && _prefs.bot_channel_enabled && _prefs.bot_trigger[0] && _prefs.bot_reply_ch[0] && + channel_idx == _prefs.bot_channel_idx && + millis() - _bot_last_ch_reply_ms > 10000UL)) + return; + + // Skip "sender_name: " prefix so the trigger is only matched against the message body. + const char* msg = text; + const char* sep = strstr(text, ": "); + if (sep) msg = sep + 2; + + const char* tr = _prefs.bot_trigger; + int tlen = strlen(tr); + bool matched = false; + for (const char* t = msg; *t && !matched; t++) { + int i = 0; + while (i < tlen && tolower((uint8_t)t[i]) == tolower((uint8_t)tr[i])) i++; + if (i == tlen) matched = true; + } + if (!matched) return; + + ChannelDetails ch; + if (!getChannel(channel_idx, ch)) return; + + uint32_t ts = getRTCClock()->getCurrentTime(); + char expanded[200]; + expandMsg(_prefs.bot_reply_ch, expanded, sizeof(expanded), + sensors.node_lat, sensors.node_lon, + sensors.node_lat != 0.0 || sensors.node_lon != 0.0, + ts, _prefs.tz_offset_hours, + &sensors, (float)board.getBattMilliVolts() / 1000.0f); + int rlen = strlen(expanded); + if (sendGroupMessage(ts, ch.channel, _prefs.node_name, expanded, rlen)) { + _bot_last_ch_reply_ms = millis(); +#ifdef DISPLAY_CLASS + if (_ui) { + char with_sender[240]; // node_name(32) + ": "(2) + expanded(200) + margin + snprintf(with_sender, sizeof(with_sender), "%s: %s", _prefs.node_name, expanded); + _ui->addChannelMsg(channel_idx, with_sender); + } +#endif + } +} diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 48c381ce..8b973727 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -25,6 +25,7 @@ struct NodePrefs { // persisted to file uint32_t ble_pin; uint8_t advert_loc_policy; uint8_t buzzer_quiet; + uint8_t buzzer_volume; // 0=min..4=max, default 4 uint8_t gps_enabled; // GPS enabled flag (0=disabled, 1=enabled) uint32_t gps_interval; // GPS read interval in seconds uint8_t autoadd_config; // bitmask for auto-add contacts config @@ -34,4 +35,52 @@ struct NodePrefs { // persisted to file uint8_t autoadd_max_hops; // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64) char default_scope_name[31]; uint8_t default_scope_key[16]; + uint8_t display_brightness; // 0=min..4=max, default 2 (medium) + uint16_t auto_off_secs; // display auto-off: 0=never, else seconds (default 15) + uint8_t auto_lock; // 0=disabled, 1=lock screen when display turns off + int8_t tz_offset_hours; // timezone offset from UTC, -12..+14 (default 0) + uint16_t low_batt_mv; // auto-shutdown threshold: 0=disabled, 3000-3500 mV + uint8_t batt_display_mode; // 0=icon, 1=percent, 2=voltage + char custom_msgs[10][140]; // user-defined quick messages (supports {loc}, {time}) + uint64_t ch_notif_override; // bitmask: bit i = channel i has explicit notification setting + uint64_t ch_notif_muted; // bitmask: bit i = channel i muted (only if override bit set) + uint8_t dm_show_all; // 0=favourites only (default), 1=all chat contacts + uint8_t room_fav_only; // 0=all room servers (default), 1=favourites only + uint8_t ringtone_bpm_idx; // index into {60,90,120,150,180} + uint8_t ringtone_len; // number of notes in custom ringtone (0 = use default) + uint8_t ringtone_notes[32]; // packed: bits0-2=pitch, bits3-4=octave-4, bits5-6=dur_idx + uint16_t home_pages_mask; // bitmask of visible home pages (bit0=Clock..bit8=Shutdown); 0=all visible + uint8_t bot_enabled; // 0=disabled, 1=DM bot active (responds to all DMs) + uint8_t bot_channel_enabled; // 0=disabled, 1=channel bot active for bot_channel_idx + uint8_t bot_channel_idx; // channel index for channel bot + char bot_trigger[64]; // trigger phrase (case-insensitive contains match) + char bot_reply_dm[140]; // auto-reply text for DM + char bot_reply_ch[140]; // auto-reply text for channel + uint8_t clock_hide_seconds; // 0=show HH:MM:SS/refresh 1s (default), 1=hide/refresh 60s + uint8_t clock_12h; // 0=24h (default), 1=12h with AM/PM + uint8_t buzzer_auto; // 0=manual (default), 1=auto-mute when BT connected + struct DmNotifEntry { uint8_t prefix[4]; uint8_t state; }; // state: 0=default,1=muted,2=force-on + static const int DM_NOTIF_TABLE_MAX = 16; + DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes + uint8_t dashboard_fields[3]; // 0=None,1=Batt,2=Temp,3=Hum,4=Pres,5=GPS,6=Alt,7=Lux,8=CO2,9=Nodes + uint32_t advert_auto_interval_sec; // periodic 0-hop advert with GPS: 0=off, else seconds + // Second melody slot (same packing as ringtone_*) + uint8_t ringtone2_bpm_idx; + uint8_t ringtone2_len; + uint8_t ringtone2_notes[32]; + // Global melody for notifications: 0=built-in, 1=melody1, 2=melody2 + uint8_t notif_melody_dm; + uint8_t notif_melody_ch; + // Per-channel melody override (2 bitmasks, 1 bit per channel) + uint64_t ch_notif_melody_set; // bit i = channel i has explicit melody + uint64_t ch_notif_melody_2; // bit i = use melody 2 (else melody 1, when set bit is set) + // Per-DM melody table + struct DmMelodyEntry { uint8_t prefix[4]; uint8_t slot; }; // slot: 0=global,1=melody1,2=melody2 + static const int DM_MELODY_TABLE_MAX = 16; + DmMelodyEntry dm_melody[DM_MELODY_TABLE_MAX]; + uint8_t use_lemon_font; // 0=default Adafruit font, 1=Lemon font (Unicode, pixel-accurate wrap) + uint8_t display_rotation; // 0-3; only used on e-ink displays + // Home screen page order: each byte = bit-index+1 (see HP_* bit positions + 9=Settings, 10=Messages). + // 0 = end of list (also uninitialized legacy). hasCustomOrder iff page_order[0] in 1..11. + uint8_t page_order[11]; }; \ No newline at end of file diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 876dc9c3..f0c87504 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -71,7 +71,10 @@ static uint32_t _atoi(const char* sp) { ArduinoSerialInterface serial_interface; #endif #elif defined(NRF52_PLATFORM) - #ifdef BLE_PIN_CODE + #ifdef DUAL_SERIAL + #include + DualSerialInterface serial_interface; + #elif defined(BLE_PIN_CODE) #include SerialBLEInterface serial_interface; #else @@ -150,7 +153,9 @@ void setup() { #endif ); -#ifdef BLE_PIN_CODE +#ifdef DUAL_SERIAL + serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin(), Serial); +#elif defined(BLE_PIN_CODE) serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin()); #else serial_interface.begin(Serial); @@ -211,6 +216,7 @@ void setup() { #error "need to define filesystem" #endif + store.restoreRTCTime(); sensors.begin(); #if ENV_INCLUDE_GPS == 1 @@ -218,15 +224,30 @@ void setup() { #endif #ifdef DISPLAY_CLASS + // Apply saved brightness as soon as prefs are available so the tail of + // the loading screen is not stuck at full brightness. + if (disp && the_mesh.getNodePrefs()) + disp->setBrightness(the_mesh.getNodePrefs()->display_brightness); ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); // still want to pass this in as dependency, as prefs might be moved #endif + +#ifdef NRF52_PLATFORM + NRF_WDT->CONFIG = 0x01; // run during sleep; pause during debug halt + NRF_WDT->CRV = 32768*30-1; // 30 second timeout + NRF_WDT->RREN = 0x01; // enable reload register 0 + NRF_WDT->TASKS_START = 1; +#endif } void loop() { +#ifdef NRF52_PLATFORM + NRF_WDT->RR[0] = 0x6E524635UL; // pet watchdog +#endif the_mesh.loop(); sensors.loop(); #ifdef DISPLAY_CLASS ui_task.loop(); #endif rtc_clock.tick(); + board.sleep(0); // CPU sleeps until next interrupt (radio, timer, BLE); nRF52 ignores the seconds param } diff --git a/examples/companion_radio/ui-new/AutoAdvertScreen.h b/examples/companion_radio/ui-new/AutoAdvertScreen.h new file mode 100644 index 00000000..50ca594c --- /dev/null +++ b/examples/companion_radio/ui-new/AutoAdvertScreen.h @@ -0,0 +1,73 @@ +#pragma once +// Configures periodic automatic 0-hop advert with GPS position. +// Included by UITask.cpp after DashboardConfigScreen.h. + +class AutoAdvertScreen : public UIScreen { + UITask* _task; + NodePrefs* _prefs; + bool _dirty; + + static const int OPT_COUNT = 8; + static const uint32_t OPTS[OPT_COUNT]; + static const char* OPT_LABELS[OPT_COUNT]; + + int currentIdx() const { + for (int i = 0; i < OPT_COUNT; i++) + if (OPTS[i] == _prefs->advert_auto_interval_sec) return i; + return 0; + } + +public: + AutoAdvertScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} + + void enter() { _dirty = false; } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + int label_y = display.listStart(); + int bar_y = label_y + display.lineStep(); + int bar_h = display.lineStep(); + int tip_y = bar_y + bar_h + 4; + + display.drawTextCentered(display.width() / 2, 0, "AUTO-ADVERT"); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); + + display.setCursor(2, label_y); + display.print("Interval:"); + + int idx = currentIdx(); + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, bar_y, display.width(), bar_h); + display.setColor(DisplayDriver::DARK); + display.drawTextCentered(display.width() / 2, bar_y + 1, OPT_LABELS[idx]); + display.setColor(DisplayDriver::LIGHT); + + display.setCursor(2, tip_y); + display.print("< > to change"); + display.setCursor(2, tip_y + display.lineStep()); + display.print("[Esc] to save"); + return 500; + } + + bool handleInput(char c) override { + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { + if (_dirty) the_mesh.savePrefs(); + _task->gotoToolsScreen(); + return true; + } + bool right = (c == KEY_RIGHT || c == KEY_NEXT || c == KEY_ENTER); + bool left = (c == KEY_LEFT || c == KEY_PREV); + if (right || left) { + int idx = currentIdx(); + idx = right ? (idx + 1) % OPT_COUNT : (idx + OPT_COUNT - 1) % OPT_COUNT; + _prefs->advert_auto_interval_sec = OPTS[idx]; + _dirty = true; + return true; + } + return false; + } +}; + +const uint32_t AutoAdvertScreen::OPTS[AutoAdvertScreen::OPT_COUNT] = { 0, 30, 60, 120, 300, 600, 1800, 3600 }; +const char* AutoAdvertScreen::OPT_LABELS[AutoAdvertScreen::OPT_COUNT] = { "off", "30s", "1min", "2min", "5min", "10min", "30min", "1h" }; diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h new file mode 100644 index 00000000..1d14d76d --- /dev/null +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -0,0 +1,195 @@ +#pragma once +// Custom screen — not part of upstream UITask.cpp +// Included by UITask.cpp after KeyboardWidget.h is defined. + +class BotScreen : public UIScreen { + UITask* _task; + NodePrefs* _prefs; + + // Items: 0=Enable(DM), 1=Channel, 2=Trigger, 3=Reply DM, 4=Reply Ch + static const int ITEM_COUNT = 5; + + int _sel; + bool _dirty; + + // keyboard state (reused for trigger and reply fields) + int _kb_field; // -1=off, 2=trigger, 3=reply DM, 4=reply Ch + KeyboardWidget _kb; + + // channel cache (refreshed on enter) + int _num_channels; + uint8_t _channel_indices[MAX_GROUP_CHANNELS]; + + void refreshChannels() { + _num_channels = 0; + ChannelDetails ch; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') + _channel_indices[_num_channels++] = (uint8_t)i; + } + } + + // Returns index into _channel_indices[] for current bot_channel_idx, or -1 if not found/disabled. + int currentChannelListIdx() const { + if (!_prefs->bot_channel_enabled) return -1; + for (int i = 0; i < _num_channels; i++) + if (_channel_indices[i] == _prefs->bot_channel_idx) return i; + return -1; + } + +public: + BotScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} + + void enter() { + _sel = 0; + _kb_field = -1; + _dirty = false; + refreshChannels(); + } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + + if (_kb_field >= 0) { + return _kb.render(display); + } + + int avail_h = display.height() - display.listStart(); + int item_h = display.lineStep(); + if (item_h * ITEM_COUNT > avail_h) item_h = avail_h / ITEM_COUNT; + int start_y = display.listStart(); + int val_x = display.valCol(); + + display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT"); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); + + static const char* labels[] = { "Enable", "Channel", "Trigger", "Reply DM", "Reply Ch" }; + for (int i = 0; i < ITEM_COUNT; i++) { + int y = start_y + i * item_h; + bool sel = (i == _sel); + if (sel) { + display.fillRect(0, y - 1, display.width(), item_h); + display.setColor(DisplayDriver::DARK); + } + display.setCursor(2, y); + display.print(labels[i]); + display.setCursor(val_x, y); + + if (i == 0) { + display.print(_prefs->bot_enabled ? "ON" : "OFF"); + } else if (i == 1) { + if (!_prefs->bot_channel_enabled || _num_channels == 0) { + display.print("OFF"); + } else { + ChannelDetails ch; + if (the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0]) + display.drawTextEllipsized(val_x, y, display.width() - val_x - 1, ch.name); + else + display.print("?"); + } + } else if (i == 2) { + const char* tr = _prefs->bot_trigger; + display.drawTextEllipsized(val_x, y, display.width() - val_x - 1, tr[0] ? tr : "(none)"); + } else if (i == 3) { + const char* rp = _prefs->bot_reply_dm; + display.drawTextEllipsized(val_x, y, display.width() - val_x - 1, rp[0] ? rp : "(none)"); + } else { + const char* rp = _prefs->bot_reply_ch; + display.drawTextEllipsized(val_x, y, display.width() - val_x - 1, rp[0] ? rp : "(none)"); + } + display.setColor(DisplayDriver::LIGHT); + } + return 2000; + } + + bool handleInput(char c) override { + bool up = (c == KEY_UP); + bool down = (c == KEY_DOWN); + bool left = (c == KEY_LEFT || c == KEY_PREV); + bool right = (c == KEY_RIGHT || c == KEY_NEXT); + bool enter = (c == KEY_ENTER); + bool cancel = (c == KEY_CANCEL || c == KEY_CONTEXT_MENU); + + if (_kb_field >= 0) { + auto res = _kb.handleInput(c); + if (res == KeyboardWidget::DONE) { + if (_kb_field == 2) { + strncpy(_prefs->bot_trigger, _kb.buf, sizeof(_prefs->bot_trigger) - 1); + _prefs->bot_trigger[sizeof(_prefs->bot_trigger) - 1] = '\0'; + } else if (_kb_field == 3) { + strncpy(_prefs->bot_reply_dm, _kb.buf, sizeof(_prefs->bot_reply_dm) - 1); + _prefs->bot_reply_dm[sizeof(_prefs->bot_reply_dm) - 1] = '\0'; + } else { + strncpy(_prefs->bot_reply_ch, _kb.buf, sizeof(_prefs->bot_reply_ch) - 1); + _prefs->bot_reply_ch[sizeof(_prefs->bot_reply_ch) - 1] = '\0'; + } + _dirty = true; + _kb_field = -1; + } else if (res == KeyboardWidget::CANCELLED) { + _kb_field = -1; + } + return true; + } + + if (cancel) { + if (_dirty) the_mesh.savePrefs(); + _task->gotoToolsScreen(); + return true; + } + if (up && _sel > 0) { _sel--; return true; } + if (down && _sel < ITEM_COUNT - 1) { _sel++; return true; } + + if (_sel == 0 && (enter || left || right)) { + _prefs->bot_enabled ^= 1; + _dirty = true; + return true; + } + if (_sel == 1) { + if (_num_channels == 0) return false; + // Cycle: OFF → ch[0] → ch[1] → ... → OFF + int idx = currentChannelListIdx(); // -1 = OFF + if (right || enter) { + idx++; + if (idx >= _num_channels) idx = -1; // wrap to OFF + } + if (left) { + idx--; + if (idx < -1) idx = _num_channels - 1; + } + if (left || right || enter) { + if (idx < 0) { + _prefs->bot_channel_enabled = 0; + } else { + _prefs->bot_channel_enabled = 1; + _prefs->bot_channel_idx = _channel_indices[idx]; + } + _dirty = true; + return true; + } + } + if ((_sel == 2 || _sel == 3 || _sel == 4) && enter) { + _kb_field = _sel; + const char* initial; + int max; + if (_sel == 2) { + initial = _prefs->bot_trigger; + max = sizeof(_prefs->bot_trigger) - 1; + } else if (_sel == 3) { + initial = _prefs->bot_reply_dm; + max = sizeof(_prefs->bot_reply_dm) - 1; + } else { + initial = _prefs->bot_reply_ch; + max = sizeof(_prefs->bot_reply_ch) - 1; + } + _kb.begin(initial, max); + if (_sel == 2) { + _kb._ph_count = 0; // trigger is literal text — placeholders never match incoming msgs + } else { + kbAddSensorPlaceholders(_kb, &sensors); + } + return true; + } + return false; + } +}; diff --git a/examples/companion_radio/ui-new/DashboardConfigScreen.h b/examples/companion_radio/ui-new/DashboardConfigScreen.h new file mode 100644 index 00000000..15957c5d --- /dev/null +++ b/examples/companion_radio/ui-new/DashboardConfigScreen.h @@ -0,0 +1,90 @@ +#pragma once +// Configures which data fields appear on the clock home page. +// Included by UITask.cpp after BotScreen.h. + +// Field type constants — used here and in UITask.cpp HP_CLOCK render. +static const uint8_t DASH_NONE = 0; +static const uint8_t DASH_BATT = 1; +static const uint8_t DASH_TEMP = 2; +static const uint8_t DASH_HUM = 3; +static const uint8_t DASH_PRES = 4; +static const uint8_t DASH_GPS = 5; +static const uint8_t DASH_ALT = 6; +static const uint8_t DASH_LUX = 7; +static const uint8_t DASH_CO2 = 8; +static const uint8_t DASH_NODES = 9; +static const uint8_t DASH_MSGS = 10; +static const uint8_t DASH_COUNT = 11; + +class DashboardConfigScreen : public UIScreen { + UITask* _task; + NodePrefs* _prefs; + + static const int FIELD_SLOTS = 3; + + static const char* OPTION_NAMES[DASH_COUNT]; + + int _sel; + bool _dirty; + + void cycle(int slot, int dir) { + uint8_t& f = _prefs->dashboard_fields[slot]; + f = (uint8_t)((f + DASH_COUNT + dir) % DASH_COUNT); + _dirty = true; + } + +public: + DashboardConfigScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} + + void enter() { _sel = 0; _dirty = false; } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + int item_h = display.lineStep(); + int start_y = display.listStart(); + int val_x = display.valCol(); + + display.drawTextCentered(display.width() / 2, 0, "CLOCK FIELDS"); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); + + static const char* labels[] = { "Field 1", "Field 2", "Field 3" }; + for (int i = 0; i < FIELD_SLOTS; i++) { + int y = start_y + i * item_h; + bool sel = (i == _sel); + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), item_h); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(2, y); + display.print(labels[i]); + display.setCursor(val_x, y); + uint8_t f = _prefs->dashboard_fields[i]; + display.print(OPTION_NAMES[f < DASH_COUNT ? f : DASH_NONE]); + display.setColor(DisplayDriver::LIGHT); + } + return 500; + } + + bool handleInput(char c) override { + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { + if (_dirty) the_mesh.savePrefs(); + _task->gotoHomeScreen(); + return true; + } + if (c == KEY_UP && _sel > 0) { _sel--; return true; } + if (c == KEY_DOWN && _sel < FIELD_SLOTS - 1){ _sel++; return true; } + if (c == KEY_LEFT || c == KEY_PREV) { cycle(_sel, -1); return true; } + if (c == KEY_RIGHT || c == KEY_NEXT) { cycle(_sel, 1); return true; } + if (c == KEY_ENTER) { cycle(_sel, 1); return true; } + return false; + } +}; + +const char* DashboardConfigScreen::OPTION_NAMES[DASH_COUNT] = { + "None", "Battery", "Temp", "Humidity", "Pressure", + "GPS", "Altitude", "Lux", "CO2", "Contacts", "Messages" +}; diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h new file mode 100644 index 00000000..f83b6359 --- /dev/null +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -0,0 +1,148 @@ +#pragma once + +#include +#include + +static const int FS_CHARS_MAX = 80; // max bytes per wrapped line + +struct FullscreenMsgView { + int scroll; + bool active; + + FullscreenMsgView() : scroll(0), active(false) {} + + enum Result { NONE, PREV, NEXT, CLOSE, REPLY }; + + void begin() { scroll = 0; active = true; } + + // Pixel-accurate word-wrap: breaks at last space that fits within max_px, + // hard-breaks long words. Works for both fixed- and variable-width fonts. + static int wrapLines(DisplayDriver& display, const char* text, int max_px, + char out[][FS_CHARS_MAX], int max_lines) { + int count = 0; + const char* seg = text; + while (*seg && count < max_lines) { + const char* p = seg; + const char* last_sp = nullptr; + const char* last_fit = seg; + + while (*p && (p - seg) < FS_CHARS_MAX - 1) { + uint8_t b0 = (uint8_t)*p; + int cb = (b0 < 0x80) ? 1 : (b0 < 0xE0) ? 2 : (b0 < 0xF0) ? 3 : 4; + if ((p - seg) + cb >= FS_CHARS_MAX) break; + + char buf[FS_CHARS_MAX]; + int n = (int)(p - seg) + cb; + memcpy(buf, seg, n); buf[n] = '\0'; + if (display.getTextWidth(buf) > max_px && p > seg) break; + + if (*p == ' ') last_sp = p; + p += cb; + last_fit = p; + } + + const char* end; + const char* next_seg; + if (!*p) { + end = p; next_seg = p; + } else if (last_sp && last_sp > seg) { + end = last_sp; next_seg = last_sp + 1; + } else { + end = last_fit; next_seg = last_fit; + } + + int len = (int)(end - seg); + if (len <= 0) { seg = *seg ? seg + 1 : seg; continue; } + if (len > FS_CHARS_MAX - 1) len = FS_CHARS_MAX - 1; + memcpy(out[count], seg, len); + out[count][len] = '\0'; + count++; + seg = next_seg; + } + return count; + } + + int render(DisplayDriver& display, const char* sender, const char* text, + bool has_prev, bool has_next) { + display.setTextSize(1); + const int lineH = display.getLineHeight(); + const int max_px = display.width() - 6; + + // detect @recipient at start of message + char to_nick[32] = ""; + const char* body = text; + if (text[0] == '@' && text[1] == '[') { + const char* close = strchr(text + 2, ']'); + if (close && close[1] == ' ' && close[2]) { + int len = (int)(close - text) - 2; + if (len >= (int)sizeof(to_nick)) len = sizeof(to_nick) - 1; + memcpy(to_nick, text + 2, len); + to_nick[len] = '\0'; + body = close + 2; + } + } + + const int cw = display.getCharWidth(); + const int header_h = to_nick[0] ? (lineH * 2 + 4) : (lineH + 2); + const int startY = header_h + 2; + const int visible = (display.height() - startY - lineH) / lineH; + + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, 0, display.width(), header_h); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(2, 1, display.width() - 4, sender); + if (to_nick[0]) { + char trans_nick[32], to_label[36]; + display.translateUTF8ToBlocks(trans_nick, to_nick, sizeof(trans_nick)); + snprintf(to_label, sizeof(to_label), "To: %s", trans_nick); + display.drawTextEllipsized(2, lineH + 3, display.width() - 4, to_label); + } + display.setColor(DisplayDriver::LIGHT); + + char trans_text[512]; + display.translateUTF8ToBlocks(trans_text, body, sizeof(trans_text)); + char lines[12][FS_CHARS_MAX]; + int lcount = wrapLines(display, trans_text, max_px, lines, 12); + int max_scroll = lcount > visible ? lcount - visible : 0; + if (scroll > max_scroll) scroll = max_scroll; + + for (int i = 0; i < visible && (scroll + i) < lcount; i++) { + display.setCursor(0, startY + i * lineH); + display.print(lines[scroll + i]); + } + if (scroll > 0) { + display.setColor(DisplayDriver::DARK); + display.fillRect(display.width() - cw, startY, cw, lineH); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(display.width() - cw, startY); + display.print("^"); + } + if (scroll < max_scroll) { + display.setColor(DisplayDriver::DARK); + display.fillRect(display.width() - cw, startY + (visible - 1) * lineH, cw, lineH); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(display.width() - cw, startY + (visible - 1) * lineH); + display.print("v"); + } + const int nav_y = display.height() - lineH; + if (has_next) { + display.setCursor(0, nav_y); + display.print("<"); + } + if (has_prev) { + display.setCursor(display.width() - cw, nav_y); + display.print(">"); + } + return 2000; + } + + Result handleInput(char c) { + if (c == KEY_UP) { if (scroll > 0) scroll--; return NONE; } + if (c == KEY_DOWN) { scroll++; return NONE; } + if (c == KEY_LEFT) return NEXT; + if (c == KEY_RIGHT) return PREV; + if (c == KEY_CONTEXT_MENU) return REPLY; + if (c == KEY_ENTER || c == KEY_CANCEL) return CLOSE; + return NONE; + } +}; diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h new file mode 100644 index 00000000..81560b7b --- /dev/null +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -0,0 +1,217 @@ +#pragma once + +#include +#include +#include "PopupMenu.h" + +// Layout constants shared by all keyboard users +static const char KB_CHARS[4][10] = { + {'a','b','c','d','e','f','g','h','i','j'}, + {'k','l','m','n','o','p','q','r','s','t'}, + {'u','v','w','x','y','z','.',' ','!','?'}, + {'1','2','3','4','5','6','7','8','9','0'}, +}; +static const int KB_ROWS_CHAR = 4; +static const int KB_COLS_CHAR = 10; +static const int KB_SPECIAL = 5; // [^] [Sp] [De] [{}] [OK] +static const int KB_MAX_LEN = 139; + +static const int KB_PH_MAX = 12; // max placeholders in list +static const int KB_PH_LEN = 9; // max placeholder string length incl. null +static const int KB_PH_VISIBLE = 3; // items shown at once in overlay + +struct KeyboardWidget { + char buf[KB_MAX_LEN + 1]; + int len; + int max_len; + int row, col; + bool caps; + char _ph_buf[KB_PH_MAX][KB_PH_LEN]; + int _ph_count; + PopupMenu _ph_menu; + + enum Result { NONE, DONE, CANCELLED }; + + void begin(const char* initial = "", int max = KB_MAX_LEN) { + max_len = (max > KB_MAX_LEN) ? KB_MAX_LEN : max; + strncpy(buf, initial, max_len); + buf[max_len] = '\0'; + len = strlen(buf); + row = col = 0; + caps = false; + _ph_menu.active = false; + // default placeholders — always available + _ph_count = 0; + addPlaceholder("{loc}"); + addPlaceholder("{time}"); + } + + void addPlaceholder(const char* ph) { + if (_ph_count < KB_PH_MAX) { + strncpy(_ph_buf[_ph_count], ph, KB_PH_LEN - 1); + _ph_buf[_ph_count][KB_PH_LEN - 1] = '\0'; + _ph_count++; + } + } + + int render(DisplayDriver& display) { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + + const int lh = display.getLineHeight(); + const int cw = display.getCharWidth(); + const int cell_w = display.width() / KB_COLS_CHAR; + // compact: don't stretch cells beyond lh; freed vertical space goes to preview lines + const int kb_h = (KB_ROWS_CHAR + 1) * lh; + const int preview_h = display.height() - kb_h - 1; + const int prev_lines = (preview_h / lh) > 1 ? (preview_h / lh) : 1; + const int sep_y = prev_lines * lh; + const int chars_y = sep_y + 1; + const int cell_h = (display.height() - chars_y) / (KB_ROWS_CHAR + 1); + const int spec_y = chars_y + KB_ROWS_CHAR * cell_h; + const int spec_w = display.width() / KB_SPECIAL; + + // multi-line text preview: cursor always on last preview line + int cpl = display.width() / cw; // chars per preview line + if (cpl < 1) cpl = 1; + int cursor_line = len / cpl; + int first_line = (cursor_line >= prev_lines) ? (cursor_line - prev_lines + 1) : 0; + int start = first_line * cpl; + for (int pl = 0; pl < prev_lines; pl++) { + int ps = start + pl * cpl; + int pe = ps + cpl; + bool cursor_here = (ps <= len && (len < pe || pl == prev_lines - 1)); + char linebuf[32]; + if (cursor_here) { + int nc = len - ps; if (nc < 0) nc = 0; if (nc > cpl - 1) nc = cpl - 1; + snprintf(linebuf, sizeof(linebuf), "%.*s_", nc, buf + ps); + } else if (len > ps) { + int nc = (len < pe) ? (len - ps) : cpl; + snprintf(linebuf, sizeof(linebuf), "%.*s", nc, buf + ps); + } else { + linebuf[0] = '\0'; + } + display.setCursor(0, pl * lh); + display.print(linebuf); + } + display.fillRect(0, sep_y, display.width(), 1); + + // character grid + for (int r = 0; r < KB_ROWS_CHAR; r++) { + int y = chars_y + r * cell_h; + for (int c = 0; c < KB_COLS_CHAR; c++) { + bool sel = (row == r && col == c); + char ch = KB_CHARS[r][c]; + if (caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; + char ch_buf[2] = { ch == ' ' ? '_' : ch, '\0' }; + int cx = c * cell_w; + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(cx, y - 1, cell_w - 1, cell_h); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(cx + (cell_w - cw) / 2, y); + display.print(ch_buf); + } + } + + // special row: [^] [Sp] [Dl] [{}] [OK] + const char* spec[] = { "[^]", "[Sp]", "[Dl]", "[{}]", "[OK]" }; + for (int i = 0; i < KB_SPECIAL; i++) { + bool sel = (row == KB_ROWS_CHAR && col == i); + bool active = (i == 0 && caps); + int sx = i * spec_w; + if (sel || active) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(sx, spec_y - 1, spec_w - 1, cell_h); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + int tw = display.getTextWidth(spec[i]); + display.setCursor(sx + (spec_w - tw) / 2, spec_y); + display.print(spec[i]); + display.setColor(DisplayDriver::LIGHT); + } + + // placeholder picker overlay (drawn on top of keyboard) + if (_ph_menu.active) _ph_menu.render(display); + + return 50; + } + + Result handleInput(char c) { + // placeholder overlay consumes all input + if (_ph_menu.active) { + auto res = _ph_menu.handleInput(c); + if (res == PopupMenu::SELECTED) { + int idx = _ph_menu.selectedIndex(); + const char* ph = _ph_buf[idx]; + int ph_len = strlen(ph); + if (len + ph_len <= max_len) { + memcpy(buf + len, ph, ph_len); + len += ph_len; + buf[len] = '\0'; + } + } + return NONE; + } + + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) return CANCELLED; + + if (c == KEY_UP) { + if (row > 0) { + row--; + if (row == KB_ROWS_CHAR - 1) // leaving special row upward + col = col * KB_COLS_CHAR / KB_SPECIAL; + } + return NONE; + } + if (c == KEY_DOWN) { + if (row < KB_ROWS_CHAR) { + row++; + if (row == KB_ROWS_CHAR) // entering special row + col = col * KB_SPECIAL / KB_COLS_CHAR; + } + return NONE; + } + if (c == KEY_LEFT) { + if (col > 0) col--; + return NONE; + } + if (c == KEY_RIGHT) { + int max_col = (row == KB_ROWS_CHAR) ? KB_SPECIAL - 1 : KB_COLS_CHAR - 1; + if (col < max_col) col++; + return NONE; + } + if (c == KEY_ENTER) { + if (row < KB_ROWS_CHAR) { + if (len < max_len) { + char ch = KB_CHARS[row][col]; + if (caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; + buf[len++] = ch; + buf[len] = '\0'; + } + } else { + switch (col) { + case 0: caps = !caps; break; + case 1: + if (len < max_len) { buf[len++] = ' '; buf[len] = '\0'; } + break; + case 2: + if (len > 0) buf[--len] = '\0'; + break; + case 3: + _ph_menu.begin("Placeholder:", KB_PH_VISIBLE); + for (int i = 0; i < _ph_count; i++) _ph_menu.addItem(_ph_buf[i]); + break; + case 4: + return DONE; + } + } + } + return NONE; + } +}; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h new file mode 100644 index 00000000..699de8c2 --- /dev/null +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -0,0 +1,573 @@ +#pragma once +#include + +#ifndef M_PI + #define M_PI 3.14159265358979323846 +#endif + +class NearbyScreen : public UIScreen { + UITask* _task; + + int _visible = 4; // updated each render; used by handleInput for scroll clamping + + static const int FILTER_COUNT = 6; + static const char* FILTER_LABELS[FILTER_COUNT]; + static const uint8_t FILTER_TYPES[FILTER_COUNT]; + + // ── nearby list state ──────────────────────────────────────────────────────── + struct Entry { + char name[32]; + int32_t lat_e6, lon_e6; + float dist_km; + uint8_t type; + int contact_idx; + uint32_t lastmod; + }; + + static const int MAX_NEARBY = 32; + Entry _entries[MAX_NEARBY]; + int _count; + int _sel; + int _scroll; + bool _detail; + int32_t _own_lat, _own_lon; + bool _own_gps; + uint8_t _filter; + + unsigned long _detail_refresh_ms; + static const unsigned long DETAIL_REFRESH_MS = 10000UL; + + PopupMenu _ctx_menu; + + // ── discover sub-screen state ──────────────────────────────────────────────── + bool _discover_mode; + bool _discovering; + unsigned long _discover_started_ms; + static const unsigned long DISCOVER_DURATION_MS = 8000UL; + + DiscoverResult _dresults[DISCOVER_RESULTS_MAX]; + int _dresult_count; + int _dscroll; + int _dsel; + bool _ddetail; + + int _d_visible = 2; // updated each render; used by handleInputDiscover + + // ── helpers ────────────────────────────────────────────────────────────────── + static void pubKeyToBase64(const uint8_t* key, char* out, int out_len) { + static const char T[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + int j = 0; + for (int i = 0; i < PUB_KEY_SIZE && j + 5 < out_len; i += 3) { + uint32_t b = ((uint32_t)key[i] << 16) + | (i+1 < PUB_KEY_SIZE ? (uint32_t)key[i+1] << 8 : 0) + | (i+2 < PUB_KEY_SIZE ? (uint32_t)key[i+2] : 0); + out[j++] = T[(b >> 18) & 63]; + out[j++] = T[(b >> 12) & 63]; + if (i+1 < PUB_KEY_SIZE) out[j++] = T[(b >> 6) & 63]; + if (i+2 < PUB_KEY_SIZE) out[j++] = T[b & 63]; + } + out[j] = '\0'; + } + + static float haversineKm(int32_t lat1, int32_t lon1, int32_t lat2, int32_t lon2) { + static const float DEG2RAD = (float)M_PI / 180.0f; + float la1 = lat1 * (1e-6f * DEG2RAD); + float la2 = lat2 * (1e-6f * DEG2RAD); + float dla = (lat2 - lat1) * (1e-6f * DEG2RAD); + float dlo = (lon2 - lon1) * (1e-6f * DEG2RAD); + float a = sinf(dla/2)*sinf(dla/2) + cosf(la1)*cosf(la2)*sinf(dlo/2)*sinf(dlo/2); + return 6371.0f * 2.0f * asinf(sqrtf(a)); + } + + static int bearingDeg(int32_t lat1, int32_t lon1, int32_t lat2, int32_t lon2) { + static const float DEG2RAD = (float)M_PI / 180.0f; + float la1 = lat1 * (1e-6f * DEG2RAD); + float la2 = lat2 * (1e-6f * DEG2RAD); + float dlo = (lon2 - lon1) * (1e-6f * DEG2RAD); + float b = atan2f(sinf(dlo)*cosf(la2), + cosf(la1)*sinf(la2) - sinf(la1)*cosf(la2)*cosf(dlo)) * (180.0f / (float)M_PI); + if (b < 0.0f) b += 360.0f; + return (int)(b + 0.5f) % 360; + } + + static const char* bearingCardinal(int deg) { + static const char* dirs[] = { "N","NE","E","SE","S","SW","W","NW" }; + return dirs[((deg + 22) % 360) / 45]; + } + + static void fmtAge(char* buf, int n, uint32_t lastmod) { + uint32_t now = rtc_clock.getCurrentTime(); + if (now < lastmod || lastmod == 0) { snprintf(buf, n, "unknown"); return; } + uint32_t age = now - lastmod; + if (age < 60) snprintf(buf, n, "%us ago", age); + else if (age < 3600) snprintf(buf, n, "%um ago", age / 60); + else if (age < 86400) snprintf(buf, n, "%uh ago", age / 3600); + else snprintf(buf, n, ">1d ago"); + } + + static void fmtDist(char* buf, int n, float km) { + if (km < 1.0f) snprintf(buf, n, "%dm", (int)(km * 1000 + 0.5f)); + else if (km < 100.0f) snprintf(buf, n, "%.1fkm", km); + else snprintf(buf, n, "%dkm", (int)(km + 0.5f)); + } + + static const char* typeName(uint8_t t) { + switch (t) { + case ADV_TYPE_CHAT: return "Companion"; + case ADV_TYPE_REPEATER: return "Repeater"; + case ADV_TYPE_ROOM: return "Room"; + case ADV_TYPE_SENSOR: return "Sensor"; + default: return "Unknown"; + } + } + + void refresh() { + _count = 0; + _own_gps = false; + _own_lat = _own_lon = 0; + +#if ENV_INCLUDE_GPS == 1 + LocationProvider* loc = sensors.getLocationProvider(); + if (loc && loc->isValid()) { + _own_lat = loc->getLatitude(); + _own_lon = loc->getLongitude(); + _own_gps = true; + } +#endif + + int nc = the_mesh.getNumContacts(); + for (int i = 0; i < nc && _count < MAX_NEARBY; i++) { + ContactInfo ci; + if (!the_mesh.getContactByIdx(i, ci)) continue; + if (_filter == 0 && !(ci.flags & 1)) continue; + if (_filter >= 2 && ci.type != FILTER_TYPES[_filter]) continue; + + Entry& e = _entries[_count++]; + strncpy(e.name, ci.name, sizeof(e.name) - 1); + e.name[sizeof(e.name) - 1] = '\0'; + e.lat_e6 = ci.gps_lat; + e.lon_e6 = ci.gps_lon; + bool remote_gps = (ci.gps_lat != 0 || ci.gps_lon != 0); + e.dist_km = (_own_gps && remote_gps) + ? haversineKm(_own_lat, _own_lon, ci.gps_lat, ci.gps_lon) + : -1.0f; + e.type = ci.type; + e.contact_idx = i; + e.lastmod = ci.lastmod; + } + + // sort by distance ascending; nodes without GPS go to the end + for (int i = 0; i < _count - 1; i++) { + int best = i; + for (int j = i + 1; j < _count; j++) { + float dj = _entries[j].dist_km, db = _entries[best].dist_km; + if (dj >= 0.0f && (db < 0.0f || dj < db)) best = j; + } + if (best != i) { Entry tmp = _entries[i]; _entries[i] = _entries[best]; _entries[best] = tmp; } + } + + if (_count == 0) { + _sel = _scroll = 0; + } else if (_sel >= _count) { + _sel = _count - 1; + if (_scroll > _sel) _scroll = _sel; + } + } + + // ── discover sub-screen ────────────────────────────────────────────────────── + void enterDiscoverMode() { + _discover_mode = true; + _discovering = true; + _ddetail = false; + _discover_started_ms = millis(); + _dresult_count = 0; + _dscroll = 0; + _dsel = 0; + the_mesh.sendNodeDiscoverReq(); + } + + int renderDiscover(DisplayDriver& display) { + int lh = display.getLineHeight(); + int hdr = display.headerH(); + int d_box_h = 2 * lh + 3; // two text rows + padding + int d_item_h = d_box_h + 2; + int d_start_y = hdr; + _d_visible = display.listVisible(d_item_h); + if (_d_visible < 1) _d_visible = 1; + + if (_ddetail) { + // ── full-screen detail for selected node ────────────────────────────── + const DiscoverResult& r = _dresults[_dsel]; + const char* fullType = (r.type == ADV_TYPE_REPEATER) ? "Repeater" : + (r.type == ADV_TYPE_SENSOR) ? "Sensor" : + (r.type == ADV_TYPE_ROOM) ? "Room" : "Node"; + + // title bar: node name inverted + char label[32]; + if (r.name[0]) { strncpy(label, r.name, 31); label[31] = '\0'; } + else { snprintf(label, sizeof(label), "[%s]", fullType); } + char filtered[32]; + display.translateUTF8ToBlocks(filtered, label, sizeof(filtered)); + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, 0, display.width(), hdr - 1); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(2, 1, display.width() - 4, filtered); + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, hdr - 1, display.width(), 1); + + // public key as base64 — truncate to one line via charWidth + char b64[48]; + pubKeyToBase64(r.pub_key, b64, sizeof(b64)); + { + int max_chars = (display.width() - 4) / display.getCharWidth(); + int b64_len = strlen(b64); + char b64_line[48]; + if (b64_len > max_chars) { + strncpy(b64_line, b64, max_chars - 3); + b64_line[max_chars - 3] = '\0'; + strcat(b64_line, "..."); + } else { + strncpy(b64_line, b64, sizeof(b64_line) - 1); + b64_line[sizeof(b64_line) - 1] = '\0'; + } + display.setCursor(2, hdr); + display.print(b64_line); + } + + // distribute 4 remaining lines below b64 — compact step, shrink only if needed + int step = display.lineStep(); + if (step * 5 > display.height() - hdr) step = (display.height() - hdr) / 5; + char buf[32]; + snprintf(buf, sizeof(buf), "RSSI: %d dBm", (int)r.rssi); + display.setCursor(2, hdr + step); display.print(buf); + + snprintf(buf, sizeof(buf), "SNR: %d dB", (int)(r.snr_x4 / 4)); + display.setCursor(2, hdr + step * 2); display.print(buf); + + snprintf(buf, sizeof(buf), "Rem: %d dB", (int)(r.remote_snr_x4 / 4)); + display.setCursor(2, hdr + step * 3); display.print(buf); + + display.setCursor(2, hdr + step * 4); + display.print(r.is_known ? "Status: known" : "Status: new"); + + return 5000; + } + + // ── list view ───────────────────────────────────────────────────────────── + _dresult_count = the_mesh.getDiscoverResults(_dresults, DISCOVER_RESULTS_MAX); + + if (_discovering && millis() - _discover_started_ms >= DISCOVER_DURATION_MS) + _discovering = false; + + display.setColor(DisplayDriver::LIGHT); + char title[28]; + if (_discovering) + snprintf(title, sizeof(title), "SCANNING... (%d)", _dresult_count); + else if (_dresult_count == 0) + snprintf(title, sizeof(title), "DISCOVER: none"); + else + snprintf(title, sizeof(title), "DISCOVER (%d found)", _dresult_count); + display.drawTextCentered(display.width() / 2, 0, title); + display.fillRect(0, hdr - 1, display.width(), 1); + + if (_dresult_count == 0) { + display.drawTextCentered(display.width() / 2, display.height() / 2, + _discovering ? "Waiting for replies..." : "No nodes found"); + } else { + if (_dsel >= _dresult_count) _dsel = _dresult_count - 1; + if (_dscroll > _dresult_count - _d_visible) + _dscroll = _dresult_count > _d_visible ? _dresult_count - _d_visible : 0; + if (_dscroll < 0) _dscroll = 0; + + for (int i = 0; i < _d_visible && (_dscroll + i) < _dresult_count; i++) { + int idx = _dscroll + i; + bool sel = (idx == _dsel); + const DiscoverResult& r = _dresults[idx]; + int y = d_start_y + i * d_item_h; + + const char* typeStr = (r.type == ADV_TYPE_REPEATER) ? "Rpt" : + (r.type == ADV_TYPE_SENSOR) ? "Snsr" : + (r.type == ADV_TYPE_ROOM) ? "Room" : "?"; + + display.setColor(DisplayDriver::LIGHT); + if (sel) { + display.fillRect(0, y, display.width(), d_box_h); + display.setColor(DisplayDriver::DARK); + } else { + display.drawRect(0, y, display.width(), d_box_h); + display.fillRect(1, y + 1, display.width() - 2, lh); + display.setColor(DisplayDriver::DARK); + } + + // header: name left, type right + char label[32]; + if (r.name[0]) { strncpy(label, r.name, 31); label[31] = '\0'; } + else { + const char* ft = (r.type == ADV_TYPE_REPEATER) ? "Repeater" : + (r.type == ADV_TYPE_SENSOR) ? "Sensor" : + (r.type == ADV_TYPE_ROOM) ? "Room" : "Node"; + snprintf(label, sizeof(label), "[%s]", ft); + } + char filtered[32]; + display.translateUTF8ToBlocks(filtered, label, sizeof(filtered)); + int tw = display.getTextWidth(typeStr); + display.drawTextEllipsized(3, y + 1, display.width() - 6 - tw, filtered); + display.setCursor(display.width() - 3 - tw, y + 1); + display.print(typeStr); + + // body: RSSI + SNR + display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); + char sig[24]; + snprintf(sig, sizeof(sig), "RSSI:%d SNR:%d", (int)r.rssi, (int)(r.snr_x4 / 4)); + display.drawTextEllipsized(3, y + lh + 2, display.width() - 6, sig); + } + + display.setColor(DisplayDriver::LIGHT); + int cw = display.getCharWidth(); + if (_dscroll > 0) + { display.setCursor(display.width() - cw, d_start_y); display.print("^"); } + if (_dscroll + _d_visible < _dresult_count) + { display.setCursor(display.width() - cw, d_start_y + (_d_visible - 1) * d_item_h); display.print("v"); } + } + + return _discovering ? 200 : 2000; + } + + bool handleInputDiscover(char c) { + if (_ddetail) { + if (c == KEY_CANCEL) { _ddetail = false; return true; } + if (c == KEY_CONTEXT_MENU || c == KEY_ENTER) { + enterDiscoverMode(); // re-scan; clears _ddetail + return true; + } + return true; + } + if (c == KEY_CANCEL) { + _discover_mode = false; + refresh(); + return true; + } + if (c == KEY_ENTER && _dresult_count > 0) { + _ddetail = true; + return true; + } + if (c == KEY_CONTEXT_MENU) { + enterDiscoverMode(); // re-scan + return true; + } + if (c == KEY_UP && _dsel > 0) { + _dsel--; + if (_dsel < _dscroll) _dscroll = _dsel; + return true; + } + if (c == KEY_DOWN && _dsel < _dresult_count - 1) { + _dsel++; + if (_dsel >= _dscroll + _d_visible) _dscroll = _dsel - _d_visible + 1; + return true; + } + return true; + } + +public: + NearbyScreen(UITask* task) + : _task(task), _count(0), _sel(0), _scroll(0), _detail(false), + _own_lat(0), _own_lon(0), _own_gps(false), _filter(0), + _detail_refresh_ms(0), + _discover_mode(false), _discovering(false), _discover_started_ms(0), + _dresult_count(0), _dscroll(0), _dsel(0), _ddetail(false) {} + + void enter() { + _sel = _scroll = 0; + _detail = false; + _filter = 0; + _discover_mode = false; + _ddetail = false; + _dsel = 0; + _ctx_menu.active = false; + refresh(); + } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + + // ── discover sub-screen ────────────────────────────────────────────────── + if (_discover_mode) return renderDiscover(display); + + // periodic refresh in detail view — preserve selected contact by idx + if (_detail && millis() - _detail_refresh_ms >= DETAIL_REFRESH_MS) { + int saved_contact_idx = (_sel < _count) ? _entries[_sel].contact_idx : -1; + refresh(); + bool found = false; + if (saved_contact_idx >= 0) { + for (int i = 0; i < _count; i++) { + if (_entries[i].contact_idx == saved_contact_idx) { _sel = i; found = true; break; } + } + } + if (!found) _detail = false; + _detail_refresh_ms = millis(); + } + + // ── detail view ────────────────────────────────────────────────────────── + if (_detail && _sel < _count) { + const Entry& e = _entries[_sel]; + int hdr = display.headerH(); + + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, 0, display.width(), hdr - 1); + display.setColor(DisplayDriver::DARK); + char filtered[32]; + display.translateUTF8ToBlocks(filtered, e.name, sizeof(filtered)); + display.drawTextEllipsized(2, 1, display.width() - 4, filtered); + display.setColor(DisplayDriver::LIGHT); + + // 5 lines: lat, lon, dist+bearing, type, seen — compact step, shrink only if needed + int step = display.lineStep(); + if (step * 5 > display.height() - hdr) step = (display.height() - hdr) / 5; + char buf[32]; + snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6); + display.setCursor(2, hdr); display.print(buf); + + snprintf(buf, sizeof(buf), "Lon: %.5f", e.lon_e6 / 1e6); + display.setCursor(2, hdr + step); display.print(buf); + + if (e.dist_km >= 0.0f) { + char dist[12]; + fmtDist(dist, sizeof(dist), e.dist_km); + int az = bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6); + snprintf(buf, sizeof(buf), "Dist: %s %s", dist, bearingCardinal(az)); + } else { + snprintf(buf, sizeof(buf), "Dist: no GPS"); + } + display.setCursor(2, hdr + step * 2); display.print(buf); + + snprintf(buf, sizeof(buf), "Type: %s", typeName(e.type)); + display.setCursor(2, hdr + step * 3); display.print(buf); + + char age[16]; + fmtAge(age, sizeof(age), e.lastmod); + snprintf(buf, sizeof(buf), "Seen: %s", age); + display.setCursor(2, hdr + step * 4); display.print(buf); + + return 2000; + } + + // ── list view ──────────────────────────────────────────────────────────── + int item_h = display.lineStep(); + int start_y = display.listStart(); + int dist_col = display.width() - display.getCharWidth() * 7; + _visible = display.listVisible(item_h); + + display.setColor(DisplayDriver::LIGHT); + char title[22]; + snprintf(title, sizeof(title), "NEARBY[%s]", FILTER_LABELS[_filter]); + display.drawTextCentered(display.width() / 2, 0, title); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); + + if (_count == 0) { + display.drawTextCentered(display.width() / 2, display.height() / 2 - display.lineStep() / 2, "No contacts found"); + display.drawTextCentered(display.width() / 2, display.height() / 2 + display.lineStep() / 2, "[Enter]=Discover"); + } else { + for (int i = 0; i < _visible && (_scroll + i) < _count; i++) { + int idx = _scroll + i; + bool sel = (idx == _sel); + int y = start_y + i * item_h; + const Entry& e = _entries[idx]; + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), item_h - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + + char filt[32]; + display.translateUTF8ToBlocks(filt, e.name, sizeof(filt)); + display.drawTextEllipsized(2, y, dist_col - 4, filt); + + display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); + char dist[10]; + if (e.dist_km >= 0.0f) fmtDist(dist, sizeof(dist), e.dist_km); + else strncpy(dist, "?GPS", sizeof(dist)); + display.setCursor(dist_col, y); + display.print(dist); + } + + display.setColor(DisplayDriver::LIGHT); + int cw = display.getCharWidth(); + if (_scroll > 0) + { display.setCursor(display.width() - cw, start_y); display.print("^"); } + if (_scroll + _visible < _count) + { display.setCursor(display.width() - cw, start_y + (_visible - 1) * item_h); display.print("v"); } + } + + if (_ctx_menu.active) { + _ctx_menu.render(display); + return 50; + } + + return _count == 0 ? 3000 : 2000; + } + + bool handleInput(char c) override { + // ── discover sub-screen ────────────────────────────────────────────────── + if (_discover_mode) return handleInputDiscover(c); + + // ── detail view ───────────────────────────────────────────────────────── + if (_detail) { + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _detail = false; return true; } + return true; + } + + // ── context menu ───────────────────────────────────────────────────────── + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED) { + if (_ctx_menu.selectedIndex() == 0) + enterDiscoverMode(); + else + _task->gotoToolsScreen(); + } + return true; + } + + // ── list view ──────────────────────────────────────────────────────────── + if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; } + if (c == KEY_CONTEXT_MENU) { + _ctx_menu.begin("Options", 2); + _ctx_menu.addItem("Discover nearby"); + _ctx_menu.addItem("Back"); + return true; + } + if (c == KEY_UP && _sel > 0) { + _sel--; + if (_sel < _scroll) _scroll = _sel; + return true; + } + if (c == KEY_DOWN && _sel < _count - 1) { + _sel++; + if (_sel >= _scroll + _visible) _scroll = _sel - _visible + 1; + return true; + } + if (c == KEY_ENTER && _count == 0) { enterDiscoverMode(); return true; } + if (c == KEY_ENTER && _count > 0) { + _detail = true; + _detail_refresh_ms = millis(); + return true; + } + if (c == KEY_LEFT) { + _filter = (_filter + FILTER_COUNT - 1) % FILTER_COUNT; + refresh(); + return true; + } + if (c == KEY_RIGHT) { + _filter = (_filter + 1) % FILTER_COUNT; + refresh(); + return true; + } + return false; + } +}; + +const char* NearbyScreen::FILTER_LABELS[6] = { "Fav", "ALL", "Comp", "Rpt", "Room", "Snsr" }; +const uint8_t NearbyScreen::FILTER_TYPES[6] = { 0, 0, ADV_TYPE_CHAT, ADV_TYPE_REPEATER, ADV_TYPE_ROOM, ADV_TYPE_SENSOR }; diff --git a/examples/companion_radio/ui-new/PopupMenu.h b/examples/companion_radio/ui-new/PopupMenu.h new file mode 100644 index 00000000..94748870 --- /dev/null +++ b/examples/companion_radio/ui-new/PopupMenu.h @@ -0,0 +1,90 @@ +#pragma once +#include +#include + +// Generic scrollable popup menu overlay. +// Caller owns item string lifetimes — addItem() stores const char* pointers. +// Navigation wraps around: up from first item goes to last, and vice versa. +struct PopupMenu { + static const int PM_MAX_ITEMS = 16; + static const int PM_BX = 12; + static const int PM_BY = 10; + static const int PM_BW = 104; + static const int PM_ITEM_H = 10; + + const char* _items[PM_MAX_ITEMS]; + int _count; + int _sel; + int _scroll; + int _visible; + bool active; + const char* _title; + + enum Result { NONE, SELECTED, CANCELLED }; + + PopupMenu() : _count(0), _sel(0), _scroll(0), _visible(3), active(false), _title(nullptr) {} + + void begin(const char* title, int visible = 3) { + _count = 0; _sel = 0; _scroll = 0; + _visible = visible; active = true; _title = title; + } + + void addItem(const char* item) { + if (_count < PM_MAX_ITEMS) _items[_count++] = item; + } + + int render(DisplayDriver& display) { + int vis = (_count < _visible) ? _count : _visible; + int bh = 12 + vis * PM_ITEM_H; + + display.setColor(DisplayDriver::DARK); + display.fillRect(PM_BX, PM_BY, PM_BW, bh); + display.setColor(DisplayDriver::LIGHT); + display.drawRect(PM_BX, PM_BY, PM_BW, bh); + display.setCursor(PM_BX + 4, PM_BY + 1); + display.print(_title); + display.fillRect(PM_BX, PM_BY + 10, PM_BW, 1); + + if (_scroll > 0) + { display.setCursor(PM_BX + PM_BW - 7, PM_BY + 1); display.print("^"); } + if (_scroll + vis < _count) + { display.setCursor(PM_BX + PM_BW - 7, PM_BY + 2 + vis * PM_ITEM_H); display.print("v"); } + + for (int i = 0; i < vis && (_scroll + i) < _count; i++) { + int idx = _scroll + i; + int py = PM_BY + 12 + i * PM_ITEM_H; + if (idx == _sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(PM_BX + 1, py - 1, PM_BW - 2, PM_ITEM_H); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(PM_BX + 4, py); + display.print(_items[idx]); + } + display.setColor(DisplayDriver::LIGHT); + return 50; + } + + Result handleInput(char c) { + if (_count == 0) { active = false; return CANCELLED; } + if (c == KEY_UP) { + _sel = (_sel > 0) ? _sel - 1 : _count - 1; + if (_sel < _scroll) _scroll = _sel; + else if (_sel == _count - 1 && _count > _visible) _scroll = _count - _visible; + return NONE; + } + if (c == KEY_DOWN) { + _sel = (_sel < _count - 1) ? _sel + 1 : 0; + if (_sel >= _scroll + _visible) _scroll = _sel - _visible + 1; + else if (_sel == 0) _scroll = 0; + return NONE; + } + if (c == KEY_ENTER) { active = false; return SELECTED; } + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { active = false; return CANCELLED; } + return NONE; + } + + int selectedIndex() const { return _sel; } +}; diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h new file mode 100644 index 00000000..6b8957aa --- /dev/null +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -0,0 +1,1223 @@ +#pragma once +// Custom screen — not part of upstream UITask.cpp +// Included by UITask.cpp after SettingsScreen.h is defined. + +class QuickMsgScreen : public UIScreen { + UITask* _task; + + enum Phase { MODE_SELECT, CONTACT_PICK, DM_HIST, MSG_PICK, CHANNEL_PICK, CHANNEL_HIST, KEYBOARD }; + Phase _phase; + + // MODE_SELECT + int _mode_sel; // 0=Direct, 1=Channel, 2=Room Servers + + // CONTACT_PICK + int _contact_sel, _contact_scroll; + int _num_contacts; + uint16_t _sorted[MAX_CONTACTS]; + ContactInfo _sel_contact; + bool _room_mode; // true = picking a room server, false = picking a DM contact + + // CHANNEL_PICK + int _channel_sel, _channel_scroll; + int _num_channels; + uint8_t _channel_indices[MAX_GROUP_CHANNELS]; + uint8_t _ch_unread[MAX_GROUP_CHANNELS]; + int _sel_channel_idx; + bool _sending_to_channel; + + // MSG_PICK (shared) + int _msg_sel, _msg_scroll; + int _active_msgs[QUICK_MSGS_MAX]; + int _active_msg_count; + + // CHANNEL_HIST + int _hist_sel, _hist_scroll; + FullscreenMsgView _fs; + int _unread_at_entry; // _ch_unread value when entering CHANNEL_HIST + int _viewing_max_seen; // highest _hist_sel reached in current session + static const int CH_HIST_MAX = 32; + + // KEYBOARD + KeyboardWidget _kb; + + // Context menu (opened by KEY_CONTEXT_MENU in CHANNEL_PICK / CONTACT_PICK / histories) + PopupMenu _ctx_menu; + bool _ctx_dirty; + char _ctx_notif_item[22]; + char _ctx_melody_item[20]; + char _reply_prefix[36]; // "@[nick] " built when reply is triggered + bool _reply_mode; // true while composing a reply (prefix is prepended) + + struct ChHistEntry { uint8_t ch_idx; char text[140]; uint32_t timestamp; }; + ChHistEntry _hist[CH_HIST_MAX]; + int _hist_head, _hist_count; + + // DM_HIST + struct DmHistEntry { uint8_t prefix[4]; uint8_t outgoing; char text[80]; uint32_t timestamp; }; + static const int DM_HIST_MAX = 64; + DmHistEntry _dm_hist[DM_HIST_MAX]; + int _dm_hist_head, _dm_hist_count; + int _dm_hist_sel, _dm_hist_scroll; + FullscreenMsgView _dm_fs; + + int _visible = 4; // updated in render(); used by handleInput() for scroll clamping + int _hist_visible = 2; // updated in render(); for history list scroll clamping + + void expandMsg(const char* tmpl, char* out, int out_len) const { + double lat = 0, lon = 0; + bool gps_valid = false; +#if ENV_INCLUDE_GPS == 1 + LocationProvider* loc = sensors.getLocationProvider(); + if (loc && loc->isValid()) { + lat = loc->getLatitude() / 1000000.0; + lon = loc->getLongitude() / 1000000.0; + gps_valid = true; + } +#endif + NodePrefs* np = _task->getNodePrefs(); + float batt = (float)board.getBattMilliVolts() / 1000.0f; + ::expandMsg(tmpl, out, out_len, lat, lon, gps_valid, + rtc_clock.getCurrentTime(), + np ? np->tz_offset_hours : 0, + &sensors, batt); + } + + // Strip "@[nick] " reply prefix from a message body for compact list display. + static const char* skipReplyPrefix(const char* text) { + if (text[0] == '@' && text[1] == '[') { + const char* close = strchr(text + 2, ']'); + if (close && close[1] == ' ' && close[2]) return close + 2; + } + return text; + } + + // Build "@[nick] " prefix from a channel message text ("nick: body") into _reply_prefix. + // Returns false if sender is "Me" (own message — no reply prefix needed). + bool buildChannelReplyPrefix(const char* text) { + const char* sep = strstr(text, ": "); + if (!sep) return false; + int slen = (int)(sep - text); + if (slen == 2 && strncmp(text, "Me", 2) == 0) return false; + if (slen > 31) slen = 31; + snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.*s] ", slen, text); + return true; + } + + void startReply(bool to_channel) { + _sending_to_channel = to_channel; + _reply_mode = true; + setupMsgPick(); + _phase = MSG_PICK; + } + + void setupMsgPick() { + _msg_sel = _msg_scroll = 0; + _active_msg_count = 0; + NodePrefs* p = _task->getNodePrefs(); + if (p) { + for (int i = 0; i < QUICK_MSGS_MAX; i++) { + if (p->custom_msgs[i][0] != '\0') + _active_msgs[_active_msg_count++] = i; + } + } + } + + + static void fmtMsgAge(char* buf, int n, uint32_t timestamp) { + uint32_t now = rtc_clock.getCurrentTime(); + if (timestamp == 0 || now < timestamp) { buf[0] = '\0'; return; } + uint32_t age = now - timestamp; + if (age < 60) snprintf(buf, n, "%us", age); + else if (age < 3600) snprintf(buf, n, "%um", age / 60); + else if (age < 86400) snprintf(buf, n, "%uh", age / 3600); + else snprintf(buf, n, ">1d"); + } + + // count history entries for a specific channel + int histCountForChannel(int ch_idx) const { + int n = 0; + for (int i = 0; i < _hist_count; i++) { + if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++; + } + return n; + } + + // get ring-buffer position of j-th history entry for channel (newest first) + int histEntryForChannel(int ch_idx, int j) const { + int n = 0; + for (int i = _hist_count - 1; i >= 0; i--) { + int pos = (_hist_head + i) % CH_HIST_MAX; + if (_hist[pos].ch_idx == (uint8_t)ch_idx) { + if (n == j) return pos; + n++; + } + } + return -1; + } + + void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { + int pos; + if (_dm_hist_count < DM_HIST_MAX) { + pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX; + _dm_hist_count++; + } else { + pos = _dm_hist_head; + _dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX; + } + memcpy(_dm_hist[pos].prefix, pub_key, 4); + _dm_hist[pos].outgoing = outgoing ? 1 : 0; + _dm_hist[pos].timestamp = rtc_clock.getCurrentTime(); + strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1); + _dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0'; + } + + int dmHistCountForContact(const uint8_t* prefix) const { + int n = 0; + for (int i = 0; i < _dm_hist_count; i++) + if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++; + return n; + } + + int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest + int n = 0; + for (int i = _dm_hist_count - 1; i >= 0; i--) { + int pos = (_dm_hist_head + i) % DM_HIST_MAX; + if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) { + if (n == j) return pos; + n++; + } + } + return -1; + } + + void afterSend(bool ok, const char* msg) { + _reply_mode = false; + if (ok && _sending_to_channel) { + _hist_sel = 0; + _hist_scroll = 0; + _phase = CHANNEL_HIST; // set before addChannelMsg so viewing=true, no unread bump + char entry[sizeof(ChHistEntry::text)]; + snprintf(entry, sizeof(entry), "Me: %s", msg); + addChannelMsg(_sel_channel_idx, entry); + // After inserting sent msg at index 0, the unread index range is stale. + // User is active in this channel — treat as fully read. + _ch_unread[_sel_channel_idx] = 0; + _unread_at_entry = 0; + _viewing_max_seen = 0; + _task->showAlert("Sent!", 600); + } else if (ok) { + storeDMMsg(_sel_contact.id.pub_key, true, msg); + _dm_hist_sel = 0; + _dm_hist_scroll = 0; + _phase = DM_HIST; + _task->showAlert("Sent!", 600); + } else { + _task->showAlert("Send failed", 1500); + _task->gotoHomeScreen(); + } + } + + bool sendText(const char* msg) { + if (_sending_to_channel) { + ChannelDetails ch; + if (!the_mesh.getChannel(_sel_channel_idx, ch)) return false; + return the_mesh.sendGroupMessage(rtc_clock.getCurrentTime(), ch.channel, + the_mesh.getNodeName(), msg, strlen(msg)); + } else { + uint32_t expected_ack = 0, est_timeout = 0; + return the_mesh.sendMessage(_sel_contact, rtc_clock.getCurrentTime(), 0, + msg, expected_ack, est_timeout) > 0; + } + } + + void buildContactList() { + NodePrefs* p = _task->getNodePrefs(); + ContactInfo c; + int total = the_mesh.getNumContacts(); + _num_contacts = 0; + if (_room_mode) { + bool fav_only = (p && p->room_fav_only); + for (int i = 0; i < total; i++) { + if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_ROOM) continue; + if (fav_only && !(c.flags & 0x01)) continue; + _sorted[_num_contacts++] = i; + } + } else { + bool show_all = (p && p->dm_show_all); + for (int i = 0; i < total; i++) { + if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue; + if (!show_all && !(c.flags & 0x01)) continue; + _sorted[_num_contacts++] = i; + } + } + } + + void buildChannelList() { + _num_channels = 0; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails ch; + if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') { + _channel_indices[_num_channels++] = (uint8_t)i; + } + } + } + + // Returns per-channel notification state: 0=follow global, 1=muted, 2=force-on + uint8_t chNotifState(uint8_t ch_idx) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + uint64_t mask = 1ULL << ch_idx; + if (!(p->ch_notif_override & mask)) return 0; + return (p->ch_notif_muted & mask) ? 1 : 2; + } + + void setChNotifState(uint8_t ch_idx, uint8_t state) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + uint64_t mask = 1ULL << ch_idx; + if (state == 0) { + p->ch_notif_override &= ~mask; + p->ch_notif_muted &= ~mask; + } else if (state == 1) { + p->ch_notif_override |= mask; + p->ch_notif_muted |= mask; + } else { + p->ch_notif_override |= mask; + p->ch_notif_muted &= ~mask; + } + } + + uint8_t dmNotifState(const uint8_t* pub_key) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) + if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) + return p->dm_notif[i].state; + return 0; + } + + void setDmNotifState(const uint8_t* pub_key, uint8_t state) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { + if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) { + if (state == 0) { memset(&p->dm_notif[i], 0, sizeof(p->dm_notif[i])); } + else p->dm_notif[i].state = state; + return; + } + } + if (state == 0) return; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { + if (p->dm_notif[i].state == 0) { + memcpy(p->dm_notif[i].prefix, pub_key, 4); + p->dm_notif[i].state = state; + return; + } + } + // table full — overwrite slot 0 + memcpy(p->dm_notif[0].prefix, pub_key, 4); + p->dm_notif[0].state = state; + } + + uint8_t chNotifMelody(uint8_t ch_idx) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + uint64_t mask = 1ULL << ch_idx; + if (!(p->ch_notif_melody_set & mask)) return 0; + return (p->ch_notif_melody_2 & mask) ? 2 : 1; + } + + void setChNotifMelody(uint8_t ch_idx, uint8_t slot) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + uint64_t mask = 1ULL << ch_idx; + if (slot == 0) { + p->ch_notif_melody_set &= ~mask; + p->ch_notif_melody_2 &= ~mask; + } else if (slot == 1) { + p->ch_notif_melody_set |= mask; + p->ch_notif_melody_2 &= ~mask; + } else { + p->ch_notif_melody_set |= mask; + p->ch_notif_melody_2 |= mask; + } + } + + uint8_t dmMelodySlot(const uint8_t* pub_key) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) + if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) + return p->dm_melody[i].slot; + return 0; + } + + void setDmMelody(const uint8_t* pub_key, uint8_t slot) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { + if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) { + if (slot == 0) memset(&p->dm_melody[i], 0, sizeof(p->dm_melody[i])); + else p->dm_melody[i].slot = slot; + return; + } + } + if (slot == 0) return; + for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { + if (p->dm_melody[i].slot == 0) { + memcpy(p->dm_melody[i].prefix, pub_key, 4); + p->dm_melody[i].slot = slot; + return; + } + } + memcpy(p->dm_melody[0].prefix, pub_key, 4); + p->dm_melody[0].slot = slot; + } + +public: + QuickMsgScreen(UITask* task) + : _task(task), _phase(MODE_SELECT), _mode_sel(0), + _contact_sel(0), _contact_scroll(0), _num_contacts(0), _room_mode(false), + _channel_sel(0), _channel_scroll(0), _num_channels(0), + _sel_channel_idx(0), _sending_to_channel(false), + _msg_sel(0), _msg_scroll(0), _active_msg_count(0), + _hist_sel(0), _hist_scroll(0), + _unread_at_entry(0), _viewing_max_seen(0), + _hist_head(0), _hist_count(0), + _dm_hist_head(0), _dm_hist_count(0), + _dm_hist_sel(-1), _dm_hist_scroll(0), + _ctx_dirty(false), _reply_mode(false) { + memset(_ch_unread, 0, sizeof(_ch_unread)); + } + + void addChannelMsg(uint8_t ch_idx, const char* text) { + int pos; + if (_hist_count < CH_HIST_MAX) { + pos = (_hist_head + _hist_count) % CH_HIST_MAX; + _hist_count++; + } else { + pos = _hist_head; + _hist_head = (_hist_head + 1) % CH_HIST_MAX; + } + _hist[pos].ch_idx = ch_idx; + _hist[pos].timestamp = rtc_clock.getCurrentTime(); + strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1); + _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; + + if (ch_idx < MAX_GROUP_CHANNELS) { + bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx); + if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++; + } + } + + void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { + storeDMMsg(pub_key, outgoing, text); + } + + int getDMUnreadTotal() const { + return _task->getDMUnreadTotal(); + } + + int getTotalChannelUnread() const { + int total = 0; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i]; + return total; + } + + void clearAllChannelUnread() { + memset(_ch_unread, 0, sizeof(_ch_unread)); + } + + void updateChannelUnread() { + if (_hist_sel < 0 || _sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return; + if (_hist_sel > _viewing_max_seen) _viewing_max_seen = _hist_sel; + // histEntryForChannel is newest-first: index 0 = newest (unread), higher = older. + // Each step down from 0 sees one more message; seen count = max_seen + 1. + int remaining = _unread_at_entry - (_viewing_max_seen + 1); + _ch_unread[_sel_channel_idx] = (uint8_t)(remaining > 0 ? remaining : 0); + } + + void reset() { + _phase = MODE_SELECT; + _mode_sel = 0; + _contact_sel = _contact_scroll = 0; + _msg_sel = _msg_scroll = 0; + _channel_sel = _channel_scroll = 0; + _sending_to_channel = false; + + _room_mode = false; + buildContactList(); + buildChannelList(); + + _ctx_menu.active = false; + _ctx_dirty = false; + _unread_at_entry = 0; + _viewing_max_seen = 0; + } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + + int lh = display.getLineHeight(); + int item_h = display.lineStep(); + int start_y = display.listStart(); + int cw = display.getCharWidth(); + _visible = display.listVisible(item_h); + + if (_phase == MODE_SELECT) { + display.drawTextCentered(display.width()/2, 0, "MESSAGE"); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); + const char* opts[] = { "Direct message", "Channels", "Room Servers" }; + int badges[3] = { + getDMUnreadTotal(), + _task->getChannelUnreadCount(), + _task->getRoomUnreadCount() + }; + for (int i = 0; i < 3; i++) { + int y = start_y + i * item_h; + bool sel = (i == _mode_sel); + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), item_h - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(0, y); + display.print(sel ? ">" : " "); + display.setCursor(cw + 2, y); + display.print(opts[i]); + if (badges[i] > 0) { + char badge[5]; + snprintf(badge, sizeof(badge), "%d", badges[i]); + int bw = display.getTextWidth(badge) + 2; + display.setCursor(display.width() - bw, y); + display.print(badge); + } + } + display.setColor(DisplayDriver::LIGHT); + + } else if (_phase == CONTACT_PICK) { + display.drawTextCentered(display.width()/2, 0, _room_mode ? "SELECT ROOM" : "SELECT CONTACT"); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); + + if (_num_contacts == 0) { + display.drawTextCentered(display.width()/2, display.height()/2, _room_mode ? "No room servers" : "No favourites"); + return 5000; + } + + for (int i = 0; i < _visible && (_contact_scroll+i) < _num_contacts; i++) { + int list_idx = _contact_scroll + i; + int mesh_idx = _sorted[list_idx]; + bool sel = (list_idx == _contact_sel); + int y = start_y + i * item_h; + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), item_h - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + + ContactInfo c; + if (the_mesh.getContactByIdx(mesh_idx, c)) { + display.setCursor(0, y); + display.print(sel ? ">" : " "); + char filtered[sizeof(c.name)]; + display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered)); + uint8_t dm_unread = _task->getDMUnread(c.id.pub_key); + char badge[5] = ""; + int bw = 0; + if (dm_unread > 0) { + snprintf(badge, sizeof(badge), "%d", (int)dm_unread); + bw = display.getTextWidth(badge) + 2; + } + display.drawTextEllipsized(cw + 2, y, display.width() - cw - 2 - bw - 1, filtered); + if (dm_unread > 0) { + display.setCursor(display.width() - bw + 1, y); + display.print(badge); + } + } + } + display.setColor(DisplayDriver::LIGHT); + if (_contact_scroll > 0) { display.setCursor(display.width() - cw, start_y); display.print("^"); } + if (_contact_scroll + _visible < _num_contacts) { display.setCursor(display.width() - cw, start_y + (_visible-1)*item_h); display.print("v"); } + + // Context menu overlay + if (_ctx_menu.active) _ctx_menu.render(display); + + } else if (_phase == CHANNEL_PICK) { + display.drawTextCentered(display.width()/2, 0, "SELECT CHANNEL"); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); + + if (_num_channels == 0) { + display.drawTextCentered(display.width()/2, display.height()/2, "No channels"); + return 5000; + } + + for (int i = 0; i < _visible && (_channel_scroll+i) < _num_channels; i++) { + int list_idx = _channel_scroll + i; + bool sel = (list_idx == _channel_sel); + int y = start_y + i * item_h; + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), item_h - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(0, y); + display.print(sel ? ">" : " "); + ChannelDetails ch; + if (the_mesh.getChannel(_channel_indices[list_idx], ch)) { + uint8_t unread = _ch_unread[_channel_indices[list_idx]]; + char badge[5] = ""; + int bw = 0; + if (unread > 0) { + snprintf(badge, sizeof(badge), "%d", (int)unread); + bw = display.getTextWidth(badge) + 2; + } + display.drawTextEllipsized(cw + 2, y, display.width() - cw - 4 - bw, ch.name); + if (unread > 0) { + display.setCursor(display.width() - bw, y); + display.print(badge); + } + } + } + display.setColor(DisplayDriver::LIGHT); + if (_channel_scroll > 0) { display.setCursor(display.width() - cw, start_y); display.print("^"); } + if (_channel_scroll + _visible < _num_channels) { display.setCursor(display.width() - cw, start_y + (_visible-1)*item_h); display.print("v"); } + + // Context menu overlay + if (_ctx_menu.active) _ctx_menu.render(display); + + } else if (_phase == DM_HIST) { + display.setTextSize(1); + char filtered_name[sizeof(_sel_contact.name)]; + display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name)); + + if (_dm_fs.active && _dm_hist_sel >= 0) { + int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + if (ring_pos >= 0) { + const DmHistEntry& e = _dm_hist[ring_pos]; + const char* sender = e.outgoing ? "Me" : filtered_name; + int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + int ret = _dm_fs.render(display, sender, e.text, + _dm_hist_sel < dm_count - 1, + _dm_hist_sel > 0); + if (_ctx_menu.active) _ctx_menu.render(display); + return ret; + } + return 500; + } + + int hist_box_h = 2 * lh + 3; + int hist_item_h = hist_box_h + 2; + int hist_start_y = display.headerH(); + int btn_h = lh + 4; + _hist_visible = (display.height() - hist_start_y - btn_h) / hist_item_h; + if (_hist_visible < 1) _hist_visible = 1; + int cby = display.height() - lh - 2; + + char title[24]; + display.setColor(DisplayDriver::LIGHT); + snprintf(title, sizeof(title), "%.23s", filtered_name); + display.drawTextCentered(display.width()/2, 0, title); + display.fillRect(0, lh + 1, display.width(), display.sepH()); + + int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + + for (int i = 0; i < _hist_visible && (_dm_hist_scroll + i) < dm_count; i++) { + int item = _dm_hist_scroll + i; + bool sel = (item == _dm_hist_sel); + int y = hist_start_y + i * hist_item_h; + + int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, item); + if (ring_pos < 0) continue; + + const DmHistEntry& e = _dm_hist[ring_pos]; + const char* sender = e.outgoing ? "Me" : filtered_name; + + char age[6]; fmtMsgAge(age, sizeof(age), e.timestamp); + int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y, display.width(), hist_box_h); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender); + if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); } + display.drawTextEllipsized(3, y + lh + 2, display.width() - cw - 2, skipReplyPrefix(e.text)); + } else { + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, y, display.width(), hist_box_h); + display.fillRect(1, y + 1, display.width() - 2, lh); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender); + if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); } + display.setColor(DisplayDriver::LIGHT); + display.drawTextEllipsized(3, y + lh + 2, display.width() - cw - 2, skipReplyPrefix(e.text)); + } + } + + if (dm_count == 0) { + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width()/2, display.height()/2, "No messages yet"); + } + + display.setColor(DisplayDriver::LIGHT); + if (_dm_hist_scroll > 0) { + display.setCursor(display.width() - cw, hist_start_y + 1); + display.print("^"); + } + if (_dm_hist_scroll + _hist_visible < dm_count) { + display.setCursor(display.width() - cw, hist_start_y + (_hist_visible - 1) * hist_item_h + hist_box_h - lh); + display.print("v"); + } + + bool compose_sel = (_dm_hist_sel == -1); + const char* ctxt = "[+ send]"; + int ctw = display.getTextWidth(ctxt); + int cbx = 1; + if (compose_sel) { + display.fillRect(cbx, cby - 1, ctw + 4, lh + 1); + display.setColor(DisplayDriver::DARK); + } + display.setCursor(cbx + 2, cby); + display.print(ctxt); + display.setColor(DisplayDriver::LIGHT); + if (_ctx_menu.active) _ctx_menu.render(display); + return dm_count > 0 ? 500 : 2000; + + } else if (_phase == CHANNEL_HIST) { + if (_fs.active && _hist_sel >= 0) { + int fs_hist_count = histCountForChannel(_sel_channel_idx); + int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); + if (ring_pos >= 0) { + const char* ftext = _hist[ring_pos].text; + const char* fsep = strstr(ftext, ": "); + char fsender[22], fmsg[140]; + if (fsep) { + int nl = fsep - ftext; if (nl > 21) nl = 21; + strncpy(fsender, ftext, nl); fsender[nl] = '\0'; + strncpy(fmsg, fsep + 2, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0'; + } else { + strncpy(fsender, "?", sizeof(fsender)); + strncpy(fmsg, ftext, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0'; + } + int ret = _fs.render(display, fsender, fmsg, + _hist_sel < fs_hist_count - 1, + _hist_sel > 0); + if (_ctx_menu.active) _ctx_menu.render(display); + return ret; + } + return 2000; + } + + int hist_box_h = 2 * lh + 3; + int hist_item_h = hist_box_h + 2; + int hist_start_y = display.headerH(); + int btn_h = lh + 4; + _hist_visible = (display.height() - hist_start_y - btn_h) / hist_item_h; + if (_hist_visible < 1) _hist_visible = 1; + int cby = display.height() - lh - 2; + + ChannelDetails ch; + the_mesh.getChannel(_sel_channel_idx, ch); + char title[24]; + snprintf(title, sizeof(title), "%.23s", ch.name); + display.drawTextCentered(display.width()/2, 0, title); + display.fillRect(0, lh + 1, display.width(), display.sepH()); + + int ch_hist_count = histCountForChannel(_sel_channel_idx); + + for (int i = 0; i < _hist_visible && (_hist_scroll + i) < ch_hist_count; i++) { + int item = _hist_scroll + i; + bool sel = (item == _hist_sel); + int y = hist_start_y + i * hist_item_h; + + int ring_pos = histEntryForChannel(_sel_channel_idx, item); + if (ring_pos < 0) continue; + + const char* text = _hist[ring_pos].text; + const char* sep = strstr(text, ": "); + char sender[22], msg_part[79]; + if (sep) { + int nl = sep - text; if (nl > 21) nl = 21; + strncpy(sender, text, nl); sender[nl] = '\0'; + strncpy(msg_part, sep + 2, sizeof(msg_part) - 1); + msg_part[sizeof(msg_part) - 1] = '\0'; + } else { + strncpy(sender, "?", sizeof(sender)); + strncpy(msg_part, text, sizeof(msg_part) - 1); + msg_part[sizeof(msg_part) - 1] = '\0'; + } + + char age[6]; fmtMsgAge(age, sizeof(age), _hist[ring_pos].timestamp); + int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y, display.width(), hist_box_h); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender); + if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); } + display.drawTextEllipsized(3, y + lh + 2, display.width() - cw - 2, skipReplyPrefix(msg_part)); + } else { + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, y, display.width(), hist_box_h); + display.fillRect(1, y + 1, display.width() - 2, lh); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender); + if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); } + display.setColor(DisplayDriver::LIGHT); + display.drawTextEllipsized(3, y + lh + 2, display.width() - cw - 2, skipReplyPrefix(msg_part)); + } + } + + if (ch_hist_count == 0) { + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width()/2, display.height()/2, "No messages yet"); + } + + // scroll hints + display.setColor(DisplayDriver::LIGHT); + if (_hist_scroll > 0) { + display.setCursor(display.width() - cw, hist_start_y + 1); + display.print("^"); + } + if (_hist_scroll + _hist_visible < ch_hist_count) { + display.setCursor(display.width() - cw, hist_start_y + (_hist_visible - 1) * hist_item_h + hist_box_h - lh); + display.print("v"); + } + + // small compose button (bottom-left, always bordered, inverted when selected) + bool compose_sel = (_hist_sel == -1); + const char* ctxt = "[+ send]"; + int ctw = display.getTextWidth(ctxt); + int cbx = 1; + if (compose_sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(cbx - 1, cby - 1, ctw + 4, lh + 2); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + display.drawRect(cbx - 1, cby - 1, ctw + 4, lh + 2); + } + display.setCursor(cbx + 1, cby); + display.print(ctxt); + display.setColor(DisplayDriver::LIGHT); + if (_ctx_menu.active) _ctx_menu.render(display); + + } else if (_phase == KEYBOARD) { + return _kb.render(display); + + } else { // MSG_PICK + char title[24]; + if (_reply_mode) { + int rlen = (int)strlen(_reply_prefix) - 4; // exclude "@[" and "] " + if (rlen < 0) rlen = 0; + if (rlen > 20) rlen = 20; + char nick_raw[32], nick_trans[32]; + snprintf(nick_raw, sizeof(nick_raw), "%.*s", rlen, _reply_prefix + 2); + display.translateUTF8ToBlocks(nick_trans, nick_raw, sizeof(nick_trans)); + snprintf(title, sizeof(title), "RE:%s", nick_trans); + } else if (_sending_to_channel) { + ChannelDetails ch; + the_mesh.getChannel(_sel_channel_idx, ch); + snprintf(title, sizeof(title), "%.23s", ch.name); + } else { + snprintf(title, sizeof(title), "TO:%.14s", _sel_contact.name); + } + display.drawTextCentered(display.width()/2, 0, title); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); + + int total_msg_items = 1 + _active_msg_count; + for (int i = 0; i < _visible && (_msg_scroll+i) < total_msg_items; i++) { + int idx = _msg_scroll + i; + bool sel = (idx == _msg_sel); + int y = start_y + i * item_h; + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), item_h - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(0, y); + display.print(sel ? ">" : " "); + + if (idx == 0) { + display.setCursor(cw + 2, y); + display.print("Custom message..."); + } else { + NodePrefs* p = _task->getNodePrefs(); + int slot = _active_msgs[idx - 1]; + const char* tmpl = p ? p->custom_msgs[slot] : ""; + display.drawTextEllipsized(cw + 2, y, display.width() - cw - 4, tmpl); + } + } + display.setColor(DisplayDriver::LIGHT); + if (_msg_scroll > 0) { display.setCursor(display.width() - cw, start_y); display.print("^"); } + if (_msg_scroll + _visible < total_msg_items) { display.setCursor(display.width() - cw, start_y + (_visible-1)*item_h); display.print("v"); } + } + return 2000; + } + + bool handleInput(char c) override { + if (_phase == MODE_SELECT) { + if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; } + if (c == KEY_UP && _mode_sel > 0) { _mode_sel--; return true; } + if (c == KEY_DOWN && _mode_sel < 2) { _mode_sel++; return true; } + if (c == KEY_ENTER) { + if (_mode_sel == 1) { + _phase = CHANNEL_PICK; + } else { + _room_mode = (_mode_sel == 2); + if (_room_mode) _task->clearRoomUnread(); + buildContactList(); + _contact_sel = _contact_scroll = 0; + _phase = CONTACT_PICK; + } + return true; + } + + } else if (_phase == CONTACT_PICK) { + // Context menu consumes all input while open + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED && _num_contacts > 0) { + ContactInfo ci; + if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) { + if (_ctx_menu.selectedIndex() == 0) { + _task->clearDMUnread(ci.id.pub_key); + } else if (_ctx_menu.selectedIndex() == 1) { + uint8_t nstate = dmNotifState(ci.id.pub_key); + setDmNotifState(ci.id.pub_key, (nstate + 1) % 3); + _ctx_dirty = true; + } else { + uint8_t slot = dmMelodySlot(ci.id.pub_key); + setDmMelody(ci.id.pub_key, (slot + 1) % 3); + _ctx_dirty = true; + } + } + } + if (res != PopupMenu::NONE && _ctx_dirty) { + the_mesh.savePrefs(); _ctx_dirty = false; + } + return true; + } + if (c == KEY_CANCEL) { _room_mode = false; _phase = MODE_SELECT; return true; } + if (c == KEY_UP && _contact_sel > 0) { + _contact_sel--; + if (_contact_sel < _contact_scroll) _contact_scroll = _contact_sel; + return true; + } + if (c == KEY_DOWN && _contact_sel < _num_contacts - 1) { + _contact_sel++; + if (_contact_sel >= _contact_scroll + _visible) _contact_scroll = _contact_sel - _visible + 1; + return true; + } + if (c == KEY_ENTER && _num_contacts > 0) { + if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { + _task->clearDMUnread(_sel_contact.id.pub_key); + _dm_hist_sel = -1; + _dm_hist_scroll = 0; + _dm_fs.active = false; + _phase = DM_HIST; + } + return true; + } + if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && !_room_mode) { + static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; + ContactInfo ci; + the_mesh.getContactByIdx(_sorted[_contact_sel], ci); + snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", + NOTIF_LABELS[dmNotifState(ci.id.pub_key)]); + { static const char* ML[] = { "global", "M1", "M2" }; + snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", + ML[dmMelodySlot(ci.id.pub_key)]); } + _ctx_menu.begin("Contact options", 3); + _ctx_menu.addItem("Mark as read"); + _ctx_menu.addItem(_ctx_notif_item); + _ctx_menu.addItem(_ctx_melody_item); + _ctx_dirty = false; + return true; + } + + } else if (_phase == CHANNEL_PICK) { + // Context menu consumes all input while open + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED && _num_channels > 0) { + uint8_t ch_idx = _channel_indices[_channel_sel]; + if (_ctx_menu.selectedIndex() == 0) { + _ch_unread[ch_idx] = 0; + } else if (_ctx_menu.selectedIndex() == 1) { + uint8_t nstate = chNotifState(ch_idx); + setChNotifState(ch_idx, (nstate + 1) % 3); + _ctx_dirty = true; + } else { + uint8_t slot = chNotifMelody(ch_idx); + setChNotifMelody(ch_idx, (slot + 1) % 3); + _ctx_dirty = true; + } + } + if (res != PopupMenu::NONE && _ctx_dirty) { + the_mesh.savePrefs(); _ctx_dirty = false; + } + return true; + } + if (c == KEY_CANCEL) { _phase = MODE_SELECT; return true; } + if (c == KEY_UP && _channel_sel > 0) { + _channel_sel--; + if (_channel_sel < _channel_scroll) _channel_scroll = _channel_sel; + return true; + } + if (c == KEY_DOWN && _channel_sel < _num_channels - 1) { + _channel_sel++; + if (_channel_sel >= _channel_scroll + _visible) _channel_scroll = _channel_sel - _visible + 1; + return true; + } + if (c == KEY_ENTER && _num_channels > 0) { + _sel_channel_idx = _channel_indices[_channel_sel]; + int hc = histCountForChannel(_sel_channel_idx); + _unread_at_entry = (int)_ch_unread[_sel_channel_idx]; + _hist_scroll = 0; + _hist_sel = hc > 0 ? 0 : -1; + _viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0; + _phase = CHANNEL_HIST; + updateChannelUnread(); + return true; + } + if (c == KEY_CONTEXT_MENU && _num_channels > 0) { + uint8_t ch_idx = _channel_indices[_channel_sel]; + static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; + snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", + NOTIF_LABELS[chNotifState(ch_idx)]); + { static const char* ML[] = { "global", "M1", "M2" }; + snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", + ML[chNotifMelody(ch_idx)]); } + _ctx_menu.begin("Channel options", 3); + _ctx_menu.addItem("Mark all read"); + _ctx_menu.addItem(_ctx_notif_item); + _ctx_menu.addItem(_ctx_melody_item); + _ctx_dirty = false; + return true; + } + + } else if (_phase == DM_HIST) { + int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + if (_dm_fs.active) { + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED) { + _dm_fs.active = false; + startReply(false); + } else if (res != PopupMenu::NONE) { + _ctx_menu.active = false; + } + return true; + } + auto res = _dm_fs.handleInput(c); + if (res == FullscreenMsgView::PREV) { + if (_dm_hist_sel < dm_count - 1) { _dm_hist_sel++; _dm_fs.scroll = 0; } + } else if (res == FullscreenMsgView::NEXT) { + if (_dm_hist_sel > 0) { _dm_hist_sel--; _dm_fs.scroll = 0; } + } else if (res == FullscreenMsgView::CLOSE) { + _dm_fs.active = false; + } else if (res == FullscreenMsgView::REPLY) { + int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + if (ring_pos >= 0 && !_dm_hist[ring_pos].outgoing) { + snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _sel_contact.name); + _ctx_menu.begin("Options", 1); + _ctx_menu.addItem("Reply"); + } + } + return true; + } + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED) { + startReply(false); + } else if (res != PopupMenu::NONE) { + _ctx_menu.active = false; + } + return true; + } + if (c == KEY_CANCEL) { _phase = CONTACT_PICK; return true; } + if (c == KEY_UP) { + if (_dm_hist_sel > 0) { + _dm_hist_sel--; + if (_dm_hist_sel < _dm_hist_scroll) _dm_hist_scroll = _dm_hist_sel; + } else if (_dm_hist_sel == 0) { + _dm_hist_sel = -1; + } + return true; + } + if (c == KEY_DOWN) { + if (_dm_hist_sel == -1 && dm_count > 0) { _dm_hist_sel = 0; _dm_hist_scroll = 0; } + else if (_dm_hist_sel >= 0 && _dm_hist_sel < dm_count - 1) { + _dm_hist_sel++; + if (_dm_hist_sel >= _dm_hist_scroll + _hist_visible) + _dm_hist_scroll = _dm_hist_sel - _hist_visible + 1; + } + return true; + } + if (c == KEY_ENTER) { + if (_dm_hist_sel >= 0) { + _dm_fs.begin(); + } else { + _sending_to_channel = false; + setupMsgPick(); + _phase = MSG_PICK; + } + return true; + } + if (c == KEY_CONTEXT_MENU && _dm_hist_sel >= 0) { + int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + if (ring_pos >= 0 && !_dm_hist[ring_pos].outgoing) { + snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _sel_contact.name); + _ctx_menu.begin("Options", 1); + _ctx_menu.addItem("Reply"); + } + return true; + } + + } else if (_phase == CHANNEL_HIST) { + int ch_hist_count = histCountForChannel(_sel_channel_idx); + if (_fs.active) { + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED) { + _fs.active = false; + startReply(true); + } else if (res != PopupMenu::NONE) { + _ctx_menu.active = false; + } + return true; + } + auto res = _fs.handleInput(c); + if (res == FullscreenMsgView::PREV) { + if (_hist_sel < ch_hist_count - 1) { _hist_sel++; _fs.scroll = 0; updateChannelUnread(); } + } else if (res == FullscreenMsgView::NEXT) { + if (_hist_sel > 0) { _hist_sel--; _fs.scroll = 0; updateChannelUnread(); } + } else if (res == FullscreenMsgView::CLOSE) { + _fs.active = false; + } else if (res == FullscreenMsgView::REPLY) { + int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); + if (ring_pos >= 0 && buildChannelReplyPrefix(_hist[ring_pos].text)) { + _ctx_menu.begin("Options", 1); + _ctx_menu.addItem("Reply"); + } + } + return true; + } + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED) { + startReply(true); + } else if (res != PopupMenu::NONE) { + _ctx_menu.active = false; + } + return true; + } + if (c == KEY_CANCEL) { _phase = CHANNEL_PICK; return true; } + if (c == KEY_UP) { + if (_hist_sel > 0) { _hist_sel--; if (_hist_sel < _hist_scroll) _hist_scroll = _hist_sel; } + else if (_hist_sel == 0) _hist_sel = -1; + updateChannelUnread(); + return true; + } + if (c == KEY_DOWN) { + if (_hist_sel == -1 && ch_hist_count > 0) { _hist_sel = 0; _hist_scroll = 0; } + else if (_hist_sel >= 0 && _hist_sel < ch_hist_count - 1) { + _hist_sel++; + if (_hist_sel >= _hist_scroll + _hist_visible) _hist_scroll = _hist_sel - _hist_visible + 1; + } + updateChannelUnread(); + return true; + } + if (c == KEY_ENTER) { + if (_hist_sel >= 0) { + _fs.begin(); + updateChannelUnread(); + } else { + _sending_to_channel = true; + setupMsgPick(); + _phase = MSG_PICK; + } + return true; + } + if (c == KEY_CONTEXT_MENU && _hist_sel >= 0) { + int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); + if (ring_pos >= 0 && buildChannelReplyPrefix(_hist[ring_pos].text)) { + _ctx_menu.begin("Options", 1); + _ctx_menu.addItem("Reply"); + } + return true; + } + + } else if (_phase == KEYBOARD) { + auto res = _kb.handleInput(c); + if (res == KeyboardWidget::CANCELLED) { + _phase = MSG_PICK; + } else if (res == KeyboardWidget::DONE) { + int min_len = _reply_mode ? (int)strlen(_reply_prefix) : 0; + if (_kb.len > min_len) { + char expanded[KB_MAX_LEN + 1]; + expandMsg(_kb.buf, expanded, sizeof(expanded)); + bool ok = sendText(expanded); + afterSend(ok, expanded); + } + } + return true; + + } else { // MSG_PICK + int total_msg_items = 1 + _active_msg_count; + if (c == KEY_CANCEL) { + _reply_mode = false; + _phase = _sending_to_channel ? CHANNEL_HIST : DM_HIST; + return true; + } + if (c == KEY_UP && _msg_sel > 0) { + _msg_sel--; + if (_msg_sel < _msg_scroll) _msg_scroll = _msg_sel; + return true; + } + if (c == KEY_DOWN && _msg_sel < total_msg_items - 1) { + _msg_sel++; + if (_msg_sel >= _msg_scroll + _visible) _msg_scroll = _msg_sel - _visible + 1; + return true; + } + if (c == KEY_ENTER) { + if (_msg_sel == 0) { + _kb.begin(_reply_mode ? _reply_prefix : ""); + kbAddSensorPlaceholders(_kb, &sensors); + _phase = KEYBOARD; + return true; + } + NodePrefs* p = _task->getNodePrefs(); + int slot = _active_msgs[_msg_sel - 1]; + const char* tmpl = p ? p->custom_msgs[slot] : "OK"; + char msg[140]; + if (_reply_mode) { + char body[140]; + expandMsg(tmpl, body, sizeof(body)); + snprintf(msg, sizeof(msg), "%s%s", _reply_prefix, body); + } else { + expandMsg(tmpl, msg, sizeof(msg)); + } + bool ok = sendText(msg); + afterSend(ok, msg); + return true; + } + } + return false; + } +}; diff --git a/examples/companion_radio/ui-new/RingtoneEditorScreen.h b/examples/companion_radio/ui-new/RingtoneEditorScreen.h new file mode 100644 index 00000000..ddd30d8c --- /dev/null +++ b/examples/companion_radio/ui-new/RingtoneEditorScreen.h @@ -0,0 +1,342 @@ +#pragma once +// Custom screen — not part of upstream UITask.cpp +// Included by UITask.cpp after the global KB_* constants are defined. + +class RingtoneEditorScreen : public UIScreen { + UITask* _task; + NodePrefs* _prefs; + + static const int MAX_NOTES = 32; + static const int MENU_VISIBLE = 5; + + int _visible_notes = 7; // updated in render(); used by clampScroll() + + static const uint16_t BPM_OPTS[5]; + static const uint8_t DUR_VALS[4]; + static const char* DUR_LABELS[4]; + static const char PITCH_NAMES[8]; // lowercase rtttl names + + uint8_t _notes[MAX_NOTES]; + uint8_t _len; + uint8_t _bpm_idx; + int _slot; // 0=melody1, 1=melody2 + int _cursor; + int _scroll; + bool _menu_open; + int _menu_sel; + int _menu_scroll; + char _play_buf[220]; // persistent RTTTL buffer — library holds a pointer into it + + enum MenuOpt { + M_PLAY_STOP, M_SWITCH, M_DURATION, M_BPM_UP, M_BPM_DOWN, + M_INSERT, M_DELETE, M_SAVE, M_DISCARD, M_COUNT + }; + static const char* MENU_LABELS[M_COUNT]; + + static uint8_t notePitch(uint8_t b) { return b & 0x07; } + static uint8_t noteOctave(uint8_t b) { return ((b >> 3) & 0x03) + 4; } + static uint8_t noteDurIdx(uint8_t b) { return (b >> 5) & 0x03; } + static uint8_t packNote(uint8_t pitch, uint8_t octave, uint8_t dur_idx) { + return (pitch & 0x07) | (((octave - 4) & 0x03) << 3) | ((dur_idx & 0x03) << 5); + } + + void clampScroll() { + if (_cursor < _scroll) _scroll = _cursor; + if (_cursor >= _scroll + _visible_notes) _scroll = _cursor - _visible_notes + 1; + if (_scroll < 0) _scroll = 0; + } + + void buildRTTTL() { + uint16_t bpm = BPM_OPTS[_bpm_idx < 5 ? _bpm_idx : 2]; + int pos = snprintf(_play_buf, sizeof(_play_buf), "Ring:d=8,o=5,b=%u:", bpm); + for (int i = 0; i < _len && pos < (int)sizeof(_play_buf) - 8; i++) { + if (i > 0 && pos < (int)sizeof(_play_buf) - 1) _play_buf[pos++] = ','; + uint8_t pitch = notePitch(_notes[i]); + uint8_t octave = noteOctave(_notes[i]); + uint8_t dur_val = DUR_VALS[noteDurIdx(_notes[i])]; + if (pitch == 0) + pos += snprintf(_play_buf + pos, sizeof(_play_buf) - pos, "%dp", dur_val); + else + pos += snprintf(_play_buf + pos, sizeof(_play_buf) - pos, "%d%c%d", dur_val, PITCH_NAMES[pitch], octave); + } + if (pos < (int)sizeof(_play_buf)) _play_buf[pos] = '\0'; + } + + void previewNote(uint8_t note_byte) { + uint8_t pitch = notePitch(note_byte); + if (pitch == 0) { _task->stopMelody(); return; } + snprintf(_play_buf, sizeof(_play_buf), "P:d=16,o=5,b=240:%c%d", PITCH_NAMES[pitch], noteOctave(note_byte)); + _task->playMelody(_play_buf); + } + +public: + RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs), _slot(0) {} + + void enter(int slot = 0) { + _slot = (slot == 1) ? 1 : 0; + bool s2 = (_slot == 1); + _bpm_idx = (_prefs && (s2 ? _prefs->ringtone2_bpm_idx : _prefs->ringtone_bpm_idx) < 5) + ? (s2 ? _prefs->ringtone2_bpm_idx : _prefs->ringtone_bpm_idx) : 2; + uint8_t rlen = _prefs ? (s2 ? _prefs->ringtone2_len : _prefs->ringtone_len) : 0; + _len = (rlen <= (uint8_t)MAX_NOTES) ? rlen : 0; + if (_prefs) memcpy(_notes, s2 ? _prefs->ringtone2_notes : _prefs->ringtone_notes, sizeof(_notes)); + _cursor = 0; + _scroll = 0; + _menu_open = false; + _menu_sel = 0; + _menu_scroll = 0; + } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + + const int lh = display.getLineHeight(); + const int cw = display.getCharWidth(); + const int cell_w = cw * 2 + 6; // fits 2-char label with margin + const int notes_y = display.listStart(); + const int cell_h = lh + 6; + _visible_notes = display.width() / cell_w; + + char hdr[32]; + snprintf(hdr, sizeof(hdr), "M%d BPM:%u %d/%d", _slot + 1, BPM_OPTS[_bpm_idx], _len, MAX_NOTES); + display.setCursor(0, 0); + display.print(hdr); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); + + for (int i = 0; i < _visible_notes; i++) { + int ni = _scroll + i; + int cx = i * cell_w; + bool sel = (ni == _cursor); + if (ni < _len) { + uint8_t pitch = notePitch(_notes[ni]); + uint8_t octave = noteOctave(_notes[ni]); + char label[3]; + if (pitch == 0) { + label[0] = '-'; label[1] = '-'; label[2] = '\0'; + } else { + label[0] = PITCH_NAMES[pitch] - 32; // uppercase + label[1] = '0' + octave; + label[2] = '\0'; + } + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(cx, notes_y, cell_w - 1, cell_h); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + display.drawRect(cx, notes_y, cell_w - 1, cell_h); + } + display.setCursor(cx + (cell_w - cw * 2) / 2, notes_y + 3); + display.print(label); + display.setColor(DisplayDriver::LIGHT); + } else if (ni == _len && _len < MAX_NOTES) { + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(cx, notes_y, cell_w - 1, cell_h); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(cx + (cell_w - cw) / 2, notes_y + 3); + display.print("+"); + display.setColor(DisplayDriver::LIGHT); + } + } + + const int info_y = notes_y + cell_h + 2; + const int bottom_y = display.height() - display.lineStep(); + if (_scroll > 0) { display.setCursor(0, info_y); display.print("<"); } + if (_scroll + _visible_notes <= _len) { display.setCursor(display.width() - display.getCharWidth(), info_y); display.print(">"); } + if (_cursor < _len) { + char info[24]; + snprintf(info, sizeof(info), "oct:%d dur:%s", + noteOctave(_notes[_cursor]), DUR_LABELS[noteDurIdx(_notes[_cursor])]); + display.setCursor(display.getCharWidth() + 2, info_y); + display.print(info); + } else if (_cursor == _len) { + display.setCursor(display.getCharWidth() + 2, info_y); + display.print("U/D to add note"); + } + + display.setCursor(0, bottom_y); + display.print("ENT:oct MENU:opts"); + + if (_menu_open) { + const int menu_ih = display.lineStep(); + const int mw = 100, mh = MENU_VISIBLE * menu_ih + 4; + int mx = (display.width() - mw) / 2; + int my = (display.height() - mh) / 2; + display.setColor(DisplayDriver::DARK); + display.fillRect(mx, my, mw, mh); + display.setColor(DisplayDriver::LIGHT); + display.drawRect(mx, my, mw, mh); + for (int i = 0; i < MENU_VISIBLE; i++) { + int item = _menu_scroll + i; + if (item >= M_COUNT) break; + int iy = my + 2 + i * menu_ih; + bool sel = (item == _menu_sel); + if (sel) { + display.fillRect(mx + 1, iy - 1, mw - 2, menu_ih); + display.setColor(DisplayDriver::DARK); + } + display.setCursor(mx + 4, iy); + if (item == M_PLAY_STOP) + display.print(_task->isMelodyPlaying() ? "Stop" : "Play"); + else if (item == M_SWITCH) + display.print(_slot == 0 ? "Edit Melody 2" : "Edit Melody 1"); + else + display.print(MENU_LABELS[item]); + display.setColor(DisplayDriver::LIGHT); + } + int cw = display.getCharWidth(); + if (_menu_scroll > 0) { display.setCursor(mx + mw - cw - 1, my + 2); display.print("^"); } + if (_menu_scroll + MENU_VISIBLE < M_COUNT) { display.setCursor(mx + mw - cw - 1, my + mh - menu_ih); display.print("v"); } + } + + return 200; + } + + bool handleInput(char c) override { + bool up = (c == KEY_UP); + bool down = (c == KEY_DOWN); + bool left = (c == KEY_LEFT || c == KEY_PREV); + bool right = (c == KEY_RIGHT || c == KEY_NEXT); + bool enter = (c == KEY_ENTER); + bool menu_key = (c == KEY_CONTEXT_MENU); + bool cancel = (c == KEY_CANCEL); + + if (_menu_open) { + if (cancel) { _menu_open = false; return true; } + if (up && _menu_sel > 0) { + _menu_sel--; + if (_menu_sel < _menu_scroll) _menu_scroll = _menu_sel; + return true; + } + if (down && _menu_sel < M_COUNT - 1) { + _menu_sel++; + if (_menu_sel >= _menu_scroll + MENU_VISIBLE) _menu_scroll = _menu_sel - MENU_VISIBLE + 1; + return true; + } + if (!enter) return true; + + _menu_open = false; + switch ((MenuOpt)_menu_sel) { + case M_PLAY_STOP: + if (_task->isMelodyPlaying()) { + _task->stopMelody(); + } else if (_len > 0) { + buildRTTTL(); + _task->playMelody(_play_buf); + } + break; + case M_SWITCH: + _task->stopMelody(); + this->enter(1 - _slot); + break; + case M_DURATION: + if (_cursor < _len) { + uint8_t p = notePitch(_notes[_cursor]); + uint8_t o = noteOctave(_notes[_cursor]); + uint8_t di = (noteDurIdx(_notes[_cursor]) + 1) & 0x03; + _notes[_cursor] = packNote(p, o, di); + } + break; + case M_BPM_UP: if (_bpm_idx < 4) _bpm_idx++; break; + case M_BPM_DOWN: if (_bpm_idx > 0) _bpm_idx--; break; + case M_INSERT: + if (_len < MAX_NOTES) { + int ins = (_cursor < _len) ? _cursor + 1 : _cursor; + for (int i = _len; i > ins; i--) _notes[i] = _notes[i - 1]; + _notes[ins] = packNote(1, 5, 1); + _len++; + _cursor = ins; + clampScroll(); + } + break; + case M_DELETE: + if (_len > 0 && _cursor < _len) { + for (int i = _cursor; i < _len - 1; i++) _notes[i] = _notes[i + 1]; + _len--; + if (_cursor >= _len && _len > 0) _cursor = _len - 1; + else if (_len == 0) _cursor = 0; + clampScroll(); + } + break; + case M_SAVE: + if (_prefs) { + if (_slot == 1) { + _prefs->ringtone2_bpm_idx = _bpm_idx; + _prefs->ringtone2_len = _len; + memcpy(_prefs->ringtone2_notes, _notes, sizeof(_notes)); + } else { + _prefs->ringtone_bpm_idx = _bpm_idx; + _prefs->ringtone_len = _len; + memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes)); + } + the_mesh.savePrefs(); + } + _task->stopMelody(); + _task->gotoToolsScreen(); + break; + case M_DISCARD: + _task->stopMelody(); + _task->gotoToolsScreen(); + break; + default: break; + } + return true; + } + + if (cancel) { _task->stopMelody(); _task->gotoToolsScreen(); return true; } + if (menu_key) { _menu_open = true; _menu_sel = 0; _menu_scroll = 0; return true; } + + if (left && _cursor > 0) { _cursor--; clampScroll(); return true; } + if (right) { + int max_cur = (_len < MAX_NOTES) ? _len : _len - 1; + if (_cursor < max_cur) { _cursor++; clampScroll(); return true; } + } + + if ((up || down) && _cursor == _len && _len < MAX_NOTES) { + _notes[_len] = packNote(1, 5, 1); + _len++; + clampScroll(); + } + if ((up || down) && _cursor < _len) { + uint8_t p = notePitch(_notes[_cursor]); + uint8_t o = noteOctave(_notes[_cursor]); + uint8_t di = noteDurIdx(_notes[_cursor]); + if (up) p = (p + 1) & 0x07; + if (down) p = (p + 7) & 0x07; + _notes[_cursor] = packNote(p, o, di); + previewNote(_notes[_cursor]); + return true; + } + + if (enter && _cursor < _len) { + uint8_t p = notePitch(_notes[_cursor]); + uint8_t o = noteOctave(_notes[_cursor]); + uint8_t di = noteDurIdx(_notes[_cursor]); + if (p != 0) o = (o < 6) ? o + 1 : 4; + _notes[_cursor] = packNote(p, o, di); + previewNote(_notes[_cursor]); + return true; + } + if (enter && _cursor == _len && _len < MAX_NOTES) { + _notes[_len] = packNote(1, 5, 1); + _len++; + clampScroll(); + previewNote(_notes[_cursor]); + return true; + } + return false; + } +}; + +const uint16_t RingtoneEditorScreen::BPM_OPTS[5] = { 60, 90, 120, 150, 180 }; +const uint8_t RingtoneEditorScreen::DUR_VALS[4] = { 4, 8, 16, 32 }; +const char* RingtoneEditorScreen::DUR_LABELS[4] = { "1/4", "1/8", "1/16", "1/32" }; +const char RingtoneEditorScreen::PITCH_NAMES[8] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' }; +const char* RingtoneEditorScreen::MENU_LABELS[RingtoneEditorScreen::M_COUNT] = { + "Play/Stop", "Switch Melody", "Duration", "BPM+", "BPM-", "Insert", "Delete", "Save & Exit", "Discard" +}; diff --git a/examples/companion_radio/ui-new/SensorPlaceholders.h b/examples/companion_radio/ui-new/SensorPlaceholders.h new file mode 100644 index 00000000..c1b652a7 --- /dev/null +++ b/examples/companion_radio/ui-new/SensorPlaceholders.h @@ -0,0 +1,46 @@ +#pragma once +// Populates a KeyboardWidget's placeholder list based on live sensor data. +// Only adds placeholders for types that actually returned a value right now. +// Always adds {loc} and {time} via begin(); this helper appends the rest. + +#include "KeyboardWidget.h" +#include +#include + +inline void kbAddSensorPlaceholders(KeyboardWidget& kb, SensorManager* sm) { + if (!sm) return; + + CayenneLPP lpp(100); + sm->querySensors(0xFF, lpp); + + // collect unique LPP types that have actual data + uint8_t present[16]; + int pc = 0; + { + LPPReader r(lpp.getBuffer(), lpp.getSize()); + uint8_t ch, type; + while (r.readHeader(ch, type)) { + bool already = false; + for (int i = 0; i < pc; i++) if (present[i] == type) { already = true; break; } + if (!already && pc < 16) present[pc++] = type; + r.skipData(type); + } + } + + static const struct { uint8_t t; const char* ph; } MAP[] = { + { LPP_TEMPERATURE, "{temp}" }, + { LPP_RELATIVE_HUMIDITY, "{hum}" }, + { LPP_BAROMETRIC_PRESSURE, "{pres}" }, + { LPP_VOLTAGE, "{batt}" }, + { LPP_ALTITUDE, "{alt}" }, + { LPP_LUMINOSITY, "{lux}" }, + { LPP_DISTANCE, "{dist}" }, + { LPP_CONCENTRATION, "{co2}" }, + }; + + for (const auto& m : MAP) { + for (int i = 0; i < pc; i++) { + if (present[i] == m.t) { kb.addPlaceholder(m.ph); break; } + } + } +} diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h new file mode 100644 index 00000000..0d977ef6 --- /dev/null +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -0,0 +1,701 @@ +#pragma once +// Custom screen — not part of upstream UITask.cpp +// Included by UITask.cpp after SensorPlaceholders.h is defined. + +class SettingsScreen : public UIScreen { + UITask* _task; + + enum SettingItem { + // Display section + SECTION_DISPLAY, +#if !defined(EINK_DISPLAY_MODEL) + BRIGHTNESS, +#endif +#if AUTO_OFF_MILLIS > 0 + AUTO_OFF, +#endif + AUTO_LOCK, + BATT_DISPLAY, +#if !defined(EINK_DISPLAY_MODEL) + CLOCK_SECONDS, +#endif + CLOCK_FORMAT, + FONT, +#if defined(EINK_DISPLAY_MODEL) + ROTATION, +#endif + // Sound section + SECTION_SOUND, + BUZZER, + BUZZER_VOLUME, + DM_MELODY, + CH_MELODY, + // Home pages section + SECTION_HOME_PAGES, + HOME_CLOCK, HOME_RECENT, HOME_RADIO, HOME_BT, HOME_ADVERT, +#if ENV_INCLUDE_GPS == 1 + HOME_GPS, +#endif +#if UI_SENSORS_PAGE == 1 + HOME_SENSORS, +#endif + HOME_SETTINGS, HOME_QUICK_MSG, + HOME_TOOLS, HOME_SHUTDOWN, + // Radio section + SECTION_RADIO, + TX_POWER, + // System section + SECTION_SYSTEM, + TIMEZONE, + LOW_BAT, +#if ENV_INCLUDE_GPS == 1 + // GPS section + SECTION_GPS, + GPS_INTERVAL, +#endif + // Contacts section + SECTION_CONTACTS, DM_FILTER, ROOM_FILTER, + // Messages section + SECTION_MESSAGES, + MSG_SLOT_0, MSG_SLOT_1, MSG_SLOT_2, MSG_SLOT_3, MSG_SLOT_4, + MSG_SLOT_5, MSG_SLOT_6, MSG_SLOT_7, MSG_SLOT_8, MSG_SLOT_9, + Count + }; + + int _selected; + int _scroll; + int _visible; // items fitting on screen; updated each render, used by handleInput + bool _dirty; + +#if AUTO_OFF_MILLIS > 0 + static const uint16_t AUTO_OFF_OPTS[5]; + static const char* AUTO_OFF_LABELS[5]; + static const int AUTO_OFF_COUNT = 5; +#endif +#if ENV_INCLUDE_GPS == 1 + static const uint32_t GPS_INTERVAL_OPTS[6]; + static const char* GPS_INTERVAL_LABELS[6]; + static const int GPS_INTERVAL_COUNT = 6; +#endif + static const uint16_t LOW_BAT_OPTS[7]; + static const char* LOW_BAT_LABELS[7]; + static const int LOW_BAT_COUNT = 7; + static const char* BATT_DISPLAY_LABELS[3]; + static const int BATT_DISPLAY_COUNT = 3; + + int lowBatIndex() { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + for (int i = 0; i < LOW_BAT_COUNT; i++) + if (LOW_BAT_OPTS[i] == p->low_batt_mv) return i; + return 0; + } + + void renderBar(DisplayDriver& display, int x, int y, int value, int max_val) { + const int gap = 2; + const int avail = display.width() - x; + const int raw = (avail - (max_val - 1) * gap) / max_val; + const int cap = display.getLineHeight() - 2; + const int box_h = raw < cap ? (raw < 2 ? 2 : raw) : cap; + const int box_w = box_h; + for (int i = 0; i < max_val; i++) { + int bx = x + i * (box_w + gap); + display.drawRect(bx, y, box_w, box_h); + if (i < value) + display.fillRect(bx + 1, y + 1, box_w - 2, box_h - 2); + } + } + +#if AUTO_OFF_MILLIS > 0 + int autoOffIndex() { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 1; + for (int i = 0; i < AUTO_OFF_COUNT; i++) + if (AUTO_OFF_OPTS[i] == p->auto_off_secs) return i; + return 1; + } +#endif + +#if ENV_INCLUDE_GPS == 1 + int gpsIntervalIndex() { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + for (int i = 0; i < GPS_INTERVAL_COUNT; i++) + if (GPS_INTERVAL_OPTS[i] == p->gps_interval) return i; + return 0; + } +#endif + + bool isSection(int item) const { + return item == SECTION_DISPLAY || item == SECTION_SOUND || + item == SECTION_HOME_PAGES || + item == SECTION_RADIO || item == SECTION_SYSTEM || + item == SECTION_CONTACTS || item == SECTION_MESSAGES +#if ENV_INCLUDE_GPS == 1 + || item == SECTION_GPS +#endif + ; + } + + const char* sectionName(int item) const { + if (item == SECTION_DISPLAY) return "Display"; + if (item == SECTION_SOUND) return "Sound"; + if (item == SECTION_HOME_PAGES) return "Home Pages"; + if (item == SECTION_RADIO) return "Radio"; + if (item == SECTION_SYSTEM) return "System"; + if (item == SECTION_CONTACTS) return "Contacts"; + if (item == SECTION_MESSAGES) return "Messages"; +#if ENV_INCLUDE_GPS == 1 + if (item == SECTION_GPS) return "GPS"; +#endif + return ""; + } + + bool isHomePage(int item) const { + return item == HOME_CLOCK || item == HOME_RECENT || item == HOME_RADIO || + item == HOME_BT || item == HOME_ADVERT || item == HOME_TOOLS || + item == HOME_SHUTDOWN || item == HOME_SETTINGS || item == HOME_QUICK_MSG +#if ENV_INCLUDE_GPS == 1 + || item == HOME_GPS +#endif +#if UI_SENSORS_PAGE == 1 + || item == HOME_SENSORS +#endif + ; + } + + uint16_t homePageBit(int item) const { + if (item == HOME_CLOCK) return HP_CLOCK; + if (item == HOME_RECENT) return HP_RECENT; + if (item == HOME_RADIO) return HP_RADIO; + if (item == HOME_BT) return HP_BLUETOOTH; + if (item == HOME_ADVERT) return HP_ADVERT; + if (item == HOME_TOOLS) return HP_TOOLS; + if (item == HOME_SHUTDOWN) return HP_SHUTDOWN; + // HOME_SETTINGS and HOME_QUICK_MSG have no mask bit — always visible +#if ENV_INCLUDE_GPS == 1 + if (item == HOME_GPS) return HP_GPS; +#endif +#if UI_SENSORS_PAGE == 1 + if (item == HOME_SENSORS) return HP_SENSORS; +#endif + return 0; + } + + const char* homePageLabel(int item) const { + if (item == HOME_CLOCK) return "Clock"; + if (item == HOME_RECENT) return "Recent"; + if (item == HOME_RADIO) return "Radio"; + if (item == HOME_BT) return "Bluetooth"; + if (item == HOME_ADVERT) return "Advert"; + if (item == HOME_TOOLS) return "Tools"; + if (item == HOME_SHUTDOWN) return "Shutdown"; + if (item == HOME_SETTINGS) return "Settings"; + if (item == HOME_QUICK_MSG) return "Messages"; +#if ENV_INCLUDE_GPS == 1 + if (item == HOME_GPS) return "GPS"; +#endif +#if UI_SENSORS_PAGE == 1 + if (item == HOME_SENSORS) return "Sensors"; +#endif + return ""; + } + + bool homePageVisible(int item, const NodePrefs* p) const { + if (item == HOME_SETTINGS || item == HOME_QUICK_MSG) return true; + uint16_t bit = homePageBit(item); + if (!bit) return false; + uint16_t mask = (p && p->home_pages_mask) ? p->home_pages_mask : HP_ALL; + return (mask & bit) != 0; + } + + bool homePageToggleable(int item) const { + return item != HOME_SETTINGS && item != HOME_QUICK_MSG; + } + + // Returns the bit-index (0-10) used in page_order for this SettingItem, or -1. + int homePageBitIndex(int item) const { + if (item == HOME_CLOCK) return 0; + if (item == HOME_RECENT) return 1; + if (item == HOME_RADIO) return 2; + if (item == HOME_BT) return 3; + if (item == HOME_ADVERT) return 4; +#if ENV_INCLUDE_GPS == 1 + if (item == HOME_GPS) return 5; +#endif +#if UI_SENSORS_PAGE == 1 + if (item == HOME_SENSORS) return 6; +#endif + if (item == HOME_TOOLS) return 7; + if (item == HOME_SHUTDOWN) return 8; + if (item == HOME_SETTINGS) return 9; + if (item == HOME_QUICK_MSG) return 10; + return -1; + } + + // Returns 1-based position of item in page_order, or 0 if no custom order / not found. + int homePagePosition(int item, const NodePrefs* p) const { + if (!p || p->page_order[0] < 1 || p->page_order[0] > 11) return 0; + int bit = homePageBitIndex(item); + if (bit < 0) return 0; + for (int i = 0; i < 11; i++) { + uint8_t v = p->page_order[i]; + if (v < 1 || v > 11) break; + if ((int)(v - 1) == bit) return i + 1; + } + return 0; + } + + // Initialises page_order to the default display sequence if not already set. + void ensurePageOrderInit(NodePrefs* p) const { + if (!p) return; + if (p->page_order[0] >= 1 && p->page_order[0] <= 11) return; + // Default: CLOCK RECENT RADIO BT ADVERT [GPS] [SENSORS] SETTINGS TOOLS MESSAGES SHUTDOWN + int j = 0; + p->page_order[j++] = 0 + 1; // CLOCK + p->page_order[j++] = 1 + 1; // RECENT + p->page_order[j++] = 2 + 1; // RADIO + p->page_order[j++] = 3 + 1; // BLUETOOTH + p->page_order[j++] = 4 + 1; // ADVERT +#if ENV_INCLUDE_GPS == 1 + p->page_order[j++] = 5 + 1; // GPS +#endif +#if UI_SENSORS_PAGE == 1 + p->page_order[j++] = 6 + 1; // SENSORS +#endif + p->page_order[j++] = 9 + 1; // SETTINGS + p->page_order[j++] = 7 + 1; // TOOLS + p->page_order[j++] = 10 + 1; // MESSAGES (QUICK_MSG) + p->page_order[j++] = 8 + 1; // SHUTDOWN + while (j < 11) p->page_order[j++] = 0; + } + + // Swaps item's page_order slot with its neighbour in the given direction (-1=earlier, +1=later). + void movePageInOrder(int item, int delta, NodePrefs* p) { + ensurePageOrderInit(p); + int bit = homePageBitIndex(item); + if (bit < 0) return; + int cur = -1, total = 0; + for (int i = 0; i < 11; i++) { + uint8_t v = p->page_order[i]; + if (v < 1 || v > 11) break; + if ((int)(v - 1) == bit) cur = i; + total++; + } + if (cur < 0) return; + int next = cur + delta; + if (next < 0 || next >= total) return; + uint8_t tmp = p->page_order[cur]; + p->page_order[cur] = p->page_order[next]; + p->page_order[next] = tmp; + } + + bool isMsgSlot(int item) const { + return item >= MSG_SLOT_0 && item <= MSG_SLOT_9; + } + + int msgSlotIndex(int item) const { + return item - MSG_SLOT_0; + } + + int nextSelectable(int from) const { + for (int i = from + 1; i < (int)Count; i++) + if (!isSection(i)) return i; + return from; + } + + int prevSelectable(int from) const { + for (int i = from - 1; i >= 0; i--) + if (!isSection(i)) return i; + return from; + } + + void renderItem(DisplayDriver& display, int item, int y) { + NodePrefs* p = _task->getNodePrefs(); + + if (isSection(item)) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y, display.width(), 1); + display.setCursor(2, y + 2); + display.print(sectionName(item)); + return; + } + + bool sel = (item == _selected); + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), display.lineStep()); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + + display.setCursor(0, y); + display.print(sel ? ">" : " "); + display.setCursor(display.getCharWidth() + 2, y); + +#if !defined(EINK_DISPLAY_MODEL) + if (item == BRIGHTNESS) { + display.print("Bright"); + renderBar(display, display.valCol(), y, (p ? p->display_brightness : 2) + 1, 5); + } else +#endif + if (item == BUZZER) { + display.print("Buzzer"); + display.setCursor(display.valCol(), y); +#ifdef PIN_BUZZER + { static const char* labels[] = { "ON", "OFF", "Auto" }; + int m = _task->getBuzzerMode(); + display.print(labels[m < 3 ? m : 0]); } +#else + display.print("N/A"); +#endif + } else if (item == BUZZER_VOLUME) { + display.print("BzrVol"); +#ifdef PIN_BUZZER + renderBar(display, display.valCol(), y, _task->getBuzzerVolume() + 1, 5); +#else + display.setCursor(display.valCol(), y); + display.print("N/A"); +#endif + } else if (item == DM_MELODY) { + display.print("DM sound"); + display.setCursor(display.valCol(), y); + { static const char* L[] = { "built-in", "M1", "M2" }; + uint8_t v = p ? p->notif_melody_dm : 0; + display.print(L[v < 3 ? v : 0]); } + } else if (item == CH_MELODY) { + display.print("Ch sound"); + display.setCursor(display.valCol(), y); + { static const char* L[] = { "built-in", "M1", "M2" }; + uint8_t v = p ? p->notif_melody_ch : 0; + display.print(L[v < 3 ? v : 0]); } + } else if (isHomePage(item)) { + int pos = homePagePosition(item, p); + if (pos > 0) { + char pb[5]; snprintf(pb, sizeof(pb), "%2d ", pos); + display.print(pb); + } + display.print(homePageLabel(item)); + display.setCursor(display.width() - 6 * display.getCharWidth(), y); + if (!homePageToggleable(item)) + display.print("always"); + else + display.print(homePageVisible(item, p) ? "ON" : "OFF"); + } else if (item == TX_POWER) { + display.print("TX Pwr"); + char buf[8]; + sprintf(buf, "%ddBm", p ? p->tx_power_dbm : 0); + display.setCursor(display.valCol(), y); + display.print(buf); +#if AUTO_OFF_MILLIS > 0 + } else if (item == AUTO_OFF) { + display.print("AutoOff"); + display.setCursor(display.valCol(), y); + display.print(AUTO_OFF_LABELS[autoOffIndex()]); +#endif + } else if (item == AUTO_LOCK) { + display.print("AutoLock"); + display.setCursor(display.valCol(), y); + display.print((p && p->auto_lock) ? "ON" : "OFF"); +#if ENV_INCLUDE_GPS == 1 + } else if (item == GPS_INTERVAL) { + display.print("GPS upd"); + display.setCursor(display.valCol(), y); + display.print(GPS_INTERVAL_LABELS[gpsIntervalIndex()]); +#endif + } else if (item == TIMEZONE) { + display.print("TimeZone"); + char buf[8]; + int8_t tz = p ? p->tz_offset_hours : 0; + if (tz >= 0) sprintf(buf, "UTC+%d", (int)tz); + else sprintf(buf, "UTC%d", (int)tz); + display.setCursor(display.valCol(), y); + display.print(buf); + } else if (item == LOW_BAT) { + display.print("LowBat"); + display.setCursor(display.valCol(), y); + display.print(LOW_BAT_LABELS[lowBatIndex()]); + } else if (item == BATT_DISPLAY) { + display.print("BattDisp"); + display.setCursor(display.valCol(), y); + uint8_t mode = p ? p->batt_display_mode : 0; + display.print(BATT_DISPLAY_LABELS[mode < BATT_DISPLAY_COUNT ? mode : 0]); +#if !defined(EINK_DISPLAY_MODEL) + } else if (item == CLOCK_SECONDS) { + display.print("Seconds"); + display.setCursor(display.valCol(), y); + display.print((p && p->clock_hide_seconds) ? "OFF" : "ON"); +#endif + } else if (item == CLOCK_FORMAT) { + display.print("Format"); + display.setCursor(display.valCol(), y); + display.print((p && p->clock_12h) ? "12h" : "24h"); + } else if (item == FONT) { + display.print("Font"); + display.setCursor(display.valCol(), y); + display.print((p && p->use_lemon_font) ? "Lemon" : "Default"); +#if defined(EINK_DISPLAY_MODEL) + } else if (item == ROTATION) { + display.print("Rotation"); + display.setCursor(display.valCol(), y); + { static const char* ROT_LABELS[] = { "0 deg", "90 deg", "180 deg", "270 deg" }; + uint8_t r = p ? (p->display_rotation & 3) : 0; + display.print(ROT_LABELS[r]); } +#endif + } else if (item == DM_FILTER) { + display.print("DM"); + display.setCursor(display.valCol(), y); + display.print((p && p->dm_show_all) ? "all" : "fav"); + } else if (item == ROOM_FILTER) { + display.print("Rooms"); + display.setCursor(display.valCol(), y); + display.print((p && p->room_fav_only) ? "fav" : "all"); + } else if (isMsgSlot(item)) { + int slot = msgSlotIndex(item); + char label[5]; + snprintf(label, sizeof(label), "Q%d:", slot + 1); + display.print(label); + const char* tmpl = (p && p->custom_msgs[slot][0]) ? p->custom_msgs[slot] : "(empty)"; + int xm = 8 + display.getCharWidth() * 4; + display.drawTextEllipsized(xm, y, display.width() - xm, tmpl); + } + } + + // Keyboard state for editing message slots + int _edit_slot; // -1 = not editing, 0..9 = slot being edited + KeyboardWidget _kb; + +public: + SettingsScreen(UITask* task) + : _task(task), _selected(AUTO_LOCK), _scroll(0), _visible(4), _dirty(false), _edit_slot(-1) {} + + + void markClean() { _dirty = false; } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + + if (_edit_slot >= 0) { + return _kb.render(display); + } + + int item_h = display.lineStep(); + int start_y = display.listStart(); + _visible = display.listVisible(item_h); + + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width() / 2, 0, "SETTINGS"); + display.fillRect(0, display.headerH() - display.sepH(), display.width(), display.sepH()); + + for (int i = 0; i < _visible && (_scroll + i) < SettingItem::Count; i++) { + renderItem(display, _scroll + i, start_y + i * item_h); + } + + // scroll indicators + if (_scroll > 0) { + display.setColor(DisplayDriver::LIGHT); + display.setCursor(display.width() - display.getCharWidth(), start_y); + display.print("^"); + } + if (_scroll + _visible < SettingItem::Count) { + display.setColor(DisplayDriver::LIGHT); + display.setCursor(display.width() - display.getCharWidth(), start_y + (_visible - 1) * item_h); + display.print("v"); + } + + return 2000; + } + + bool handleInput(char c) override { + NodePrefs* p = _task->getNodePrefs(); + + // Keyboard editing mode for message slots + if (_edit_slot >= 0) { + auto res = _kb.handleInput(c); + if (res == KeyboardWidget::DONE) { + if (p) { + strncpy(p->custom_msgs[_edit_slot], _kb.buf, sizeof(p->custom_msgs[0]) - 1); + p->custom_msgs[_edit_slot][sizeof(p->custom_msgs[0]) - 1] = '\0'; + _dirty = true; + } + _edit_slot = -1; + } else if (res == KeyboardWidget::CANCELLED) { + _edit_slot = -1; + } + return true; + } + + if (c == KEY_UP) { + int prev = prevSelectable(_selected); + if (prev != _selected) { + _selected = prev; + if (_selected < _scroll) _scroll = _selected; + } + return true; + } + if (c == KEY_DOWN) { + int next = nextSelectable(_selected); + if (next != _selected) { + _selected = next; + if (_selected >= _scroll + _visible) _scroll = _selected - _visible + 1; + } + return true; + } + if (c == KEY_CANCEL) { + if (_dirty) the_mesh.savePrefs(); + _task->gotoHomeScreen(); + return true; + } + + bool right = (c == KEY_RIGHT || c == KEY_NEXT); + bool left = (c == KEY_LEFT || c == KEY_PREV); + bool enter = (c == KEY_ENTER); + +#if !defined(EINK_DISPLAY_MODEL) + if (_selected == BRIGHTNESS) { + uint8_t lvl = _task->getBrightnessLevel(); + if (right && lvl < 4) { _task->setBrightnessLevel(lvl + 1); _dirty = true; return true; } + if (left && lvl > 0) { _task->setBrightnessLevel(lvl - 1); _dirty = true; return true; } + return right || left; + } +#endif + if (_selected == BUZZER && (left || right || enter)) { + _task->cycleBuzzerMode(); + _dirty = true; + return true; + } + if (_selected == BUZZER_VOLUME) { +#ifdef PIN_BUZZER + uint8_t lvl = _task->getBuzzerVolume(); + if (right && lvl < 4) { _task->setBuzzerVolumeLevel(lvl + 1); _dirty = true; return true; } + if (left && lvl > 0) { _task->setBuzzerVolumeLevel(lvl - 1); _dirty = true; return true; } +#endif + return right || left; + } + if (_selected == DM_MELODY && p && (left || right || enter)) { + p->notif_melody_dm = (p->notif_melody_dm + (left ? 2 : 1)) % 3; + _dirty = true; return true; + } + if (_selected == CH_MELODY && p && (left || right || enter)) { + p->notif_melody_ch = (p->notif_melody_ch + (left ? 2 : 1)) % 3; + _dirty = true; return true; + } + if (isHomePage(_selected) && p) { + if (left || right) { + movePageInOrder(_selected, left ? -1 : 1, p); + _dirty = true; + return true; + } + if (enter && homePageToggleable(_selected)) { + p->home_pages_mask ^= homePageBit(_selected); + _dirty = true; + return true; + } + return enter; + } + if (_selected == TX_POWER && p) { + if (right && p->tx_power_dbm < 22) { p->tx_power_dbm++; _task->applyTxPower(); _dirty = true; return true; } + if (left && p->tx_power_dbm > 2) { p->tx_power_dbm--; _task->applyTxPower(); _dirty = true; return true; } + } +#if AUTO_OFF_MILLIS > 0 + if (_selected == AUTO_OFF && p) { + int idx = autoOffIndex(); + if (right) idx = (idx + 1) % AUTO_OFF_COUNT; + if (left) idx = (idx + AUTO_OFF_COUNT - 1) % AUTO_OFF_COUNT; + if (left || right) { p->auto_off_secs = AUTO_OFF_OPTS[idx]; _dirty = true; return true; } + } +#endif + if (_selected == AUTO_LOCK && p && (left || right || enter)) { + p->auto_lock ^= 1; + _dirty = true; + return true; + } +#if ENV_INCLUDE_GPS == 1 + if (_selected == GPS_INTERVAL && p) { + int idx = gpsIntervalIndex(); + if (right) idx = (idx + 1) % GPS_INTERVAL_COUNT; + if (left) idx = (idx + GPS_INTERVAL_COUNT - 1) % GPS_INTERVAL_COUNT; + if (left || right) { + p->gps_interval = GPS_INTERVAL_OPTS[idx]; + _task->applyGPSInterval(); + _dirty = true; + return true; + } + } +#endif + if (_selected == TIMEZONE && p) { + if (right && p->tz_offset_hours < 14) { p->tz_offset_hours++; _dirty = true; return true; } + if (left && p->tz_offset_hours > -12) { p->tz_offset_hours--; _dirty = true; return true; } + } + if (_selected == LOW_BAT && p) { + int idx = lowBatIndex(); + if (right) idx = (idx + 1) % LOW_BAT_COUNT; + if (left) idx = (idx + LOW_BAT_COUNT - 1) % LOW_BAT_COUNT; + if (left || right) { p->low_batt_mv = LOW_BAT_OPTS[idx]; _dirty = true; return true; } + } + if (_selected == BATT_DISPLAY && p) { + int idx = p->batt_display_mode < BATT_DISPLAY_COUNT ? p->batt_display_mode : 0; + if (right) idx = (idx + 1) % BATT_DISPLAY_COUNT; + if (left) idx = (idx + BATT_DISPLAY_COUNT - 1) % BATT_DISPLAY_COUNT; + if (left || right) { p->batt_display_mode = idx; _dirty = true; return true; } + } +#if !defined(EINK_DISPLAY_MODEL) + if (_selected == CLOCK_SECONDS && p && (left || right || enter)) { + p->clock_hide_seconds ^= 1; + _dirty = true; + return true; + } +#endif + if (_selected == CLOCK_FORMAT && p && (left || right || enter)) { + p->clock_12h ^= 1; + _dirty = true; + return true; + } + if (_selected == FONT && p && (left || right || enter)) { + p->use_lemon_font ^= 1; + _task->applyFont(); + _dirty = true; + return true; + } +#if defined(EINK_DISPLAY_MODEL) + if (_selected == ROTATION && p && (left || right || enter)) { + p->display_rotation = (p->display_rotation + (left ? 3 : 1)) & 3; + _task->applyRotation(); + _dirty = true; + return true; + } +#endif + if (_selected == DM_FILTER && p && (left || right || enter)) { + p->dm_show_all = p->dm_show_all ? 0 : 1; + _dirty = true; + return true; + } + if (_selected == ROOM_FILTER && p && (left || right || enter)) { + p->room_fav_only = p->room_fav_only ? 0 : 1; + _dirty = true; + return true; + } + if (isMsgSlot(_selected) && enter) { + int slot = msgSlotIndex(_selected); + _edit_slot = slot; + _kb.begin(p ? p->custom_msgs[slot] : ""); + kbAddSensorPlaceholders(_kb, &sensors); + return true; + } + return false; + } +}; + +#if AUTO_OFF_MILLIS > 0 +const uint16_t SettingsScreen::AUTO_OFF_OPTS[5] = { 5, 15, 30, 60, 0 }; +const char* SettingsScreen::AUTO_OFF_LABELS[5] = { "5s", "15s", "30s", "60s", "never" }; +#endif +#if ENV_INCLUDE_GPS == 1 +const uint32_t SettingsScreen::GPS_INTERVAL_OPTS[6] = { 0, 30, 60, 300, 900, 1800 }; +const char* SettingsScreen::GPS_INTERVAL_LABELS[6] = { "off", "30s", "1min", "5min", "15min", "30min" }; +#endif +const uint16_t SettingsScreen::LOW_BAT_OPTS[7] = { 0, 3000, 3100, 3200, 3300, 3400, 3500 }; +const char* SettingsScreen::LOW_BAT_LABELS[7] = { "off", "3.0V", "3.1V", "3.2V", "3.3V", "3.4V", "3.5V" }; +const char* SettingsScreen::BATT_DISPLAY_LABELS[3] = { "icon", "%", "V" }; diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h new file mode 100644 index 00000000..cde36181 --- /dev/null +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -0,0 +1,57 @@ +#pragma once +// Custom screen — not part of upstream UITask.cpp +// Included by UITask.cpp just before HomeScreen. + +class ToolsScreen : public UIScreen { + UITask* _task; + int _sel; + + static const int ITEM_COUNT = 4; + static const char* ITEMS[ITEM_COUNT]; + +public: + ToolsScreen(UITask* task) : _task(task), _sel(0) {} + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width() / 2, 0, "TOOLS"); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); + + int item_h = display.lineStep(); + int start_y = display.listStart(); + int cw = display.getCharWidth(); + + for (int i = 0; i < ITEM_COUNT; i++) { + int y = start_y + i * item_h; + bool sel = (i == _sel); + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), item_h); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(0, y); + display.print(sel ? ">" : " "); + display.setCursor(cw + 2, y); + display.print(ITEMS[i]); + } + display.setColor(DisplayDriver::LIGHT); + return 500; + } + + bool handleInput(char c) override { + if (c == KEY_UP && _sel > 0) { _sel--; return true; } + if (c == KEY_DOWN && _sel < ITEM_COUNT - 1) { _sel++; return true; } + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoHomeScreen(); return true; } + if (c == KEY_ENTER) { + if (_sel == 0) { _task->gotoRingtoneEditor(); return true; } + if (_sel == 1) { _task->gotoBotScreen(); return true; } + if (_sel == 2) { _task->gotoNearbyScreen(); return true; } + if (_sel == 3) { _task->gotoAutoAdvertScreen(); return true; } + } + return false; + } +}; +const char* ToolsScreen::ITEMS[4] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert" }; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 94a8ee3e..0203f572 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1,6 +1,7 @@ #include "UITask.h" #include #include "../MyMesh.h" +#include "../MsgExpand.h" #include "target.h" #ifdef WIFI_SSID #include @@ -35,11 +36,11 @@ class SplashScreen : public UIScreen { UITask* _task; unsigned long dismiss_after; char _version_info[12]; + char _plus_ver[12]; public: SplashScreen(UITask* task) : _task(task) { - // strip off dash and commit hash by changing dash to null terminator - // e.g: v1.2.3-abcdef -> v1.2.3 + // strip off dash and commit hash: v1.2.3-abcdef -> v1.2.3 const char *ver = FIRMWARE_VERSION; const char *dash = strchr(ver, '-'); @@ -48,22 +49,55 @@ public: memcpy(_version_info, ver, len); _version_info[len] = 0; + // extract plus version: v1.15-plus.1.4-SHA -> "1.4" + _plus_ver[0] = '\0'; + const char *plus = strstr(ver, "plus."); + if (plus) { + plus += 5; // skip "plus." + const char *end = strchr(plus, '-'); + int plen = end ? end - plus : strlen(plus); + if (plen >= (int)sizeof(_plus_ver)) plen = sizeof(_plus_ver) - 1; + memcpy(_plus_ver, plus, plen); + _plus_ver[plen] = '\0'; + } + dismiss_after = millis() + BOOT_SCREEN_MILLIS; } int render(DisplayDriver& display) override { - // meshcore logo - display.setColor(DisplayDriver::BLUE); - int logoWidth = 128; - display.drawXbm((display.width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); - - // version info - display.setColor(DisplayDriver::LIGHT); - display.setTextSize(2); - display.drawTextCentered(display.width()/2, 22, _version_info); - display.setTextSize(1); - display.drawTextCentered(display.width()/2, 42, FIRMWARE_BUILD_DATE); + const int lh = display.getLineHeight(); + const int step = display.lineStep(); + + // meshcore logo + display.setColor(DisplayDriver::LIGHT); + int logoWidth = 128; + int logo_y = 3; + display.drawXbm((display.width() - logoWidth) / 2, logo_y, meshcore_logo, logoWidth, 13); + + // version info at sz2 + int ver_y = logo_y + 13 + 2; + display.setTextSize(2); + int lh2 = display.getLineHeight(); + display.drawTextCentered(display.width()/2, ver_y, _version_info); + + // build date at sz1, below sz2 version + int date_y = ver_y + lh2 + 2; + display.setTextSize(1); + display.drawTextCentered(display.width()/2, date_y, FIRMWARE_BUILD_DATE); + +#ifdef FIRMWARE_PLUS_BUILD + int plus_y = date_y + step; + display.fillRect(0, plus_y - 1, display.width(), lh + 2); + display.setColor(DisplayDriver::DARK); + char plus_label[24]; + if (_plus_ver[0]) + snprintf(plus_label, sizeof(plus_label), "Plus %s for Wio", _plus_ver); + else + snprintf(plus_label, sizeof(plus_label), "Plus for Wio"); + display.drawTextCentered(display.width()/2, plus_y, plus_label); + display.setColor(DisplayDriver::LIGHT); +#endif return 1000; } @@ -75,9 +109,39 @@ public: } }; +static const int QUICK_MSGS_MAX = 10; + +// Bit positions for NodePrefs::home_pages_mask. +// Bit=1 means page is shown. 0 in the field means ALL visible (default/unset). +static const uint16_t HP_CLOCK = 1 << 0; +static const uint16_t HP_RECENT = 1 << 1; +static const uint16_t HP_RADIO = 1 << 2; +static const uint16_t HP_BLUETOOTH = 1 << 3; +static const uint16_t HP_ADVERT = 1 << 4; +static const uint16_t HP_GPS = 1 << 5; +static const uint16_t HP_SENSORS = 1 << 6; +static const uint16_t HP_TOOLS = 1 << 7; +static const uint16_t HP_SHUTDOWN = 1 << 8; +static const uint16_t HP_ALL = 0x01FF; + +#include "KeyboardWidget.h" +#include "FullscreenMsgView.h" +#include "SensorPlaceholders.h" +#include "SettingsScreen.h" +#include "QuickMsgScreen.h" + +// ── Custom screens (separate files to ease upstream merges) ─────────────────── +#include "RingtoneEditorScreen.h" +#include "BotScreen.h" +#include "NearbyScreen.h" +#include "DashboardConfigScreen.h" +#include "AutoAdvertScreen.h" +#include "ToolsScreen.h" + +// ── HomeScreen ──────────────────────────────────────────────────────────────── class HomeScreen : public UIScreen { enum HomePage { - FIRST, + CLOCK, RECENT, RADIO, BLUETOOTH, @@ -88,6 +152,9 @@ class HomeScreen : public UIScreen { #if UI_SENSORS_PAGE == 1 SENSORS, #endif + SETTINGS, + TOOLS, + QUICK_MSG, SHUTDOWN, Count // keep as last }; @@ -100,45 +167,206 @@ class HomeScreen : public UIScreen { bool _shutdown_init; AdvertPath recent[UI_RECENT_LIST_SIZE]; + int pageBit(int page) const { + if (page == CLOCK) return 0; + if (page == RECENT) return 1; + if (page == RADIO) return 2; + if (page == BLUETOOTH) return 3; + if (page == ADVERT) return 4; +#if ENV_INCLUDE_GPS == 1 + if (page == GPS) return 5; +#endif +#if UI_SENSORS_PAGE == 1 + if (page == SENSORS) return 6; +#endif + if (page == TOOLS) return 7; + if (page == SHUTDOWN) return 8; + return -1; // SETTINGS, QUICK_MSG always visible + } - void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { - // Convert millivolts to percentage + // Maps page_order bit-index (0-10) back to the HomePage enum value for this build. + // Returns -1 if the page is not compiled in. + int bitToPage(int bit) const { + switch (bit) { + case 0: return CLOCK; + case 1: return RECENT; + case 2: return RADIO; + case 3: return BLUETOOTH; + case 4: return ADVERT; + case 5: +#if ENV_INCLUDE_GPS == 1 + return GPS; +#else + return -1; +#endif + case 6: +#if UI_SENSORS_PAGE == 1 + return SENSORS; +#else + return -1; +#endif + case 7: return TOOLS; + case 8: return SHUTDOWN; + case 9: return SETTINGS; + case 10: return QUICK_MSG; + default: return -1; + } + } + + bool isPageVisible(int page) const { + int bit = pageBit(page); + if (bit < 0) return true; + uint16_t mask = (_node_prefs && _node_prefs->home_pages_mask) ? _node_prefs->home_pages_mask : HP_ALL; + return (mask >> bit) & 1; + } + + // Build ordered list of all visible pages, respecting page_order when set. + // Returns count; out[] receives HomePage enum values. + int buildVisibleOrder(int* out) const { + int n = 0; + bool custom = _node_prefs && _node_prefs->page_order[0] >= 1 && _node_prefs->page_order[0] <= 11; + if (custom) { + for (int i = 0; i < 11; i++) { + uint8_t v = _node_prefs->page_order[i]; + if (v < 1 || v > 11) break; + int pg = bitToPage(v - 1); + if (pg >= 0 && pg < (int)Count && isPageVisible(pg)) out[n++] = pg; + } + } else { + for (int pg = 0; pg < (int)Count; pg++) + if (isPageVisible(pg)) out[n++] = pg; + } + return n; + } + + int navPage(int from, int dir) const { + int order[11]; int n = buildVisibleOrder(order); + if (n == 0) return from; + int cur = 0; + for (int i = 0; i < n; i++) if (order[i] == from) { cur = i; break; } + return order[((cur + dir) % n + n) % n]; + } + + int renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { #ifndef BATT_MIN_MILLIVOLTS - #define BATT_MIN_MILLIVOLTS 3000 + #define BATT_MIN_MILLIVOLTS 3200 #endif -#ifndef BATT_MAX_MILLIVOLTS - #define BATT_MAX_MILLIVOLTS 4200 -#endif - const int minMilliVolts = BATT_MIN_MILLIVOLTS; - const int maxMilliVolts = BATT_MAX_MILLIVOLTS; - int batteryPercentage = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts); - if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0% - if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100% + // LiPo discharge curve: voltage (mV) → raw capacity (%) + static const struct { uint16_t mv; uint8_t pct; } CURVE[] = { + {3200, 0}, {3300, 3}, {3400, 8}, {3500, 15}, + {3600, 25}, {3650, 33}, {3700, 45}, {3750, 58}, + {3800, 68}, {3900, 77}, {4000, 86}, {4100, 93}, {4200, 100} + }; + static const int CURVE_LEN = sizeof(CURVE) / sizeof(CURVE[0]); - // battery icon - int iconWidth = 24; - int iconHeight = 10; - int iconX = display.width() - iconWidth - 5; // Position the icon near the top-right corner - int iconY = 0; - display.setColor(DisplayDriver::GREEN); + auto curveAt = [&](int mv) -> int { + if (mv <= (int)CURVE[0].mv) return CURVE[0].pct; + if (mv >= (int)CURVE[CURVE_LEN-1].mv) return CURVE[CURVE_LEN-1].pct; + for (int i = 1; i < CURVE_LEN; i++) { + if (mv <= (int)CURVE[i].mv) { + int span_mv = CURVE[i].mv - CURVE[i-1].mv; + int span_pct = CURVE[i].pct - CURVE[i-1].pct; + return CURVE[i-1].pct + (mv - (int)CURVE[i-1].mv) * span_pct / span_mv; + } + } + return 100; + }; - // battery outline - display.drawRect(iconX, iconY, iconWidth, iconHeight); + int low_mv = (_node_prefs && _node_prefs->low_batt_mv > 0) + ? (int)_node_prefs->low_batt_mv : BATT_MIN_MILLIVOLTS; + int raw_pct = curveAt((int)batteryMilliVolts); + int low_pct = curveAt(low_mv); + // rescale so low_mv = 0% and 4200mV = 100% + int pct = (low_pct >= 100) ? 0 + : (raw_pct - low_pct) * 100 / (100 - low_pct); + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; - // battery "cap" - display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2); + uint8_t mode = (_node_prefs && _node_prefs->batt_display_mode < 3) + ? _node_prefs->batt_display_mode : 0; - // fill the battery based on the percentage - int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100; - display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4); + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + + const int lh = display.getLineHeight(); + const int cw = display.getCharWidth(); + const int ind = cw + 2; // single-char indicator width + const int ind_gap = (lh >= 16) ? 3 : 1; // gap between indicator boxes + + int battLeftX; + if (mode == 1) { // percent + char buf[6]; + sprintf(buf, "%d%%", pct); + battLeftX = display.width() - display.getTextWidth(buf) - 1; + display.setCursor(battLeftX, 0); + display.print(buf); + } else if (mode == 2) { // voltage + char buf[8]; + sprintf(buf, "%u.%02uV", batteryMilliVolts / 1000, (batteryMilliVolts % 1000) / 10); + battLeftX = display.width() - display.getTextWidth(buf) - 1; + display.setCursor(battLeftX, 0); + display.print(buf); + } else { // icon — scales with lh + const int iconH = lh; + const int iconW = lh * 2; + const int bm = (lh >= 16) ? 3 : 2; // inner margin: 3px on landscape (2px border), 2px on OLED + battLeftX = display.width() - iconW - 3; + display.drawRect(battLeftX, 0, iconW, iconH); + display.fillRect(battLeftX + iconW, iconH / 4, 2, iconH / 2); + int fillW = (pct * (iconW - 2 * bm)) / 100; + display.fillRect(battLeftX + bm, bm, fillW, iconH - 2 * bm); + } - // show muted icon if buzzer is muted #ifdef PIN_BUZZER if (_task->isBuzzerQuiet()) { - display.setColor(DisplayDriver::RED); - display.drawXbm(iconX - 9, iconY + 1, muted_icon, 8, 8); + display.setColor(DisplayDriver::LIGHT); + int mx = battLeftX - ind - ind_gap; + display.fillRect(mx, 0, ind, lh); + display.setColor(DisplayDriver::DARK); + display.setCursor(mx + 1, 0); + display.print("M"); + display.setColor(DisplayDriver::LIGHT); + battLeftX = mx; } #endif + + // BT connection indicator (left of muted/battery icons) + int leftmostX = battLeftX; + if (_task->isSerialEnabled()) { + int btX = battLeftX - ind - ind_gap; + if (_task->hasConnection()) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(btX, 0, ind, lh); + display.setColor(DisplayDriver::DARK); + display.setCursor(btX + 1, 0); + display.print("B"); + display.setColor(DisplayDriver::LIGHT); + } else { + display.setCursor(btX + 1, 0); + display.print("b"); + } + leftmostX = btX - ind_gap; + + // "A" indicator — left of BT; blinks on OLED, always shown on e-ink + if (_node_prefs && _node_prefs->advert_auto_interval_sec > 0) { + int aX = leftmostX - ind; +#ifdef EINK_DISPLAY_MODEL + bool show_a = true; +#else + bool show_a = (millis() % 4000) < 2000; +#endif + if (show_a) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(aX, 0, ind, lh); + display.setColor(DisplayDriver::DARK); + display.setCursor(aX + 1, 0); + display.print("A"); + display.setColor(DisplayDriver::LIGHT); + } + leftmostX = aX - 1; + } + } + return leftmostX; } CayenneLPP sensors_lpp; @@ -181,56 +409,166 @@ public: int render(DisplayDriver& display) override { char tmp[80]; - // node name display.setTextSize(1); - display.setColor(DisplayDriver::GREEN); - char filtered_name[sizeof(_node_prefs->node_name)]; - display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); - display.setCursor(0, 0); - display.print(filtered_name); + const int lh = display.getLineHeight(); // line height at sz1 + const int step = display.lineStep(); // lh + 2 + const int dots_y = lh + 4; // page-dot row: just below header + const int content_y = dots_y + 6; // first content row (6px gap keeps dots visible) - // battery voltage - renderBatteryIndicator(display, _task->getBattMilliVolts()); + // node name + battery — hidden on CLOCK page (full screen used for dashboard) + if (_page != CLOCK) { + display.setColor(DisplayDriver::LIGHT); + char filtered_name[sizeof(_node_prefs->node_name)]; + display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); + int rightEdge = renderBatteryIndicator(display, _task->getBattMilliVolts()); + display.setColor(DisplayDriver::LIGHT); + display.drawTextEllipsized(0, 0, rightEdge - 2, filtered_name); + } - // curr page indicator - int y = 14; - int x = display.width() / 2 - 5 * (HomePage::Count-1); - for (uint8_t i = 0; i < HomePage::Count; i++, x += 10) { - if (i == _page) { - display.fillRect(x-1, y-1, 3, 3); - } else { - display.fillRect(x, y, 1, 1); + // ensure current page is visible (e.g. after settings change) + if (!isPageVisible(_page)) _page = navPage(_page, +1); + + // curr page indicator — hidden on CLOCK page (full screen used for dashboard) + if (_page != CLOCK) { + int order[11]; int n = buildVisibleOrder(order); + int curr_vis = 0; + for (int i = 0; i < n; i++) if (order[i] == _page) { curr_vis = i; break; } + int x = display.width() / 2 - 5 * (n - 1); + for (int i = 0; i < n; i++) { + int ds = (lh >= 16) ? 2 : 1; + if (i == curr_vis) display.fillRect(x-ds, dots_y-ds, 2*ds+1, 2*ds+1); + else display.fillRect(x-ds+1, dots_y-ds+1, 2*ds-1, 2*ds-1); + x += 10; } } - if (_page == HomePage::FIRST) { - display.setColor(DisplayDriver::YELLOW); - display.setTextSize(2); - sprintf(tmp, "MSG: %d", _task->getMsgCount()); - display.drawTextCentered(display.width() / 2, 20, tmp); - - #ifdef WIFI_SSID - IPAddress ip = WiFi.localIP(); - snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); + if (_page == HomePage::CLOCK) { + uint32_t unix_ts = _rtc->getCurrentTime(); + if (unix_ts < 1000000000UL) { + display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 54, tmp); - #endif - if (_task->hasConnection()) { - display.setColor(DisplayDriver::GREEN); - display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 43, "< Connected >"); + int mid_y = display.height() / 2 - step; + display.drawTextCentered(display.width() / 2, mid_y, "! No time sync"); + display.drawTextCentered(display.width() / 2, mid_y + step, "Enable GPS or"); + display.drawTextCentered(display.width() / 2, mid_y + step * 2, "connect app"); + } else { + int8_t tz = _node_prefs ? _node_prefs->tz_offset_hours : 0; + unix_ts += (int32_t)tz * 3600; + time_t t = (time_t)unix_ts; + struct tm* ti = gmtime(&t); - } else if (the_mesh.getBLEPin() != 0) { // BT pin - display.setColor(DisplayDriver::RED); + char buf[24]; + display.setColor(DisplayDriver::LIGHT); display.setTextSize(2); - sprintf(tmp, "Pin:%d", the_mesh.getBLEPin()); - display.drawTextCentered(display.width() / 2, 43, tmp); + bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds; + bool h12 = _node_prefs && _node_prefs->clock_12h; + if (h12) { + int h = ti->tm_hour % 12; if (h == 0) h = 12; + const char* ap = ti->tm_hour < 12 ? "AM" : "PM"; + if (show_sec) sprintf(buf, "%d:%02d:%02d%s", h, ti->tm_min, ti->tm_sec, ap); + else sprintf(buf, "%d:%02d %s", h, ti->tm_min, ap); + } else { + if (show_sec) sprintf(buf, "%02d:%02d:%02d", ti->tm_hour, ti->tm_min, ti->tm_sec); + else sprintf(buf, "%02d:%02d", ti->tm_hour, ti->tm_min); + } + int lh2 = display.getLineHeight(); // sz2 height: 16 (OLED) or 32 (landscape) + display.drawTextCentered(display.width() / 2, 0, buf); + + display.setTextSize(1); + static const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; + static const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; + sprintf(buf, "%s %d %s %d", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon], 1900 + ti->tm_year); + int date_y = lh2 + 2; + display.drawTextCentered(display.width() / 2, date_y, buf); + + int sep_y = date_y + lh + 1; + int dash0 = sep_y + display.sepH() + 2; + display.fillRect(0, sep_y, display.width(), display.sepH()); + + // dashboard data fields + if (_node_prefs) { + refresh_sensors(); + const int FIELD_Y[3] = { dash0, dash0 + step, dash0 + step * 2 }; + for (int fi = 0; fi < 3; fi++) { + uint8_t field = _node_prefs->dashboard_fields[fi]; + if (field == DASH_NONE) continue; + + char label[10], val[20]; + label[0] = '\0'; + val[0] = '\0'; + + if (field == DASH_BATT) { + strcpy(label, "Batt"); + uint16_t mv = _task->getBattMilliVolts(); + if (mv > 0) snprintf(val, sizeof(val), "%u.%02uV", mv/1000, (mv%1000)/10); + else strcpy(val, "--"); + } else if (field == DASH_GPS) { + strcpy(label, "GPS"); +#if ENV_INCLUDE_GPS == 1 + LocationProvider* loc = sensors.getLocationProvider(); + if (loc && loc->isValid()) + snprintf(val, sizeof(val), "%.3f %.3f", + loc->getLatitude()/1000000.0f, loc->getLongitude()/1000000.0f); + else + strcpy(val, "no fix"); +#else + strcpy(val, "--"); +#endif + } else if (field == DASH_NODES) { + strcpy(label, "Nodes"); + snprintf(val, sizeof(val), "%d", the_mesh.getNumContacts()); + } else if (field == DASH_MSGS) { + strcpy(label, "Msgs"); + int unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount(); + snprintf(val, sizeof(val), "%d", unread); + } else { + uint8_t lpp_type = 0; + switch (field) { + case DASH_TEMP: strcpy(label, "Temp"); lpp_type = LPP_TEMPERATURE; break; + case DASH_HUM: strcpy(label, "Hum"); lpp_type = LPP_RELATIVE_HUMIDITY; break; + case DASH_PRES: strcpy(label, "Pres"); lpp_type = LPP_BAROMETRIC_PRESSURE; break; + case DASH_ALT: strcpy(label, "Alt"); lpp_type = LPP_ALTITUDE; break; + case DASH_LUX: strcpy(label, "Lux"); lpp_type = LPP_LUMINOSITY; break; + case DASH_CO2: strcpy(label, "CO2"); lpp_type = LPP_CONCENTRATION; break; + } + if (lpp_type) { + LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize()); + uint8_t ch, type; + while (r.readHeader(ch, type)) { + if (type == lpp_type) { + float v; + switch (lpp_type) { + case LPP_TEMPERATURE: r.readTemperature(v); snprintf(val, sizeof(val), "%.1f\xf8""C", v); break; + case LPP_RELATIVE_HUMIDITY: r.readRelativeHumidity(v); snprintf(val, sizeof(val), "%.0f%%", v); break; + case LPP_BAROMETRIC_PRESSURE: r.readPressure(v); snprintf(val, sizeof(val), "%.0fhPa", v); break; + case LPP_ALTITUDE: r.readAltitude(v); snprintf(val, sizeof(val), "%.0fm", v); break; + case LPP_LUMINOSITY: r.readLuminosity(v); snprintf(val, sizeof(val), "%.0flux", v); break; + case LPP_CONCENTRATION: r.readConcentration(v); snprintf(val, sizeof(val), "%.0fppm", v); break; + } + break; + } + r.skipData(type); + } + } + if (!val[0]) strcpy(val, "--"); + } + + if (val[0] && label[0]) { + display.setColor(DisplayDriver::LIGHT); + display.setCursor(0, FIELD_Y[fi]); + display.print(label); + int vw = display.getTextWidth(val); + display.setCursor(display.width() - vw - 1, FIELD_Y[fi]); + display.print(val); + } + } + } } } else if (_page == HomePage::RECENT) { the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE); - display.setColor(DisplayDriver::GREEN); - int y = 20; - for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) { + display.setColor(DisplayDriver::LIGHT); + int y = content_y; + for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += step) { auto a = &recent[i]; if (a->name[0] == 0) continue; // empty slot int secs = _rtc->getCurrentTime() - a->recv_timestamp; @@ -252,40 +590,47 @@ public: display.print(tmp); } } else if (_page == HomePage::RADIO) { - display.setColor(DisplayDriver::YELLOW); - display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); // freq / sf - display.setCursor(0, 20); + display.setCursor(0, content_y); sprintf(tmp, "FQ: %06.3f SF: %d", _node_prefs->freq, _node_prefs->sf); display.print(tmp); - display.setCursor(0, 31); + display.setCursor(0, content_y + step); sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); display.print(tmp); - // tx power, noise floor - display.setCursor(0, 42); + // tx power, noise floor + display.setCursor(0, content_y + step * 2); sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm); display.print(tmp); - display.setCursor(0, 53); + display.setCursor(0, content_y + step * 3); sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor()); display.print(tmp); } else if (_page == HomePage::BLUETOOTH) { - display.setColor(DisplayDriver::GREEN); - display.drawXbm((display.width() - 32) / 2, 18, + display.setColor(DisplayDriver::LIGHT); + int icon_y = content_y; + display.drawXbm((display.width() - 32) / 2, icon_y, _task->isSerialEnabled() ? bluetooth_on : bluetooth_off, 32, 32); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL); + int pin_y = icon_y + 32 + 3; + int hint_y = pin_y + step; + if (_task->isSerialEnabled() && !_task->hasConnection() && the_mesh.getBLEPin() != 0) { + char pin_buf[16]; + snprintf(pin_buf, sizeof(pin_buf), "PIN: %d", the_mesh.getBLEPin()); + display.drawTextCentered(display.width() / 2, pin_y, pin_buf); + } + display.drawTextCentered(display.width() / 2, hint_y, "toggle: " PRESS_LABEL); } else if (_page == HomePage::ADVERT) { - display.setColor(DisplayDriver::GREEN); - display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32); - display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL); + display.setColor(DisplayDriver::LIGHT); + display.drawXbm((display.width() - 32) / 2, content_y, advert_icon, 32, 32); + display.drawTextCentered(display.width() / 2, content_y + 32 + 3, "advert: " PRESS_LABEL); #if ENV_INCLUDE_GPS == 1 } else if (_page == HomePage::GPS) { LocationProvider* nmea = sensors.getLocationProvider(); char buf[50]; - int y = 18; + int y = content_y; bool gps_state = _task->getGPSState(); #ifdef PIN_GPS_SWITCH bool hw_gps_state = digitalRead(PIN_GPS_SWITCH); @@ -299,119 +644,153 @@ public: #endif display.drawTextLeftAlign(0, y, buf); if (nmea == NULL) { - y = y + 12; + y += step; display.drawTextLeftAlign(0, y, "Can't access GPS"); } else { strcpy(buf, nmea->isValid()?"fix":"no fix"); display.drawTextRightAlign(display.width()-1, y, buf); - y = y + 12; + y += step; display.drawTextLeftAlign(0, y, "sat"); sprintf(buf, "%d", nmea->satellitesCount()); display.drawTextRightAlign(display.width()-1, y, buf); - y = y + 12; + y += step; display.drawTextLeftAlign(0, y, "pos"); - sprintf(buf, "%.4f %.4f", + sprintf(buf, "%.4f %.4f", nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.); display.drawTextRightAlign(display.width()-1, y, buf); - y = y + 12; + y += step; display.drawTextLeftAlign(0, y, "alt"); sprintf(buf, "%.2f", nmea->getAltitude()/1000.); display.drawTextRightAlign(display.width()-1, y, buf); - y = y + 12; + y += step; } #endif #if UI_SENSORS_PAGE == 1 } else if (_page == HomePage::SENSORS) { - int y = 18; + int y = content_y; refresh_sensors(); - char buf[30]; - char name[30]; - LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize()); - for (int i = 0; i < sensors_scroll_offset; i++) { - uint8_t channel, type; - r.readHeader(channel, type); - r.skipData(type); - } + uint8_t avail_types[16]; + int avail_count = _sensors ? _sensors->getAvailableLPPTypes(avail_types, 16) : 0; + bool need_scroll = avail_count > UI_RECENT_LIST_SIZE; + int offset = need_scroll ? (sensors_scroll_offset % avail_count) : 0; + int show_n = need_scroll ? UI_RECENT_LIST_SIZE : avail_count; - for (int i = 0; i < (sensors_scroll?UI_RECENT_LIST_SIZE:sensors_nb); i++) { - uint8_t channel, type; - if (!r.readHeader(channel, type)) { // reached end, reset - r.reset(); - r.readHeader(channel, type); + for (int i = 0; i < show_n; i++) { + uint8_t target = avail_types[(offset + i) % avail_count]; + + // scan LPP buffer for this type + LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize()); + uint8_t ch, type; + bool found = false; + char buf[22] = "--"; + while (r.readHeader(ch, type)) { + if (type == target) { + float v, v2, v3; + switch (type) { + case LPP_GPS: + r.readGPS(v, v2, v3); + if (v != 0 || v2 != 0) snprintf(buf, sizeof(buf), "%.4f %.4f", v, v2); + break; + case LPP_VOLTAGE: r.readVoltage(v); snprintf(buf, sizeof(buf), "%.2fV", v); break; + case LPP_CURRENT: r.readCurrent(v); snprintf(buf, sizeof(buf), "%.3fA", v); break; + case LPP_POWER: r.readPower(v); snprintf(buf, sizeof(buf), "%.1fW", v); break; + case LPP_TEMPERATURE:r.readTemperature(v); snprintf(buf, sizeof(buf), "%.1f\xf8""C", v); break; + case LPP_RELATIVE_HUMIDITY: r.readRelativeHumidity(v); snprintf(buf, sizeof(buf), "%.0f%%", v); break; + case LPP_BAROMETRIC_PRESSURE: r.readPressure(v); snprintf(buf, sizeof(buf), "%.1fhPa", v); break; + case LPP_ALTITUDE: r.readAltitude(v); snprintf(buf, sizeof(buf), "%.0fm", v); break; + case LPP_LUMINOSITY: r.readLuminosity(v); snprintf(buf, sizeof(buf), "%.0flux", v); break; + case LPP_PERCENTAGE: r.readPercentage(v); snprintf(buf, sizeof(buf), "%.0f%%", v); break; + case LPP_DISTANCE: r.readDistance(v); snprintf(buf, sizeof(buf), "%.2fm", v); break; + case LPP_CONCENTRATION: r.readConcentration(v); snprintf(buf, sizeof(buf), "%.0fppm", v); break; + default: r.skipData(type); continue; + } + found = true; + break; + } + r.skipData(type); } + (void)found; + + static const struct { uint8_t type; const char* name; } TYPE_NAMES[] = { + { LPP_VOLTAGE, "voltage" }, + { LPP_GPS, "gps" }, + { LPP_TEMPERATURE, "temp" }, + { LPP_RELATIVE_HUMIDITY, "humidity" }, + { LPP_BAROMETRIC_PRESSURE,"pressure" }, + { LPP_ALTITUDE, "altitude" }, + { LPP_CURRENT, "current" }, + { LPP_POWER, "power" }, + { LPP_LUMINOSITY, "light" }, + { LPP_PERCENTAGE, "moisture" }, + { LPP_DISTANCE, "distance" }, + { LPP_CONCENTRATION, "CO2" }, + }; + const char* name = "sensor"; + for (auto& tn : TYPE_NAMES) { if (tn.type == target) { name = tn.name; break; } } - display.setCursor(0, y); - float v; - switch (type) { - case LPP_GPS: // GPS - float lat, lon, alt; - r.readGPS(lat, lon, alt); - strcpy(name, "gps"); sprintf(buf, "%.4f %.4f", lat, lon); - break; - case LPP_VOLTAGE: - r.readVoltage(v); - strcpy(name, "voltage"); sprintf(buf, "%6.2f", v); - break; - case LPP_CURRENT: - r.readCurrent(v); - strcpy(name, "current"); sprintf(buf, "%.3f", v); - break; - case LPP_TEMPERATURE: - r.readTemperature(v); - strcpy(name, "temperature"); sprintf(buf, "%.2f", v); - break; - case LPP_RELATIVE_HUMIDITY: - r.readRelativeHumidity(v); - strcpy(name, "humidity"); sprintf(buf, "%.2f", v); - break; - case LPP_BAROMETRIC_PRESSURE: - r.readPressure(v); - strcpy(name, "pressure"); sprintf(buf, "%.2f", v); - break; - case LPP_ALTITUDE: - r.readAltitude(v); - strcpy(name, "altitude"); sprintf(buf, "%.0f", v); - break; - case LPP_POWER: - r.readPower(v); - strcpy(name, "power"); sprintf(buf, "%6.2f", v); - break; - default: - r.skipData(type); - strcpy(name, "unk"); sprintf(buf, ""); - } display.setCursor(0, y); display.print(name); - display.setCursor( - display.width()-display.getTextWidth(buf)-1, y - ); + display.setCursor(display.width() - display.getTextWidth(buf) - 1, y); display.print(buf); - y = y + 12; + y += step; } - if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb; + if (need_scroll) sensors_scroll_offset = (sensors_scroll_offset + 1) % avail_count; else sensors_scroll_offset = 0; #endif + } else if (_page == HomePage::SETTINGS) { + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, content_y, "Settings"); + display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open"); + } else if (_page == HomePage::TOOLS) { + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, content_y, "Tools"); + display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open"); + } else if (_page == HomePage::QUICK_MSG) { + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, content_y, "Messages"); + int total_unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount(); + if (total_unread > 0) { + char badge[20]; + snprintf(badge, sizeof(badge), "%d unread", total_unread); + display.drawTextCentered(display.width() / 2, content_y + step, badge); + } + display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open"); } else if (_page == HomePage::SHUTDOWN) { - display.setColor(DisplayDriver::GREEN); + display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); if (_shutdown_init) { - display.drawTextCentered(display.width() / 2, 34, "hibernating..."); + display.drawTextCentered(display.width() / 2, content_y + step, "hibernating..."); } else { - display.drawXbm((display.width() - 32) / 2, 18, power_icon, 32, 32); - display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL); + display.drawXbm((display.width() - 32) / 2, content_y, power_icon, 32, 32); + int hint_base = content_y + 32 + 3; + display.drawTextCentered(display.width() / 2, hint_base, "hibernate:"); + display.drawTextCentered(display.width() / 2, hint_base + step, PRESS_LABEL); } } - return 5000; // next render after 5000 ms + bool auto_adv = _node_prefs && _node_prefs->advert_auto_interval_sec > 0; +#ifdef EINK_DISPLAY_MODEL + if (_page == HomePage::CLOCK) return 30000; // no seconds on e-ink; refresh every 30s + return 30000; // e-ink: limit base polling; new messages still force immediate refresh via notify() +#else + if (_page == HomePage::CLOCK) { + bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds; + return auto_adv ? 1000 : (show_sec ? 1000 : 60000); + } + return auto_adv ? 1000 : 5000; +#endif } bool handleInput(char c) override { if (c == KEY_LEFT || c == KEY_PREV) { - _page = (_page + HomePage::Count - 1) % HomePage::Count; + _page = navPage(_page, -1); return true; } if (c == KEY_NEXT || c == KEY_RIGHT) { - _page = (_page + 1) % HomePage::Count; + _page = navPage(_page, +1); if (_page == HomePage::RECENT) { _task->showAlert("Recent adverts", 800); } @@ -447,109 +826,37 @@ public: return true; } #endif + if (c == KEY_ENTER && _page == HomePage::SETTINGS) { + _task->gotoSettingsScreen(); + return true; + } + if (c == KEY_ENTER && _page == HomePage::TOOLS) { + _task->gotoToolsScreen(); + return true; + } + if (c == KEY_ENTER && _page == HomePage::QUICK_MSG) { + _task->gotoQuickMsgScreen(); + return true; + } if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) { _shutdown_init = true; // need to wait for button to be released return true; } - return false; - } -}; - -class MsgPreviewScreen : public UIScreen { - UITask* _task; - mesh::RTCClock* _rtc; - - struct MsgEntry { - uint32_t timestamp; - char origin[62]; - char msg[78]; - }; - #define MAX_UNREAD_MSGS 32 - int num_unread; - int head = MAX_UNREAD_MSGS - 1; // index of latest unread message - MsgEntry unread[MAX_UNREAD_MSGS]; - -public: - MsgPreviewScreen(UITask* task, mesh::RTCClock* rtc) : _task(task), _rtc(rtc) { num_unread = 0; } - - void addPreview(uint8_t path_len, const char* from_name, const char* msg) { - head = (head + 1) % MAX_UNREAD_MSGS; - if (num_unread < MAX_UNREAD_MSGS) num_unread++; - - auto p = &unread[head]; - p->timestamp = _rtc->getCurrentTime(); - if (path_len == 0xFF) { - sprintf(p->origin, "(D) %s:", from_name); - } else { - sprintf(p->origin, "(%d) %s:", (uint32_t) path_len, from_name); - } - StrHelper::strncpy(p->msg, msg, sizeof(p->msg)); - } - - int render(DisplayDriver& display) override { - char tmp[16]; - display.setCursor(0, 0); - display.setTextSize(1); - display.setColor(DisplayDriver::GREEN); - sprintf(tmp, "Unread: %d", num_unread); - display.print(tmp); - - auto p = &unread[head]; - - int secs = _rtc->getCurrentTime() - p->timestamp; - if (secs < 60) { - sprintf(tmp, "%ds", secs); - } else if (secs < 60*60) { - sprintf(tmp, "%dm", secs / 60); - } else { - sprintf(tmp, "%dh", secs / (60*60)); - } - display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0); - display.print(tmp); - - display.drawRect(0, 11, display.width(), 1); // horiz line - - display.setCursor(0, 14); - display.setColor(DisplayDriver::YELLOW); - char filtered_origin[sizeof(p->origin)]; - display.translateUTF8ToBlocks(filtered_origin, p->origin, sizeof(filtered_origin)); - display.print(filtered_origin); - - display.setCursor(0, 25); - display.setColor(DisplayDriver::LIGHT); - char filtered_msg[sizeof(p->msg)]; - display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg)); - display.printWordWrap(filtered_msg, display.width()); - -#if AUTO_OFF_MILLIS==0 // probably e-ink - return 10000; // 10 s -#else - return 1000; // next render after 1000 ms -#endif - } - - bool handleInput(char c) override { - if (c == KEY_NEXT || c == KEY_RIGHT) { - head = (head + MAX_UNREAD_MSGS - 1) % MAX_UNREAD_MSGS; - num_unread--; - if (num_unread == 0) { - _task->gotoHomeScreen(); - } - return true; - } - if (c == KEY_ENTER) { - num_unread = 0; // clear unread queue - _task->gotoHomeScreen(); + if (c == KEY_CONTEXT_MENU && _page == HomePage::CLOCK) { + _task->gotoDashboardConfig(); return true; } return false; } }; + void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) { _display = display; _sensors = sensors; - _auto_off = millis() + AUTO_OFF_MILLIS; + _node_prefs = node_prefs; + uint32_t aoff = autoOffMillis(); + _auto_off = millis() + (aoff > 0 ? aoff : AUTO_OFF_MILLIS); #if defined(PIN_USER_BTN) user_btn.begin(); @@ -558,45 +865,230 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no analog_btn.begin(); #endif - _node_prefs = node_prefs; - if (_display != NULL) { _display->turnOn(); } #ifdef PIN_BUZZER - buzzer.begin(); buzzer.quiet(_node_prefs->buzzer_quiet); + buzzer.setVolume(_node_prefs->buzzer_volume); + buzzer.begin(); #endif #ifdef PIN_VIBRATION vibration.begin(); #endif + // Set default quick message if slot 0 is empty (first boot) + if (_node_prefs && _node_prefs->custom_msgs[0][0] == '\0') { + strncpy(_node_prefs->custom_msgs[0], "OK", sizeof(_node_prefs->custom_msgs[0]) - 1); + } + ui_started_at = millis(); _alert_expiry = 0; + _batt_mv = AbstractUITask::getBattMilliVolts(); // seed EMA with first reading splash = new SplashScreen(this); home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); - msg_preview = new MsgPreviewScreen(this, &rtc_clock); + settings = new SettingsScreen(this); + quick_msg = new QuickMsgScreen(this); + tools_screen = new ToolsScreen(this); + ringtone_edit = new RingtoneEditorScreen(this, node_prefs); + bot_screen = new BotScreen(this, node_prefs); + nearby_screen = new NearbyScreen(this); + dashboard_config = new DashboardConfigScreen(this, node_prefs); + auto_advert_screen = new AutoAdvertScreen(this, node_prefs); + applyBrightness(); + applyFont(); + applyRotation(); setCurrScreen(splash); } +void UITask::gotoSettingsScreen() { + ((SettingsScreen*)settings)->markClean(); + setCurrScreen(settings); +} + +void UITask::gotoToolsScreen() { + setCurrScreen(tools_screen); +} + +void UITask::gotoRingtoneEditor(int slot) { + ((RingtoneEditorScreen*)ringtone_edit)->enter(slot); + setCurrScreen(ringtone_edit); +} + +void UITask::gotoBotScreen() { + ((BotScreen*)bot_screen)->enter(); + setCurrScreen(bot_screen); +} + +void UITask::gotoNearbyScreen() { + ((NearbyScreen*)nearby_screen)->enter(); + setCurrScreen(nearby_screen); +} + +void UITask::gotoDashboardConfig() { + ((DashboardConfigScreen*)dashboard_config)->enter(); + setCurrScreen(dashboard_config); +} + +void UITask::gotoAutoAdvertScreen() { + ((AutoAdvertScreen*)auto_advert_screen)->enter(); + setCurrScreen(auto_advert_screen); +} + +void UITask::playMelody(const char* melody) { +#ifdef PIN_BUZZER + buzzer.playForced(melody); +#endif +} + +void UITask::stopMelody() { +#ifdef PIN_BUZZER + buzzer.stop(); +#endif +} + +bool UITask::isMelodyPlaying() { +#ifdef PIN_BUZZER + return buzzer.isPlaying(); +#else + return false; +#endif +} + +void UITask::gotoQuickMsgScreen() { + ((QuickMsgScreen*)quick_msg)->reset(); + setCurrScreen(quick_msg); +} + +void UITask::addChannelMsg(uint8_t channel_idx, const char* text) { + _last_notif_ch_idx = (int)channel_idx; + ((QuickMsgScreen*)quick_msg)->addChannelMsg(channel_idx, text); +} + +int UITask::getChannelUnreadCount() const { + return ((QuickMsgScreen*)quick_msg)->getTotalChannelUnread(); +} + +void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { + ((QuickMsgScreen*)quick_msg)->addDMMsg(pub_key, outgoing, text); +} + +int UITask::getDMUnreadTotal() const { + int total = 0; + for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) + total += _dm_unread_table[i].count; + return total; +} + void UITask::showAlert(const char* text, int duration_millis) { - strcpy(_alert, text); + snprintf(_alert, sizeof(_alert), "%s", text); _alert_expiry = millis() + duration_millis; } +static void buildMelodyFromPrefs(const NodePrefs* p, int slot, char* buf, int size) { + static const uint16_t BPM_OPTS[] = { 60, 90, 120, 150, 180 }; + static const uint8_t DUR_VALS[] = { 4, 8, 16, 32 }; + static const char PITCHES[] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' }; + const uint8_t* notes = (slot == 2) ? p->ringtone2_notes : p->ringtone_notes; + uint8_t len = (slot == 2) ? p->ringtone2_len : p->ringtone_len; + uint8_t bpm_i = (slot == 2) ? p->ringtone2_bpm_idx : p->ringtone_bpm_idx; + if (len == 0) { buf[0] = '\0'; return; } + uint16_t bpm = BPM_OPTS[bpm_i < 5 ? bpm_i : 2]; + int pos = snprintf(buf, size, "Ring:d=8,o=5,b=%u:", bpm); + for (int i = 0; i < len && pos < size - 8; i++) { + if (i > 0 && pos < size - 1) buf[pos++] = ','; + uint8_t pitch = notes[i] & 0x07; + uint8_t octave = ((notes[i] >> 3) & 0x03) + 4; + uint8_t dur_val = DUR_VALS[(notes[i] >> 5) & 0x03]; + if (pitch == 0) pos += snprintf(buf + pos, size - pos, "%dp", dur_val); + else pos += snprintf(buf + pos, size - pos, "%d%c%d", dur_val, PITCHES[pitch], octave); + } + if (pos < size) buf[pos] = '\0'; +} + void UITask::notify(UIEventType t) { #if defined(PIN_BUZZER) switch(t){ - case UIEventType::contactMessage: - // gemini's pick - buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); + case UIEventType::contactMessage: { + bool play = false; + bool force = false; + if (_last_notif_dm_valid && _node_prefs) { + uint8_t state = 0; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { + if (_node_prefs->dm_notif[i].state && + memcmp(_node_prefs->dm_notif[i].prefix, _last_notif_dm_prefix, 4) == 0) { + state = _node_prefs->dm_notif[i].state; break; + } + } + if (state == 2) { play = true; force = true; } // force-on + else if (state == 1) { /* muted */ } + else { play = !buzzer.isQuiet(); } // default: follow global + } else { + play = !buzzer.isQuiet(); + } + _last_notif_dm_valid = false; + if (play) { + int slot = _node_prefs ? (int)_node_prefs->notif_melody_dm : 0; + if (_node_prefs) { + for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) + if (_node_prefs->dm_melody[i].slot && + memcmp(_node_prefs->dm_melody[i].prefix, _last_notif_dm_prefix, 4) == 0) + { slot = _node_prefs->dm_melody[i].slot; break; } + } + bool custom_played = false; + if (slot > 0 && _node_prefs) { + buildMelodyFromPrefs(_node_prefs, slot, _notif_mel_buf, sizeof(_notif_mel_buf)); + if (_notif_mel_buf[0]) { + if (force) buzzer.playForced(_notif_mel_buf); else buzzer.play(_notif_mel_buf); + custom_played = true; + } + } + if (!custom_played) { + if (force) buzzer.playForced("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); + else buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); + } + } break; - case UIEventType::channelMessage: - buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#"); + } + case UIEventType::channelMessage: { + bool play = false; + bool force = false; + if (_last_notif_ch_idx >= 0 && _last_notif_ch_idx < 64 && _node_prefs) { + uint64_t mask = 1ULL << _last_notif_ch_idx; + if (_node_prefs->ch_notif_override & mask) { + if (!(_node_prefs->ch_notif_muted & mask)) { play = true; force = true; } + } else { + play = !buzzer.isQuiet(); + } + } else { + play = !buzzer.isQuiet(); + } + if (play) { + int slot = _node_prefs ? (int)_node_prefs->notif_melody_ch : 0; + if (_last_notif_ch_idx >= 0 && _last_notif_ch_idx < 64 && _node_prefs) { + uint64_t mask = 1ULL << _last_notif_ch_idx; + if (_node_prefs->ch_notif_melody_set & mask) + slot = (_node_prefs->ch_notif_melody_2 & mask) ? 2 : 1; + } + bool custom_played = false; + if (slot > 0 && _node_prefs) { + buildMelodyFromPrefs(_node_prefs, slot, _notif_mel_buf, sizeof(_notif_mel_buf)); + if (_notif_mel_buf[0]) { + if (force) buzzer.playForced(_notif_mel_buf); else buzzer.play(_notif_mel_buf); + custom_played = true; + } + } + if (!custom_played) { + if (force) buzzer.playForced("kerplop:d=16,o=6,b=120:32g#,32c#"); + else buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#"); + } + } + _last_notif_ch_idx = -1; break; + } case UIEventType::ack: buzzer.play("ack:d=32,o=8,b=120:c"); break; @@ -620,23 +1112,43 @@ switch(t){ void UITask::msgRead(int msgcount) { _msgcount = msgcount; if (msgcount == 0) { - gotoHomeScreen(); + _room_unread = 0; + memset(_dm_unread_table, 0, sizeof(_dm_unread_table)); + ((QuickMsgScreen*)quick_msg)->clearAllChannelUnread(); } } -void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) { +void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type, const uint8_t* pub_key) { _msgcount = msgcount; + if (contact_type == ADV_TYPE_ROOM && _room_unread < _msgcount) _room_unread++; + if (contact_type == ADV_TYPE_CHAT && pub_key != nullptr) { + memcpy(_last_notif_dm_prefix, pub_key, 4); + _last_notif_dm_valid = true; + int slot = -1, empty_slot = -1; + for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) { + if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0) { slot = i; break; } + if (empty_slot < 0 && _dm_unread_table[i].count == 0) empty_slot = i; + } + if (slot >= 0) { + if (_dm_unread_table[slot].count < 99) _dm_unread_table[slot].count++; + } else if (empty_slot >= 0) { + memcpy(_dm_unread_table[empty_slot].prefix, pub_key, 4); + _dm_unread_table[empty_slot].count = 1; + } + } - ((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text); - setCurrScreen(msg_preview); + char alert_buf[80]; + snprintf(alert_buf, sizeof(alert_buf), "Msg: %.20s", from_name); + showAlert(alert_buf, 3000); - if (_display != NULL) { + if (_display != NULL && !_locked) { if (!_display->isOn() && !hasConnection()) { _display->turnOn(); } if (_display->isOn()) { - _auto_off = millis() + AUTO_OFF_MILLIS; // extend the auto-off timer - _next_refresh = 100; // trigger refresh + uint32_t aoff = autoOffMillis(); + if (aoff > 0) _auto_off = millis() + aoff; + _next_refresh = 100; } } } @@ -671,6 +1183,7 @@ void UITask::setCurrScreen(UIScreen* c) { hardware-agnostic pre-shutdown activity should be done here */ void UITask::shutdown(bool restart){ + the_mesh.saveRTCTime(); #ifdef PIN_BUZZER /* note: we have a choice here - @@ -680,7 +1193,7 @@ void UITask::shutdown(bool restart){ */ buzzer.shutdown(); uint32_t buzzer_timer = millis(); // fail-safe shutdown - while (buzzer.isPlaying() && (millis() - 2500) < buzzer_timer) + while (buzzer.isPlaying() && (millis() - buzzer_timer) < 2500) buzzer.loop(); #endif // PIN_BUZZER @@ -702,15 +1215,107 @@ bool UITask::isButtonPressed() const { #endif } +static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_mv, + CayenneLPP* lpp = nullptr) { + val[0] = '\0'; + switch (field) { + case DASH_NONE: return; + case DASH_BATT: + if (batt_mv > 0) snprintf(val, val_len, "%u.%02uV", batt_mv/1000, (batt_mv%1000)/10); + else strcpy(val, "--"); + return; + case DASH_NODES: + snprintf(val, val_len, "%d nodes", the_mesh.getNumContacts()); + return; +#if ENV_INCLUDE_GPS == 1 + case DASH_GPS: { + LocationProvider* loc = sensors.getLocationProvider(); + if (loc && loc->isValid()) + snprintf(val, val_len, "%.2f %.2f", + loc->getLatitude()/1000000.0f, loc->getLongitude()/1000000.0f); + else strcpy(val, "no fix"); + return; + } +#endif + default: break; + } + // LPP sensor fields + uint8_t lpp_type = 0; + switch (field) { + case DASH_TEMP: lpp_type = LPP_TEMPERATURE; break; + case DASH_HUM: lpp_type = LPP_RELATIVE_HUMIDITY; break; + case DASH_PRES: lpp_type = LPP_BAROMETRIC_PRESSURE; break; + case DASH_ALT: lpp_type = LPP_ALTITUDE; break; + case DASH_LUX: lpp_type = LPP_LUMINOSITY; break; + case DASH_CO2: lpp_type = LPP_CONCENTRATION; break; + } + if (lpp_type) { + CayenneLPP local_lpp(200); + if (!lpp) { local_lpp.reset(); sensors.querySensors(0xFF, local_lpp); lpp = &local_lpp; } + LPPReader r(lpp->getBuffer(), lpp->getSize()); + uint8_t ch, type; + while (r.readHeader(ch, type)) { + if (type == lpp_type) { + float v; + switch (lpp_type) { + case LPP_TEMPERATURE: r.readTemperature(v); snprintf(val, val_len, "%.1f\xf8""C", v); return; + case LPP_RELATIVE_HUMIDITY: r.readRelativeHumidity(v); snprintf(val, val_len, "%.0f%%", v); return; + case LPP_BAROMETRIC_PRESSURE: r.readPressure(v); snprintf(val, val_len, "%.0fhPa", v); return; + case LPP_ALTITUDE: r.readAltitude(v); snprintf(val, val_len, "%.0fm", v); return; + case LPP_LUMINOSITY: r.readLuminosity(v); snprintf(val, val_len, "%.0flux", v); return; + case LPP_CONCENTRATION: r.readConcentration(v); snprintf(val, val_len, "%.0fppm", v); return; + } + } + r.skipData(type); + } + strcpy(val, "--"); + } +} + void UITask::loop() { char c = 0; #if UI_HAS_JOYSTICK int ev = user_btn.check(); if (ev == BUTTON_EVENT_CLICK) { - c = checkDisplayOn(KEY_ENTER); + if (back_btn.isPressed()) { + // Enter clicked while Back is held — lock/unlock sequence + if (_display && !_display->isOn()) { + _display->turnOn(); // turn on display so hints are visible + } + _lock_wake_until = millis() + 5000; // keep display on during sequence + if (millis() - _lock_seq_ms > 3000) _lock_seq_count = 0; // timeout reset + _lock_seq_count++; + _lock_seq_ms = millis(); + _next_refresh = 0; // update hint immediately on each press + if (_lock_seq_count >= 3) { + _lock_seq_count = 0; + _lock_seq_used = true; // suppress Back release click + _locked = !_locked; + if (_locked) { + _lock_wake_until = millis() + 2000; + } else { + if (_display && !_display->isOn()) _display->turnOn(); + uint32_t aoff = autoOffMillis(); + if (aoff > 0) _auto_off = millis() + aoff; + } + } + // eat the Enter — don't pass to curr + } else { + c = checkDisplayOn(KEY_ENTER); + } } else if (ev == BUTTON_EVENT_LONG_PRESS) { c = handleLongPress(KEY_ENTER); // REVISIT: could be mapped to different key code } +#if UI_HAS_JOYSTICK_UPDOWN + ev = joystick_up.check(); + if (ev == BUTTON_EVENT_CLICK) { + c = checkDisplayOn(KEY_UP); + } + ev = joystick_down.check(); + if (ev == BUTTON_EVENT_CLICK) { + c = checkDisplayOn(KEY_DOWN); + } +#endif ev = joystick_left.check(); if (ev == BUTTON_EVENT_CLICK) { c = checkDisplayOn(KEY_LEFT); @@ -723,9 +1328,20 @@ void UITask::loop() { } else if (ev == BUTTON_EVENT_LONG_PRESS) { c = handleLongPress(KEY_RIGHT); } + if (_lock_seq_used && millis() - _lock_seq_ms > 5000) { + _lock_seq_used = false; // safety reset if Back release event was missed + } ev = back_btn.check(); - if (ev == BUTTON_EVENT_TRIPLE_CLICK) { - c = handleTripleClick(KEY_SELECT); + if (ev == BUTTON_EVENT_CLICK) { + if (_lock_seq_count > 0 || _lock_seq_used) { + // Back released mid-sequence or after completing it — cancel/suppress + _lock_seq_count = 0; + _lock_seq_used = false; + } else { + c = checkDisplayOn(KEY_CANCEL); + } + } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { + if (!_locked) c = handleTripleClick(KEY_SELECT); } #elif defined(PIN_USER_BTN) int ev = user_btn.check(); @@ -766,33 +1382,133 @@ void UITask::loop() { } #endif - if (c != 0 && curr) { - curr->handleInput(c); - _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer - _next_refresh = 100; // trigger refresh + if (c != 0) { + if (!_locked && curr) { + curr->handleInput(c); + { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer + _next_refresh = 100; // trigger refresh + } else if (_locked) { + // Locked: eat all keys — wake window is set only when display first turns on + _next_refresh = 0; + } } userLedHandler(); #ifdef PIN_BUZZER + if (_node_prefs && _node_prefs->buzzer_auto) { + bool should_quiet = hasConnection(); + if (buzzer.isQuiet() != should_quiet) { + buzzer.quiet(should_quiet); + _next_refresh = 0; + } + } if (buzzer.isPlaying()) buzzer.loop(); #endif if (curr) curr->poll(); if (_display != NULL && _display->isOn()) { - if (millis() >= _next_refresh && curr) { + if (_locked && millis() > _lock_wake_until) { + _display->turnOff(); + } else if (_locked && millis() >= _next_refresh) { + _display->startFrame(); + // Lock screen: clock + unlock hint popup + uint32_t unix_ts = rtc_clock.getCurrentTime(); + _display->setColor(DisplayDriver::LIGHT); + _display->setTextSize(1); + const int lk_lh = _display->getLineHeight(); + const int lk_step = _display->lineStep(); + if (unix_ts < 1000000000UL) { + _display->drawTextCentered(_display->width() / 2, _display->height() / 2 - lk_step, "No time sync"); + } else { + int8_t tz = _node_prefs ? _node_prefs->tz_offset_hours : 0; + unix_ts += (int32_t)tz * 3600; + time_t t = (time_t)unix_ts; + struct tm* ti = gmtime(&t); + char buf[12]; + _display->setTextSize(2); + const int lh2 = _display->getLineHeight(); // sz2 line height + const int clk_y = 2; + if (_node_prefs && _node_prefs->clock_12h) { + int h = ti->tm_hour % 12; if (h == 0) h = 12; + sprintf(buf, "%d:%02d %s", h, ti->tm_min, ti->tm_hour < 12 ? "AM" : "PM"); + } else { + sprintf(buf, "%02d:%02d", ti->tm_hour, ti->tm_min); + } + _display->drawTextCentered(_display->width() / 2, clk_y, buf); + _display->setTextSize(1); + static const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; + static const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; + sprintf(buf, "%s %d %s", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon]); + int date_y = clk_y + lh2 + 2; + _display->drawTextCentered(_display->width() / 2, date_y, buf); + + // Two sensor values side by side (dashboard_fields[0] and [1]) + if (_node_prefs) { + char v0[20] = "", v1[20] = ""; + CayenneLPP shared_lpp(200); + CayenneLPP* lpp_ptr = nullptr; + uint8_t f0 = _node_prefs->dashboard_fields[0], f1 = _node_prefs->dashboard_fields[1]; + auto isLPP = [](uint8_t f) { + return f==DASH_TEMP||f==DASH_HUM||f==DASH_PRES||f==DASH_ALT||f==DASH_LUX||f==DASH_CO2; + }; + if (isLPP(f0) || isLPP(f1)) { + shared_lpp.reset(); sensors.querySensors(0xFF, shared_lpp); lpp_ptr = &shared_lpp; + } + formatDashVal(f0, v0, sizeof(v0), _batt_mv, lpp_ptr); + formatDashVal(f1, v1, sizeof(v1), _batt_mv, lpp_ptr); + if (v0[0] || v1[0]) { + int sv_y = date_y + lk_step; + _display->setColor(DisplayDriver::LIGHT); + if (v0[0] && v1[0]) { + _display->setCursor(0, sv_y); + _display->print(v0); + int vw = _display->getTextWidth(v1); + _display->setCursor(_display->width() - vw, sv_y); + _display->print(v1); + } else { + const char* sv = v0[0] ? v0 : v1; + _display->drawTextCentered(_display->width() / 2, sv_y, sv); + } + } + } + } + // Hint popup at bottom (like alert style) + _display->setTextSize(1); + const char* hint = _lock_seq_count == 0 ? "Hold Back + 3xEnter" : + _lock_seq_count == 1 ? "Enter x2 more..." : "Enter x1 more..."; + int p = 3; + int hy = _display->height() - lk_lh - p * 2; + int hw = _display->getTextWidth(hint); + int hx = (_display->width() - hw) / 2; + _display->setColor(DisplayDriver::LIGHT); + _display->fillRect(hx - p, hy - p, hw + p*2, lk_lh + p*2); + _display->setColor(DisplayDriver::DARK); + _display->setCursor(hx, hy); + _display->print(hint); + _display->endFrame(); +#ifdef EINK_DISPLAY_MODEL + _next_refresh = millis() + 30000; +#else + _next_refresh = millis() + 1000; +#endif + } else if (!_locked && millis() >= _next_refresh && curr) { _display->startFrame(); int delay_millis = curr->render(*_display); - if (millis() < _alert_expiry) { // render alert popup + if (millis() < _alert_expiry && curr == home) { // render alert only on home screen _display->setTextSize(1); - int y = _display->height() / 3; - int p = _display->height() / 32; + int lh = _display->getLineHeight(); + int pad = 3; + int box_h = lh + pad * 2; + int box_w = _display->width() - 8; + int box_x = 4; + int box_y = (_display->height() - box_h) / 2; _display->setColor(DisplayDriver::DARK); - _display->fillRect(p, y, _display->width() - p*2, y); - _display->setColor(DisplayDriver::LIGHT); // draw box border - _display->drawRect(p, y, _display->width() - p*2, y); - _display->drawTextCentered(_display->width() / 2, y + p*3, _alert); + _display->fillRect(box_x, box_y, box_w, box_h); + _display->setColor(DisplayDriver::LIGHT); + _display->drawRect(box_x, box_y, box_w, box_h); + _display->drawTextCentered(_display->width() / 2, box_y + pad, _alert); _next_refresh = _alert_expiry; // will need refresh when alert is dismissed } else { _next_refresh = millis() + delay_millis; @@ -800,8 +1516,15 @@ void UITask::loop() { _display->endFrame(); } #if AUTO_OFF_MILLIS > 0 - if (millis() > _auto_off) { + if (!_locked && autoOffMillis() > 0 && millis() > _auto_off) { _display->turnOff(); +#ifdef PIN_LED + digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power +#endif + if (_node_prefs && _node_prefs->auto_lock) { + _locked = true; + _lock_wake_until = 0; + } } #endif } @@ -810,39 +1533,49 @@ void UITask::loop() { vibration.loop(); #endif -#ifdef AUTO_SHUTDOWN_MILLIVOLTS if (millis() > next_batt_chck) { - uint16_t milliVolts = getBattMilliVolts(); - if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) { - - // show low battery shutdown alert - // we should only do this for eink displays, which will persist after power loss - #if defined(THINKNODE_M1) || defined(LILYGO_TECHO) + uint16_t raw = AbstractUITask::getBattMilliVolts(); + if (raw > 0) { + // EMA filter: alpha=0.2 (80% old, 20% new) — smooths ADC noise from uneven load + _batt_mv = (_batt_mv == 0) ? raw : (uint16_t)((_batt_mv * 4u + raw) / 5u); + } + uint16_t low_mv = _node_prefs ? _node_prefs->low_batt_mv : 0; + if (low_mv > 0 && _batt_mv > 0 && _batt_mv < low_mv) { if (_display != NULL) { _display->startFrame(); - _display->setTextSize(2); - _display->setColor(DisplayDriver::RED); - _display->drawTextCentered(_display->width() / 2, 20, "Low Battery."); - _display->drawTextCentered(_display->width() / 2, 40, "Shutting Down!"); + _display->setTextSize(1); + _display->setColor(DisplayDriver::LIGHT); + int mid = _display->height() / 2; + int step = _display->lineStep(); + _display->drawTextCentered(_display->width() / 2, mid - step, "Low Battery"); + _display->drawTextCentered(_display->width() / 2, mid, "Shutting down"); _display->endFrame(); + delay(2000); } - #endif - shutdown(); - } next_batt_chck = millis() + 8000; } -#endif } char UITask::checkDisplayOn(char c) { if (_display != NULL) { if (!_display->isOn()) { - _display->turnOn(); // turn display on and consume event + _display->turnOn(); +#ifdef PIN_LED + digitalWrite(PIN_LED, LOW); // ensure LED is off when waking display (userLedHandler takes over) +#endif + if (_locked) { + _lock_wake_until = millis() + 5000; + _next_refresh = 0; + return 0; // eat the waking key press + } c = 0; } - _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer + if (!_locked) { + uint32_t aoff = autoOffMillis(); + if (aoff > 0) _auto_off = millis() + aoff; // extend auto-off timer + } _next_refresh = 0; // trigger refresh } return c; @@ -851,8 +1584,9 @@ char UITask::checkDisplayOn(char c) { char UITask::handleLongPress(char c) { if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue the_mesh.enterCLIRescue(); - c = 0; // consume event + return 0; } + if (c == KEY_ENTER) return KEY_CONTEXT_MENU; return c; } @@ -906,18 +1640,93 @@ void UITask::toggleGPS() { } } +void UITask::applyTxPower() { + if (_node_prefs == NULL) return; + radio_set_tx_power(_node_prefs->tx_power_dbm); +} + +void UITask::applyGPSInterval() { + if (_node_prefs == NULL) return; + char buf[12]; + sprintf(buf, "%u", _node_prefs->gps_interval); + sensors.setSettingValue("gps_interval", buf); +} + +void UITask::applyBrightness() { + if (_display != NULL && _node_prefs != NULL) { + _display->setBrightness(_node_prefs->display_brightness); + } +} + +void UITask::applyFont() { + if (_display != NULL && _node_prefs != NULL) { + _display->setLemonFont(_node_prefs->use_lemon_font != 0); + _next_refresh = 0; + } +} + +void UITask::applyRotation() { + if (_display != NULL && _node_prefs != NULL) { + _display->setDisplayRotation(_node_prefs->display_rotation); + _next_refresh = 0; + } +} + +void UITask::setBrightnessLevel(uint8_t level) { + if (_node_prefs == NULL) return; + if (level > 4) level = 4; + _node_prefs->display_brightness = level; + applyBrightness(); + _next_refresh = 0; +} + +void UITask::setBuzzerVolumeLevel(uint8_t level) { +#ifdef PIN_BUZZER + if (_node_prefs == NULL) return; + if (level > 4) level = 4; + _node_prefs->buzzer_volume = level; + buzzer.setVolume(level); + if (level > 0) buzzer.playForced("Vol:d=16,o=6,b=120:c"); + _next_refresh = 0; +#endif +} + void UITask::toggleBuzzer() { - // Toggle buzzer quiet mode #ifdef PIN_BUZZER + if (_node_prefs) _node_prefs->buzzer_auto = 0; // exit auto mode if (buzzer.isQuiet()) { buzzer.quiet(false); notify(UIEventType::ack); } else { buzzer.quiet(true); } - _node_prefs->buzzer_quiet = buzzer.isQuiet(); + if (_node_prefs) _node_prefs->buzzer_quiet = buzzer.isQuiet(); the_mesh.savePrefs(); showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800); - _next_refresh = 0; // trigger refresh + _next_refresh = 0; #endif } + +int UITask::getBuzzerMode() { +#ifdef PIN_BUZZER + if (_node_prefs && _node_prefs->buzzer_auto) return 2; + return buzzer.isQuiet() ? 1 : 0; +#else + return 1; +#endif +} + +void UITask::cycleBuzzerMode() { +#ifdef PIN_BUZZER + if (!_node_prefs) return; + int mode = getBuzzerMode(); + mode = (mode + 1) % 3; // ON(0) → OFF(1) → Auto(2) → ON + _node_prefs->buzzer_auto = (mode == 2) ? 1 : 0; + if (mode == 0) { buzzer.quiet(false); _node_prefs->buzzer_quiet = 0; notify(UIEventType::ack); } + if (mode == 1) { buzzer.quiet(true); _node_prefs->buzzer_quiet = 1; } + if (mode == 2) { buzzer.quiet(hasConnection()); } + static const char* labels[] = { "Buzzer: ON", "Buzzer: OFF", "Buzzer: Auto" }; + showAlert(labels[mode], 800); + _next_refresh = 0; +#endif +} diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index a77ad6e7..806f8401 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -33,10 +33,24 @@ class UITask : public AbstractUITask { #endif unsigned long _next_refresh, _auto_off; NodePrefs* _node_prefs; + bool _locked; + unsigned long _lock_wake_until; // when to blank screen again after locked wake (5s) + int _lock_seq_count; // Enter presses while Back held (lock/unlock sequence) + unsigned long _lock_seq_ms; // millis() of last lock-sequence press (for timeout) + bool _lock_seq_used; // true = suppress next back_btn CLICK (post-sequence release) char _alert[80]; + char _notif_mel_buf[220]; // persistent RTTTL buffer for custom notification melodies unsigned long _alert_expiry; int _msgcount; + int _room_unread; + int _last_notif_ch_idx; + uint8_t _last_notif_dm_prefix[4]; + bool _last_notif_dm_valid; + struct DMUnreadEntry { uint8_t prefix[4]; uint8_t count; }; + static const int DM_UNREAD_TABLE_SIZE = 16; + DMUnreadEntry _dm_unread_table[DM_UNREAD_TABLE_SIZE]; unsigned long ui_started_at, next_batt_chck; + uint16_t _batt_mv; // EMA-filtered battery voltage int next_backlight_btn_check = 0; #ifdef PIN_STATUS_LED int led_state = 0; @@ -50,7 +64,14 @@ class UITask : public AbstractUITask { UIScreen* splash; UIScreen* home; - UIScreen* msg_preview; + UIScreen* settings; + UIScreen* quick_msg; + UIScreen* tools_screen; + UIScreen* ringtone_edit; + UIScreen* bot_screen; + UIScreen* nearby_screen; + UIScreen* dashboard_config; + UIScreen* auto_advert_screen; UIScreen* curr; void userLedHandler(); @@ -65,16 +86,55 @@ class UITask : public AbstractUITask { public: - UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) { + UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL), _node_prefs(NULL) { next_batt_chck = _next_refresh = 0; ui_started_at = 0; + _batt_mv = 0; + _msgcount = _room_unread = 0; + _locked = false; + _lock_wake_until = 0; + _lock_seq_count = 0; _lock_seq_ms = 0; _lock_seq_used = false; + _last_notif_ch_idx = -1; + _last_notif_dm_valid = false; + memset(_last_notif_dm_prefix, 0, sizeof(_last_notif_dm_prefix)); + memset(_dm_unread_table, 0, sizeof(_dm_unread_table)); curr = NULL; } void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs); + NodePrefs* getNodePrefs() const { return _node_prefs; } + uint16_t getBattMilliVolts() const { return _batt_mv > 0 ? _batt_mv : AbstractUITask::getBattMilliVolts(); } void gotoHomeScreen() { setCurrScreen(home); } + void gotoSettingsScreen(); + void gotoQuickMsgScreen(); + void gotoToolsScreen(); + void gotoRingtoneEditor(int slot = 0); + void gotoBotScreen(); + void gotoNearbyScreen(); + void gotoDashboardConfig(); + void gotoAutoAdvertScreen(); + void playMelody(const char* melody); + void stopMelody(); + bool isMelodyPlaying(); void showAlert(const char* text, int duration_millis); + void addChannelMsg(uint8_t channel_idx, const char* text) override; + void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) override; + int getDMUnreadTotal() const; int getMsgCount() const { return _msgcount; } + int getChannelUnreadCount() const; + int getRoomUnreadCount() const { return _room_unread; } + void clearRoomUnread() { _room_unread = 0; } + uint8_t getDMUnread(const uint8_t* pub_key) const { + for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) + if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0) + return _dm_unread_table[i].count; + return 0; + } + void clearDMUnread(const uint8_t* pub_key) { + for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) + if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0) + { _dm_unread_table[i].count = 0; return; } + } bool hasDisplay() const { return _display != NULL; } bool isButtonPressed() const; @@ -87,13 +147,28 @@ public: } void toggleBuzzer(); + void cycleBuzzerMode(); // ON → OFF → Auto → ON + int getBuzzerMode(); // 0=ON, 1=OFF, 2=Auto bool getGPSState(); void toggleGPS(); + void applyBrightness(); + void setBrightnessLevel(uint8_t level); + uint8_t getBrightnessLevel() const { return _node_prefs ? _node_prefs->display_brightness : 2; } + void setBuzzerVolumeLevel(uint8_t level); + uint8_t getBuzzerVolume() const { return _node_prefs ? _node_prefs->buzzer_volume : 4; } + void applyTxPower(); + void applyGPSInterval(); + void applyFont(); + void applyRotation(); + uint32_t autoOffMillis() const { + if (!_node_prefs || _node_prefs->auto_off_secs == 0) return 0; + return (uint32_t)_node_prefs->auto_off_secs * 1000UL; + } // from AbstractUITask void msgRead(int msgcount) override; - void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override; + void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type = 0, const uint8_t* pub_key = nullptr) override; void notify(UIEventType t = UIEventType::none) override; void loop() override; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 57fee140..0c96e14d 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -50,7 +50,9 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t path_sz = flags & 0x03; // NEW v1.11+: lower 2 bits is path hash size uint8_t len = pkt->payload_len - i; - uint8_t offset = pkt->path_len << path_sz; + // path_len*entry_size can exceed 255 (path_len up to 63, entry_size up to 8); + // a uint8_t offset would wrap and steer the isHashMatch() read to the wrong place. + uint16_t offset = (uint16_t)pkt->path_len << path_sz; if (offset >= len) { // TRACE has reached end of given path onTraceRecv(pkt, trace_tag, auth_code, flags, pkt->path, &pkt->payload[i], len); } else if (self_id.isHashMatch(&pkt->payload[i + offset], 1 << path_sz) && allowPacketForward(pkt) && !_tables->hasSeen(pkt)) { diff --git a/src/helpers/BaseSerialInterface.h b/src/helpers/BaseSerialInterface.h index e6092765..91d2c8c7 100644 --- a/src/helpers/BaseSerialInterface.h +++ b/src/helpers/BaseSerialInterface.h @@ -14,6 +14,10 @@ public: virtual bool isEnabled() const = 0; virtual bool isConnected() const = 0; + // Returns true when a BLE companion app is connected. Default delegates to + // isConnected(); override in dual-interface wrappers that always report + // isConnected()=true but still need to distinguish BLE from USB state. + virtual bool isBLEConnected() const { return isConnected(); } virtual bool isWriteBusy() const = 0; virtual size_t writeFrame(const uint8_t src[], size_t len) = 0; diff --git a/src/helpers/SensorManager.h b/src/helpers/SensorManager.h index 89a174c2..a1ca4306 100644 --- a/src/helpers/SensorManager.h +++ b/src/helpers/SensorManager.h @@ -23,6 +23,7 @@ public: virtual const char* getSettingValue(int i) const { return NULL; } virtual bool setSettingValue(const char* name, const char* value) { return false; } virtual LocationProvider* getLocationProvider() { return NULL; } + virtual int getAvailableLPPTypes(uint8_t* types, int max_count) const { return 0; } // Helper functions to manage setting by keys (useful in many places ...) const char* getSettingByKey(const char* key) { diff --git a/src/helpers/nrf52/DualSerialInterface.h b/src/helpers/nrf52/DualSerialInterface.h new file mode 100644 index 00000000..fe239552 --- /dev/null +++ b/src/helpers/nrf52/DualSerialInterface.h @@ -0,0 +1,68 @@ +#pragma once + +#include "../BaseSerialInterface.h" +#include "../ArduinoSerialInterface.h" +#include "SerialBLEInterface.h" + +// Wraps BLE + USB serial interfaces: BLE takes priority when connected, +// USB is always ready as a fallback. +// enable()/disable() control BLE only — USB is always on. +// BLE state machine is only pumped when BLE is enabled; USB is not read while BLE is connected. +class DualSerialInterface : public BaseSerialInterface { + SerialBLEInterface _ble; + ArduinoSerialInterface _usb; + uint8_t _ble_buf[MAX_FRAME_SIZE]; + uint8_t _usb_buf[MAX_FRAME_SIZE]; + bool _ble_enabled; + bool _ble_was_connected; + +public: + DualSerialInterface() : _ble_enabled(false), _ble_was_connected(false) {} + + void begin(const char* ble_prefix, char* node_name, uint32_t pin_code, Stream& usb_stream) { + _ble.begin(ble_prefix, node_name, pin_code); + _usb.begin(usb_stream); + _usb.enable(); // USB is always on + } + + void enable() override { _ble.enable(); _ble_enabled = true; } + void disable() override { _ble.disable(); _ble_enabled = false; } + bool isEnabled() const override { return _ble_enabled; } + + // Always true — USB is always available as fallback, so the mesh can send. + bool isConnected() const override { return true; } + // True only when a BLE companion app is paired and connected. + bool isBLEConnected() const override { return _ble_enabled && _ble.isConnected(); } + + bool isWriteBusy() const override { + return (_ble_enabled && _ble.isConnected()) ? _ble.isWriteBusy() : _usb.isWriteBusy(); + } + + size_t writeFrame(const uint8_t src[], size_t len) override { + return (_ble_enabled && _ble.isConnected()) ? _ble.writeFrame(src, len) : _usb.writeFrame(src, len); + } + + size_t checkRecvFrame(uint8_t dest[]) override { + if (_ble_enabled) { + size_t ble_len = _ble.checkRecvFrame(_ble_buf); + bool ble_now = _ble.isConnected(); + + if (ble_now) { + _ble_was_connected = true; + if (ble_len > 0) { memcpy(dest, _ble_buf, ble_len); return ble_len; } + return 0; // BLE active — don't read USB to keep its state machine clean + } + + if (_ble_was_connected) { + // BLE just disconnected — reset USB state machine so stale partial frames don't block it + _ble_was_connected = false; + _usb.enable(); + return 0; + } + } + + size_t usb_len = _usb.checkRecvFrame(_usb_buf); + if (usb_len > 0) { memcpy(dest, _usb_buf, usb_len); return usb_len; } + return 0; + } +}; diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 19472406..3e421944 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -540,6 +540,46 @@ bool EnvironmentSensorManager::querySensors(uint8_t requester_permissions, Cayen } +int EnvironmentSensorManager::getAvailableLPPTypes(uint8_t* types, int max_count) const { + int n = 0; + auto add = [&](uint8_t t) { if (n < max_count) types[n++] = t; }; + + add(LPP_VOLTAGE); // battery voltage — always present + +#if ENV_INCLUDE_GPS + add(LPP_GPS); +#endif + + bool has_temp = AHTX0_initialized || BME680_initialized || BME280_initialized || + BMP280_initialized || SHTC3_initialized || SHT4X_initialized || + LPS22HB_initialized || MLX90614_initialized || BMP085_initialized || + RAK12035_initialized; + if (has_temp) add(LPP_TEMPERATURE); + + bool has_hum = AHTX0_initialized || BME680_initialized || BME280_initialized || + SHTC3_initialized || SHT4X_initialized || RAK12035_initialized; + if (has_hum) add(LPP_RELATIVE_HUMIDITY); + + bool has_press = BME680_initialized || LPS22HB_initialized; + if (has_press) add(LPP_BAROMETRIC_PRESSURE); + + bool has_alt = BME680_initialized || BME280_initialized || BMP280_initialized || BMP085_initialized; + if (has_alt) add(LPP_ALTITUDE); + + bool has_power = INA3221_initialized || INA219_initialized || INA260_initialized || INA226_initialized; + if (has_power) { add(LPP_CURRENT); add(LPP_POWER); } + +#if ENV_INCLUDE_VL53L0X + if (VL53L0X_initialized) add(LPP_DISTANCE); +#endif + +#if ENV_INCLUDE_RAK12035 + if (RAK12035_initialized) add(LPP_PERCENTAGE); +#endif + + return n; +} + int EnvironmentSensorManager::getNumSettings() const { int settings = 0; #if ENV_INCLUDE_GPS diff --git a/src/helpers/sensors/EnvironmentSensorManager.h b/src/helpers/sensors/EnvironmentSensorManager.h index 32413ebc..dea329ad 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.h +++ b/src/helpers/sensors/EnvironmentSensorManager.h @@ -49,6 +49,7 @@ public: #endif bool begin() override; bool querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) override; + int getAvailableLPPTypes(uint8_t* types, int max_count) const override; #if ENV_INCLUDE_GPS void loop() override; #endif diff --git a/src/helpers/sensors/LPPDataHelpers.h b/src/helpers/sensors/LPPDataHelpers.h index 37b50f3f..964395da 100644 --- a/src/helpers/sensors/LPPDataHelpers.h +++ b/src/helpers/sensors/LPPDataHelpers.h @@ -136,6 +136,22 @@ public: m = getFloat(&_buf[_pos], 2, 1, true); _pos += 2; return _pos <= _len; } + bool readLuminosity(float& lux) { + lux = getFloat(&_buf[_pos], 2, 1, false); _pos += 2; + return _pos <= _len; + } + bool readPercentage(float& pct) { + pct = getFloat(&_buf[_pos], 1, 1, false); _pos += 1; + return _pos <= _len; + } + bool readConcentration(float& ppm) { + ppm = getFloat(&_buf[_pos], 2, 1, false); _pos += 2; + return _pos <= _len; + } + bool readDistance(float& m) { + m = getFloat(&_buf[_pos], 4, 1000, false); _pos += 4; + return _pos <= _len; + } void skipData(uint8_t type) { switch (type) { diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index ec63c191..c489f9cb 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -7,6 +7,7 @@ class DisplayDriver { int _w, _h; protected: DisplayDriver(int w, int h) { _w = w; _h = h; } + void setDimensions(int w, int h) { _w = w; _h = h; } public: enum Color { DARK=0, LIGHT, RED, GREEN, BLUE, YELLOW, ORANGE }; // on b/w screen, colors will be !=0 synonym of light @@ -27,6 +28,21 @@ public: virtual void drawRect(int x, int y, int w, int h) = 0; virtual void drawXbm(int x, int y, const uint8_t* bits, int w, int h) = 0; virtual uint16_t getTextWidth(const char* str) = 0; + virtual int getCharWidth() const { return 6; } // typical character advance width (px) + virtual int getLineHeight() const { return 8; } // pixel rows per text line + virtual void setLemonFont(bool) { } // no-op; overridden by displays that support Lemon + + // Layout helpers — derived from font metrics and screen size. + // Use these instead of hardcoded pixel values so layouts adapt to any display. + int lineStep() const { return getLineHeight() + 2; } // row pitch: text + gap + int headerH() const { return getLineHeight() + 3; } // title bar height + int listStart() const { return headerH(); } // y where list items begin + int listVisible(int itemH) const { return (height() - listStart()) / itemH; } + int listVisible() const { return listVisible(lineStep()); } + // x where a right-side value column starts (leaves ~8 chars for the value) + int valCol() const { return width() - getCharWidth() * 8; } + // separator line thickness: 2px on landscape e-ink, 1px on OLED + int sepH() const { return (width() >= height()) ? 2 : 1; } virtual void drawTextCentered(int mid_x, int y, const char* str) { // helper method (override to optimise) int w = getTextWidth(str); setCursor(mid_x - w/2, y); @@ -42,17 +58,112 @@ public: print(str); } - // convert UTF-8 characters to displayable block characters for compatibility + static char transliterateCodepoint(uint32_t cp) { + switch (cp) { + // Polish + case 0x0105: return 'a'; case 0x0104: return 'A'; // ą Ą + case 0x0107: return 'c'; case 0x0106: return 'C'; // ć Ć + case 0x0119: return 'e'; case 0x0118: return 'E'; // ę Ę + case 0x0142: return 'l'; case 0x0141: return 'L'; // ł Ł + case 0x0144: return 'n'; case 0x0143: return 'N'; // ń Ń + case 0x00F3: return 'o'; case 0x00D3: return 'O'; // ó Ó + case 0x015B: return 's'; case 0x015A: return 'S'; // ś Ś + case 0x017A: return 'z'; case 0x0179: return 'Z'; // ź Ź + case 0x017C: return 'z'; case 0x017B: return 'Z'; // ż Ż + // Czech/Slovak + case 0x010D: return 'c'; case 0x010C: return 'C'; // č Č + case 0x0161: return 's'; case 0x0160: return 'S'; // š Š + case 0x017E: return 'z'; case 0x017D: return 'Z'; // ž Ž + case 0x0159: return 'r'; case 0x0158: return 'R'; // ř Ř + case 0x011B: return 'e'; case 0x011A: return 'E'; // ě Ě + case 0x016F: return 'u'; case 0x016E: return 'U'; // ů Ů + case 0x010F: return 'd'; case 0x010E: return 'D'; // ď Ď + case 0x0165: return 't'; case 0x0164: return 'T'; // ť Ť + case 0x0148: return 'n'; case 0x0147: return 'N'; // ň Ň + case 0x013E: return 'l'; case 0x013D: return 'L'; // ľ Ľ + case 0x013A: return 'l'; case 0x0139: return 'L'; // ĺ Ĺ + case 0x0155: return 'r'; case 0x0154: return 'R'; // ŕ Ŕ + // German + case 0x00E4: return 'a'; case 0x00C4: return 'A'; // ä Ä + case 0x00F6: return 'o'; case 0x00D6: return 'O'; // ö Ö + case 0x00FC: return 'u'; case 0x00DC: return 'U'; // ü Ü + case 0x00DF: return 's'; // ß + // Scandinavian + case 0x00E5: return 'a'; case 0x00C5: return 'A'; // å Å + case 0x00F8: return 'o'; case 0x00D8: return 'O'; // ø Ø + case 0x00E6: return 'a'; case 0x00C6: return 'A'; // æ Æ + // Hungarian + case 0x0151: return 'o'; case 0x0150: return 'O'; // ő Ő + case 0x0171: return 'u'; case 0x0170: return 'U'; // ű Ű + // Romanian + case 0x0103: return 'a'; case 0x0102: return 'A'; // ă Ă + case 0x0219: return 's'; case 0x0218: return 'S'; // ș Ș + case 0x021B: return 't'; case 0x021A: return 'T'; // ț Ț + // Croatian + case 0x0111: return 'd'; case 0x0110: return 'D'; // đ Đ + // Icelandic + case 0x00F0: return 'd'; case 0x00D0: return 'D'; // ð Ð (eth) + case 0x00FE: return 't'; case 0x00DE: return 'T'; // þ Þ (thorn) + // Romanian cedilla variants (legacy encoding) + case 0x0163: return 't'; case 0x0162: return 'T'; // ţ Ţ + // Turkish + case 0x011F: return 'g'; case 0x011E: return 'G'; // ğ Ğ + case 0x015F: return 's'; case 0x015E: return 'S'; // ş Ş + case 0x0131: return 'i'; // ı + // Baltic (Lithuanian/Latvian) + case 0x0101: return 'a'; case 0x0100: return 'A'; // ā Ā + case 0x0113: return 'e'; case 0x0112: return 'E'; // ē Ē + case 0x012B: return 'i'; case 0x012A: return 'I'; // ī Ī + case 0x016B: return 'u'; case 0x016A: return 'U'; // ū Ū + case 0x0123: return 'g'; case 0x0122: return 'G'; // ģ Ģ + case 0x0137: return 'k'; case 0x0136: return 'K'; // ķ Ķ + case 0x013C: return 'l'; case 0x013B: return 'L'; // ļ Ļ + case 0x0146: return 'n'; case 0x0145: return 'N'; // ņ Ņ + case 0x0157: return 'r'; case 0x0156: return 'R'; // ŗ Ŗ + case 0x0173: return 'u'; case 0x0172: return 'U'; // ų Ų + case 0x0117: return 'e'; case 0x0116: return 'E'; // ė Ė + case 0x012F: return 'i'; case 0x012E: return 'I'; // į Į + // French/Spanish/Portuguese common accents + case 0x00E0: case 0x00E1: case 0x00E2: case 0x00E3: return 'a'; + case 0x00C0: case 0x00C1: case 0x00C2: case 0x00C3: return 'A'; + case 0x00E8: case 0x00E9: case 0x00EA: case 0x00EB: return 'e'; + case 0x00C8: case 0x00C9: case 0x00CA: case 0x00CB: return 'E'; + case 0x00EC: case 0x00ED: case 0x00EE: case 0x00EF: return 'i'; + case 0x00CC: case 0x00CD: case 0x00CE: case 0x00CF: return 'I'; + case 0x00F2: case 0x00F4: case 0x00F5: return 'o'; + case 0x00D2: case 0x00D4: case 0x00D5: return 'O'; + case 0x00F9: case 0x00FA: case 0x00FB: return 'u'; + case 0x00D9: case 0x00DA: case 0x00DB: return 'U'; + case 0x00E7: return 'c'; case 0x00C7: return 'C'; // ç Ç + case 0x00F1: return 'n'; case 0x00D1: return 'N'; // ñ Ñ + case 0x00FD: return 'y'; case 0x00DD: return 'Y'; // ý Ý + default: return '?'; + } + } + + // convert UTF-8 to ASCII, transliterating accented/diacritic characters virtual void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) { size_t j = 0; - for (size_t i = 0; src[i] != 0 && j < dest_size - 1; i++) { - unsigned char c = (unsigned char)src[i]; - if (c >= 32 && c <= 126) { - dest[j++] = c; // ASCII printable - } else if (c >= 0x80) { - dest[j++] = '\xDB'; // CP437 full block █ - while (src[i+1] && (src[i+1] & 0xC0) == 0x80) - i++; // skip UTF-8 continuation bytes + const uint8_t* p = (const uint8_t*)src; + while (*p && j < dest_size - 1) { + uint8_t c = *p++; + if (c < 0x80) { + if (c >= 32) dest[j++] = c; + } else { + uint32_t cp = c; + if ((c & 0xE0) == 0xC0) { + cp = c & 0x1F; + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + } else if ((c & 0xF0) == 0xE0) { + cp = c & 0x0F; + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + } else { + while (*p && (*p & 0xC0) == 0x80) p++; + dest[j++] = '\xDB'; + continue; + } + dest[j++] = transliterateCodepoint(cp); } } dest[j] = 0; @@ -61,10 +172,7 @@ public: // draw text with ellipsis if it exceeds max_width virtual void drawTextEllipsized(int x, int y, int max_width, const char* str) { char temp_str[256]; // reasonable buffer size - size_t len = strlen(str); - if (len >= sizeof(temp_str)) len = sizeof(temp_str) - 1; - memcpy(temp_str, str, len); - temp_str[len] = 0; + translateUTF8ToBlocks(temp_str, str, sizeof(temp_str)); if (getTextWidth(temp_str) <= max_width) { setCursor(x, y); @@ -90,11 +198,17 @@ public: while (str_len > 0 && getTextWidth(temp_str) > max_width - ellipsis_width) { temp_str[--str_len] = 0; } + // Strip orphaned UTF-8 leading byte left by byte-at-a-time trimming above. + while (str_len > 0 && ((uint8_t)temp_str[str_len - 1] & 0xC0) == 0xC0) { + temp_str[--str_len] = 0; + } strcat(temp_str, ellipsis); setCursor(x, y); print(temp_str); } + virtual void setBrightness(uint8_t level) { } // level 0-4 (min to max), no-op default + virtual void setDisplayRotation(uint8_t rot) { } // 0-3, no-op for fixed-orientation displays virtual void endFrame() = 0; }; diff --git a/src/helpers/ui/GxEPDDisplay.cpp b/src/helpers/ui/GxEPDDisplay.cpp index ad47754b..7d337a3a 100644 --- a/src/helpers/ui/GxEPDDisplay.cpp +++ b/src/helpers/ui/GxEPDDisplay.cpp @@ -1,4 +1,3 @@ - #include "GxEPDDisplay.h" #ifdef EXP_PIN_BACKLIGHT @@ -7,13 +6,22 @@ #endif #ifndef DISPLAY_ROTATION - #define DISPLAY_ROTATION 3 + #define DISPLAY_ROTATION 0 #endif #ifdef ESP32 SPIClass SPI1 = SPIClass(FSPI); #endif +// GFX fonts use the baseline as the cursor origin. UI code assumes top-of-cell +// coordinates (same convention as the OLED driver). Add the font ascender so +// the two conventions match. +static int fontAscender(int sz, bool use_lemon, int scale) { + if (sz == 3) return 26; // FreeSans18pt7b: proportional, baseline origin + if (sz == 1 && use_lemon) return 8 * scale; // Lemon GFX font: baseline origin, ascender 8px×scale + return 0; // GFX built-in font: cursor is top-left of cell +} + bool GxEPDDisplay::begin() { display.epd2.selectSPI(SPI1, SPISettings(4000000, MSBFIRST, SPI_MODE0)); #ifdef ESP32 @@ -23,15 +31,14 @@ bool GxEPDDisplay::begin() { #endif display.init(115200, true, 2, false); display.setRotation(DISPLAY_ROTATION); - setTextSize(1); // Default to size 1 + setTextSize(1); display.setPartialWindow(0, 0, display.width(), display.height()); - display.fillScreen(GxEPD_WHITE); display.display(true); - #if DISP_BACKLIGHT +#if DISP_BACKLIGHT digitalWrite(DISP_BACKLIGHT, LOW); pinMode(DISP_BACKLIGHT, OUTPUT); - #endif +#endif _init = true; return true; } @@ -64,30 +71,39 @@ void GxEPDDisplay::clear() { void GxEPDDisplay::startFrame(Color bkg) { display.fillScreen(GxEPD_WHITE); display.setTextColor(_curr_color = GxEPD_BLACK); + _text_sz = 1; + int sc = (width() >= height()) ? 2 : 1; + display.setFont(_use_lemon ? &Lemon : NULL); + display.setTextSize(sc); display_crc.reset(); } void GxEPDDisplay::setTextSize(int sz) { + _text_sz = sz; display_crc.update(sz); - switch(sz) { - case 1: // Small - display.setFont(&FreeSans9pt7b); - break; - case 2: // Medium Bold - display.setFont(&FreeSansBold12pt7b); - break; - case 3: // Large + // Size 1 scales with orientation: 1× in portrait (≈OLED width), 2× in landscape. + // Size 2 always uses 2× built-in (12×16) — fixed because layout Y-positions are hardcoded. + // Size 3 always uses FreeSans18pt for large headings. + int sc = (width() >= height()) ? 2 : 1; + switch (sz) { + case 3: display.setFont(&FreeSans18pt7b); + display.setTextSize(1); + break; + case 2: + display.setFont(NULL); + display.setTextSize((width() >= height()) ? 4 : 2); break; default: - display.setFont(&FreeSans9pt7b); + display.setFont(_use_lemon ? &Lemon : NULL); + display.setTextSize(sc); break; } } void GxEPDDisplay::setColor(Color c) { - display_crc.update (c); - // colours need to be inverted for epaper displays + display_crc.update(c); + // e-ink: DARK background = white paper, LIGHT foreground = black ink if (c == DARK) { display.setTextColor(_curr_color = GxEPD_WHITE); } else { @@ -98,7 +114,10 @@ void GxEPDDisplay::setColor(Color c) { void GxEPDDisplay::setCursor(int x, int y) { display_crc.update(x); display_crc.update(y); - display.setCursor((x+offset_x)*scale_x, (y+offset_y)*scale_y); + // Offset y by the font ascender: callers pass top-of-cell y, GFX fonts + // expect baseline y. Without this, text would be clipped at the top. + int sc = (width() >= height()) ? 2 : 1; + display.setCursor(x, y + fontAscender(_text_sz, _use_lemon, sc)); } void GxEPDDisplay::print(const char* str) { @@ -111,7 +130,7 @@ void GxEPDDisplay::fillRect(int x, int y, int w, int h) { display_crc.update(y); display_crc.update(w); display_crc.update(h); - display.fillRect(x*scale_x, y*scale_y, w*scale_x, h*scale_y, _curr_color); + display.fillRect(x, y, w, h, _curr_color); } void GxEPDDisplay::drawRect(int x, int y, int w, int h) { @@ -119,7 +138,9 @@ void GxEPDDisplay::drawRect(int x, int y, int w, int h) { display_crc.update(y); display_crc.update(w); display_crc.update(h); - display.drawRect(x*scale_x, y*scale_y, w*scale_x, h*scale_y, _curr_color); + display.drawRect(x, y, w, h, _curr_color); + if (width() >= height() && w > 2 && h > 2) + display.drawRect(x + 1, y + 1, w - 2, h - 2, _curr_color); } void GxEPDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { @@ -128,36 +149,13 @@ void GxEPDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { display_crc.update(w); display_crc.update(h); display_crc.update(bits, w * h / 8); - // Calculate the base position in display coordinates - uint16_t startX = x * scale_x; - uint16_t startY = y * scale_y; - - // Width in bytes for bitmap processing uint16_t widthInBytes = (w + 7) / 8; - - // Process the bitmap row by row for (uint16_t by = 0; by < h; by++) { - // Calculate the target y-coordinates for this logical row - int y1 = startY + (int)(by * scale_y); - int y2 = startY + (int)((by + 1) * scale_y); - int block_h = y2 - y1; - - // Scan across the row bit by bit for (uint16_t bx = 0; bx < w; bx++) { - // Calculate the target x-coordinates for this logical column - int x1 = startX + (int)(bx * scale_x); - int x2 = startX + (int)((bx + 1) * scale_x); - int block_w = x2 - x1; - - // Get the current bit uint16_t byteOffset = (by * widthInBytes) + (bx / 8); uint8_t bitMask = 0x80 >> (bx & 7); - bool bitSet = pgm_read_byte(bits + byteOffset) & bitMask; - - // If the bit is set, draw a block of pixels - if (bitSet) { - // Draw the block as a filled rectangle - display.fillRect(x1, y1, block_w, block_h, _curr_color); + if (pgm_read_byte(bits + byteOffset) & bitMask) { + display.drawPixel(x + bx, y + by, _curr_color); } } } @@ -167,7 +165,13 @@ uint16_t GxEPDDisplay::getTextWidth(const char* str) { int16_t x1, y1; uint16_t w, h; display.getTextBounds(str, 0, 0, &x1, &y1, &w, &h); - return ceil((w + 1) / scale_x); + return w; +} + +void GxEPDDisplay::setDisplayRotation(uint8_t rot) { + display.setRotation(rot & 3); + setDimensions(display.width(), display.height()); + last_display_crc_value = -1; // force redraw on next endFrame } void GxEPDDisplay::endFrame() { diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index 1a04cc24..6cffc323 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -15,38 +15,78 @@ #include #include "DisplayDriver.h" +#include "LemonFont.h" + +#ifndef DISPLAY_ROTATION + #define DISPLAY_ROTATION 0 +#endif + +// Panel native dimensions before rotation. Derived from the model class when +// EINK_DISPLAY_MODEL is set; override with EINK_PANEL_W/H for other panels. +#if defined(EINK_DISPLAY_MODEL) + #ifndef EINK_PANEL_W + #define EINK_PANEL_W EINK_DISPLAY_MODEL::WIDTH + #endif + #ifndef EINK_PANEL_H + #define EINK_PANEL_H EINK_DISPLAY_MODEL::HEIGHT + #endif +#else + #ifndef EINK_PANEL_W + #define EINK_PANEL_W 200 + #define EINK_PANEL_H 200 + #endif +#endif + +// Odd rotations (1, 3) swap width and height. +#define EINK_DISP_W ((DISPLAY_ROTATION & 1) ? EINK_PANEL_H : EINK_PANEL_W) +#define EINK_DISP_H ((DISPLAY_ROTATION & 1) ? EINK_PANEL_W : EINK_PANEL_H) class GxEPDDisplay : public DisplayDriver { - #if defined(EINK_DISPLAY_MODEL) GxEPD2_BW display; - const float scale_x = EINK_SCALE_X; - const float scale_y = EINK_SCALE_Y; - const float offset_x = EINK_X_OFFSET; - const float offset_y = EINK_Y_OFFSET; #else GxEPD2_BW display; - const float scale_x = 1.5625f; - const float scale_y = 1.5625f; - const float offset_x = 0; - const float offset_y = 10; #endif bool _init = false; bool _isOn = false; + bool _use_lemon = false; uint16_t _curr_color; CRC32 display_crc; int last_display_crc_value = 0; + int _text_sz = 1; public: #if defined(EINK_DISPLAY_MODEL) - GxEPDDisplay() : DisplayDriver(128, 128), display(EINK_DISPLAY_MODEL(PIN_DISPLAY_CS, PIN_DISPLAY_DC, PIN_DISPLAY_RST, PIN_DISPLAY_BUSY)) {} + GxEPDDisplay() : DisplayDriver(EINK_DISP_W, EINK_DISP_H), + display(EINK_DISPLAY_MODEL(PIN_DISPLAY_CS, PIN_DISPLAY_DC, PIN_DISPLAY_RST, PIN_DISPLAY_BUSY)) {} #else - GxEPDDisplay() : DisplayDriver(128, 128), display(GxEPD2_150_BN(DISP_CS, DISP_DC, DISP_RST, DISP_BUSY)) {} + GxEPDDisplay() : DisplayDriver(EINK_DISP_W, EINK_DISP_H), + display(GxEPD2_150_BN(DISP_CS, DISP_DC, DISP_RST, DISP_BUSY)) {} #endif + // Line height and approx. char width for each font size: + // 1 = FreeSans9pt (lineH=16, charW≈9) + // 2 = FreeSansBold12pt (lineH=20, charW≈12) + // 3 = FreeSans18pt (lineH=28, charW≈17) + // Size 1 scales with orientation (portrait 1×, landscape 2×); size 2 is always 12×16; + // size 3 is FreeSans18pt (~17×28). Landscape = width >= height. + int getCharWidth() const override { + int sc = (width() >= height()) ? 2 : 1; + if (_text_sz == 3) return 17; + if (_text_sz == 2) return 12 * sc; + return (_use_lemon ? 5 : 6) * sc; + } + int getLineHeight() const override { + int sc = (width() >= height()) ? 2 : 1; + if (_text_sz == 3) return 28; + if (_text_sz == 2) return 16 * sc; + return (_use_lemon ? 10 : 8) * sc; + } + void setLemonFont(bool enabled) override { _use_lemon = enabled; } + bool begin(); - bool isOn() override {return _isOn;}; + bool isOn() override { return _isOn; } void turnOn() override; void turnOff() override; void clear() override; @@ -59,5 +99,6 @@ public: void drawRect(int x, int y, int w, int h) override; void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override; uint16_t getTextWidth(const char* str) override; + void setDisplayRotation(uint8_t rot) override; void endFrame() override; }; diff --git a/src/helpers/ui/LemonFont.h b/src/helpers/ui/LemonFont.h new file mode 100644 index 00000000..3a81d691 --- /dev/null +++ b/src/helpers/ui/LemonFont.h @@ -0,0 +1,1523 @@ +// Lemon font, converted from lemon.bdf +// Range: U+0020–U+04FF (1074 BDF glyphs + 174 placeholders) +// Generated by bdf2lemon.py +#pragma once +#include + +static const uint8_t lemonBitmaps[] PROGMEM = { + 0xF4, 0xB6, 0x80, 0x56, 0xD5, 0xB5, 0x00, 0x27, 0x86, 0x1E, 0x20, 0xCD, 0x24, 0xB3, 0x45, 0x11, + 0x59, 0x34, 0xE0, 0x2A, 0x49, 0x11, 0x88, 0x92, 0x54, 0x96, 0xF6, 0x90, 0x21, 0x3E, 0x42, 0x00, + 0x58, 0xF0, 0xC0, 0x11, 0x22, 0x44, 0x88, 0x69, 0xDB, 0x96, 0x75, 0x50, 0xE1, 0x16, 0x8F, 0xE1, + 0x61, 0x1E, 0x99, 0x97, 0x11, 0xF8, 0xE1, 0x1E, 0x68, 0xE9, 0x96, 0xF1, 0x22, 0x44, 0x69, 0x69, + 0x96, 0x69, 0x97, 0x16, 0xD8, 0x51, 0x60, 0x2A, 0x22, 0xE3, 0x80, 0x88, 0xA8, 0xF4, 0xA0, 0x80, + 0x69, 0xBB, 0x87, 0x69, 0x9F, 0x99, 0xE9, 0xE9, 0x9E, 0x78, 0x88, 0x87, 0xE9, 0x99, 0x9E, 0xF8, + 0xE8, 0x8F, 0xF8, 0xE8, 0x88, 0x78, 0x8B, 0x97, 0x99, 0xF9, 0x99, 0xE9, 0x25, 0xC0, 0x71, 0x11, + 0x96, 0x9A, 0xCA, 0x99, 0x88, 0x88, 0x8F, 0x9F, 0xF9, 0x99, 0x9D, 0xDB, 0xB9, 0x69, 0x99, 0x96, + 0xE9, 0x9E, 0x88, 0x69, 0x99, 0x96, 0x30, 0xE9, 0x9E, 0x99, 0x78, 0x61, 0x1E, 0xF9, 0x08, 0x42, + 0x10, 0x99, 0x99, 0x97, 0x99, 0x96, 0x66, 0x99, 0x9F, 0xF9, 0x99, 0x69, 0x99, 0x8A, 0x88, 0x42, + 0x10, 0xF1, 0x24, 0x8F, 0xF2, 0x49, 0x27, 0x88, 0x44, 0x22, 0x11, 0xE4, 0x92, 0x4F, 0x22, 0xA2, + 0xF0, 0xA4, 0x79, 0x9B, 0x50, 0x88, 0xE9, 0x99, 0xE0, 0x78, 0x88, 0x70, 0x11, 0x79, 0x9B, 0x50, + 0x69, 0xF8, 0x70, 0x73, 0xC9, 0x20, 0x79, 0x9B, 0x51, 0x60, 0x88, 0xE9, 0x99, 0x90, 0x4D, 0x54, + 0x21, 0x92, 0x49, 0xC0, 0x88, 0x9A, 0xE9, 0x90, 0xD5, 0x54, 0x9F, 0xB9, 0x90, 0xE9, 0x99, 0x90, + 0x69, 0x99, 0x60, 0xE9, 0x99, 0xE8, 0x80, 0x79, 0x99, 0x71, 0x10, 0xBC, 0x88, 0x80, 0x78, 0x61, + 0xE0, 0x5D, 0x24, 0x40, 0x99, 0x99, 0x70, 0x99, 0x96, 0x60, 0x99, 0x9F, 0x90, 0x99, 0x69, 0x90, + 0x99, 0x99, 0x71, 0x60, 0xF2, 0x48, 0xF0, 0x29, 0x6C, 0x91, 0xFF, 0x89, 0x36, 0x94, 0x45, 0x6A, + 0x20, 0x6F, 0xF6, 0xBC, 0x17, 0xAA, 0xA7, 0x40, 0x32, 0x11, 0xC4, 0x27, 0xC0, 0x8B, 0x94, 0xA7, + 0x44, 0x8A, 0x88, 0xE2, 0x38, 0x80, 0xEE, 0x34, 0x69, 0x96, 0x2C, 0xA0, 0x7A, 0x1B, 0x69, 0xB6, + 0x17, 0x80, 0x79, 0x97, 0x5A, 0x50, 0xF8, 0x42, 0xE0, 0x7A, 0x1B, 0xED, 0xAE, 0x17, 0x80, 0xE0, + 0x55, 0x00, 0x27, 0x20, 0x70, 0xC5, 0x4E, 0xC7, 0x1C, 0x60, 0x99, 0x99, 0xE8, 0x7D, 0x6A, 0xD2, + 0x94, 0xF0, 0x47, 0x80, 0x75, 0x69, 0x96, 0xA2, 0xA8, 0x46, 0x10, 0xA3, 0x29, 0xE2, 0x46, 0x10, + 0xE0, 0x88, 0xE0, 0xC0, 0x84, 0x0A, 0xD8, 0xA3, 0xC2, 0x41, 0x4B, 0xC0, 0x42, 0x69, 0x9F, 0x99, + 0x24, 0x69, 0x9F, 0x99, 0x25, 0x06, 0x99, 0xF9, 0x90, 0x5A, 0x69, 0x9F, 0x99, 0x48, 0x0C, 0x94, + 0xBD, 0x29, 0x69, 0x69, 0x9F, 0x99, 0x7E, 0x49, 0xBC, 0x92, 0x70, 0x78, 0x88, 0x87, 0x24, 0x42, + 0xF8, 0xE8, 0x8F, 0x24, 0xF8, 0xE8, 0x8F, 0x4A, 0xF8, 0xE8, 0x8F, 0xA0, 0xF8, 0xE8, 0x8F, 0x88, + 0x74, 0x92, 0xE0, 0x28, 0x74, 0x92, 0xE0, 0x54, 0x74, 0x92, 0xE0, 0xA3, 0xA4, 0x97, 0x72, 0x7A, + 0x94, 0xB8, 0x2A, 0x80, 0xE4, 0xA5, 0x29, 0x84, 0x06, 0x99, 0x96, 0x24, 0x06, 0x99, 0x96, 0x4A, + 0x06, 0x99, 0x96, 0x5A, 0x06, 0x99, 0x96, 0x48, 0x0C, 0x94, 0xA4, 0xC0, 0xAA, 0x80, 0x35, 0x65, + 0x9A, 0x6A, 0xC0, 0x84, 0x99, 0x99, 0x97, 0x12, 0x99, 0x99, 0x97, 0x25, 0x09, 0x99, 0x99, 0x70, + 0x50, 0x99, 0x99, 0x97, 0x11, 0x22, 0xA2, 0x10, 0x84, 0x88, 0xE9, 0x9E, 0x80, 0x69, 0xA9, 0x9A, + 0x80, 0x84, 0x07, 0x99, 0xB5, 0x24, 0x07, 0x99, 0xB5, 0x4A, 0x07, 0x99, 0xB5, 0x5A, 0x07, 0x99, + 0xB5, 0x90, 0x79, 0x9B, 0x50, 0x20, 0x79, 0x9B, 0x50, 0x75, 0x6F, 0x45, 0x80, 0x78, 0x88, 0x72, + 0x40, 0x84, 0x06, 0x9F, 0x87, 0x24, 0x06, 0x9F, 0x87, 0x4A, 0x06, 0x9F, 0x87, 0xA0, 0x69, 0xF8, + 0x70, 0x93, 0x55, 0x28, 0x64, 0x92, 0x54, 0x64, 0x92, 0xA3, 0x24, 0x90, 0xA6, 0x97, 0x99, 0x96, + 0x5A, 0x0E, 0x99, 0x99, 0x41, 0x00, 0x64, 0xA5, 0x26, 0x12, 0x06, 0x99, 0x96, 0x4A, 0x06, 0x99, + 0x96, 0x5A, 0x06, 0x99, 0x96, 0x90, 0x69, 0x99, 0x60, 0x20, 0x3E, 0x02, 0x00, 0x35, 0x67, 0x9A, + 0xB0, 0x84, 0x09, 0x99, 0x97, 0x11, 0x00, 0x94, 0xA5, 0x27, 0x4A, 0x09, 0x99, 0x97, 0x50, 0x12, + 0x94, 0xA4, 0xE0, 0x24, 0x09, 0x99, 0x97, 0x16, 0x88, 0xE9, 0x99, 0xE8, 0x80, 0x50, 0x99, 0x99, + 0x71, 0x60, 0xF0, 0x69, 0x9F, 0x99, 0xF0, 0x79, 0x9B, 0x50, 0x96, 0x06, 0x99, 0xF9, 0x90, 0x96, + 0x07, 0x99, 0xB5, 0x64, 0xA5, 0xE9, 0x48, 0x20, 0x74, 0xA5, 0x65, 0x04, 0x24, 0x07, 0x88, 0x88, + 0x70, 0x24, 0x07, 0x88, 0x87, 0x25, 0x07, 0x88, 0x88, 0x70, 0x25, 0x07, 0x88, 0x87, 0x20, 0x78, + 0x88, 0x87, 0x20, 0x78, 0x88, 0x70, 0x52, 0x07, 0x88, 0x88, 0x70, 0x52, 0x07, 0x88, 0x87, 0xA4, + 0x0E, 0x99, 0x99, 0xE0, 0x04, 0x51, 0x9C, 0x92, 0x4B, 0x14, 0x72, 0x7A, 0x94, 0xB8, 0x38, 0x9D, + 0x29, 0x59, 0x40, 0x60, 0xF8, 0xE8, 0x8F, 0xF0, 0x69, 0xF8, 0x70, 0x96, 0x0F, 0x8E, 0x88, 0xF0, + 0x96, 0x06, 0x9F, 0x87, 0x40, 0xF8, 0xE8, 0x8F, 0x40, 0x69, 0xF8, 0x70, 0xF4, 0x39, 0x08, 0x78, + 0x20, 0x69, 0xF8, 0x61, 0xA4, 0x0F, 0x8E, 0x88, 0xF0, 0xA4, 0x06, 0x9F, 0x87, 0x25, 0x07, 0x88, + 0xB9, 0x70, 0x25, 0x07, 0x99, 0xB5, 0x16, 0x96, 0x07, 0x88, 0xB9, 0x70, 0x96, 0x07, 0x99, 0xB5, + 0x16, 0x20, 0x78, 0x8B, 0x97, 0x20, 0x79, 0x9B, 0x51, 0x60, 0x78, 0x8B, 0x97, 0x24, 0x24, 0x07, + 0x99, 0xB5, 0x16, 0x25, 0x09, 0x9F, 0x99, 0x90, 0x25, 0x88, 0xE9, 0x99, 0x90, 0xFD, 0x27, 0x92, + 0x49, 0x20, 0xF2, 0x1C, 0x94, 0xA5, 0x20, 0x5A, 0x07, 0x22, 0x22, 0x70, 0x5A, 0x06, 0x22, 0x22, + 0xE3, 0xA4, 0x97, 0xE3, 0x24, 0x90, 0xA8, 0x74, 0x92, 0xE0, 0xA8, 0x64, 0x92, 0xE9, 0x25, 0xE2, + 0x4D, 0x5E, 0x40, 0x43, 0xA4, 0x97, 0xD5, 0x40, 0xFD, 0x14, 0x51, 0x57, 0xA0, 0x48, 0x36, 0x94, + 0xA5, 0x26, 0x25, 0x07, 0x11, 0x19, 0x60, 0x25, 0x06, 0x22, 0x22, 0x2C, 0x9A, 0xCA, 0x99, 0x48, + 0x88, 0x9A, 0xE9, 0x94, 0x80, 0x9A, 0xE9, 0x90, 0x12, 0x88, 0x88, 0x8F, 0x12, 0xC4, 0x44, 0x44, + 0x40, 0x88, 0x88, 0x8F, 0x24, 0x64, 0x92, 0x4A, 0x80, 0x39, 0x88, 0x88, 0xF0, 0x31, 0xC4, 0x44, + 0x44, 0x42, 0x88, 0x8A, 0x8F, 0xC4, 0x44, 0x54, 0x40, 0x8A, 0xC8, 0x8F, 0xC9, 0x3C, 0x90, 0x24, + 0x09, 0xDD, 0xBB, 0x90, 0x24, 0x0E, 0x99, 0x99, 0x9D, 0xDB, 0xBD, 0x48, 0x24, 0x0E, 0x99, 0x9D, + 0x48, 0x52, 0x9D, 0xDB, 0xB9, 0x52, 0x0E, 0x99, 0x99, 0x88, 0x0E, 0x99, 0x99, 0x9D, 0xDB, 0xB9, + 0x16, 0x24, 0x0E, 0x99, 0x99, 0x16, 0xF0, 0x69, 0x99, 0x96, 0xF0, 0x69, 0x99, 0x60, 0x96, 0x06, + 0x99, 0x99, 0x60, 0x96, 0x06, 0x99, 0x96, 0x5A, 0x06, 0x99, 0x99, 0x60, 0x5A, 0x06, 0x99, 0x96, + 0x7E, 0x49, 0xA4, 0x91, 0xF0, 0x75, 0x6F, 0x47, 0x80, 0x24, 0xE9, 0x9E, 0x99, 0x24, 0x0B, 0xC8, + 0x88, 0xE9, 0x9E, 0x99, 0x48, 0xBC, 0x88, 0x84, 0x80, 0xA4, 0xE9, 0x9E, 0x99, 0xA4, 0x0B, 0xC8, + 0x88, 0x24, 0x07, 0x86, 0x11, 0xE0, 0x24, 0x07, 0x86, 0x1E, 0x25, 0x07, 0x86, 0x11, 0xE0, 0x25, + 0x07, 0x86, 0x1E, 0x78, 0x61, 0x1E, 0x22, 0x78, 0x61, 0xE2, 0x20, 0x52, 0x07, 0x86, 0x11, 0xE0, + 0x52, 0x07, 0x86, 0x1E, 0xF9, 0x08, 0x42, 0x11, 0x00, 0x5D, 0x24, 0x50, 0x51, 0x01, 0xF2, 0x10, + 0x84, 0x20, 0x31, 0x4E, 0x44, 0x42, 0xF9, 0x08, 0xE2, 0x10, 0x5D, 0x74, 0x40, 0x5A, 0x09, 0x99, + 0x99, 0x70, 0x5A, 0x09, 0x99, 0x97, 0xF0, 0x99, 0x99, 0x97, 0xF0, 0x99, 0x99, 0x70, 0x96, 0x09, + 0x99, 0x99, 0x70, 0x96, 0x09, 0x99, 0x97, 0x25, 0x29, 0x99, 0x99, 0x70, 0x25, 0x29, 0x99, 0x97, + 0x4C, 0x81, 0x29, 0x4A, 0x52, 0x70, 0x4C, 0x81, 0x29, 0x4A, 0x4E, 0x99, 0x99, 0x97, 0x42, 0x99, + 0x99, 0x74, 0x20, 0x25, 0x09, 0x99, 0x9F, 0x90, 0x25, 0x09, 0x99, 0xF9, 0x22, 0x81, 0x15, 0x10, + 0x84, 0x20, 0x25, 0x09, 0x99, 0x97, 0x16, 0x50, 0x22, 0xA2, 0x10, 0x84, 0x24, 0x0F, 0x12, 0x48, + 0xF0, 0x24, 0x0F, 0x24, 0x8F, 0x20, 0xF1, 0x24, 0x8F, 0x20, 0xF2, 0x48, 0xF0, 0x52, 0x0F, 0x12, + 0x48, 0xF0, 0x52, 0x0F, 0x24, 0x8F, 0x34, 0xC4, 0x44, 0x40, 0xE2, 0x1C, 0x94, 0xA5, 0xC0, 0xF2, + 0x5C, 0x94, 0xB8, 0xE5, 0x21, 0xE8, 0xC6, 0x3E, 0xEA, 0x8E, 0x99, 0x9E, 0x43, 0x07, 0x91, 0x45, + 0x17, 0x80, 0x46, 0x1C, 0x94, 0xA5, 0xC0, 0xE1, 0x11, 0x1E, 0x0B, 0xA1, 0x08, 0x41, 0xC0, 0x78, + 0x88, 0x70, 0x72, 0x7A, 0x94, 0xB8, 0x7A, 0x92, 0x49, 0x24, 0xE0, 0x39, 0x42, 0xF8, 0xC6, 0x6D, + 0x75, 0x17, 0x99, 0xB5, 0x7C, 0x4E, 0x10, 0xBC, 0xE1, 0xF9, 0x60, 0x69, 0x48, 0x96, 0x7A, 0x1C, + 0x84, 0x22, 0x00, 0x34, 0x74, 0x44, 0x48, 0x77, 0x16, 0x05, 0x89, 0x0E, 0x00, 0x8A, 0x94, 0x42, + 0x29, 0x44, 0x84, 0x33, 0x5A, 0xC8, 0x92, 0x49, 0x50, 0xE3, 0xA4, 0x97, 0x95, 0x71, 0x49, 0x48, + 0x68, 0x9A, 0xE9, 0x90, 0xC9, 0x74, 0x90, 0x4E, 0x66, 0x99, 0xAD, 0x6B, 0x57, 0x80, 0x4B, 0x5E, + 0xB4, 0xC0, 0xE9, 0x99, 0x10, 0x69, 0xF9, 0x60, 0x04, 0x16, 0xA4, 0x92, 0x49, 0x18, 0x04, 0x10, + 0x98, 0x92, 0x49, 0x18, 0x09, 0x99, 0x65, 0x96, 0x56, 0x40, 0x09, 0x99, 0x65, 0x95, 0x90, 0xFA, + 0x92, 0x4E, 0x20, 0x80, 0x0C, 0x5E, 0x24, 0x92, 0x4E, 0x20, 0x80, 0x87, 0x25, 0x2E, 0x52, 0x41, + 0xE1, 0x68, 0x87, 0xE1, 0x68, 0x70, 0xFA, 0x48, 0x88, 0xFC, 0x45, 0x18, 0x42, 0x10, 0x83, 0x5D, + 0x24, 0x50, 0xF9, 0x48, 0x42, 0x10, 0x2B, 0xA4, 0x88, 0xFD, 0x48, 0x42, 0x10, 0x0C, 0xE5, 0x29, + 0x49, 0xC0, 0x0C, 0xE5, 0x29, 0x38, 0xDA, 0xA3, 0x18, 0xC5, 0xC0, 0x94, 0x63, 0x18, 0xC5, 0xC0, + 0x06, 0x35, 0x08, 0x20, 0x82, 0x00, 0x0C, 0xE5, 0x29, 0x38, 0x4C, 0xF0, 0xBE, 0x88, 0x78, 0xF2, + 0xF8, 0xF0, 0xF1, 0x21, 0x96, 0xF8, 0x48, 0x96, 0xF8, 0x48, 0x96, 0x78, 0x21, 0x02, 0x4A, 0xC4, + 0x4E, 0x74, 0x43, 0xF2, 0x23, 0xE0, 0xFA, 0x1C, 0x10, 0xC5, 0xC0, 0xFA, 0x1C, 0x18, 0xB8, 0x23, + 0x88, 0x20, 0xC5, 0xC0, 0xAD, 0x9A, 0xC8, 0x80, 0x24, 0x92, 0x49, 0x24, 0xB6, 0xDB, 0x6D, 0xB4, + 0x21, 0x09, 0xF2, 0x13, 0xE4, 0x21, 0x00, 0x24, 0x92, 0x08, 0x14, 0x2C, 0x2F, 0xA6, 0xAA, 0xAC, + 0xDC, 0x17, 0x2A, 0x2F, 0xA6, 0xAB, 0x37, 0x20, 0xD2, 0x88, 0x7E, 0x9A, 0xAC, 0x7C, 0x8C, 0x63, + 0x18, 0xD7, 0x40, 0x8C, 0x27, 0x18, 0xC7, 0xA1, 0x30, 0xCA, 0x16, 0x94, 0xA5, 0xA1, 0x30, 0xAD, + 0x7B, 0xDE, 0xD6, 0xC0, 0xAD, 0x3F, 0xDE, 0xD6, 0xA1, 0x30, 0x08, 0x37, 0x5A, 0xD6, 0xA1, 0x30, + 0x96, 0x06, 0x9F, 0x99, 0x96, 0x07, 0x99, 0xB5, 0xA8, 0x74, 0x97, 0xA8, 0x64, 0x92, 0x96, 0x06, + 0x99, 0x96, 0x96, 0x06, 0x99, 0x60, 0x52, 0x99, 0x99, 0x97, 0x96, 0x09, 0x99, 0x70, 0xF2, 0x99, + 0x99, 0x97, 0xF6, 0x09, 0x99, 0x97, 0x24, 0xD9, 0x99, 0x97, 0x26, 0x09, 0x99, 0x97, 0x52, 0xD9, + 0x99, 0x97, 0x52, 0x50, 0x99, 0x97, 0x42, 0xB9, 0x99, 0x97, 0x46, 0x09, 0x99, 0x97, 0xE1, 0xF9, + 0x60, 0xFA, 0x06, 0x9F, 0x99, 0xFA, 0x07, 0x99, 0xB5, 0xF6, 0x06, 0x9F, 0x99, 0xF6, 0x07, 0x99, + 0xB5, 0x70, 0x1F, 0x4A, 0x7A, 0x94, 0xB8, 0x70, 0x1D, 0x5B, 0xD1, 0xE0, 0x78, 0x8B, 0xB7, 0x79, + 0x9B, 0x75, 0x60, 0x52, 0x07, 0x8B, 0x97, 0x52, 0x07, 0x99, 0xB5, 0x16, 0x9A, 0xCA, 0x99, 0x88, + 0x9A, 0xE9, 0x90, 0x69, 0x99, 0x96, 0x30, 0x69, 0x99, 0x63, 0x52, 0x69, 0x99, 0x96, 0x30, 0x52, + 0x06, 0x99, 0x96, 0x30, 0x52, 0xF1, 0x21, 0x96, 0x52, 0x0F, 0x12, 0x19, 0x60, 0x52, 0x06, 0x22, + 0x22, 0x2C, 0xDE, 0x9A, 0xAA, 0xAA, 0xCD, 0xC0, 0xC2, 0x8B, 0xE9, 0xAA, 0xCD, 0xC0, 0x20, 0x87, + 0xE9, 0xAA, 0xC7, 0xC0, 0x52, 0x78, 0x8B, 0x97, 0x52, 0x07, 0x99, 0xB5, 0x16, 0xA5, 0x2B, 0xDA, + 0xD6, 0x40, 0xB6, 0x63, 0x2A, 0x62, 0x00, 0x42, 0x9D, 0xDB, 0xB9, 0x42, 0x0E, 0x99, 0x99, 0x6C, + 0x46, 0x9F, 0x99, 0x6C, 0x47, 0x99, 0xB5, 0x10, 0x87, 0xE4, 0x9B, 0xC9, 0x27, 0x11, 0x1D, 0x5B, + 0xD1, 0x60, 0x08, 0x53, 0x56, 0x59, 0xA6, 0xAC, 0x08, 0x50, 0x0D, 0x59, 0xE6, 0xAC, 0xA5, 0x69, + 0x9F, 0x99, 0xA5, 0x07, 0x99, 0xB5, 0x69, 0x69, 0x9F, 0x99, 0x69, 0x07, 0x99, 0xB5, 0xA5, 0xF8, + 0xE8, 0x8F, 0xA5, 0x06, 0x9F, 0x87, 0x69, 0xF8, 0xE8, 0x8F, 0x69, 0x69, 0xF8, 0x70, 0xA5, 0xE4, + 0x44, 0x4E, 0xA5, 0x06, 0x22, 0x22, 0x74, 0x40, 0xE2, 0x10, 0x8E, 0x69, 0x00, 0x62, 0x22, 0xA2, + 0x80, 0x64, 0xA5, 0x29, 0x30, 0xA2, 0x80, 0x64, 0xA5, 0x26, 0x69, 0x06, 0x99, 0x99, 0x60, 0x69, + 0x06, 0x99, 0x96, 0xA5, 0xE9, 0x9E, 0x99, 0xA5, 0x0B, 0xC8, 0x88, 0x69, 0xE9, 0x9E, 0x99, 0x69, + 0x0B, 0xC8, 0x88, 0xA5, 0x89, 0x99, 0x97, 0xA5, 0x09, 0x99, 0x97, 0x69, 0x09, 0x99, 0x97, 0x69, + 0x00, 0x99, 0x97, 0x78, 0x61, 0x1E, 0x40, 0x78, 0x61, 0xE4, 0xF9, 0x08, 0x42, 0x11, 0x00, 0x5D, + 0x24, 0xE0, 0x69, 0x16, 0x91, 0x68, 0x61, 0x69, 0x2C, 0x52, 0x99, 0xF9, 0x99, 0x5A, 0x8E, 0x99, + 0x99, 0xB6, 0x63, 0x18, 0xC4, 0x21, 0x10, 0x47, 0x24, 0x92, 0x67, 0x4A, 0x30, 0x53, 0x17, 0x46, + 0x2E, 0x54, 0x62, 0xE8, 0xC5, 0xC0, 0xF0, 0x88, 0x88, 0x78, 0x20, 0xF1, 0x11, 0x0F, 0x04, 0x20, + 0x69, 0x9F, 0x99, 0x20, 0x79, 0x9B, 0x50, 0xF8, 0xE8, 0x8F, 0x40, 0x69, 0xF8, 0x78, 0xF0, 0x96, + 0x99, 0x96, 0xF0, 0x90, 0x69, 0x96, 0x75, 0xA6, 0x99, 0x99, 0x60, 0xE5, 0xA0, 0x69, 0x99, 0x60, + 0x20, 0x69, 0x99, 0x96, 0x20, 0x06, 0x99, 0x96, 0xF0, 0x26, 0x99, 0x96, 0xF0, 0x20, 0x69, 0x96, + 0x70, 0x22, 0xA2, 0x10, 0x84, 0x60, 0x99, 0x99, 0x71, 0x60, 0x61, 0x08, 0x43, 0x34, 0xC0, 0xB3, + 0x28, 0xA6, 0xB4, 0x20, 0x42, 0x3C, 0x85, 0x35, 0xC0, 0x64, 0x92, 0x70, 0x21, 0x1D, 0x5A, 0xD5, + 0xC0, 0x75, 0x6B, 0x57, 0x10, 0x80, 0x09, 0x96, 0xD7, 0xA7, 0x20, 0x09, 0xD4, 0xC6, 0x62, 0xE0, + 0x09, 0xD4, 0xC4, 0x5C, 0x41, 0x04, 0x3C, 0x41, 0x07, 0xC0, 0x0F, 0xCC, 0x66, 0x32, 0x94, 0x74, + 0x18, 0x2E, 0x24, 0xC0, 0xF2, 0x48, 0xF4, 0x30, 0x74, 0x42, 0x22, 0x10, 0x80, 0x74, 0x42, 0x22, + 0x10, 0x71, 0x37, 0x1A, 0xC9, 0xC0, 0x99, 0xF9, 0x96, 0x66, 0x69, 0x99, 0x0F, 0xE5, 0x2F, 0x53, + 0x1F, 0x84, 0x00, 0x08, 0x44, 0xEA, 0xFF, 0x0E, 0x84, 0x00, 0x08, 0x20, 0x87, 0x0A, 0x27, 0x00, + 0x10, 0x0C, 0x23, 0x88, 0x42, 0x60, 0x74, 0xA5, 0x29, 0x38, 0x41, 0x74, 0xA5, 0x27, 0x08, 0x20, + 0x72, 0x53, 0xE4, 0xA4, 0x5F, 0x10, 0x84, 0x00, 0x8B, 0x94, 0x42, 0x10, 0x9F, 0x99, 0x71, 0x60, + 0xAD, 0x99, 0xE0, 0x5B, 0x9B, 0x50, 0xAD, 0x9D, 0xA0, 0x88, 0xE9, 0x99, 0xE0, 0x69, 0x89, 0x60, + 0x69, 0x8B, 0x60, 0x10, 0x9D, 0x29, 0x59, 0x41, 0x08, 0x84, 0xE9, 0x4A, 0xCA, 0x69, 0xF1, 0xE0, + 0xE1, 0xF9, 0x60, 0xE8, 0xBD, 0x26, 0x00, 0x78, 0x68, 0x87, 0xE1, 0x61, 0x1E, 0xE8, 0x98, 0x21, + 0x70, 0x69, 0xA9, 0x96, 0x62, 0x36, 0x22, 0xC0, 0x0B, 0xA5, 0x2B, 0x28, 0x4C, 0x79, 0x9B, 0x51, + 0x60, 0x78, 0xB9, 0x70, 0x99, 0x96, 0x69, 0x60, 0x85, 0x24, 0x8C, 0x31, 0x23, 0x00, 0x99, 0x99, + 0x71, 0x10, 0x68, 0x8E, 0x99, 0x99, 0x68, 0x8E, 0x99, 0x99, 0x20, 0x43, 0x2E, 0x90, 0xC9, 0x24, + 0x40, 0xE9, 0x2E, 0x61, 0x08, 0xDB, 0x10, 0x80, 0x62, 0x6A, 0x72, 0x20, 0xC9, 0x24, 0x91, 0x87, + 0xE3, 0x2B, 0x45, 0x26, 0x99, 0x9F, 0x90, 0x99, 0x9F, 0x91, 0x9F, 0xB9, 0x92, 0x72, 0x52, 0x94, + 0xC0, 0xE4, 0xA5, 0x29, 0x04, 0x9D, 0xB9, 0x90, 0x69, 0xF9, 0x60, 0x7A, 0x59, 0xE4, 0x7C, 0x74, + 0x63, 0x5D, 0x80, 0x23, 0xAB, 0x5A, 0xB8, 0x80, 0x11, 0x13, 0xD0, 0x11, 0x11, 0x3D, 0x10, 0x84, + 0x6D, 0x04, 0xBC, 0x88, 0x88, 0xBC, 0x88, 0x88, 0x40, 0x69, 0x88, 0x80, 0x69, 0x11, 0x10, 0x79, + 0x97, 0x99, 0x99, 0xE9, 0x9E, 0x78, 0x61, 0xE8, 0x40, 0x19, 0x08, 0x42, 0x10, 0x98, 0x19, 0x08, + 0xE2, 0x10, 0x98, 0x89, 0x24, 0x88, 0x11, 0x08, 0x42, 0x32, 0xC9, 0xC2, 0x27, 0x22, 0x20, 0x9F, + 0x99, 0x70, 0x96, 0x99, 0x70, 0xA9, 0x96, 0x60, 0x66, 0x99, 0x90, 0x30, 0xC4, 0x92, 0x84, 0x9F, + 0xB9, 0x90, 0x68, 0xE9, 0x99, 0x90, 0x8A, 0x88, 0x40, 0xF2, 0x48, 0xF1, 0x20, 0xF2, 0x4A, 0xF2, + 0xF2, 0x42, 0x19, 0x60, 0xF2, 0x42, 0x5B, 0x60, 0x74, 0x42, 0x22, 0x10, 0x80, 0x74, 0x60, 0x82, + 0x10, 0x80, 0x21, 0x08, 0x20, 0xC5, 0xC0, 0x78, 0x88, 0x88, 0x70, 0x74, 0x6B, 0x17, 0x00, 0xE9, + 0xE9, 0xE0, 0x69, 0x79, 0x60, 0x0B, 0xA1, 0x69, 0x38, 0x99, 0xF9, 0x90, 0x20, 0x62, 0x22, 0x7A, + 0xC0, 0x99, 0x75, 0x91, 0x10, 0x88, 0x88, 0xF0, 0x0B, 0xA5, 0x29, 0x38, 0x42, 0x74, 0x42, 0x22, + 0x38, 0x80, 0x74, 0x60, 0x82, 0x38, 0x80, 0x20, 0x87, 0xE9, 0xAA, 0xC7, 0xC0, 0x20, 0x87, 0xE9, + 0xAA, 0x97, 0x42, 0x20, 0x87, 0xE9, 0xAA, 0xD7, 0xC4, 0x43, 0xF6, 0x16, 0x44, 0xE0, 0x0A, 0xBC, + 0xA5, 0x28, 0xC2, 0x20, 0x43, 0xB5, 0x16, 0x54, 0xF1, 0x00, 0x21, 0x04, 0x3E, 0x55, 0x55, 0x42, + 0x10, 0x84, 0x2F, 0x8B, 0x47, 0xC0, 0x84, 0x3F, 0x2A, 0x63, 0xE0, 0xAD, 0x55, 0x5A, 0xA8, 0xFC, + 0x41, 0xF8, 0x80, 0x8A, 0x52, 0x93, 0x84, 0x20, 0x89, 0x24, 0x92, 0x38, 0x20, 0x81, 0x2A, 0x22, + 0x88, 0xA8, 0x8A, 0x88, 0x22, 0xA2, 0x22, 0xA2, 0x8A, 0x88, 0xC0, 0xC0, 0x60, 0x90, 0xE8, 0x05, + 0xC0, 0xE8, 0x8B, 0x80, 0xF0, 0x55, 0x00, 0x64, 0x6D, 0x80, 0x55, 0xA0, 0x8A, 0x88, 0xA2, 0x00, + 0x72, 0x24, 0xDE, 0xD3, 0x20, 0x90, 0x60, 0x69, 0x6D, 0x80, 0xE0, 0xF8, 0x96, 0x80, 0xA0, 0xC5, + 0x00, 0x55, 0x00, 0x4C, 0x80, 0xA8, 0xC0, 0xB4, 0x24, 0x5C, 0x74, 0x40, 0x6C, 0xD8, 0xE4, 0xD8, + 0xF1, 0x10, 0x64, 0x5D, 0x00, 0xE0, 0xA0, 0x25, 0x20, 0xF4, 0xA8, 0x5A, 0x21, 0x20, 0xBC, 0x75, + 0x70, 0x55, 0x40, 0xF6, 0xE8, 0x8B, 0x80, 0x74, 0x40, 0x88, 0x8F, 0x88, 0x80, 0x82, 0x10, 0xF4, + 0x20, 0xFD, 0x48, 0x42, 0x10, 0xFD, 0x48, 0x42, 0x00, 0x78, 0x36, 0xC0, 0x9B, 0xBD, 0xD9, 0x95, + 0xB5, 0x29, 0x04, 0x10, 0xC0, 0x74, 0x43, 0x17, 0x00, 0x74, 0x69, 0x17, 0x00, 0x74, 0x4B, 0x17, + 0x00, 0x51, 0x60, 0xFF, 0xFF, 0xC0, 0xFF, 0xFE, 0xAA, 0xAA, 0x78, 0x12, 0xD2, 0x00, 0x24, 0x06, + 0x99, 0xF9, 0x90, 0xF0, 0x24, 0xF8, 0xE8, 0x8F, 0x22, 0x72, 0xF4, 0xA5, 0x20, 0x27, 0xA2, 0x22, + 0x70, 0x44, 0x0C, 0x94, 0xA5, 0x26, 0x45, 0x4A, 0x51, 0x08, 0x40, 0x44, 0x1D, 0x18, 0xC5, 0x5B, + 0x12, 0xD2, 0x00, 0x20, 0x82, 0x08, 0x20, 0x69, 0x9F, 0x99, 0xE9, 0xE9, 0x9E, 0xF8, 0x88, 0x80, + 0x21, 0x14, 0xA8, 0xFC, 0xF8, 0xE8, 0x8F, 0xF1, 0x24, 0x8F, 0x99, 0xF9, 0x99, 0x69, 0xBD, 0x96, + 0xE9, 0x25, 0xC0, 0x9A, 0xCA, 0x99, 0x21, 0x14, 0xA8, 0xC4, 0x9F, 0xB9, 0x99, 0x9D, 0xDB, 0xB9, + 0xF8, 0x1C, 0x0F, 0x80, 0x69, 0x99, 0x96, 0x7A, 0x52, 0x94, 0xA4, 0xE9, 0x9E, 0x88, 0xFA, 0x08, + 0x88, 0x7C, 0xF9, 0x08, 0x42, 0x10, 0x8C, 0x54, 0x42, 0x10, 0x23, 0xAB, 0x5A, 0xB8, 0x80, 0x99, + 0x69, 0x99, 0xAD, 0x6A, 0xE2, 0x10, 0x74, 0x63, 0x15, 0x6C, 0xA1, 0x24, 0x90, 0x50, 0x23, 0x15, + 0x10, 0x84, 0x24, 0x07, 0x99, 0xB5, 0x24, 0x07, 0x8E, 0x87, 0x24, 0x0E, 0x99, 0x99, 0x10, 0x28, + 0x64, 0x92, 0x12, 0xD2, 0x12, 0x49, 0x24, 0x8E, 0x79, 0x9B, 0x50, 0x69, 0xA9, 0x9A, 0x80, 0x99, + 0x56, 0x55, 0x20, 0xF8, 0x69, 0x99, 0x60, 0x78, 0xE8, 0x70, 0xF2, 0x48, 0x87, 0x20, 0xE9, 0x99, + 0x91, 0x69, 0xF9, 0x60, 0xD5, 0x40, 0x9A, 0xE9, 0x90, 0x88, 0x46, 0x69, 0x90, 0x99, 0x99, 0xE8, + 0x99, 0x96, 0x60, 0x69, 0x48, 0x86, 0x12, 0x69, 0x99, 0x60, 0xFA, 0x94, 0xA5, 0x00, 0x69, 0x99, + 0xE8, 0x88, 0x78, 0x88, 0x72, 0x40, 0x7C, 0xA5, 0x26, 0x00, 0xF4, 0x44, 0x20, 0xA9, 0x99, 0x60, + 0xB5, 0x6B, 0x57, 0x10, 0x80, 0x99, 0x69, 0x90, 0xAD, 0x6A, 0xE2, 0x10, 0x80, 0x54, 0x6B, 0x55, + 0x00, 0xA3, 0x24, 0x90, 0x50, 0x12, 0x94, 0xA4, 0xE0, 0x12, 0x06, 0x99, 0x96, 0x11, 0x00, 0x94, + 0xA5, 0x27, 0x22, 0x00, 0xA8, 0xD7, 0x71, 0x9A, 0xCA, 0x99, 0x20, 0x69, 0xE9, 0x96, 0x31, 0x25, + 0xFA, 0x48, 0xC0, 0x8A, 0x35, 0x08, 0x20, 0x80, 0x42, 0x28, 0xD4, 0x20, 0x82, 0x00, 0x50, 0x08, + 0xA3, 0x50, 0x82, 0x08, 0x21, 0x1D, 0x5A, 0xD5, 0xC4, 0x20, 0xFC, 0x6B, 0xB8, 0x80, 0x94, 0x99, + 0x29, 0x90, 0x74, 0x63, 0x18, 0xB8, 0x84, 0x74, 0x63, 0x17, 0x10, 0x80, 0x74, 0x21, 0x08, 0x38, + 0x26, 0x74, 0x21, 0x07, 0x04, 0xC0, 0xF8, 0xF9, 0x88, 0xF8, 0xEA, 0x88, 0x81, 0x25, 0x9A, 0x48, + 0x10, 0x2A, 0x72, 0xA0, 0x42, 0x69, 0x35, 0x10, 0x42, 0x69, 0x35, 0x10, 0xAD, 0x6B, 0x55, 0x87, + 0xC0, 0xAD, 0x6A, 0xB0, 0xF8, 0x59, 0x97, 0x11, 0x10, 0x59, 0x97, 0x11, 0x88, 0x8E, 0x99, 0x91, + 0xE0, 0x13, 0xAA, 0x90, 0xBA, 0x00, 0xE1, 0x68, 0x96, 0xE1, 0x68, 0x87, 0x55, 0x48, 0xA5, 0x47, + 0xE0, 0x55, 0x48, 0xA7, 0x00, 0x78, 0xE9, 0x99, 0x60, 0x68, 0xA9, 0x96, 0x23, 0xAA, 0x42, 0x10, + 0x31, 0x3F, 0x42, 0x30, 0x9A, 0x65, 0x90, 0x69, 0x99, 0xE8, 0x61, 0x78, 0x88, 0x70, 0x21, 0x92, + 0x49, 0xC0, 0x69, 0xBD, 0x96, 0x78, 0xE8, 0x70, 0xE1, 0x71, 0xE0, 0x8E, 0x99, 0xE8, 0x8E, 0x99, + 0xE8, 0x80, 0x78, 0x88, 0x87, 0x9F, 0xB9, 0x99, 0x9F, 0xB9, 0x88, 0x69, 0x99, 0xE8, 0xC8, 0x74, + 0x42, 0x18, 0xB8, 0x78, 0xA8, 0x87, 0xE1, 0x51, 0x1E, 0x42, 0xF8, 0xE8, 0x8F, 0xA0, 0xF8, 0xE8, + 0x8F, 0xE2, 0x1C, 0x94, 0xA5, 0x22, 0x24, 0xF8, 0x88, 0x88, 0x69, 0xC8, 0x96, 0x3A, 0x0C, 0x10, + 0xB8, 0x24, 0x92, 0x40, 0xA9, 0x24, 0x90, 0x38, 0x42, 0x14, 0x98, 0x31, 0x45, 0x95, 0x56, 0x60, + 0xA5, 0x3D, 0x5A, 0xD8, 0xF2, 0x1C, 0x94, 0xA5, 0x20, 0x13, 0x54, 0xC5, 0x25, 0x20, 0x42, 0x9B, + 0xBD, 0xD9, 0x52, 0x99, 0x97, 0x16, 0x8C, 0x63, 0x1F, 0x90, 0x80, 0x69, 0x9F, 0x99, 0x72, 0x1C, + 0x94, 0xA5, 0xC0, 0xE9, 0xE9, 0x9E, 0xF8, 0x88, 0x88, 0x32, 0x94, 0xA5, 0x7E, 0x20, 0xF8, 0xE8, + 0x8F, 0xAD, 0x5D, 0x5A, 0xD4, 0x69, 0x21, 0x96, 0x4A, 0xD6, 0xD6, 0xA4, 0x52, 0x9B, 0xBD, 0xD9, + 0x9A, 0xCA, 0x99, 0x75, 0x55, 0x59, 0x9F, 0xB9, 0x99, 0x99, 0xF9, 0x99, 0x69, 0x99, 0x96, 0x7A, + 0x52, 0x94, 0xA4, 0x72, 0x52, 0xE4, 0x20, 0x78, 0x88, 0x87, 0xE9, 0x24, 0x80, 0x99, 0x96, 0x2C, + 0x23, 0xAB, 0x5A, 0xD5, 0xC4, 0x99, 0x69, 0x99, 0xAA, 0xAA, 0xAF, 0x10, 0x99, 0x71, 0x11, 0xAD, + 0x6B, 0x5A, 0xF8, 0xAA, 0xAA, 0xAA, 0xA9, 0xF0, 0x40, 0xC4, 0x46, 0x56, 0x4A, 0x52, 0xD5, 0xB4, + 0x42, 0x10, 0xE4, 0xB8, 0x69, 0x31, 0x96, 0x95, 0x7B, 0x5A, 0xC8, 0x79, 0x97, 0x99, 0x79, 0x9B, + 0x50, 0x16, 0x8E, 0x99, 0xE0, 0xE9, 0xE9, 0xE0, 0xF8, 0x88, 0x80, 0x32, 0x94, 0xA5, 0x7E, 0x20, + 0x69, 0xF8, 0x70, 0xAD, 0x5D, 0x5A, 0x80, 0x69, 0x29, 0x60, 0x9B, 0xFD, 0x90, 0x52, 0x9B, 0xFD, + 0x90, 0x9A, 0xE9, 0x90, 0x75, 0x55, 0x90, 0x9F, 0xB9, 0x90, 0x99, 0xF9, 0x90, 0x69, 0x99, 0x60, + 0xF9, 0x99, 0x90, 0xE9, 0x99, 0xE8, 0x80, 0x78, 0x88, 0x70, 0xE9, 0x24, 0x99, 0x96, 0x2C, 0x23, + 0xAB, 0x5A, 0xB8, 0x80, 0x99, 0x69, 0x90, 0xAA, 0xAA, 0xF1, 0x99, 0x71, 0x10, 0xAD, 0x6B, 0x5F, + 0x00, 0xAA, 0xAA, 0xAA, 0x7C, 0x10, 0xC4, 0x65, 0x60, 0x99, 0xDB, 0xD0, 0x88, 0xE9, 0xE0, 0x69, + 0x39, 0x60, 0x95, 0x7B, 0x59, 0x00, 0x79, 0x79, 0x90, 0x42, 0x06, 0x9F, 0x87, 0xA0, 0x69, 0xF8, + 0x70, 0x47, 0x10, 0xE4, 0xA4, 0x40, 0x24, 0xF8, 0x88, 0x80, 0x69, 0xC9, 0x60, 0x78, 0x61, 0xE0, + 0x4D, 0x54, 0xA3, 0x24, 0x90, 0x21, 0x92, 0x49, 0xC0, 0x31, 0x45, 0x95, 0x98, 0xA5, 0x3D, 0x5B, + 0x00, 0x42, 0x3C, 0x87, 0x25, 0x20, 0x12, 0x09, 0xAE, 0x99, 0x42, 0x09, 0xBB, 0xD9, 0x96, 0x09, + 0x99, 0x97, 0x16, 0x8C, 0x63, 0x17, 0x10, 0x80, 0xAD, 0x6B, 0x5A, 0xD5, 0x40, 0xAD, 0x6B, 0x55, + 0x00, 0x47, 0x10, 0xE4, 0xA5, 0xC0, 0x47, 0x10, 0xE4, 0xB8, 0x95, 0x69, 0xEA, 0x56, 0x40, 0x9D, + 0x3D, 0x49, 0x80, 0x21, 0x14, 0xEA, 0xD4, 0x22, 0x9D, 0x5A, 0x80, 0xA5, 0x35, 0xEA, 0xD4, 0xA6, + 0xBD, 0x5A, 0x80, 0xFC, 0x54, 0xEA, 0xD6, 0xA0, 0x72, 0x88, 0xEA, 0x80, 0xF6, 0xA9, 0xEA, 0xD6, + 0xA0, 0xF6, 0xA9, 0xEA, 0x80, 0xA4, 0x0E, 0x16, 0x11, 0x68, 0x60, 0xA4, 0x0E, 0x16, 0x16, 0x84, + 0xAD, 0x6B, 0x57, 0x10, 0x80, 0xAD, 0x6A, 0xE2, 0x10, 0x69, 0x9F, 0x99, 0x60, 0x69, 0xF9, 0x60, + 0x94, 0xE4, 0xC6, 0x30, 0x94, 0xE4, 0xC6, 0x00, 0x50, 0xA4, 0x93, 0x48, 0xC3, 0x0C, 0xA2, 0xA5, + 0x39, 0x31, 0x80, 0x45, 0x29, 0x5A, 0xD5, 0x61, 0x30, 0x45, 0x6B, 0x55, 0x84, 0xC0, 0x23, 0xAB, + 0x18, 0xC6, 0xAE, 0x20, 0x23, 0xAB, 0x1A, 0xB8, 0x80, 0x64, 0x82, 0x48, 0xC6, 0x35, 0x50, 0x64, + 0x82, 0x48, 0xC6, 0xAA, 0xFD, 0x41, 0x5A, 0xD6, 0xB5, 0x50, 0xFD, 0x41, 0x5A, 0xD6, 0xAA, 0x78, + 0x88, 0x86, 0x27, 0x78, 0x88, 0x62, 0x70, 0x11, 0x63, 0xC6, 0x88, 0x16, 0x80, 0x1F, 0x06, 0x99, + 0x96, 0x2D, 0x06, 0x99, 0x96, 0x44, 0x06, 0x99, 0x96, 0x22, 0x06, 0x99, 0x96, 0x4B, 0x06, 0x99, + 0x96, 0x14, 0x82, 0xFD, 0x05, 0x40, 0x14, 0x82, 0x8D, 0x05, 0x40, 0x51, 0x25, 0x6B, 0x6B, 0x53, + 0x10, 0x51, 0x25, 0x6F, 0x6A, 0x62, 0x47, 0x10, 0xE4, 0xB8, 0xE4, 0xAD, 0xE8, 0xC0, 0xE4, 0xA5, + 0x6F, 0x46, 0x00, 0x1F, 0x88, 0x88, 0x80, 0x1F, 0x88, 0x88, 0x7A, 0x3C, 0x84, 0x20, 0x7A, 0x3C, + 0x84, 0x00, 0xE8, 0xE9, 0x99, 0x20, 0xC8, 0xE9, 0x92, 0xAA, 0xA7, 0x2A, 0xAA, 0xB0, 0x40, 0xAA, + 0xA7, 0x2A, 0xAC, 0x10, 0x69, 0x21, 0x96, 0x24, 0x69, 0x29, 0x62, 0x40, 0x95, 0x31, 0x49, 0x4C, + 0x20, 0x95, 0x39, 0x29, 0x84, 0x9A, 0xEF, 0x99, 0x9E, 0xED, 0x90, 0x4F, 0x98, 0xA4, 0xA4, 0x4F, + 0x9C, 0x94, 0x80, 0xCA, 0x98, 0xA4, 0xA4, 0xCA, 0x9C, 0x94, 0x80, 0x94, 0xBD, 0x29, 0x4C, 0x20, + 0x94, 0xBD, 0x29, 0x84, 0x9C, 0xBD, 0x29, 0x48, 0x9C, 0xBD, 0x29, 0x00, 0xF2, 0x49, 0x24, 0x9A, + 0x50, 0x42, 0xF2, 0x49, 0x26, 0x94, 0x10, 0x80, 0x44, 0xAB, 0x5A, 0xB8, 0x60, 0x44, 0xAB, 0x57, + 0x0C, 0x78, 0x88, 0x87, 0x26, 0x78, 0x88, 0x72, 0x60, 0xF9, 0x08, 0x42, 0x18, 0x40, 0xF9, 0x08, + 0x43, 0x08, 0x8A, 0x88, 0x42, 0x10, 0x8A, 0x88, 0x42, 0x10, 0x8A, 0x88, 0xE2, 0x10, 0x8A, 0x88, + 0xE2, 0x10, 0x94, 0x99, 0x29, 0x4C, 0x20, 0x94, 0x99, 0x29, 0x84, 0xE9, 0x24, 0x92, 0x49, 0xF0, + 0x40, 0xE9, 0x24, 0x92, 0x7C, 0x10, 0x94, 0x9C, 0x21, 0x1C, 0x20, 0x94, 0x9C, 0x23, 0x04, 0x99, + 0xF5, 0x11, 0x99, 0xF5, 0x10, 0x88, 0xAD, 0x99, 0x90, 0x88, 0xE9, 0x99, 0x90, 0x32, 0x72, 0xF4, + 0x1C, 0x36, 0x5E, 0x83, 0x80, 0x32, 0x72, 0xF4, 0x1C, 0x40, 0x36, 0x5E, 0x83, 0x88, 0x24, 0x92, + 0x40, 0x51, 0x23, 0x57, 0x56, 0xB5, 0x51, 0x01, 0x5A, 0xBA, 0xB5, 0x9A, 0xCA, 0x99, 0x20, 0x9A, + 0xE9, 0x92, 0x72, 0x94, 0xA5, 0x4C, 0x20, 0x72, 0x94, 0xA9, 0x84, 0x99, 0xF9, 0x99, 0x20, 0x99, + 0xF9, 0x92, 0x94, 0xBD, 0x29, 0x4C, 0x40, 0x94, 0xBD, 0x29, 0x88, 0x99, 0x71, 0x13, 0x20, 0x99, + 0x71, 0x32, 0x97, 0xAD, 0x29, 0x4C, 0x40, 0x97, 0xAD, 0x29, 0x88, 0x49, 0x74, 0x80, 0x96, 0x69, + 0x9F, 0x99, 0x96, 0x07, 0x99, 0xB5, 0x90, 0x69, 0x9F, 0x99, 0x90, 0x79, 0x9B, 0x50, 0x7E, 0x49, + 0xBC, 0x92, 0x70, 0x75, 0x6F, 0x47, 0x80, 0x96, 0xF8, 0xE8, 0x8F, 0x96, 0x06, 0x9F, 0x87, 0xE1, + 0xF9, 0x96, 0xE1, 0xF9, 0x60, 0x90, 0xE1, 0xF9, 0x96, 0x90, 0xE1, 0xF9, 0x60, 0x50, 0x2B, 0x57, + 0x56, 0xB5, 0x50, 0x2B, 0x57, 0x56, 0xA0, 0x90, 0x69, 0x21, 0x96, 0x90, 0x69, 0x29, 0x60, 0x90, + 0x69, 0x21, 0x96, 0x90, 0x69, 0x29, 0x60, 0x60, 0x9B, 0xBD, 0xD9, 0x60, 0x9B, 0xFD, 0x90, 0x90, + 0x9B, 0xBD, 0xD9, 0x90, 0x9B, 0xFD, 0x90, 0x90, 0x69, 0x99, 0x96, 0x90, 0x69, 0x99, 0x60, 0x69, + 0xF9, 0x96, 0x69, 0xF9, 0x60, 0x90, 0x69, 0xF9, 0x96, 0x69, 0xF9, 0x60, 0x90, 0x69, 0x31, 0x96, + 0x90, 0x69, 0x39, 0x60, 0x70, 0x22, 0xA2, 0x10, 0x84, 0x70, 0x22, 0xA2, 0x10, 0x84, 0x90, 0x99, + 0x96, 0x2C, 0x90, 0x99, 0x96, 0x2C, 0x5B, 0x09, 0x96, 0x2C, 0x5A, 0x09, 0x96, 0x2C, 0x90, 0x99, + 0xF5, 0x11, 0x90, 0x99, 0xF5, 0x10, 0xF8, 0x88, 0x8C, 0x40, 0x7A, 0x38, 0x86, 0x10, 0x90, 0x99, + 0x9D, 0xBD, 0x90, 0x99, 0xDB, 0xD0, 0x7A, 0x38, 0x84, 0x30, 0x42, 0x7A, 0x38, 0x86, 0x08, 0x80, + 0x94, 0x99, 0x29, 0x44, 0x40, 0x97, 0x99, 0x28, 0x88, 0x99, 0x6F, 0x99, 0x99, 0x6F, 0x90, +}; + +static const GFXglyph lemonGlyphs[] PROGMEM = { + { 0, 0, 0, 5, 0, 0 }, // U+0020 char32 + { 0, 1, 6, 2, 0, -6 }, // U+0021 uni0021 + { 1, 3, 3, 4, 0, -7 }, // U+0022 uni0022 + { 3, 5, 5, 6, 0, -5 }, // U+0023 uni0023 + { 7, 4, 7, 5, 0, -7 }, // U+0024 uni0053 + { 11, 4, 6, 5, 0, -6 }, // U+0025 uni0025 + { 14, 5, 6, 6, 0, -6 }, // U+0026 uni0026 + { 18, 1, 3, 2, 0, -7 }, // U+0027 uni0027 + { 19, 3, 8, 4, 0, -7 }, // U+0028 uni0028 + { 22, 3, 8, 4, 0, -7 }, // U+0029 uni0028 + { 25, 4, 5, 5, 0, -5 }, // U+002A uni002A + { 28, 5, 5, 6, 0, -6 }, // U+002B uni002B + { 32, 2, 3, 3, 0, -2 }, // U+002C uni002C + { 33, 4, 1, 5, 0, -4 }, // U+002D uni002D + { 34, 1, 2, 2, 0, -2 }, // U+002E uni002E + { 35, 4, 8, 5, 0, -7 }, // U+002F uni002F + { 39, 4, 6, 5, 0, -6 }, // U+0030 uni0030 + { 42, 2, 6, 3, 0, -6 }, // U+0031 uni0031 + { 44, 4, 6, 5, 0, -6 }, // U+0032 uni0032 + { 47, 4, 6, 5, 0, -6 }, // U+0033 uni0033 + { 50, 4, 6, 5, 0, -6 }, // U+0034 uni0034 + { 53, 4, 6, 5, 0, -6 }, // U+0035 uni0035 + { 56, 4, 6, 5, 0, -6 }, // U+0036 uni0036 + { 59, 4, 6, 5, 0, -6 }, // U+0037 uni0037 + { 62, 4, 6, 5, 0, -6 }, // U+0038 uni0038 + { 65, 4, 6, 5, 0, -6 }, // U+0039 uni0039 + { 68, 1, 5, 2, 0, -5 }, // U+003A uni003A + { 69, 2, 6, 3, 0, -5 }, // U+003B uni003B + { 71, 3, 5, 4, 0, -5 }, // U+003C uni003C + { 73, 3, 3, 4, 0, -5 }, // U+003D uni003D + { 75, 3, 5, 4, 0, -5 }, // U+003E uni003E + { 77, 3, 6, 4, 0, -6 }, // U+003F uni003F + { 80, 4, 6, 5, 0, -6 }, // U+0040 uni0040 + { 83, 4, 6, 5, 0, -6 }, // U+0041 uni0041 + { 86, 4, 6, 5, 0, -6 }, // U+0042 uni0042 + { 89, 4, 6, 5, 0, -6 }, // U+0043 uni0043 + { 92, 4, 6, 5, 0, -6 }, // U+0044 uni0044 + { 95, 4, 6, 5, 0, -6 }, // U+0045 uni0045 + { 98, 4, 6, 5, 0, -6 }, // U+0046 uni0046 + { 101, 4, 6, 5, 0, -6 }, // U+0047 uni0047 + { 104, 4, 6, 5, 0, -6 }, // U+0048 uni0048 + { 107, 3, 6, 4, 0, -6 }, // U+0049 uni0049 + { 110, 4, 6, 5, 0, -6 }, // U+004A uni004A + { 113, 4, 6, 5, 0, -6 }, // U+004B uni004B + { 116, 4, 6, 5, 0, -6 }, // U+004C uni004C + { 119, 4, 6, 5, 0, -6 }, // U+004D uni004D + { 122, 4, 6, 5, 0, -6 }, // U+004E uni004E + { 125, 4, 6, 5, 0, -6 }, // U+004F uni004F + { 128, 4, 6, 5, 0, -6 }, // U+0050 uni0050 + { 131, 4, 7, 5, 0, -6 }, // U+0051 uni0051 + { 135, 4, 6, 5, 0, -6 }, // U+0052 uni0052 + { 138, 4, 6, 5, 0, -6 }, // U+0053 uni0053 + { 141, 5, 6, 6, 0, -6 }, // U+0054 uni0054 + { 145, 4, 6, 5, 0, -6 }, // U+0055 uni0055 + { 148, 4, 6, 5, 0, -6 }, // U+0056 uni0056 + { 151, 4, 6, 5, 0, -6 }, // U+0057 uni0057 + { 154, 4, 6, 5, 0, -6 }, // U+0058 uni0058 + { 157, 5, 6, 6, 0, -6 }, // U+0059 uni0059 + { 161, 4, 6, 5, 0, -6 }, // U+005A uni005A + { 164, 3, 8, 4, 0, -7 }, // U+005B uni005B + { 167, 4, 8, 5, 0, -7 }, // U+005C uni005C + { 171, 3, 8, 4, 0, -7 }, // U+005D uni005D + { 174, 5, 3, 6, 0, -7 }, // U+005E uni005E + { 176, 4, 1, 5, 0, 0 }, // U+005F uni005F + { 177, 2, 3, 3, 0, -7 }, // U+0060 uni0060 + { 178, 4, 5, 5, 0, -5 }, // U+0061 uni0061 + { 181, 4, 7, 5, 0, -7 }, // U+0062 uni0062 + { 185, 4, 5, 5, 0, -5 }, // U+0063 uni0063 + { 188, 4, 7, 5, 0, -7 }, // U+0064 uni0064 + { 192, 4, 5, 5, 0, -5 }, // U+0065 uni0065 + { 195, 3, 7, 4, 0, -7 }, // U+0066 uni0066 + { 198, 4, 7, 5, 0, -5 }, // U+0067 uni0067 + { 202, 4, 7, 5, 0, -7 }, // U+0068 uni0068 + { 206, 2, 7, 3, 0, -7 }, // U+0069 uni0069 + { 208, 3, 9, 4, 0, -7 }, // U+006A uni006A + { 212, 4, 7, 5, 0, -7 }, // U+006B uni006B + { 216, 2, 7, 3, 0, -7 }, // U+006C uni006C + { 218, 4, 5, 5, 0, -5 }, // U+006D uni006D + { 221, 4, 5, 5, 0, -5 }, // U+006E uni006E + { 224, 4, 5, 5, 0, -5 }, // U+006F uni006F + { 227, 4, 7, 5, 0, -5 }, // U+0070 uni0070 + { 231, 4, 7, 5, 0, -5 }, // U+0071 uni0071 + { 235, 4, 5, 5, 0, -5 }, // U+0072 uni0072 + { 238, 4, 5, 5, 0, -5 }, // U+0073 uni0073 + { 241, 3, 6, 4, 0, -6 }, // U+0074 uni0074 + { 244, 4, 5, 5, 0, -5 }, // U+0075 uni0075 + { 247, 4, 5, 5, 0, -5 }, // U+0076 uni0076 + { 250, 4, 5, 5, 0, -5 }, // U+0077 uni0077 + { 253, 4, 5, 5, 0, -5 }, // U+0078 uni0078 + { 256, 4, 7, 5, 0, -5 }, // U+0079 uni0079 + { 260, 4, 5, 5, 0, -5 }, // U+007A uni007A + { 263, 3, 8, 4, 0, -7 }, // U+007B uni007B + { 266, 1, 8, 2, 0, -7 }, // U+007C uni007C + { 267, 3, 8, 4, 0, -7 }, // U+007D uni007D + { 270, 5, 4, 6, 0, -5 }, // U+007E uni007E + { 273, 0, 0, 5, 0, 0 }, // U+007F (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0080 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0081 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0082 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0083 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0084 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0085 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0086 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0087 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0088 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0089 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+008A (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+008B (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+008C (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+008D (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+008E (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+008F (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0090 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0091 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0092 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0093 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0094 (placeholder) + { 273, 4, 4, 5, 0, -4 }, // U+0095 char149 + { 275, 0, 0, 5, 0, 0 }, // U+0096 (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+0097 (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+0098 (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+0099 (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+009A (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+009B (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+009C (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+009D (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+009E (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+009F (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+00A0 (placeholder) + { 275, 1, 6, 2, 0, -6 }, // U+00A1 char161 + { 276, 4, 7, 5, 0, -6 }, // U+00A2 char162 + { 280, 5, 7, 6, 0, -7 }, // U+00A3 char163 + { 285, 5, 6, 6, 0, -6 }, // U+00A4 char164 + { 289, 5, 7, 6, 0, -7 }, // U+00A5 char165 + { 294, 1, 7, 2, 0, -6 }, // U+00A6 char166 + { 295, 4, 8, 5, 0, -7 }, // U+00A7 char167 + { 299, 3, 1, 4, 0, -6 }, // U+00A8 char168 + { 300, 6, 7, 7, 0, -7 }, // U+00A9 char169 + { 306, 4, 4, 5, 0, -6 }, // U+00AA char170 + { 308, 4, 3, 5, 0, -5 }, // U+00AB char171 + { 310, 5, 3, 6, 0, -4 }, // U+00AC char172 + { 312, 3, 1, 4, 0, -3 }, // U+00AD SOFT HYPHEN + { 313, 6, 7, 7, 0, -7 }, // U+00AE char174 + { 319, 4, 1, 5, 0, -7 }, // U+00AF char175 + { 320, 3, 3, 4, 0, -6 }, // U+00B0 char176 + { 322, 4, 5, 5, 0, -5 }, // U+00B1 char177 + { 325, 3, 5, 4, 0, -7 }, // U+00B2 char178 + { 327, 3, 5, 4, 0, -7 }, // U+00B3 char179 + { 329, 2, 2, 3, 0, -8 }, // U+00B4 char180 + { 330, 4, 6, 5, 0, -5 }, // U+00B5 char181 + { 333, 5, 6, 6, 0, -6 }, // U+00B6 char182 + { 337, 2, 2, 3, 0, -4 }, // U+00B7 char183 + { 338, 3, 3, 4, 0, -4 }, // U+00B8 char184 + { 340, 2, 4, 3, 0, -6 }, // U+00B9 char185 + { 341, 4, 4, 5, 0, -8 }, // U+00BA char186 + { 343, 5, 3, 6, 0, -5 }, // U+00BB char187 + { 345, 5, 8, 6, 0, -8 }, // U+00BC char188 + { 350, 5, 7, 6, 0, -8 }, // U+00BD char189 + { 355, 6, 8, 7, 0, -8 }, // U+00BE char190 + { 361, 3, 6, 4, 0, -6 }, // U+00BF char191 + { 364, 4, 8, 5, 0, -8 }, // U+00C0 char192 + { 368, 4, 8, 5, 0, -8 }, // U+00C1 char193 + { 372, 4, 9, 5, 0, -9 }, // U+00C2 char194 + { 377, 4, 8, 5, 0, -8 }, // U+00C3 char195 + { 381, 5, 8, 6, 0, -8 }, // U+00C4 char196 + { 386, 4, 8, 5, 0, -8 }, // U+00C5 char197 + { 390, 6, 6, 7, 0, -6 }, // U+00C6 char198 + { 395, 4, 8, 5, 0, -6 }, // U+00C7 char199 + { 399, 4, 8, 5, 0, -8 }, // U+00C8 char200 + { 403, 4, 8, 5, 0, -8 }, // U+00C9 char201 + { 407, 4, 8, 5, 0, -8 }, // U+00CA char202 + { 411, 4, 8, 5, 0, -8 }, // U+00CB char203 + { 415, 3, 9, 4, 0, -9 }, // U+00CC char204 + { 419, 3, 9, 4, 0, -9 }, // U+00CD char205 + { 423, 3, 9, 4, 0, -9 }, // U+00CE char206 + { 427, 3, 8, 4, 0, -8 }, // U+00CF char207 + { 430, 5, 6, 6, 0, -6 }, // U+00D0 char208 + { 434, 5, 8, 6, 0, -8 }, // U+00D1 char209 + { 439, 4, 8, 5, 0, -8 }, // U+00D2 char210 + { 443, 4, 8, 5, 0, -8 }, // U+00D3 char211 + { 447, 4, 8, 5, 0, -8 }, // U+00D4 char212 + { 451, 4, 8, 5, 0, -8 }, // U+00D5 char213 + { 455, 5, 7, 6, 0, -7 }, // U+00D6 char214 + { 460, 3, 3, 4, 0, -5 }, // U+00D7 char215 + { 462, 6, 6, 7, 0, -6 }, // U+00D8 char216 + { 467, 4, 8, 5, 0, -8 }, // U+00D9 char217 + { 471, 4, 8, 5, 0, -8 }, // U+00DA char218 + { 475, 4, 9, 5, 0, -9 }, // U+00DB char219 + { 480, 4, 8, 5, 0, -8 }, // U+00DC char220 + { 484, 5, 8, 6, 0, -8 }, // U+00DD char221 + { 489, 4, 7, 5, 0, -6 }, // U+00DE char222 + { 493, 4, 7, 5, 0, -6 }, // U+00DF char223 + { 497, 4, 8, 5, 0, -8 }, // U+00E0 char224 + { 501, 4, 8, 5, 0, -8 }, // U+00E1 char225 + { 505, 4, 8, 5, 0, -8 }, // U+00E2 char226 + { 509, 4, 8, 5, 0, -8 }, // U+00E3 char227 + { 513, 4, 7, 5, 0, -7 }, // U+00E4 char228 + { 517, 4, 7, 5, 0, -7 }, // U+00E5 char229 + { 521, 5, 5, 6, 0, -5 }, // U+00E6 char230 + { 525, 4, 7, 5, 0, -5 }, // U+00E7 char231 + { 529, 4, 8, 5, 0, -8 }, // U+00E8 char232 + { 533, 4, 8, 5, 0, -8 }, // U+00E9 char233 + { 537, 4, 8, 5, 0, -8 }, // U+00EA char234 + { 541, 4, 7, 5, 0, -7 }, // U+00EB char235 + { 545, 2, 8, 3, 0, -8 }, // U+00EC char236 + { 547, 3, 8, 4, 0, -8 }, // U+00ED char237 + { 550, 3, 8, 4, 0, -8 }, // U+00EE char238 + { 553, 3, 7, 4, 0, -7 }, // U+00EF char239 + { 556, 4, 8, 5, 0, -8 }, // U+00F0 char240 + { 560, 4, 8, 5, 0, -8 }, // U+00F1 char241 + { 564, 5, 8, 6, 0, -8 }, // U+00F2 char242 + { 569, 4, 8, 5, 0, -8 }, // U+00F3 char243 + { 573, 4, 8, 5, 0, -8 }, // U+00F4 char244 + { 577, 4, 8, 5, 0, -8 }, // U+00F5 char245 + { 581, 4, 7, 5, 0, -7 }, // U+00F6 char246 + { 585, 5, 5, 6, 0, -6 }, // U+00F7 char247 + { 589, 6, 5, 7, 0, -5 }, // U+00F8 char248 + { 593, 4, 8, 5, 0, -8 }, // U+00F9 char249 + { 597, 5, 8, 6, 0, -8 }, // U+00FA char250 + { 602, 4, 8, 5, 0, -8 }, // U+00FB char251 + { 606, 5, 7, 6, 0, -7 }, // U+00FC char252 + { 611, 4, 10, 5, 0, -8 }, // U+00FD char253 + { 616, 4, 9, 5, 0, -7 }, // U+00FE char254 + { 621, 4, 9, 5, 0, -7 }, // U+00FF char255 + { 626, 4, 8, 5, 0, -8 }, // U+0100 uni0041 + { 630, 4, 7, 5, 0, -7 }, // U+0101 uni0061 + { 634, 4, 9, 5, 0, -9 }, // U+0102 uni0041 + { 639, 4, 8, 5, 0, -8 }, // U+0103 uni0061 + { 643, 5, 7, 6, 0, -6 }, // U+0104 uni0041 + { 648, 5, 6, 6, 0, -5 }, // U+0105 uni0061 + { 652, 4, 9, 5, 0, -9 }, // U+0106 uni0043 + { 657, 4, 8, 5, 0, -8 }, // U+0107 uni0063 + { 661, 4, 9, 5, 0, -9 }, // U+0108 uni0043 + { 666, 4, 8, 5, 0, -8 }, // U+0109 uni0063 + { 670, 4, 8, 5, 0, -8 }, // U+010A uni0043 + { 674, 4, 7, 5, 0, -7 }, // U+010B uni0063 + { 678, 4, 9, 5, 0, -9 }, // U+010C uni0043 + { 683, 4, 8, 5, 0, -8 }, // U+010D uni0063 + { 687, 4, 9, 5, 0, -9 }, // U+010E uni0044 + { 692, 6, 8, 7, 0, -8 }, // U+010F uni0064 + { 698, 5, 6, 6, 0, -6 }, // U+0110 uni0044 + { 702, 5, 7, 6, 0, -7 }, // U+0111 uni0064 + { 707, 4, 8, 5, 0, -8 }, // U+0112 uni0045 + { 711, 4, 7, 5, 0, -7 }, // U+0113 uni0065 + { 715, 4, 9, 5, 0, -9 }, // U+0114 uni0045 + { 720, 4, 8, 5, 0, -8 }, // U+0115 uni0065 + { 724, 4, 8, 5, 0, -8 }, // U+0116 uni0045 + { 728, 4, 7, 5, 0, -7 }, // U+0117 uni0065 + { 732, 5, 7, 6, 0, -6 }, // U+0118 uni0045 + { 737, 4, 6, 5, 0, -5 }, // U+0119 uni0065 + { 740, 4, 9, 5, 0, -9 }, // U+011A uni0045 + { 745, 4, 8, 5, 0, -8 }, // U+011B uni0065 + { 749, 4, 9, 5, 0, -9 }, // U+011C uni0047 + { 754, 4, 10, 5, 0, -8 }, // U+011D uni0067 + { 759, 4, 9, 5, 0, -9 }, // U+011E uni0047 + { 764, 4, 10, 5, 0, -8 }, // U+011F uni0067 + { 769, 4, 8, 5, 0, -8 }, // U+0120 uni0047 + { 773, 4, 9, 5, 0, -7 }, // U+0121 uni0067 + { 778, 4, 8, 5, 0, -6 }, // U+0122 uni0047 + { 782, 4, 10, 5, 0, -8 }, // U+0123 uni0067 + { 787, 4, 9, 5, 0, -9 }, // U+0124 uni0048 + { 792, 4, 9, 5, 0, -9 }, // U+0125 uni0068 + { 797, 6, 6, 7, 0, -6 }, // U+0126 uni0048 + { 802, 5, 7, 6, 0, -7 }, // U+0127 uni0068 + { 807, 4, 9, 5, 0, -9 }, // U+0128 uni0049 + { 812, 4, 8, 5, 0, -8 }, // U+0129 uni0069 + { 816, 3, 8, 4, 0, -8 }, // U+012A uni0049 + { 819, 3, 7, 4, 0, -7 }, // U+012B uni0069 + { 822, 3, 9, 4, 0, -9 }, // U+012C uni0049 + { 826, 3, 8, 4, 0, -8 }, // U+012D uni0069 + { 829, 3, 8, 4, 0, -6 }, // U+012E uni0049 + { 832, 2, 9, 3, 0, -7 }, // U+012F uni0069 + { 835, 3, 8, 4, 0, -8 }, // U+0130 uni0049 + { 838, 2, 5, 3, 0, -5 }, // U+0131 uni0069 + { 840, 6, 6, 7, 0, -6 }, // U+0132 uni0049 + { 845, 5, 8, 6, 0, -7 }, // U+0133 uni0069 + { 850, 4, 9, 5, 0, -9 }, // U+0134 uni004A + { 855, 4, 10, 5, 0, -8 }, // U+0135 uni006A + { 860, 4, 8, 5, 0, -6 }, // U+0136 uni004B + { 864, 4, 9, 5, 0, -7 }, // U+0137 uni006B + { 869, 4, 5, 5, 0, -5 }, // U+0138 uni006B + { 872, 4, 8, 5, 0, -8 }, // U+0139 uni004C + { 876, 4, 9, 5, 0, -9 }, // U+013A uni006C + { 881, 4, 8, 5, 0, -6 }, // U+013B uni004C + { 885, 3, 9, 4, 0, -7 }, // U+013C uni006C + { 889, 4, 7, 5, 0, -7 }, // U+013D uni004C + { 893, 4, 10, 5, 0, -9 }, // U+013E uni006C + { 898, 4, 6, 5, 0, -6 }, // U+013F uni004C + { 901, 4, 7, 5, 0, -7 }, // U+0140 uni006C + { 905, 4, 6, 5, 0, -6 }, // U+0141 uni004C + { 908, 3, 7, 4, 0, -7 }, // U+0142 uni006C + { 911, 4, 9, 5, 0, -9 }, // U+0143 uni004E + { 916, 4, 8, 5, 0, -8 }, // U+0144 uni006E + { 920, 4, 8, 5, 0, -6 }, // U+0145 uni004E + { 924, 4, 10, 5, 0, -8 }, // U+0146 uni006E + { 929, 4, 8, 5, 0, -8 }, // U+0147 uni004E + { 933, 4, 8, 5, 0, -8 }, // U+0148 uni006E + { 937, 4, 8, 5, 0, -8 }, // U+0149 uni006E + { 941, 4, 8, 5, 0, -6 }, // U+014A uni004E + { 945, 4, 10, 5, 0, -8 }, // U+014B uni006E + { 950, 4, 8, 5, 0, -8 }, // U+014C uni004F + { 954, 4, 7, 5, 0, -7 }, // U+014D uni006F + { 958, 4, 9, 5, 0, -9 }, // U+014E uni004F + { 963, 4, 8, 5, 0, -8 }, // U+014F uni006F + { 967, 4, 9, 5, 0, -9 }, // U+0150 uni004F + { 972, 4, 8, 5, 0, -8 }, // U+0151 uni006F + { 976, 6, 6, 7, 0, -6 }, // U+0152 char198 + { 981, 5, 5, 6, 0, -5 }, // U+0153 char230 + { 985, 4, 8, 5, 0, -8 }, // U+0154 uni0052 + { 989, 4, 8, 5, 0, -8 }, // U+0155 uni0072 + { 993, 4, 8, 5, 0, -6 }, // U+0156 uni0052 + { 997, 4, 7, 5, 0, -5 }, // U+0157 uni0072 + { 1001, 4, 8, 5, 0, -8 }, // U+0158 uni0052 + { 1005, 4, 8, 5, 0, -8 }, // U+0159 uni0072 + { 1009, 4, 9, 5, 0, -9 }, // U+015A uni0053 + { 1014, 4, 8, 5, 0, -8 }, // U+015B uni0073 + { 1018, 4, 9, 5, 0, -9 }, // U+015C uni0053 + { 1023, 4, 8, 5, 0, -8 }, // U+015D uni0073 + { 1027, 4, 8, 5, 0, -6 }, // U+015E uni0053 + { 1031, 4, 7, 5, 0, -5 }, // U+015F uni0073 + { 1035, 4, 9, 5, 0, -9 }, // U+0160 uni0053 + { 1040, 4, 8, 5, 0, -8 }, // U+0161 uni0073 + { 1044, 5, 7, 6, 0, -6 }, // U+0162 uni0054 + { 1049, 3, 7, 4, 0, -6 }, // U+0163 uni0074 + { 1052, 5, 9, 6, 0, -9 }, // U+0164 uni0054 + { 1058, 4, 8, 5, 0, -8 }, // U+0165 uni0074 + { 1062, 5, 6, 6, 0, -6 }, // U+0166 uni0054 + { 1066, 3, 6, 4, 0, -6 }, // U+0167 uni0074 + { 1069, 4, 9, 5, 0, -9 }, // U+0168 uni0055 + { 1074, 4, 8, 5, 0, -8 }, // U+0169 uni0075 + { 1078, 4, 8, 5, 0, -8 }, // U+016A uni0055 + { 1082, 4, 7, 5, 0, -7 }, // U+016B uni0075 + { 1086, 4, 9, 5, 0, -9 }, // U+016C uni0055 + { 1091, 4, 8, 5, 0, -8 }, // U+016D uni0075 + { 1095, 4, 9, 5, 0, -9 }, // U+016E uni0055 + { 1100, 4, 8, 5, 0, -8 }, // U+016F uni0075 + { 1104, 5, 9, 6, 0, -9 }, // U+0170 uni0055 + { 1110, 5, 8, 6, 0, -8 }, // U+0171 uni0075 + { 1115, 4, 8, 5, 0, -6 }, // U+0172 uni0055 + { 1119, 4, 7, 5, 0, -5 }, // U+0173 uni0075 + { 1123, 4, 9, 5, 0, -9 }, // U+0174 uni0077 + { 1128, 4, 8, 5, 0, -8 }, // U+0175 uni0077 + { 1132, 5, 9, 6, 0, -9 }, // U+0176 uni0059 + { 1138, 4, 10, 5, 0, -8 }, // U+0177 uni0079 + { 1143, 5, 8, 6, 0, -8 }, // U+0178 uni0059 + { 1148, 4, 9, 5, 0, -9 }, // U+0179 uni005A + { 1153, 4, 8, 5, 0, -8 }, // U+017A uni007A + { 1157, 4, 8, 5, 0, -8 }, // U+017B uni005A + { 1161, 4, 7, 5, 0, -7 }, // U+017C uni007A + { 1165, 4, 9, 5, 0, -9 }, // U+017D uni005A + { 1170, 4, 8, 5, 0, -8 }, // U+017E uni007A + { 1174, 4, 7, 5, 0, -7 }, // U+017F LATIN SMALL LETTER LONG S + { 1178, 5, 7, 6, 0, -7 }, // U+0180 uni0062 + { 1183, 5, 6, 6, 0, -6 }, // U+0181 uni0042 + { 1187, 5, 8, 6, 0, -8 }, // U+0182 uni0042 + { 1192, 4, 8, 5, 0, -8 }, // U+0183 uni0042 + { 1196, 6, 7, 7, 0, -7 }, // U+0184 uni0062 + { 1202, 5, 7, 6, 0, -7 }, // U+0185 uni0062 + { 1207, 4, 6, 5, 0, -6 }, // U+0186 uni0043 + { 1210, 5, 7, 6, 0, -7 }, // U+0187 uni0043 + { 1215, 4, 5, 5, 0, -5 }, // U+0188 uni0063 + { 1218, 5, 6, 6, 0, -6 }, // U+0189 uni0044 + { 1222, 6, 6, 7, 0, -6 }, // U+018A uni0044 + { 1227, 5, 8, 6, 0, -8 }, // U+018B uni0042 + { 1232, 4, 8, 5, 0, -8 }, // U+018C uni0042 + { 1236, 0, 0, 5, 0, 0 }, // U+018D (placeholder) + { 1236, 5, 6, 6, 0, -6 }, // U+018E uni0045 + { 1240, 4, 5, 5, 0, -5 }, // U+018F uni0065 + { 1243, 4, 6, 5, 0, -6 }, // U+0190 char1028 + { 1246, 5, 7, 6, 0, -6 }, // U+0191 uni0046 + { 1251, 4, 8, 5, 0, -7 }, // U+0192 uni0066 + { 1255, 7, 6, 8, 0, -6 }, // U+0193 uni0047 + { 1261, 5, 8, 6, 0, -6 }, // U+0194 LATIN CAPITAL LETTER GAMMA + { 1266, 5, 6, 6, 0, -6 }, // U+0195 LATIN SMALL LETTER HV + { 1270, 3, 7, 4, 0, -7 }, // U+0196 LATIN CAPITAL LETTER IOTA + { 1273, 3, 8, 4, 0, -8 }, // U+0197 uni0049 + { 1276, 5, 6, 6, 0, -6 }, // U+0198 uni004B + { 1280, 4, 7, 5, 0, -7 }, // U+0199 uni006B + { 1284, 3, 7, 4, 0, -7 }, // U+019A uni0049 + { 1287, 4, 6, 5, 0, -6 }, // U+019B LATIN SMALL LETTER LAMBDA WITH STROKE + { 1290, 5, 5, 6, 0, -5 }, // U+019C LATIN CAPITAL LETTER TURNED M + { 1294, 5, 6, 6, 0, -5 }, // U+019D LATIN CAPITAL LETTER N WITH LEFT HOOK + { 1298, 4, 5, 5, 0, -4 }, // U+019E uni006E + { 1301, 4, 5, 5, 0, -5 }, // U+019F GREEK SMALL LETTER THETA + { 1304, 6, 8, 7, 0, -8 }, // U+01A0 uni004F + { 1310, 6, 8, 7, 0, -8 }, // U+01A1 uni006F + { 1316, 6, 7, 7, 0, -7 }, // U+01A2 uni004F + { 1322, 6, 6, 7, 0, -6 }, // U+01A3 uni006F + { 1327, 6, 6, 7, 0, -6 }, // U+01A4 uni0050 + { 1332, 6, 9, 7, 0, -7 }, // U+01A5 uni0070 + { 1339, 5, 8, 6, 0, -7 }, // U+01A6 LATIN LETTER YR + { 1344, 4, 6, 5, 0, -6 }, // U+01A7 uni0053 + { 1347, 4, 5, 5, 0, -5 }, // U+01A8 uni0073 + { 1350, 5, 6, 6, 0, -6 }, // U+01A9 GREEK CAPITAL LETTER SIGMA + { 1354, 5, 8, 6, 0, -7 }, // U+01AA LATIN LETTER REVERSED ESH LOOP + { 1359, 3, 7, 4, 0, -6 }, // U+01AB uni0074 + { 1362, 5, 6, 6, 0, -6 }, // U+01AC uni0054 + { 1366, 3, 7, 4, 0, -7 }, // U+01AD uni0074 + { 1369, 5, 6, 6, 0, -6 }, // U+01AE uni0054 + { 1373, 5, 7, 6, 0, -7 }, // U+01AF uni0055 + { 1378, 5, 6, 6, 0, -6 }, // U+01B0 uni0075 + { 1382, 5, 7, 6, 0, -7 }, // U+01B1 LATIN CAPITAL LETTER UPSILON + { 1387, 5, 7, 6, 0, -7 }, // U+01B2 LATIN CAPITAL LETTER V WITH HOOK + { 1392, 6, 7, 7, 0, -7 }, // U+01B3 uni0059 + { 1398, 5, 8, 6, 0, -6 }, // U+01B4 uni0079 + { 1403, 5, 6, 6, 0, -6 }, // U+01B5 uni005A + { 1407, 4, 5, 5, 0, -5 }, // U+01B6 uni007A + { 1410, 4, 6, 5, 0, -6 }, // U+01B7 LATIN CAPITAL LETTER EZH + { 1413, 4, 6, 5, 0, -6 }, // U+01B8 LATIN CAPITAL LETTER EZH + { 1416, 4, 6, 5, 0, -5 }, // U+01B9 LATIN CAPITAL LETTER EZH + { 1419, 6, 8, 7, 0, -6 }, // U+01BA LATIN CAPITAL LETTER EZH + { 1425, 5, 7, 6, 0, -7 }, // U+01BB LATIN LETTER TWO WITH STROKE + { 1430, 5, 7, 6, 0, -7 }, // U+01BC LATIN CAPITAL LETTER TONE FIVE + { 1435, 5, 6, 6, 0, -6 }, // U+01BD LATIN SMALL LETTER TONE FIVE + { 1439, 5, 7, 6, 0, -8 }, // U+01BE LATIN LETTER INVERTED GLOTTAL STOP WITH STROKE + { 1444, 4, 7, 5, 0, -7 }, // U+01BF LATIN LETTER WYNN + { 1448, 3, 10, 4, 0, -8 }, // U+01C0 LATIN LETTER DENTAL CLICK + { 1452, 3, 10, 4, 0, -8 }, // U+01C1 LATIN LETTER LATERAL CLICK + { 1456, 5, 10, 6, 0, -8 }, // U+01C2 LATIN LETTER ALVEOLAR CLICK + { 1463, 3, 7, 4, 0, -7 }, // U+01C3 LATIN LETTER RETROFLEX CLICK + { 1466, 6, 9, 7, 0, -9 }, // U+01C4 LATIN CAPITAL LETTER DZ WITH CARON + { 1473, 6, 8, 7, 0, -8 }, // U+01C5 LATIN CAPITAL LETTER D WITH SMALL LETTER Z WITH CARON + { 1479, 6, 9, 7, 0, -9 }, // U+01C6 LATIN SMALL LETTER DZ WITH CARON + { 1486, 5, 7, 6, 0, -7 }, // U+01C7 LATIN CAPITAL LETTER LJ + { 1491, 5, 9, 6, 0, -7 }, // U+01C8 LATIN CAPITAL LETTER L WITH SMALL LETTER J + { 1497, 5, 9, 6, 0, -7 }, // U+01C9 LATIN SMALL LETTER LJ + { 1503, 5, 7, 6, 0, -7 }, // U+01CA LATIN CAPITAL LETTER NJ + { 1508, 5, 9, 6, 0, -7 }, // U+01CB LATIN CAPITAL LETTER N WITH SMALL LETTER J + { 1514, 5, 9, 6, 0, -7 }, // U+01CC LATIN SMALL LETTER NJ + { 1520, 4, 8, 5, 0, -8 }, // U+01CD uni0041 + { 1524, 4, 8, 5, 0, -8 }, // U+01CE uni0061 + { 1528, 3, 8, 4, 0, -8 }, // U+01CF uni0049 + { 1531, 3, 8, 4, 0, -8 }, // U+01D0 uni0069 + { 1534, 4, 8, 5, 0, -8 }, // U+01D1 uni004F + { 1538, 4, 7, 5, 0, -7 }, // U+01D2 uni004F + { 1542, 4, 8, 5, 0, -8 }, // U+01D3 uni0055 + { 1546, 4, 7, 5, 0, -7 }, // U+01D4 uni0075 + { 1550, 4, 8, 5, 0, -8 }, // U+01D5 uni0055 + { 1554, 4, 8, 5, 0, -8 }, // U+01D6 uni0075 + { 1558, 4, 8, 5, 0, -8 }, // U+01D7 uni0055 + { 1562, 4, 8, 5, 0, -8 }, // U+01D8 uni0075 + { 1566, 4, 8, 5, 0, -8 }, // U+01D9 uni0055 + { 1570, 4, 8, 5, 0, -8 }, // U+01DA uni0075 + { 1574, 4, 8, 5, 0, -8 }, // U+01DB uni0055 + { 1578, 4, 8, 5, 0, -8 }, // U+01DC uni0075 + { 1582, 4, 5, 5, 0, -5 }, // U+01DD uni0065 + { 1585, 4, 8, 5, 0, -8 }, // U+01DE uni0041 + { 1589, 4, 8, 5, 0, -8 }, // U+01DF uni0061 + { 1593, 4, 8, 5, 0, -8 }, // U+01E0 uni0041 + { 1597, 4, 8, 5, 0, -8 }, // U+01E1 uni0061 + { 1601, 5, 9, 6, 0, -9 }, // U+01E2 LATIN CAPITAL LETTER AE WITH MACRON + { 1607, 5, 7, 6, 0, -7 }, // U+01E3 LATIN SMALL LETTER AE WITH MACRON + { 1612, 4, 6, 5, 0, -6 }, // U+01E4 uni0047 + { 1615, 4, 7, 5, 0, -5 }, // U+01E5 uni0067 + { 1619, 4, 8, 5, 0, -8 }, // U+01E6 uni0047 + { 1623, 4, 10, 5, 0, -8 }, // U+01E7 uni0067 + { 1628, 4, 6, 5, 0, -6 }, // U+01E8 uni004B + { 1631, 4, 7, 5, 0, -7 }, // U+01E9 uni006B + { 1635, 4, 7, 5, 0, -6 }, // U+01EA uni0051 + { 1639, 4, 6, 5, 0, -5 }, // U+01EB uni0051 + { 1642, 4, 9, 5, 0, -8 }, // U+01EC uni0051 + { 1647, 4, 9, 5, 0, -8 }, // U+01ED uni0051 + { 1652, 4, 8, 5, 0, -8 }, // U+01EE LATIN CAPITAL LETTER EZH + { 1656, 4, 9, 5, 0, -8 }, // U+01EF LATIN CAPITAL LETTER EZH + { 1661, 4, 10, 5, 0, -8 }, // U+01F0 uni006A + { 1666, 6, 7, 7, 0, -7 }, // U+01F1 LATIN CAPITAL LETTER DZ + { 1672, 6, 7, 7, 0, -7 }, // U+01F2 LATIN CAPITAL LETTER D WITH SMALL LETTER Z + { 1678, 6, 7, 7, 0, -7 }, // U+01F3 LATIN SMALL LETTER DZ + { 1684, 4, 8, 5, 0, -8 }, // U+01F4 uni0047 + { 1688, 4, 10, 5, 0, -8 }, // U+01F5 uni0067 + { 1693, 5, 7, 6, 0, -7 }, // U+01F6 LATIN CAPITAL LETTER HWAIR + { 1698, 5, 7, 6, 0, -7 }, // U+01F7 LATIN CAPITAL LETTER WYNN + { 1703, 4, 8, 5, 0, -8 }, // U+01F8 uni004E + { 1707, 4, 8, 5, 0, -8 }, // U+01F9 uni006E + { 1711, 4, 8, 5, 0, -8 }, // U+01FA uni0041 + { 1715, 4, 8, 5, 0, -8 }, // U+01FB uni0061 + { 1719, 6, 8, 7, 0, -8 }, // U+01FC char198 + { 1725, 5, 7, 6, 0, -7 }, // U+01FD char230 + { 1730, 6, 8, 7, 0, -8 }, // U+01FE char216 + { 1736, 6, 8, 7, 0, -8 }, // U+01FF char248 + { 1742, 4, 8, 5, 0, -8 }, // U+0200 uni0041 + { 1746, 4, 8, 5, 0, -8 }, // U+0201 uni0061 + { 1750, 4, 8, 5, 0, -8 }, // U+0202 uni0041 + { 1754, 4, 8, 5, 0, -8 }, // U+0203 uni0061 + { 1758, 4, 8, 5, 0, -8 }, // U+0204 uni0045 + { 1762, 4, 8, 5, 0, -8 }, // U+0205 uni0065 + { 1766, 4, 8, 5, 0, -8 }, // U+0206 uni0045 + { 1770, 4, 7, 5, 0, -7 }, // U+0207 uni0065 + { 1774, 4, 8, 5, 0, -8 }, // U+0208 uni0049 + { 1778, 4, 8, 5, 0, -8 }, // U+0209 uni0069 + { 1782, 5, 8, 6, 0, -8 }, // U+020A uni0049 + { 1787, 4, 8, 5, 0, -8 }, // U+020B uni0069 + { 1791, 5, 9, 6, 0, -9 }, // U+020C LATIN CAPITAL LETTER O WITH DOUBLE GRAVE + { 1797, 5, 8, 6, 0, -8 }, // U+020D LATIN SMALL LETTER O WITH DOUBLE GRAVE + { 1802, 4, 9, 5, 0, -9 }, // U+020E LATIN CAPITAL LETTER O WITH INVERTED BREVE + { 1807, 4, 8, 5, 0, -8 }, // U+020F LATIN SMALL LETTER O WITH INVERTED BREVE + { 1811, 4, 8, 5, 0, -8 }, // U+0210 uni0052 + { 1815, 4, 8, 5, 0, -8 }, // U+0211 uni0072 + { 1819, 4, 8, 5, 0, -8 }, // U+0212 uni0052 + { 1823, 4, 8, 5, 0, -8 }, // U+0213 uni0072 + { 1827, 4, 8, 5, 0, -8 }, // U+0214 uni0055 + { 1831, 4, 8, 5, 0, -8 }, // U+0215 uni0075 + { 1835, 4, 8, 5, 0, -8 }, // U+0216 uni0055 + { 1839, 4, 8, 5, 0, -8 }, // U+0217 uni0075 + { 1843, 4, 7, 5, 0, -6 }, // U+0218 uni0053 + { 1847, 4, 6, 5, 0, -5 }, // U+0219 uni0073 + { 1850, 5, 7, 6, 0, -6 }, // U+021A uni0054 + { 1855, 3, 7, 4, 0, -6 }, // U+021B uni0074 + { 1858, 4, 8, 5, 0, -7 }, // U+021C LATIN CAPITAL LETTER YOGH + { 1862, 4, 6, 5, 0, -6 }, // U+021D LATIN SMALL LETTER YOGH + { 1865, 4, 8, 5, 0, -8 }, // U+021E uni0048 + { 1869, 4, 8, 5, 0, -8 }, // U+021F uni0068 + { 1873, 5, 8, 6, 0, -6 }, // U+0220 LATIN CAPITAL LETTER N WITH LONG RIGHT LEG + { 1878, 6, 8, 7, 0, -7 }, // U+0221 LATIN SMALL LETTER D WITH CURL + { 1884, 5, 8, 6, 0, -8 }, // U+0222 LATIN CAPITAL LETTER OU + { 1889, 5, 7, 6, 0, -7 }, // U+0223 LATIN SMALL LETTER OU + { 1894, 5, 7, 6, 0, -6 }, // U+0224 uni005A + { 1899, 5, 6, 6, 0, -5 }, // U+0225 uni007A + { 1903, 4, 8, 5, 0, -8 }, // U+0226 uni0041 + { 1907, 4, 7, 5, 0, -7 }, // U+0227 uni0061 + { 1911, 4, 7, 5, 0, -6 }, // U+0228 uni0045 + { 1915, 4, 6, 5, 0, -5 }, // U+0229 uni0065 + { 1918, 4, 8, 5, 0, -8 }, // U+022A LATIN CAPITAL LETTER O WITH INVERTED BREVE + { 1922, 4, 8, 5, 0, -8 }, // U+022B LATIN SMALL LETTER O WITH INVERTED BREVE + { 1926, 4, 9, 5, 0, -9 }, // U+022C LATIN CAPITAL LETTER O WITH INVERTED BREVE + { 1931, 4, 9, 5, 0, -9 }, // U+022D LATIN SMALL LETTER O WITH INVERTED BREVE + { 1936, 4, 8, 5, 0, -8 }, // U+022E LATIN CAPITAL LETTER O WITH INVERTED BREVE + { 1940, 4, 8, 5, 0, -8 }, // U+022F LATIN SMALL LETTER O WITH INVERTED BREVE + { 1944, 4, 8, 5, 0, -8 }, // U+0230 LATIN CAPITAL LETTER O WITH INVERTED BREVE + { 1948, 4, 8, 5, 0, -8 }, // U+0231 LATIN SMALL LETTER O WITH INVERTED BREVE + { 1952, 5, 8, 6, 0, -8 }, // U+0232 uni0059 + { 1957, 4, 9, 5, 0, -7 }, // U+0233 uni0079 + { 1962, 5, 7, 6, 0, -7 }, // U+0234 LATIN SMALL LETTER L WITH CURL + { 1967, 6, 6, 7, 0, -5 }, // U+0235 LATIN SMALL LETTER N WITH CURL + { 1972, 5, 7, 6, 0, -7 }, // U+0236 LATIN SMALL LETTER T WITH CURL + { 1977, 3, 7, 4, 0, -5 }, // U+0237 LATIN SMALL LETTER DOTLESS J + { 1980, 5, 7, 6, 0, -7 }, // U+0238 LATIN SMALL LETTER DB DIGRAPH + { 1985, 5, 7, 6, 0, -5 }, // U+0239 LATIN SMALL LETTER QP DIGRAPH + { 1990, 5, 7, 6, 0, -7 }, // U+023A uni0041 + { 1995, 5, 7, 6, 0, -7 }, // U+023B uni0043 + { 2000, 5, 6, 6, 0, -6 }, // U+023C uni0063 + { 2004, 6, 7, 7, 0, -7 }, // U+023D LATIN CAPITAL LETTER L WITH BAR + { 2010, 5, 8, 6, 0, -8 }, // U+023E LATIN CAPITAL LETTER T WITH DIAGONAL STROKE + { 2015, 5, 7, 6, 0, -5 }, // U+023F uni0073 + { 2020, 4, 7, 5, 0, -5 }, // U+0240 uni007A + { 2024, 5, 7, 6, 0, -8 }, // U+0241 LATIN CAPITAL LETTER GLOTTAL STOP + { 2029, 5, 6, 6, 0, -7 }, // U+0242 LATIN SMALL LETTER GLOTTAL STOP + { 2033, 6, 6, 7, 0, -6 }, // U+0243 uni0042 + { 2038, 4, 6, 5, 0, -6 }, // U+0244 uni0041 + { 2041, 4, 6, 5, 0, -6 }, // U+0245 uni0056 + { 2044, 5, 10, 6, 0, -8 }, // U+0246 LATIN CAPITAL LETTER E WITH STROKE + { 2051, 5, 10, 6, 0, -8 }, // U+0247 LATIN SMALL LETTER E WITH STROKE + { 2058, 6, 7, 7, 0, -7 }, // U+0248 LATIN CAPITAL LETTER J WITH STROKE + { 2064, 5, 9, 6, 0, -7 }, // U+0249 LATIN SMALL LETTER J WITH STROKE + { 2070, 5, 8, 6, 0, -6 }, // U+024A uni0071 + { 2075, 5, 7, 6, 0, -5 }, // U+024B uni0071 + { 2080, 5, 6, 6, 0, -6 }, // U+024C uni0052 + { 2084, 5, 5, 6, 0, -5 }, // U+024D uni0072 + { 2088, 5, 6, 6, 0, -6 }, // U+024E uni0059 + { 2092, 4, 7, 5, 0, -5 }, // U+024F uni0079 + { 2096, 4, 5, 5, 0, -5 }, // U+0250 uni0061 + { 2099, 4, 5, 5, 0, -5 }, // U+0251 uni0061 + { 2102, 4, 5, 5, 0, -5 }, // U+0252 uni0061 + { 2105, 4, 7, 5, 0, -7 }, // U+0253 uni0062 + { 2109, 4, 5, 5, 0, -5 }, // U+0254 uni0063 + { 2112, 4, 5, 5, 0, -5 }, // U+0255 uni0063 + { 2115, 5, 8, 6, 0, -7 }, // U+0256 uni0064 + { 2120, 5, 8, 6, 0, -8 }, // U+0257 uni0064 + { 2125, 4, 5, 5, 0, -5 }, // U+0258 uni0065 + { 2128, 4, 5, 5, 0, -5 }, // U+0259 uni0065 + { 2131, 5, 5, 6, 0, -5 }, // U+025A uni0065 + { 2135, 4, 6, 5, 0, -6 }, // U+025B uni0033 + { 2138, 4, 6, 5, 0, -6 }, // U+025C uni0033 + { 2141, 5, 6, 6, 0, -6 }, // U+025D uni0033 + { 2145, 4, 6, 5, 0, -6 }, // U+025E char223 + { 2148, 4, 7, 5, 0, -5 }, // U+025F uni006A + { 2152, 5, 8, 6, 0, -6 }, // U+0260 uni0067 + { 2157, 4, 7, 5, 0, -5 }, // U+0261 uni0067 + { 2161, 4, 5, 5, 0, -5 }, // U+0262 uni0047 + { 2164, 4, 7, 5, 0, -5 }, // U+0263 uni0076 + { 2168, 6, 7, 7, 0, -5 }, // U+0264 uni0076 + { 2174, 4, 7, 5, 0, -7 }, // U+0265 uni0068 + { 2178, 4, 8, 5, 0, -8 }, // U+0266 uni0068 + { 2182, 4, 9, 5, 0, -8 }, // U+0267 uni0068 + { 2187, 3, 7, 4, 0, -7 }, // U+0268 uni0069 + { 2190, 3, 6, 4, 0, -5 }, // U+0269 uni0069 + { 2193, 3, 5, 4, 0, -5 }, // U+026A uni0069 + { 2195, 5, 7, 6, 0, -7 }, // U+026B uni006C + { 2200, 4, 7, 5, 0, -7 }, // U+026C uni006C + { 2204, 3, 8, 4, 0, -7 }, // U+026D uni006C + { 2207, 5, 8, 6, 0, -7 }, // U+026E LATIN SMALL LETTER LEZH + { 2212, 4, 5, 5, 0, -5 }, // U+026F uni006D + { 2215, 4, 6, 5, 0, -5 }, // U+0270 uni006D + { 2218, 4, 6, 5, 0, -5 }, // U+0271 uni006D + { 2221, 5, 6, 6, 0, -5 }, // U+0272 uni006E + { 2225, 5, 6, 6, 0, -5 }, // U+0273 uni006E + { 2229, 4, 5, 5, 0, -5 }, // U+0274 GREEK SMALL LETTER PAMPHYLIAN DIGAMMA + { 2232, 4, 5, 5, 0, -5 }, // U+0275 LATIN SMALL LETTER BARRED O + { 2235, 6, 5, 7, 0, -5 }, // U+0276 char230 + { 2239, 5, 5, 6, 0, -5 }, // U+0277 GREEK SMALL LETTER OMEGA WITH TONOS + { 2243, 5, 7, 6, 0, -7 }, // U+0278 GREEK CAPITAL LETTER PHI + { 2248, 4, 5, 5, 0, -5 }, // U+0279 uni0072 + { 2251, 4, 6, 5, 0, -6 }, // U+027A uni0072 + { 2254, 5, 6, 6, 0, -5 }, // U+027B uni0072 + { 2258, 4, 6, 5, 0, -6 }, // U+027C uni0072 + { 2261, 4, 7, 5, 0, -6 }, // U+027D uni0072 + { 2265, 4, 5, 5, 0, -5 }, // U+027E uni0072 + { 2268, 4, 5, 5, 0, -5 }, // U+027F uni0072 + { 2271, 4, 6, 5, 0, -6 }, // U+0280 char1071 + { 2274, 4, 6, 5, 0, -6 }, // U+0281 char1071 + { 2277, 4, 7, 5, 0, -5 }, // U+0282 uni0073 + { 2281, 5, 8, 6, 0, -7 }, // U+0283 uni0066 + { 2286, 5, 8, 6, 0, -7 }, // U+0284 uni0066 + { 2291, 3, 7, 4, 0, -6 }, // U+0285 uni0066 + { 2294, 5, 8, 6, 0, -7 }, // U+0286 uni0066 + { 2299, 4, 7, 5, 0, -7 }, // U+0287 uni0066 + { 2303, 4, 5, 5, 0, -5 }, // U+0288 uni0075 + { 2306, 4, 5, 5, 0, -5 }, // U+0289 uni0075 + { 2309, 4, 5, 5, 0, -5 }, // U+028A uni0076 + { 2312, 4, 5, 5, 0, -5 }, // U+028B uni0076 + { 2315, 6, 5, 7, 0, -5 }, // U+028C uni0076 + { 2319, 4, 5, 5, 0, -5 }, // U+028D uni006D + { 2322, 4, 7, 5, 0, -5 }, // U+028E uni0079 + { 2326, 5, 4, 6, 0, -4 }, // U+028F uni0059 + { 2329, 4, 7, 5, 0, -5 }, // U+0290 uni007A + { 2333, 4, 6, 5, 0, -5 }, // U+0291 uni007A + { 2336, 4, 7, 5, 0, -5 }, // U+0292 uni007A + { 2340, 4, 7, 5, 0, -5 }, // U+0293 uni007A + { 2344, 5, 7, 6, 0, -7 }, // U+0294 LATIN LETTER GLOTTAL STOP + { 2349, 5, 7, 6, 0, -7 }, // U+0295 LATIN LETTER PHARYNGEAL VOICED FRICATIVE + { 2354, 5, 7, 6, 0, -7 }, // U+0296 LATIN LETTER INVERTED GLOTTAL STOP + { 2359, 4, 7, 5, 0, -6 }, // U+0297 uni0043 + { 2363, 5, 5, 6, 0, -5 }, // U+0298 LATIN LETTER BILABIAL CLICK + { 2367, 4, 5, 5, 0, -5 }, // U+0299 LATIN LETTER SMALL CAPITAL B + { 2370, 4, 5, 5, 0, -5 }, // U+029A LATIN SMALL LETTER CLOSED OPEN E + { 2373, 5, 6, 6, 0, -6 }, // U+029B uni0047 + { 2377, 4, 5, 5, 0, -5 }, // U+029C uni0048 + { 2380, 4, 9, 5, 0, -7 }, // U+029D uni006A + { 2385, 4, 7, 5, 0, -7 }, // U+029E uni006B + { 2389, 4, 5, 5, 0, -5 }, // U+029F LATIN LETTER SMALL CAPITAL L + { 2392, 5, 8, 6, 0, -6 }, // U+02A0 uni0071 + { 2397, 5, 7, 6, 0, -7 }, // U+02A1 LATIN LETTER GLOTTAL STOP WITH STROKE + { 2402, 5, 7, 6, 0, -7 }, // U+02A2 LATIN LETTER REVERSED GLOTTAL STOP WITH STROKE + { 2407, 6, 7, 7, 0, -7 }, // U+02A3 LATIN SMALL LETTER DZ DIGRAPH + { 2413, 6, 8, 7, 0, -7 }, // U+02A4 LATIN SMALL LETTER DEZH DIGRAPH + { 2419, 6, 8, 7, 0, -7 }, // U+02A5 LATIN SMALL LETTER DZ DIGRAPH WITH CURL + { 2425, 6, 6, 7, 0, -6 }, // U+02A6 LATIN SMALL LETTER TS DIGRAPH + { 2430, 5, 9, 6, 0, -7 }, // U+02A7 LATIN SMALL LETTER TESH DIGRAPH + { 2436, 6, 7, 7, 0, -6 }, // U+02A8 LATIN SMALL LETTER TC DIGRAPH WITH CURL + { 2442, 6, 9, 7, 0, -7 }, // U+02A9 LATIN SMALL LETTER FENG DIGRAPH + { 2449, 5, 7, 6, 0, -7 }, // U+02AA LATIN SMALL LETTER LS DIGRAPH + { 2454, 5, 7, 6, 0, -7 }, // U+02AB LATIN SMALL LETTER LZ DIGRAPH + { 2459, 5, 6, 6, 0, -6 }, // U+02AC LATIN LETTER BILABIAL PERCUSSIVE + { 2463, 5, 5, 6, 0, -5 }, // U+02AD LATIN LETTER BIDENTAL PERCUSSIVE + { 2467, 5, 7, 6, 0, -7 }, // U+02AE uni0068 + { 2472, 6, 8, 7, 0, -7 }, // U+02AF uni0068 + { 2478, 0, 0, 5, 0, 0 }, // U+02B0 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B1 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B2 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B3 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B4 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B5 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B6 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B7 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B8 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B9 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02BA (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02BB (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02BC (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02BD (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02BE (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02BF (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02C0 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02C1 (placeholder) + { 2478, 3, 5, 4, 0, -5 }, // U+02C2 uni003C + { 2480, 3, 5, 4, 0, -5 }, // U+02C3 uni003C + { 2482, 5, 3, 6, 0, -5 }, // U+02C4 uni005E + { 2484, 5, 3, 6, 0, -5 }, // U+02C5 uni005E + { 2486, 5, 3, 6, 0, -8 }, // U+02C6 MODIFIER LETTER CIRCUMFLEX ACCENT + { 2488, 5, 3, 6, 0, -9 }, // U+02C7 CARON + { 2490, 1, 2, 2, 0, -7 }, // U+02C8 MODIFIER LETTER VERTICAL LINE + { 2491, 2, 1, 3, 0, -7 }, // U+02C9 MODIFIER LETTER MACRON + { 2492, 2, 2, 3, 0, -7 }, // U+02CA MODIFIER LETTER ACUTE ACCENT + { 2493, 2, 2, 3, 0, -7 }, // U+02CB MODIFIER LETTER GRAVE ACCENT + { 2494, 0, 0, 5, 0, 0 }, // U+02CC (placeholder) + { 2494, 0, 0, 5, 0, 0 }, // U+02CD (placeholder) + { 2494, 0, 0, 5, 0, 0 }, // U+02CE (placeholder) + { 2494, 0, 0, 5, 0, 0 }, // U+02CF (placeholder) + { 2494, 3, 6, 4, 0, -6 }, // U+02D0 MODIFIER LETTER TRIANGULAR COLON + { 2497, 3, 2, 4, 0, -4 }, // U+02D1 MODIFIER LETTER HALF TRIANGULAR COLON + { 2498, 0, 0, 5, 0, 0 }, // U+02D2 (placeholder) + { 2498, 0, 0, 5, 0, 0 }, // U+02D3 (placeholder) + { 2498, 0, 0, 5, 0, 0 }, // U+02D4 (placeholder) + { 2498, 0, 0, 5, 0, 0 }, // U+02D5 (placeholder) + { 2498, 0, 0, 5, 0, 0 }, // U+02D6 (placeholder) + { 2498, 0, 0, 5, 0, 0 }, // U+02D7 (placeholder) + { 2498, 5, 2, 6, 0, -8 }, // U+02D8 BREVE + { 2500, 2, 2, 3, 0, -9 }, // U+02D9 DOT ABOVE + { 2501, 3, 3, 4, 0, -8 }, // U+02DA RING ABOVE + { 2503, 2, 3, 3, 0, -1 }, // U+02DB OGONEK + { 2504, 5, 2, 6, 0, -8 }, // U+02DC SMALL TILDE + { 2506, 4, 3, 5, 0, -9 }, // U+02DD DOUBLE ACUTE ACCENT + { 2508, 0, 0, 5, 0, 0 }, // U+02DE (placeholder) + { 2508, 0, 0, 5, 0, 0 }, // U+02DF (placeholder) + { 2508, 5, 5, 6, 0, -6 }, // U+02E0 MODIFIER LETTER SMALL GAMMA + { 2512, 0, 0, 5, 0, 0 }, // U+02E1 (placeholder) + { 2512, 0, 0, 5, 0, 0 }, // U+02E2 (placeholder) + { 2512, 0, 0, 5, 0, 0 }, // U+02E3 (placeholder) + { 2512, 3, 5, 4, 0, -8 }, // U+02E4 MODIFIER LETTER SMALL REVERSED GLOTTAL STOP + { 2514, 0, 0, 5, 0, 0 }, // U+02E5 (placeholder) + { 2514, 0, 0, 5, 0, 0 }, // U+02E6 (placeholder) + { 2514, 0, 0, 5, 0, 0 }, // U+02E7 (placeholder) + { 2514, 0, 0, 5, 0, 0 }, // U+02E8 (placeholder) + { 2514, 0, 0, 5, 0, 0 }, // U+02E9 (placeholder) + { 2514, 0, 0, 5, 0, 0 }, // U+02EA (placeholder) + { 2514, 0, 0, 5, 0, 0 }, // U+02EB (placeholder) + { 2514, 0, 0, 5, 0, 0 }, // U+02EC (placeholder) + { 2514, 0, 0, 5, 0, 0 }, // U+02ED (placeholder) + { 2514, 5, 4, 6, 0, -7 }, // U+02EE MODIFIER LETTER DOUBLE APOSTROPHE + { 2517, 0, 0, 5, 0, 0 }, // U+02EF (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F0 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F1 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F2 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F3 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F4 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F5 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F6 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F7 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F8 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F9 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02FA (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02FB (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02FC (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02FD (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02FE (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02FF (placeholder) + { 2517, 2, 2, 3, 0, -9 }, // U+0300 COMBINING GRAVE ACCENT + { 2518, 2, 2, 3, 0, -9 }, // U+0301 COMBINING ACUTE ACCENT + { 2519, 4, 2, 5, 0, -9 }, // U+0302 COMBINING CIRCUMFLEX ACCENT + { 2520, 5, 2, 6, 0, -9 }, // U+0303 COMBINING TILDE + { 2522, 3, 1, 4, 0, -9 }, // U+0304 COMBINING MACRON + { 2523, 5, 1, 6, 0, -9 }, // U+0305 COMBINING OVERLINE + { 2524, 4, 2, 5, 0, -9 }, // U+0306 COMBINING BREVE + { 2525, 1, 1, 2, 0, -9 }, // U+0307 COMBINING DOT ABOVE + { 2526, 3, 1, 4, 0, -9 }, // U+0308 COMBINING DIAERESIS + { 2527, 3, 3, 4, 0, -9 }, // U+0309 COMBINING HOOK ABOVE + { 2529, 3, 3, 4, 0, -9 }, // U+030A COMBINING RING ABOVE + { 2531, 5, 2, 6, 0, -9 }, // U+030B COMBINING DOUBLE ACUTE ACCENT + { 2533, 3, 2, 4, 0, -9 }, // U+030C COMBINING CARON + { 2534, 1, 2, 2, 0, -9 }, // U+030D COMBINING VERTICAL LINE ABOVE + { 2535, 3, 2, 4, 0, -9 }, // U+030E COMBINING DOUBLE VERTICAL LINE ABOVE + { 2536, 0, 0, 5, 0, 0 }, // U+030F (placeholder) + { 2536, 5, 3, 6, 0, -9 }, // U+0310 COMBINING CANDRABINDU + { 2538, 5, 2, 6, 0, -9 }, // U+0311 COMBINING INVERTED BREVE + { 2540, 2, 3, 3, 0, -9 }, // U+0312 COMBINING TURNED COMMA ABOVE + { 2541, 2, 3, 3, 0, -9 }, // U+0313 COMBINING COMMA ABOVE + { 2542, 2, 3, 3, 0, -9 }, // U+0314 COMBINING REVERSED COMMA ABOVE + { 2543, 2, 3, 3, 0, -9 }, // U+0315 COMBINING COMMA ABOVE RIGHT + { 2544, 0, 0, 5, 0, 0 }, // U+0316 (placeholder) + { 2544, 0, 0, 5, 0, 0 }, // U+0317 (placeholder) + { 2544, 0, 0, 5, 0, 0 }, // U+0318 (placeholder) + { 2544, 0, 0, 5, 0, 0 }, // U+0319 (placeholder) + { 2544, 4, 3, 5, 0, -9 }, // U+031A COMBINING LEFT ANGLE ABOVE + { 2546, 0, 0, 5, 0, 0 }, // U+031B (placeholder) + { 2546, 2, 3, 3, 0, 0 }, // U+031C COMBINING LEFT HALF RING BELOW + { 2547, 0, 0, 5, 0, 0 }, // U+031D (placeholder) + { 2547, 0, 0, 5, 0, 0 }, // U+031E (placeholder) + { 2547, 3, 3, 4, 0, 0 }, // U+031F COMBINING PLUS SIGN BELOW + { 2549, 3, 1, 4, 0, 1 }, // U+0320 COMBINING MINUS SIGN BELOW + { 2550, 0, 0, 5, 0, 0 }, // U+0321 (placeholder) + { 2550, 0, 0, 5, 0, 0 }, // U+0322 (placeholder) + { 2550, 0, 0, 5, 0, 0 }, // U+0323 (placeholder) + { 2550, 4, 1, 5, 0, 1 }, // U+0324 COMBINING DIAERESIS BELOW + { 2551, 4, 3, 5, 0, 0 }, // U+0325 COMBINING RING BELOW + { 2553, 0, 0, 5, 0, 0 }, // U+0326 (placeholder) + { 2553, 0, 0, 5, 0, 0 }, // U+0327 (placeholder) + { 2553, 0, 0, 5, 0, 0 }, // U+0328 (placeholder) + { 2553, 0, 0, 5, 0, 0 }, // U+0329 (placeholder) + { 2553, 3, 2, 4, 0, 0 }, // U+032A COMBINING BRIDGE BELOW + { 2554, 0, 0, 5, 0, 0 }, // U+032B (placeholder) + { 2554, 3, 2, 4, 0, 0 }, // U+032C COMBINING CARON BELOW + { 2555, 0, 0, 5, 0, 0 }, // U+032D (placeholder) + { 2555, 0, 0, 5, 0, 0 }, // U+032E (placeholder) + { 2555, 0, 0, 5, 0, 0 }, // U+032F (placeholder) + { 2555, 4, 2, 5, 0, 1 }, // U+0330 COMBINING TILDE BELOW + { 2556, 0, 0, 5, 0, 0 }, // U+0331 (placeholder) + { 2556, 0, 0, 5, 0, 0 }, // U+0332 (placeholder) + { 2556, 0, 0, 5, 0, 0 }, // U+0333 (placeholder) + { 2556, 0, 0, 5, 0, 0 }, // U+0334 (placeholder) + { 2556, 0, 0, 5, 0, 0 }, // U+0335 (placeholder) + { 2556, 0, 0, 5, 0, 0 }, // U+0336 (placeholder) + { 2556, 0, 0, 5, 0, 0 }, // U+0337 (placeholder) + { 2556, 0, 0, 5, 0, 0 }, // U+0338 (placeholder) + { 2556, 4, 3, 5, 0, 0 }, // U+0339 COMBINING RIGHT HALF RING BELOW + { 2558, 3, 2, 4, 0, 1 }, // U+033A COMBINING INVERTED BRIDGE BELOW + { 2559, 4, 3, 5, 0, 0 }, // U+033B COMBINING SQUARE BELOW + { 2561, 5, 2, 6, 0, 1 }, // U+033C COMBINING SEAGULL BELOW + { 2563, 0, 0, 5, 0, 0 }, // U+033D (placeholder) + { 2563, 0, 0, 5, 0, 0 }, // U+033E (placeholder) + { 2563, 0, 0, 5, 0, 0 }, // U+033F (placeholder) + { 2563, 0, 0, 5, 0, 0 }, // U+0340 (placeholder) + { 2563, 0, 0, 5, 0, 0 }, // U+0341 (placeholder) + { 2563, 0, 0, 5, 0, 0 }, // U+0342 (placeholder) + { 2563, 2, 4, 3, 0, -7 }, // U+0343 COMBINING GREEK KORONIS + { 2564, 3, 2, 4, 0, -4 }, // U+0344 COMBINING GREEK DIALYTIKA TONOS + { 2565, 0, 0, 5, 0, 0 }, // U+0345 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0346 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0347 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0348 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0349 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+034A (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+034B (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+034C (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+034D (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+034E (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+034F (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0350 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0351 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0352 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0353 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0354 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0355 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0356 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0357 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0358 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0359 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+035A (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+035B (placeholder) + { 2565, 5, 2, 6, 0, -1 }, // U+035C COMBINING DOUBLE BREVE BELOW + { 2567, 0, 0, 5, 0, 0 }, // U+035D (placeholder) + { 2567, 0, 0, 5, 0, 0 }, // U+035E (placeholder) + { 2567, 0, 0, 5, 0, 0 }, // U+035F (placeholder) + { 2567, 0, 0, 5, 0, 0 }, // U+0360 (placeholder) + { 2567, 5, 2, 6, 0, -9 }, // U+0361 COMBINING DOUBLE INVERTED BREVE + { 2569, 0, 0, 5, 0, 0 }, // U+0362 (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+0363 (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+0364 (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+0365 (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+0366 (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+0367 (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+0368 (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+0369 (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+036A (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+036B (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+036C (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+036D (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+036E (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+036F (placeholder) + { 2569, 4, 7, 5, 0, -7 }, // U+0370 GREEK CAPITAL LETTER HETA + { 2573, 5, 6, 6, 0, -6 }, // U+0371 GREEK SMALL LETTER HETA + { 2577, 5, 6, 6, 0, -6 }, // U+0372 GREEK CAPITAL LETTER ARCHAIC SAMPI + { 2581, 5, 5, 6, 0, -5 }, // U+0373 GREEK SMALL LETTER ARCHAIC SAMPI + { 2585, 3, 2, 4, 0, -8 }, // U+0374 GREEK NUMERAL SIGN + { 2586, 4, 3, 5, 0, -2 }, // U+0375 GREEK LOWER NUMERAL SIGN + { 2588, 4, 6, 5, 0, -6 }, // U+0376 uni004E + { 2591, 5, 6, 6, 0, -5 }, // U+0377 GREEK SMALL LETTER PAMPHYLIAN DIGAMMA + { 2595, 0, 0, 5, 0, 0 }, // U+0378 (placeholder) + { 2595, 0, 0, 5, 0, 0 }, // U+0379 (placeholder) + { 2595, 5, 2, 6, 0, 0 }, // U+037A GREEK YPOGEGRAMMENI + { 2597, 5, 5, 6, 0, -5 }, // U+037B GREEK SMALL REVERSED LUNATE SIGMA SYMBOL + { 2601, 5, 5, 6, 0, -5 }, // U+037C GREEK SMALL DOTTED LUNATE SIGMA SYMBOL + { 2605, 5, 5, 6, 0, -5 }, // U+037D GREEK SMALL REVERSED DOTTED LUNATE SIGMA SYMBOL + { 2609, 2, 6, 3, 0, -5 }, // U+037E uni003B + { 2611, 6, 3, 7, 0, -4 }, // U+037F uni037F + { 2614, 5, 3, 6, 0, -3 }, // U+0380 uni037F + { 2616, 5, 3, 6, 0, -3 }, // U+0381 uni0380 + { 2618, 0, 0, 5, 0, 0 }, // U+0382 (placeholder) + { 2618, 0, 0, 5, 0, 0 }, // U+0383 (placeholder) + { 2618, 2, 3, 3, 0, -8 }, // U+0384 GREEK TONOS + { 2619, 6, 3, 7, 0, -8 }, // U+0385 GREEK DIALYTIKA TONOS + { 2622, 4, 9, 5, 0, -9 }, // U+0386 GREEK CAPITAL LETTER ALPHA WITH TONOS + { 2627, 2, 2, 3, 0, -5 }, // U+0387 GREEK ANO TELEIA + { 2628, 4, 8, 5, 0, -8 }, // U+0388 char201 + { 2632, 5, 7, 6, 0, -7 }, // U+0389 GREEK CAPITAL LETTER ETA WITH TONOS + { 2637, 4, 7, 5, 0, -7 }, // U+038A GREEK CAPITAL LETTER IOTA WITH TONOS + { 2641, 0, 0, 5, 0, 0 }, // U+038B (placeholder) + { 2641, 5, 8, 6, 0, -8 }, // U+038C GREEK CAPITAL LETTER OMICRON WITH TONOS + { 2646, 0, 0, 5, 0, 0 }, // U+038D (placeholder) + { 2646, 5, 7, 6, 0, -7 }, // U+038E GREEK CAPITAL LETTER UPSILON WITH TONOS + { 2651, 5, 8, 6, 0, -8 }, // U+038F GREEK CAPITAL LETTER OMEGA WITH TONOS + { 2656, 6, 9, 7, 0, -9 }, // U+0390 GREEK SMALL LETTER IOTA WITH DIALYTIKA AND TONOS + { 2663, 4, 6, 5, 0, -6 }, // U+0391 GREEK CAPITAL LETTER ALPHA + { 2666, 4, 6, 5, 0, -6 }, // U+0392 uni0042 + { 2669, 4, 5, 5, 0, -5 }, // U+0393 GREEK CAPITAL LETTER GAMMA + { 2672, 5, 6, 6, 0, -6 }, // U+0394 GREEK CAPITAL LETTER DELTA + { 2676, 4, 6, 5, 0, -6 }, // U+0395 uni0045 + { 2679, 4, 6, 5, 0, -6 }, // U+0396 uni005A + { 2682, 4, 6, 5, 0, -6 }, // U+0397 uni0048 + { 2685, 4, 6, 5, 0, -6 }, // U+0398 GREEK CAPITAL LETTER THETA + { 2688, 3, 6, 4, 0, -6 }, // U+0399 GREEK CAPITAL LETTER IOTA + { 2691, 4, 6, 5, 0, -6 }, // U+039A uni004B + { 2694, 5, 6, 6, 0, -6 }, // U+039B GREEK CAPITAL LETTER LAMDA + { 2698, 4, 6, 5, 0, -6 }, // U+039C uni004D + { 2701, 4, 6, 5, 0, -6 }, // U+039D uni004E + { 2704, 5, 5, 6, 0, -5 }, // U+039E GREEK CAPITAL LETTER XI + { 2708, 4, 6, 5, 0, -6 }, // U+039F GREEK CAPITAL LETTER OMICRON + { 2711, 5, 6, 6, 0, -6 }, // U+03A0 char1055 + { 2715, 4, 6, 5, 0, -6 }, // U+03A1 uni0050 + { 2718, 0, 0, 5, 0, 0 }, // U+03A2 (placeholder) + { 2718, 5, 6, 6, 0, -6 }, // U+03A3 GREEK CAPITAL LETTER SIGMA + { 2722, 5, 6, 6, 0, -6 }, // U+03A4 GREEK CAPITAL LETTER TAU + { 2726, 5, 6, 6, 0, -6 }, // U+03A5 uni0059 + { 2730, 5, 7, 6, 0, -7 }, // U+03A6 GREEK CAPITAL LETTER PHI + { 2735, 4, 6, 5, 0, -6 }, // U+03A7 uni0058 + { 2738, 5, 6, 6, 0, -6 }, // U+03A8 GREEK CAPITAL LETTER PSI + { 2742, 5, 6, 6, 0, -6 }, // U+03A9 GREEK CAPITAL LETTER OMEGA + { 2746, 3, 7, 4, 0, -7 }, // U+03AA GREEK CAPITAL LETTER IOTA WITH DIALYTIKA + { 2749, 5, 8, 6, 0, -8 }, // U+03AB GREEK CAPITAL LETTER UPSILON WITH DIALYTIKA + { 2754, 4, 8, 5, 0, -8 }, // U+03AC char225 + { 2758, 4, 8, 5, 0, -8 }, // U+03AD GREEK SMALL LETTER EPSILON WITH TONOS + { 2762, 4, 9, 5, 0, -8 }, // U+03AE uni006E + { 2767, 3, 8, 4, 0, -8 }, // U+03AF GREEK SMALL LETTER IOTA WITH TONOS + { 2770, 6, 8, 7, 0, -8 }, // U+03B0 GREEK SMALL LETTER UPSILON WITH DIALYTIKA AND TONOS + { 2776, 4, 5, 5, 0, -5 }, // U+03B1 uni0061 + { 2779, 4, 7, 5, 0, -6 }, // U+03B2 char223 + { 2783, 4, 7, 5, 0, -5 }, // U+03B3 uni0076 + { 2787, 4, 7, 5, 0, -7 }, // U+03B4 GREEK SMALL LETTER DELTA + { 2791, 4, 5, 5, 0, -5 }, // U+03B5 GREEK SMALL LETTER EPSILON + { 2794, 4, 7, 5, 0, -6 }, // U+03B6 GREEK SMALL LETTER ZETA + { 2798, 4, 6, 5, 0, -5 }, // U+03B7 GREEK SMALL LETTER ETA + { 2801, 4, 5, 5, 0, -5 }, // U+03B8 GREEK SMALL LETTER THETA + { 2804, 2, 5, 3, 0, -5 }, // U+03B9 GREEK SMALL LETTER IOTA + { 2806, 4, 5, 5, 0, -5 }, // U+03BA uni006B + { 2809, 4, 7, 5, 0, -7 }, // U+03BB GREEK SMALL LETTER LAMDA + { 2813, 4, 6, 5, 0, -5 }, // U+03BC uni0075 + { 2816, 4, 5, 5, 0, -5 }, // U+03BD uni0076 + { 2819, 4, 8, 5, 0, -6 }, // U+03BE GREEK SMALL LETTER XI + { 2823, 4, 5, 5, 0, -5 }, // U+03BF GREEK SMALL LETTER OMICRON + { 2826, 5, 5, 6, 0, -5 }, // U+03C0 char960 + { 2830, 4, 8, 5, 0, -5 }, // U+03C1 uni0070 + { 2834, 4, 7, 5, 0, -5 }, // U+03C2 char231 + { 2838, 5, 5, 6, 0, -5 }, // U+03C3 char240 + { 2842, 4, 5, 5, 0, -5 }, // U+03C4 GREEK SMALL LETTER TAU + { 2845, 4, 5, 5, 0, -5 }, // U+03C5 uni0075 + { 2848, 5, 7, 6, 0, -5 }, // U+03C6 GREEK SMALL LETTER PHI + { 2853, 4, 5, 5, 0, -5 }, // U+03C7 uni0078 + { 2856, 5, 7, 6, 0, -5 }, // U+03C8 GREEK SMALL LETTER PSI + { 2861, 5, 5, 6, 0, -5 }, // U+03C9 GREEK SMALL LETTER OMEGA + { 2865, 3, 7, 4, 0, -7 }, // U+03CA GREEK SMALL LETTER IOTA WITH DIALYTIKA + { 2868, 5, 7, 6, 0, -7 }, // U+03CB char252 + { 2873, 4, 8, 5, 0, -8 }, // U+03CC char243 + { 2877, 5, 8, 6, 0, -8 }, // U+03CD char250 + { 2882, 5, 8, 6, 0, -8 }, // U+03CE GREEK SMALL LETTER OMEGA WITH TONOS + { 2887, 4, 7, 5, 0, -6 }, // U+03CF uni004B + { 2891, 4, 6, 5, 0, -6 }, // U+03D0 GREEK BETA SYMBOL + { 2894, 6, 6, 7, 0, -6 }, // U+03D1 GREEK THETA SYMBOL + { 2899, 6, 6, 7, 0, -6 }, // U+03D2 uni0059 + { 2904, 6, 7, 7, 0, -7 }, // U+03D3 uni0059 + { 2910, 6, 8, 7, 0, -8 }, // U+03D4 uni0059 + { 2916, 5, 9, 6, 0, -7 }, // U+03D5 GREEK PHI SYMBOL + { 2922, 5, 5, 6, 0, -5 }, // U+03D6 GREEK PI SYMBOL + { 2926, 5, 6, 6, 0, -5 }, // U+03D7 uni0078 + { 2930, 5, 8, 6, 0, -6 }, // U+03D8 GREEK LETTER ARCHAIC KOPPA + { 2935, 5, 7, 6, 0, -5 }, // U+03D9 GREEK SMALL LETTER ARCHAIC KOPPA + { 2940, 5, 8, 6, 0, -6 }, // U+03DA GREEK LETTER STIGMA + { 2945, 5, 7, 6, 0, -5 }, // U+03DB GREEK SMALL LETTER STIGMA + { 2950, 4, 6, 5, 0, -6 }, // U+03DC uni0046 + { 2953, 4, 6, 5, 0, -5 }, // U+03DD uni0046 + { 2956, 6, 6, 7, 0, -5 }, // U+03DE GREEK LETTER KOPPA + { 2961, 3, 7, 4, 0, -6 }, // U+03DF GREEK SMALL LETTER KOPPA + { 2964, 4, 7, 5, 0, -6 }, // U+03E0 GREEK LETTER SAMPI + { 2968, 4, 7, 5, 0, -6 }, // U+03E1 GREEK SMALL LETTER SAMPI + { 2972, 5, 7, 6, 0, -6 }, // U+03E2 COPTIC CAPITAL LETTER SHEI + { 2977, 5, 6, 6, 0, -4 }, // U+03E3 COPTIC SMALL LETTER SHEI + { 2981, 4, 7, 5, 0, -7 }, // U+03E4 COPTIC CAPITAL LETTER FEI + { 2985, 4, 6, 5, 0, -6 }, // U+03E5 COPTIC SMALL LETTER FEI + { 2988, 4, 9, 5, 0, -7 }, // U+03E6 COPTIC CAPITAL LETTER KHEI + { 2993, 5, 7, 6, 0, -6 }, // U+03E7 COPTIC SMALL LETTER KHEI + { 2998, 4, 6, 5, 0, -6 }, // U+03E8 uni0053 + { 3001, 4, 6, 5, 0, -6 }, // U+03E9 uni0053 + { 3004, 5, 7, 6, 0, -7 }, // U+03EA COPTIC CAPITAL LETTER GANGIA + { 3009, 5, 5, 6, 0, -5 }, // U+03EB COPTIC SMALL LETTER GANGIA + { 3013, 4, 7, 5, 0, -7 }, // U+03EC COPTIC CAPITAL LETTER SHIMA + { 3017, 4, 6, 5, 0, -6 }, // U+03ED COPTIC SMALL LETTER SHIMA + { 3020, 5, 6, 6, 0, -6 }, // U+03EE COPTIC CAPITAL LETTER DEI + { 3024, 5, 6, 6, 0, -6 }, // U+03EF COPTIC SMALL LETTER DEI + { 3028, 4, 5, 5, 0, -5 }, // U+03F0 uni0078 + { 3031, 4, 8, 5, 0, -5 }, // U+03F1 GREEK RHO SYMBOL + { 3035, 4, 5, 5, 0, -5 }, // U+03F2 uni0063 + { 3038, 3, 9, 4, 0, -7 }, // U+03F3 uni006A + { 3042, 4, 6, 5, 0, -6 }, // U+03F4 GREEK CAPITAL LETTER THETA + { 3045, 4, 5, 5, 0, -5 }, // U+03F5 GREEK LUNATE EPSILON SYMBOL + { 3048, 4, 5, 5, 0, -5 }, // U+03F6 GREEK REVERSED LUNATE EPSILON SYMBOL + { 3051, 4, 6, 5, 0, -6 }, // U+03F7 GREEK CAPITAL LETTER SHO + { 3054, 4, 7, 5, 0, -6 }, // U+03F8 GREEK SMALL LETTER SHO + { 3058, 4, 6, 5, 0, -6 }, // U+03F9 uni0043 + { 3061, 4, 6, 5, 0, -6 }, // U+03FA uni004D + { 3064, 4, 6, 5, 0, -4 }, // U+03FB GREEK SMALL LETTER SAN + { 3067, 4, 8, 5, 0, -5 }, // U+03FC uni0070 + { 3071, 5, 6, 6, 0, -6 }, // U+03FD uni0043 + { 3075, 4, 6, 5, 0, -6 }, // U+03FE uni0043 + { 3078, 4, 6, 5, 0, -6 }, // U+03FF uni0043 + { 3081, 4, 8, 5, 0, -8 }, // U+0400 char203 + { 3085, 4, 8, 5, 0, -8 }, // U+0401 char203 + { 3089, 5, 8, 6, 0, -7 }, // U+0402 char1026 + { 3094, 4, 8, 5, 0, -8 }, // U+0403 char1027 + { 3098, 4, 6, 5, 0, -6 }, // U+0404 char1028 + { 3101, 5, 6, 6, 0, -6 }, // U+0405 char1029 + { 3105, 3, 6, 4, 0, -6 }, // U+0406 char1030 + { 3108, 3, 7, 4, 0, -7 }, // U+0407 char1031 + { 3111, 5, 6, 6, 0, -6 }, // U+0408 char1032 + { 3115, 6, 6, 7, 0, -6 }, // U+0409 char1033 + { 3120, 5, 6, 6, 0, -6 }, // U+040A char1034 + { 3124, 5, 7, 6, 0, -7 }, // U+040B char1035 + { 3129, 5, 7, 6, 0, -7 }, // U+040C char1036 + { 3134, 4, 8, 5, 0, -8 }, // U+040D char1080 + { 3138, 4, 8, 5, 0, -8 }, // U+040E char1038 + { 3142, 5, 7, 6, 0, -6 }, // U+040F char1039 + { 3147, 4, 6, 5, 0, -6 }, // U+0410 char1040 + { 3150, 5, 7, 6, 0, -7 }, // U+0411 char1041 + { 3155, 4, 6, 5, 0, -6 }, // U+0412 char1042 + { 3158, 4, 6, 5, 0, -6 }, // U+0413 char1043 + { 3161, 5, 7, 6, 0, -6 }, // U+0414 char1044 + { 3166, 4, 6, 5, 0, -6 }, // U+0415 uni0045 + { 3169, 5, 6, 6, 0, -6 }, // U+0416 uni0058 + { 3173, 4, 6, 5, 0, -6 }, // U+0417 uni0033 + { 3176, 5, 6, 6, 0, -6 }, // U+0418 char1080 + { 3180, 4, 8, 5, 0, -8 }, // U+0419 char1080 + { 3184, 4, 6, 5, 0, -6 }, // U+041A uni004B + { 3187, 4, 6, 5, 0, -6 }, // U+041B char1051 + { 3190, 4, 6, 5, 0, -6 }, // U+041C uni004D + { 3193, 4, 6, 5, 0, -6 }, // U+041D uni0048 + { 3196, 4, 6, 5, 0, -6 }, // U+041E uni004F + { 3199, 5, 6, 6, 0, -6 }, // U+041F char1055 + { 3203, 5, 6, 6, 0, -6 }, // U+0420 char1104 + { 3207, 4, 6, 5, 0, -6 }, // U+0421 uni0043 + { 3210, 3, 6, 4, 0, -6 }, // U+0422 uni0054 + { 3213, 4, 6, 5, 0, -6 }, // U+0423 uni0079 + { 3216, 5, 8, 6, 0, -7 }, // U+0424 char1060 + { 3221, 4, 6, 5, 0, -6 }, // U+0425 uni0058 + { 3224, 4, 7, 5, 0, -6 }, // U+0426 char1062 + { 3228, 4, 6, 5, 0, -6 }, // U+0427 char1063 + { 3231, 5, 6, 6, 0, -6 }, // U+0428 char1064 + { 3235, 6, 7, 7, 0, -6 }, // U+0429 char1065 + { 3241, 4, 6, 5, 0, -6 }, // U+042A char1066 + { 3244, 5, 6, 6, 0, -6 }, // U+042B char1067 + { 3248, 5, 6, 6, 0, -6 }, // U+042C char1068 + { 3252, 4, 6, 5, 0, -6 }, // U+042D uni0033 + { 3255, 5, 6, 6, 0, -6 }, // U+042E char1070 + { 3259, 4, 6, 5, 0, -6 }, // U+042F char1071 + { 3262, 4, 5, 5, 0, -5 }, // U+0430 uni0061 + { 3265, 4, 7, 5, 0, -7 }, // U+0431 char1073 + { 3269, 4, 5, 5, 0, -5 }, // U+0432 char1074 + { 3272, 4, 5, 5, 0, -5 }, // U+0433 char1075 + { 3275, 5, 7, 6, 0, -6 }, // U+0434 char1044 + { 3280, 4, 5, 5, 0, -5 }, // U+0435 uni0065 + { 3283, 5, 5, 6, 0, -5 }, // U+0436 uni0058 + { 3287, 4, 5, 5, 0, -5 }, // U+0437 uni0033 + { 3290, 4, 5, 5, 0, -5 }, // U+0438 char1080 + { 3293, 4, 7, 5, 0, -7 }, // U+0439 char1080 + { 3297, 4, 5, 5, 0, -5 }, // U+043A uni006B + { 3300, 4, 5, 5, 0, -5 }, // U+043B char1051 + { 3303, 4, 5, 5, 0, -5 }, // U+043C uni006D + { 3306, 4, 5, 5, 0, -5 }, // U+043D uni006D + { 3309, 4, 5, 5, 0, -5 }, // U+043E uni006F + { 3312, 4, 5, 5, 0, -5 }, // U+043F uni006E + { 3315, 4, 7, 5, 0, -5 }, // U+0440 uni0070 + { 3319, 4, 5, 5, 0, -5 }, // U+0441 uni0063 + { 3322, 3, 5, 4, 0, -5 }, // U+0442 uni0074 + { 3324, 4, 6, 5, 0, -4 }, // U+0443 uni0079 + { 3327, 5, 7, 6, 0, -6 }, // U+0444 char1060 + { 3332, 4, 5, 5, 0, -5 }, // U+0445 uni0078 + { 3335, 4, 6, 5, 0, -5 }, // U+0446 char1062 + { 3338, 4, 5, 5, 0, -5 }, // U+0447 char1063 + { 3341, 5, 5, 6, 0, -5 }, // U+0448 char1064 + { 3345, 6, 6, 7, 0, -5 }, // U+0449 char1065 + { 3350, 4, 5, 5, 0, -5 }, // U+044A char1066 + { 3353, 4, 5, 5, 0, -5 }, // U+044B char1067 + { 3356, 4, 5, 5, 0, -5 }, // U+044C char1068 + { 3359, 4, 5, 5, 0, -5 }, // U+044D uni0033 + { 3362, 5, 5, 6, 0, -5 }, // U+044E char1070 + { 3366, 4, 5, 5, 0, -5 }, // U+044F char1071 + { 3369, 4, 8, 5, 0, -8 }, // U+0450 uni0065 + { 3373, 4, 7, 5, 0, -7 }, // U+0451 uni0065 + { 3377, 5, 7, 6, 0, -6 }, // U+0452 char1026 + { 3382, 4, 7, 5, 0, -7 }, // U+0453 char1027 + { 3386, 4, 5, 5, 0, -5 }, // U+0454 char1028 + { 3389, 4, 5, 5, 0, -5 }, // U+0455 uni0073 + { 3392, 2, 7, 3, 0, -7 }, // U+0456 uni0069 + { 3394, 3, 7, 4, 0, -7 }, // U+0457 uni0069 + { 3397, 3, 9, 4, 0, -7 }, // U+0458 uni006A + { 3401, 6, 5, 7, 0, -5 }, // U+0459 CYRILLIC SMALL LETTER LJE + { 3405, 5, 5, 6, 0, -5 }, // U+045A CYRILLIC SMALL LETTER NJE + { 3409, 5, 7, 6, 0, -7 }, // U+045B CYRILLIC SMALL LETTER TSHE + { 3414, 4, 8, 5, 0, -8 }, // U+045C CYRILLIC SMALL LETTER KJE + { 3418, 4, 8, 5, 0, -8 }, // U+045D CYRILLIC SMALL LETTER I WITH GRAVE + { 3422, 4, 10, 5, 0, -8 }, // U+045E uni0079 + { 3427, 5, 7, 6, 0, -5 }, // U+045F uni0079 + { 3432, 5, 7, 6, 0, -7 }, // U+0460 CYRILLIC CAPITAL LETTER OMEGA + { 3437, 5, 5, 6, 0, -5 }, // U+0461 CYRILLIC SMALL LETTER OMEGA + { 3441, 5, 7, 6, 0, -7 }, // U+0462 char1122 + { 3446, 5, 6, 6, 0, -6 }, // U+0463 char1123 + { 3450, 5, 7, 6, 0, -7 }, // U+0464 CYRILLIC CAPITAL LETTER IOTIFIED E + { 3455, 5, 5, 6, 0, -5 }, // U+0465 CYRILLIC SMALL LETTER IOTIFIED E + { 3459, 5, 6, 6, 0, -6 }, // U+0466 char1126 + { 3463, 5, 5, 6, 0, -5 }, // U+0467 char1127 + { 3467, 5, 6, 6, 0, -6 }, // U+0468 char1128 + { 3471, 5, 5, 6, 0, -5 }, // U+0469 char1129 + { 3475, 5, 7, 6, 0, -7 }, // U+046A CYRILLIC CAPITAL LETTER BIG YUS + { 3480, 5, 5, 6, 0, -5 }, // U+046B CYRILLIC SMALL LETTER BIG YUS + { 3484, 5, 7, 6, 0, -7 }, // U+046C CYRILLIC CAPITAL LETTER IOTIFIED BIG YUS + { 3489, 5, 5, 6, 0, -5 }, // U+046D CYRILLIC SMALL LETTER IOTIFIED BIG YUS + { 3493, 4, 11, 5, 0, -9 }, // U+046E CYRILLIC CAPITAL LETTER KSI + { 3499, 4, 10, 5, 0, -8 }, // U+046F CYRILLIC SMALL LETTER KSI + { 3504, 5, 7, 6, 0, -7 }, // U+0470 CYRILLIC CAPITAL LETTER PSI + { 3509, 5, 6, 6, 0, -6 }, // U+0471 CYRILLIC SMALL LETTER PSI + { 3513, 4, 7, 5, 0, -7 }, // U+0472 CYRILLIC CAPITAL LETTER FITA + { 3517, 4, 5, 5, 0, -5 }, // U+0473 CYRILLIC SMALL LETTER FITA + { 3520, 5, 6, 6, 0, -6 }, // U+0474 char1140 + { 3524, 5, 5, 6, 0, -5 }, // U+0475 char1141 + { 3528, 6, 8, 7, 0, -8 }, // U+0476 char1142 + { 3534, 5, 7, 6, 0, -7 }, // U+0477 char1143 + { 3539, 5, 9, 6, 0, -7 }, // U+0478 CYRILLIC CAPITAL LETTER UK + { 3545, 5, 7, 6, 0, -5 }, // U+0479 CYRILLIC SMALL LETTER UK + { 3550, 5, 9, 6, 0, -8 }, // U+047A CYRILLIC CAPITAL LETTER ROUND OMEGA + { 3556, 5, 7, 6, 0, -6 }, // U+047B CYRILLIC SMALL LETTER ROUND OMEGA + { 3561, 5, 9, 6, 0, -9 }, // U+047C CYRILLIC CAPITAL LETTER OMEGA WITH TITLO + { 3567, 5, 8, 6, 0, -8 }, // U+047D CYRILLIC SMALL LETTER OMEGA WITH TITLO + { 3572, 5, 9, 6, 0, -9 }, // U+047E CYRILLIC CAPITAL LETTER OT + { 3578, 5, 8, 6, 0, -8 }, // U+047F CYRILLIC SMALL LETTER OT + { 3583, 4, 8, 5, 0, -6 }, // U+0480 uni0043 + { 3587, 4, 7, 5, 0, -5 }, // U+0481 uni0063 + { 3591, 4, 8, 5, 0, -8 }, // U+0482 CYRILLIC THOUSANDS SIGN + { 3595, 4, 3, 5, 0, -7 }, // U+0483 char1155 + { 3597, 4, 8, 5, 0, -8 }, // U+0484 uni006F + { 3601, 4, 8, 5, 0, -8 }, // U+0485 uni006F + { 3605, 4, 8, 5, 0, -8 }, // U+0486 uni006F + { 3609, 4, 8, 5, 0, -8 }, // U+0487 uni006F + { 3613, 4, 8, 5, 0, -8 }, // U+0488 uni006F + { 3617, 7, 5, 8, 0, -5 }, // U+0489 char1161 + { 3622, 7, 5, 8, 0, -5 }, // U+048A char1161 + { 3627, 5, 9, 6, 0, -8 }, // U+048B char1080 + { 3633, 5, 8, 6, 0, -7 }, // U+048C char1080 + { 3638, 5, 6, 6, 0, -6 }, // U+048D char1068 + { 3642, 5, 6, 6, 0, -6 }, // U+048E uni0050 + { 3646, 5, 7, 6, 0, -5 }, // U+048F uni0070 + { 3651, 4, 7, 5, 0, -7 }, // U+0490 char1027 + { 3655, 4, 6, 5, 0, -6 }, // U+0491 char1075 + { 3658, 5, 6, 6, 0, -6 }, // U+0492 uni0046 + { 3662, 5, 5, 6, 0, -5 }, // U+0493 uni0046 + { 3666, 4, 7, 5, 0, -6 }, // U+0494 char1026 + { 3670, 4, 6, 5, 0, -5 }, // U+0495 char1026 + { 3673, 6, 7, 7, 0, -6 }, // U+0496 uni0058 + { 3679, 6, 6, 7, 0, -5 }, // U+0497 uni0058 + { 3684, 4, 8, 5, 0, -6 }, // U+0498 uni0033 + { 3688, 4, 7, 5, 0, -5 }, // U+0499 uni0033 + { 3692, 5, 7, 6, 0, -6 }, // U+049A uni004B + { 3697, 5, 6, 6, 0, -5 }, // U+049B uni006B + { 3701, 4, 6, 5, 0, -6 }, // U+049C uni004B + { 3704, 4, 5, 5, 0, -5 }, // U+049D uni006B + { 3707, 5, 6, 6, 0, -6 }, // U+049E uni004B + { 3711, 5, 5, 6, 0, -5 }, // U+049F uni006B + { 3715, 5, 6, 6, 0, -6 }, // U+04A0 uni004B + { 3719, 5, 5, 6, 0, -5 }, // U+04A1 uni006B + { 3723, 5, 7, 6, 0, -6 }, // U+04A2 uni0048 + { 3728, 5, 6, 6, 0, -5 }, // U+04A3 uni006D + { 3732, 5, 6, 6, 0, -6 }, // U+04A4 uni0048 + { 3736, 5, 5, 6, 0, -5 }, // U+04A5 uni006D + { 3740, 6, 8, 7, 0, -6 }, // U+04A6 char1055 + { 3746, 6, 7, 7, 0, -5 }, // U+04A7 uni006E + { 3752, 5, 7, 6, 0, -6 }, // U+04A8 char1060 + { 3757, 5, 6, 6, 0, -5 }, // U+04A9 char1060 + { 3761, 4, 8, 5, 0, -6 }, // U+04AA uni0043 + { 3765, 4, 7, 5, 0, -5 }, // U+04AB uni0063 + { 3769, 5, 7, 6, 0, -6 }, // U+04AC uni0054 + { 3774, 5, 6, 6, 0, -5 }, // U+04AD uni0054 + { 3778, 5, 6, 6, 0, -6 }, // U+04AE uni0059 + { 3782, 5, 6, 6, 0, -4 }, // U+04AF uni0059 + { 3786, 5, 6, 6, 0, -6 }, // U+04B0 uni0059 + { 3790, 5, 6, 6, 0, -4 }, // U+04B1 uni0059 + { 3794, 5, 7, 6, 0, -6 }, // U+04B2 uni0058 + { 3799, 5, 6, 6, 0, -5 }, // U+04B3 uni0078 + { 3803, 6, 7, 7, 0, -6 }, // U+04B4 uni0054 + { 3809, 6, 6, 7, 0, -5 }, // U+04B5 uni0054 + { 3814, 5, 7, 6, 0, -6 }, // U+04B6 char1063 + { 3819, 5, 6, 6, 0, -5 }, // U+04B7 char1063 + { 3823, 4, 6, 5, 0, -6 }, // U+04B8 char1063 + { 3826, 4, 5, 5, 0, -5 }, // U+04B9 char1063 + { 3829, 4, 7, 5, 0, -7 }, // U+04BA uni0068 + { 3833, 4, 7, 5, 0, -7 }, // U+04BB uni0068 + { 3837, 5, 6, 6, 0, -6 }, // U+04BC uni0065 + { 3841, 5, 5, 6, 0, -5 }, // U+04BD uni0065 + { 3845, 5, 7, 6, 0, -6 }, // U+04BE uni0065 + { 3850, 5, 6, 6, 0, -5 }, // U+04BF uni0065 + { 3854, 3, 6, 4, 0, -6 }, // U+04C0 char1030 + { 3857, 5, 8, 6, 0, -8 }, // U+04C1 uni0058 + { 3862, 5, 8, 6, 0, -8 }, // U+04C2 uni0058 + { 3867, 4, 7, 5, 0, -6 }, // U+04C3 uni004B + { 3871, 4, 6, 5, 0, -5 }, // U+04C4 uni006B + { 3874, 5, 7, 6, 0, -6 }, // U+04C5 char1051 + { 3879, 5, 6, 6, 0, -5 }, // U+04C6 char1051 + { 3883, 4, 7, 5, 0, -6 }, // U+04C7 uni0048 + { 3887, 4, 6, 5, 0, -5 }, // U+04C8 uni006D + { 3890, 5, 7, 6, 0, -6 }, // U+04C9 uni0048 + { 3895, 5, 6, 6, 0, -5 }, // U+04CA uni006D + { 3899, 4, 7, 5, 0, -6 }, // U+04CB char1063 + { 3903, 4, 6, 5, 0, -5 }, // U+04CC char1063 + { 3906, 5, 7, 6, 0, -6 }, // U+04CD uni004D + { 3911, 5, 6, 6, 0, -5 }, // U+04CE uni006D + { 3915, 3, 6, 4, 0, -6 }, // U+04CF char1030 + { 3918, 4, 8, 5, 0, -8 }, // U+04D0 char1040 + { 3922, 4, 8, 5, 0, -8 }, // U+04D1 uni0061 + { 3926, 4, 8, 5, 0, -8 }, // U+04D2 char1040 + { 3930, 4, 7, 5, 0, -7 }, // U+04D3 uni0061 + { 3934, 6, 6, 7, 0, -6 }, // U+04D4 char198 + { 3939, 5, 5, 6, 0, -5 }, // U+04D5 uni0061 + { 3943, 4, 8, 5, 0, -8 }, // U+04D6 uni0045 + { 3947, 4, 8, 5, 0, -8 }, // U+04D7 uni0065 + { 3951, 4, 6, 5, 0, -6 }, // U+04D8 uni0065 + { 3954, 4, 5, 5, 0, -5 }, // U+04D9 uni0065 + { 3957, 4, 8, 5, 0, -8 }, // U+04DA uni0065 + { 3961, 4, 7, 5, 0, -7 }, // U+04DB uni0065 + { 3965, 5, 8, 6, 0, -8 }, // U+04DC uni0058 + { 3970, 5, 7, 6, 0, -7 }, // U+04DD uni0058 + { 3975, 4, 8, 5, 0, -8 }, // U+04DE uni0033 + { 3979, 4, 7, 5, 0, -7 }, // U+04DF uni0033 + { 3983, 4, 8, 5, 0, -8 }, // U+04E0 uni0033 + { 3987, 4, 7, 5, 0, -7 }, // U+04E1 uni0033 + { 3991, 4, 8, 5, 0, -8 }, // U+04E2 char1080 + { 3995, 4, 7, 5, 0, -7 }, // U+04E3 char1080 + { 3999, 4, 8, 5, 0, -8 }, // U+04E4 char1080 + { 4003, 4, 7, 5, 0, -7 }, // U+04E5 char1080 + { 4007, 4, 8, 5, 0, -8 }, // U+04E6 uni004F + { 4011, 4, 7, 5, 0, -7 }, // U+04E7 uni006F + { 4015, 4, 6, 5, 0, -6 }, // U+04E8 uni004F + { 4018, 4, 5, 5, 0, -5 }, // U+04E9 uni006F + { 4021, 4, 8, 5, 0, -8 }, // U+04EA uni004F + { 4025, 4, 5, 5, 0, -5 }, // U+04EB uni006F + { 4028, 4, 8, 5, 0, -8 }, // U+04EC uni0033 + { 4032, 4, 7, 5, 0, -7 }, // U+04ED uni0033 + { 4036, 5, 8, 6, 0, -8 }, // U+04EE uni0059 + { 4041, 5, 8, 6, 0, -6 }, // U+04EF uni0059 + { 4046, 4, 8, 5, 0, -8 }, // U+04F0 uni0079 + { 4050, 4, 8, 5, 0, -6 }, // U+04F1 uni0079 + { 4054, 4, 8, 5, 0, -8 }, // U+04F2 uni0079 + { 4058, 4, 8, 5, 0, -6 }, // U+04F3 uni0079 + { 4062, 4, 8, 5, 0, -8 }, // U+04F4 char1063 + { 4066, 4, 7, 5, 0, -7 }, // U+04F5 char1063 + { 4070, 4, 7, 5, 0, -6 }, // U+04F6 char1043 + { 4074, 5, 6, 6, 0, -5 }, // U+04F7 char1075 + { 4078, 4, 8, 5, 0, -8 }, // U+04F8 char1067 + { 4082, 4, 7, 5, 0, -7 }, // U+04F9 char1067 + { 4086, 5, 8, 6, 0, -6 }, // U+04FA char1043 + { 4091, 5, 7, 6, 0, -5 }, // U+04FB char1075 + { 4096, 5, 7, 6, 0, -6 }, // U+04FC uni0058 + { 4101, 5, 6, 6, 0, -5 }, // U+04FD uni0078 + { 4105, 4, 6, 5, 0, -6 }, // U+04FE uni0058 + { 4108, 4, 5, 5, 0, -5 }, // U+04FF uni0078 +}; + +static const GFXfont Lemon PROGMEM = { + (uint8_t*)lemonBitmaps, + (GFXglyph*)lemonGlyphs, + 0x0020, 0x04FF, + 10 // yAdvance +}; diff --git a/src/helpers/ui/LemonIcons.h b/src/helpers/ui/LemonIcons.h new file mode 100644 index 00000000..949d3203 --- /dev/null +++ b/src/helpers/ui/LemonIcons.h @@ -0,0 +1,19 @@ +// Lemon font icon glyphs (Private Use Area), converted manually from lemon.bdf +// Add new icons by appending to the bitmap array, lemonIconCPs, and lemonIconGlyphs. +#pragma once +#include + +static const uint8_t lemonIconBitmaps[] PROGMEM = { + 0xEE, 0xBA, 0x2B, 0xA3, 0xB0, // U+E03B mute: 6×6, yoff=0 +}; + +static const uint32_t lemonIconCPs[] PROGMEM = { + 0xE03B, // mute +}; + +static const GFXglyph lemonIconGlyphs[] PROGMEM = { + // off w h adv xo yo + { 0, 6, 6, 5, 0, -6 }, // U+E03B mute +}; + +static const uint8_t lemonIconCount = sizeof(lemonIconCPs) / sizeof(lemonIconCPs[0]); diff --git a/src/helpers/ui/SH1106Display.cpp b/src/helpers/ui/SH1106Display.cpp index f383bb00..79455b04 100644 --- a/src/helpers/ui/SH1106Display.cpp +++ b/src/helpers/ui/SH1106Display.cpp @@ -1,6 +1,8 @@ #include "SH1106Display.h" #include #include "Adafruit_SH110X.h" +#include "LemonFont.h" +#include "LemonIcons.h" bool SH1106Display::i2c_probe(TwoWire &wire, uint8_t addr) { @@ -17,6 +19,9 @@ bool SH1106Display::begin() void SH1106Display::turnOn() { display.oled_command(SH110X_DISPLAYON); + uint8_t pre[] = { 0xD9, _precharge }; + display.oled_commandList(pre, 2); + display.setContrast(_contrast); _isOn = true; } @@ -38,11 +43,13 @@ void SH1106Display::startFrame(Color bkg) _color = SH110X_WHITE; display.setTextColor(_color); display.setTextSize(1); + _text_sz = 1; display.cp437(true); // Use full 256 char 'Code Page 437' font } void SH1106Display::setTextSize(int sz) { + _text_sz = sz; display.setTextSize(sz); } @@ -57,9 +64,107 @@ void SH1106Display::setCursor(int x, int y) display.setCursor(x, y); } +uint32_t SH1106Display::decodeUtf8(const uint8_t*& p) { + uint8_t c = *p++; + if (c < 0x80) return c; + if ((c & 0xE0) == 0xC0) { + uint32_t cp = c & 0x1F; + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + return cp; + } + if ((c & 0xF0) == 0xE0) { + uint32_t cp = c & 0x0F; + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + return cp; + } + if ((c & 0xF8) == 0xF0) { + uint32_t cp = c & 0x07; + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + return cp; + } + while (*p && (*p & 0xC0) == 0x80) p++; + return 0xFFFD; +} + +int16_t SH1106Display::drawLemonChar(int16_t x, int16_t y, uint32_t cp) { + int sz = _text_sz; + for (uint8_t i = 0; i < lemonIconCount; i++) { + if (pgm_read_dword(&lemonIconCPs[i]) == cp) { + const GFXglyph* g = &lemonIconGlyphs[i]; + uint8_t w = pgm_read_byte(&g->width), h = pgm_read_byte(&g->height); + int8_t xo = (int8_t)pgm_read_byte(&g->xOffset), yo = (int8_t)pgm_read_byte(&g->yOffset); + uint8_t xa = pgm_read_byte(&g->xAdvance); + uint16_t bo = pgm_read_word(&g->bitmapOffset); + uint8_t bits = 0, bit = 0; + for (uint8_t row = 0; row < h; row++) + for (uint8_t col = 0; col < w; col++) { + if (!bit) { bits = pgm_read_byte(&lemonIconBitmaps[bo++]); bit = 0x80; } + if (bits & bit) { + if (sz == 1) display.drawPixel(x + xo + col, y + 6 + yo + row, _color); + else display.fillRect(x + xo*sz + col*sz, y + 6*sz + yo*sz + row*sz, sz, sz, _color); + } + bit >>= 1; + } + return x + xa * sz; + } + } + + if (cp < Lemon.first || cp > Lemon.last) { + if (cp >= 0x20) display.fillRect(x + sz, y - sz, 4*sz, 6*sz, _color); + return x + 6 * sz; + } + const GFXglyph* g = &lemonGlyphs[cp - Lemon.first]; + uint8_t w = pgm_read_byte(&g->width), h = pgm_read_byte(&g->height); + int8_t xo = (int8_t)pgm_read_byte(&g->xOffset), yo = (int8_t)pgm_read_byte(&g->yOffset); + uint8_t xa = pgm_read_byte(&g->xAdvance); + uint16_t bo = pgm_read_word(&g->bitmapOffset); + uint8_t bits = 0, bit = 0; + for (uint8_t row = 0; row < h; row++) + for (uint8_t col = 0; col < w; col++) { + if (!bit) { bits = pgm_read_byte(&lemonBitmaps[bo++]); bit = 0x80; } + if (bits & bit) { + if (sz == 1) display.drawPixel(x + xo + col, y + 6 + yo + row, _color); + else display.fillRect(x + xo*sz + col*sz, y + 6*sz + yo*sz + row*sz, sz, sz, _color); + } + bit >>= 1; + } + return x + xa * sz; +} + +uint8_t SH1106Display::lemonXAdvance(uint32_t cp) { + uint8_t xa; + if (cp < Lemon.first || cp > Lemon.last) xa = 6; + else xa = pgm_read_byte(&lemonGlyphs[cp - Lemon.first].xAdvance); + return xa * _text_sz; +} + +void SH1106Display::translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) { + if (_use_lemon) { + size_t n = strlen(src); + if (n >= dest_size) n = dest_size - 1; + memcpy(dest, src, n); + dest[n] = '\0'; + } else { + DisplayDriver::translateUTF8ToBlocks(dest, src, dest_size); + } +} + void SH1106Display::print(const char *str) { - display.print(str); + if (!_use_lemon) { display.print(str); return; } + + int16_t cx = display.getCursorX(); + int16_t cy = display.getCursorY(); + const uint8_t* p = (const uint8_t*)str; + while (*p) { + uint32_t cp = decodeUtf8(p); + if (cp == '\n') { cy += Lemon.yAdvance; cx = 0; } + else { cx = drawLemonChar(cx, cy, cp); } + } + display.setCursor(cx, cy); } void SH1106Display::fillRect(int x, int y, int w, int h) @@ -79,12 +184,33 @@ void SH1106Display::drawXbm(int x, int y, const uint8_t *bits, int w, int h) uint16_t SH1106Display::getTextWidth(const char *str) { + if (_use_lemon) { + uint16_t width = 0; + const uint8_t* p = (const uint8_t*)str; + while (*p) width += lemonXAdvance(decodeUtf8(p)); + return width; + } int16_t x1, y1; uint16_t w, h; display.getTextBounds(str, 0, 0, &x1, &y1, &w, &h); return w; } +void SH1106Display::setBrightness(uint8_t level) +{ + // Contrast alone has limited effect on some OLED panels; combining with + // pre-charge period (0xD9) gives a wider perceptible dimming range. + // Pre-charge 0x11 = phase1=1,phase2=1 (minimum drive); 0x1F = default. + static const uint8_t contrast_values[] = { 0, 25, 60, 150, 255 }; + static const uint8_t precharge_values[] = { 0x11, 0x15, 0x1F, 0x1F, 0x1F }; + uint8_t idx = level < 5 ? level : 4; + _contrast = contrast_values[idx]; + _precharge = precharge_values[idx]; + uint8_t pre[] = { 0xD9, _precharge }; + display.oled_commandList(pre, 2); + display.setContrast(_contrast); +} + void SH1106Display::endFrame() { display.display(); diff --git a/src/helpers/ui/SH1106Display.h b/src/helpers/ui/SH1106Display.h index b52a6adf..53900804 100644 --- a/src/helpers/ui/SH1106Display.h +++ b/src/helpers/ui/SH1106Display.h @@ -19,11 +19,20 @@ class SH1106Display : public DisplayDriver Adafruit_SH1106G display; bool _isOn; uint8_t _color; + uint8_t _contrast; + uint8_t _precharge; + bool _use_lemon; + int _text_sz; bool i2c_probe(TwoWire &wire, uint8_t addr); + static uint32_t decodeUtf8(const uint8_t*& p); + int16_t drawLemonChar(int16_t x, int16_t y, uint32_t cp); + uint8_t lemonXAdvance(uint32_t cp); public: - SH1106Display() : DisplayDriver(128, 64), display(128, 64, &Wire, PIN_OLED_RESET) { _isOn = false; } + SH1106Display() : DisplayDriver(128, 64), display(128, 64, &Wire, PIN_OLED_RESET) { + _isOn = false; _contrast = 255; _precharge = 0x1F; _use_lemon = false; _text_sz = 1; + } bool begin(); bool isOn() override { return _isOn; } @@ -39,5 +48,10 @@ public: void drawRect(int x, int y, int w, int h) override; void drawXbm(int x, int y, const uint8_t *bits, int w, int h) override; uint16_t getTextWidth(const char *str) override; + int getCharWidth() const override { return (_use_lemon ? 5 : 6) * _text_sz; } + int getLineHeight() const override { return (_use_lemon ? 9 : 8) * _text_sz; } + void setLemonFont(bool enabled) override { _use_lemon = enabled; } + void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) override; + void setBrightness(uint8_t level) override; void endFrame() override; }; diff --git a/src/helpers/ui/buzzer.cpp b/src/helpers/ui/buzzer.cpp index ca469d17..12cb515a 100644 --- a/src/helpers/ui/buzzer.cpp +++ b/src/helpers/ui/buzzer.cpp @@ -2,61 +2,243 @@ #include "buzzer.h" void genericBuzzer::begin() { -// Serial.print("DBG: Setting up buzzer on pin "); -// Serial.println(PIN_BUZZER); #ifdef PIN_BUZZER_EN pinMode(PIN_BUZZER_EN, OUTPUT); digitalWrite(PIN_BUZZER_EN, HIGH); #endif - - quiet(false); pinMode(PIN_BUZZER, OUTPUT); - digitalWrite(PIN_BUZZER, LOW); // need to pull low by default to avoid extreme power draw + digitalWrite(PIN_BUZZER, LOW); startup(); } -void genericBuzzer::play(const char *melody) { - if (isPlaying()) // interrupt existing - { - rtttl::stop(); - } - - if (_is_quiet) return; - - rtttl::begin(PIN_BUZZER,melody); -// Serial.print("DBG: Playing melody - isQuiet: "); -// Serial.println(isQuiet()); -} - -bool genericBuzzer::isPlaying() { - return rtttl::isPlaying(); -} - -void genericBuzzer::loop() { - if (!rtttl::done()) rtttl::play(); -} - -void genericBuzzer::startup() { - play(startup_song); -} - -void genericBuzzer::shutdown() { - play(shutdown_song); -} - void genericBuzzer::quiet(bool buzzer_state) { _is_quiet = buzzer_state; #ifdef PIN_BUZZER_EN - if (_is_quiet) { - digitalWrite(PIN_BUZZER_EN, LOW); - } else { - digitalWrite(PIN_BUZZER_EN, HIGH); - } + digitalWrite(PIN_BUZZER_EN, _is_quiet ? LOW : HIGH); #endif } -bool genericBuzzer::isQuiet() { - return _is_quiet; +bool genericBuzzer::isQuiet() { return _is_quiet; } + +void genericBuzzer::startup() { play(startup_song); } +void genericBuzzer::shutdown() { play(shutdown_song); } + +// --------------------------------------------------------------------------- +// nRF52 path — direct NRF_PWM2 control, bypasses tone() +// --------------------------------------------------------------------------- +#if defined(NRF52_PLATFORM) + +// Chromatic frequencies for octave 4 (Hz): C C# D D# E F F# G G# A A# B +static const uint16_t CHROM4[12] = { 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494 }; + +// Map 'a'-'g' → chromatic index within octave +static const uint8_t NOTE_IDX[7] = { 9, 11, 0, 2, 4, 5, 7 }; // a b c d e f g + +uint16_t genericBuzzer::_noteFreq(char letter, bool sharp, uint8_t octave) { + if (letter == 'p') return 0; + if (letter < 'a' || letter > 'g') return 0; + uint8_t idx = NOTE_IDX[letter - 'a']; + if (sharp) { if (++idx >= 12) { idx = 0; octave++; } } + if (octave < 4) octave = 4; + if (octave > 7) octave = 7; + uint32_t f = (uint32_t)CHROM4[idx] << (octave - 4); + return (uint16_t)(f > 25000 ? 25000 : f); } -#endif // ifdef PIN_BUZZER \ No newline at end of file +void genericBuzzer::_parseHeader(const char* melody, uint8_t& def_dur, uint8_t& def_oct, + uint16_t& bpm, const char*& notes) { + def_dur = 4; def_oct = 5; bpm = 120; + const char* p = melody; + while (*p && *p != ':') p++; + if (*p == ':') p++; + while (*p && *p != ':') { + while (*p == ' ' || *p == ',') p++; + if (p[0]=='d' && p[1]=='=') { p+=2; def_dur=(uint8_t)atoi(p); while(*p&&*p!=','&&*p!=':')p++; } + else if (p[0]=='o' && p[1]=='=') { p+=2; def_oct=(uint8_t)atoi(p); while(*p&&*p!=','&&*p!=':')p++; } + else if (p[0]=='b' && p[1]=='=') { p+=2; bpm=(uint16_t)atoi(p); while(*p&&*p!=','&&*p!=':')p++; } + else { while(*p&&*p!=','&&*p!=':')p++; } + } + if (*p == ':') p++; + notes = p; +} + +bool genericBuzzer::_parseNext(const char*& p, uint8_t def_dur, uint8_t def_oct, uint16_t bpm, + uint16_t& freq, uint32_t& dur_ms) { + while (*p == ' ' || *p == ',') p++; + if (*p == '\0') return false; + + uint8_t dur = def_dur; + if (*p >= '0' && *p <= '9') { dur=(uint8_t)atoi(p); while(*p>='0'&&*p<='9')p++; } + if (dur == 0) dur = 4; + + if (*p == '\0') return false; + char note = *p++; + bool sharp = (*p == '#') ? (p++, true) : false; + uint8_t oct = def_oct; + if (*p >= '4' && *p <= '8') oct = (uint8_t)(*p++ - '0'); + bool dot = (*p == '.') ? (p++, true) : false; + + dur_ms = (60000UL * 4UL) / ((uint32_t)bpm * dur); + if (dot) dur_ms = dur_ms * 3 / 2; + + freq = _noteFreq(note, sharp, oct); + return true; +} + +uint8_t genericBuzzer::_dutyPct() const { + // Inverted polarity (0x8000 bit): duty_HIGH = 100% - PCT. + // Values chosen for ~6-8 dB perceptual steps: -24/-16/-9/-3/0 dB. + static const uint8_t PCT[5] = { 2, 5, 12, 25, 50 }; + return PCT[_volume_level < 5 ? _volume_level : 4]; +} + +void genericBuzzer::_nrfStartPwm(uint16_t freq) { + if (freq < 20 || freq > 25000) { _nrfStopPwm(); return; } + + uint32_t nrf_pin = g_ADigitalPinMap[PIN_BUZZER]; + uint16_t top = 125000 / freq; + uint16_t cmp = (uint16_t)(((uint32_t)top * _dutyPct()) / 100); + if (cmp == 0) cmp = 1; // inverted polarity: cmp=0 → 100% HIGH → no AC → silence + + // Write duty BEFORE SEQSTART so DMA reads our value on the very first period + _duty_buf = 0x8000U | cmp; + __DMB(); + + NRF_PWM2->TASKS_STOP = 1; + // Wait for STOPPED (short busy-wait, typically < 1 PWM period) + uint32_t t = millis(); + while (!(NRF_PWM2->EVENTS_STOPPED) && (millis() - t) < 2) {} + NRF_PWM2->EVENTS_STOPPED = 0; + + NRF_PWM2->PSEL.OUT[0] = nrf_pin; + NRF_PWM2->PSEL.OUT[1] = 0xFFFFFFFFUL; + NRF_PWM2->PSEL.OUT[2] = 0xFFFFFFFFUL; + NRF_PWM2->PSEL.OUT[3] = 0xFFFFFFFFUL; + NRF_PWM2->ENABLE = PWM_ENABLE_ENABLE_Enabled << PWM_ENABLE_ENABLE_Pos; + NRF_PWM2->MODE = PWM_MODE_UPDOWN_Up << PWM_MODE_UPDOWN_Pos; + // DIV_128 on 16 MHz = 125 kHz — same clock as tone(), so same frequency math + NRF_PWM2->PRESCALER = PWM_PRESCALER_PRESCALER_DIV_128 << PWM_PRESCALER_PRESCALER_Pos; + NRF_PWM2->COUNTERTOP = top; + NRF_PWM2->DECODER = (PWM_DECODER_LOAD_Common << PWM_DECODER_LOAD_Pos) | + (PWM_DECODER_MODE_RefreshCount << PWM_DECODER_MODE_Pos); + NRF_PWM2->SHORTS = PWM_SHORTS_LOOPSDONE_SEQSTART0_Msk; + NRF_PWM2->LOOP = 0xFFFFUL << PWM_LOOP_CNT_Pos; + + // Both SEQ0 and SEQ1 point to the same buffer; REFRESH=0 means DMA re-reads every period + NRF_PWM2->SEQ[0].PTR = (uint32_t)&_duty_buf; + NRF_PWM2->SEQ[0].CNT = 1; + NRF_PWM2->SEQ[0].REFRESH = 0; + NRF_PWM2->SEQ[0].ENDDELAY = 0; + NRF_PWM2->SEQ[1].PTR = (uint32_t)&_duty_buf; + NRF_PWM2->SEQ[1].CNT = 1; + NRF_PWM2->SEQ[1].REFRESH = 0; + NRF_PWM2->SEQ[1].ENDDELAY = 0; + + NRF_PWM2->TASKS_SEQSTART[0] = 1; + _pwm_on = true; +} + +void genericBuzzer::_nrfStopPwm() { + NRF_PWM2->TASKS_STOP = 1; + NRF_PWM2->PSEL.OUT[0] = 0xFFFFFFFFUL; + NRF_PWM2->ENABLE = 0; + digitalWrite(PIN_BUZZER, LOW); + _pwm_on = false; +} + +void genericBuzzer::_nrfBegin(const char* melody) { + _nrfStopPwm(); + if (!melody || !*melody) { _rtttl_done = true; return; } + const char* notes; + _parseHeader(melody, _def_dur, _def_oct, _def_bpm, notes); + _rtttl_pos = notes; + _rtttl_done = false; + _nrfAdvance(); +} + +void genericBuzzer::_nrfAdvance() { + uint16_t freq; uint32_t dur_ms; + if (_parseNext(_rtttl_pos, _def_dur, _def_oct, _def_bpm, freq, dur_ms)) { + _note_end_ms = millis() + dur_ms; + if (freq > 0) _nrfStartPwm(freq); else _nrfStopPwm(); + } else { + _nrfStopPwm(); + _rtttl_done = true; + } +} + +void genericBuzzer::applyVolume() { + if (!_pwm_on) return; + uint16_t top = (uint16_t)NRF_PWM2->COUNTERTOP; + uint16_t cmp = (uint16_t)(((uint32_t)top * _dutyPct()) / 100); + if (cmp == 0) cmp = 1; + _duty_buf = 0x8000U | cmp; // DMA picks this up within one period (< 2.3 ms at A4) +} + +void genericBuzzer::play(const char* melody) { + if (_is_quiet) return; + _nrfBegin(melody); +} + +void genericBuzzer::playForced(const char* melody) { + _nrfBegin(melody); +} + +bool genericBuzzer::isPlaying() { return !_rtttl_done; } + +void genericBuzzer::stop() { + _nrfStopPwm(); + _rtttl_done = true; +} + +void genericBuzzer::loop() { + if (!_rtttl_done && (millis() >= _note_end_ms)) _nrfAdvance(); +} + +void genericBuzzer::setVolume(uint8_t level) { + _volume_level = level < 5 ? level : 4; + applyVolume(); +} + +// --------------------------------------------------------------------------- +// Non-nRF52 path — NonBlockingRtttl + analogWrite for volume +// --------------------------------------------------------------------------- +#else + +void genericBuzzer::applyVolume() { + // After tone() sets 50% duty, analogWrite overrides duty on the same PWM channel. + static const uint8_t duty[5] = { 6, 20, 50, 90, 128 }; + uint8_t d = duty[_volume_level < 5 ? _volume_level : 4]; + if (d < 128) analogWrite(PIN_BUZZER, d); +} + +void genericBuzzer::play(const char* melody) { + if (isPlaying()) rtttl::stop(); + if (_is_quiet) return; + rtttl::begin(PIN_BUZZER, melody); +} + +void genericBuzzer::playForced(const char* melody) { + if (isPlaying()) rtttl::stop(); + rtttl::begin(PIN_BUZZER, melody); +} + +bool genericBuzzer::isPlaying() { return rtttl::isPlaying(); } + +void genericBuzzer::stop() { rtttl::stop(); } + +void genericBuzzer::loop() { + if (!rtttl::done()) { + rtttl::play(); + if (_volume_level < 4) applyVolume(); + } +} + +void genericBuzzer::setVolume(uint8_t level) { + _volume_level = level < 5 ? level : 4; + if (isPlaying()) applyVolume(); +} + +#endif // NRF52_PLATFORM + +#endif // PIN_BUZZER diff --git a/src/helpers/ui/buzzer.h b/src/helpers/ui/buzzer.h index 0a500552..4ed795b1 100644 --- a/src/helpers/ui/buzzer.h +++ b/src/helpers/ui/buzzer.h @@ -1,37 +1,58 @@ #pragma once #include -#include -/* class abstracts underlying RTTTL library - - Just a simple imlementation to start. At the moment use same - melody for message and discovery - Suggest enum type for different sounds - - on message - - on discovery - - TODO - - make message ring tone configurable - -*/ +#if !defined(NRF52_PLATFORM) + #include +#endif class genericBuzzer { public: - void begin(); // set up buzzer port - void play(const char *melody); // Generic play function - void loop(); // loop driven-nonblocking - void startup(); // play startup sound - void shutdown(); // play shutdown sound - bool isPlaying(); // returns true if a sound is still playing else false - void quiet(bool buzzer_state); // enables or disables the buzzer - bool isQuiet(); // get buzzer state on/off + void begin(); + void play(const char *melody); + void playForced(const char *melody); + void loop(); + void startup(); + void shutdown(); + bool isPlaying(); + void quiet(bool buzzer_state); + bool isQuiet(); + void setVolume(uint8_t level); + uint8_t getVolume() const { return _volume_level; } + void stop(); private: - // gemini's picks: - const char *startup_song = "Startup:d=4,o=5,b=160:16c6,16e6,8g6"; + uint8_t _volume_level = 4; + void applyVolume(); + const char *startup_song = "Startup:d=4,o=5,b=160:16c6,16e6,8g6"; const char *shutdown_song = "Shutdown:d=4,o=5,b=100:8g5,16e5,16c5"; - bool _is_quiet = true; + +#if defined(NRF52_PLATFORM) + // Own RTTTL player — bypasses tone() to allow volume control from note start. + // tone() pre-computes seq_refresh so the DMA repeats 50% duty for ~30ms before + // re-reading its buffer; we cannot beat that timing. By owning NRF_PWM2 directly + // and setting REFRESH=0, DMA re-reads _duty_buf every period so duty takes effect + // immediately at SEQSTART. + volatile uint16_t _duty_buf = 0; // DMA source — must stay in RAM + const char* _rtttl_pos = nullptr; + bool _rtttl_done = true; + bool _pwm_on = false; + unsigned long _note_end_ms = 0; + uint8_t _def_dur = 4; + uint8_t _def_oct = 5; + uint16_t _def_bpm = 120; + + void _nrfBegin(const char* melody); + void _nrfAdvance(); + void _nrfStartPwm(uint16_t freq); + void _nrfStopPwm(); + uint8_t _dutyPct() const; + static uint16_t _noteFreq(char letter, bool sharp, uint8_t octave); + static bool _parseNext(const char*& pos, uint8_t def_dur, uint8_t def_oct, + uint16_t bpm, uint16_t& freq_hz, uint32_t& dur_ms); + static void _parseHeader(const char* melody, uint8_t& def_dur, uint8_t& def_oct, + uint16_t& bpm, const char*& notes_start); +#endif }; diff --git a/variants/wio-tracker-l1-eink/platformio.ini b/variants/wio-tracker-l1-eink/platformio.ini index 42c83477..0bb891db 100644 --- a/variants/wio-tracker-l1-eink/platformio.ini +++ b/variants/wio-tracker-l1-eink/platformio.ini @@ -1,12 +1,14 @@ [WioTrackerL1Eink] extends = nrf52_base board = seeed-wio-tracker-l1 -board_build.ldscript = boards/nrf52840_s140_v7.ld +board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld +board_upload.maximum_size = 708608 build_flags = ${nrf52_base.build_flags} ${sensor_base.build_flags} -I lib/nrf52/s140_nrf52_7.3.0_API/include -I lib/nrf52/s140_nrf52_7.3.0_API/include/nrf52 -I variants/wio-tracker-l1 + -I examples/companion_radio/ui-new -D WIO_TRACKER_L1 -D WIO_TRACKER_L1_EINK -D USE_SX1262 @@ -15,56 +17,51 @@ build_flags = ${nrf52_base.build_flags} -D LORA_TX_POWER=22 -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 - -D PIN_OLED_RESET=-1 -D EINK_DISPLAY_MODEL=GxEPD2_213_B74 - -D EINK_SCALE_X=1.953125f - -D EINK_SCALE_Y=1.28f - -D EINK_X_OFFSET=0 - -D EINK_Y_OFFSET=10 -D DISPLAY_ROTATION=1 - -D DISABLE_DIAGNOSTIC_OUTPUT - -D AUTO_OFF_MILLIS=0 -D GPS_BAUD_RATE=9600 -D ENV_PIN_SDA=PIN_WIRE1_SDA -D ENV_PIN_SCL=PIN_WIRE1_SCL + -D DISABLE_DIAGNOSTIC_OUTPUT + -D AUTO_OFF_MILLIS=0 + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D DISPLAY_CLASS=GxEPDDisplay + -D UI_HAS_JOYSTICK=1 + -D UI_HAS_JOYSTICK_UPDOWN=1 + -D PIN_BUZZER=12 + -D QSPIFLASH=1 + -D FIRMWARE_PLUS_BUILD=1 + -D UI_SENSORS_PAGE=1 + -D BLE_PIN_CODE=123456 build_src_filter = ${nrf52_base.build_src_filter} + +<../variants/wio-tracker-l1> + - + -lib_deps= ${nrf52_base.lib_deps} - ${sensor_base.lib_deps} - adafruit/Adafruit GFX Library @ ^1.12.1 - zinggjm/GxEPD2 @ 1.6.2 - bakercp/CRC32 @ ^2.0.0 - -[env:WioTrackerL1Eink_companion_radio_ble] -extends = WioTrackerL1Eink -board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld -board_upload.maximum_size = 708608 -build_flags = ${WioTrackerL1Eink.build_flags} - -I examples/companion_radio/ui-new - -D MAX_CONTACTS=350 - -D MAX_GROUP_CHANNELS=40 - -D BLE_PIN_CODE=123456 - -D BLE_DEBUG_LOGGING=1 - -D OFFLINE_QUEUE_SIZE=256 - -D DISPLAY_CLASS=GxEPDDisplay - -D UI_HAS_JOYSTICK=1 - -D PIN_BUZZER=12 - -D QSPIFLASH=1 - ; -D MESH_PACKET_LOGGING=1 - ; -D MESH_DEBUG=1 - -D UI_RECENT_LIST_SIZE=6 - -D UI_SENSORS_PAGE=1 -build_src_filter = ${WioTrackerL1Eink.build_src_filter} - + + + + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> -lib_deps = ${WioTrackerL1Eink.lib_deps} +lib_deps = ${nrf52_base.lib_deps} + ${sensor_base.lib_deps} + adafruit/Adafruit GFX Library @ ^1.12.1 adafruit/RTClib @ ^2.1.3 + zinggjm/GxEPD2 @ 1.6.2 + bakercp/CRC32 @ ^2.0.0 densaugeo/base64 @ ~1.4.0 end2endzone/NonBlockingRTTTL@^1.3.0 -debug_tool=stlink + +; BLE only +[env:WioTrackerL1Eink_companion_radio_ble] +extends = WioTrackerL1Eink +extra_scripts = post:create-uf2.py + +; Dual BLE+USB +[env:WioTrackerL1Eink_companion_radio_dual] +extends = WioTrackerL1Eink +build_flags = ${WioTrackerL1Eink.build_flags} + -D DUAL_SERIAL=1 +extra_scripts = post:create-uf2.py diff --git a/variants/wio-tracker-l1/WioTrackerL1Board.cpp b/variants/wio-tracker-l1/WioTrackerL1Board.cpp index 4153714e..61be5369 100644 --- a/variants/wio-tracker-l1/WioTrackerL1Board.cpp +++ b/variants/wio-tracker-l1/WioTrackerL1Board.cpp @@ -8,6 +8,8 @@ void WioTrackerL1Board::begin() { btn_prev_state = HIGH; pinMode(PIN_VBAT_READ, INPUT); // VBAT ADC input + pinMode(VBAT_ENABLE, OUTPUT); + digitalWrite(VBAT_ENABLE, LOW); // keep voltage divider off; enabled only during ADC read // Set all button pins to INPUT_PULLUP pinMode(PIN_BUTTON1, INPUT_PULLUP); pinMode(PIN_BUTTON2, INPUT_PULLUP); diff --git a/variants/wio-tracker-l1/WioTrackerL1Board.h b/variants/wio-tracker-l1/WioTrackerL1Board.h index 052238e6..14a9746b 100644 --- a/variants/wio-tracker-l1/WioTrackerL1Board.h +++ b/variants/wio-tracker-l1/WioTrackerL1Board.h @@ -22,11 +22,12 @@ public: #endif uint16_t getBattMilliVolts() override { - int adcvalue = 0; + digitalWrite(VBAT_ENABLE, HIGH); analogReadResolution(12); analogReference(AR_INTERNAL); - delay(10); - adcvalue = analogRead(PIN_VBAT_READ); + delay(10); // allow voltage divider to stabilize + int adcvalue = analogRead(PIN_VBAT_READ); + digitalWrite(VBAT_ENABLE, LOW); return (adcvalue * ADC_MULTIPLIER * AREF_VOLTAGE) / 4.096; } diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index 6c1e8f63..1e2325ed 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -55,7 +55,7 @@ build_flags = ${WioTrackerL1.build_flags} lib_deps = ${WioTrackerL1.lib_deps} adafruit/RTClib @ ^2.1.3 -[env:WioTrackerL1_companion_radio_usb] +[WioTrackerL1CompanionUSB] extends = WioTrackerL1 board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld board_upload.maximum_size = 708608 @@ -65,9 +65,12 @@ build_flags = ${WioTrackerL1.build_flags} -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SH1106Display -D UI_HAS_JOYSTICK=1 + -D UI_HAS_JOYSTICK_UPDOWN=1 -D OFFLINE_QUEUE_SIZE=256 -D PIN_BUZZER=12 -D QSPIFLASH=1 + -D FIRMWARE_PLUS_BUILD=1 + -D UI_SENSORS_PAGE=1 ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${WioTrackerL1.build_src_filter} @@ -81,7 +84,16 @@ lib_deps = ${WioTrackerL1.lib_deps} densaugeo/base64 @ ~1.4.0 end2endzone/NonBlockingRTTTL@^1.3.0 -[env:WioTrackerL1_companion_radio_ble] +[env:WioTrackerL1_companion_radio_usb] +extends = WioTrackerL1CompanionUSB + +[env:WioTrackerL1_companion_radio_usb_settings] +extends = WioTrackerL1CompanionUSB +build_flags = ${WioTrackerL1CompanionUSB.build_flags} + -D UI_HAS_JOYSTICK_UPDOWN=1 +extra_scripts = post:create-uf2.py + +[WioTrackerL1CompanionBLE] extends = WioTrackerL1 board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld board_upload.maximum_size = 708608 @@ -94,9 +106,12 @@ build_flags = ${WioTrackerL1.build_flags} -D OFFLINE_QUEUE_SIZE=256 -D DISPLAY_CLASS=SH1106Display -D UI_HAS_JOYSTICK=1 + -D UI_HAS_JOYSTICK_UPDOWN=1 -D PIN_BUZZER=12 -D QSPIFLASH=1 -D ADVERT_NAME='"@@MAC"' + -D FIRMWARE_PLUS_BUILD=1 + -D UI_SENSORS_PAGE=1 -D ENV_PIN_SDA=PIN_WIRE1_SDA -D ENV_PIN_SCL=PIN_WIRE1_SCL ; -D MESH_PACKET_LOGGING=1 @@ -111,3 +126,27 @@ lib_deps = ${WioTrackerL1.lib_deps} adafruit/RTClib @ ^2.1.3 densaugeo/base64 @ ~1.4.0 end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:WioTrackerL1_companion_radio_ble] +extends = WioTrackerL1CompanionBLE + +[env:WioTrackerL1_companion_radio_ble_settings] +extends = WioTrackerL1CompanionBLE +build_flags = ${WioTrackerL1CompanionBLE.build_flags} + -D UI_HAS_JOYSTICK_UPDOWN=1 +extra_scripts = post:create-uf2.py + +[WioTrackerL1CompanionDual] +extends = WioTrackerL1CompanionBLE +build_flags = ${WioTrackerL1CompanionBLE.build_flags} + -D DUAL_SERIAL=1 + -UBLE_DEBUG_LOGGING + +[env:WioTrackerL1_companion_radio_dual] +extends = WioTrackerL1CompanionDual + +[env:WioTrackerL1_companion_radio_dual_settings] +extends = WioTrackerL1CompanionDual +build_flags = ${WioTrackerL1CompanionDual.build_flags} + -D UI_HAS_JOYSTICK_UPDOWN=1 +extra_scripts = post:create-uf2.py diff --git a/variants/wio-tracker-l1/target.cpp b/variants/wio-tracker-l1/target.cpp index 896ead26..00af685a 100644 --- a/variants/wio-tracker-l1/target.cpp +++ b/variants/wio-tracker-l1/target.cpp @@ -25,6 +25,10 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); MomentaryButton joystick_left(JOYSTICK_LEFT, 1000, true, false, false); MomentaryButton joystick_right(JOYSTICK_RIGHT, 1000, true, false, false); MomentaryButton back_btn(PIN_BACK_BTN, 1000, true, false, true); + #ifdef UI_HAS_JOYSTICK_UPDOWN + MomentaryButton joystick_up(JOYSTICK_UP, 1000, true, false, false); + MomentaryButton joystick_down(JOYSTICK_DOWN, 1000, true, false, false); + #endif #endif bool radio_init() { diff --git a/variants/wio-tracker-l1/target.h b/variants/wio-tracker-l1/target.h index 72766e77..3722182c 100644 --- a/variants/wio-tracker-l1/target.h +++ b/variants/wio-tracker-l1/target.h @@ -28,6 +28,10 @@ extern EnvironmentSensorManager sensors; extern MomentaryButton joystick_left; extern MomentaryButton joystick_right; extern MomentaryButton back_btn; + #ifdef UI_HAS_JOYSTICK_UPDOWN + extern MomentaryButton joystick_up; + extern MomentaryButton joystick_down; + #endif #endif bool radio_init(); diff --git a/variants/wio-tracker-l1/variant.cpp b/variants/wio-tracker-l1/variant.cpp index 4fc2606a..d09e4a90 100644 --- a/variants/wio-tracker-l1/variant.cpp +++ b/variants/wio-tracker-l1/variant.cpp @@ -67,9 +67,9 @@ void initVariant() { pinMode(PIN_QSPI_CS, OUTPUT); digitalWrite(PIN_QSPI_CS, HIGH); - // VBAT_ENABLE + // VBAT_ENABLE: keep LOW by default, WioTrackerL1Board::getBattMilliVolts() toggles it pinMode(VBAT_ENABLE, OUTPUT); - digitalWrite(VBAT_ENABLE, HIGH); + digitalWrite(VBAT_ENABLE, LOW); // set LED pin as output and set it low pinMode(PIN_LED, OUTPUT); @@ -78,5 +78,4 @@ void initVariant() { // set buzzer pin as output and set it low pinMode(12, OUTPUT); digitalWrite(12, LOW); - pinMode(12, OUTPUT); }