From 06e741b310efcf479fe3c65d98c70769a9ed6130 Mon Sep 17 00:00:00 2001 From: pmow Date: Fri, 9 Oct 2026 08:48:03 -1000 Subject: [PATCH 1/4] fix(ui-new): keep CardKB key presses during an e-ink refresh While the loop task is inside a display refresh (seconds on a big e-ink panel) the CardKB isn't polled, and the keyboard's own one-key buffer drops every press but the last. A small FreeRTOS task polls the CardKB every 10 ms while a refresh is in progress, into a 16-entry queue that the loop drains afterwards. A mutex guards the shared Wire1 bus against querySensors(). The task is created after the screens so its stack doesn't starve their allocations, and the loop falls back to the old direct polling if it can't be created. Tested on hardware: Wio Tracker L1 + CardKB, several quick presses during an 800x480 refresh all arrive. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KxAZVAbhhYr18Dd4guTQM1 --- .../companion_radio/ui-new/StatusScreen.h | 2 +- examples/companion_radio/ui-new/UITask.cpp | 121 +++++++++++++++++- examples/companion_radio/ui-new/UITask.h | 22 ++++ 3 files changed, 138 insertions(+), 7 deletions(-) diff --git a/examples/companion_radio/ui-new/StatusScreen.h b/examples/companion_radio/ui-new/StatusScreen.h index 157c8f9b..59c281a1 100644 --- a/examples/companion_radio/ui-new/StatusScreen.h +++ b/examples/companion_radio/ui-new/StatusScreen.h @@ -229,7 +229,7 @@ private: // Sensors: every reading but the battery (above) and the position (GPS). if (!_lpp_at || millis() - _lpp_at > 5000) { // both passes (measure, draw) read one query _lpp.reset(); - sensors.querySensors(0xFF, _lpp); + { CardKBBusLock bus_lock; sensors.querySensors(0xFF, _lpp); } // shares the CardKB's bus _lpp_at = millis() | 1; } static const struct { uint8_t type; const char* name; } NAMES[] = { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 5a923257..0d8c8044 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -8,6 +8,20 @@ #include "../Features.h" #include "../GeoUtils.h" #include "target.h" + +// The CardKB background poll task (see UITask::cardkbTask) shares its I2C bus +// with the environment sensors, and some screens query those sensors while +// rendering -- exactly when that task is active. Holding this lock around a +// sensor query makes the two take turns. No-op on builds without the task. +#if defined(CARDKB_I2C) && defined(NRF52_PLATFORM) +static void* g_cardkb_bus_mutex = nullptr; // SemaphoreHandle_t, set by startCardKBCapture() +struct CardKBBusLock { + CardKBBusLock() { if (g_cardkb_bus_mutex) xSemaphoreTake((SemaphoreHandle_t)g_cardkb_bus_mutex, portMAX_DELAY); } + ~CardKBBusLock() { if (g_cardkb_bus_mutex) xSemaphoreGive((SemaphoreHandle_t)g_cardkb_bus_mutex); } +}; +#else +struct CardKBBusLock { }; +#endif #ifdef WIFI_SSID #include #endif @@ -824,7 +838,7 @@ class HomeScreen : public UIScreen { sensors_lpp.reset(); sensors_nb = 0; sensors_lpp.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); - sensors.querySensors(0xFF, sensors_lpp); + { CardKBBusLock bus_lock; sensors.querySensors(0xFF, sensors_lpp); } LPPReader reader (sensors_lpp.getBuffer(), sensors_lpp.getSize()); uint8_t channel, type; while(reader.readHeader(channel, type)) { @@ -2039,6 +2053,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no #if defined(PIN_GPIO1) gpio_screen = new GpioScreen(this, node_prefs); #endif + startCardKBCapture(); // after every screen: an optional task must not starve one applyBrightness(); applyRotation(); applyFullRefreshInterval(); @@ -2720,7 +2735,11 @@ static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_m { NodePrefs* np = the_mesh.getNodePrefs(); in.gps_on = np && np->gps_enabled; } // off: no stale satellite count #endif if (telemetry::isSensor(field)) { - if (!lpp) { static CayenneLPP s_lpp(200); s_lpp.reset(); sensors.querySensors(0xFF, s_lpp); lpp = &s_lpp; } + if (!lpp) { + static CayenneLPP s_lpp(200); s_lpp.reset(); + { CardKBBusLock bus_lock; sensors.querySensors(0xFF, s_lpp); } + lpp = &s_lpp; + } in.lpp = lpp->getBuffer(); in.lpp_len = lpp->getSize(); } @@ -2887,12 +2906,100 @@ void UITask::pollCardKB() { // Polling every loop() iteration (same as a digital button's check(), which // has no throttle either) just shrinks that miss window down to exactly the // render() duration instead of render()+30ms. + // First, keys the background task captured while the display blocked, in order. + while (_cardkb_q_tail != _cardkb_q_head) { + uint8_t q = _cardkb_q[_cardkb_q_tail]; + _cardkb_q_tail = (uint8_t)((_cardkb_q_tail + 1) % CARDKB_Q_LEN); + handleCardKBByte(q); + } + uint8_t raw; + if (!readCardKBEdge(raw)) return; + handleCardKBByte(raw); +#endif +} + +#if defined(CARDKB_I2C) +bool UITask::readCardKBEdge(uint8_t& raw) { CARDKB_I2C.requestFrom(0x5F, 1); - if (!CARDKB_I2C.available()) return; - uint8_t raw = CARDKB_I2C.read(); - if (raw == _cardkb_last_raw) return; // still held (or still released) -- no new edge + if (!CARDKB_I2C.available()) return false; + raw = CARDKB_I2C.read(); + if (raw == _cardkb_last_raw) return false; // still held (or still released) -- no new edge _cardkb_last_raw = raw; - if (raw == 0) return; // key just released, nothing to enqueue + return raw != 0; // 0 = key just released, nothing to enqueue +} + +// Single producer (the poll task) / single consumer (pollCardKB() in the +// loop) ring -- byte-sized indices, so no locking needed. +void UITask::cardkbQueuePush(uint8_t raw) { + uint8_t next = (uint8_t)((_cardkb_q_head + 1) % CARDKB_Q_LEN); + if (next == _cardkb_q_tail) return; // full: drop (16 keys within one refresh) + _cardkb_q[_cardkb_q_head] = raw; + _cardkb_q_head = next; +} + +#if defined(NRF52_PLATFORM) +void UITask::cardkbTask(void* self) { + UITask* t = (UITask*)self; + SemaphoreHandle_t m = (SemaphoreHandle_t)t->_cardkb_mutex; + for (;;) { + if (t->_cardkb_bg_active && xSemaphoreTake(m, portMAX_DELAY) == pdTRUE) { + if (t->_cardkb_bg_active) { // re-check: loop may have just finished + uint8_t raw; + if (t->readCardKBEdge(raw)) t->cardkbQueuePush(raw); + } + xSemaphoreGive(m); + } + vTaskDelay(ms2tick(10)); // a keypress lasts ~50+ ms, so 10 ms never misses one + } +} +#endif +#endif + +// Keep sampling the CardKB while the display blocks the loop, so keys typed +// during a (long, on big panels) refresh aren't lost. Called LAST in begin(): +// the heap is tight on large-panel builds, and every screen is allocated with +// `new` -- a failed allocation leaves that screen silently unopenable. Creating +// the optional poll task afterwards means a short heap costs the task (the +// CardKB is then only read between frames, as without it), never a screen. +void UITask::startCardKBCapture() { +#if defined(CARDKB_I2C) && defined(NRF52_PLATFORM) + if (!_has_cardkb) return; + SemaphoreHandle_t m = xSemaphoreCreateMutex(); + if (!m) return; + // Publish the mutex BEFORE creating the task: at higher priority it starts + // running inside xTaskCreate() and reads _cardkb_mutex straight away. + _cardkb_mutex = (void*)m; + // TASK_PRIO_NORMAL (2) preempts the loop task (TASK_PRIO_LOW, 1); BLE runs higher. + if (xTaskCreate(&UITask::cardkbTask, "cardkb", 512, this, TASK_PRIO_NORMAL, NULL) == pdPASS) { + g_cardkb_bus_mutex = m; // shared with CardKBBusLock (sensor queries) + return; + } + _cardkb_mutex = nullptr; // no task -> setCardKBBackground() stays a no-op + vSemaphoreDelete(m); +#endif +} + +// Hand CardKB sampling to the background task (on = loop is about to block on +// the display) or take it back (off). Taking the mutex on the way out waits for +// any in-flight background I2C read to finish before the loop touches the bus. +void UITask::setCardKBBackground(bool on) { +#if defined(CARDKB_I2C) && defined(NRF52_PLATFORM) + if (!_cardkb_mutex) return; + if (on) { + _cardkb_bg_active = true; + } else { + SemaphoreHandle_t m = (SemaphoreHandle_t)_cardkb_mutex; + xSemaphoreTake(m, portMAX_DELAY); + _cardkb_bg_active = false; + xSemaphoreGive(m); + } +#else + (void)on; +#endif +} + +void UITask::handleCardKBByte(uint8_t raw) { +#if defined(CARDKB_I2C) // Compact mode (Settings > Keyboard's "Ext. KB" row) hides the letter grid // entirely, and is meant to guarantee joystick-free operation: while it's @@ -3460,6 +3567,7 @@ void UITask::loop() { if (now != ext) { ext = now; _next_refresh = 0; } } if (_display != NULL && _display->isOn()) { + setCardKBBackground(true); // keep sampling the keyboard while the frame blocks // Lock-screen password prompt if (_locked && _unlock_kb && (int32_t)(millis() - _lock_wake_until) >= 0) { cancelUnlockPrompt(); // Cancel unlock attempt on idle @@ -3528,6 +3636,7 @@ void UITask::loop() { _display->endFrame(); PERF_T2(); } + setCardKBBackground(false); #if AUTO_OFF_MILLIS > 0 #ifdef KEEP_DISPLAY_ON_USB // Opt-in: refresh the auto-off deadline while externally powered, so the diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 993ac5cd..9202f2ac 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -209,8 +209,30 @@ private: // the last raw byte seen so a held key enqueues exactly one press instead // of one per poll tick. uint8_t _cardkb_last_raw = 0; + // Keys sampled by the background poll task while the display blocks the + // main loop; drained (in order) by the next pollCardKB(). + static const uint8_t CARDKB_Q_LEN = 16; + uint8_t _cardkb_q[CARDKB_Q_LEN]; + volatile uint8_t _cardkb_q_head = 0, _cardkb_q_tail = 0; // shared with the poll task + bool readCardKBEdge(uint8_t& raw); // one I2C read + debounce; true on a new keypress + void cardkbQueuePush(uint8_t raw); // add a sampled key to _cardkb_q (drops if full) +#if defined(NRF52_PLATFORM) + // nRF52 (FreeRTOS): a small higher-priority task samples the CardKB every + // 10 ms while the loop is busy drawing / sending / refreshing the e-ink + // frame -- the whole window, not just the panel's BUSY wait (on an 800x480 + // panel the byte-by-byte SPI transfers alone block for the better part of a + // second). _cardkb_bg_active marks that window; the mutex guarantees the + // task and the loop never use the I2C bus at the same time. Leaves the + // display's busy-wait hook (setBusyPumpFn) to the radio pump. + void* _cardkb_mutex = nullptr; // SemaphoreHandle_t + volatile bool _cardkb_bg_active = false; + static void cardkbTask(void* self); +#endif #endif void pollCardKB(); + void handleCardKBByte(uint8_t raw); + void setCardKBBackground(bool on); // no-op without CardKB / the poll task + void startCardKBCapture(); // end of begin(): start the poll task // Optional magnetic "flip cover" lock: a Hall-effect or reed sensor wired to // any free GPIO, closing (pulling active) when a magnet is near. Entirely From a0a76fbc60c907677c103fce35b02637176a8c07 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:47:03 +0200 Subject: [PATCH 2/4] fix(watch13): build with -Os like the other nRF52 solo boards The Watch13 env still compiled with -Ofast and had run out of flash after the map, calendar and QR commits. -Os brings it to 70.7%. Co-Authored-By: Claude Opus 5.5 --- solo/gat562_mesh_watch13/platformio.ini | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/solo/gat562_mesh_watch13/platformio.ini b/solo/gat562_mesh_watch13/platformio.ini index 32dfe132..7d6b53e3 100644 --- a/solo/gat562_mesh_watch13/platformio.ini +++ b/solo/gat562_mesh_watch13/platformio.ini @@ -13,10 +13,12 @@ extends = GAT562_Mesh_Watch13 board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld board_upload.maximum_size = 712704 +; Optimise for size, not speed, as the 30S and the L1 do: the nRF52 Arduino +; core defaults to -Ofast, which bloats flash by ~200 KB on this solo build. ; The offline queue (messages held for the phone app while it's away) at 128, ; not the board's 256: on the nRF52 the 256 x 177 B left ~5 KB of heap next to ; the on-device UI (which keeps its own history), too little for a sign buffer. -build_unflags = -D OFFLINE_QUEUE_SIZE=256 +build_unflags = -Ofast -D OFFLINE_QUEUE_SIZE=256 build_flags = ${GAT562_Mesh_Watch13.build_flags} -I examples/companion_radio/ui-new @@ -33,6 +35,7 @@ build_flags = ; GPS — re-enable (base undefines it); Serial1 pins 15/16 from variant -D ENV_INCLUDE_GPS=1 -D GPS_BAUD_RATE=9600 + -Os ; Buzzer available on IO3 (pin 21) — uncomment to enable ; -D PIN_BUZZER=21 ; -D PIN_VIBRATION=36 From 6fba3dfca71e1c0622df694134c979f0fb03d1d7 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:47:03 +0200 Subject: [PATCH 3/4] feat: app-only settings on the device: privacy, adding contacts, advanced radio - Settings schema gains Privacy (position in adverts, who can ask for status / location / sensors), Adding contacts (all or by type, max hops, replace oldest) and Advanced radio (path hash size, double ACK, listen before talk, interference threshold, RX boost, RX delay, airtime factor); both UIs build their rows from it - Auto-advert carries position only when Position in adverts is on - ui-new accordion holds 16 sections and 128 rows - docs: settings, tools, feature matrix Co-Authored-By: Claude Opus 5.5 --- docs/solo/developer/feature-matrix.md | 3 + docs/solo/settings.md | 48 +++++++- docs/solo/tools.md | 4 +- examples/companion_radio/MyMesh.cpp | 3 +- .../companion_radio/ui-core/SettingsSchema.h | 104 ++++++++++++++++-- examples/companion_radio/ui-core/UiCore.h | 1 + .../companion_radio/ui-lvgl/RadioScreen.h | 8 +- examples/companion_radio/ui-lvgl/UITask.cpp | 11 +- .../companion_radio/ui-new/AccordionList.h | 10 +- .../companion_radio/ui-new/SettingsScreen.h | 19 +++- 10 files changed, 181 insertions(+), 30 deletions(-) diff --git a/docs/solo/developer/feature-matrix.md b/docs/solo/developer/feature-matrix.md index 7c6dd0f3..7ded2b87 100644 --- a/docs/solo/developer/feature-matrix.md +++ b/docs/solo/developer/feature-matrix.md @@ -61,6 +61,8 @@ build flag it needs. | Contact details | yes | + compass rose, path hops | + compass rose, path hops | + compass rose, path hops | card with actions | | Contact expiry / prune | yes | yes | yes | yes | yes | | Remote admin | yes | yes | yes | yes | yes | +| Adding contacts (auto-add by type, hops, replace oldest) | Settings › Contacts | same | same | same | Messages & contacts | +| Privacy (position in adverts, telemetry requests) | Settings › Privacy | same | same | same | Settings › Privacy | ### Navigation (`ENV_INCLUDE_GPS`) @@ -84,6 +86,7 @@ build flag it needs. | Melodies (`PIN_BUZZER`) | Ringtones tool | same | same | wide editor | Settings › Sound | | Status: Radio, GPS, Power, Mesh | yes, 32 min history | yes, 8 h charts | yes, 8 h charts | tiles with charts | Diagnostics app (+ noise chart) | | Sky view (`GPS_SKYVIEW`) | Status › Sky | same | same | + side panel | GPS app: Sky, Signal, Details | +| Advanced radio (hash size, double ACK, CAD, interference, RX boost, RX delay, airtime) | end of Settings › Radio | same | same | same | Radio › Advanced | | Battery curve | Settings | same | same | same | Settings › Power, draggable chart | | QR code of the manual | Status › System, Enter | same | same | same | Settings › About | diff --git a/docs/solo/settings.md b/docs/solo/settings.md index 642009a7..aa1fa85c 100644 --- a/docs/solo/settings.md +++ b/docs/solo/settings.md @@ -10,10 +10,11 @@ board supports are shown. | **Display** | Brightness, auto-off, wake on message, the message alert (banner, corner envelope or off), lock screen, the lock clock's look and the [PIN](./lock.md), battery as icon / % / volts, time zone, clock format and seconds; on e-ink also rotation and full refresh | | **Sound** | Buzzer on / off / auto (quiet while the app is connected), volume, quiet hours, the melody for messages, channels and adverts | | **Home Pages** | Order of the home pages, and which are shown | -| **Radio** | TX power, preset, frequency, SF / BW / CR, saved presets, Auto pwr, the scope list | +| **Radio** | TX power, preset, frequency, SF / BW / CR, saved presets, Auto pwr, the scope list, then the advanced options (below) | | **System** | Device name, low-battery shutdown, GPS power saving, units, the battery curve, reboot | | **Keyboard** | ABC or T9 layout, the two scripts, CardKB mode | -| **Contacts** | Show all or favourites only (DMs, channels, rooms), favourites on top, contact expiry and prune | +| **Contacts** | Show all or favourites only (DMs, channels, rooms), favourites on top, contact expiry and prune, which heard nodes are added | +| **Privacy** | Whether your adverts carry your position, and who may ask for your status, location and sensor readings | | **Messages** | Automatic resend of direct messages, the ten quick messages | A few notes: @@ -32,6 +33,46 @@ A few notes: 10 mV, **Reset** goes back to LiPo. The current voltage is shown, so a point can be set against what the battery reads. +### Adding contacts + +A node you hear is added to your contacts according to **Auto-add**: + +- **All**: every node heard. +- **By type**: only the types switched on below it (companions, repeaters, + rooms, sensors). With none on, nodes are added only by hand (Nearby › + **Add contact**). + +**Up to** limits how far away an added node may be: **Direct** (heard with +no repeater) or a number of hops. **Replace old** makes room when the list is +full by dropping the oldest contact that isn't a favourite; without it, new +nodes aren't added until you make room. + +### Privacy + +- **Advert pos** (Position in adverts): your adverts carry your position, + including the automatic ones. Off, nodes see your name but not where you + are. Live sharing and `{loc}` in a message still send it, because you send + those on purpose. +- **Ask status / loc / sens**: who may request your telemetry: **Nobody**, + **Allowed** (the contacts you gave that permission, for now in the phone + app) or **Everyone**. Status is the battery; a request for location or + sensors is answered only where status is allowed too. + +### Advanced radio + +At the end of **Radio**. The defaults suit almost everyone; change them only +if your mesh agrees on it. + +| Setting | Does | +| --- | --- | +| **Hash size** | Bytes per repeater in a message's path: 1 (the default) fits the most hops, 2 or 3 tell repeaters apart in a big mesh | +| **Double ACK** | Sends each acknowledgement twice, for a lossy link | +| **LBT (CAD)** | Listen before talk: waits for a quiet channel before sending | +| **Interf.** | Treats the channel as busy when the signal is this far above the noise floor | +| **RX boost** | The radio's boosted receive gain: hears weaker signals for a little more power (SX126x radios) | +| **RX delay** | How long a repeated packet waits, so the best path wins; off by default | +| **Airtime** | The pause after sending, as a multiple of the time on air | + > [!NOTE] > **Wio Tracker L2:** one list of pages: > @@ -39,7 +80,8 @@ A few notes: > Power (battery, GPS, and the **Battery curve** as a chart whose points you > drag), Sound (with the melody editor), Keyboard, Messages > & contacts. -> - **Connections**: Radio, Bluetooth, WiFi, GPS. +> - **Connections**: Radio (with **Advanced** at the bottom), Privacy, +> Bluetooth, WiFi, GPS. Adding contacts is in Messages & contacts. > - **Map & data**: Map (trail, live sharing and arrival alert options), > Storage. > - **System**: Name, Time, Units, Firmware update, About; Reboot and Power off. diff --git a/docs/solo/tools.md b/docs/solo/tools.md index 2ed9920c..11c90fcf 100644 --- a/docs/solo/tools.md +++ b/docs/solo/tools.md @@ -64,8 +64,8 @@ room's saved login. ## Auto-advert -**Tools › Auto-advert** sends an advert with your position every 30 seconds -to 1 hour, so others see you in their Nearby list. With it on at both ends +**Tools › Auto-advert** sends your advert every 30 seconds to 1 hour (with +your position if Settings › Privacy › **Advert pos** is on), so others see you in their Nearby list. With it on at both ends and Settings › Sound › **AD sound** set, each device beeps when it hears the other: a hands-free "still in range". diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 547511fb..bdce7e95 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3484,7 +3484,8 @@ void MyMesh::loop() { } if (_prefs.advert_auto_interval_sec > 0 && millisHasNowPassed(_next_auto_advert_ms)) { - mesh::Packet* pkt = (sensors.node_lat != 0 || sensors.node_lon != 0) + // The position only where Privacy lets adverts carry it, and once there is one. + mesh::Packet* pkt = (_prefs.advert_loc_policy != ADVERT_LOC_NONE && (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); diff --git a/examples/companion_radio/ui-core/SettingsSchema.h b/examples/companion_radio/ui-core/SettingsSchema.h index 82bf971d..e8f865d8 100644 --- a/examples/companion_radio/ui-core/SettingsSchema.h +++ b/examples/companion_radio/ui-core/SettingsSchema.h @@ -9,7 +9,9 @@ // uint8_t holding that index; with a `values` table the field (1, 2 or 4 bytes, // `size`) holds values[index] instead -- seconds, millivolts, an hour offset, // or {1, 0} for a switch whose field is stored inverted. count == 2 with no -// option labeller is an on/off switch. +// option labeller is an on/off switch. A `bit` mask makes it a switch over that +// one bit of a uint8_t field; size FLOAT_X100 is a float field with values in +// hundredths. #include #include "../NodePrefs.h" @@ -21,28 +23,33 @@ namespace settings { // Pages (one screen each) and the sections on them, in display order. // PG_NAV is the Map's options (its trail / live share / target tools), // PG_ADVERT sits in Nearby's advert popup; the rest are Settings pages. -enum Page : uint8_t { PG_NAV, PG_ADVERT, PG_DEVICE, PG_MESSAGES, PG_SOUND, PG_COUNT }; +// PG_PRIVACY and PG_RADIO (Radio's advanced options) are Settings pages too. +enum Page : uint8_t { PG_NAV, PG_ADVERT, PG_DEVICE, PG_MESSAGES, PG_SOUND, PG_PRIVACY, PG_RADIO, PG_COUNT }; enum Section : uint8_t { SEC_TRAIL, SEC_LIVE_SHARE, SEC_LOCATOR, SEC_ADVERT, SEC_DISPLAY, SEC_POWER, SEC_TIME, SEC_UNITS, - SEC_MESSAGES, SEC_CONTACTS, - SEC_SOUND, SEC_SOUND_FOR, SEC_QUIET, SEC_COUNT }; + SEC_MESSAGES, SEC_CONTACTS, SEC_AUTO_ADD, + SEC_SOUND, SEC_SOUND_FOR, SEC_QUIET, + SEC_PRIVACY, SEC_TELEMETRY, SEC_RADIO_ADV, SEC_COUNT }; static const char* pageTitle(uint8_t p) { - static const char* T[PG_COUNT] = { "Map options", "Advert", "Display & power", "Messages & contacts", "Sound" }; + static const char* T[PG_COUNT] = { "Map options", "Advert", "Display & power", "Messages & contacts", "Sound", + "Privacy", "Advanced radio" }; return p < PG_COUNT ? T[p] : ""; } static const char* sectionTitle(uint8_t s) { static const char* T[SEC_COUNT] = { "TRAIL", "LIVE SHARE", "ARRIVAL ALERT", "AUTOMATIC", "DISPLAY", "POWER", "TIME", "UNITS", - "MESSAGES", "CONTACTS", - "SOUND", "PLAYS FOR", "QUIET HOURS" }; + "MESSAGES", "CONTACTS", "ADDING CONTACTS", + "SOUND", "PLAYS FOR", "QUIET HOURS", + "ADVERTS", "WHO CAN ASK FOR", "ADVANCED" }; return s < SEC_COUNT ? T[s] : ""; } static uint8_t sectionPage(uint8_t s) { static const uint8_t P[SEC_COUNT] = { PG_NAV, PG_NAV, PG_NAV, PG_ADVERT, PG_DEVICE, PG_DEVICE, PG_DEVICE, PG_DEVICE, - PG_MESSAGES, PG_MESSAGES, - PG_SOUND, PG_SOUND, PG_SOUND }; + PG_MESSAGES, PG_MESSAGES, PG_MESSAGES, + PG_SOUND, PG_SOUND, PG_SOUND, + PG_PRIVACY, PG_PRIVACY, PG_RADIO }; return s < SEC_COUNT ? P[s] : PG_DEVICE; } @@ -57,8 +64,10 @@ struct Setting { // Side effect after a change (nullptr: none). The caller saves the prefs. void (*changed)(UiCore& core); const int32_t* values; // stored value per index; nullptr: the index itself - uint8_t size; // field bytes (1, 2, 4) + uint8_t size; // field bytes (1, 2, 4), or FLOAT_X100 + uint8_t bit; // nonzero: a switch over this bit of a uint8_t field }; +static const uint8_t FLOAT_X100 = 0x84; // size: a float, values in hundredths // ── Value labels ────────────────────────────────────────────────────────────── #ifndef TRAIL_FIXED_MIN_DELTA_M @@ -152,12 +161,40 @@ static void optAdvertScope(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%s", v == ADVERT_SOUND_SCOPE_ZERO_HOP ? "Direct" : "All"); } +static void optTelemetry(uint8_t v, char* b, int n, const NodePrefs&) { + static const char* const L[] = { "Nobody", "Allowed", "Everyone" }; // TELEM_MODE_* + snprintf(b, n, "%s", L[v < 3 ? v : 0]); +} +static void optAutoAdd(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%s", v ? "By type" : "All"); } +// autoadd_max_hops: 0 = any, 1 = direct only, N = up to N-1 hops. +static const int32_t MAX_HOPS[] = { 0, 1, 2, 3, 4, 6, 9 }; +static void optMaxHops(uint8_t v, char* b, int n, const NodePrefs&) { + int32_t h = MAX_HOPS[v]; + if (h == 0) snprintf(b, n, "Any"); + else if (h == 1) snprintf(b, n, "Direct"); + else snprintf(b, n, "%ld hop%s", (long)(h - 1), h == 2 ? "" : "s"); +} +static void optHashSize(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%u byte%s", v + 1, v ? "s" : ""); } +static const int32_t INT_THRESH[] = { 0, 8, 10, 12, 14, 16, 20 }; +static void optIntThresh(uint8_t v, char* b, int n, const NodePrefs&) { + if (INT_THRESH[v] == 0) snprintf(b, n, "Off"); else snprintf(b, n, "%ld dB", (long)INT_THRESH[v]); +} +static const int32_t RX_DELAY[] = { 0, 200, 500, 1000, 2000 }; // rx_delay_base, x100 +static void optRxDelay(uint8_t v, char* b, int n, const NodePrefs&) { + if (RX_DELAY[v] == 0) snprintf(b, n, "Off"); else snprintf(b, n, "%ld", (long)(RX_DELAY[v] / 100)); +} +static const int32_t AIRTIME[] = { 0, 50, 100, 200, 300, 500, 900 }; // airtime_factor, x100 +static void optAirtime(uint8_t v, char* b, int n, const NodePrefs&) { + snprintf(b, n, "%ld.%ld x", (long)(AIRTIME[v] / 100), (long)(AIRTIME[v] % 100 / 10)); +} + // ── Side effects ────────────────────────────────────────────────────────────── static void rearmLocator(UiCore& c) { c.locator.reset(); } static void restartShareClock(UiCore& c) { c.live_share.restartClock(); } // a new length starts over static void applyDisplay(UiCore& c) { c.host()->applyDisplayPrefs(); } static void applyGpsDuty(UiCore& c) { c.applyGpsInterval(); } static void applySound(UiCore& c) { c.host()->applySoundPrefs(); } +static void applyRxGain(UiCore& c) { if (c.prefs()) radio_driver.setRxBoostedGainMode(c.prefs()->rx_boosted_gain); } #define NP_OFF(f) (uint16_t)offsetof(NodePrefs, f) #define NP_SIZE(f) (uint8_t)sizeof(((NodePrefs*)0)->f) @@ -166,6 +203,8 @@ static void applySound(UiCore& c) { c.host()->applySoundPrefs(); } #define IDX(label, hint, sec, f, count, opt, chg) { label, hint, sec, NP_OFF(f), count, opt, chg, nullptr, 1 } #define SW(label, hint, sec, f, chg) { label, hint, sec, NP_OFF(f), 2, nullptr, chg, nullptr, 1 } #define MAP(label, hint, sec, f, vals, opt, chg) { label, hint, sec, NP_OFF(f), COUNT_OF(vals), opt, chg, vals, NP_SIZE(f) } +#define MAPF(label, hint, sec, f, vals, opt) { label, hint, sec, NP_OFF(f), COUNT_OF(vals), opt, nullptr, vals, FLOAT_X100 } +#define BIT(label, hint, sec, f, mask) { label, hint, sec, NP_OFF(f), 2, nullptr, nullptr, nullptr, 1, mask } static const Setting ALL[] = { #ifndef TRAIL_FIXED_MIN_DELTA_M // else every sample past a fixed jitter gate is kept IDX("Point spacing", "Between trail points", SEC_TRAIL, trail_min_delta_idx, @@ -194,7 +233,7 @@ static const Setting ALL[] = { NodePrefs::LOCATOR_MODE_COUNT, optLocMode, rearmLocator), SW("Proximity beeper", "Ticks faster when closer", SEC_LOCATOR, locator_beeper, nullptr), - MAP("Auto-advert", "Advert with your position", SEC_ADVERT, advert_auto_interval_sec, AUTO_ADVERT, + MAP("Auto-advert", "Your advert, on a timer", SEC_ADVERT, advert_auto_interval_sec, AUTO_ADVERT, optAutoAdvert, nullptr), IDX("Brightness", nullptr, SEC_DISPLAY, display_brightness, 5, optBrightness, applyDisplay), @@ -221,6 +260,15 @@ static const Setting ALL[] = { NodePrefs::CONTACT_EXPIRY_COUNT, optExpiry, nullptr), SW("Favourites first", "In contact and node lists", SEC_CONTACTS, fav_sort, nullptr), + // manual_add_contacts bit 0: 0 adds every node heard, 1 only the types below. + IDX("Add heard nodes", "Every node, or only these types", SEC_AUTO_ADD, manual_add_contacts, 2, optAutoAdd, nullptr), + BIT("Companions", nullptr, SEC_AUTO_ADD, autoadd_config, 0x02), + BIT("Repeaters", nullptr, SEC_AUTO_ADD, autoadd_config, 0x04), + BIT("Rooms", nullptr, SEC_AUTO_ADD, autoadd_config, 0x08), + BIT("Sensors", nullptr, SEC_AUTO_ADD, autoadd_config, 0x10), + MAP("Up to", "How far away a node may be", SEC_AUTO_ADD, autoadd_max_hops, MAX_HOPS, optMaxHops, nullptr), + BIT("Replace oldest", "When the list is full; not favourites", SEC_AUTO_ADD, autoadd_config, 0x01), + // On / Off / Auto spans two fields (soundctl::setMode): the frontend's own row. IDX("Volume", nullptr, SEC_SOUND, buzzer_volume, 5, optVolume, applySound), SW("Quiet hours", "Muted, screen dark; alarms ring", SEC_QUIET, quiet_hours, nullptr), @@ -231,6 +279,23 @@ static const Setting ALL[] = { IDX("Channels", nullptr, SEC_SOUND_FOR, notif_melody_ch, soundctl::SOUND_COUNT, optSound, nullptr), IDX("Adverts", "A node announcing itself", SEC_SOUND_FOR, notif_melody_ad, soundctl::SOUND_COUNT, optSound, nullptr), IDX("Advert sound for", "Direct: no repeaters", SEC_SOUND_FOR, advert_sound_scope, 2, optAdvertScope, nullptr), + + SW("Position in adverts", "Your adverts carry your location", SEC_PRIVACY, advert_loc_policy, nullptr), + // TELEM_MODE_*: "Allowed" is the contacts given that permission. + IDX("Status", "Battery; the two below need it", SEC_TELEMETRY, telemetry_mode_base, 3, optTelemetry, nullptr), + IDX("Location", nullptr, SEC_TELEMETRY, telemetry_mode_loc, 3, optTelemetry, nullptr), + IDX("Sensors", nullptr, SEC_TELEMETRY, telemetry_mode_env, 3, optTelemetry, nullptr), + + IDX("Path hash size", "Bytes per repeater in a path", SEC_RADIO_ADV, path_hash_mode, 3, optHashSize, nullptr), + SW("Double ACK", "Send each acknowledgement twice", SEC_RADIO_ADV, multi_acks, nullptr), + SW("Listen before talk", "Wait for a quiet channel (CAD)", SEC_RADIO_ADV, cad_enabled, nullptr), + MAP("Interference", "Busy above the noise floor by", SEC_RADIO_ADV, interference_threshold, INT_THRESH, + optIntThresh, nullptr), +#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR2021) + SW("Boosted RX gain", "Hears more, uses a little more power", SEC_RADIO_ADV, rx_boosted_gain, applyRxGain), +#endif + MAPF("RX delay", "Repeaters wait to pick the best path", SEC_RADIO_ADV, rx_delay_base, RX_DELAY, optRxDelay), + MAPF("Airtime factor", "Pause after sending, times airtime", SEC_RADIO_ADV, airtime_factor, AIRTIME, optAirtime), }; static const int COUNT = (int)(sizeof(ALL) / sizeof(ALL[0])); @@ -249,8 +314,19 @@ static const struct { uint16_t offset; const char* text; } SHORT_LABELS[] = { { NP_OFF(quiet_to), " until" }, { NP_OFF(notif_melody_dm), "DM sound" }, { NP_OFF(notif_melody_ch), "Ch sound" }, { NP_OFF(notif_melody_ad), "AD sound" }, { NP_OFF(advert_sound_scope), "AD scope" }, + { NP_OFF(manual_add_contacts), "Auto-add" }, { NP_OFF(autoadd_max_hops), " up to" }, + { NP_OFF(advert_loc_policy), "Advert pos" }, { NP_OFF(telemetry_mode_base), "Ask status" }, + { NP_OFF(telemetry_mode_loc), "Ask loc" }, { NP_OFF(telemetry_mode_env), "Ask sens" }, + { NP_OFF(path_hash_mode), "Hash size" }, { NP_OFF(multi_acks), "Double ACK" }, + { NP_OFF(cad_enabled), "LBT (CAD)" }, { NP_OFF(interference_threshold), "Interf." }, + { NP_OFF(rx_boosted_gain), "RX boost" }, { NP_OFF(rx_delay_base), "RX delay" }, + { NP_OFF(airtime_factor), "Airtime" }, }; static const char* shortLabel(const Setting& s) { + if (s.bit) { // the auto-add types share one field + static const char* const B[] = { "Replace old", " companions", " repeaters", " rooms", " sensors" }; + for (int i = 0; i < 5; i++) if (s.bit == (1 << i)) return B[i]; + } for (auto& l : SHORT_LABELS) if (l.offset == s.offset) return l.text; return s.label; } @@ -262,12 +338,16 @@ static int indexOf(uint16_t offset) { #undef IDX #undef SW #undef MAP +#undef MAPF +#undef BIT #undef COUNT_OF #undef NP_SIZE #undef NP_OFF static int32_t readRaw(const NodePrefs& p, const Setting& s) { const uint8_t* f = (const uint8_t*)&p + s.offset; + if (s.bit) return (f[0] & s.bit) ? 1 : 0; + if (s.size == FLOAT_X100) { float v; memcpy(&v, f, 4); return (int32_t)(v * 100 + (v < 0 ? -0.5f : 0.5f)); } bool sgn = false; // a table with negative values is over a signed field for (uint8_t i = 0; s.values && i < s.count; i++) if (s.values[i] < 0) sgn = true; if (s.size == 2) { uint16_t v; memcpy(&v, f, 2); return sgn ? (int32_t)(int16_t)v : (int32_t)v; } @@ -276,6 +356,8 @@ static int32_t readRaw(const NodePrefs& p, const Setting& s) { } static void writeRaw(NodePrefs& p, const Setting& s, int32_t v) { uint8_t* f = (uint8_t*)&p + s.offset; + if (s.bit) { f[0] = v ? (f[0] | s.bit) : (f[0] & ~s.bit); return; } + if (s.size == FLOAT_X100) { float w = v / 100.0f; memcpy(f, &w, 4); return; } if (s.size == 2) { uint16_t w = (uint16_t)v; memcpy(f, &w, 2); } else if (s.size == 4) { uint32_t w = (uint32_t)v; memcpy(f, &w, 4); } else f[0] = (uint8_t)v; diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index 6bb9524a..4b50400f 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -152,6 +152,7 @@ public: } UiCoreHost* host() { return _host; } + NodePrefs* prefs() { return _prefs; } // ════ MyMesh::Listener ════════════════════════════════════════════════════ diff --git a/examples/companion_radio/ui-lvgl/RadioScreen.h b/examples/companion_radio/ui-lvgl/RadioScreen.h index 35f551cf..80aa2267 100644 --- a/examples/companion_radio/ui-lvgl/RadioScreen.h +++ b/examples/companion_radio/ui-lvgl/RadioScreen.h @@ -96,13 +96,19 @@ void UITask::buildRadio() { paramRows(g, p, radioctl::currentPreset(p), p->freq, p->sf, p->bw, p->cr, onRadioDropdown, IDS, onRadioFreq, "Higher = longer range"); groupNote(body, "Everyone you talk to needs the same settings."); - fill(&UITask::radioGroup, 1, 3); // the rest as the loop goes + fill(&UITask::radioGroup, 1, 4); // the rest as the loop goes } void UITask::radioGroup(int i) { using namespace radioview; NodePrefs* p = _prefs; if (i == 2) { buildRadioExtras(_body); return; } // my presets, scopes (RadioExtras.h) + if (i == 3) { // the schema's advanced options, a page of their own + lv_obj_t* g = group(_body, nullptr); + listRow(g, LV_SYMBOL_SETTINGS " Advanced", "Path hash size, ACKs, listen before talk, delays", + onOpenSchemaPage, (void*)(uintptr_t)settings::PG_RADIO); + return; + } lv_obj_t* g = group(_body, "TRANSMIT"); int o = 0; for (int v = TX_MIN; v <= TX_MAX; v++) o += snprintf(s_opts + o, OPTS_LEN - o, v > TX_MIN ? "\n%d dBm" : "%d dBm", v); diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 119c1927..33ab9cb0 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -2399,6 +2399,8 @@ void UITask::back() { if (_settings_page == settings::PG_NAV && s_opts_from_map) { openMap(true); if (s_nav_section >= 0) navToolsPopup(); // one section's options: back into Map tools + } else if (_settings_page == settings::PG_RADIO) { + showRadio(); } else { showSettings(); } @@ -3548,6 +3550,7 @@ static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, #include "MapRegions.h" #include "HomeScreen.h" #include "ClockScreen.h" +static void onOpenSchemaPage(lv_event_t* e); // Radio > Advanced #include "RadioScreen.h" #include "WifiScreen.h" #include "ChannelScreen.h" @@ -4536,11 +4539,13 @@ void UITask::buildAboutPage(lv_obj_t* body) { // (fill); a popup's list gets them all now. static lv_obj_t* s_schema_body = nullptr; static bool s_schema_one = false; // one section: its own page, no heading +static bool s_schema_bare = false; // no section headings void UITask::schemaRows(lv_obj_t* body, uint8_t page) { for (lv_obj_t*& c : s_sec_card) c = nullptr; s_schema_body = body; s_schema_one = page == settings::PG_NAV && s_nav_section >= 0; + s_schema_bare = s_schema_one || page == settings::PG_RADIO; // one section: the page title says it uint8_t sec = 0xFF; bool first = true; for (int i = 0; i < settings::COUNT; i++) { @@ -4557,7 +4562,7 @@ void UITask::schemaRows(lv_obj_t* body, uint8_t page) { // The group of the section setting i starts, with its rows. void UITask::schemaSection(int i) { uint8_t sec = settings::ALL[i].section; - lv_obj_t* card = s_sec_card[sec] = group(s_schema_body, s_schema_one ? nullptr : settings::sectionTitle(sec)); + lv_obj_t* card = s_sec_card[sec] = group(s_schema_body, s_schema_bare ? nullptr : settings::sectionTitle(sec)); if (sec == settings::SEC_SOUND) buildSoundRows(card, true); // On / Off / Auto for (int k = i; k < settings::COUNT && settings::ALL[k].section == sec; k++) { if (settings::ALL[k].offset == offsetof(NodePrefs, lock_compact)) continue; // ui-new's lock look; the slide card has one @@ -4684,7 +4689,7 @@ void UITask::settingsGroup(int i) { snprintf(sub, sizeof(sub), aa == ma ? "%s" : "%s + %s", SCRIPT[ma], SCRIPT[aa]); } listRow(g, LV_SYMBOL_KEYBOARD " Keyboard", sub, onOpenSchemaPage, (void*)(uintptr_t)PG_KEYBOARD); - listRow(g, LV_SYMBOL_ENVELOPE " Messages & contacts", "Resend, expiry, quick messages", + listRow(g, LV_SYMBOL_ENVELOPE " Messages & contacts", "Resend, adding, expiry, quick messages", onOpenSchemaPage, (void*)(uintptr_t)settings::PG_MESSAGES); break; @@ -4696,6 +4701,8 @@ void UITask::settingsGroup(int i) { if (pi >= 0) radioctl::presetAt(_prefs, pi, pn, f, b, sf, cr); snprintf(sub, sizeof(sub), "%s - %.3f MHz, %d dBm", pn, _prefs->freq, _prefs->tx_power_dbm); listRow(g, UI_SYMBOL_RADIO " Radio", sub, onOpenRadio, NULL); + listRow(g, LV_SYMBOL_EYE_CLOSE " Privacy", "Position in adverts, who can ask for telemetry", + onOpenSchemaPage, (void*)(uintptr_t)settings::PG_PRIVACY); } bluetoothRow(g); wifiRow(g); diff --git a/examples/companion_radio/ui-new/AccordionList.h b/examples/companion_radio/ui-new/AccordionList.h index 0b5529e0..4727137f 100644 --- a/examples/companion_radio/ui-new/AccordionList.h +++ b/examples/companion_radio/ui-new/AccordionList.h @@ -22,15 +22,15 @@ public: enum Result { IGNORED, HANDLED, ACTIVATED }; - static const int MAX_SECTIONS = 8; // collapse bitmask is 8 bits - static const int MAX_ROWS = 64; + static const int MAX_SECTIONS = 16; // collapse bitmask is 16 bits + static const int MAX_ROWS = 128; // (Re)initialise from per-section item counts. `collapsed` sets the initial // fold state for every section (Settings opens folded, so default true). void begin(const uint8_t* section_sizes, int section_count, bool collapsed = true) { _count = section_count > MAX_SECTIONS ? MAX_SECTIONS : section_count; for (int i = 0; i < _count; i++) _sizes[i] = section_sizes[i]; - _collapsed = collapsed ? 0xFF : 0x00; + _collapsed = collapsed ? 0xFFFF : 0x0000; _sel = 0; _scroll = 0; rebuild(); } @@ -68,7 +68,7 @@ public: // the cursor never jumps to an unrelated row when items appear/disappear. void toggle(int sec) { if (sec < 0 || sec >= _count) return; - _collapsed ^= (uint8_t)(1u << sec); + _collapsed ^= (uint16_t)(1u << sec); rebuild(); for (int i = 0; i < _vis_count; i++) if (_rows[i].sec == sec && _rows[i].item < 0) { _sel = i; break; } @@ -77,7 +77,7 @@ public: private: uint8_t _sizes[MAX_SECTIONS]; int _count = 0; - uint8_t _collapsed = 0xFF; + uint16_t _collapsed = 0xFFFF; Row _rows[MAX_ROWS]; int _vis_count = 0; int _sel = 0, _scroll = 0; diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 90c0b3bc..d59197c8 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -46,6 +46,7 @@ class SettingsScreen : public UIScreen { #endif TX_APC, SCOPE_NAME, + SCHEMA_RADIO, // the advanced radio options // System section SECTION_SYSTEM, DEVICE_NAME, @@ -61,7 +62,9 @@ class SettingsScreen : public UIScreen { KEYBOARD_CARDKB_COMPACT, #endif // Contacts section - SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, SCHEMA_CONTACTS, PRUNE_NOW, + SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, SCHEMA_CONTACTS, PRUNE_NOW, SCHEMA_AUTO_ADD, + // Privacy section: what adverts carry, who may ask for telemetry + SECTION_PRIVACY, SCHEMA_PRIVACY, // Messages section SECTION_MESSAGES, SCHEMA_MESSAGES, @@ -83,8 +86,8 @@ class SettingsScreen : public UIScreen { bool _keep_place = false; // set when opening a screen from a row, so Back lands on it AccordionList _acc; - static const int NUM_SECTIONS = 8; - static const int MAX_PER_SEC = 16; + static const int NUM_SECTIONS = 9; + static const int MAX_PER_SEC = 20; uint8_t _sec_items[NUM_SECTIONS][MAX_PER_SEC]; // SettingItem per (section, row) uint8_t _sec_count[NUM_SECTIONS]; uint8_t _sec_header[NUM_SECTIONS]; // the SECTION_* enum for each section @@ -136,7 +139,8 @@ class SettingsScreen : public UIScreen { item == SECTION_HOME_PAGES || item == SECTION_RADIO || item == SECTION_SYSTEM || item == SECTION_KEYBOARD || - item == SECTION_CONTACTS || item == SECTION_MESSAGES; + item == SECTION_CONTACTS || item == SECTION_MESSAGES || + item == SECTION_PRIVACY; } const char* sectionName(int item) const { @@ -148,6 +152,7 @@ class SettingsScreen : public UIScreen { if (item == SECTION_KEYBOARD) return "Keyboard"; if (item == SECTION_CONTACTS) return "Contacts"; if (item == SECTION_MESSAGES) return "Messages"; + if (item == SECTION_PRIVACY) return "Privacy"; return ""; } @@ -178,7 +183,8 @@ class SettingsScreen : public UIScreen { static bool isSchemaGroup(int item) { return item == SCHEMA_DISPLAY || item == SCHEMA_SOUND || item == SCHEMA_SYSTEM || - item == SCHEMA_CONTACTS || item == SCHEMA_MESSAGES; + item == SCHEMA_CONTACTS || item == SCHEMA_MESSAGES || item == SCHEMA_RADIO || item == SCHEMA_AUTO_ADD || + item == SCHEMA_PRIVACY; } // Which schema sections a placeholder stands for. static bool schemaIn(int group, uint8_t sec) { @@ -188,6 +194,9 @@ class SettingsScreen : public UIScreen { case SCHEMA_SOUND: return sec == SEC_SOUND || sec == SEC_QUIET || sec == SEC_SOUND_FOR; case SCHEMA_SYSTEM: return sec == SEC_POWER || sec == SEC_UNITS; case SCHEMA_CONTACTS: return sec == SEC_CONTACTS; + case SCHEMA_AUTO_ADD: return sec == SEC_AUTO_ADD; + case SCHEMA_RADIO: return sec == SEC_RADIO_ADV; + case SCHEMA_PRIVACY: return sec == SEC_PRIVACY || sec == SEC_TELEMETRY; case SCHEMA_MESSAGES: return sec == SEC_MESSAGES; } return false; From c5bb4752d03222a8279454dc3506786122705fe1 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:59:35 +0200 Subject: [PATCH 4/4] feat: per-contact actions, device settings, a companion CLI and your contact's QR code - Nodes: telemetry permissions per contact, reset path, share nearby (L1 options menu; L2 node card with "Can ask for" switches) - Settings > Privacy: fixed position (typed in, from a waypoint, cleared) and the Bluetooth PIN; Settings > System: factory reset, asked twice - Companion CLI (CMD_RUN_CLI_COMMAND 66 / RESP_CODE_CLI_REPLY 29, as upstream; FIRMWARE_VER_CODE 14): upstream's command names plus a key for every schema setting (get / set / keys) - Screenshots move to 0x7E; tools/screenshot.py falls back to 66 / 29 - Your contact as a QR code (meshcore://contact/add): Status > System on ui-new, Settings > About on the L2; Nayuki's qrcodegen made header-only Co-Authored-By: Claude Opus 5.5 --- docs/solo/contacts.md | 12 +- docs/solo/developer/feature-matrix.md | 5 + docs/solo/settings.md | 60 +- docs/solo/tools.md | 4 +- examples/companion_radio/MyMesh.cpp | 62 +- examples/companion_radio/MyMesh.h | 22 +- examples/companion_radio/MyMeshCli.h | 201 +++ examples/companion_radio/main.cpp | 13 + .../companion_radio/ui-core/ContactControl.h | 17 + examples/companion_radio/ui-core/ContactQr.h | 47 + examples/companion_radio/ui-core/QrCodeGen.h | 1398 +++++++++++++++++ .../companion_radio/ui-core/SettingsCli.h | 146 ++ examples/companion_radio/ui-core/UiCore.h | 37 + examples/companion_radio/ui-core/UiCoreHost.h | 2 + examples/companion_radio/ui-lvgl/UITask.cpp | 265 +++- examples/companion_radio/ui-lvgl/UITask.h | 10 + .../companion_radio/ui-new/NearbyScreen.h | 61 +- .../companion_radio/ui-new/SettingsScreen.h | 151 +- .../companion_radio/ui-new/StatusScreen.h | 68 +- examples/companion_radio/ui-new/UITask.cpp | 8 + examples/companion_radio/ui-new/UITask.h | 1 + tools/screenshot.py | 17 +- 22 files changed, 2521 insertions(+), 86 deletions(-) create mode 100644 examples/companion_radio/MyMeshCli.h create mode 100644 examples/companion_radio/ui-core/ContactQr.h create mode 100644 examples/companion_radio/ui-core/QrCodeGen.h create mode 100644 examples/companion_radio/ui-core/SettingsCli.h diff --git a/docs/solo/contacts.md b/docs/solo/contacts.md index e34da227..60b05c38 100644 --- a/docs/solo/contacts.md +++ b/docs/solo/contacts.md @@ -18,6 +18,9 @@ for what you can do with it: | Ping | Sends a ping and shows the round trip time and SNR | | Save waypoint, Set as target | Its position as a waypoint, or as the [Locator](./navigation.md#locator) target | | Fav, Pin to dial | Favourite, or a place on the Favourites page | +| Telem | What this contact may ask for: None, Status, Stat+loc, Stat+sens, All. Shown when a [Privacy](./settings.md#privacy) setting is **Allowed** | +| Reset path | Forgets the learned route; the next message floods and finds a new one. Shown when there is a route | +| Share nearby | Sends the contact's last advert to the nodes in direct range, so they can add it | | Admin | Remote admin, for a repeater or room (below) | | Discover scan | Asks the repeaters, rooms and sensors in direct range to answer, with their signal | @@ -32,7 +35,14 @@ back out, **Enter** opens its details and **Hold Enter** its options. > **Wio Tracker L2:** the **Nodes** app. Filter chips at the top, the sort in > the header, and a map button that shows every node with a position. A > node's card has buttons for the same actions, plus **Add** for a node that -> isn't a contact yet and **Delete**. +> isn't a contact yet and **Delete**. The telemetry permissions are three +> switches under **Can ask for**. + +## Your own contact + +**Status › System**, **Enter** shows your contact as a QR code: the phone +app's scanner adds you from it, with your name and key. On the Wio Tracker L2 +it is in **Settings › About**. ## Favourites diff --git a/docs/solo/developer/feature-matrix.md b/docs/solo/developer/feature-matrix.md index 7ded2b87..96d80284 100644 --- a/docs/solo/developer/feature-matrix.md +++ b/docs/solo/developer/feature-matrix.md @@ -63,6 +63,10 @@ build flag it needs. | Remote admin | yes | yes | yes | yes | yes | | Adding contacts (auto-add by type, hops, replace oldest) | Settings › Contacts | same | same | same | Messages & contacts | | Privacy (position in adverts, telemetry requests) | Settings › Privacy | same | same | same | Settings › Privacy | +| Per-contact actions (telemetry permissions, reset path, share nearby) | Nodes › options | same | same | same | Node card | +| Fixed position, Bluetooth PIN | Settings › Privacy | same | same | same | Settings › Privacy | +| Factory reset | Settings › System | same | same | same | Settings, at the bottom | +| Console (companion CLI, upstream commands plus settings keys) | app / USB tools | same | same | same | same | ### Navigation (`ENV_INCLUDE_GPS`) @@ -89,6 +93,7 @@ build flag it needs. | Advanced radio (hash size, double ACK, CAD, interference, RX boost, RX delay, airtime) | end of Settings › Radio | same | same | same | Radio › Advanced | | Battery curve | Settings | same | same | same | Settings › Power, draggable chart | | QR code of the manual | Status › System, Enter | same | same | same | Settings › About | +| QR code of your contact | Status › System, Enter | same | same | same | Settings › About | ### Device diff --git a/docs/solo/settings.md b/docs/solo/settings.md index aa1fa85c..f3721b42 100644 --- a/docs/solo/settings.md +++ b/docs/solo/settings.md @@ -11,10 +11,10 @@ board supports are shown. | **Sound** | Buzzer on / off / auto (quiet while the app is connected), volume, quiet hours, the melody for messages, channels and adverts | | **Home Pages** | Order of the home pages, and which are shown | | **Radio** | TX power, preset, frequency, SF / BW / CR, saved presets, Auto pwr, the scope list, then the advanced options (below) | -| **System** | Device name, low-battery shutdown, GPS power saving, units, the battery curve, reboot | +| **System** | Device name, low-battery shutdown, GPS power saving, units, the battery curve, reboot, factory reset | | **Keyboard** | ABC or T9 layout, the two scripts, CardKB mode | | **Contacts** | Show all or favourites only (DMs, channels, rooms), favourites on top, contact expiry and prune, which heard nodes are added | -| **Privacy** | Whether your adverts carry your position, and who may ask for your status, location and sensor readings | +| **Privacy** | Whether your adverts carry your position and which position, who may ask for your status, location and sensor readings, the Bluetooth PIN | | **Messages** | Automatic resend of direct messages, the ten quick messages | A few notes: @@ -32,6 +32,9 @@ A few notes: doesn't follow the usual LiPo curve: **Left / Right** move a point by 10 mV, **Reset** goes back to LiPo. The current voltage is shown, so a point can be set against what the battery reads. +- **Factory reset** erases contacts, channels, messages, settings and the + node's keys, then restarts as a new node. It asks twice. On the Wio Tracker + L2 the SD card is left as it is. ### Adding contacts @@ -53,10 +56,18 @@ nodes aren't added until you make room. including the automatic ones. Off, nodes see your name but not where you are. Live sharing and `{loc}` in a message still send it, because you send those on purpose. +- **Position**: where your adverts say you are while the GPS is off. **Enter** + to type it in (`50.06143, 19.93658`), take it from a waypoint, or clear it. + With the GPS on, the row shows **GPS** and each fix replaces it; turning the + GPS off keeps the last fix. The phone app sets the same position. - **Ask status / loc / sens**: who may request your telemetry: **Nobody**, - **Allowed** (the contacts you gave that permission, for now in the phone - app) or **Everyone**. Status is the battery; a request for location or + **Allowed** (the contacts you gave that permission: **Telem** in a node's + options in [Nodes](./contacts.md#nearby-nodes), or the phone app) or + **Everyone**. Status is the battery; a request for location or sensors is answered only where status is allowed too. +- **BT PIN**: the six-digit Bluetooth pairing PIN. Leave it empty for a new + random one at each start, shown on screen. A new PIN is used from the next + start; the phone pairs again after that. ### Advanced radio @@ -84,4 +95,43 @@ if your mesh agrees on it. > Bluetooth, WiFi, GPS. Adding contacts is in Messages & contacts. > - **Map & data**: Map (trail, live sharing and arrival alert options), > Storage. -> - **System**: Name, Time, Units, Firmware update, About; Reboot and Power off. +> - **System**: Name, Time, Units, Firmware update, About; Reboot, Power off +> and Factory reset. Privacy holds **Fixed position** and, under +> Bluetooth, the **Pairing PIN**. + +## Console + +The phone app's console and the console in the site's +[USB tools](https://solo.marekzegarek.com/#pc-tools) change settings by +text. The commands are the ones upstream MeshCore's companion firmware has, +so its documentation applies: + +| Command | Does | +| --- | --- | +| `get radio`, `set radio 869.525,250,11,5` | Frequency, bandwidth, SF and CR, applied at once | +| `get tx`, `set tx 14` | TX power in dBm | +| `get af` / `set af`, `get dutycycle` / `set dutycycle` | Airtime factor, or the same as a duty cycle in % | +| `rxdelay`, `int.thresh`, `cad`, `radio.rxgain`, `path.hash.mode`, `multi.acks` | The advanced radio options, with `get` / `set` | +| `get name`, `set name …` | The device name | +| `get pin`, `set pin 123456` | The Bluetooth PIN, `0` for a random one; used from the next start | +| `get tz.offset`, `set tz.offset 2` | Time zone, in hours from UTC | +| `get lat`, `set lat …`, `get lon`, `set lon …` | The fixed position | +| `ver`, `board` | Firmware version, hardware | +| `reboot`, `poweroff` | Restart or turn off | + +Every other setting has a key: `keys` lists them a page at a time (`keys 2`, +`keys 3` …). `get` shows a value as the Settings screen does, and `set` +takes it the same way, case ignored: + +``` +get telemetry.base > Nobody +set telemetry.base Allowed +set display.off 30 s +set advert.loc on +set autoadd.room off +``` + +A number works too: the position in the list (`set telemetry.base 2`), or a +stored value such as seconds or millivolts (`set display.off 60`, +`set batt.shutdown 3300`). A wrong value answers with the ones that fit. +Factory reset and the node's keys are not in the console. diff --git a/docs/solo/tools.md b/docs/solo/tools.md index 11c90fcf..d875639a 100644 --- a/docs/solo/tools.md +++ b/docs/solo/tools.md @@ -103,7 +103,7 @@ Right** between them, **Up / Down** scrolls): | **Sky** | The satellites on a sky plot, or each one's signal as bars (**Enter** swaps them); Wio Tracker L1 | | **Power** | Battery level, voltage, source and trend, then the readings of any sensors | | **Mesh** | Packets received and sent with a traffic line, forwarded, nodes heard, contacts; **hold Enter** resets the counters | -| **System** | Uptime, firmware, device, memory, the send queue, errors, and a sample of every script the font covers; **Enter** shows a QR code that opens this manual on a phone | +| **System** | Uptime, firmware, device, memory, the send queue, errors, and a sample of every script the font covers; **Enter** shows your contact as a QR code, which the phone app scans to add you, and **Enter** again a QR code that opens this manual; any other key closes it | **GPS pwr** in Settings › System turns the GPS off between fixes to save battery; anything that needs your position (the trail, live share, the @@ -116,7 +116,7 @@ locator) keeps it on. > of the satellites and the fix), **Signal** (a bar per satellite with its > strength, and the constellations) and **Details** (position, precision, > time to first fix and what the receiver reports). -> The manual's QR code is in Settings › About. +> Your contact's QR code and the manual's are in Settings › About. ## GPIO diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index bdce7e95..0db5ea60 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -71,8 +71,9 @@ #define CMD_SET_DEFAULT_FLOOD_SCOPE 63 #define CMD_GET_DEFAULT_FLOOD_SCOPE 64 #define CMD_SEND_RAW_PACKET 65 +#define CMD_RUN_CLI_COMMAND 66 // v14+ #ifdef ENABLE_SCREENSHOT -#define CMD_GET_SCREENSHOT 66 // Request screenshot from display +#define CMD_GET_SCREENSHOT 0x7E // Request screenshot from display (Solo; 66 is upstream's CLI) #endif // Stats sub-types for CMD_GET_STATS @@ -109,8 +110,9 @@ #define RESP_ALLOWED_REPEAT_FREQ 26 #define RESP_CODE_CHANNEL_DATA_RECV 27 #define RESP_CODE_DEFAULT_FLOOD_SCOPE 28 +#define RESP_CODE_CLI_REPLY 29 // v14+, a reply to CMD_RUN_CLI_COMMAND #ifdef ENABLE_SCREENSHOT -#define RESP_CODE_SCREENSHOT 29 // Response with screenshot data +#define RESP_CODE_SCREENSHOT 0x7E // reply to CMD_GET_SCREENSHOT (Solo; 29 is upstream's CLI reply) #define SCREENSHOT_TYPE_RGB565 2 // display_type of a colour screen's frame #endif @@ -288,9 +290,8 @@ bool MyMesh::getCADEnabled() const { // RSSI-threshold interference detection relies on _noise_floor, which is only // kept fresh by continuous RX — stale during RX duty-cycle sleep. Auto-enable // hardware CAD (a fresh explicit scan) whenever power-save is actually active. - // _prefs.cad_enabled itself has no UI/CLI exposure yet on companion_radio - // (unlike simple_repeater's CommonCLI `cad` command) — it's wired and - // persisted for a future manual override, but always 0 today. + // _prefs.cad_enabled is the manual override: Settings > Radio's advanced + // options and the companion CLI's `cad` (MyMeshCli.h) set it. // rx_powersave is never actually applied to the radio while FEAT_RX_POWERSAVE // is 0 (see Features.h) -- don't let a stale persisted byte from before that // still auto-enable CAD here for a duty-cycle mode that isn't running. @@ -487,6 +488,37 @@ bool MyMesh::setContactFavourite(const uint8_t* pub_key, bool fav) { return true; } +bool MyMesh::setContactTelemetry(const uint8_t* pub_key, uint8_t perms) { + ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (!c) return false; + c->flags = (c->flags & ~0x0E) | ((perms & 0x07) << 1); + c->lastmod = getRTCClock()->getCurrentTime(); + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + return true; +} + +bool MyMesh::factoryReset() { + bool was_on = _serial && _serial->isEnabled(); + if (_serial) { + MESH_DEBUG_PRINTLN("Factory reset: disabling serial interface to prevent reconnects (BLE/WiFi)"); + _serial->disable(); + } + if (_store->formatFileSystem()) return true; + if (was_on) _serial->enable(); // nothing erased: the link as it was + return false; +} + +// lastmod bumped (unlike CMD_RESET_PATH, which the app sent itself) so the +// app's copy of the contact drops the old path too. +bool MyMesh::resetContactPath(const uint8_t* pub_key) { + ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (!c) return false; + c->out_path_len = OUT_PATH_UNKNOWN; + c->lastmod = getRTCClock()->getCurrentTime(); + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + return true; +} + // Shared staleness test for the Settings > Contacts "Prune now" sweep. // // Caveats on lastmod, deliberately accepted: it's "last touched", not purely @@ -3017,11 +3049,7 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid stats sub-type } } else if (cmd_frame[0] == CMD_FACTORY_RESET && memcmp(&cmd_frame[1], "reset", 5) == 0) { - if (_serial) { - MESH_DEBUG_PRINTLN("Factory reset: disabling serial interface to prevent reconnects (BLE/WiFi)"); - _serial->disable(); // Phone app disconnects before we can send OK frame so it's safe here - } - bool success = _store->formatFileSystem(); + bool success = factoryReset(); // phone app disconnects before we can send OK frame so it's safe here if (success) { writeOKFrame(); #ifdef SIM_PLATFORM @@ -3116,6 +3144,19 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(&out_frame[i], &r->upper_freq, 4); i += 4; } _serial->writeFrame(out_frame, i); + } else if (cmd_frame[0] == CMD_RUN_CLI_COMMAND && len >= 3) { // v14+, MyMeshCli.h + char text[MAX_FRAME_SIZE + 1]; + memcpy(text, &cmd_frame[1], len - 1); + text[len - 1] = 0; + char* reply = (char*)&out_frame[1]; + const int n = MAX_FRAME_SIZE - 1; + reply[0] = 0; + if (!handleCliCommand(text, reply, n)) { + size_t at = strlen(reply); // after an "xx|" prefix, if it had one + snprintf(reply + at, n - at, "Unknown command"); + } + out_frame[0] = RESP_CODE_CLI_REPLY; + _serial->writeFrame(out_frame, 1 + strlen(reply)); } else if (cmd_frame[0] == CMD_SEND_RAW_PACKET && len >= 4) { auto pkt = obtainNewPacket(); if (pkt) { @@ -3534,3 +3575,4 @@ bool MyMesh::hasPendingWork() const { } #include "MyMeshBot.h" +#include "MyMeshCli.h" diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index e8bf4e30..276fbcb9 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -9,7 +9,7 @@ class UITask; /*------------ Frame Protocol --------------*/ -#define FIRMWARE_VER_CODE 13 +#define FIRMWARE_VER_CODE 14 // 14: CMD_RUN_CLI_COMMAND, as upstream // Fallback only -- every real build (local or CI) goes through build.sh, which // always injects its own FIRMWARE_BUILD_DATE (today's date at build time). @@ -188,6 +188,9 @@ public: // its state and restarts. Returns false if it doesn't handle it, and // MyMesh then flushes and reboots by itself. virtual bool requestShutdown(bool restart) { return false; } + // A companion CLI command MyMesh has no answer for (its settings keys): + // the reply into `reply` (n bytes), false when unknown. + virtual bool onCliCommand(const char* command, char* reply, int n) { return false; } }; MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store); @@ -402,6 +405,22 @@ public: // "fav" list filters no longer depend on the app having starred anything. bool setContactFavourite(const uint8_t* pub_key, bool fav); + // On-device per-contact telemetry permissions (Nodes): ContactInfo::flags + // bits 1-3 = status / location / sensors, read where Settings > Privacy says + // "Allowed" (onContactRequest). The same bits the app's per-contact toggles write. + bool setContactTelemetry(const uint8_t* pub_key, uint8_t perms); + + // On-device path reset (Nodes): forget the learned route; the next message + // floods and learns a new one, as CMD_RESET_PATH does. + bool resetContactPath(const uint8_t* pub_key); + + // Factory reset (CMD_FACTORY_RESET, Settings on the device): the serial link + // off, then every file and setting erased, keys too. The caller reboots. + bool factoryReset(); + + // Companion CLI (MyMeshCli.h): CMD_RUN_CLI_COMMAND's text in, the reply out. + bool handleCliCommand(const char* command, char* reply, int n); + // On-device "remote admin" (Tools > Admin): send a CLI command to a node // you're logged into with admin permission (see ClientACL::isAdmin()). The // reply is a text frame (TXT_TYPE_CLI_DATA) delivered via onCommandDataRecv(); @@ -592,6 +611,7 @@ private: } void checkCLIRescueCmd(); + bool cliRadioCommand(const char* command, char* reply, int n); void checkSerialInterface(); bool isValidClientRepeatFreq(uint32_t f) const; #ifdef ENABLE_SCREENSHOT diff --git a/examples/companion_radio/MyMeshCli.h b/examples/companion_radio/MyMeshCli.h new file mode 100644 index 00000000..5f36f00a --- /dev/null +++ b/examples/companion_radio/MyMeshCli.h @@ -0,0 +1,201 @@ +#pragma once +// Companion CLI (CMD_RUN_CLI_COMMAND) -- not part of upstream MyMesh.cpp. +// Included at the bottom of MyMesh.cpp after all class definitions. +// +// The commands upstream's companion CLI has (meshcore-dev MyMesh::handleCommand +// and CommonRadioPrefs::handleCommand), with the same names and replies, so +// the phone app's console works as it does on upstream firmware. What isn't +// one of those goes to the listener: the frontend's settings keys +// (ui-core/SettingsCli.h), the same rows its Settings screen shows. +// No factory reset or key export here: those stay on the device. + +static bool cliOnOff(const char* v) { return strncmp(v, "on", 2) == 0 || strcmp(v, "1") == 0; } + +bool MyMesh::handleCliCommand(const char* command, char* reply, int n) { + while (*command == ' ') command++; + if (strlen(command) > 4 && command[2] == '|') { // optional "xx|" prefix, reflected back + memcpy(reply, command, 3); + reply += 3; + n -= 3; + *reply = 0; + command += 3; + } + if (cliRadioCommand(command, reply, n)) return true; + + if (strcmp(command, "reboot") == 0 || strcmp(command, "poweroff") == 0 || strcmp(command, "shutdown") == 0) { + bool restart = command[0] == 'r'; + if (!(_listener && _listener->requestShutdown(restart))) { + flushDirtyContacts(); + savePrefs(); + if (restart) board.reboot(); else board.powerOff(); + } + return true; + } + if (memcmp(command, "set name ", 9) == 0) { + if (command[9] && strlen(&command[9]) < sizeof(_prefs.node_name)) { + StrHelper::strncpy(_prefs.node_name, &command[9], sizeof(_prefs.node_name)); + savePrefs(); + snprintf(reply, n, "OK"); + } else { + snprintf(reply, n, "Error, bad name"); + } + return true; + } + if (strcmp(command, "get name") == 0) { snprintf(reply, n, "> %s", _prefs.node_name); return true; } + if (memcmp(command, "set pin ", 8) == 0) { + char* end; + uint32_t pin = (uint32_t)strtoul(&command[8], &end, 10); + if (end == &command[8] || *end || (pin != 0 && (pin < 100000 || pin > 999999))) { + snprintf(reply, n, "Error, 6 digits or 0"); + return true; + } + _prefs.ble_pin = pin; + savePrefs(); + if (pin) snprintf(reply, n, "> pin is now %06lu", (unsigned long)pin); + else snprintf(reply, n, "> pin is now random each start"); + return true; + } + if (strcmp(command, "get pin") == 0) { + if (_prefs.ble_pin) snprintf(reply, n, "> %06lu", (unsigned long)_prefs.ble_pin); + else snprintf(reply, n, "> 0 (random each start)"); + return true; + } + if (strcmp(command, "board") == 0) { snprintf(reply, n, "%s", board.getManufacturerName()); return true; } + if (strcmp(command, "ver") == 0) { + snprintf(reply, n, "%s (Build: %s)", FIRMWARE_VERSION, FIRMWARE_BUILD_DATE); + return true; + } + if (strcmp(command, "get tz.offset") == 0) { snprintf(reply, n, "> %d", _prefs.tz_offset_hours); return true; } + if (memcmp(command, "set tz.offset ", 14) == 0) { + int tz = atoi(&command[14]); + if (tz < -12 || tz > 14) snprintf(reply, n, "Error, must be from -12 to +14"); + else { _prefs.tz_offset_hours = (int8_t)tz; savePrefs(); snprintf(reply, n, "OK"); } + return true; + } + // The position adverts carry while the GPS is off (Settings > Privacy > Position). + if (strcmp(command, "get lat") == 0) { snprintf(reply, n, "> %.6f", sensors.node_lat); return true; } + if (strcmp(command, "get lon") == 0) { snprintf(reply, n, "> %.6f", sensors.node_lon); return true; } + if (memcmp(command, "set lat ", 8) == 0 || memcmp(command, "set lon ", 8) == 0) { + char* end; + double v = strtod(&command[8], &end); + bool lat = command[6] == 't'; + if (end == &command[8] || *end || (lat ? (v < -90 || v > 90) : (v < -180 || v > 180))) { snprintf(reply, n, "Error, bad coordinate"); return true; } + (lat ? sensors.node_lat : sensors.node_lon) = v; + savePrefs(); + snprintf(reply, n, "OK"); + return true; + } + + return _listener && _listener->onCliCommand(command, reply, n); +} + +// CommonRadioPrefs::handleCommand's keys, over this firmware's NodePrefs. +bool MyMesh::cliRadioCommand(const char* command, char* reply, int n) { + if (strcmp(command, "get radio") == 0) { + char freq[16], bw[16]; + strcpy(freq, StrHelper::ftoa(_prefs.freq)); + strcpy(bw, StrHelper::ftoa3(_prefs.bw)); + snprintf(reply, n, "> %s,%s,%d,%d", freq, bw, (int)_prefs.sf, (int)_prefs.cr); + return true; + } + if (memcmp(command, "set radio ", 10) == 0) { + char* p = (char*)&command[10]; // "freq,bw,sf,cr" (strtof: no float scanf on every libc) + float freq = strtof(p, &p); + float bw = *p == ',' ? strtof(p + 1, &p) : 0; + int sf = *p == ',' ? (int)strtol(p + 1, &p, 10) : 0; + int cr = *p == ',' ? (int)strtol(p + 1, &p, 10) : 0; + if (freq >= 150.0f && freq <= 2500.0f && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7.0f && bw <= 500.0f) { + _prefs.freq = freq; _prefs.bw = bw; _prefs.sf = sf; _prefs.cr = cr; + savePrefs(); + applyRepeaterRadio(); // at once, as CMD_SET_RADIO_PARAMS does + snprintf(reply, n, "OK"); + } else { + snprintf(reply, n, "Error, invalid radio params"); + } + return true; + } + if (strcmp(command, "get freq") == 0) { snprintf(reply, n, "> %s", StrHelper::ftoa(_prefs.freq)); return true; } + + if (strcmp(command, "get af") == 0) { snprintf(reply, n, "> %s", StrHelper::ftoa(_prefs.airtime_factor)); return true; } + if (memcmp(command, "set af ", 7) == 0) { + char* end; + float af = strtof(&command[7], &end); + if (end == &command[7] || af < 0 || af > 9) snprintf(reply, n, "ERROR: af must be 0-9"); + else { _prefs.airtime_factor = af; savePrefs(); snprintf(reply, n, "OK"); } + return true; + } + if (strcmp(command, "get dutycycle") == 0) { + float dc = 100.0f / (_prefs.airtime_factor + 1.0f); + snprintf(reply, n, "> %d.%d%%", (int)dc, (int)((dc - (int)dc) * 10.0f + 0.5f)); + return true; + } + if (memcmp(command, "set dutycycle ", 14) == 0) { + float dc = atof(&command[14]); + if (dc < 1 || dc > 100) { snprintf(reply, n, "ERROR: dutycycle must be 1-100"); return true; } + _prefs.airtime_factor = 100.0f / dc - 1.0f; + savePrefs(); + float a = 100.0f / (_prefs.airtime_factor + 1.0f); + snprintf(reply, n, "OK - %d.%d%%", (int)a, (int)((a - (int)a) * 10.0f + 0.5f)); + return true; + } + if (strcmp(command, "get int.thresh") == 0) { snprintf(reply, n, "> %d", (int)_prefs.interference_threshold); return true; } + if (memcmp(command, "set int.thresh ", 15) == 0) { + _prefs.interference_threshold = (uint8_t)constrain(atoi(&command[15]), 0, 255); + savePrefs(); + snprintf(reply, n, "OK"); + return true; + } + if (strcmp(command, "get cad") == 0) { snprintf(reply, n, "> %s", _prefs.cad_enabled ? "on" : "off"); return true; } + if (memcmp(command, "set cad ", 8) == 0) { + _prefs.cad_enabled = cliOnOff(&command[8]) ? 1 : 0; + savePrefs(); + snprintf(reply, n, "OK"); + return true; + } + if (strcmp(command, "get radio.rxgain") == 0) { snprintf(reply, n, "> %s", _prefs.rx_boosted_gain ? "on" : "off"); return true; } + if (memcmp(command, "set radio.rxgain ", 17) == 0) { +#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR2021) + _prefs.rx_boosted_gain = cliOnOff(&command[17]) ? 1 : 0; + savePrefs(); + snprintf(reply, n, radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain) ? "OK" : "Error: unsupported"); +#else + snprintf(reply, n, "Error: unsupported"); +#endif + return true; + } + if (memcmp(command, "get tx", 6) == 0 && (command[6] == 0 || command[6] == ' ')) { + snprintf(reply, n, "> %d", (int)_prefs.tx_power_dbm); + return true; + } + if (memcmp(command, "set tx ", 7) == 0) { + int p = atoi(&command[7]); + if (p < -9 || p > MAX_LORA_TX_POWER) { snprintf(reply, n, "Error, must be -9 to %d", MAX_LORA_TX_POWER); return true; } + radio_driver.setTxPower(p); + _prefs.tx_power_dbm = radio_driver.getTxPower(); // what the radio applied (see CMD_SET_RADIO_TX_POWER) + savePrefs(); + snprintf(reply, n, "OK"); + return true; + } + if (strcmp(command, "get rxdelay") == 0) { snprintf(reply, n, "> %s", StrHelper::ftoa(_prefs.rx_delay_base)); return true; } + if (memcmp(command, "set rxdelay ", 12) == 0) { + float d = atof(&command[12]); + if (d < 0 || d > 20.0f) snprintf(reply, n, "Error, must be 0-20"); + else { _prefs.rx_delay_base = d; savePrefs(); snprintf(reply, n, "OK"); } + return true; + } + if (strcmp(command, "get path.hash.mode") == 0) { snprintf(reply, n, "> %d", (int)_prefs.path_hash_mode); return true; } + if (memcmp(command, "set path.hash.mode ", 19) == 0) { + int m = atoi(&command[19]); + if (m < 0 || m > 2) snprintf(reply, n, "Error, must be 0,1, or 2"); + else { _prefs.path_hash_mode = (uint8_t)m; savePrefs(); snprintf(reply, n, "OK"); } + return true; + } + if (strcmp(command, "get multi.acks") == 0) { snprintf(reply, n, "> %d", (int)_prefs.multi_acks); return true; } + if (memcmp(command, "set multi.acks ", 15) == 0) { + _prefs.multi_acks = (uint8_t)constrain(atoi(&command[15]), 0, 1); + savePrefs(); + snprintf(reply, n, "OK"); + return true; + } + return false; +} diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 08a20392..bde1d506 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -694,4 +694,17 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_login_first_repeater(const char* pa uint32_t est_timeout; return the_mesh.sendRoomLogin(ci, password, est_timeout) ? 1 : 0; } + +// The companion CLI (CMD_RUN_CLI_COMMAND) without a transport: the text in, +// the reply out, as the app's console gets it. +extern "C" EMSCRIPTEN_KEEPALIVE const char* sim_cli(const char* command) { + static char reply[MAX_FRAME_SIZE]; + reply[0] = 0; + if (!g_sim_ready) return reply; + if (!the_mesh.handleCliCommand(command, reply, sizeof(reply))) { + size_t at = strlen(reply); + snprintf(reply + at, sizeof(reply) - at, "Unknown command"); + } + return reply; +} #endif diff --git a/examples/companion_radio/ui-core/ContactControl.h b/examples/companion_radio/ui-core/ContactControl.h index 14974dcd..6a3b06d4 100644 --- a/examples/companion_radio/ui-core/ContactControl.h +++ b/examples/companion_radio/ui-core/ContactControl.h @@ -47,6 +47,23 @@ inline void setMelody(NodePrefs* p, const uint8_t* pub_key, uint8_t slot) { inline bool favourite(const ContactInfo& ci) { return (ci.flags & 0x01) != 0; } inline bool setFavourite(const uint8_t* pub_key, bool on) { return the_mesh.setContactFavourite(pub_key, on); } +// ContactInfo::flags bits 1-3: what this contact may ask for (TELEM_PERM_*) +// where Settings > Privacy says "Allowed" -- only then do they matter. +inline uint8_t telemetry(const ContactInfo& ci) { return (ci.flags >> 1) & 0x07; } +inline bool setTelemetry(const uint8_t* pub_key, uint8_t perms) { return the_mesh.setContactTelemetry(pub_key, perms); } +inline bool telemetryPerContact(const NodePrefs* p) { + return p && (p->telemetry_mode_base == TELEM_MODE_ALLOW_FLAGS || p->telemetry_mode_loc == TELEM_MODE_ALLOW_FLAGS + || p->telemetry_mode_env == TELEM_MODE_ALLOW_FLAGS); +} +// A path to forget: the contact has a learned one. +inline bool hasPath(const ContactInfo& ci) { return ci.out_path_len != OUT_PATH_UNKNOWN; } +inline bool resetPath(const uint8_t* pub_key) { return the_mesh.resetContactPath(pub_key); } +// Its last advert, sent again zero-hop so the nodes in range can add it. +inline bool share(const uint8_t* pub_key) { + ContactInfo* c = the_mesh.lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + return c && the_mesh.shareContactZeroHop(*c); +} + // "@[nick] " -- how a reply names who it answers (raw UTF-8, over the air as is). inline void replyPrefix(const char* nick, char* out, size_t n) { snprintf(out, n, "@[%.31s] ", nick); } diff --git a/examples/companion_radio/ui-core/ContactQr.h b/examples/companion_radio/ui-core/ContactQr.h new file mode 100644 index 00000000..053eba56 --- /dev/null +++ b/examples/companion_radio/ui-core/ContactQr.h @@ -0,0 +1,47 @@ +#pragma once +// Your own contact as a QR code: the link the phone app scans to add you, +// meshcore://contact/add?name=&public_key=&type=1 (docs/faq.md). +// Made when it is shown (the name can change); both frontends draw it. + +#include "QrCodeGen.h" + +namespace contactqr { + +// Version 10 (57 modules) holds the link with the longest name, low error +// correction; a short name gives a smaller code. +static const int MAX_VERSION = 10; + +struct Code { + uint8_t buf[qrcodegen_BUFFER_LEN_FOR_VERSION(MAX_VERSION)]; + int n = 0; // modules a side, 0: none + bool dark(int x, int y) const { return qrcodegen_getModule(buf, x, y); } +}; + +// The link, the name percent-encoded. +inline void link(char* out, size_t n) { + static const char DIGITS[] = "0123456789abcdef"; + size_t o = snprintf(out, n, "meshcore://contact/add?name="); + for (const char* p = the_mesh.getNodeName(); *p && o + 4 < n; p++) { + unsigned char ch = (unsigned char)*p; + if (isalnum(ch) || ch == '-' || ch == '_' || ch == '.' || ch == '~') out[o++] = ch; + else { out[o++] = '%'; out[o++] = DIGITS[ch >> 4]; out[o++] = DIGITS[ch & 15]; } + } + o += snprintf(out + o, n - o, "&public_key="); + for (int i = 0; i < PUB_KEY_SIZE && o + 3 < n; i++) { + out[o++] = DIGITS[the_mesh.self_id.pub_key[i] >> 4]; + out[o++] = DIGITS[the_mesh.self_id.pub_key[i] & 15]; + } + snprintf(out + o, n - o, "&type=%d", ADV_TYPE_CHAT); +} + +inline bool make(Code& c) { + char text[224]; + link(text, sizeof(text)); + uint8_t tmp[qrcodegen_BUFFER_LEN_FOR_VERSION(MAX_VERSION)]; + bool ok = qrcodegen_encodeText(text, tmp, c.buf, qrcodegen_Ecc_LOW, qrcodegen_VERSION_MIN, MAX_VERSION, + qrcodegen_Mask_AUTO, true); + c.n = ok ? qrcodegen_getSize(c.buf) : 0; + return ok; +} + +} // namespace contactqr diff --git a/examples/companion_radio/ui-core/QrCodeGen.h b/examples/companion_radio/ui-core/QrCodeGen.h new file mode 100644 index 00000000..aab226cd --- /dev/null +++ b/examples/companion_radio/ui-core/QrCodeGen.h @@ -0,0 +1,1398 @@ +#pragma once +// QR Code generator by Project Nayuki (MIT licence, notice below), as LVGL +// bundles it (lvgl/src/libs/qrcode), made header-only and free of LVGL: every +// function static inline, so a frontend includes it once and the linker keeps +// only what it calls. Used for your own contact's QR (ui-core/ContactQr.h). +/* + * QR Code generator library (C) + * + * Copyright (c) Project Nayuki. (MIT License) + * https://www.nayuki.io/page/qr-code-generator-library + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * - The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * - The Software is provided "as is", without warranty of any kind, express or + * implied, including but not limited to the warranties of merchantability, + * fitness for a particular purpose and noninfringement. In no event shall the + * authors or copyright holders be liable for any claim, damages or other + * liability, whether in an action of contract, tort or otherwise, arising from, + * out of or in connection with the Software or the use or other dealings in the + * Software. + */ + +#include +#include +#include +#include +#include +#include + +#define QR_ASSERT(x) ((void)0) + +/* + * This library creates QR Code symbols, which is a type of two-dimension barcode. + * Invented by Denso Wave and described in the ISO/IEC 18004 standard. + * A QR Code structure is an immutable square grid of black and white cells. + * The library provides functions to create a QR Code from text or binary data. + * The library covers the QR Code Model 2 specification, supporting all versions (sizes) + * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. + * + * Ways to create a QR Code object: + * - High level: Take the payload data and call qrcodegen_encodeText() or qrcodegen_encodeBinary(). + * - Low level: Custom-make the list of segments and call + * qrcodegen_encodeSegments() or qrcodegen_encodeSegmentsAdvanced(). + * (Note that all ways require supplying the desired error correction level and various byte buffers.) + */ + + +/*---- Enum and struct types----*/ + +/* + * The error correction level in a QR Code symbol. + */ +enum qrcodegen_Ecc { + // Must be declared in ascending order of error protection + // so that an internal qrcodegen function works properly + qrcodegen_Ecc_LOW = 0, // The QR Code can tolerate about 7% erroneous codewords + qrcodegen_Ecc_MEDIUM, // The QR Code can tolerate about 15% erroneous codewords + qrcodegen_Ecc_QUARTILE, // The QR Code can tolerate about 25% erroneous codewords + qrcodegen_Ecc_HIGH, // The QR Code can tolerate about 30% erroneous codewords +}; + + +/* + * The mask pattern used in a QR Code symbol. + */ +enum qrcodegen_Mask { + // A special value to tell the QR Code encoder to + // automatically select an appropriate mask pattern + qrcodegen_Mask_AUTO = -1, + // The eight actual mask patterns + qrcodegen_Mask_0 = 0, + qrcodegen_Mask_1, + qrcodegen_Mask_2, + qrcodegen_Mask_3, + qrcodegen_Mask_4, + qrcodegen_Mask_5, + qrcodegen_Mask_6, + qrcodegen_Mask_7, +}; + + +/* + * Describes how a segment's data bits are interpreted. + */ +enum qrcodegen_Mode { + qrcodegen_Mode_NUMERIC = 0x1, + qrcodegen_Mode_ALPHANUMERIC = 0x2, + qrcodegen_Mode_BYTE = 0x4, + qrcodegen_Mode_KANJI = 0x8, + qrcodegen_Mode_ECI = 0x7, +}; + + +/* + * A segment of character/binary/control data in a QR Code symbol. + * The mid-level way to create a segment is to take the payload data + * and call a factory function such as qrcodegen_makeNumeric(). + * The low-level way to create a segment is to custom-make the bit buffer + * and initialize a qrcodegen_Segment struct with appropriate values. + * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. + * Any segment longer than this is meaningless for the purpose of generating QR Codes. + * Moreover, the maximum allowed bit length is 32767 because + * the largest QR Code (version 40) has 31329 modules. + */ +struct qrcodegen_Segment { + // The mode indicator of this segment. + enum qrcodegen_Mode mode; + + // The length of this segment's unencoded data. Measured in characters for + // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. + // Always zero or positive. Not the same as the data's bit length. + int numChars; + + // The data bits of this segment, packed in bitwise big endian. + // Can be null if the bit length is zero. + uint8_t * data; + + // The number of valid data bits used in the buffer. Requires + // 0 <= bitLength <= 32767, and bitLength <= (capacity of data array) * 8. + // The character count (numChars) must agree with the mode and the bit buffer length. + int bitLength; +}; + + + +/*---- Macro constants and functions ----*/ + +#define qrcodegen_VERSION_MIN 1 // The minimum version number supported in the QR Code Model 2 standard +#define qrcodegen_VERSION_MAX 40 // The maximum version number supported in the QR Code Model 2 standard + +// Calculates the number of bytes needed to store any QR Code up to and including the given version number, +// as a compile-time constant. For example, 'uint8_t buffer[qrcodegen_BUFFER_LEN_FOR_VERSION(25)];' +// can store any single QR Code from version 1 to 25 (inclusive). The result fits in an int (or int16). +// Requires qrcodegen_VERSION_MIN <= n <= qrcodegen_VERSION_MAX. +#define qrcodegen_BUFFER_LEN_FOR_VERSION(n) ((((n) * 4 + 17) * ((n) * 4 + 17) + 7) / 8 + 1) + +// The worst-case number of bytes needed to store one QR Code, up to and including +// version 40. This value equals 3918, which is just under 4 kilobytes. +// Use this more convenient value to avoid calculating tighter memory bounds for buffers. +#define qrcodegen_BUFFER_LEN_MAX qrcodegen_BUFFER_LEN_FOR_VERSION(qrcodegen_VERSION_MAX) + + + +/*---- Functions (high level) to generate QR Codes ----*/ + +/* + * Encodes the given text string to a QR Code, returning true if encoding succeeded. + * If the data is too long to fit in any version in the given range + * at the given ECC level, then false is returned. + * - The input text must be encoded in UTF-8 and contain no NULs. + * - The variables ecl and mask must correspond to enum constant values. + * - Requires 1 <= minVersion <= maxVersion <= 40. + * - The arrays tempBuffer and qrcode must each have a length + * of at least qrcodegen_BUFFER_LEN_FOR_VERSION(maxVersion). + * - After the function returns, tempBuffer contains no useful data. + * - If successful, the resulting QR Code may use numeric, + * alphanumeric, or byte mode to encode the text. + * - In the most optimistic case, a QR Code at version 40 with low ECC + * can hold any UTF-8 string up to 2953 bytes, or any alphanumeric string + * up to 4296 characters, or any digit string up to 7089 characters. + * These numbers represent the hard upper limit of the QR Code standard. + * - Please consult the QR Code specification for information on + * data capacities per version, ECC level, and text encoding mode. + */ +static inline bool qrcodegen_encodeText(const char * text, uint8_t tempBuffer[], uint8_t qrcode[], + enum qrcodegen_Ecc ecl, int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl); + + +/* + * Encodes the given binary data to a QR Code, returning true if encoding succeeded. + * If the data is too long to fit in any version in the given range + * at the given ECC level, then false is returned. + * - The input array range dataAndTemp[0 : dataLen] should normally be + * valid UTF-8 text, but is not required by the QR Code standard. + * - The variables ecl and mask must correspond to enum constant values. + * - Requires 1 <= minVersion <= maxVersion <= 40. + * - The arrays dataAndTemp and qrcode must each have a length + * of at least qrcodegen_BUFFER_LEN_FOR_VERSION(maxVersion). + * - After the function returns, the contents of dataAndTemp may have changed, + * and does not represent useful data anymore. + * - If successful, the resulting QR Code will use byte mode to encode the data. + * - In the most optimistic case, a QR Code at version 40 with low ECC can hold any byte + * sequence up to length 2953. This is the hard upper limit of the QR Code standard. + * - Please consult the QR Code specification for information on + * data capacities per version, ECC level, and text encoding mode. + */ +static inline bool qrcodegen_encodeBinary(uint8_t dataAndTemp[], size_t dataLen, uint8_t qrcode[], + enum qrcodegen_Ecc ecl, int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl); + + +/*---- Functions (low level) to generate QR Codes ----*/ + +/* + * Renders a QR Code representing the given segments at the given error correction level. + * The smallest possible QR Code version is automatically chosen for the output. Returns true if + * QR Code creation succeeded, or false if the data is too long to fit in any version. The ECC level + * of the result may be higher than the ecl argument if it can be done without increasing the version. + * This function allows the user to create a custom sequence of segments that switches + * between modes (such as alphanumeric and byte) to encode text in less space. + * This is a low-level API; the high-level API is qrcodegen_encodeText() and qrcodegen_encodeBinary(). + * To save memory, the segments' data buffers can alias/overlap tempBuffer, and will + * result in them being clobbered, but the QR Code output will still be correct. + * But the qrcode array must not overlap tempBuffer or any segment's data buffer. + */ +static inline bool qrcodegen_encodeSegments(const struct qrcodegen_Segment segs[], size_t len, + enum qrcodegen_Ecc ecl, uint8_t tempBuffer[], uint8_t qrcode[]); + + +/* + * Renders a QR Code representing the given segments with the given encoding parameters. + * Returns true if QR Code creation succeeded, or false if the data is too long to fit in the range of versions. + * The smallest possible QR Code version within the given range is automatically + * chosen for the output. Iff boostEcl is true, then the ECC level of the result + * may be higher than the ecl argument if it can be done without increasing the + * version. The mask number is either between 0 to 7 (inclusive) to force that + * mask, or -1 to automatically choose an appropriate mask (which may be slow). + * This function allows the user to create a custom sequence of segments that switches + * between modes (such as alphanumeric and byte) to encode text in less space. + * This is a low-level API; the high-level API is qrcodegen_encodeText() and qrcodegen_encodeBinary(). + * To save memory, the segments' data buffers can alias/overlap tempBuffer, and will + * result in them being clobbered, but the QR Code output will still be correct. + * But the qrcode array must not overlap tempBuffer or any segment's data buffer. + */ +static inline bool qrcodegen_encodeSegmentsAdvanced(const struct qrcodegen_Segment segs[], size_t len, enum qrcodegen_Ecc ecl, + int minVersion, int maxVersion, int mask, bool boostEcl, uint8_t tempBuffer[], uint8_t qrcode[]); + + +/* + * Tests whether the given string can be encoded as a segment in alphanumeric mode. + * A string is encodable iff each character is in the following set: 0 to 9, A to Z + * (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. + */ +static inline bool qrcodegen_isAlphanumeric(const char * text); + + +/* + * Tests whether the given string can be encoded as a segment in numeric mode. + * A string is encodable iff each character is in the range 0 to 9. + */ +static inline bool qrcodegen_isNumeric(const char * text); + + +/* + * Returns the number of bytes (uint8_t) needed for the data buffer of a segment + * containing the given number of characters using the given mode. Notes: + * - Returns SIZE_MAX on failure, i.e. numChars > INT16_MAX or + * the number of needed bits exceeds INT16_MAX (i.e. 32767). + * - Otherwise, all valid results are in the range [0, ceil(INT16_MAX / 8)], i.e. at most 4096. + * - It is okay for the user to allocate more bytes for the buffer than needed. + * - For byte mode, numChars measures the number of bytes, not Unicode code points. + * - For ECI mode, numChars must be 0, and the worst-case number of bytes is returned. + * An actual ECI segment can have shorter data. For non-ECI modes, the result is exact. + */ +static inline size_t qrcodegen_calcSegmentBufferSize(enum qrcodegen_Mode mode, size_t numChars); + + +/* + * Returns a segment representing the given binary data encoded in + * byte mode. All input byte arrays are acceptable. Any text string + * can be converted to UTF-8 bytes and encoded as a byte mode segment. + */ +static inline struct qrcodegen_Segment qrcodegen_makeBytes(const uint8_t data[], size_t len, uint8_t buf[]); + + +/* + * Returns a segment representing the given string of decimal digits encoded in numeric mode. + */ +static inline struct qrcodegen_Segment qrcodegen_makeNumeric(const char * digits, uint8_t buf[]); + + +/* + * Returns a segment representing the given text string encoded in alphanumeric mode. + * The characters allowed are: 0 to 9, A to Z (uppercase only), space, + * dollar, percent, asterisk, plus, hyphen, period, slash, colon. + */ +static inline struct qrcodegen_Segment qrcodegen_makeAlphanumeric(const char * text, uint8_t buf[]); + + +/* + * Returns a segment representing an Extended Channel Interpretation + * (ECI) designator with the given assignment value. + */ +static inline struct qrcodegen_Segment qrcodegen_makeEci(long assignVal, uint8_t buf[]); + + +/*---- Functions to extract raw data from QR Codes ----*/ + +/* + * Returns the side length of the given QR Code, assuming that encoding succeeded. + * The result is in the range [21, 177]. Note that the length of the array buffer + * is related to the side length - every 'uint8_t qrcode[]' must have length at least + * qrcodegen_BUFFER_LEN_FOR_VERSION(version), which equals ceil(size^2 / 8 + 1). + */ +static inline int qrcodegen_getSize(const uint8_t qrcode[]); + + +/* + * Returns the color of the module (pixel) at the given coordinates, which is false + * for white or true for black. The top left corner has the coordinates (x=0, y=0). + * If the given coordinates are out of bounds, then false (white) is returned. + */ +static inline bool qrcodegen_getModule(const uint8_t qrcode[], int x, int y); + +/* + * Returns the qrcode size of the specified version. Returns -1 on failure + */ +static inline int qrcodegen_version2size(int version); +/* + * Returns the min version of the data that can be stored. Returns -1 on failure + */ +static inline int qrcodegen_getMinFitVersion(enum qrcodegen_Ecc ecl, size_t dataLen); + + +/*---- Forward declarations for private functions ----*/ + +// Regarding all public and private functions defined in this source file: +// - They require all pointer/array arguments to be not null unless the array length is zero. +// - They only read input scalar/array arguments, write to output pointer/array +// arguments, and return scalar values; they are "pure" functions. +// - They don't read mutable global variables or write to any global variables. +// - They don't perform I/O, read the clock, print to console, etc. +// - They allocate a small and constant amount of stack memory. +// - They don't allocate or free any memory on the heap. +// - They don't recurse or mutually recurse. All the code +// could be inlined into the top-level public functions. +// - They run in at most quadratic time with respect to input arguments. +// Most functions run in linear time, and some in constant time. +// There are no unbounded loops or non-obvious termination conditions. +// - They are completely thread-safe if the caller does not give the +// same writable buffer to concurrent calls to these functions. + +static inline void appendBitsToBuffer(unsigned int val, int numBits, uint8_t buffer[], int * bitLen); + +static inline void addEccAndInterleave(uint8_t data[], int version, enum qrcodegen_Ecc ecl, uint8_t result[]); +static inline int getNumDataCodewords(int version, enum qrcodegen_Ecc ecl); +static inline int getNumRawDataModules(int ver); + +static inline void calcReedSolomonGenerator(int degree, uint8_t result[]); +static inline void calcReedSolomonRemainder(const uint8_t data[], int dataLen, + const uint8_t generator[], int degree, uint8_t result[]); +static inline uint8_t finiteFieldMultiply(uint8_t x, uint8_t y); + +static inline void initializeFunctionModules(int version, uint8_t qrcode[]); +static void drawWhiteFunctionModules(uint8_t qrcode[], int version); +static void drawFormatBits(enum qrcodegen_Ecc ecl, enum qrcodegen_Mask mask, uint8_t qrcode[]); +static inline int getAlignmentPatternPositions(int version, uint8_t result[7]); +static void fillRectangle(int left, int top, int width, int height, uint8_t qrcode[]); + +static void drawCodewords(const uint8_t data[], int dataLen, uint8_t qrcode[]); +static void applyMask(const uint8_t functionModules[], uint8_t qrcode[], enum qrcodegen_Mask mask); +static long getPenaltyScore(const uint8_t qrcode[]); +static void addRunToHistory(unsigned char run, unsigned char history[7]); +static bool hasFinderLikePattern(const unsigned char runHistory[7]); + +static inline bool getModule(const uint8_t qrcode[], int x, int y); +static inline void setModule(uint8_t qrcode[], int x, int y, bool isBlack); +static inline void setModuleBounded(uint8_t qrcode[], int x, int y, bool isBlack); +static bool getBit(int x, int i); + +static inline int calcSegmentBitLength(enum qrcodegen_Mode mode, size_t numChars); +static inline int getTotalBits(const struct qrcodegen_Segment segs[], size_t len, int version); +static int numCharCountBits(enum qrcodegen_Mode mode, int version); + + + +/*---- Private tables of constants ----*/ + +// The set of all legal characters in alphanumeric mode, where each character +// value maps to the index in the string. For checking text and encoding segments. +static const char * ALPHANUMERIC_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; + +// For generating error correction codes. +static inline const int8_t ECC_CODEWORDS_PER_BLOCK[4][41] = { + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + {-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // Low + {-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28}, // Medium + {-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // Quartile + {-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // High +}; + +#define qrcodegen_REED_SOLOMON_DEGREE_MAX 30 // Based on the table above + +// For generating error correction codes. +static inline const int8_t NUM_ERROR_CORRECTION_BLOCKS[4][41] = { + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + {-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25}, // Low + {-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49}, // Medium + {-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68}, // Quartile + {-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81}, // High +}; + +// For automatic mask pattern selection. +static const int PENALTY_N1 = 3; +static const int PENALTY_N2 = 3; +static const int PENALTY_N3 = 40; +static const int PENALTY_N4 = 10; + + + +/*---- High-level QR Code encoding functions ----*/ + +// Public function - see documentation comment in header file. +static inline bool qrcodegen_encodeText(const char * text, uint8_t tempBuffer[], uint8_t qrcode[], + enum qrcodegen_Ecc ecl, int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl) +{ + + size_t textLen = strlen(text); + if(textLen == 0) + return qrcodegen_encodeSegmentsAdvanced(NULL, 0, ecl, minVersion, maxVersion, mask, boostEcl, tempBuffer, qrcode); + size_t bufLen = qrcodegen_BUFFER_LEN_FOR_VERSION(maxVersion); + + struct qrcodegen_Segment seg; + if(qrcodegen_isNumeric(text)) { + if(qrcodegen_calcSegmentBufferSize(qrcodegen_Mode_NUMERIC, textLen) > bufLen) + goto fail; + seg = qrcodegen_makeNumeric(text, tempBuffer); + } + else if(qrcodegen_isAlphanumeric(text)) { + if(qrcodegen_calcSegmentBufferSize(qrcodegen_Mode_ALPHANUMERIC, textLen) > bufLen) + goto fail; + seg = qrcodegen_makeAlphanumeric(text, tempBuffer); + } + else { + if(textLen > bufLen) + goto fail; + for(size_t i = 0; i < textLen; i++) + tempBuffer[i] = (uint8_t)text[i]; + seg.mode = qrcodegen_Mode_BYTE; + seg.bitLength = calcSegmentBitLength(seg.mode, textLen); + if(seg.bitLength == -1) + goto fail; + seg.numChars = (int)textLen; + seg.data = tempBuffer; + } + return qrcodegen_encodeSegmentsAdvanced(&seg, 1, ecl, minVersion, maxVersion, mask, boostEcl, tempBuffer, qrcode); + +fail: + qrcode[0] = 0; // Set size to invalid value for safety + return false; +} + + +// Public function - see documentation comment in header file. +static inline bool qrcodegen_encodeBinary(uint8_t dataAndTemp[], size_t dataLen, uint8_t qrcode[], + enum qrcodegen_Ecc ecl, int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl) +{ + + struct qrcodegen_Segment seg; + seg.mode = qrcodegen_Mode_BYTE; + seg.bitLength = calcSegmentBitLength(seg.mode, dataLen); + if(seg.bitLength == -1) { + qrcode[0] = 0; // Set size to invalid value for safety + return false; + } + seg.numChars = (int)dataLen; + seg.data = dataAndTemp; + return qrcodegen_encodeSegmentsAdvanced(&seg, 1, ecl, minVersion, maxVersion, mask, boostEcl, dataAndTemp, qrcode); +} + + +// Appends the given number of low-order bits of the given value to the given byte-based +// bit buffer, increasing the bit length. Requires 0 <= numBits <= 16 and val < 2^numBits. +static inline void appendBitsToBuffer(unsigned int val, int numBits, uint8_t buffer[], int * bitLen) +{ + QR_ASSERT(0 <= numBits && numBits <= 16 && (unsigned long)val >> numBits == 0); + for(int i = numBits - 1; i >= 0; i--, (*bitLen)++) + buffer[*bitLen >> 3] |= ((val >> i) & 1) << (7 - (*bitLen & 7)); +} + + + +/*---- Low-level QR Code encoding functions ----*/ + +// Public function - see documentation comment in header file. +static inline bool qrcodegen_encodeSegments(const struct qrcodegen_Segment segs[], size_t len, + enum qrcodegen_Ecc ecl, uint8_t tempBuffer[], uint8_t qrcode[]) +{ + return qrcodegen_encodeSegmentsAdvanced(segs, len, ecl, + qrcodegen_VERSION_MIN, qrcodegen_VERSION_MAX, -1, true, tempBuffer, qrcode); +} + + +// Public function - see documentation comment in header file. +static inline bool qrcodegen_encodeSegmentsAdvanced(const struct qrcodegen_Segment segs[], size_t len, enum qrcodegen_Ecc ecl, + int minVersion, int maxVersion, int mask, bool boostEcl, uint8_t tempBuffer[], uint8_t qrcode[]) +{ + QR_ASSERT(segs != NULL || len == 0); + QR_ASSERT(qrcodegen_VERSION_MIN <= minVersion && minVersion <= maxVersion && maxVersion <= qrcodegen_VERSION_MAX); + QR_ASSERT(0 <= (int)ecl && (int)ecl <= 3 && -1 <= (int)mask && (int)mask <= 7); + + // Find the minimal version number to use + int version, dataUsedBits; + for(version = minVersion; ; version++) { + int dataCapacityBits = getNumDataCodewords(version, ecl) * 8; // Number of data bits available + dataUsedBits = getTotalBits(segs, len, version); + if(dataUsedBits != -1 && dataUsedBits <= dataCapacityBits) + break; // This version number is found to be suitable + if(version >= maxVersion) { // All versions in the range could not fit the given data + qrcode[0] = 0; // Set size to invalid value for safety + return false; + } + } + QR_ASSERT(dataUsedBits != -1); + + // Increase the error correction level while the data still fits in the current version number + for(int i = (int)qrcodegen_Ecc_MEDIUM; i <= (int)qrcodegen_Ecc_HIGH; i++) { // From low to high + if(boostEcl && dataUsedBits <= getNumDataCodewords(version, (enum qrcodegen_Ecc)i) * 8) + ecl = (enum qrcodegen_Ecc)i; + } + + // Concatenate all segments to create the data bit string + memset(qrcode, 0, qrcodegen_BUFFER_LEN_FOR_VERSION(version) * sizeof(qrcode[0])); + int bitLen = 0; + for(size_t i = 0; i < len; i++) { + const struct qrcodegen_Segment * seg = &segs[i]; + appendBitsToBuffer((int)seg->mode, 4, qrcode, &bitLen); + appendBitsToBuffer(seg->numChars, numCharCountBits(seg->mode, version), qrcode, &bitLen); + for(int j = 0; j < seg->bitLength; j++) + appendBitsToBuffer((seg->data[j >> 3] >> (7 - (j & 7))) & 1, 1, qrcode, &bitLen); + } + QR_ASSERT(bitLen == dataUsedBits); + + // Add terminator and pad up to a byte if applicable + int dataCapacityBits = getNumDataCodewords(version, ecl) * 8; + QR_ASSERT(bitLen <= dataCapacityBits); + int terminatorBits = dataCapacityBits - bitLen; + if(terminatorBits > 4) + terminatorBits = 4; + appendBitsToBuffer(0, terminatorBits, qrcode, &bitLen); + appendBitsToBuffer(0, (8 - bitLen % 8) % 8, qrcode, &bitLen); + QR_ASSERT(bitLen % 8 == 0); + + // Pad with alternating bytes until data capacity is reached + for(uint8_t padByte = 0xEC; bitLen < dataCapacityBits; padByte ^= 0xEC ^ 0x11) + appendBitsToBuffer(padByte, 8, qrcode, &bitLen); + + // Draw function and data codeword modules + addEccAndInterleave(qrcode, version, ecl, tempBuffer); + initializeFunctionModules(version, qrcode); + drawCodewords(tempBuffer, getNumRawDataModules(version) / 8, qrcode); + drawWhiteFunctionModules(qrcode, version); + initializeFunctionModules(version, tempBuffer); + + // Handle masking + if(mask == qrcodegen_Mask_AUTO) { // Automatically choose best mask + long minPenalty = LONG_MAX; + for(int i = 0; i < 8; i++) { + enum qrcodegen_Mask msk = (enum qrcodegen_Mask)i; + applyMask(tempBuffer, qrcode, msk); + drawFormatBits(ecl, msk, qrcode); + long penalty = getPenaltyScore(qrcode); + if(penalty < minPenalty) { + mask = msk; + minPenalty = penalty; + } + applyMask(tempBuffer, qrcode, msk); // Undoes the mask due to XOR + } + } + QR_ASSERT(0 <= (int)mask && (int)mask <= 7); + applyMask(tempBuffer, qrcode, (enum qrcodegen_Mask)mask); + drawFormatBits(ecl, (enum qrcodegen_Mask)mask, qrcode); + return true; +} + + + +/*---- Error correction code generation functions ----*/ + +// Appends error correction bytes to each block of the given data array, then interleaves +// bytes from the blocks and stores them in the result array. data[0 : dataLen] contains +// the input data. data[dataLen : rawCodewords] is used as a temporary work area and will +// be clobbered by this function. The final answer is stored in result[0 : rawCodewords]. +static inline void addEccAndInterleave(uint8_t data[], int version, enum qrcodegen_Ecc ecl, uint8_t result[]) +{ + // Calculate parameter numbers + QR_ASSERT(0 <= (int)ecl && (int)ecl < 4 && qrcodegen_VERSION_MIN <= version && version <= qrcodegen_VERSION_MAX); + int numBlocks = NUM_ERROR_CORRECTION_BLOCKS[(int)ecl][version]; + int blockEccLen = ECC_CODEWORDS_PER_BLOCK [(int)ecl][version]; + int rawCodewords = getNumRawDataModules(version) / 8; + int dataLen = getNumDataCodewords(version, ecl); + int numShortBlocks = numBlocks - rawCodewords % numBlocks; + int shortBlockDataLen = rawCodewords / numBlocks - blockEccLen; + + // Split data into blocks, calculate ECC, and interleave + // (not concatenate) the bytes into a single sequence + uint8_t generator[qrcodegen_REED_SOLOMON_DEGREE_MAX]; + calcReedSolomonGenerator(blockEccLen, generator); + const uint8_t * dat = data; + for(int i = 0; i < numBlocks; i++) { + int datLen = shortBlockDataLen + (i < numShortBlocks ? 0 : 1); + uint8_t * ecc = &data[dataLen]; // Temporary storage + calcReedSolomonRemainder(dat, datLen, generator, blockEccLen, ecc); + for(int j = 0, k = i; j < datLen; j++, k += numBlocks) { // Copy data + if(j == shortBlockDataLen) + k -= numShortBlocks; + result[k] = dat[j]; + } + for(int j = 0, k = dataLen + i; j < blockEccLen; j++, k += numBlocks) // Copy ECC + result[k] = ecc[j]; + dat += datLen; + } +} + + +// Returns the number of 8-bit codewords that can be used for storing data (not ECC), +// for the given version number and error correction level. The result is in the range [9, 2956]. +static inline int getNumDataCodewords(int version, enum qrcodegen_Ecc ecl) +{ + int v = version, e = (int)ecl; + QR_ASSERT(0 <= e && e < 4); + return getNumRawDataModules(v) / 8 + - ECC_CODEWORDS_PER_BLOCK [e][v] + * NUM_ERROR_CORRECTION_BLOCKS[e][v]; +} + + +// Returns the number of data bits that can be stored in a QR Code of the given version number, after +// all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. +// The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. +static inline int getNumRawDataModules(int ver) +{ + QR_ASSERT(qrcodegen_VERSION_MIN <= ver && ver <= qrcodegen_VERSION_MAX); + int result = (16 * ver + 128) * ver + 64; + if(ver >= 2) { + int numAlign = ver / 7 + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if(ver >= 7) + result -= 36; + } + return result; +} + + + +/*---- Reed-Solomon ECC generator functions ----*/ + +// Calculates the Reed-Solomon generator polynomial of the given degree, storing in result[0 : degree]. +static inline void calcReedSolomonGenerator(int degree, uint8_t result[]) +{ + // Start with the monomial x^0 + QR_ASSERT(1 <= degree && degree <= qrcodegen_REED_SOLOMON_DEGREE_MAX); + memset(result, 0, degree * sizeof(result[0])); + result[degree - 1] = 1; + + // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), + // drop the highest term, and store the rest of the coefficients in order of descending powers. + // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). + uint8_t root = 1; + for(int i = 0; i < degree; i++) { + // Multiply the current product by (x - r^i) + for(int j = 0; j < degree; j++) { + result[j] = finiteFieldMultiply(result[j], root); + if(j + 1 < degree) + result[j] ^= result[j + 1]; + } + root = finiteFieldMultiply(root, 0x02); + } +} + + +// Calculates the remainder of the polynomial data[0 : dataLen] when divided by the generator[0 : degree], where all +// polynomials are in big endian and the generator has an implicit leading 1 term, storing the result in result[0 : degree]. +static inline void calcReedSolomonRemainder(const uint8_t data[], int dataLen, + const uint8_t generator[], int degree, uint8_t result[]) +{ + + // Perform polynomial division + QR_ASSERT(1 <= degree && degree <= qrcodegen_REED_SOLOMON_DEGREE_MAX); + memset(result, 0, degree * sizeof(result[0])); + for(int i = 0; i < dataLen; i++) { + uint8_t factor = data[i] ^ result[0]; + memmove(&result[0], &result[1], (degree - 1) * sizeof(result[0])); + result[degree - 1] = 0; + for(int j = 0; j < degree; j++) + result[j] ^= finiteFieldMultiply(generator[j], factor); + } +} + +#undef qrcodegen_REED_SOLOMON_DEGREE_MAX + + +// Returns the product of the two given field elements modulo GF(2^8/0x11D). +// All inputs are valid. This could be implemented as a 256*256 lookup table. +static inline uint8_t finiteFieldMultiply(uint8_t x, uint8_t y) +{ + // Russian peasant multiplication + uint8_t z = 0; + for(int i = 7; i >= 0; i--) { + z = (z << 1) ^ ((z >> 7) * 0x11D); + z ^= ((y >> i) & 1) * x; + } + return z; +} + + + +/*---- Drawing function modules ----*/ + +// Clears the given QR Code grid with white modules for the given +// version's size, then marks every function module as black. +static inline void initializeFunctionModules(int version, uint8_t qrcode[]) +{ + // Initialize QR Code + int qrsize = version * 4 + 17; + memset(qrcode, 0, ((qrsize * qrsize + 7) / 8 + 1) * sizeof(qrcode[0])); + qrcode[0] = (uint8_t)qrsize; + + // Fill horizontal and vertical timing patterns + fillRectangle(6, 0, 1, qrsize, qrcode); + fillRectangle(0, 6, qrsize, 1, qrcode); + + // Fill 3 finder patterns (all corners except bottom right) and format bits + fillRectangle(0, 0, 9, 9, qrcode); + fillRectangle(qrsize - 8, 0, 8, 9, qrcode); + fillRectangle(0, qrsize - 8, 9, 8, qrcode); + + // Fill numerous alignment patterns + uint8_t alignPatPos[7]; + int numAlign = getAlignmentPatternPositions(version, alignPatPos); + for(int i = 0; i < numAlign; i++) { + for(int j = 0; j < numAlign; j++) { + // Don't draw on the three finder corners + if(!((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0))) + fillRectangle(alignPatPos[i] - 2, alignPatPos[j] - 2, 5, 5, qrcode); + } + } + + // Fill version blocks + if(version >= 7) { + fillRectangle(qrsize - 11, 0, 3, 6, qrcode); + fillRectangle(0, qrsize - 11, 6, 3, qrcode); + } +} + + +// Draws white function modules and possibly some black modules onto the given QR Code, without changing +// non-function modules. This does not draw the format bits. This requires all function modules to be previously +// marked black (namely by initializeFunctionModules()), because this may skip redrawing black function modules. +static void drawWhiteFunctionModules(uint8_t qrcode[], int version) +{ + // Draw horizontal and vertical timing patterns + int qrsize = qrcodegen_getSize(qrcode); + for(int i = 7; i < qrsize - 7; i += 2) { + setModule(qrcode, 6, i, false); + setModule(qrcode, i, 6, false); + } + + // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) + for(int dy = -4; dy <= 4; dy++) { + for(int dx = -4; dx <= 4; dx++) { + int dist = abs(dx); + if(abs(dy) > dist) + dist = abs(dy); + if(dist == 2 || dist == 4) { + setModuleBounded(qrcode, 3 + dx, 3 + dy, false); + setModuleBounded(qrcode, qrsize - 4 + dx, 3 + dy, false); + setModuleBounded(qrcode, 3 + dx, qrsize - 4 + dy, false); + } + } + } + + // Draw numerous alignment patterns + uint8_t alignPatPos[7]; + int numAlign = getAlignmentPatternPositions(version, alignPatPos); + for(int i = 0; i < numAlign; i++) { + for(int j = 0; j < numAlign; j++) { + if((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0)) + continue; // Don't draw on the three finder corners + for(int dy = -1; dy <= 1; dy++) { + for(int dx = -1; dx <= 1; dx++) + setModule(qrcode, alignPatPos[i] + dx, alignPatPos[j] + dy, dx == 0 && dy == 0); + } + } + } + + // Draw version blocks + if(version >= 7) { + // Calculate error correction code and pack bits + int rem = version; // version is uint6, in the range [7, 40] + for(int i = 0; i < 12; i++) + rem = (rem << 1) ^ ((rem >> 11) * 0x1F25); + long bits = (long)version << 12 | rem; // uint18 + QR_ASSERT(bits >> 18 == 0); + + // Draw two copies + for(int i = 0; i < 6; i++) { + for(int j = 0; j < 3; j++) { + int k = qrsize - 11 + j; + setModule(qrcode, k, i, (bits & 1) != 0); + setModule(qrcode, i, k, (bits & 1) != 0); + bits >>= 1; + } + } + } +} + + +// Draws two copies of the format bits (with its own error correction code) based +// on the given mask and error correction level. This always draws all modules of +// the format bits, unlike drawWhiteFunctionModules() which might skip black modules. +static void drawFormatBits(enum qrcodegen_Ecc ecl, enum qrcodegen_Mask mask, uint8_t qrcode[]) +{ + // Calculate error correction code and pack bits + QR_ASSERT(0 <= (int)mask && (int)mask <= 7); + static const int table[] = {1, 0, 3, 2}; + int data = table[(int)ecl] << 3 | (int)mask; // errCorrLvl is uint2, mask is uint3 + int rem = data; + for(int i = 0; i < 10; i++) + rem = (rem << 1) ^ ((rem >> 9) * 0x537); + int bits = (data << 10 | rem) ^ 0x5412; // uint15 + QR_ASSERT(bits >> 15 == 0); + + // Draw first copy + for(int i = 0; i <= 5; i++) + setModule(qrcode, 8, i, getBit(bits, i)); + setModule(qrcode, 8, 7, getBit(bits, 6)); + setModule(qrcode, 8, 8, getBit(bits, 7)); + setModule(qrcode, 7, 8, getBit(bits, 8)); + for(int i = 9; i < 15; i++) + setModule(qrcode, 14 - i, 8, getBit(bits, i)); + + // Draw second copy + int qrsize = qrcodegen_getSize(qrcode); + for(int i = 0; i < 8; i++) + setModule(qrcode, qrsize - 1 - i, 8, getBit(bits, i)); + for(int i = 8; i < 15; i++) + setModule(qrcode, 8, qrsize - 15 + i, getBit(bits, i)); + setModule(qrcode, 8, qrsize - 8, true); // Always black +} + + +// Calculates and stores an ascending list of positions of alignment patterns +// for this version number, returning the length of the list (in the range [0,7]). +// Each position is in the range [0,177), and are used on both the x and y axes. +// This could be implemented as lookup table of 40 variable-length lists of unsigned bytes. +static inline int getAlignmentPatternPositions(int version, uint8_t result[7]) +{ + if(version == 1) + return 0; + int numAlign = version / 7 + 2; + int step = (version == 32) ? 26 : + (version * 4 + numAlign * 2 + 1) / (numAlign * 2 - 2) * 2; + for(int i = numAlign - 1, pos = version * 4 + 10; i >= 1; i--, pos -= step) + result[i] = pos; + result[0] = 6; + return numAlign; +} + + +// Sets every pixel in the range [left : left + width] * [top : top + height] to black. +static void fillRectangle(int left, int top, int width, int height, uint8_t qrcode[]) +{ + for(int dy = 0; dy < height; dy++) { + for(int dx = 0; dx < width; dx++) + setModule(qrcode, left + dx, top + dy, true); + } +} + + + +/*---- Drawing data modules and masking ----*/ + +// Draws the raw codewords (including data and ECC) onto the given QR Code. This requires the initial state of +// the QR Code to be black at function modules and white at codeword modules (including unused remainder bits). +static void drawCodewords(const uint8_t data[], int dataLen, uint8_t qrcode[]) +{ + int qrsize = qrcodegen_getSize(qrcode); + int i = 0; // Bit index into the data + // Do the funny zigzag scan + for(int right = qrsize - 1; right >= 1; right -= 2) { // Index of right column in each column pair + if(right == 6) + right = 5; + for(int vert = 0; vert < qrsize; vert++) { // Vertical counter + for(int j = 0; j < 2; j++) { + int x = right - j; // Actual x coordinate + bool upward = ((right + 1) & 2) == 0; + int y = upward ? qrsize - 1 - vert : vert; // Actual y coordinate + if(!getModule(qrcode, x, y) && i < dataLen * 8) { + bool black = getBit(data[i >> 3], 7 - (i & 7)); + setModule(qrcode, x, y, black); + i++; + } + // If this QR Code has any remainder bits (0 to 7), they were assigned as + // 0/false/white by the constructor and are left unchanged by this method + } + } + } + QR_ASSERT(i == dataLen * 8); +} + + +// XORs the codeword modules in this QR Code with the given mask pattern. +// The function modules must be marked and the codeword bits must be drawn +// before masking. Due to the arithmetic of XOR, calling applyMask() with +// the same mask value a second time will undo the mask. A final well-formed +// QR Code needs exactly one (not zero, two, etc.) mask applied. +static void applyMask(const uint8_t functionModules[], uint8_t qrcode[], enum qrcodegen_Mask mask) +{ + QR_ASSERT(0 <= (int)mask && (int)mask <= 7); // Disallows qrcodegen_Mask_AUTO + int qrsize = qrcodegen_getSize(qrcode); + for(int y = 0; y < qrsize; y++) { + for(int x = 0; x < qrsize; x++) { + if(getModule(functionModules, x, y)) + continue; + bool invert; + switch((int)mask) { + case 0: + invert = (x + y) % 2 == 0; + break; + case 1: + invert = y % 2 == 0; + break; + case 2: + invert = x % 3 == 0; + break; + case 3: + invert = (x + y) % 3 == 0; + break; + case 4: + invert = (x / 3 + y / 2) % 2 == 0; + break; + case 5: + invert = x * y % 2 + x * y % 3 == 0; + break; + case 6: + invert = (x * y % 2 + x * y % 3) % 2 == 0; + break; + case 7: + invert = ((x + y) % 2 + x * y % 3) % 2 == 0; + break; + default: + QR_ASSERT(false); + return; + } + bool val = getModule(qrcode, x, y); + setModule(qrcode, x, y, val ^ invert); + } + } +} + + +// Calculates and returns the penalty score based on state of the given QR Code's current modules. +// This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. +static long getPenaltyScore(const uint8_t qrcode[]) +{ + int qrsize = qrcodegen_getSize(qrcode); + long result = 0; + + // Adjacent modules in row having same color, and finder-like patterns + for(int y = 0; y < qrsize; y++) { + unsigned char runHistory[7] = {0}; + bool color = false; + unsigned char runX = 0; + for(int x = 0; x < qrsize; x++) { + if(getModule(qrcode, x, y) == color) { + runX++; + if(runX == 5) + result += PENALTY_N1; + else if(runX > 5) + result++; + } + else { + addRunToHistory(runX, runHistory); + if(!color && hasFinderLikePattern(runHistory)) + result += PENALTY_N3; + color = getModule(qrcode, x, y); + runX = 1; + } + } + addRunToHistory(runX, runHistory); + if(color) + addRunToHistory(0, runHistory); // Dummy run of white + if(hasFinderLikePattern(runHistory)) + result += PENALTY_N3; + } + // Adjacent modules in column having same color, and finder-like patterns + for(int x = 0; x < qrsize; x++) { + unsigned char runHistory[7] = {0}; + bool color = false; + unsigned char runY = 0; + for(int y = 0; y < qrsize; y++) { + if(getModule(qrcode, x, y) == color) { + runY++; + if(runY == 5) + result += PENALTY_N1; + else if(runY > 5) + result++; + } + else { + addRunToHistory(runY, runHistory); + if(!color && hasFinderLikePattern(runHistory)) + result += PENALTY_N3; + color = getModule(qrcode, x, y); + runY = 1; + } + } + addRunToHistory(runY, runHistory); + if(color) + addRunToHistory(0, runHistory); // Dummy run of white + if(hasFinderLikePattern(runHistory)) + result += PENALTY_N3; + } + + // 2*2 blocks of modules having same color + for(int y = 0; y < qrsize - 1; y++) { + for(int x = 0; x < qrsize - 1; x++) { + bool color = getModule(qrcode, x, y); + if(color == getModule(qrcode, x + 1, y) && + color == getModule(qrcode, x, y + 1) && + color == getModule(qrcode, x + 1, y + 1)) + result += PENALTY_N2; + } + } + + // Balance of black and white modules + int black = 0; + for(int y = 0; y < qrsize; y++) { + for(int x = 0; x < qrsize; x++) { + if(getModule(qrcode, x, y)) + black++; + } + } + int total = qrsize * qrsize; // Note that size is odd, so black/total != 1/2 + // Compute the smallest integer k >= 0 such that (45-5k)% <= black/total <= (55+5k)% + int k = (int)((labs(black * 20L - total * 10L) + total - 1) / total) - 1; + result += k * PENALTY_N4; + return result; +} + + +// Inserts the given value to the front of the given array, which shifts over the +// existing values and deletes the last value. A helper function for getPenaltyScore(). +static void addRunToHistory(unsigned char run, unsigned char history[7]) +{ + memmove(&history[1], &history[0], 6 * sizeof(history[0])); + history[0] = run; +} + + +// Tests whether the given run history has the pattern of ratio 1:1:3:1:1 in the middle, and +// surrounded by at least 4 on either or both ends. A helper function for getPenaltyScore(). +// Must only be called immediately after a run of white modules has ended. +static bool hasFinderLikePattern(const unsigned char runHistory[7]) +{ + unsigned char n = runHistory[1]; + // The maximum QR Code size is 177, hence the run length n <= 177. + // Arithmetic is promoted to int, so n*4 will not overflow. + return n > 0 && runHistory[2] == n && runHistory[4] == n && runHistory[5] == n + && runHistory[3] == n * 3 && (runHistory[0] >= n * 4 || runHistory[6] >= n * 4); +} + + + +/*---- Basic QR Code information ----*/ + +// Public function - see documentation comment in header file. +static inline int qrcodegen_getSize(const uint8_t qrcode[]) +{ + QR_ASSERT(qrcode != NULL); + int result = qrcode[0]; + QR_ASSERT((qrcodegen_VERSION_MIN * 4 + 17) <= result + && result <= (qrcodegen_VERSION_MAX * 4 + 17)); + return result; +} + + +// Public function - see documentation comment in header file. +static inline bool qrcodegen_getModule(const uint8_t qrcode[], int x, int y) +{ + QR_ASSERT(qrcode != NULL); + int qrsize = qrcode[0]; + return (0 <= x && x < qrsize && 0 <= y && y < qrsize) && getModule(qrcode, x, y); +} + + +// Gets the module at the given coordinates, which must be in bounds. +static inline bool getModule(const uint8_t qrcode[], int x, int y) +{ + int qrsize = qrcode[0]; + QR_ASSERT(21 <= qrsize && qrsize <= 177 && 0 <= x && x < qrsize && 0 <= y && y < qrsize); + int index = y * qrsize + x; + return getBit(qrcode[(index >> 3) + 1], index & 7); +} + + +// Sets the module at the given coordinates, which must be in bounds. +static inline void setModule(uint8_t qrcode[], int x, int y, bool isBlack) +{ + int qrsize = qrcode[0]; + QR_ASSERT(21 <= qrsize && qrsize <= 177 && 0 <= x && x < qrsize && 0 <= y && y < qrsize); + int index = y * qrsize + x; + int bitIndex = index & 7; + int byteIndex = (index >> 3) + 1; + if(isBlack) + qrcode[byteIndex] |= 1 << bitIndex; + else + qrcode[byteIndex] &= (1 << bitIndex) ^ 0xFF; +} + + +// Sets the module at the given coordinates, doing nothing if out of bounds. +static inline void setModuleBounded(uint8_t qrcode[], int x, int y, bool isBlack) +{ + int qrsize = qrcode[0]; + if(0 <= x && x < qrsize && 0 <= y && y < qrsize) + setModule(qrcode, x, y, isBlack); +} + + +// Returns true iff the i'th bit of x is set to 1. Requires x >= 0 and 0 <= i <= 14. +static bool getBit(int x, int i) +{ + return ((x >> i) & 1) != 0; +} + + + +/*---- Segment handling ----*/ + +// Public function - see documentation comment in header file. +static inline bool qrcodegen_isAlphanumeric(const char * text) +{ + QR_ASSERT(text != NULL); + for(; *text != '\0'; text++) { + if(strchr(ALPHANUMERIC_CHARSET, *text) == NULL) + return false; + } + return true; +} + + +// Public function - see documentation comment in header file. +static inline bool qrcodegen_isNumeric(const char * text) +{ + QR_ASSERT(text != NULL); + for(; *text != '\0'; text++) { + if(*text < '0' || *text > '9') + return false; + } + return true; +} + + +// Public function - see documentation comment in header file. +static inline size_t qrcodegen_calcSegmentBufferSize(enum qrcodegen_Mode mode, size_t numChars) +{ + int temp = calcSegmentBitLength(mode, numChars); + if(temp == -1) + return SIZE_MAX; + QR_ASSERT(0 <= temp && temp <= INT16_MAX); + return ((size_t)temp + 7) / 8; +} + + +// Returns the number of data bits needed to represent a segment +// containing the given number of characters using the given mode. Notes: +// - Returns -1 on failure, i.e. numChars > INT16_MAX or +// the number of needed bits exceeds INT16_MAX (i.e. 32767). +// - Otherwise, all valid results are in the range [0, INT16_MAX]. +// - For byte mode, numChars measures the number of bytes, not Unicode code points. +// - For ECI mode, numChars must be 0, and the worst-case number of bits is returned. +// An actual ECI segment can have shorter data. For non-ECI modes, the result is exact. +static inline int calcSegmentBitLength(enum qrcodegen_Mode mode, size_t numChars) +{ + // All calculations are designed to avoid overflow on all platforms + if(numChars > (unsigned int)INT16_MAX) + return -1; + long result = (long)numChars; + if(mode == qrcodegen_Mode_NUMERIC) + result = (result * 10 + 2) / 3; // ceil(10/3 * n) + else if(mode == qrcodegen_Mode_ALPHANUMERIC) + result = (result * 11 + 1) / 2; // ceil(11/2 * n) + else if(mode == qrcodegen_Mode_BYTE) + result *= 8; + else if(mode == qrcodegen_Mode_KANJI) + result *= 13; + else if(mode == qrcodegen_Mode_ECI && numChars == 0) + result = 3 * 8; + else { // Invalid argument + QR_ASSERT(false); + return -1; + } + QR_ASSERT(result >= 0); + if((unsigned int)result > (unsigned int)INT16_MAX) + return -1; + return (int)result; +} + + +// Public function - see documentation comment in header file. +static inline struct qrcodegen_Segment qrcodegen_makeBytes(const uint8_t data[], size_t len, uint8_t buf[]) +{ + QR_ASSERT(data != NULL || len == 0); + struct qrcodegen_Segment result; + result.mode = qrcodegen_Mode_BYTE; + result.bitLength = calcSegmentBitLength(result.mode, len); + QR_ASSERT(result.bitLength != -1); + result.numChars = (int)len; + if(len > 0) + memcpy(buf, data, len * sizeof(buf[0])); + result.data = buf; + return result; +} + + +// Public function - see documentation comment in header file. +static inline struct qrcodegen_Segment qrcodegen_makeNumeric(const char * digits, uint8_t buf[]) +{ + QR_ASSERT(digits != NULL); + struct qrcodegen_Segment result; + size_t len = strlen(digits); + result.mode = qrcodegen_Mode_NUMERIC; + int bitLen = calcSegmentBitLength(result.mode, len); + QR_ASSERT(bitLen != -1); + result.numChars = (int)len; + if(bitLen > 0) + memset(buf, 0, ((size_t)bitLen + 7) / 8 * sizeof(buf[0])); + result.bitLength = 0; + + unsigned int accumData = 0; + int accumCount = 0; + for(; *digits != '\0'; digits++) { + char c = *digits; + QR_ASSERT('0' <= c && c <= '9'); + accumData = accumData * 10 + (unsigned int)(c - '0'); + accumCount++; + if(accumCount == 3) { + appendBitsToBuffer(accumData, 10, buf, &result.bitLength); + accumData = 0; + accumCount = 0; + } + } + if(accumCount > 0) // 1 or 2 digits remaining + appendBitsToBuffer(accumData, accumCount * 3 + 1, buf, &result.bitLength); + QR_ASSERT(result.bitLength == bitLen); + result.data = buf; + return result; +} + + +// Public function - see documentation comment in header file. +static inline struct qrcodegen_Segment qrcodegen_makeAlphanumeric(const char * text, uint8_t buf[]) +{ + QR_ASSERT(text != NULL); + struct qrcodegen_Segment result; + size_t len = strlen(text); + result.mode = qrcodegen_Mode_ALPHANUMERIC; + int bitLen = calcSegmentBitLength(result.mode, len); + QR_ASSERT(bitLen != -1); + result.numChars = (int)len; + if(bitLen > 0) + memset(buf, 0, ((size_t)bitLen + 7) / 8 * sizeof(buf[0])); + result.bitLength = 0; + + unsigned int accumData = 0; + int accumCount = 0; + for(; *text != '\0'; text++) { + const char * temp = strchr(ALPHANUMERIC_CHARSET, *text); + QR_ASSERT(temp != NULL); + accumData = accumData * 45 + (unsigned int)(temp - ALPHANUMERIC_CHARSET); + accumCount++; + if(accumCount == 2) { + appendBitsToBuffer(accumData, 11, buf, &result.bitLength); + accumData = 0; + accumCount = 0; + } + } + if(accumCount > 0) // 1 character remaining + appendBitsToBuffer(accumData, 6, buf, &result.bitLength); + QR_ASSERT(result.bitLength == bitLen); + result.data = buf; + return result; +} + + +// Public function - see documentation comment in header file. +static inline struct qrcodegen_Segment qrcodegen_makeEci(long assignVal, uint8_t buf[]) +{ + struct qrcodegen_Segment result; + result.mode = qrcodegen_Mode_ECI; + result.numChars = 0; + result.bitLength = 0; + if(assignVal < 0) { + QR_ASSERT(false); + } + else if(assignVal < (1 << 7)) { + memset(buf, 0, 1 * sizeof(buf[0])); + appendBitsToBuffer(assignVal, 8, buf, &result.bitLength); + } + else if(assignVal < (1 << 14)) { + memset(buf, 0, 2 * sizeof(buf[0])); + appendBitsToBuffer(2, 2, buf, &result.bitLength); + appendBitsToBuffer(assignVal, 14, buf, &result.bitLength); + } + else if(assignVal < 1000000L) { + memset(buf, 0, 3 * sizeof(buf[0])); + appendBitsToBuffer(6, 3, buf, &result.bitLength); + appendBitsToBuffer(assignVal >> 10, 11, buf, &result.bitLength); + appendBitsToBuffer(assignVal & 0x3FF, 10, buf, &result.bitLength); + } + else { + QR_ASSERT(false); + } + result.data = buf; + return result; +} + + +// Calculates the number of bits needed to encode the given segments at the given version. +// Returns a non-negative number if successful. Otherwise returns -1 if a segment has too +// many characters to fit its length field, or the total bits exceeds INT16_MAX. +static inline int getTotalBits(const struct qrcodegen_Segment segs[], size_t len, int version) +{ + QR_ASSERT(segs != NULL || len == 0); + long result = 0; + for(size_t i = 0; i < len; i++) { + int numChars = segs[i].numChars; + int bitLength = segs[i].bitLength; + QR_ASSERT(0 <= numChars && numChars <= INT16_MAX); + QR_ASSERT(0 <= bitLength && bitLength <= INT16_MAX); + int ccbits = numCharCountBits(segs[i].mode, version); + QR_ASSERT(0 <= ccbits && ccbits <= 16); + if(numChars >= (1L << ccbits)) + return -1; // The segment's length doesn't fit the field's bit width + result += 4L + ccbits + bitLength; + if(result > INT16_MAX) + return -1; // The sum might overflow an int type + } + QR_ASSERT(0 <= result && result <= INT16_MAX); + return (int)result; +} + + +// Returns the bit width of the character count field for a segment in the given mode +// in a QR Code at the given version number. The result is in the range [0, 16]. +static int numCharCountBits(enum qrcodegen_Mode mode, int version) +{ + QR_ASSERT(qrcodegen_VERSION_MIN <= version && version <= qrcodegen_VERSION_MAX); + int i = (version + 7) / 17; + switch(mode) { + case qrcodegen_Mode_NUMERIC : { + static const int temp[] = {10, 12, 14}; + return temp[i]; + } + case qrcodegen_Mode_ALPHANUMERIC: { + static const int temp[] = { 9, 11, 13}; + return temp[i]; + } + case qrcodegen_Mode_BYTE : { + static const int temp[] = { 8, 16, 16}; + return temp[i]; + } + case qrcodegen_Mode_KANJI : { + static const int temp[] = { 8, 10, 12}; + return temp[i]; + } + case qrcodegen_Mode_ECI : + return 0; + default: + QR_ASSERT(false); + return -1; // Dummy value + } +} + +static inline int qrcodegen_getMinFitVersion(enum qrcodegen_Ecc ecl, size_t dataLen) +{ + struct qrcodegen_Segment seg; + seg.mode = qrcodegen_Mode_BYTE; + seg.bitLength = calcSegmentBitLength(seg.mode, dataLen); + seg.numChars = (int)dataLen; + + for(int version = qrcodegen_VERSION_MIN; version <= qrcodegen_VERSION_MAX; version++) { + int dataCapacityBits = getNumDataCodewords(version, ecl) * 8; // Number of data bits available + int dataUsedBits = getTotalBits(&seg, 1, version); + if(dataUsedBits != -1 && dataUsedBits <= dataCapacityBits) + return version; + } + return -1; +} + +static inline int qrcodegen_version2size(int version) +{ + if(version < qrcodegen_VERSION_MIN || version > qrcodegen_VERSION_MAX) { + return -1; + } + + return ((version - 1) * 4 + 21); +} + +#undef QR_ASSERT diff --git a/examples/companion_radio/ui-core/SettingsCli.h b/examples/companion_radio/ui-core/SettingsCli.h new file mode 100644 index 00000000..e6006876 --- /dev/null +++ b/examples/companion_radio/ui-core/SettingsCli.h @@ -0,0 +1,146 @@ +#pragma once +// The companion CLI's settings keys, after MyMesh's own upstream-named +// commands (MyMeshCli.h): "get ", "set ", "keys [page]". +// Each key names a row of the settings schema (SettingsSchema.h), so the CLI +// reads and writes what the Settings screens show, with the same values and +// side effects. A value is set by its label as `get` prints it ("2 h", +// "Everyone"; case doesn't matter), "on" / "off" for a switch, or a number: +// the index for a list, one of the stored values for a table (seconds, mV ...). + +#include "SettingsSchema.h" + +namespace settingscli { + +struct Key { const char* key; uint16_t offset; uint8_t bit; }; +#define K(name, f) { name, (uint16_t)offsetof(NodePrefs, f), 0 } +#define KB(name, f, mask) { name, (uint16_t)offsetof(NodePrefs, f), mask } +static const Key KEYS[] = { + K("advert.interval", advert_auto_interval_sec), K("advert.loc", advert_loc_policy), + K("telemetry.base", telemetry_mode_base), K("telemetry.loc", telemetry_mode_loc), K("telemetry.env", telemetry_mode_env), + K("autoadd", manual_add_contacts), KB("autoadd.chat", autoadd_config, 0x02), KB("autoadd.repeater", autoadd_config, 0x04), + KB("autoadd.room", autoadd_config, 0x08), KB("autoadd.sensor", autoadd_config, 0x10), + KB("autoadd.overwrite", autoadd_config, 0x01), K("autoadd.max.hops", autoadd_max_hops), + K("contacts.expiry", contact_expiry_idx), K("contacts.fav.first", fav_sort), K("dm.resend", dm_resend_count), + K("display.brightness", display_brightness), K("display.off", auto_off_secs), K("display.wake", msg_wake), + K("display.alert", msg_alert), K("lock", auto_lock), K("lock.clock", lock_compact), + K("batt.display", batt_display_mode), K("batt.shutdown", low_batt_mv), K("gps.interval", gps_interval), + K("clock.12h", clock_12h), K("clock.seconds", clock_hide_seconds), K("units.imperial", units_imperial), + K("sound.volume", buzzer_volume), K("quiet", quiet_hours), K("quiet.from", quiet_from), K("quiet.until", quiet_to), + K("melody.dm", notif_melody_dm), K("melody.channel", notif_melody_ch), K("melody.advert", notif_melody_ad), + K("advert.sound", advert_sound_scope), + K("trail.spacing", trail_min_delta_idx), K("trail.pause", trail_autopause_idx), K("trail.lowbatt", trail_autosave_lowbatt), + K("gps.avg", gps_avg_idx), + K("share.others", track_shared_loc), K("share.stop", loc_share_duration_idx), K("share.move", loc_share_move_idx), + K("share.every", loc_share_interval_idx), K("share.heartbeat", loc_share_heartbeat_idx), + K("alert", locator_enabled), K("alert.radius", locator_radius_idx), K("alert.on", locator_mode), + K("alert.beeper", locator_beeper), +}; +#undef K +#undef KB +static const int KEY_COUNT = sizeof(KEYS) / sizeof(KEYS[0]); + +// The schema row of a key, or -1 (this board leaves it out). +static int row(const Key& k) { + for (int i = 0; i < settings::COUNT; i++) + if (settings::ALL[i].offset == k.offset && settings::ALL[i].bit == k.bit) return i; + return -1; +} + +static bool sameText(const char* a, const char* b) { + for (; *a && *b; a++, b++) if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) return false; + return *a == *b; +} + +// The value text into index v; false when it is none of them. +static bool parse(const NodePrefs& p, const settings::Setting& st, const char* v, int& index) { + if (!st.option) { // a switch + if (sameText(v, "on") || !strcmp(v, "1")) { index = 1; return true; } + if (sameText(v, "off") || !strcmp(v, "0")) { index = 0; return true; } + return false; + } + char buf[24]; + for (int i = 0; i < st.count; i++) { + st.option((uint8_t)i, buf, sizeof(buf), p); + if (sameText(v, buf)) { index = i; return true; } + } + char* end; + double d = strtod(v, &end); + if (end == v || *end) return false; + if (!st.values) { // a list: its index + if (d < 0 || d >= st.count || d != (int)d) return false; + index = (int)d; + return true; + } + // A table: one of its stored values (one between would read back as another). + int32_t raw = (int32_t)(st.size == settings::FLOAT_X100 ? d * 100 + (d < 0 ? -0.5 : 0.5) : d); + for (int i = 0; i < st.count; i++) if (st.values[i] == raw) { index = i; return true; } + return false; +} + +// The labels a setting takes, "a, b, c" into buf. +static void choices(const NodePrefs& p, const settings::Setting& st, char* out, int n) { + if (!st.option) { snprintf(out, n, "on, off"); return; } + int o = 0; + char buf[24]; + for (int i = 0; i < st.count && o < n - 1; i++) { + st.option((uint8_t)i, buf, sizeof(buf), p); + o += snprintf(out + o, n - o, "%s%s", i ? ", " : "", buf); + } +} + +// True when the command was a settings one (reply set either way). +static bool handle(UiCore& core, const char* cmd, char* reply, int n) { + NodePrefs* p = core.prefs(); + if (!p) return false; + if (!strcmp(cmd, "keys") || !strncmp(cmd, "keys ", 5)) { // the key names, a frame's worth a page + int page = cmd[4] ? atoi(cmd + 5) : 1, pg = 1, o = 0; + bool more = false; + reply[0] = 0; + for (int k = 0; k < KEY_COUNT; k++) { + if (row(KEYS[k]) < 0) continue; + int len = (int)strlen(KEYS[k].key) + 1; + if (o + len > n - 20) { // room left for " (more: keys N)" + if (pg == page) { more = true; break; } + pg++; + o = 0; + } + if (pg == page) o += snprintf(reply + o, n - o, "%s%s", o ? " " : "", KEYS[k].key); + else o += len; + } + if (more) snprintf(reply + o, n - o, " (more: keys %d)", page + 1); + else if (pg < page || page < 1) snprintf(reply, n, "No more keys"); + return true; + } + bool set = !strncmp(cmd, "set ", 4); + if (!set && strncmp(cmd, "get ", 4)) return false; + const char* name = cmd + 4; + const char* value = set ? strchr(name, ' ') : nullptr; + size_t name_len = value ? (size_t)(value - name) : strlen(name); + for (int k = 0; k < KEY_COUNT; k++) { + if (strlen(KEYS[k].key) != name_len || strncmp(KEYS[k].key, name, name_len)) continue; + int i = row(KEYS[k]); + if (i < 0) { snprintf(reply, n, "Error, not on this device"); return true; } + const settings::Setting& st = settings::ALL[i]; + char buf[24]; + if (!set) { + snprintf(reply, n, "> %s", settings::text(*p, st, buf, sizeof(buf), "on", "off")); + return true; + } + if (!value) { snprintf(reply, n, "Error, no value"); return true; } + int index = 0; + if (!parse(*p, st, value + 1, index)) { + char opts[120]; + choices(*p, st, opts, sizeof(opts)); + snprintf(reply, n, "Error, one of: %s", opts); + return true; + } + settings::set(*p, st, (uint8_t)index); + if (st.changed) st.changed(core); + the_mesh.savePrefs(); + snprintf(reply, n, "OK"); + return true; + } + return false; // not a settings key: MyMesh says "Unknown command" +} + +} // namespace settingscli diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index 4b50400f..21827d4a 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -143,6 +143,38 @@ public: return true; } + // Settings > Privacy > Position: where adverts say you are while the GPS is + // off -- the sensor manager's node_lat / node_lon, saved with the prefs as + // the app's CMD_SET_ADVERT_LATLON does. With the GPS on, each fix overwrites it. + bool fixedPosition(int32_t& lat, int32_t& lon) const { + lat = (int32_t)lround(_sensors->node_lat * 1e6); + lon = (int32_t)lround(_sensors->node_lon * 1e6); + return lat != 0 || lon != 0; + } + void setFixedPosition(int32_t lat, int32_t lon) { // 0, 0: none + _sensors->node_lat = lat / 1e6; + _sensors->node_lon = lon / 1e6; + the_mesh.savePrefs(); + } + + // Settings > Privacy > Bluetooth PIN: NodePrefs::ble_pin, six digits, or 0 + // for a new random one each start (shown on screen). Used from the next + // start, like the app's CMD_SET_DEVICE_PIN. + static bool validBlePin(uint32_t pin) { return pin == 0 || (pin >= 100000 && pin <= 999999); } + bool setBlePin(uint32_t pin) { + if (!_prefs || !validBlePin(pin)) return false; + _prefs->ble_pin = pin; + the_mesh.savePrefs(); + return true; + } + + // Settings > System > Factory reset: everything erased, then a restart + // straight away -- no shutdown flush, which would write the prefs back. + // Returns only when the erase failed. + void factoryReset() { + if (the_mesh.factoryReset()) board.reboot(); + } + // GPS duty cycle (NodePrefs::gps_interval, seconds; 0 = always on) to the sensor layer. void applyGpsInterval() { if (!_sensors || !_prefs) return; @@ -275,6 +307,11 @@ public: } _host->onAdminStateChanged(); } + // The companion CLI's settings keys: the frontend answers them from the + // schema (ui-core/SettingsCli.h, included where the schema is). + bool onCliCommand(const char* command, char* reply, int n) override { + return _host->cliCommand(command, reply, n); + } void onAdminReply(const uint8_t* pub_key, const char* text) override { admin.onReply(pub_key, text); _host->onAdminStateChanged(); diff --git a/examples/companion_radio/ui-core/UiCoreHost.h b/examples/companion_radio/ui-core/UiCoreHost.h index b375760b..0729a30a 100644 --- a/examples/companion_radio/ui-core/UiCoreHost.h +++ b/examples/companion_radio/ui-core/UiCoreHost.h @@ -41,6 +41,8 @@ public: // A sound pref changed through the schema (the volume): push it to the // speaker / buzzer. virtual void applySoundPrefs() {} + // A companion CLI command MyMesh left over (its settings keys). + virtual bool cliCommand(const char* command, char* reply, int n) { return false; } // Controlled power-down / restart (flush state first). See // AbstractUITask::shutdown(). diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 33ab9cb0..ab6ba1fa 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -72,6 +72,8 @@ static bool s_chats_stale = false; // Messages: a refresh waits for a held row #include "../ui-core/NearbyModel.h" #include "../ui-core/EtaTracker.h" #include "../ui-core/SettingsSchema.h" +#include "../ui-core/SettingsCli.h" // the companion CLI's settings keys +#include "../ui-core/ContactQr.h" #include "../ui-core/GpsAverager.h" #include "../ui-core/TrackBack.h" #include "../ui-core/TrailProfile.h" @@ -2475,6 +2477,7 @@ static void onOpenRepeater(lv_event_t* e); // RepeaterScreen.h static void onOpenAdminPick(lv_event_t* e); // AdminScreen.h static void onNodeName(lv_event_t* e); static void onPowerRow(lv_event_t* e); +static void onFactoryReset(lv_event_t* e) { s_ui->factoryResetStep((int)(uintptr_t)lv_event_get_user_data(e)); } // Home itself (pages, clock, minimap, apps) is HomeScreen.h. @@ -3059,7 +3062,8 @@ void UITask::buildContacts() { static NearbyModel::Entry s_node; // the node open in SCR_NODE (a copy: the list re-sorts) // Its info card's values (UITask::_node_info is the card). static lv_obj_t *s_nd_type, *s_nd_status, *s_nd_dist, *s_nd_pos, *s_nd_heard, *s_nd_signal, *s_nd_id; -enum : uint8_t { NODE_MSG, NODE_PING, NODE_FAV, NODE_ADD, NODE_DELETE, NODE_NAV, NODE_ADMIN, NODE_WAYPOINT, NODE_PIN }; +enum : uint8_t { NODE_MSG, NODE_PING, NODE_FAV, NODE_ADD, NODE_DELETE, NODE_NAV, NODE_ADMIN, NODE_WAYPOINT, NODE_PIN, + NODE_RESET_PATH, NODE_SHARE }; static void onNearbyChip(lv_event_t* e) { s_ui->setNearbyFilter((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } static void onNearbySort(lv_event_t* e) { (void)e; s_ui->toggleNearbySort(); } @@ -3355,12 +3359,25 @@ static void nodeFavText() { if (s_nd_fav) lv_label_set_text(s_nd_fav, s_node.fav ? UI_SYMBOL_STAR " Remove from favourites" : UI_SYMBOL_STAR " Add to favourites"); } +static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref); // below + +// One of the node's "Can ask for" switches; user data = its TELEM_PERM_* bit. +static void onNodeTelemSwitch(lv_event_t* e) { + ContactInfo* ci = the_mesh.lookupContactByPubKey(s_node.pub_key, PUB_KEY_SIZE); + if (!ci) return; + uint8_t bit = (uint8_t)(uintptr_t)lv_event_get_user_data(e); + uint8_t perms = contactctl::telemetry(*ci); + if (lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)) perms |= bit; else perms &= ~bit; + contactctl::setTelemetry(s_node.pub_key, perms); +} + void UITask::buildNode() { const NearbyModel::Entry& e = s_node; lv_obj_t* body = newScreen(e.name[0] ? e.name : "(unknown)", true); lv_obj_t* info = scrollList(body); // the info, scrolling above the actions at the bottom s_nd_fav = nullptr; + _node_path_lbl = nullptr; nodeVizBuild(info); nodeviz::s_bearing = nodeviz::s_hops = -2; // nothing drawn yet: the first set fills both _node_info = infoCard(info); @@ -3391,10 +3408,31 @@ void UITask::buildNode() { if (contact) s_nd_fav = actionRow(g, "", onNodeAction, (void*)(uintptr_t)NODE_FAV); nodeFavText(); if (contact && e.has_key) actionRow(g, UI_SYMBOL_TACK " Pin to Home", onNodeAction, (void*)(uintptr_t)NODE_PIN); + ContactInfo* ci = (contact && e.has_key) ? the_mesh.lookupContactByPubKey(e.pub_key, PUB_KEY_SIZE) : nullptr; + if (ci && contactctl::hasPath(*ci)) + _node_path_lbl = actionRow(g, LV_SYMBOL_REFRESH " Reset path", onNodeAction, (void*)(uintptr_t)NODE_RESET_PATH); + if (ci) actionRow(g, LV_SYMBOL_UPLOAD " Share with nodes in range", onNodeAction, (void*)(uintptr_t)NODE_SHARE); if (contact) _node_delete_lbl = actionRow(g, LV_SYMBOL_TRASH " Delete contact", onNodeAction, (void*)(uintptr_t)NODE_DELETE, theme::FAIL); } + // Where Settings > Privacy says "Allowed", what this contact may ask for. + ContactInfo* ci = (contact && e.has_key) ? the_mesh.lookupContactByPubKey(e.pub_key, PUB_KEY_SIZE) : nullptr; + if (ci && contactctl::telemetryPerContact(_prefs)) { + lv_obj_t* g = group(info, "Can ask for"); + static const struct { const char* text; const char* sub; uint8_t bit; } T[] = { + { "Status", "Battery; the two below need it", TELEM_PERM_BASE }, + { "Location", nullptr, TELEM_PERM_LOCATION }, + { "Sensors", nullptr, TELEM_PERM_ENVIRONMENT }, + }; + uint8_t perms = contactctl::telemetry(*ci); + for (const auto& t : T) { + lv_obj_t* sw = switchRow(g, t.text, t.sub, nullptr); + if (perms & t.bit) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, onNodeTelemSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)t.bit); + } + } + refreshNode(); } @@ -3531,6 +3569,16 @@ void UITask::nodeAction(uint8_t action) { case NODE_PIN: // to the favourites dial (Home), as from a chat's options pinPopup(false, 0, e.pub_key); break; + case NODE_RESET_PATH: // the row goes, the chain above turns to flood + if (contactctl::resetPath(e.pub_key) && _node_path_lbl) { + lv_obj_add_flag(lv_obj_get_parent(lv_obj_get_parent(_node_path_lbl)), LV_OBJ_FLAG_HIDDEN); // label < text box < row + _node_path_lbl = nullptr; + refreshNode(); + } + break; + case NODE_SHARE: + showToast(contactctl::share(e.pub_key) ? "Sent to nodes in range" : "No advert saved to send"); + break; case NODE_DELETE: if (!tapConfirmed(_node_delete_lbl, LV_SYMBOL_TRASH " Tap again to delete")) break; if (the_mesh.deleteContactByKey(e.pub_key)) { @@ -3541,7 +3589,6 @@ void UITask::nodeAction(uint8_t action) { } } -static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref); // below #include "MapScreen.h" #include "Chart.h" #include "ProfileView.h" @@ -4454,10 +4501,147 @@ void UITask::buildSchemaSettings() { schemaRows(body, _settings_page); if (_settings_page == settings::PG_SOUND) then(&UITask::schemaExtras, settings::PG_SOUND); // after its groups if (_settings_page == settings::PG_MESSAGES) then(&UITask::schemaExtras, settings::PG_MESSAGES); + if (_settings_page == settings::PG_PRIVACY) then(&UITask::schemaExtras, settings::PG_PRIVACY); +} + +// ── Settings > Privacy: position, Bluetooth PIN; factory reset ───────────────── + +static void onOwnPos(lv_event_t* e) { (void)e; s_ui->ownPositionPopup(); } +static void onOwnPosPick(lv_event_t* e) { s_ui->ownPositionPick((int)(uintptr_t)lv_event_get_user_data(e)); } +static void onOwnPosKb(lv_event_t* e) { s_ui->ownPositionDone(lv_event_get_code(e) == LV_EVENT_READY); } +static void onBlePin(lv_event_t* e) { (void)e; s_ui->blePinPopup(); } +static void onBlePinKb(lv_event_t* e) { s_ui->blePinDone(lv_event_get_code(e) == LV_EVENT_READY); } + +enum : int { OWNPOS_TYPE, OWNPOS_WAYPOINTS, OWNPOS_CLEAR, OWNPOS_WP0 = 100 }; + +void UITask::ownPositionPopup() { + if (_core->gpsEnabled()) { showToast("The GPS sets your position while it's on", 2500); return; } + int32_t lat, lon; + bool have = _core->fixedPosition(lat, lon); + lv_obj_t* g = group(navPopupPanel("Fixed position", false), nullptr); + listRow(g, "Type coordinates", "Latitude, longitude in degrees", onOwnPosPick, (void*)(uintptr_t)OWNPOS_TYPE); + if (_core->waypoints.count() > 0) { + char sub[24]; + snprintf(sub, sizeof(sub), "%d saved", _core->waypoints.count()); + listRow(g, "From a waypoint", sub, onOwnPosPick, (void*)(uintptr_t)OWNPOS_WAYPOINTS); + } + if (have) actionRow(g, "Clear", onOwnPosPick, (void*)(uintptr_t)OWNPOS_CLEAR); +} + +void UITask::ownPositionPick(int code) { + const WaypointModel& wp = _core->waypoints; + if (code == OWNPOS_TYPE) { + int32_t lat, lon; + char cur[32] = ""; + if (_core->fixedPosition(lat, lon)) snprintf(cur, sizeof(cur), "%.5f, %.5f", lat / 1e6, lon / 1e6); + TextEntry t = { "Fixed position", onOwnPosKb }; + t.text = cur; + t.hint = "50.06143, 19.93658"; + t.max_bytes = 31; + t.accepted = "0123456789.,- "; + t.symbols = true; + navTextEntry(t); + return; + } + if (code == OWNPOS_WAYPOINTS) { + lv_obj_t* list = scrollList(navPopupPanel("From a waypoint", true)); + for (int i = 0; i < wp.count(); i++) { + const Waypoint& w = wp.at(i); + char title[WAYPOINT_LABEL_LEN + 8], sub[40]; + snprintf(title, sizeof(title), UI_SYMBOL_FLAG " %s", w.label[0] ? w.label : "(unnamed)"); + snprintf(sub, sizeof(sub), "%.5f, %.5f", w.lat_1e6 / 1e6, w.lon_1e6 / 1e6); + listRow(list, title, sub, onOwnPosPick, (void*)(uintptr_t)(OWNPOS_WP0 + i)); + } + return; + } + if (code == OWNPOS_CLEAR) _core->setFixedPosition(0, 0); + else if (code >= OWNPOS_WP0 && code - OWNPOS_WP0 < wp.count()) + _core->setFixedPosition(wp.at(code - OWNPOS_WP0).lat_1e6, wp.at(code - OWNPOS_WP0).lon_1e6); + navClosePopup(); + if (_screen == SCR_SETTINGS_NAV) rebuildSchemaSettings(); +} + +void UITask::ownPositionDone(bool ok) { + if (ok && _nav_ta) { + int32_t lat, lon; + if (!geo::parseLatLon(lv_textarea_get_text(_nav_ta), lat, lon) || (!lat && !lon)) { + showToast("Type it as latitude, longitude"); + return; // the field stays, to fix it + } + _core->setFixedPosition(lat, lon); + } + navClosePopup(); + if (ok && _screen == SCR_SETTINGS_NAV) rebuildSchemaSettings(); +} + +void UITask::blePinPopup() { + if (!_prefs) return; + char cur[8] = ""; + if (_prefs->ble_pin) snprintf(cur, sizeof(cur), "%06lu", (unsigned long)_prefs->ble_pin); + TextEntry t = { "Bluetooth PIN", onBlePinKb }; + t.text = cur; + t.hint = "6 digits, empty for a random one"; + t.max_bytes = 6; + t.accepted = "0123456789"; + t.symbols = true; + navTextEntry(t); +} + +void UITask::blePinDone(bool ok) { + if (ok && _nav_ta) { + const char* t = lv_textarea_get_text(_nav_ta); + if ((t[0] && strlen(t) != 6) || !_core->setBlePin((uint32_t)strtoul(t, nullptr, 10))) { + showToast("Six digits, not starting with 0"); + return; + } + showToast("Used from the next start", 2000); + } + navClosePopup(); + if (ok && _screen == SCR_SETTINGS_NAV) rebuildSchemaSettings(); +} + +void UITask::factoryResetStep(int stage) { + if (stage == 1) + confirmBody(navPopupPanel("Factory reset?", false), + "Erases contacts, channels, messages, settings and this node's keys. The SD card is left as it is.", + "Continue", onFactoryReset, 2); + else if (stage == 2) + confirmBody(navPopupPanel("Erase everything?", false), + "This can't be undone. The device restarts as a new node.", + LV_SYMBOL_TRASH " Erase", onFactoryReset, 3); + else { + navClosePopup(); + _core->factoryReset(); // restarts; back here only if the erase failed + showToast("Erase failed"); + } +} + +// The companion CLI's settings keys (ui-core/SettingsCli.h); a Settings page +// open on the screen is built again with the new value. +bool UITask::cliCommand(const char* command, char* reply, int n) { + if (!settingscli::handle(*_core, command, reply, n)) return false; + if (_screen == SCR_SETTINGS_NAV) rebuildSchemaSettings(); + return true; } // What a page adds to its schema groups, once they are there. void UITask::schemaExtras(int page) { + if (page == settings::PG_PRIVACY) { + if (lv_obj_t* c = s_sec_card[settings::SEC_PRIVACY]) { // with "Position in adverts" + char sub[40]; + int32_t lat, lon; + if (_core->gpsEnabled()) snprintf(sub, sizeof(sub), "From the GPS while it's on"); + else if (_core->fixedPosition(lat, lon)) snprintf(sub, sizeof(sub), "%.5f, %.5f", lat / 1e6, lon / 1e6); + else snprintf(sub, sizeof(sub), "Not set"); + listRow(c, "Fixed position", sub, onOwnPos, NULL); + } +#ifdef BLE_PIN_CODE + char pin[40]; + if (_prefs && _prefs->ble_pin) snprintf(pin, sizeof(pin), "%06lu", (unsigned long)_prefs->ble_pin); + else snprintf(pin, sizeof(pin), "Random each start, shown on screen"); + listRow(group(_body, "BLUETOOTH"), "Pairing PIN", pin, onBlePin, NULL); +#endif + } if (page == settings::PG_SOUND) buildSoundRows(_body, false); // the melodies if (page == settings::PG_MESSAGES) { lv_obj_t* c = s_sec_card[settings::SEC_CONTACTS]; // the action that goes with "Contact expiry" @@ -4483,30 +4667,51 @@ void UITask::buildKeyboardPage(lv_obj_t* body) { groupNote(body, "Hold a letter for accents and other variants."); } -// The Solo site's QR code (ui-core/SiteQr.h): a 1-bit image, black on white -// with its quiet zone, built once at 5 px a module. +// A QR code as a 1-bit image into dsc / data (the palette, then the rows): +// black on white with its quiet zone, s px a module. The image cache forgets +// an older picture at the same place. +template +static void qrImage(lv_image_dsc_t& dsc, uint8_t* data, size_t size, int n, int s, Dark dark) { + const int side = n + 2 * siteqr::QUIET, px = side * s, stride = (px + 7) / 8; + memset(data, 0, size); + const lv_color32_t pal[2] = { lv_color32_make(0xFF, 0xFF, 0xFF, 0xFF), lv_color32_make(0, 0, 0, 0xFF) }; + memcpy(data, pal, sizeof(pal)); + uint8_t* rows = data + 8; + for (int y = 0; y < px; y++) + for (int x = 0; x < px; x++) { + int mx = x / s - siteqr::QUIET, my = y / s - siteqr::QUIET; + if (mx >= 0 && my >= 0 && mx < n && my < n && dark(mx, my)) rows[y * stride + x / 8] |= 0x80 >> (x % 8); + } + lv_image_cache_drop(&dsc); + dsc.header.magic = LV_IMAGE_HEADER_MAGIC; + dsc.header.cf = LV_COLOR_FORMAT_I1; + dsc.header.w = px; + dsc.header.h = px; + dsc.header.stride = stride; + dsc.data_size = 8 + stride * px; + dsc.data = data; +} + +// The Solo site's QR code (ui-core/SiteQr.h), built once at 5 px a module. static lv_obj_t* siteQr(lv_obj_t* parent) { - static const int S = 5, SIDE = siteqr::N + 2 * siteqr::QUIET, PX = SIDE * S, STRIDE = (PX + 7) / 8; - static uint8_t data[8 + STRIDE * PX]; // the palette (white, black), then the rows + static const int S = 5, PX = (siteqr::N + 2 * siteqr::QUIET) * S; + static uint8_t data[8 + (PX + 7) / 8 * PX]; static lv_image_dsc_t dsc; - if (!dsc.data) { - const lv_color32_t pal[2] = { lv_color32_make(0xFF, 0xFF, 0xFF, 0xFF), lv_color32_make(0, 0, 0, 0xFF) }; - memcpy(data, pal, sizeof(pal)); - uint8_t* px = data + 8; - for (int y = 0; y < PX; y++) - for (int x = 0; x < PX; x++) { - int mx = x / S - siteqr::QUIET, my = y / S - siteqr::QUIET; - if (mx >= 0 && my >= 0 && mx < siteqr::N && my < siteqr::N && siteqr::dark(mx, my)) - px[y * STRIDE + x / 8] |= 0x80 >> (x % 8); - } - dsc.header.magic = LV_IMAGE_HEADER_MAGIC; - dsc.header.cf = LV_COLOR_FORMAT_I1; - dsc.header.w = PX; - dsc.header.h = PX; - dsc.header.stride = STRIDE; - dsc.data_size = sizeof(data); - dsc.data = data; - } + if (!dsc.data) qrImage(dsc, data, sizeof(data), siteqr::N, S, siteqr::dark); + lv_obj_t* img = lv_image_create(parent); + lv_image_set_src(img, &dsc); + return img; +} + +// Your own contact's QR code (ui-core/ContactQr.h), made each time it is +// shown (the name can change), 3 px a module; null when it doesn't fit. +static lv_obj_t* contactQr(lv_obj_t* parent) { + static const int S = 3, PX = (contactqr::MAX_VERSION * 4 + 17 + 2 * siteqr::QUIET) * S; + static uint8_t data[8 + (PX + 7) / 8 * PX]; + static lv_image_dsc_t dsc; + static contactqr::Code code; + if (!contactqr::make(code)) return nullptr; + qrImage(dsc, data, sizeof(data), code.n, S, [](int x, int y) { return code.dark(x, y); }); lv_obj_t* img = lv_image_create(parent); lv_image_set_src(img, &dsc); return img; @@ -4518,6 +4723,13 @@ void UITask::buildAboutPage(lv_obj_t* body) { infoRow(about, "Node", the_mesh.getNodeName()); infoRow(about, "Firmware", FIRMWARE_VERSION); if (!strstr(FIRMWARE_VERSION, FIRMWARE_BUILD_DATE)) infoRow(about, "Built", FIRMWARE_BUILD_DATE); + sectionTitle(body, "MY CONTACT"); // the phone app scans it to add you + lv_obj_t* me = infoCard(body); + lv_obj_set_flex_align(me, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_ver(me, theme::PAD, 0); + lv_obj_set_style_pad_row(me, 6, 0); + contactQr(me); + label(me, "Scan with the MeshCore app to add this node", THEME_FONT_SMALL, theme::TEXT_MUTED); sectionTitle(body, "MANUAL"); lv_obj_t* man = infoCard(body); lv_obj_set_flex_align(man, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); @@ -4701,7 +4913,7 @@ void UITask::settingsGroup(int i) { if (pi >= 0) radioctl::presetAt(_prefs, pi, pn, f, b, sf, cr); snprintf(sub, sizeof(sub), "%s - %.3f MHz, %d dBm", pn, _prefs->freq, _prefs->tx_power_dbm); listRow(g, UI_SYMBOL_RADIO " Radio", sub, onOpenRadio, NULL); - listRow(g, LV_SYMBOL_EYE_CLOSE " Privacy", "Position in adverts, who can ask for telemetry", + listRow(g, LV_SYMBOL_EYE_CLOSE " Privacy", "Position, who can ask for telemetry, Bluetooth PIN", onOpenSchemaPage, (void*)(uintptr_t)settings::PG_PRIVACY); } bluetoothRow(g); @@ -4735,13 +4947,14 @@ void UITask::settingsGroup(int i) { schemaRow(g, SETTING(units_imperial)); } listRow(g, LV_SYMBOL_DOWNLOAD " Firmware update", FIRMWARE_VERSION, onOpenOta, NULL); - listRow(g, LV_SYMBOL_LIST " About", "Node, firmware, manual, credits", onOpenSchemaPage, (void*)(uintptr_t)PG_ABOUT); + listRow(g, LV_SYMBOL_LIST " About", "Node, your contact QR, manual, credits", onOpenSchemaPage, (void*)(uintptr_t)PG_ABOUT); break; case 4: g = group(body, nullptr); actionRow(g, LV_SYMBOL_REFRESH " Reboot", onPowerRow, (void*)(uintptr_t)1); actionRow(g, LV_SYMBOL_POWER " Power off", onPowerRow, (void*)(uintptr_t)0, theme::FAIL); + actionRow(g, LV_SYMBOL_TRASH " Factory reset", onFactoryReset, (void*)(uintptr_t)1, theme::FAIL); break; } } diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 442036a1..dfe11aed 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -60,6 +60,7 @@ public: void onViewedHistoryGrew(bool channel) override { (void)channel; _thread_dirty = true; } void botSetGPS(bool on) override; void applySoundPrefs() override; + bool cliCommand(const char* command, char* reply, int n) override; // ── Navigation (called from LVGL event callbacks) ──────────────────────── void showHome(); @@ -87,6 +88,14 @@ public: void otaAction(); void adminPick(int row); void pruneContacts(); + // Settings > Privacy: the position adverts carry with the GPS off, the + // Bluetooth PIN; Settings: factory reset, asked twice (stage 1, 2; 3 erases). + void ownPositionPopup(); + void ownPositionPick(int code); + void ownPositionDone(bool ok); + void blePinPopup(); + void blePinDone(bool ok); + void factoryResetStep(int stage); void setBrightnessPct(uint8_t pct, bool save); void applyDisplayPrefs() override; void setSchemaValue(int idx, int v); @@ -595,6 +604,7 @@ private: lv_obj_t* _node_info = nullptr; lv_obj_t* _node_ping = nullptr; lv_obj_t* _node_delete_lbl = nullptr; + lv_obj_t* _node_path_lbl = nullptr; bool _node_from_map = false; // Map (SCR_MAP): view centre in fractional tile coords at zoom _map_z diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index e201180d..8e08d3f6 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -21,7 +21,8 @@ class NearbyScreen : public UIScreen, protected NearbyModel { // ── action-menu actions (matched by id, not by row index) ──────────────────── enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_LOCATOR, - ACT_ADD, ACT_DELETE, ACT_FAV, ACT_PIN, ACT_ADMIN, ACT_SORT, ACT_SCAN, ACT_MAP }; + ACT_ADD, ACT_DELETE, ACT_FAV, ACT_PIN, ACT_ADMIN, ACT_SORT, ACT_SCAN, ACT_MAP, + ACT_TELEM, ACT_RESET_PATH, ACT_SHARE }; // Set by UITask::pickAdminTarget() (Tools > Admin, which is remote-only): // while true, ENTER on an eligible row (a stored repeater/room contact) hands @@ -55,7 +56,7 @@ class NearbyScreen : public UIScreen, protected NearbyModel { PopupMenu _ping_menu; // ping (special: read-only result rows) PopupMenu _confirm; // delete-contact confirmation (destructive → 2-step) - Action _menu_actions[12]; // parallel to _menu rows — stable action ids + Action _menu_actions[16]; // parallel to _menu rows — stable action ids int _menu_action_count; char _sort_label[16]; // dynamic label for the Sort row @@ -303,6 +304,24 @@ class NearbyScreen : public UIScreen, protected NearbyModel { char _fav_label[12]; // "Fav: ON" / "Fav: OFF" -- rewritten in place by L/R char _pin_label[24]; // "Pin to dial" / "Unpin (slot N)" -- _menu stores the pointer + char _telem_label[20]; // "Telem: ..." -- rewritten in place by L/R + + // What a contact may ask for, as one cycled value: status first, since + // location and sensors go unanswered without it (MyMesh::onContactRequest). + static uint8_t telemPerms(int step) { + static const uint8_t STEPS[5] = { + 0, TELEM_PERM_BASE, TELEM_PERM_BASE | TELEM_PERM_LOCATION, TELEM_PERM_BASE | TELEM_PERM_ENVIRONMENT, + TELEM_PERM_BASE | TELEM_PERM_LOCATION | TELEM_PERM_ENVIRONMENT }; + return STEPS[step]; + } + static int telemStep(uint8_t perms) { + for (int i = 4; i > 0; i--) if (perms == telemPerms(i)) return i; + return 0; + } + void buildTelemLabel(uint8_t perms) { + static const char* const NAMES[5] = { "None", "Status", "Stat+loc", "Stat+sens", "All" }; + snprintf(_telem_label, sizeof(_telem_label), "Telem: %s", NAMES[telemStep(perms)]); + } void openActionMenu() { const Entry* e = selected(); @@ -347,6 +366,15 @@ class NearbyScreen : public UIScreen, protected NearbyModel { else snprintf(_pin_label, sizeof(_pin_label), "Pin to dial"); add(_pin_label, ACT_PIN); } + // A saved contact (not a scan row): its telemetry permissions -- only where + // Privacy says "Allowed" do they count -- its path and its advert. + ContactInfo* ci = (is_contact && has_key) ? the_mesh.lookupContactByPubKey(e->pub_key, PUB_KEY_SIZE) : nullptr; + if (ci && contactctl::telemetryPerContact(_task->getNodePrefs())) { + buildTelemLabel(contactctl::telemetry(*ci)); + addValue(_telem_label, ACT_TELEM); + } + if (ci && contactctl::hasPath(*ci)) add("Reset path", ACT_RESET_PATH); + if (ci) add("Share nearby", ACT_SHARE); if (is_admin_target) add("Admin", ACT_ADMIN); if (is_contact && has_key) add("Delete contact", ACT_DELETE); if (stored) addValue(_sort_label, ACT_SORT); // sort is meaningless for live-scan rows @@ -365,9 +393,9 @@ class NearbyScreen : public UIScreen, protected NearbyModel { refreshKeepingSelection(); } - // Advance the value on the menu's value rows (Sort, Fav). Both are two-state, - // so LEFT and RIGHT do the same thing here and Enter joins them. - void cycleMenuValue(int i) { + // Advance the value on the menu's value rows (Sort, Fav, Telem). Sort and Fav + // are two-state, so LEFT and RIGHT do the same there; Telem steps by `dir`. + void cycleMenuValue(int i, int dir = 1) { if (i < 0 || i >= _menu_action_count) return; if (_menu_actions[i] == ACT_SORT) { _sort = (_sort == SORT_DIST) ? SORT_TIME : SORT_DIST; @@ -375,6 +403,12 @@ class NearbyScreen : public UIScreen, protected NearbyModel { refresh(); } else if (_menu_actions[i] == ACT_FAV) { toggleFavSelected(); + } else if (_menu_actions[i] == ACT_TELEM) { + const Entry* e = selected(); + ContactInfo* ci = e ? the_mesh.lookupContactByPubKey(e->pub_key, PUB_KEY_SIZE) : nullptr; + if (!ci) return; + uint8_t next = telemPerms((telemStep(contactctl::telemetry(*ci)) + dir + 5) % 5); + if (contactctl::setTelemetry(e->pub_key, next)) buildTelemLabel(next); } } @@ -416,6 +450,17 @@ class NearbyScreen : public UIScreen, protected NearbyModel { break; } case ACT_FAV: break; // value rows -- see cycleMenuValue() + case ACT_TELEM: break; + case ACT_RESET_PATH: { + const Entry* e = selected(); + if (e && contactctl::resetPath(e->pub_key)) _task->showAlert("Path reset", 800); + break; + } + case ACT_SHARE: { + const Entry* e = selected(); + _task->showAlert(e && contactctl::share(e->pub_key) ? "Shared nearby" : "No advert saved", 1000); + break; + } case ACT_PIN: { const Entry* e = selected(); if (e && e->has_key) togglePinToDial(e->pub_key); @@ -914,10 +959,10 @@ public: if (_ping_menu.active) { handlePingMenuInput(c); return true; } if (_menu.active) { // LEFT/RIGHT -- and Enter, which PopupMenu reports as VALUE_NEXT on a - // value row -- cycle Sort and Fav in place; the popup stays open so the - // user can keep tapping, and only Back closes it. Other rows swallow L/R. + // value row -- cycle Sort, Fav and Telem in place; the popup stays open so + // the user can keep tapping, and only Back closes it. Other rows swallow L/R. if (keyIsPrev(c) || keyIsNext(c)) { - cycleMenuValue(_menu.selectedIndex()); + cycleMenuValue(_menu.selectedIndex(), keyIsNext(c) ? 1 : -1); return true; } auto res = _menu.handleInput(c); diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index d59197c8..bcb7dccf 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -10,6 +10,7 @@ #include "PopupMenu.h" // scope list management's per-row action menu #include "InfoKit.h" // value / switch rows #include "../ui-core/Battery.h" // the Battery curve row +#include "../GeoUtils.h" // Position typed in class SettingsScreen : public UIScreen { UITask* _task; @@ -53,6 +54,7 @@ class SettingsScreen : public UIScreen { SCHEMA_SYSTEM, // power, units BATT_CURVE, REBOOT, + FACTORY_RESET, // Keyboard section SECTION_KEYBOARD, KEYBOARD_TYPE, @@ -63,8 +65,11 @@ class SettingsScreen : public UIScreen { #endif // Contacts section SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, SCHEMA_CONTACTS, PRUNE_NOW, SCHEMA_AUTO_ADD, - // Privacy section: what adverts carry, who may ask for telemetry - SECTION_PRIVACY, SCHEMA_PRIVACY, + // Privacy section: what adverts carry, who may ask for telemetry, the pairing PIN + SECTION_PRIVACY, SCHEMA_PRIVACY, OWN_POSITION, SCHEMA_TELEMETRY, +#ifdef BLE_PIN_CODE + BLE_PIN, +#endif // Messages section SECTION_MESSAGES, SCHEMA_MESSAGES, @@ -184,7 +189,7 @@ class SettingsScreen : public UIScreen { static bool isSchemaGroup(int item) { return item == SCHEMA_DISPLAY || item == SCHEMA_SOUND || item == SCHEMA_SYSTEM || item == SCHEMA_CONTACTS || item == SCHEMA_MESSAGES || item == SCHEMA_RADIO || item == SCHEMA_AUTO_ADD || - item == SCHEMA_PRIVACY; + item == SCHEMA_PRIVACY || item == SCHEMA_TELEMETRY; } // Which schema sections a placeholder stands for. static bool schemaIn(int group, uint8_t sec) { @@ -196,7 +201,8 @@ class SettingsScreen : public UIScreen { case SCHEMA_CONTACTS: return sec == SEC_CONTACTS; case SCHEMA_AUTO_ADD: return sec == SEC_AUTO_ADD; case SCHEMA_RADIO: return sec == SEC_RADIO_ADV; - case SCHEMA_PRIVACY: return sec == SEC_PRIVACY || sec == SEC_TELEMETRY; + case SCHEMA_PRIVACY: return sec == SEC_PRIVACY; + case SCHEMA_TELEMETRY: return sec == SEC_TELEMETRY; case SCHEMA_MESSAGES: return sec == SEC_MESSAGES; } return false; @@ -512,6 +518,20 @@ class SettingsScreen : public UIScreen { val("Batt curve", p && battery::validCurve(p->batt_curve_mv) ? "Custom" : "LiPo"); } else if (item == REBOOT) { display.print("Reboot"); // action row: Enter reboots this device + } else if (item == FACTORY_RESET) { + display.print("Factory reset"); // action row: asks twice, then erases everything + } else if (item == OWN_POSITION) { + int32_t lat, lon; + if (_task->core().gpsEnabled()) val("Position", "GPS"); + else if (_task->core().fixedPosition(lat, lon)) { + snprintf(buf, sizeof(buf), "%.4f,%.4f", lat / 1e6, lon / 1e6); + val("Position", buf); + } else val("Position", "None"); +#ifdef BLE_PIN_CODE + } else if (item == BLE_PIN) { + if (p && p->ble_pin) snprintf(buf, sizeof(buf), "%06lu", (unsigned long)p->ble_pin); + val("BT PIN", p && p->ble_pin ? buf : "Random"); +#endif } else if (item == KEYBOARD_TYPE) { val("Type", (p && p->keyboard_type) ? "T9" : "ABC"); } else if (item == KEYBOARD_MAIN_ALPHABET) { @@ -587,6 +607,16 @@ class SettingsScreen : public UIScreen { PopupMenu _prune_confirm; char _prune_confirm_title[40]; + // Privacy > Position: Type in / From waypoint / Clear, then the waypoints; + // the keyboard for typing one in. Privacy > BT PIN: the number pad. + PopupMenu _pos_menu; + bool _pos_pick_wp = false; // _pos_menu lists the waypoints + bool _edit_pos = false; + bool _edit_ble_pin = false; + // System > Factory reset: asked twice (1, 2), 0 = not asking. + PopupMenu _reset_confirm; + uint8_t _reset_stage = 0; + int renderScopeMgmt(DisplayDriver& display) { display.setColor(DisplayDriver::LIGHT); drawScreenHeader(display, "Scope", -1, 0, true, _scope_action_menu.active); @@ -636,6 +666,10 @@ public: _scope_action_menu.active = false; _scope_delete_confirm_active = false; _prune_confirm.active = false; + _pos_menu.active = false; + _reset_confirm.active = false; + _reset_stage = 0; + _edit_pos = _edit_ble_pin = false; _edit_lock_pass = false; _lock_pass_first[0] = '\0'; if (!keep) resetList(); @@ -645,7 +679,7 @@ public: int render(DisplayDriver& display) override { display.setTextSize(1); - if (_edit_slot >= 0 || _edit_name || _edit_lock_pass || _scope_rename_idx != -2 || _picker.saving) { + if (_edit_slot >= 0 || _edit_name || _edit_lock_pass || _edit_pos || _edit_ble_pin || _scope_rename_idx != -2 || _picker.saving) { return _kb->render(display); } @@ -673,6 +707,8 @@ public: if (_picker.menu.active) _picker.menu.render(display); if (_prune_confirm.active) _prune_confirm.render(display); + if (_pos_menu.active) _pos_menu.render(display); + if (_reset_confirm.active) _reset_confirm.render(display); return (mq_delay > 0 && mq_delay < 2000) ? mq_delay : 2000; } @@ -741,6 +777,40 @@ public: return true; } + // Privacy > Position typed in: "lat, lon" + if (_edit_pos) { + auto res = _kb->handleInput(c); + if (res == KeyboardWidget::DONE) { + int32_t lat, lon; + if (geo::parseLatLon(_kb->buf, lat, lon) && (lat || lon)) { + _task->core().setFixedPosition(lat, lon); + _edit_pos = false; + } else { + _kb->prompt = "Lat, lon?"; // stays open to fix it + } + } else if (res == KeyboardWidget::CANCELLED) { + _edit_pos = false; + } + return true; + } + + // Privacy > BT PIN: six digits, or nothing for a random one + if (_edit_ble_pin) { + auto res = _kb->handleInput(c); + if (res == KeyboardWidget::DONE) { + uint32_t pin = (uint32_t)strtoul(_kb->buf, nullptr, 10); + if ((_kb->buf[0] && strlen(_kb->buf) != 6) || !_task->core().setBlePin(pin)) { + _kb->prompt = "6 digits"; + } else { + _edit_ble_pin = false; + _task->showAlert("From next start", 1200); + } + } else if (res == KeyboardWidget::CANCELLED) { + _edit_ble_pin = false; + } + return true; + } + // Keyboard editing mode for adding/renaming a scope-list entry if (_scope_rename_idx != -2) { auto res = _kb->handleInput(c); @@ -854,6 +924,52 @@ public: return true; } + // Privacy > Position menu, and its waypoint list + if (_pos_menu.active) { + auto res = _pos_menu.handleInput(c); + if (res == PopupMenu::SELECTED) { + int sel = _pos_menu.selectedIndex(); + const WaypointModel& wp = _task->core().waypoints; + if (_pos_pick_wp) { + if (sel < wp.count()) _task->core().setFixedPosition(wp.at(sel).lat_1e6, wp.at(sel).lon_1e6); + _pos_pick_wp = false; + } else if (sel == 0) { // Type in + int32_t lat, lon; + char cur[32] = ""; + if (_task->core().fixedPosition(lat, lon)) snprintf(cur, sizeof(cur), "%.5f, %.5f", lat / 1e6, lon / 1e6); + _edit_pos = true; + _kb->begin(cur, 31); + _kb->prompt = "Lat, lon"; + _kb->clearPlaceholders(); + } else if (sel == 1 && wp.count() > 0) { // From waypoint + _pos_menu.begin("Waypoint", 4); + for (int i = 0; i < wp.count() && i < 24; i++) _pos_menu.addItem(wp.at(i).label[0] ? wp.at(i).label : "(unnamed)"); + _pos_pick_wp = true; + } else { // Clear + _task->core().setFixedPosition(0, 0); + } + } else if (res != PopupMenu::NONE) { + _pos_pick_wp = false; + } + return true; + } + + // System > Factory reset: the second yes erases + if (_reset_confirm.active) { + auto res = _reset_confirm.handleInput(c); + if (res == PopupMenu::SELECTED && _reset_confirm.selectedIndex() == 0) { + if (_reset_stage == 1) { + _reset_stage = 2; + _reset_confirm.beginConfirm("Keys too. Sure?", "Erase"); + return true; + } + _task->core().factoryReset(); // restarts; back here only if the erase failed + _task->showAlert("Erase failed", 1400); + } + if (res != PopupMenu::NONE) _reset_stage = 0; + return true; + } + // Contacts > "Prune now" confirm if (_prune_confirm.active) { auto res = _prune_confirm.handleInput(c); @@ -992,6 +1108,31 @@ public: _task->shutdown(true); // flushes prefs/RTC/contacts/trail, then reboots -- single choke point return true; } + if (_selected == FACTORY_RESET && enter) { + _reset_stage = 1; + _reset_confirm.beginConfirm("Erase everything?", "Erase"); + return true; + } + if (_selected == OWN_POSITION && enter) { + if (_task->core().gpsEnabled()) { _task->showAlert("GPS sets it", 1200); return true; } + int32_t lat, lon; + bool have = _task->core().fixedPosition(lat, lon); + bool wps = _task->core().waypoints.count() > 0; + _pos_menu.begin("Position", 3); + _pos_menu.addItem("Type in"); + if (wps) _pos_menu.addItem("From waypoint"); + if (have) _pos_menu.addItem("Clear"); + _pos_pick_wp = false; + return true; + } +#ifdef BLE_PIN_CODE + if (_selected == BLE_PIN && enter) { + _edit_ble_pin = true; + _kb->beginPin("", 6, false, "6 digits, or none"); + _kb->clearPlaceholders(); + return true; + } +#endif if (_selected == KEYBOARD_TYPE && p && (left || right || enter)) { p->keyboard_type ^= 1; _dirty = true; diff --git a/examples/companion_radio/ui-new/StatusScreen.h b/examples/companion_radio/ui-new/StatusScreen.h index 157c8f9b..0ebf4637 100644 --- a/examples/companion_radio/ui-new/StatusScreen.h +++ b/examples/companion_radio/ui-new/StatusScreen.h @@ -8,6 +8,7 @@ #include "TabBar.h" #include "../MyMesh.h" #include "../ui-core/SiteQr.h" +#include "../ui-core/ContactQr.h" #include "../ui-core/Diagnostics.h" #include "../ui-core/Battery.h" #if ENV_INCLUDE_GPS == 1 && defined(GPS_SKYVIEW) @@ -46,7 +47,10 @@ private: int _scroll = 0; // blocks scrolled off the top bool _more = false; // the last frame cut blocks off at the bottom bool _signal = false; // Sky: the bars instead of the plot - bool _qr = false; // System: the site's QR code, full screen + // System: a QR code full screen -- your contact, then (Enter) the manual. + enum : uint8_t { QR_NONE, QR_CONTACT, QR_MANUAL }; + uint8_t _qr = QR_NONE; + contactqr::Code _contact_qr; PopupMenu _reset_menu; // Mesh, Hold Enter -> confirm CayenneLPP _lpp; unsigned long _lpp_at = 0; @@ -296,7 +300,7 @@ private: row(d, f, rsv, "Firmware", a); row(d, f, rsv, "Built", FIRMWARE_BUILD_DATE); row(d, f, rsv, "Device", board.getManufacturerName()); - row(d, f, rsv, "Manual", "Enter: QR code"); + row(d, f, rsv, "QR codes", "Enter: you, manual"); uint32_t heap_free, heap_total; DeviceDiag::getHeapStats(heap_free, heap_total); if (heap_total) snprintf(a, sizeof(a), "%lu of %lu KB", (unsigned long)(heap_free / 1024), (unsigned long)(heap_total / 1024)); @@ -320,19 +324,33 @@ private: if (f.place(d.lineStep())) d.drawTextEllipsized(1, f.at, d.width() - rsv - 3, lines[i]); } - // The site's QR code as large as the screen allows, dark modules on light - // (LIGHT is the ink on e-ink, the lit pixel elsewhere), with the address - // beside it or under it where it fits. + // A QR code as large as the screen allows, dark modules on light (LIGHT is + // the ink on e-ink, the lit pixel elsewhere), with its caption beside it or + // under it: your contact, or the site's manual. void renderQr(DisplayDriver& d) { + static const char* const MANUAL[] = { "Solo manual", siteqr::URL, nullptr }; + static const char* const MANUAL_NARROW[] = { "Scan for", "the Solo", "manual" }; + if (_qr == QR_CONTACT && _contact_qr.n) { + const char* const you[] = { "Scan to add", the_mesh.getNodeName(), nullptr }; + renderQr(d, _contact_qr.n, [&](int x, int y) { return _contact_qr.dark(x, y); }, you, you); + } else { + renderQr(d, siteqr::N, siteqr::dark, MANUAL, MANUAL_NARROW); + } + } + // Caption: up to three lines, `wide` when every line fits beside / under + // the code, else `narrow` (lines cut to fit). + template + void renderQr(DisplayDriver& d, int n, Dark dark, const char* const* wide, const char* const* narrow) { const int W = d.width(), H = d.height(), lh = d.getLineHeight(); const bool tall = H > W; - const int side = siteqr::N + 2 * siteqr::QUIET; - const int s = (tall ? W : H) / side; + const int side = n + 2 * siteqr::QUIET; + const int s = (tall ? W : H) / side > 0 ? (tall ? W : H) / side : 1; const int px = side * s; - // The text: "Solo manual" and the address if that fits on one line. const int tx = tall ? 0 : px + 2, tw = W - tx; - const bool url = d.getTextWidth(siteqr::URL) <= tw; - const int lines = url ? 2 : 3; + const char* const* cap = wide; + for (int i = 0; i < 3 && wide[i]; i++) if (d.getTextWidth(wide[i]) > tw) cap = narrow; + int lines = 0; + while (lines < 3 && cap[lines]) lines++; // Portrait: the code and the text under it, centred together. const int x0 = tall ? (W - px) / 2 : 0; const int y0 = tall ? (H - px - lh / 2 - lines * lh) / 2 : (H - px) / 2; @@ -340,19 +358,15 @@ private: if (!eink) { d.setColor(DisplayDriver::LIGHT); d.fillRect(x0, y0, px, px); } d.setColor(eink ? DisplayDriver::LIGHT : DisplayDriver::DARK); const int q = x0 + siteqr::QUIET * s, r = y0 + siteqr::QUIET * s; - for (int y = 0; y < siteqr::N; y++) - for (int x = 0; x < siteqr::N; x++) - if (siteqr::dark(x, y)) d.fillRect(q + x * s, r + y * s, s, s); + for (int y = 0; y < n; y++) + for (int x = 0; x < n; x++) + if (dark(x, y)) d.fillRect(q + x * s, r + y * s, s, s); d.setColor(DisplayDriver::LIGHT); - const int mid = tx + tw / 2; int ty = tall ? y0 + px + lh / 2 : (H - lines * lh) / 2; - if (url) { - d.drawTextCentered(mid, ty, "Solo manual"); ty += lh; - d.drawTextCentered(mid, ty, siteqr::URL); - } else { - d.drawTextCentered(mid, ty, "Scan for"); ty += lh; - d.drawTextCentered(mid, ty, "the Solo"); ty += lh; - d.drawTextCentered(mid, ty, "manual"); + for (int i = 0; i < lines; i++) { + int w = d.getTextWidth(cap[i]); + d.drawTextEllipsized(w < tw ? tx + (tw - w) / 2 : tx, ty, tw, cap[i]); + ty += lh; } } @@ -386,7 +400,7 @@ public: // Open on a given tab (Status page: Radio; the GPS shortcuts: GPS / Sky). void showTab(uint8_t t) { - _qr = false; + _qr = QR_NONE; for (uint8_t i = 0; i < _count; i++) if (_tabs[i] == t) { _cur = i; _scroll = 0; } } // GPS stays awake while its live tabs are on screen. @@ -457,7 +471,10 @@ public: } return true; } - if (_qr) { _qr = false; return true; } + if (_qr) { // Enter: the other code; anything else closes + _qr = c == KEY_ENTER && _qr == QR_CONTACT ? QR_MANUAL : c == KEY_ENTER && _qr == QR_MANUAL ? QR_CONTACT : QR_NONE; + return true; + } if (keyIsPrev(c)) { _cur = (_cur + _count - 1) % _count; _scroll = 0; return true; } if (keyIsNext(c)) { _cur = (_cur + 1) % _count; _scroll = 0; return true; } if (c == KEY_UP) { if (_scroll > 0) _scroll--; return true; } @@ -466,7 +483,10 @@ public: if (c == KEY_ENTER && tab() == TAB_GPS) { _task->toggleGPS(); return true; } #endif if (c == KEY_ENTER && tab() == TAB_SKY) { _signal = !_signal; return true; } - if (c == KEY_ENTER && tab() == TAB_SYSTEM) { _qr = true; return true; } + if (c == KEY_ENTER && tab() == TAB_SYSTEM) { + _qr = contactqr::make(_contact_qr) ? QR_CONTACT : QR_MANUAL; + return true; + } if (c == KEY_CONTEXT_MENU && tab() == TAB_MESH) { _reset_menu.beginConfirm("Reset counters?", "Reset"); return true; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 5a923257..c2fe44d8 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -173,6 +173,7 @@ static const telemetry::Style L1_INFO = { "\xc2\xb0", true, false, 5 }; #include "SensorPlaceholders.h" #include "../ui-core/UiCore.h" // shared UI Core: history rings + unread models (MessagesScreen views them) #include "../ui-core/SettingsSchema.h" // the settings both frontends share (SettingsScreen.h) +#include "../ui-core/SettingsCli.h" // and their companion CLI keys #include "SettingsScreen.h" #include "MessagesScreen.h" @@ -4053,6 +4054,13 @@ void UITask::applySoundPrefs() { #endif } +// The companion CLI's settings keys (ui-core/SettingsCli.h). +bool UITask::cliCommand(const char* command, char* reply, int n) { + if (!settingscli::handle(*_core, command, reply, n)) return false; + _next_refresh = 0; // a screen showing the value draws it anew + return true; +} + void UITask::setBuzzerVolumeLevel(uint8_t level) { #ifdef PIN_BUZZER if (_node_prefs == NULL) return; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 993ac5cd..4e552438 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -508,6 +508,7 @@ public: // Settings changed through the schema (ui-core/SettingsSchema.h). void applyDisplayPrefs() override { applyBrightness(); _next_refresh = 0; } void applySoundPrefs() override; + bool cliCommand(const char* command, char* reply, int n) override; void setBuzzerVolumeLevel(uint8_t level); void applyTxPower(); void applyPowerSave(); // hardware duty-cycle RX on/off from prefs diff --git a/tools/screenshot.py b/tools/screenshot.py index 01cd9229..e14167d1 100755 --- a/tools/screenshot.py +++ b/tools/screenshot.py @@ -23,8 +23,12 @@ import os # Mesh protocol constants FRAME_START_OUT = 0x3C # '<' - used when sending TO device FRAME_START_IN = 0x3E # '>' - used when receiving FROM device -CMD_GET_SCREENSHOT = 66 -RESP_CODE_SCREENSHOT = 29 +CMD_GET_SCREENSHOT = 0x7E +RESP_CODE_SCREENSHOT = 0x7E +# Firmware before v2.0 used 66 / 29, which upstream gave to its CLI since: +# asked with the new code, such firmware answers an error, and we ask again. +CMD_GET_SCREENSHOT_OLD = 66 +RESP_CODE_SCREENSHOT_OLD = 29 RESP_CODE_ERR = 1 # display_type values (byte [5] in response frame) @@ -129,7 +133,7 @@ def _expected_buffer_size(width, height, display_type): return (width * height) // 8 -def receive_screenshot(ser, timeout=5): +def receive_screenshot(ser, timeout=5, code=RESP_CODE_SCREENSHOT): """Receive a multi-chunk screenshot response. Returns (buf, w, h, type, rot) or None.""" start_time = time.time() buffer_data = bytearray() @@ -154,7 +158,7 @@ def receive_screenshot(ser, timeout=5): print(f"Error: Device returned error code 0x{err:02x}") return None - if resp_code != RESP_CODE_SCREENSHOT: + if resp_code != code: # 29 is now the CLI reply: only the code asked for continue if len(frame) < HEADER_SIZE: @@ -365,6 +369,11 @@ def main(): time.sleep(0.1) result = receive_screenshot(ser) + if result is None: # older firmware: the old code + ser.reset_input_buffer() + send_command(ser, CMD_GET_SCREENSHOT_OLD) + time.sleep(0.1) + result = receive_screenshot(ser, code=RESP_CODE_SCREENSHOT_OLD) if result: buffer_data, width, height, display_type, disp_rotation = result type_str = {DISPLAY_TYPE_EINK: "e-ink", DISPLAY_TYPE_RGB565: "colour"}.get(display_type, "OLED")