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")