diff --git a/.github/workflows/build-solo-sim.yml b/.github/workflows/build-solo-sim.yml new file mode 100644 index 00000000..72e35275 --- /dev/null +++ b/.github/workflows/build-solo-sim.yml @@ -0,0 +1,67 @@ +name: Build Solo Sim (WASM) + +# Builds the companion_radio browser sim (real firmware compiled to WASM via +# Emscripten, see variants/sim/) and attaches it to the same draft release +# build-solo-firmwares.yml creates for this tag, as a single fixed-name +# asset: solo-sim-wasm.zip. Fixed name (no version in it) so +# meshcore-solo-site's server-side deploy script can always fetch it from +# the stable "latest release" download URL without knowing the tag -- +# see meshcore-solo-site/scripts/fetch-wasm-release.sh. +# +# Independent job/workflow from the hardware firmware builds: different +# toolchain (Emscripten, not PlatformIO), and a WASM build failure shouldn't +# block or delay the actual firmware release. + +permissions: + contents: write + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +jobs: + build-sim-wasm: + runs-on: ubuntu-latest + steps: + - name: Clone Repo + uses: actions/checkout@v4 + + - name: Cache emsdk + uses: actions/cache@v4 + with: + path: variants/sim/tools/emsdk + key: emsdk-6.0.9-${{ runner.os }} + + - name: Install emsdk 6.0.9 + run: | + if [ ! -x variants/sim/tools/emsdk/upstream/emscripten/em++ ]; then + rm -rf variants/sim/tools/emsdk + git clone --depth 1 https://github.com/emscripten-core/emsdk.git variants/sim/tools/emsdk + cd variants/sim/tools/emsdk + python3 ./emsdk.py install 6.0.9 + python3 ./emsdk.py activate 6.0.9 + else + echo "emsdk 6.0.9 already cached" + fi + + - name: Build companion WASM + run: variants/sim/build_wasm.sh + + - name: Build repeater WASM + run: variants/sim/build_wasm_repeater.sh + + - name: Package + run: | + mkdir -p out/wasm/companion out/wasm/repeater + cp variants/sim/web/build/meshcore_sim.js variants/sim/web/build/meshcore_sim.wasm out/wasm/companion/ + cp variants/sim/web/build/repeater/meshcore_sim_repeater.js variants/sim/web/build/repeater/meshcore_sim_repeater.wasm out/wasm/repeater/ + cd out && zip -r solo-sim-wasm.zip wasm + + - name: Attach to release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + draft: true + files: out/solo-sim-wasm.zip diff --git a/examples/companion_radio/GeoUtils.h b/examples/companion_radio/GeoUtils.h index 9f823c11..32264d03 100644 --- a/examples/companion_radio/GeoUtils.h +++ b/examples/companion_radio/GeoUtils.h @@ -63,11 +63,17 @@ static inline void fmtDist(char* buf, int n, float km, bool imperial) { // Compact age tag for a timestamp, e.g. "12s" / "5m" / "3h" / "2d" — sized to // sit inline after a name (unlike a full "X ago" sentence). Empty string for -// an unknown (0) or future timestamp. Takes `now` rather than reading the RTC -// itself, so this stays a pure function like the rest of this file. +// a genuinely unknown (0) timestamp (callers like NearbyScreen::fmtAge() rely +// on this to show "unknown" for a contact never actually heard from, which a +// clamped "0s ago" would misreport as just-seen). A timestamp slightly ahead +// of `now` (sender/receiver clock skew -- e.g. an incoming message whose +// sender's clock runs a little fast, or ours hasn't synced yet) clamps to +// "0s" instead of going blank, since it did just arrive. Takes `now` rather +// than reading the RTC itself, so this stays a pure function like the rest +// of this file. static inline void fmtAgeShort(char* buf, int n, uint32_t now, uint32_t lastmod) { - if (lastmod == 0 || now < lastmod) { buf[0] = '\0'; return; } - uint32_t age = now - lastmod; + if (lastmod == 0) { buf[0] = '\0'; return; } + uint32_t age = (now > lastmod) ? (now - lastmod) : 0; if (age < 60) snprintf(buf, n, "%us", (unsigned)age); else if (age < 3600) snprintf(buf, n, "%um", (unsigned)(age / 60)); else if (age < 86400) snprintf(buf, n, "%uh", (unsigned)(age / 3600)); diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index bd8c0eb7..e08d09c4 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -460,19 +460,24 @@ class MessagesScreen : public UIScreen { } // Selection frame for one history message box, shared by the DM/room and - // channel history lists. Selected = solid fill; unselected = outline with a - // filled header strip. Leaves the ink DARK (for the sender row drawn next). + // channel history lists. Selected = solid fill, dark text (the same + // drawSelectionRow() convention every other list in the UI uses). Unselected + // = plain black box (like the rest of the UI) with a thin light frame + // separating it from its neighbours -- no filled header strip, which used + // to leave a patch of light background on every bubble whether selected or + // not, unlike anywhere else in the UI. Leaves the ink colour set correctly + // for the sender row drawn next (and everything after it, until something + // else changes it): LIGHT for the whole unselected box, DARK for selected. // Spans exactly [box_x, box_x+box_w) — the caller sizes/positions the bubble // (see computeBubbleBox below), this just draws whatever box it's given. static void drawHistRowFrame(DisplayDriver& d, int box_x, int box_w, int y, int bh, int lh, bool sel) { d.setColor(DisplayDriver::LIGHT); if (sel) { d.fillRect(box_x, y, box_w, bh); + d.setColor(DisplayDriver::DARK); } else { d.drawRect(box_x, y, box_w, bh); - d.fillRect(box_x + 1, y + 1, box_w - 2, lh); } - d.setColor(DisplayDriver::DARK); } // Width of an ack/delivery glyph (see drawAckGlyph) — needed up front to size @@ -1560,14 +1565,23 @@ public: const char* body = skipReplyPrefix(dmDisplayParts(e, is_room, filtered_name, sender_buf, sizeof(sender_buf))); const char* sender = sender_buf; - char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, e.timestamp); + // e.timestamp==0 shouldn't happen (storeDMMsg() falls back to receipt + // time at write time), but if it ever does, show the receipt time + // (now_ts) rather than leave the row with no age at all. + char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, e.timestamp ? e.timestamp : now_ts); int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; // Size the bubble to its own content before drawing anything (see // computeBubbleBox): the header (sender+ack+age) vs the body, measured // once here and reused below instead of re-wrapping. int full_avail = display.width() - reserve; - int ack_w = e.outgoing ? (3 + ackGlyphWidth(display, _history.dmEffectiveStatus(e), e.attempt + 1)) : 0; + // Incoming: the hop count the DM actually took to reach us, shown as + // the same tiny digit icon an outgoing send uses for its relay/echo + // count -- no ack glyph exists for incoming (there's nothing to + // deliver), so this slot is otherwise empty. + int in_hop_count = !e.outgoing ? (e.path_len & 63) : 0; + int ack_w = e.outgoing ? (3 + ackGlyphWidth(display, _history.dmEffectiveStatus(e), e.attempt + 1)) + : (in_hop_count > 0 ? (3 + miniIconNumberWidth(display, in_hop_count)) : 0); int header_w = 3 + display.getTextWidth(sender) + ack_w + age_w + 3; int body_w, nl = 0; if (portrait_expand) { @@ -1590,9 +1604,13 @@ public: if (e.outgoing) { // delivery marker after "Me" int gx = box.x + 3 + display.getTextWidth(sender) + 3; drawAckGlyph(display, gx, y + 1, _history.dmEffectiveStatus(e), e.attempt + 1); + } else if (in_hop_count > 0) { // hop count after the sender name + int gx = box.x + 3 + display.getTextWidth(sender) + 3; + miniIconDrawNumber(display, gx, y + 1, in_hop_count); } if (age[0]) { display.setCursor(box.x + box.w - age_w, y + 1); display.print(age); } - if (!sel) display.setColor(DisplayDriver::LIGHT); + // Ink is already LIGHT (unselected) or DARK (selected) from + // drawHistRowFrame above, and nothing since has changed it. if (portrait_expand) { for (int li = 0; li < nl; li++) { display.setCursor(box.x + 3, y + (li + 1) * lh + 1); display.print(s_wrap_lines[li]); } } else { @@ -1751,7 +1769,11 @@ public: msg_part[sizeof(msg_part) - 1] = '\0'; } - char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, _history.chAtPos(ring_pos).timestamp); + // See the DM history block above: 0 shouldn't happen (addChannelMsg() + // also falls back to receipt time at write time), but fall back to + // now_ts here too rather than leave the row with no age at all. + uint32_t ch_ts = _history.chAtPos(ring_pos).timestamp; + char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, ch_ts ? ch_ts : now_ts); int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; const char* body = skipReplyPrefix(msg_part); @@ -1763,8 +1785,12 @@ public: bool outgoing = strcmp(sender, "Me") == 0; bool show_ack = outgoing && _history.chAtPos(ring_pos).relay_status == ACK_OK; int relay_count = show_ack ? (_history.chAtPos(ring_pos).path_len & 63) : 0; + // Incoming: the hop count this post actually took to reach us, shown + // the same way an outgoing post's repeater-echo count is. + int in_hop_count = !outgoing ? (_history.chAtPos(ring_pos).path_len & 63) : 0; int full_avail = display.width() - reserve; - int ack_w = show_ack ? (3 + ackGlyphWidth(display, ACK_OK, 1, relay_count)) : 0; + int ack_w = show_ack ? (3 + ackGlyphWidth(display, ACK_OK, 1, relay_count)) + : (in_hop_count > 0 ? (3 + miniIconNumberWidth(display, in_hop_count)) : 0); int header_w = 3 + display.getTextWidth(sender) + ack_w + age_w + 3; int body_w, nl = 0; if (portrait_expand) { @@ -1785,9 +1811,13 @@ public: if (show_ack) { int gx = box.x + 3 + display.getTextWidth(sender) + 3; drawAckGlyph(display, gx, y + 1, ACK_OK, 1, relay_count); + } else if (in_hop_count > 0) { + int gx = box.x + 3 + display.getTextWidth(sender) + 3; + miniIconDrawNumber(display, gx, y + 1, in_hop_count); } if (age[0]) { display.setCursor(box.x + box.w - age_w, y + 1); display.print(age); } - if (!sel) display.setColor(DisplayDriver::LIGHT); + // Ink is already LIGHT (unselected) or DARK (selected) from + // drawHistRowFrame above, and nothing since has changed it. if (portrait_expand) { for (int li = 0; li < nl; li++) { display.setCursor(box.x + 3, y + (li + 1) * lh + 1); display.print(s_wrap_lines[li]); } } else { @@ -2305,11 +2335,12 @@ public: if (_dm_hist_sel >= _dm_hist_scroll + _hist_visible) _dm_hist_scroll = _dm_hist_sel - _hist_visible + 1; } else if (_dm_hist_sel == dm_count - 1) { - // Oldest (top of the list) -> wrap to the compose row, the list's - // own bottom-most stop -- same ring-wrap every other list in the UI - // does at its ends, just closing the loop through the compose row - // instead of straight back to index 0. - _dm_hist_sel = -1; + // Oldest (top of the list) -> wrap straight to the newest (bottom + // of the list), same ring-wrap every other list in the UI does at + // its ends -- the compose row sits below the list itself and is + // not part of this wrap. + _dm_hist_sel = 0; + _dm_hist_scroll = 0; } return true; } @@ -2399,8 +2430,9 @@ public: _hist_sel++; if (_hist_sel >= _hist_scroll + _hist_visible) _hist_scroll = _hist_sel - _hist_visible + 1; } else if (_hist_sel == ch_hist_count - 1) { - // Oldest -> wrap to the compose row, same as the DM history handler. - _hist_sel = -1; + // Oldest -> wrap straight to the newest, same as the DM history handler. + _hist_sel = 0; + _hist_scroll = 0; } updateChannelUnread(); return true; diff --git a/release-notes.md b/release-notes.md index 9e09d2ae..7332148f 100644 --- a/release-notes.md +++ b/release-notes.md @@ -1,3 +1,23 @@ +## MeshCore Solo Companion Firmware v1.28 + +### What's new + +- **The Clock/Lock dashboard gets a separate "Altitude (GPS)" field**, alongside the existing barometric one (now labelled "Altitude (Baro)") — the original single Altitude field only ever read a barometric sensor's telemetry, showing `--` on any board without one even with a perfectly good GPS fix. +- **Received messages now show how many hops they actually took to reach you**, right in the message list — the same tiny digit-icon a sent message already uses for its repeater/echo count, now shown for incoming DMs and channel posts too, using the hop path the mesh already records for them. + +### Fixes + +- **Every altitude reading (baro + GPS, Clock and Lock screen) and the GPS home page's "alt" row now respect Settings › System › Units**, same as every other distance in the UI — none of them checked metric/imperial before. Locator's Radius row had the same gap and is fixed the same way. +- **The Radio home page's noise floor showed "n/a" whenever Pwr save (RX duty-cycle) was on**, on the assumption duty-cycle RX can't sample it. That's stale — the radio already recalibrates it periodically even under duty-cycle, which is why Diagnostics' own reading was already showing something real. Radio home page now matches. +- **"Msg wake" moved from Settings › Sound to Settings › Display** — a more logical home for a display-wake toggle than notifications. Menu order only; the setting itself is unaffected. +- **Remote Bot's channel replies (trigger reply, channel command reply, `!gps` fix result) never showed which repeaters echoed them back**, unlike every other way of posting to a channel. Bot-originated posts now arm the same relay tracking an app or on-device send already gets. +- **Creating a new message channel didn't refresh the channel list until leaving and re-entering the screen.** Contributed by @3urobeat. +- **The DM and channel history lists didn't wrap at their ends like every other list in the UI.** UP at the oldest message now wraps straight to the newest, and DOWN past the newest wraps to the compose row and back to the oldest, closing the loop both ways. +- **Unselected message bubbles had a stray light-filled header strip behind the sender name**, the one place in the UI with a filled patch on an otherwise plain row. Unselected bubbles are now plain (matching every other unselected row); the selected fill is unchanged. +- **A received message could show no time at all — just the sender name and hop count** — whenever its timestamp read slightly ahead of the device's own clock (sender/receiver clock skew, or the receiver's clock not synced yet) or was genuinely unknown. Both cases now show the receipt time instead of going blank. + +--- + ## MeshCore Solo Companion Firmware v1.27 ### What's new