diff --git a/docs/solo/tools.md b/docs/solo/tools.md index f7424b82..2ed9920c 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 | +| **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 | **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,6 +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. ## GPIO diff --git a/examples/companion_radio/ui-core/SiteQr.h b/examples/companion_radio/ui-core/SiteQr.h new file mode 100644 index 00000000..22facdc3 --- /dev/null +++ b/examples/companion_radio/ui-core/SiteQr.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +// A QR code for the Solo site, the same on every board (Status > System on +// ui-new, Settings > About on the L2). Precomputed, so no QR library: the +// URL in upper case fits QR's alphanumeric mode, which keeps it at version 2 +// (25 x 25 modules, error correction M) -- large modules on a small screen. +// Regenerate with the Python qrcode package: QRCode(error_correction=M, +// border=0), add_data() the string below; module x of row y is bit 31 - x. +namespace siteqr { + +static const char* const URL = "solo.marekzegarek.com"; +static const int N = 25; // modules per side +static const int QUIET = 2; // light modules around it; 2 is plenty on a screen + +static const uint32_t ROWS[N] = { // "HTTPS://SOLO.MAREKZEGAREK.COM" + 0xFEC2BF80u, 0x824FA080u, 0xBAC6AE80u, 0xBA2B2E80u, 0xBA362E80u, + 0x82D62080u, 0xFEAABF80u, 0x007D8000u, 0xA3679280u, 0xCDA53C80u, + 0xFE9AEA80u, 0xB02B9C80u, 0x578F0700u, 0x69892480u, 0xE34E8D00u, + 0x19B92900u, 0xD7C6F980u, 0x00E48F80u, 0xFEB0AB00u, 0x82658E80u, + 0xBA3DFA00u, 0xBA083C00u, 0xBACE4B80u, 0x821AA080u, 0xFEC76F80u, +}; + +static inline bool dark(int x, int y) { return (ROWS[y] >> (31 - x)) & 1; } + +} // namespace siteqr diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 315ab707..119c1927 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -87,6 +87,7 @@ static bool s_chats_stale = false; // Messages: a refresh waits for a held row #include "../ui-core/SoundControl.h" #include "../ui-core/SoundNotifier.h" #include "../ui-core/MessageText.h" +#include "../ui-core/SiteQr.h" #include "Theme.h" #include "Anim.h" #include "LvglPort.h" @@ -4479,12 +4480,48 @@ void UITask::buildKeyboardPage(lv_obj_t* body) { groupNote(body, "Hold a letter for accents and other variants."); } -// Settings > About: this node, the firmware, credits. +// 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. +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 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; + } + lv_obj_t* img = lv_image_create(parent); + lv_image_set_src(img, &dsc); + return img; +} + +// Settings > About: this node, the firmware, the manual's QR code, credits. void UITask::buildAboutPage(lv_obj_t* body) { lv_obj_t* about = infoCard(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, "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); + lv_obj_set_style_pad_ver(man, theme::PAD, 0); + lv_obj_set_style_pad_row(man, 6, 0); + siteQr(man); + label(man, siteqr::URL, THEME_FONT_SMALL, theme::TEXT_MUTED); sectionTitle(body, "CREDITS"); lv_obj_t* cr = infoCard(body); infoNote(cr, "Map data", creditText((lvport::mountStorage() && mapview::s_provider->available()) @@ -4691,7 +4728,7 @@ 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, credits", onOpenSchemaPage, (void*)(uintptr_t)PG_ABOUT); + listRow(g, LV_SYMBOL_LIST " About", "Node, firmware, manual, credits", onOpenSchemaPage, (void*)(uintptr_t)PG_ABOUT); break; case 4: diff --git a/examples/companion_radio/ui-new/StatusScreen.h b/examples/companion_radio/ui-new/StatusScreen.h index 4880d60a..157c8f9b 100644 --- a/examples/companion_radio/ui-new/StatusScreen.h +++ b/examples/companion_radio/ui-new/StatusScreen.h @@ -7,6 +7,7 @@ #include "PopupMenu.h" #include "TabBar.h" #include "../MyMesh.h" +#include "../ui-core/SiteQr.h" #include "../ui-core/Diagnostics.h" #include "../ui-core/Battery.h" #if ENV_INCLUDE_GPS == 1 && defined(GPS_SKYVIEW) @@ -30,6 +31,7 @@ extern MyMesh the_mesh; // Mesh -- packet counts with a traffic line, nodes heard, contacts. // Hold Enter resets the counters. // System -- uptime, firmware, device, memory, queue, errors, a font card. +// Enter shows the QR code of the Solo site; any key closes it. // Replaces Tools › Diagnostics and Tools › Satellites. Drawn with InfoKit.h. class StatusScreen : public UIScreen { public: @@ -44,6 +46,7 @@ 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 PopupMenu _reset_menu; // Mesh, Hold Enter -> confirm CayenneLPP _lpp; unsigned long _lpp_at = 0; @@ -293,6 +296,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"); 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)); @@ -316,6 +320,42 @@ 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. + void renderQr(DisplayDriver& d) { + 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 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; + // 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; + const bool eink = d.isEink(); + 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); + 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"); + } + } + // All of a tab's blocks, drawn (or, through a measuring Flow, only counted). void layout(DisplayDriver& d, info::Flow& f, int rsv) { switch (tab()) { @@ -346,6 +386,7 @@ public: // Open on a given tab (Status page: Radio; the GPS shortcuts: GPS / Sky). void showTab(uint8_t t) { + _qr = false; 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. @@ -380,6 +421,7 @@ public: int render(DisplayDriver& display) override { display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); + if (_qr) { renderQr(display); return 1000; } tabbar::draw(display, _labels, _count, _cur); const int top = display.listStart(), bottom = display.height(); int next = 1000; @@ -415,6 +457,7 @@ public: } return true; } + if (_qr) { _qr = false; 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; } @@ -423,6 +466,7 @@ 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_CONTEXT_MENU && tab() == TAB_MESH) { _reset_menu.beginConfirm("Reset counters?", "Reset"); return true;