Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@
"title": "Space under the HUD on Android",
"body": "The HUD's buttons no longer sit on Android's navigation bar."
},
{
"id": "2026-09-27-noise-values",
"date": "2026-09-27",
"where": "app",
"title": "The noise floor as a number",
"body": "Noise cells show their median in dBm from zoom 16, and the HUD shows the latest reading. The sender moved beside the number."
},
{
"id": "2026-09-27-float-no-pause",
"date": "2026-09-27",
Expand Down
22 changes: 13 additions & 9 deletions app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,11 @@
</div>
<div id="rx-log" aria-label="Receptions" data-map-overlay></div>
<section id="hud" data-map-overlay>
<!-- Row 1 is the reading, row 2 the sender line (#618). Before the first
reception the reading is empty and the sender line says so, here in
the markup, so a slow config.json or module never shows two blank
rows. The line is senderReadout(null) as paintSender draws a note
(hudlayout.test.js). -->
<!-- Row 1 is the number and who it came from, row 2 the measurements
(#708, after #618). Before the first reception the numbers are empty
and the sender says so, here in the markup, so a slow config.json or
module never shows two blank rows. The line is senderReadout(null) as
paintSender draws a note (hudlayout.test.js). -->
<div id="hud-readout-row">
<!-- The direction arrow (#660): which way the sender is, relative to
where you are heading. Filled for an advertised position, outlined
Expand All @@ -118,15 +118,19 @@
<svg viewBox="-12 -12 24 24" aria-hidden="true"><polygon points="0,-10.6 6.57,8.27 0,4.45 -6.57,8.27" stroke-linejoin="round"/></svg>
</span>
<div id="hud-rssi"></div>
<div id="hud-snr"></div>
<div id="hud-since" title="Age of the reception the ticker points at"></div>
</div>
<div id="hud-sender-row">
<div id="hud-sender" class="empty" title="Sender of the reception the ticker points at, or the repeater it was heard via">No reception yet</div>
<!-- Receptions captured but not yet sent on. The MQTT dot answers
"is the socket open", which is a different question (#454). -->
<div id="hud-backlog" role="status" hidden></div>
</div>
<div id="hud-measure-row">
<div id="hud-snr"></div>
<!-- The companion's latest noise floor (#708), while the noise layer is
on. Live, not the shown reception's: the companion measures it
between packets. -->
<div id="hud-noise"></div>
<div id="hud-since" title="Age of the reception the ticker points at"></div>
</div>
<!-- The filtered/all stand shared with the ticker, and the quick actions
on the shown sender (#555). The closed eye appears while the filter
is keeping receptions off the HUD. -->
Expand Down
47 changes: 32 additions & 15 deletions app/src/__tests__/hudlayout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,28 +26,45 @@ const subject = (selector) => selector.split(/\s*[>+~]\s*|\s+/).filter(Boolean).
const rulesStyling = (id) => rules.filter((r) => r.selectors.some((s) => namesId(id, subject(s))))
const where = (r) => r.selectors.join(', ')

describe('the HUD readout rows (#637, #618)', () => {
describe('the HUD readout rows (#637, #618, #708)', () => {
// #637: SNR shared the row's deficit with the sender (min-width:0 and an
// ellipsis), so a long relay name cut it to "SNR -1…". The sender has its
// own row now, and the SNR keeps its digits. No rule may take that back.
it('never shrinks the SNR (#637)', () => {
const snr = rulesStyling('hud-snr')
expect(snr.length, 'app.css declares #hud-snr').toBeGreaterThan(0)
expect(snr.some((r) => /(^|[;\s])flex:\s*none\s*(;|$)/.test(r.body))).toBe(true)
for (const r of snr) {
expect(r.body, where(r)).not.toMatch(/text-overflow/)
expect(r.body, where(r)).not.toMatch(/(^|[;\s])min-width:\s*0/)
expect(r.body, where(r)).not.toMatch(/(^|[;\s])max-width:/)
expect(r.body, where(r)).not.toMatch(/(^|[;\s])flex:(?!\s*none\s*(;|$))/)
expect(r.body, where(r)).not.toMatch(/(^|[;\s])flex-shrink:(?!\s*0\s*(;|$))/)
// ellipsis), so a long relay name cut it to "SNR -1…". Since #708 the
// measurements have a row of their own, numbers of a known widest form, and
// none of them gives up digits. No rule may take that back.
for (const id of ['hud-snr', 'hud-noise']) {
it(`never shrinks #${id} (#637)`, () => {
const own = rulesStyling(id)
expect(own.length, `app.css declares #${id}`).toBeGreaterThan(0)
expect(own.some((r) => /(^|[;\s])flex:\s*none\s*(;|$)/.test(r.body))).toBe(true)
for (const r of own) {
expect(r.body, where(r)).not.toMatch(/text-overflow/)
expect(r.body, where(r)).not.toMatch(/(^|[;\s])min-width:\s*0/)
expect(r.body, where(r)).not.toMatch(/(^|[;\s])max-width:/)
expect(r.body, where(r)).not.toMatch(/(^|[;\s])flex:(?!\s*none\s*(;|$))/)
expect(r.body, where(r)).not.toMatch(/(^|[;\s])flex-shrink:(?!\s*0\s*(;|$))/)
}
})
}

// Kasper, 27 September (#708): who beside the number, the measurements
// under it. The sender is the only thing in row 1 that can give up width,
// and row 2 holds only numbers.
it('puts the number and who in row 1, the measurements in row 2', () => {
const html = readFileSync(new URL('../../index.html', import.meta.url), 'utf8')
const ids = (rowId) => {
const start = html.indexOf(`id="${rowId}"`)
const body = html.slice(start, html.indexOf('\n </div>', start))
return [...body.matchAll(/\bid="(hud-[\w-]+)"/g)].map((m) => m[1]).filter((id) => id !== rowId)
}
expect(ids('hud-readout-row')).toEqual(['hud-dir', 'hud-rssi', 'hud-sender', 'hud-backlog'])
expect(ids('hud-measure-row')).toEqual(['hud-snr', 'hud-noise', 'hud-since'])
})

// #hud is anchored to the bottom and the FAB rail sits a fixed distance above
// it (#264), so a row that grows moves every button. The direction arrow
// (#660) goes into row 1 and must not be able to do that.
it('fixes the height of both readout rows, so an arrow cannot move the FAB rail', () => {
for (const id of ['hud-readout-row', 'hud-sender-row']) {
for (const id of ['hud-readout-row', 'hud-measure-row']) {
const row = rulesStyling(id)
expect(row.some((r) => /(^|[;\s])height:\s*\d+(\.\d+)?px\s*(;|$)/.test(r.body)), `#${id} declares a px height`).toBe(true)
for (const r of row) {
Expand Down Expand Up @@ -126,7 +143,7 @@ describe('the HUD before app.js runs (#618)', () => {
expect(sender, 'index.html has #hud-sender holding text only').toBeTruthy()
expect(sender[1]).toMatch(/\bclass="([^"]*\s)?empty(\s[^"]*)?"/)
expect(sender[2]).toBe(senderReadout(null).text)
for (const id of ['hud-rssi', 'hud-snr', 'hud-since']) {
for (const id of ['hud-rssi', 'hud-snr', 'hud-noise', 'hud-since']) {
expect(slot(id), `index.html has #${id} holding text only`).toBeTruthy()
expect(slot(id)[2], `#${id}`).toBe('')
}
Expand Down
71 changes: 62 additions & 9 deletions app/src/__tests__/noise.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { buildStatsRadioRequest, parseStatsRadio, shouldSampleNoise, noiseSample, noiseCells, noiseFill, withNoise, noiseHexFC, NOISE_BANDS, CMD_GET_STATS, RESP_CODE_STATS, STATS_TYPE_RADIO } from '../noise.js'
import { buildStatsRadioRequest, parseStatsRadio, shouldSampleNoise, noiseSample, noiseCells, noiseFill, withNoise, noiseSpotFC, noiseRadius, noiseLabelItems, noiseReadout, NOISE_LIVE_MS, NOISE_SPOT_SPREAD, PX_PER_MERCATOR_M_Z0, NOISE_BANDS, CMD_GET_STATS, RESP_CODE_STATS, STATS_TYPE_RADIO } from '../noise.js'
import { INTERVAL_MS, MOVE_THRESHOLD_M } from '../autoping.js'

// STATS_TYPE_RADIO (docs/stats_binary_frames.md in meshcore-dev/MeshCore), 14
Expand Down Expand Up @@ -125,22 +125,75 @@ describe('the noise layer on the map (#410)', () => {
expect(withNoise(both, false)).toEqual({ ...both, noise: false })
})

it('draws one hex per cell, carrying its median', () => {
// Kasper, 28 September: the same cells and medians, drawn as soft spots
// rather than hexes. A hex read as one of the signal cells, while a noise
// floor is a property of the place around the sample. One blurred circle
// per cell at its centre, wider than the cell so neighbours flow together.
it('draws one spot per cell at its centre, carrying its median and the cell radius', () => {
const samples = [
{ lat: 51.84, lon: 5.85, noise_floor: -100, stationary: false, session: 's' },
{ lat: 51.84, lon: 5.85, noise_floor: -110, stationary: false, session: 's' },
{ lat: 52.0, lon: 5.0, noise_floor: -120, stationary: false, session: 's' },
]
const cellAt = (lat) => (lat > 51.9 ? 'far' : 'near')
const boundary = (id) => (id === 'near' ? [[0, 0], [0, 1], [1, 1], [0, 0]] : [[2, 2], [2, 3], [3, 3], [2, 2]])
const fc = noiseHexFC(samples, cellAt, boundary)
expect(fc.features.map((f) => f.properties)).toEqual([{ nf: -105, count: 2 }, { nf: -120, count: 1 }])
// hexBoundary answers [lat, lon]; GeoJSON wants [lon, lat].
expect(fc.features[0].geometry).toEqual({ type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] })
const centreOf = (id) => (id === 'near' ? [51.8401, 5.8499] : [52.0002, 5.0001])
const fc = noiseSpotFC(samples, cellAt, centreOf, 360)
expect(fc.features.map((f) => f.properties)).toEqual([{ id: 'near', nf: -105, count: 2, r: 360 }, { id: 'far', nf: -120, count: 1, r: 360 }])
// hexgrid answers [lat, lon]; GeoJSON wants [lon, lat].
expect(fc.features[0].geometry).toEqual({ type: 'Point', coordinates: [5.8499, 51.8401] })
})

it('skips a cell whose boundary does not parse', () => {
const fc = noiseHexFC([{ lat: 1, lon: 1, noise_floor: -100 }], () => 'x', () => null)
it('skips a cell whose centre does not parse', () => {
const fc = noiseSpotFC([{ lat: 1, lon: 1, noise_floor: -100 }], () => 'x', () => null, 360)
expect(fc.features).toEqual([])
})

// The radius follows the zoom so a spot stays the cell's size on the
// ground: r is in Web Mercator metres, and at zoom z a mercator metre is
// 512 * 2^z / (2 * pi * 6378137) px, so the expression doubles per zoom.
it('sizes a spot to its cell on the ground, wider by the spread, at every zoom', () => {
expect(NOISE_SPOT_SPREAD).toBe(1.3)
expect(PX_PER_MERCATOR_M_Z0).toBeCloseTo(512 / (2 * Math.PI * 6378137), 12)
expect(noiseRadius()).toEqual(['interpolate', ['exponential', 2], ['zoom'],
0, ['*', ['get', 'r'], NOISE_SPOT_SPREAD * PX_PER_MERCATOR_M_Z0],
24, ['*', ['get', 'r'], NOISE_SPOT_SPREAD * PX_PER_MERCATOR_M_Z0 * 2 ** 24]])
})
})

describe('the measured value, on the map and in the HUD (#708)', () => {
// A spot is a Point at the cell's centre ([lon, lat]), so the label sits
// where the spot is.
const cell = (id, nf, lon, lat) => ({ type: 'Feature', properties: { id, nf, count: 1, r: 360 }, geometry: { type: 'Point', coordinates: [lon, lat] } })

it('labels each cell with its median in whole dBm, at the centre of the cell', () => {
const fc = { type: 'FeatureCollection', features: [cell('a', -104.5, 1, 1), cell('b', -97.4, 11, 11)] }
expect(noiseLabelItems(fc, () => true)).toEqual([
{ id: 'a', label: '-104', lat: 1, lon: 1 },
{ id: 'b', label: '-97', lat: 11, lon: 11 },
])
})

it('labels only the cells whose centre is in view', () => {
const fc = { type: 'FeatureCollection', features: [cell('a', -104, 1, 1), cell('b', -97, 11, 11)] }
expect(noiseLabelItems(fc, (lat) => lat < 5).map((it) => it.id)).toEqual(['a'])
})

const T = 1_000_000
it('reads the latest value in the HUD while the layer is on and the companion answers', () => {
expect(noiseReadout({ on: true, connected: true, value: -104, at: T, now: T + 5000 })).toBe('Noise -104 dBm')
})

it('says nothing with the layer off, without a companion, or without a reading', () => {
expect(noiseReadout({ on: false, connected: true, value: -104, at: T, now: T })).toBe('')
expect(noiseReadout({ on: true, connected: false, value: -104, at: T, now: T })).toBe('')
expect(noiseReadout({ on: true, connected: true, value: null, at: T, now: T })).toBe('')
})

it('drops a reading older than three rounds, so a lost fix does not leave an old value standing', () => {
// The companion is only asked with a fix (noiseTick): without one the
// last value would stay on screen, looking live, for as long as it lasts.
expect(noiseReadout({ on: true, connected: true, value: -104, at: T, now: T + NOISE_LIVE_MS })).toBe('Noise -104 dBm')
expect(noiseReadout({ on: true, connected: true, value: -104, at: T, now: T + NOISE_LIVE_MS + 1 })).toBe('')
expect(NOISE_LIVE_MS).toBe(3 * INTERVAL_MS)
})
})
19 changes: 16 additions & 3 deletions app/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { backlogState } from './backlog.js'
import { mqttShouldRun, mqttAction } from './mqttlifecycle.js'
import { Publisher } from './publisher.js'
import { Gps, shouldNoticePoorFix, accuracyLabel, GPS_MAX_ACC_M, isUsableFix } from './gps.js'
import { buildStatsRadioRequest, parseStatsRadio, shouldSampleNoise, noiseSample } from './noise.js'
import { buildStatsRadioRequest, parseStatsRadio, shouldSampleNoise, noiseSample, noiseReadout } from './noise.js'
import { requestSelfInfo, radioSummary } from './selfinfo.js'
import { requestStatsCore, mvToPercent, isLowBattery } from './battery.js'
import { senderReadout } from './hudsender.js'
Expand Down Expand Up @@ -325,7 +325,7 @@ const state = {
battery: { mv: null, timer: null, failures: 0 },
// The noise-floor sampler (#410): the connection the samples belong to, the
// last sample's time and place for the rhythm, and the misses in a row.
noise: { session: null, last: null, failures: 0, busy: false },
noise: { session: null, last: null, failures: 0, busy: false, value: null, valueAt: null },
// The noise layer on the map (#410): a setting, kept across launches, and
// while it is on the samples it draws, which noiseTick appends to.
noiseLayer: { on: loadNoiseLayer(), samples: null },
Expand Down Expand Up @@ -1541,6 +1541,7 @@ async function renderTick() {
await drawOnce()
await trackTick()
noiseTick()
renderHudNoise()
setTimeout(renderTick, 1000)
}

Expand Down Expand Up @@ -1578,6 +1579,14 @@ async function applyNoiseLayer() {
if (state.map) state.map.setNoise(rows)
}

// The HUD's noise line (#708), every tick, so the layer's switch, a connect
// and a reading that goes stale all reach it within a second.
function renderHudNoise() {
const text = noiseReadout({ on: state.noiseLayer.on, connected: state.connected, value: state.noise.value, at: state.noise.valueAt, now: Date.now() })
const out = el('hud-noise')
if (out.textContent !== text) out.textContent = text
}

function noiseTick() {
const n = state.noise
if (!state.connected || !state.transport || n.busy || !n.session || n.failures >= NOISE_FAILURES_BEFORE_GIVING_UP) return
Expand All @@ -1588,6 +1597,10 @@ function noiseTick() {
n.busy = true
sendAndWait(buildStatsRadioRequest(), parseStatsRadio, NOISE_REPLY_TIMEOUT_MS)
.then(async (r) => {
// The HUD shows the latest answer (#708): a miss or the firmware's 0
// (not measured yet) clears it rather than leaving an old value up.
n.value = r ? r.noiseFloor : null
n.valueAt = now
if (!r) { n.failures++; return }
n.failures = 0
const sample = noiseSample({ noiseFloor: r.noiseFloor, fix, session: n.session, rxPubkey: state.rxPubkey, nowMs: now, last: n.last })
Expand Down Expand Up @@ -2312,7 +2325,7 @@ async function connectAll() {
const info = await requestSelfInfo(state.transport, 'core-hunter')
state.rxPubkey = info.pubkey.toLowerCase()
// A noise session is one connection (#410, Kasper 2026-09-25).
state.noise = { session: new Date().toISOString(), last: null, failures: 0, busy: false }
state.noise = { session: new Date().toISOString(), last: null, failures: 0, busy: false, value: null, valueAt: null }
state.name = info.name || ''
state.sf = info.sf ?? null
// The rest of the radio (#650): what the companion reports, for the Status
Expand Down
2 changes: 1 addition & 1 deletion app/src/filtersheet.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function filterSheetMarkup({ types, idClasses }) {
<input type="checkbox" id="fs-noise" />
<span>Noise floor</span>
</label>
<p class="ss-hint">Colours the cells by the noise your companion measured there, instead of by signal. Loud cells explain a stretch with nothing heard.</p>
<p class="ss-hint">Shows the noise your companion measured, as soft spots in place of the signal cells. A loud spot explains a stretch with nothing heard.</p>
</div>
<div class="fs-foot">
<span class="fs-live">Changes apply immediately</span>
Expand Down
Loading
Loading