mirror of
https://github.com/meshtastic/firmware.git
synced 2026-11-06 06:14:45 -05:00
* LoRa LBT: fix SX128x/SX126x CAD encoding + slot model, ESP32 sleep race Three fixes from the "are we really listening?" LBT audit: - SX128x CAD: pass RADIOLIB_SX128X_CAD_ON_4_SYMB instead of the raw count 4. The raw value set bits 7:5 = 000, i.e. a 1-symbol scan, leaving LBT nearly blind on 2.4 GHz. Now runs the intended 4-symbol CAD. - SX126x CAD / slot time: name the symNum value explicitly as CAD_ON_4_SYMB (identical 0x02 raw value, so no on-air change) and add a virtual getCadSymbolCount() so computeSlotTimeMsec() models the real ~4.5-symbol scan for the SX126x/LLCC68/STM32WL family only, leaving LR11x0/SX127x at their correct 2.5 symbols. Fixes the too-short contention-window slots. - ESP32 light sleep: if the DIO1/RF95_IRQ line is already asserted just before esp_light_sleep_start(), skip the sleep so the main loop services the pending packet instead of stranding it for up to a full sleep chunk (the detachInterrupt-clears-the-GPIO-wake disarm race). clod helped too * LoRa LBT: keep software-poll preamble flags latched while idle (anti-stomp) Finding #10 from the "are we really listening?" audit, LORA_DIO1_SOFTWARE_POLL boards (meshnology-w10). The 1 ms poll cleared latched PREAMBLE_DETECTED|HEADER_VALID whenever they were the only bits set, so for the rest of a reception isActivelyReceiving() read idle. A TX queued during the payload phase then passed the send gate and startTransmit overwrote the shared chip buffer, losing the inbound packet. That clear only exists to stop a noise preamble from permanently blocking our own TX (starvation). Starvation can only happen when we actually have something to send, so gate the clear on a non-empty txQueue. While the queue is empty the bits stay latched, isActivelyReceiving() reports the in-progress packet, and a later TX defers instead of stomping it. A queued TX still gets the escape hatch. The poll cadence is a fixed 1 ms reschedule either way, so latched bits don't spin the loop; readData() clears everything once a real RX_DONE arrives. clod helped * LoRa LBT: optional RSSI energy second-gate before TX (default off) Findings #4/#5 of the "are we really listening?" audit. CAD only detects a preamble. A neighbour's packet whose preamble already aired - during our own TX, a CAD scan, or a standby gap - is mid-payload and invisible to CAD, so we key up on top of it. Add an RSSI energy check as a second listen-before- talk gate in the TX-decision path: channelBusyByRSSI() compares the instantaneous in-RX RSSI against the rolling noise floor (getNoiseFloor() + margin) and defers if the channel is hot. It reads RSSI before isChannelActive()'s setStandby() via the || short-circuit, so the sample is taken while still in RX where GET_RSSI_INST is valid - no mode juggling. Orthogonal to CAD: CAD has processing gain and catches weak preambles below the noise floor; RSSI catches strong energy at any packet phase, including the mid- payload case CAD is blind to. Neither subsumes the other. DEFAULT OFF. A mis-set threshold makes a node read "busy" on noise and stop transmitting entirely, which is worse than the bug, so it must be tuned on hardware before it ships live. Compiled out unless LORA_RSSI_LBT_MARGIN_DB is defined at build time; when off it is a compile-time `return false` (zero behaviour change). Fails open: returns not-busy unless in RX with a full noise-floor window and a sane dBm reading, so it can never persistently silence a node. Native build (gate off) links clean. clod helped * LoRa LBT: RSSI margin profiler to tune RSSI_LBT_MARGIN_DB (default off) Instrumentation to pick the #4/#5 RSSI second-gate margin from measured data instead of guessing. Build with -DLORA_RSSI_LBT_PROFILE; compiled out otherwise. It accumulates (RSSI - rolling noise floor) in two channel states: - idle (reuses updateNoiseFloor()'s existing RX sample, no extra SPI): the noise- jitter distribution. Its high tail is the floor the margin must clear. - signal (decoded-packet RSSI, hooked in handleReceiveInterrupt): the real-traffic rise. Its low tail is the ceiling the margin must stay under. A throttled log dump prints both distributions (min/mean/max + percentiles) and the derived usable window (idle p99, signal p05). Placement: the stats/histogram engine is a dependency-free, radio-agnostic header (RssiProfiler.h) so it stays out of the core file and is testable on its own; the two hooks and the RSSI/log I/O live on RadioLibInterface where the state is. Independent of the gate flag (LORA_RSSI_LBT_MARGIN_DB) on purpose: profile first to choose the margin, then build the gate on. Native build (profiler on) compiles clean. clod helped * docs: describe the RSSI LBT margin profiler Feature doc for the RSSI listen-before-talk margin profiler (-DLORA_RSSI_LBT_PROFILE): what it measures (idle noise-jitter vs decoded-signal distributions of RSSI-minus-noise-floor), how it's wired (RssiProfiler.h engine + RadioLibInterface hooks, idle sample reused from updateNoiseFloor), how to build and run it, and how to read the log output to choose RSSI_LBT_MARGIN_DB. clod helped * LoRa LBT: draft #3/#6 fixes behind MESHTASTIC_RADIOLIB_HAS_RESUME_RECEIVE (inactive) Firmware side of the CAD->RX handoff (#3) and ISR_RX re-arm (#6) from the LBT audit. Both need to re-arm RX WITHOUT the standby() that startReceive() forces (which aborts an in-flight reception) - unavailable in stock RadioLib, provided by resumeReceive() in NomDeTom/RadioLib@rxResume. Guarded by MESHTASTIC_RADIOLIB_HAS_RESUME_RECEIVE, left UNDEFINED: against stock RadioLib these paths compile out and behaviour is unchanged. Define it (via build_flags) only when linking a RadioLib that has resumeReceive(). - resumeReceiveInPlace(bool startRx): base-class helper - iface->resumeReceive() (remap RX IRQ, optional SetRx, no standby) + re-attach the MCU ISR + base RX bookkeeping. One place, chip-agnostic via iface. - #3: SX126x/LR11x0 isChannelActive() CAD exitMode -> GOTO_RX; the TX-decision path splits the busy check into rssi/cad flags and, on a CAD detect, hands the packet to RX via resumeReceiveInPlace(false) instead of startReceive(). Macro-off, the split is exactly the old `channelBusyByRSSI() || isChannelActive()` short-circuit. - #6: ISR_RX re-arms via resumeReceiveInPlace(true). Open (flagged in-code, for review + HW validation): whether re-issuing SetRx (#6) disturbs an on-chip in-flight reception, and the #3 handoff's interaction with a beacon-target packet on the home config. clod helped * LoRa LBT #4: synthesize GPIO wake cause when light sleep is skipped doLightSleep() skips esp_light_sleep_start() when a LoRa IRQ is already asserted, but still read esp_sleep_get_wakeup_cause() unconditionally - so the skip path returned the previous real sleep's cause (e.g. TIMER) and missed the ESP_SLEEP_WAKEUP_GPIO branch that sets runASAP. Capture whether we skipped and return a synthetic GPIO cause in that case so PowerFSM takes the packet-service path. Reads the ESP-IDF cause only after an actual sleep. clod helped too * LoRa LBT #10: keep HEADER_VALID latched in the software IRQ poll The LORA_DIO1_SOFTWARE_POLL anti-starvation escape hatch cleared both PREAMBLE_DETECTED and HEADER_VALID whenever a TX was queued. HEADER_VALID is evidence of a real inbound frame, so clearing it made isActivelyReceiving() read idle mid-payload and let the queued TX stomp the frame. Gate the hatch on a bare-preamble IRQ (PREAMBLE set, HEADER_VALID clear) and clear only PREAMBLE_DETECTED. A stuck header still can't block TX forever - receiveDetected() ages it out after maxPacketTimeMsec - and readData() clears everything on RX_DONE. clod helped too * LoRa LBT #3/#6: CAD->RX handoff + continuous-RX re-arm on stock RadioLib Reimplements the two audit fixes that previously needed the NomDeTom/RadioLib @rxResume fork (MESHTASTIC_RADIOLIB_HAS_RESUME_RECEIVE), using only stock RadioLib, behind a new default-off flag MESHTASTIC_LBT_CAD_TO_RX. Per the RadioLib maintainer's feedback, no new RX API is needed. (GOTO_RX) with the RX IRQs pre-mapped in the scan config, so the chip's own CAD->RX transition delivers RX_DONE with no library call in between. irqFlags (status-enable) carries the CAD verdict + full RX set incl. PREAMBLE/HEADER so isActivelyReceiving() can read them; irqMask (DIO trigger) carries the CAD verdict + terminal RX events only - NOT PREAMBLE/HEADER, which would fire the ISR mid-frame and run readData() on an incomplete packet. On detection it hands off in place: clear the latched CAD verdict, re-attach the MCU ISR, mark receiving - no standby (which would abort the packet just detected). standby), so re-attach the MCU ISR instead of re-issuing SetRx (which could disturb a second packet already arriving on-chip). Removes resumeReceiveInPlace() and the fork macro. SX126x-only: LR11x0's startChannelScan conflates irqFlags/irqMask (drives the DIO from irqFlags), so it keeps the safe GOTO_STDBY path, re-arming on detect to stay coherent with onNotify when the flag is on. Default-off: needs on-hardware validation and assumes continuous RX (the default preamble=16/minSymbols=8 degenerates startReceiveDutyCycleAuto to continuous). Native builds pass both flag-off and flag-on. clod helped too * LoRa LBT #1: re-arm RX unconditionally when deferring a busy channel The post-CAD/RSSI defer path wrapped its startReceive() re-arm in a !hasTargetRadioSettings(txp) guard, so a beacon-target packet that hit a busy channel took setTransmitDelay() with the radio left in standby. When the beacon's target config coincides with the home config the radio is on the home channel, so the node went deaf on home traffic until the next TX cycle - and kept skipping the re-arm every cycle the channel stayed busy. Drop the guard: re-arm unconditionally and keep listening while we wait to key up. A beacon's job is to get a packet out, but it should still mirror the main RX path. (No effect under MESHTASTIC_LBT_CAD_TO_RX, where the chip is already left in RX.) clod helped too * docs: fix RSSI profiler fences (MD040) + RAM figure, note negative-bin limit - Tag the formula and sample-output fenced blocks as `text` so markdownlint MD040 passes. - Correct the RAM figure: RssiDeltaStats is 148 bytes (12 scalar + 34*4 histogram), so two instances are ~296 bytes, not ~280. - Add a limitations note that all negative deltas collapse into bin[0], so a percentile landing there returns the minimum sample. This does not affect the margin (keyed off the always-positive idle p99) but can understate signal p05 when a node decodes below-floor packets; never biases toward a smaller margin. clod helped too * docs: RSSI profiler - record histogram bucketing design choices Add a "Histogram bucketing" section laying out why a fixed histogram is used over exact percentiles, what the axis has to resolve (the margin window sits at roughly -a few..+20 dB), and the arguments for five bucketing schemes: the current positive-only layout (A), a cost-neutral re-centred layout with negative-range resolution (B, recommended if signal p05 must be trusted), wider full-range bins (C), more bins (D), and exact/off-box (E). Explains the current choice and when to switch. clod helped too * LoRa LBT: move RSSI profiler + energy second-gate to listening-now-rssi Split the RSSI work off this branch so the active CAD/sleep fixes and the #3/#6 CAD->RX rework can land without the tuning-dependent RSSI pieces. Removed here (preserved intact on branch listening-now-rssi): - RSSI margin profiler: RssiProfiler.h, the LORA_RSSI_LBT_PROFILE hooks, and docs/rssi_lbt_margin_profiler.md. - RSSI energy second-gate: channelBusyByRSSI() and the LORA_RSSI_LBT_MARGIN_DB gate. The pre-existing noise-floor sampler (updateNoiseFloor/getNoiseFloor, already on develop and consumed by telemetry) is untouched. The TX-decision block reverts to a plain CAD scan; #3/#6 (behind MESHTASTIC_LBT_CAD_TO_RX) and #1 unchanged. Native build green. clod helped too * reduce to bare essentials * LoRa LBT: calibrate CAD symbol count and detPeak against vendor data NUM_SYM_CAD carried two incompatible meanings: computeSlotTimeMsec() read it as a literal symbol count, while the drivers passed it into scanChannel(), where symNum is a register encoding on SX126x/SX128x. On SX126x the raw 2 encodes CAD_ON_4_SYMB, so the chip has scanned 4 symbols since #5749/#5772 while the slot time was priced at 2.5 - a 1.8x mis-scaling of the CSMA backoff unit, live since January 2025. Give the constant one meaning (a literal count of 4) and have each driver translate at its own call site. getCadSymbolCount() reports the real scan length so slotTimeMsec matches it; RF95 overrides it because SX127x CAD is fixed in hardware and its legacy scanChannel() takes no symNum. AN1200.48 Table 1 recommends 4 symbols for SF9-SF12, which is where most shipped presets sit, so 4 is the right target. The comment claiming 2 was reading that note's SF7/SF8 row. detPeak is calibrated per (SF, symbol count), and on LR11x0 per bandwidth as well. Both LR drivers ran thresholds meant for a 2-symbol scan, leaving them less sensitive than measured-best and biased toward missed detections. Now sourced explicitly: LR20x0 LR20xx datasheet rev 2.2 Table 6-19, indexed by symbols x SF LR11x0 Semtech SWSD003 lr11xx/apps/cad/main_cad.c optimized_parameters, indexed by symbols x bandwidth x SF SX126x RadioLib's SF+13, which sits at or below every AN1200.48 value and so errs toward deferring rather than transmitting Combinations the vendor data does not characterise fall back to the RadioLib default rather than guessing. LR2021 also pins fastCad, which scanChannel() reads off the radio object rather than the config struct. Also fix the 2.4 GHz slot term: (2 * sf + 3) / 32 is integer division that truncates to 0 for every legal SF, so the AN1200.22 fractional term never contributed anything. Not hardware-verified. * LoRa LBT: drop audit-doc pointers and cross-chip asides from comments The LBT comments referenced an external audit document by item number (#1, #3, #3/#6, #10, and a see-.notes/lbt pointer), which means nothing to anyone reading the tree on its own. They also described what sibling drivers do, which goes stale silently the moment one of them changes and belongs in that driver's own file anyway. Keep the reasoning, drop the pointer and the comparison. Where a comment explained a rule that genuinely spans drivers - NUM_SYM_CAD's register- encoding caveat - state it as a rule rather than a roll-call of chips. No behaviour change; comments only. * LoRa LBT: don't index the LR11x0 CAD table with a 2.4 GHz bandwidth The detPeak lookup picked its bandwidth row by range, so wideLora's 406.25 kHz fell into the sub-GHz 500 kHz row - SWSD003's table is the sub-GHz LoRa modem (BW enums 62.5-500), and 2.4 GHz is a separate modem with its own calibration. Match the four bandwidths exactly instead, so anything unmatched falls back to the RadioLib default rather than borrowing a row it has no claim to. Those four are the whole sub-GHz set today - LR11x0::setBandwidth() rejects anything else - so note that a newly unlocked bandwidth, or a symbol count outside 2/4/8/16, needs a row adding here and fails silently if it doesn't get one. Same note on LR20x0 for symbol counts above 4. Also correct two citations. The 2.4 GHz slot term is the SX1280 datasheet's own CAD duration formula, and the CAD symbol-count guidance is AN1200.77, not AN1200.22 - which is "LoRa Modulation Basics" from 2015 and predates the part. Record that SX1280 ignores detPeak/detMin/exitMode entirely: SetCadParams carries only cadSymbolNum, and the PNR threshold lives in register 0x942. * LoRa LBT: set the SX1280 CAD PNR threshold and scan 8 symbols SetCadParams on this part carries only cadSymbolNum - detPeak/detMin in the scan config are inert - so CAD sensitivity is governed entirely by register 0x942, which nothing was writing. The scan therefore ran at the chip's reset value of 0x32, against the 19-36 that AN1200.77 Table 1 calibrates per SF and bandwidth: at LONG_FAST wideLora the recommendation is 27 and we were using 50. A threshold that high needs a much stronger correlation peak, so 2.4 GHz LBT was under-detecting on top of the symbol-count bug already fixed. Write the table value before each scan, indexed by bandwidth x SF. Unmatched bandwidths write nothing and leave the chip's default, as on the LR parts. This goes through our own Module rather than lora.writeRegister(), which RadioLib keeps protected unless RADIOLIB_LOW_LEVEL unlocks raw register access across every driver. SX128x::writeRegister() is a straight SPIwriteRegisterBurst on the same Module we already own, and begin() has pointed spiConfig at the 0x18 WriteRegister opcode long before any scan runs, so the transaction is identical - no subclass needed. Also raise the CAD window from 4 to 8 symbols, matching RadioLib's own SX128x default. AN1200.77 measures a longer window as moving P(Detection) further from P(False Detection); its thresholds are stated to hold for any window duration, so the table above is unaffected. NUM_SYM_CAD_24GHZ moves in step so the slot time still reflects the real scan. Not hardware-verified. * LoRa LBT: hand CAD straight to RX on the LR parts CAD exited to standby, and the busy-channel path then issued a full startReceive() - which standbys the radio and re-enters RX from scratch, throwing away the reception CAD had just detected. Both LR families expose a CAD-to-RX exit mode that avoids the round trip, and it reaches the chip through the public startChannelScan(cfg), so nothing here needs a library patch. A note in an earlier commit claimed this was impossible because RadioLib collapses irqFlags and irqMask into one set. That reasoning is SX126x's, where irqMask is a status gate and an event omitted from it never latches at all. The LR parts have no such gate: SetDioIrqParams takes a mask per interrupt pin and SetDioIrqConfig maps IRQs to one DIO, while getIrqStatus() reads the status bytes returned on every SPI transaction and consults no pin mapping. So the CAD IRQ set now carries the verdict plus terminal RX events only - preamble and header stay off the pin, where they would fire the ISR mid-frame and run readData() on a partial packet - and isActivelyReceiving() still sees them. rearmReceive() cannot be unconditionally standby-free the way SX126x's is. cad_timeout 0 means "RX until one packet arrives, then standby", so the two re-arm paths leave the chip in different states: already listening after the handoff, back in standby after that packet's RX_DONE. It branches on a flag set at detection and cleared on use, so the first re-arm skips the standby and the next restores continuous RX. Forcing continuous RX through the timeout instead is not reliable - RadioLib converts it to RTC ticks through a float divide, so the 0xFFFFFF "no timeout" encoding is not addressable. On a CAD false positive the chip now waits in single-shot RX rather than returning to standby. Still listening, but a state the old code never entered. Not hardware-verified. * LoRa LBT: size the 2.4 GHz CAD slot per chip, not per band computeSlotTimeMsec() branches on myRegion->wideLora, which is a region rather than a part. LR1120 and LR2021 both run there, and both scan NUM_SYM_CAD symbols - but that branch read the flat NUM_SYM_CAD_24GHZ, so raising it to 8 for SX128x moved the slot for two families whose scan never changed. At LONG_FAST wideLora that priced ~22 ms of CAD against a ~10 ms scan. getCadSymbolCount() exists precisely so the slot tracks the real scan, but only the sub-GHz branch consulted it. Use it in both, and give SX128x an override returning NUM_SYM_CAD_24GHZ. That constant stops being a fleet-wide 2.4 GHz value and becomes what one driver reports; SX128x is unchanged at 8.78 symbols, while the LR parts on 2.4 GHz drop from a wrong 8.78 to 4.78. The error was conservative - slots too long, so throughput lost rather than collisions caused - but it is the same scan/slot mismatch the rest of this work removes. * LoRa LBT: declare CAD symbols per chip and per band, 8 on 2.4 GHz A 2.4 GHz region is shared: an LR1120 and an SX1280 can sit on the same mesh. Leaving the LR parts at 4 symbols there while SX1280 scanned 8 gave them different slot times, and so a different CSMA backoff scale between neighbours - the cross-family asymmetry this branch removed for sub-GHz, reintroduced one band up. Both LR families now scan 8 on 2.4 GHz, matching SX1280. Rather than switch on the band inside a shared constant, each driver states its own count for each band it works in, and derives the symNum it passes from the same accessor - so the scan and the slot cannot drift apart: SX126x 4 sub-GHz SX128x 8 on 2.4 GHz LR11x0 4 sub-GHz, 8 on 2.4 GHz LR20x0 4 sub-GHz, 8 on 2.4 GHz RF95 2 sub-GHz, its CAD being fixed in hardware and not settable NUM_SYM_CAD and NUM_SYM_CAD_24GHZ become base-class defaults nothing falls through to; they now record the fleet convention rather than any part's behaviour. LR20xx Table 6-19 stops at 4 symbols, so an 8-symbol scan reuses that row - the last measured point, erring high and so slightly less sensitive than a true 8-symbol value. Noted at the table. Also a comment pass over the branch: drop retrospective phrasing that describes what the code used to do, cut trailing justification clauses, and fix comments the per-band change left stale - one still claimed the LR parts scan fewer symbols than SX1280 on 2.4 GHz, which is now the opposite of the truth. * LoRa LBT: sweep up a CAD->RX handoff that delivers nothing A handoff leaves the chip listening by its own exit mode, so nothing in the driver arms the radio and no return value says whether it worked. If the detection was false, or the exit mode was not honoured, the node waits in a state we never verified - and on the LR parts that is single-shot RX, which does not return to continuous listening on its own. Stamp the handoff and let the existing ~1s poll re-arm if a whole max-length airtime passes with no packet, the same bound receiveDetected() uses. Clearing it on a real RX_DONE is obvious; clearing it in startSend() is not, but a TX can intervene before any packet arrives and its completion re-arms RX anyway, so leaving the marker set would have the poll re-arm a radio already listening. Reads the clock through Time::getMillis(), the same source Throttle compares against, so the site stays testable across the millis() wrap. 0 is the "none outstanding" sentinel and is tested before the elapsed compare. Also document on rearmReceive() that an override must never call the chip's own startReceive() - its standby+SetRx aborts the packet CAD just detected, which is the entire reason the override exists. * define txen pin to allow sx128x testing * LoRa LBT: bound the CAD->RX handoff in hardware, and re-arm after it delivers cadTimeout only applies to the CAD->RX exit mode. Every driver on develop exited CAD to standby, so the field was ignored and its value - 0, which no datasheet defines - was dead data. Switching the exit mode to RX made it load-bearing while the line itself never changed, so the diff showed nothing. All three handoff parts now bound that RX at one max-length airtime, taken from getPacketTime() so it tracks the preset. A detection that delivers nothing expires to standby and raises TIMEOUT, already mapped to the pin, so the existing ISR re-arms and the queued TX retries on its own backoff. handleReceiveInterrupt() returns early on TIMEOUT without RX_DONE; otherwise each dud detection would reach readData() and be logged as a bad packet. That RX is single-shot, so the arm after it delivers must be a full one. SX126x re-armed unconditionally without a standby, which left the chip in standby while the interface believed it was listening - and nothing swept it, since RX_DONE has already cleared the handoff stamp. A node that received a handoff packet then went deaf until its next transmit. It now carries the same cadHandedToRx flag the LR drivers already had. Sourcing, since the parts disagree and RadioLib's uniform ChannelScanConfig_t hides it: SX126x DS_SX1261-2 p98 (15.625us steps), LR11x0 UM Table 8-8 (31.25us), LR2021 lr20xx_driver cad_timeout_in_pll_step (31.25us, contra the datasheet's "32 MHz periods"). RadioLib scales both LR parts as 30.52us, so the programmed value lands ~2% long - harmless, and left for upstream. Also marks the three SWSD003 cells at 2 symbols/BW500/SF10-12 as that SDK's fallback rather than measurements. * LoRa LBT: own the CAD->RX handoff in one place, and void it on standby The handoff state was split across two classes: cadHandoffRxStart in RadioLibInterface, cadHandedToRx in each of SX126x/LR11x0/LR20x0, whose rearmReceive() overrides were byte-identical. Neither field was cleared by setStandby(), so a standby taken while a handoff was outstanding could strand cadHandedToRx set - and the next rearmReceive() would then re-attach the ISR to a chip sitting in standby, marking the interface as receiving with no RX_DONE ever to come and no recovery path. A stale cadHandoffRxStart was milder but still re-armed the radio over whatever was arriving at the time. Hold both in the base, arm them together in noteCadHandoffToRx(), clear both in setStandby(), and collapse the three overrides into one non-virtual base rearmReceive(). SX128x and RF95 never arm the flag, so they keep taking the plain startReceive() branch. Document on isChannelActive() that a true return must be followed by rearmReceive() - the invariant the whole scheme rests on. Two fixes fall out of the shape: - The handoff re-arm attaches a rising-edge ISR to a line that is not known to be low, unlike startReceive() which clears the IRQ status first. Follow it with checkRxDoneIrqFlag(), as startReceive() already does. - checkCadHandoffTimeout() used one max-length airtime, the same figure the chip's own cadTimeout now uses, so the two expired together. Losing that race left a queued ISR_RX to run against flags the software path had just cleared, falling past the TIMEOUT guard into readData() on an empty buffer. Double it so the hardware always expires first; the backstop then only covers an RX whose timer stopped on a header that never completed. * LoRa LBT: re-arm RX when a queued packet is dropped for tx_enabled=false Pre-existing on develop, not introduced by this branch, but it is the same failure this series exists to remove. TRANSMIT_DELAY_COMPLETED reaches startSend() only through isChannelActive(), which leaves the chip in standby for the transmit about to follow. When that transmit is then dropped because LoRa TX is disabled, the early return frees the packet and returns without arming anything: isReceiving stays false, so pollMissedIrqs() skips the radio too, and the node is deaf until something else re-arms it. Narrow - tx_enabled has to change between enqueue and dequeue, since send() checks it as well - but the fix is one call on a path that already exists. * LoRa LBT: say what the CAD slot time actually does per chip Two comments in RadioInterface.h described intentions the code does not carry out. NUM_SYM_CAD/NUM_SYM_CAD_24GHZ claimed the per-band values keep the CW backoff scale equal across a mesh. They do not: the slot has to match the scan each part really runs, and RF95's CAD is fixed in hardware at 2.25 symbols while SX126x/LR scan 4. A mixed sub-GHz mesh therefore runs two slot lengths, which is correct per part and worth stating rather than implying otherwise. The slotTimeMsec initializer runs during RadioInterface's own construction, so the virtual getCadSymbolCount() it calls always resolves to the fallbacks here, never a driver override. That is harmless only because applyModemConfig() recomputes it before anything reads it - left as-is deliberately, since initializing to 0 would make random(0, 2^CW) * slotTimeMsec fire an immediate transmit if anything ever did read it early. * LoRa LBT: set the SX128x CAD threshold at config time, not per scan isChannelActive() looked up the AN1200.77 PNR row and wrote register 0x0942 on every scan, but sf and bw only change in applyModemConfig(). That is an SPI round trip between the channel scan and the transmit that follows it, on the one path this file's own comments ask to keep short. Move it to applyCadDetPeak(), called from reconfigure() after the sf/bw setters it depends on, and from init() as well - init() does not route through reconfigure(), so it needs its own call. * ESP32: stop skipping light sleep forever on a stuck LoRa IRQ The skip added earlier in this series returns instead of sleeping whenever DIO1 already reads high, so the main loop can service the packet rather than strand it for the sleep chunk. It had no bound: a line that latches high with nothing clearing it makes every subsequent call skip too, and the node quietly stops sleeping at all, announced only by a LOG_INFO each time. Count consecutive skips - warn at 3, sleep through at 5, reset on any real sleep. If the IRQ has survived five attempts the main loop is not going to clear it, and never sleeping costs more than one stranded packet. Also drop the LORA_DIO1_EXTENDED_IO branch in loraWakeIrqAsserted(): those boards return from doLightSleep() before it is ever called. * nrf52 promicro: gate the P1.06 TXEN pin, and add an optional SX1280 build P1.06 was wired to SX126X_TXEN unconditionally earlier in this series, to drive an external PA on a bench unit. That is not safe to ship: three envs share this variant.h, and P1.06 is PIN_WIRE_SCL on the EasyProMicro pinout and PIN_EINK_BUSY on the InkHUD one. The default builds go back to RADIOLIB_NC. Put it behind -D PROMICRO_SX126X_TXEN, refused wherever the pin is already claimed. The EasyProMicro arrangement has its own SX126X_TXEN on P1.00 in the #else branch and never reaches this, so only the e-ink exclusion is needed. The pin was wanted for SX1280 in the first place - every other SX128X line is already assigned on this board and TXEN was the one missing - so add that too, behind -D PROMICRO_SX1280 and a new nrf52_promicro_diy-sx1280 env. Both opt-ins claim P1.06, so asking for both is an #error rather than a silent pick. Costs about 10.7 KB: the image ends at 0xE5140, 19 KB clear of the warm region, against 0xE2710 and 30 KB for the base env. * LoRa LBT: fix two defects in the CAD->RX handoff found in review Both reported by Copilot against 1fcd219af, both confirmed against the code as it stands after the handoff was hoisted into RadioLibInterface. rearmReceive() armed the MCU ISR before marking the interface as receiving. An ISR firing in that window queues ISR_RX while isReceiving is still false, so handleReceiveInterrupt() bails with "called while not in rx mode" and the packet is dropped - the exact packet the CAD handoff existed to catch. Reversed, which also makes this path match the order the drivers' own startReceive() has always used: base state, then arm, then checkRxDoneIrqFlag(). noteCadHandoffToRx() stored Time::getMillis() raw, but 0 is the "no handoff outstanding" sentinel that checkCadHandoffTimeout() tests before its elapsed comparison. A handoff armed on the one millisecond per wrap where getMillis() returns 0 reads as "none outstanding" and the backstop never fires. Step past it to 1, the same hand-remap Throttle.h's own TODO records GPS.cpp using for fixHoldEnds. One millisecond of error, once per 49.7 days. * ESP32: wake on SX128X_DIO1, which five SX1280 boards define instead of LORA_DIO1 enableLoraInterrupt() only ever looked at LORA_DIO1 and RF95_IRQ. RadioInterface builds an SX1280Interface from SX128X_DIO1, and several variants name the IRQ pin only that way, or put it on a different pin from LORA_DIO1: tlora_v2_1_18 LORA_DIO1 undefined SX128X_DIO1 26 betafpv_2400_tx_micro LORA_DIO1 undefined SX128X_DIO1 4 t-eth-elite LORA_DIO1 16 SX128X_DIO1 8 tlora_t3s3_v1 LORA_DIO1 33 // TCXO_EN? SX128X_DIO1 9 tlora_t3s3_epaper LORA_DIO1 33 // TCXO_EN? SX128X_DIO1 9 Every branch of the function is gated on defined(LORA_DIO1), so on the first two it preprocessed to an empty function: esp_sleep_enable_gpio_wakeup() still enabled GPIO as a wake class, but no pin was ever armed for the radio. On the other three it armed the wrong pin - GPIO33 there being the line the variant's own comment questions as TCXO_EN. Those boards could not be woken from light sleep by an incoming packet; they waited out the 30s chunk and pollMissedIrqs() recovered the latched RX_DONE about a second after waking. Delayed, not lost, but deaf for the duration. loraWakeIrqAsserted(), added earlier in this series, inherited the same blind spot and was dead code on the two boards with no LORA_DIO1 at all - so the skip-the-sleep fix never once fired on an SX1280. Resolve the pin per radio in loraWakeDio1Pin() and use it for arming, polling and teardown alike, so the three cannot drift apart again, and widen their guards to accept either macro. Behaviour is unchanged wherever it already worked: for any non-SX1280 radio the helper returns LORA_DIO1, including on RF95 boards where the SOC_PM_SUPPORT_EXT_WAKEUP branch arms it regardless of radio type. LORA_DIO1_EXTENDED_IO still short-circuits first. Note this is a power-profile change for those five boards, not only a fix: they gain a LoRa wake source they have never had, so on a busy channel their light sleep will now be cut short by traffic like every other board's. * LoRa LBT: trim three-line comments to the repo's two-line limit copilot-instructions.md:340 says one or two lines; the P1.06 availability note and the LR2021 DIO routing note were both three. CodeRabbit flagged both. * LoRa LBT: trace the CAD scan, its exits and the handoff outcome Eight short lines, all in the base class so they cost one set of strings rather than one per chip driver. The scan logs arm/busy/free; the in-chip GOTO_RX handoff logs started, then exactly one of pkt, empty, timeout or void - so a dropped or double-counted handoff is visible by inspection. void and timeout are LOG_WARN because both mean an invariant broke: a standby discarded a live handoff, or the chip's own timer failed to fire and the software backstop had to re-arm. The rest are LOG_DEBUG, so a node at normal log level stays quiet unless something is actually wrong. The two pre-existing handoff strings were 44 and 41 bytes of flash; cut to "CAD>RX timeout" and "CAD>RX empty", they pay for most of the new set. The shared "CAD" prefix does not dedup - only string suffixes merge - and splitting it out behind a %s measured 8 bytes worse on Cortex-M once the extra pointer argument per call site is counted. Also fixes the delivered-packet trace, which sat above the TIMEOUT check and so claimed a packet on a window that expired empty. * LoRa LBT: restore the SX1280 CAD threshold after a chip-state recovery The calibrated AN1200.77 PNR threshold moved from a per-scan register write to a config-time one, so it now has to be re-applied wherever the chip's state is rebuilt. recoverChipStateLoss() hardware-resets the chip and reprograms only the modem params, which left a recovered SX1280 doing CAD against the vendor default threshold until the next reconfigure() - and on a node whose config never changes, that is the rest of its uptime. * LoRa LBT: retire RX flags that plain RX never completes Plain RX runs with no chip timeout (startReceiveDutyCycleAuto(16, 8) falls back to RX_TIMEOUT_INF) and the DIO mask carries RX_DONE only, so a PREAMBLE_DETECTED, HEADER_VALID or HEADER_ERR that never completes stays latched until something re-arms RX. On a node with nothing to send, nothing does. The CAD->RX handoff already has this recovery (cadTimeout plus checkCadHandoffTimeout()); plain RX had none. checkStaleRxFlags() is its twin on the ~1 s pollMissedIrqs() tick. It notes when flags are first seen, and if no RX_DONE has consumed them one max-length packet later: - a header (valid or errored) re-arms RX. RX_DONE clears every flag in readData(), so a header still latched has no frame behind it. SX1280 DS rev 3.3 section 16.2: a header error leaves RX wedged with no RX_DONE or timeout, and only a re-arm recovers it. - a bare preamble is only cleared. ClearIrqStatus/ClearIrq changes the IRQ register alone on SX126x, SX1280, LR11x0 and LR20x0, so it never aborts a reception; startReceive() goes through standby and would. HEADER_VALID is never cleared on its own: RadioLib's readData() reads it at RX_DONE, and on LR2021 its absence reports the packet as header damaged. Skipped while a CAD handoff is outstanding (its own timeout owns that RX) and while RX_DONE is pending. RF95 opts out: its carrier sense reads modem status rather than latched flags, and SX127x maps neither PREAMBLE_DETECTED nor HEADER_ERR. The decision is staleRxFlagAction() in RadioInterface.h, pinned in test_radio at both sides of the window and for each action. * LoRa LBT: hold TX on a seen preamble instead of judging it by a deadline receiveDetected() declared a bare PREAMBLE_DETECTED false once 2 * preambleTimeMsec passed without HEADER_VALID - about 8 ms at SF7/BW500. Nothing reads the flags on that cadence: they are latched status bits, never routed to DIO, and the only reader is the TX path, once per CSMA backoff (0-56 ms at SF7/BW500). The verdict was usually reached by a read older than its own deadline, and the queued TX went out over the frame. Reported in #11933 by @jp-bennett, whose two-node bench measured own TX a median 43 ms after the verdict, inside a ~102 ms frame. This branch's longer CAD slot widens it to LONG_FAST: 0-308 ms of backoff at CWmin against a 262 ms deadline. A read that sees a bare preamble now clears PREAMBLE_DETECTED and holds TX for one max-length airtime. The clear touches only the IRQ register (ClearIrqStatus/ClearIrq on SX126x, SX1280, LR11x0, LR20x0), so it never aborts a reception. Reads during the hold report busy, so the normal random backoff still spreads retries, and any RX interrupt ends the hold early. A preamble that fires again during the hold stays latched, and the first read after it holds again: a source that keeps triggering keeps deferring TX, one airtime at a time, while a single stale flag cannot block it. HEADER_VALID is never cleared here - RadioLib's readData() reads it at RX_DONE, and on LR2021 its absence reports the packet as header-damaged - and keeps its one-airtime ageing. The SX126x software IRQ poll routes its bare-preamble case through the same hold instead of clearing on every tick while a TX is queued, which would have released TX before the next read could see a refire. That poll read the private txQueue directly, so meshnology_w10 - the only env that defines LORA_DIO1_SOFTWARE_POLL - did not compile. hasQueuedTx() exposes the one fact it needs. #11937 addresses the same defect by deferring the verdict when the previous read was stale; the two conflict in receiveDetected(). * LoRa: re-arm RX before logging the packet that just went out After TX_DONE the chip sits in standby, deaf, until startReceive(). The ISR_TX path used to log airtime ("Packet TX") and printPacket() the sent packet first, which kept it deaf for ~2-5 ms more depending on where the log goes. Measured on an nRF52 SX1262 bench node: the TX_DONE-to-RX gap drops from ~8.8 ms to ~3.7 ms with USB serial logging (~11.3 to ~6.2 ms with the log also going to a phone over BLE). completeSending() is split into detachSentPacket(), which clears sendingPacket and runs packetReleased() so RX re-arms on the home config, and finishSentPacket(), which does the airtime, counters, log and pool release. setStandby() callers keep the combined completeSending(). * SX126x: keep the TCXO powered in standby RadioLib leaves standbyXOSC off, so standby and the post-TX/RX fallback both use STDBY_RC, which powers a DIO3-controlled TCXO down. Every SetRx and SetTx after that first waits out the TCXO start-up delay programmed into the chip (RadioLib's setTCXO() default, 5000 us). On an nRF52 SX1262 bench node the RX start after TX_DONE took ~6.0 ms at SF7, SF9 and SF11 alike; with setStandbyXOSC(true) it takes ~0.95 ms, and the TX_DONE-to-RX gap falls to ~1.1 ms together with the previous commit. The result held across the periodic AGC reset. Only boards that drive a TCXO from DIO3 change. The cost is the TCXO and XOSC current while the chip sits in standby, which the firmware keeps brief; duty-cycled RX still sleeps, and powers the TCXO down, between windows. * LoRa: skip the periodic AGC reset while a packet is queued resetAGC() ends in startReceive(), and trySetStandby() first runs checkNotification() to drain pending events. With a packet queued that can be TRANSMIT_DELAY_COMPLETED: CAD passes, startSend() keys up, and trySetStandby() then puts the chip in standby over the transmission it just started. completeSending() still reports the packet as sent and counts its airtime. A bench node logged this twice in 20 minutes; one was a packet's last retry, so it never went on air. Skip the reset for this tick when a TX is queued. The queue drains within seconds, and the reset comes round again on the next interval. * GPS: re-enable on a LoRa config save only when the region was unset #10386 keeps the GPS off until a region is set and turns it on when a LoRa config save sets one. The check never looked at the old region, so a GPS the probe had disabled ("GPS not detected; not present this boot") was re-enabled by every LoRa config save, preset changes included, and the probe ran again. The probe blocks the cooperative main loop: on a bench node with no GPS fitted, TX_DONE handling stalled by 0.34-0.89 s on 4 of 9 packets after a preset change, leaving the radio deaf in standby for that long. * LR20x0: CAD exit to RX is 0x01 on the LR2021 RadioLib (510e00cf) defines RADIOLIB_LR2021_CAD_EXIT_MODE_RX as 0x02. The LR2021 takes SetLoraCadParams exit modes 0x00 CAD only, 0x01 RX on detection and 0x10 TX when clear, as the LR11x0 does, and refuses 0x02 with a processing error (-706). With the CAD>RX handoff passing 0x02, every scan failed before the CAD ran and isChannelActive() reported the channel clear: on a Seeed Mesh-Tracker X1, 539 scans against live traffic gave no busy verdict, so the node transmitted with no carrier sense. Pass 0x01, and warn when a scan fails rather than reporting it clear in silence. * LoRa LBT: take out the preamble hold on a seen preamble Revertbe3bf1f38. receiveDetected() judges a preamble by its two-preamble deadline again, and the SX126x IRQ poll clears a bare preamble directly. hasQueuedTx() stays: periodicRadioMaintenance() still uses it. * SX126x: read the queued-TX state through hasQueuedTx() in the IRQ poll957fbd3f5restored the poll's direct txQueue.empty() read along with the preamble clear. txQueue is private to RadioLibInterface, so meshnology_w10, the only env that defines LORA_DIO1_SOFTWARE_POLL, no longer compiled. * SX126x: retry a standby that reports a stale command timeout After a CAD-to-RX handoff's bounded RX times out, the status byte that comes back with the next SET_STANDBY still carries command status 0x3 (timeout). RadioLib reports that as RADIOLIB_ERR_SPI_CMD_TIMEOUT although the chip took the command, so every "CAD>RX empty" was followed by a failed standby, a failed RX start and a full chip re-init. Send SET_STANDBY once more on that error; the second one sees a fresh status. Measured on a CH341 host (SHORT_TURBO): chip re-inits went from 3 to 0, and all 7 retries returned 0. Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9 (cherry picked from commit57d6a86b71) --------- Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2523 lines
117 KiB
C++
2523 lines
117 KiB
C++
#include "AdminModule.h"
|
|
#include "Channels.h"
|
|
#include "CryptoEngine.h"
|
|
#include "DisplayFormatters.h"
|
|
#include "HardwareRNG.h"
|
|
#include "MeshService.h"
|
|
#include "NodeDB.h"
|
|
#include "PositionPrecision.h"
|
|
#include "PowerFSM.h"
|
|
#include "SPILock.h"
|
|
#include "UptimeClock.h"
|
|
#include "gps/RTC.h"
|
|
#include "input/InputBroker.h"
|
|
#include "meshUtils.h"
|
|
#include <ErriezCRC32.h>
|
|
#include <FSCommon.h>
|
|
#include <Throttle.h>
|
|
#include <ctype.h> // for better whitespace handling
|
|
#if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI
|
|
#include "MeshtasticOTA.h"
|
|
#endif
|
|
#include "Router.h"
|
|
#include "configuration.h"
|
|
#include "main.h"
|
|
#ifdef ARCH_NRF52
|
|
#include "main.h"
|
|
#endif
|
|
#ifdef ARCH_PORTDUINO
|
|
#include "PortduinoGlue.h"
|
|
#include "unistd.h"
|
|
#endif
|
|
|
|
#include "Default.h"
|
|
#include "MeshRadio.h"
|
|
#include "MessageStore.h"
|
|
#include "RadioInterface.h"
|
|
#include "TypeConversions.h"
|
|
#include "mesh/AdminKeys.h"
|
|
#include "mesh/RadioLibInterface.h"
|
|
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
|
#include "mesh/PhoneAPI.h"
|
|
#endif
|
|
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
|
#include "security/EncryptedStorage.h"
|
|
#endif
|
|
#if !MESHTASTIC_EXCLUDE_BEACON
|
|
#include "modules/MeshBeaconModule.h"
|
|
#endif
|
|
|
|
#if !MESHTASTIC_EXCLUDE_MQTT
|
|
#include "mqtt/MQTT.h"
|
|
#endif
|
|
|
|
#if !MESHTASTIC_EXCLUDE_GPS
|
|
#include "GPS.h"
|
|
#endif
|
|
|
|
#include <RNG.h> // CryptRNG, the seeded CSPRNG used as fallback for the session passkey
|
|
#include <Throttle.h> // rollover-safe elapsed-time checks for the session passkey
|
|
|
|
#if MESHTASTIC_EXCLUDE_GPS
|
|
#include "modules/PositionModule.h"
|
|
#endif
|
|
|
|
#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && !MESHTASTIC_EXCLUDE_ACCELEROMETER
|
|
#include "motion/AccelerometerThread.h"
|
|
#endif
|
|
// Unguarded: serialConfigIsValid() is needed on every platform. The class stays guarded in the header.
|
|
#include "SerialModule.h"
|
|
|
|
AdminModule *adminModule;
|
|
|
|
#ifdef ARCH_STM32
|
|
// Client detach window before the DFU jump (see the enter_dfu case).
|
|
static constexpr uint32_t STM32_DFU_DETACH_DELAY_MS = 5000;
|
|
#endif
|
|
|
|
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
|
static bool licensedIdentityWillMigrate()
|
|
{
|
|
if (config.security.private_key.size != 32 || config.security.public_key.size != 32)
|
|
return true;
|
|
if (nodeDB->checkLowEntropyPublicKey(config.security.public_key))
|
|
return true;
|
|
return crc32Buffer(config.security.public_key.bytes, config.security.public_key.size) != nodeDB->getNodeNum();
|
|
}
|
|
#endif
|
|
|
|
/// A special reserved string to indicate strings we can not share with external nodes. We will use this 'reserved' word instead.
|
|
/// Also, to make setting work correctly, if someone tries to set a string to this reserved value we assume they don't really want
|
|
/// a change.
|
|
static const char *secretReserved = "sekrit";
|
|
|
|
/// If buf is the reserved secret word, replace the buffer with currentVal
|
|
static void writeSecret(char *buf, size_t bufsz, const char *currentVal)
|
|
{
|
|
if (strcmp(buf, secretReserved) == 0) {
|
|
strncpy(buf, currentVal, bufsz);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @brief Handle received protobuf message
|
|
*
|
|
* @param mp Received MeshPacket
|
|
* @param r Decoded AdminMessage
|
|
* @return bool
|
|
*/
|
|
bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *r)
|
|
{
|
|
// if handled == false, then let others look at this message also if they want
|
|
bool handled = false;
|
|
assert(r);
|
|
|
|
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
|
// While storage is locked, drop every admin payload - both local and
|
|
// remote (PKC, mesh-relayed). Lockdown unlock is the prerequisite for
|
|
// any admin operation: operators must authenticate via lockdown_auth
|
|
// first. The lockdown_auth path itself is handled synchronously in
|
|
// PhoneAPI::handleToRadioPacket before reaching here, so the real
|
|
// unlock flow is not affected by this gate. Without this, a remote
|
|
// PKC-authorized peer (or a USERPREFS-baked admin_key) could drive
|
|
// factory_reset / set_config against a locked device before the
|
|
// operator has even unlocked it.
|
|
// Only gate when lockdown is ACTIVE. A lockdown-capable build that hasn't
|
|
// been provisioned (or was disabled) is not unlocked either, but must
|
|
// still serve admin normally - so check isLockdownActive() first.
|
|
if (EncryptedStorage::isLockdownActive() && !EncryptedStorage::isUnlocked()) {
|
|
LOG_WARN("AdminModule: dropping admin payload - storage locked");
|
|
return handled;
|
|
}
|
|
#endif
|
|
|
|
bool fromOthers = !isFromUs(&mp);
|
|
if (mp.which_payload_variant != meshtastic_MeshPacket_decoded_tag) {
|
|
return handled;
|
|
}
|
|
#ifdef ARCH_PORTDUINO
|
|
// Simulator only: honor exit_simulator unconditionally for the local client (from==0).
|
|
// The from==0 branch below now covers pki_encrypted local packets too, but is_managed
|
|
// can still block it. Rather than threading simulator awareness through the auth gates,
|
|
// intercept here before any auth logic runs. Local-origin + force_simradio only.
|
|
// TODO: should a local client bypass admin auth at all? Fenced to the simulator for now.
|
|
if (portduino_config.force_simradio && mp.from == 0 &&
|
|
r->which_payload_variant == meshtastic_AdminMessage_exit_simulator_tag) {
|
|
LOG_INFO("Exiting simulator");
|
|
exit(0);
|
|
}
|
|
#endif
|
|
meshtastic_Channel *ch = &channels.getByIndex(mp.channel);
|
|
if (messageIsResponse(r)) {
|
|
// Only accept a response from a remote we sent the matching request to. from == 0 is a
|
|
// local client, which PhoneAPI has already gated.
|
|
const pb_size_t moduleConfigTag = r->which_payload_variant == meshtastic_AdminMessage_get_module_config_response_tag
|
|
? r->get_module_config_response.which_payload_variant
|
|
: 0;
|
|
if (mp.from != 0 && !responseIsSolicited(mp, r->which_payload_variant, moduleConfigTag)) {
|
|
LOG_INFO("Ignore admin response from 0x%08x, no outstanding request", mp.from);
|
|
return handled;
|
|
}
|
|
LOG_TRACE("Allow admin response message");
|
|
} else if (mp.from == 0) {
|
|
// Local admin from a BLE/USB/TCP client. from == 0 cannot arrive from the
|
|
// mesh: RF drops packets without a sender (RadioLibInterface) and MQTT treats
|
|
// from == 0 as our own downlink and ignores it. Clients may set pki_encrypted
|
|
// on self-addressed admin (the python CLI does), so don't use it to reroute
|
|
// local packets into the remote-PKC key check.
|
|
//
|
|
// Under MESHTASTIC_PHONEAPI_ACCESS_CONTROL, the per-connection auth
|
|
// gate lives in PhoneAPI::handleToRadioPacket - any local admin
|
|
// payload other than lockdown_auth is dropped there if the
|
|
// originating connection is unauthorized. By the time we reach
|
|
// this branch the connection has already proven the passphrase,
|
|
// so is_managed needs no additional gate here.
|
|
//
|
|
// Without that build flag the legacy is_managed semantics still
|
|
// apply: refuse all plain local admin and require PKC instead.
|
|
#ifndef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
|
if (config.security.is_managed) {
|
|
LOG_INFO("Ignore local admin payload: is_managed");
|
|
return handled;
|
|
}
|
|
#endif
|
|
} else if (strcasecmp(ch->settings.name, Channels::adminChannel) == 0) {
|
|
if (!config.security.admin_channel_enabled) {
|
|
LOG_INFO("Ignore admin channel, legacy admin disabled");
|
|
myReply = allocErrorResponse(meshtastic_Routing_Error_NOT_AUTHORIZED, &mp);
|
|
return handled;
|
|
}
|
|
} else if (mp.pki_encrypted) {
|
|
if (AdminKeys::isAuthorized(mp.public_key.bytes)) {
|
|
LOG_INFO("PKC admin payload with authorized sender key");
|
|
|
|
// Note: PKC admin does NOT automatically authorize the
|
|
// originating local PhoneAPI connection for content
|
|
// redaction purposes. PKC and the per-connection lockdown
|
|
// auth slot are independent gates - operators using PKC
|
|
// admin from a local app should still send lockdown_auth
|
|
// separately to unlock the redacted FromRadio stream.
|
|
// (The previous auto-authorize path read a shared
|
|
// g_currentContext set during synchronous PhoneAPI
|
|
// dispatch; by the time this Router-thread handler runs
|
|
// that pointer is unrelated, so the path was unsafe.)
|
|
|
|
// Automatically favorite the node that is using the admin key
|
|
auto remoteNode = nodeDB->getMeshNode(mp.from);
|
|
if (remoteNode && !nodeInfoLiteIsFavorite(remoteNode)) {
|
|
if (config.device.role == meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) {
|
|
// Special case for CLIENT_BASE: is_favorite has special meaning, and we don't want to automatically set it
|
|
// without the user doing so deliberately.
|
|
LOG_INFO("PKC admin valid, not auto-favoriting node 0x%08x: role==CLIENT_BASE", mp.from);
|
|
} else {
|
|
if (nodeDB->setProtectedFlag(remoteNode, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true)) {
|
|
LOG_INFO("PKC admin valid. Auto-favoriting node 0x%08x", mp.from);
|
|
} else {
|
|
LOG_WARN("PKC admin valid, but auto-favorite refused for node %x (protected-node cap)", mp.from);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
myReply = allocErrorResponse(meshtastic_Routing_Error_ADMIN_PUBLIC_KEY_UNAUTHORIZED, &mp);
|
|
LOG_INFO("PKC admin payload: sender public key doesn't match admin authorized key");
|
|
return handled;
|
|
}
|
|
} else {
|
|
LOG_INFO("Ignore unauthorized admin payload %i", r->which_payload_variant);
|
|
myReply = allocErrorResponse(meshtastic_Routing_Error_NOT_AUTHORIZED, &mp);
|
|
return handled;
|
|
}
|
|
|
|
LOG_INFO("Handle admin payload %i", r->which_payload_variant);
|
|
|
|
// all of the get and set messages, including those for other modules, flow through here first.
|
|
// any message that changes state, we want to check the passkey for
|
|
if (mp.from != 0 && !messageIsRequest(r) && !messageIsResponse(r)) {
|
|
if (!checkPassKey(r)) {
|
|
LOG_WARN("Admin message without session_key");
|
|
myReply = allocErrorResponse(meshtastic_Routing_Error_ADMIN_BAD_SESSION_KEY, &mp);
|
|
return handled;
|
|
}
|
|
}
|
|
// Before the switch, so every case below sees consistent transaction state.
|
|
expireStaleEditTransaction();
|
|
|
|
switch (r->which_payload_variant) {
|
|
|
|
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
|
// lockdown_auth is handled synchronously in
|
|
// PhoneAPI::handleToRadioPacket - see handleLockdownAuthInline. A
|
|
// packet should not normally reach AdminModule under that flag set,
|
|
// but if it ever does (e.g. injected via a non-PhoneAPI path), drop
|
|
// it silently rather than leaking a partial response.
|
|
case meshtastic_AdminMessage_lockdown_auth_tag:
|
|
LOG_WARN("AdminModule: lockdown_auth reached Router/AdminModule path; ignoring (PhoneAPI handles)");
|
|
return handled;
|
|
#endif // MESHTASTIC_ENCRYPTED_STORAGE
|
|
|
|
/**
|
|
* Getters
|
|
*/
|
|
case meshtastic_AdminMessage_get_owner_request_tag:
|
|
LOG_DEBUG("Client got owner");
|
|
handleGetOwner(mp);
|
|
break;
|
|
|
|
case meshtastic_AdminMessage_get_config_request_tag:
|
|
LOG_DEBUG("Client got config");
|
|
handleGetConfig(mp, r->get_config_request);
|
|
break;
|
|
|
|
case meshtastic_AdminMessage_get_module_config_request_tag:
|
|
LOG_DEBUG("Client got module config");
|
|
handleGetModuleConfig(mp, r->get_module_config_request);
|
|
break;
|
|
|
|
case meshtastic_AdminMessage_get_channel_request_tag: {
|
|
uint32_t i = r->get_channel_request - 1;
|
|
LOG_DEBUG("Client got channel %u", i);
|
|
if (i >= MAX_NUM_CHANNELS)
|
|
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
|
|
else
|
|
handleGetChannel(mp, i);
|
|
break;
|
|
}
|
|
|
|
/**
|
|
* Setters
|
|
*/
|
|
case meshtastic_AdminMessage_set_owner_tag:
|
|
LOG_DEBUG("Client set owner");
|
|
// Validate names
|
|
if (*r->set_owner.long_name) {
|
|
const char *start = r->set_owner.long_name;
|
|
// Skip all whitespace (space, tab, newline, etc)
|
|
while (*start && isspace((unsigned char)*start))
|
|
start++;
|
|
if (*start == '\0') {
|
|
LOG_WARN("Rejected long_name: needs 1+ non-whitespace char");
|
|
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
|
|
break;
|
|
}
|
|
}
|
|
if (*r->set_owner.short_name) {
|
|
const char *start = r->set_owner.short_name;
|
|
while (*start && isspace((unsigned char)*start))
|
|
start++;
|
|
if (*start == '\0') {
|
|
LOG_WARN("Rejected short_name: needs 1+ non-whitespace char");
|
|
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
|
|
break;
|
|
}
|
|
}
|
|
handleSetOwner(r->set_owner);
|
|
break;
|
|
|
|
case meshtastic_AdminMessage_set_config_tag: {
|
|
LOG_DEBUG("Client set config");
|
|
|
|
// Non-LoRa configs need no further validation.
|
|
if (r->set_config.which_payload_variant != meshtastic_Config_lora_tag) {
|
|
LOG_DEBUG("Non-LoRa config, applying directly");
|
|
handleSetConfig(r->set_config, fromOthers);
|
|
break;
|
|
}
|
|
|
|
// Only LORA_24 requires hardware capability validation.
|
|
if (r->set_config.payload_variant.lora.region != meshtastic_Config_LoRaConfig_RegionCode_LORA_24) {
|
|
LOG_DEBUG("LoRa config, region is not LORA_24, applying directly");
|
|
handleSetConfig(r->set_config, fromOthers);
|
|
break;
|
|
}
|
|
|
|
// Hardware supports 2.4 GHz - apply the config.
|
|
// Fail closed: null instance is treated as incapable.
|
|
if (RadioLibInterface::instance && RadioLibInterface::instance->wideLora()) {
|
|
LOG_DEBUG("LORA_24 requested, radio hardware supports 2.4 GHz, applying");
|
|
handleSetConfig(r->set_config, fromOthers);
|
|
break;
|
|
}
|
|
|
|
LOG_WARN("No 2.4 GHz radio support; rejecting LORA_24 region");
|
|
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
|
|
break;
|
|
}
|
|
|
|
case meshtastic_AdminMessage_set_module_config_tag:
|
|
LOG_DEBUG("Client set module config");
|
|
if (!handleSetModuleConfig(r->set_module_config)) {
|
|
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
|
|
}
|
|
break;
|
|
|
|
case meshtastic_AdminMessage_set_channel_tag:
|
|
LOG_DEBUG("Client set channel %d", r->set_channel.index);
|
|
if (r->set_channel.index < 0 || r->set_channel.index >= (int)MAX_NUM_CHANNELS)
|
|
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
|
|
else
|
|
handleSetChannel(r->set_channel);
|
|
break;
|
|
case meshtastic_AdminMessage_set_ham_mode_tag:
|
|
LOG_DEBUG("Client set ham mode");
|
|
// Without this a rejected request falls through to the generic Routing_Error_NONE ack below,
|
|
// so a client would report ham mode as enabled on a node that changed nothing.
|
|
if (!handleSetHamMode(r->set_ham_mode))
|
|
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
|
|
break;
|
|
case meshtastic_AdminMessage_get_ui_config_request_tag: {
|
|
LOG_DEBUG("Client is getting device-ui config");
|
|
handleGetDeviceUIConfig(mp);
|
|
handled = true;
|
|
break;
|
|
}
|
|
|
|
/**
|
|
* Other
|
|
*/
|
|
case meshtastic_AdminMessage_reboot_seconds_tag: {
|
|
reboot(r->reboot_seconds);
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_ota_request_tag: {
|
|
#if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI
|
|
LOG_INFO("OTA Requested");
|
|
|
|
if (r->ota_request.ota_hash.size != 32) {
|
|
suppressRebootBanner = true;
|
|
sendWarningAndLog("Cannot start OTA: Invalid `ota_hash` provided.");
|
|
break;
|
|
}
|
|
|
|
meshtastic_OTAMode mode = r->ota_request.reboot_ota_mode;
|
|
const char *mode_name = (mode == METHOD_OTA_BLE ? "BLE" : "WiFi");
|
|
|
|
// Check that we have an OTA partition
|
|
const esp_partition_t *part = MeshtasticOTA::getAppPartition();
|
|
if (part == NULL) {
|
|
suppressRebootBanner = true;
|
|
sendWarningAndLog("Cannot start OTA: Cannot find OTA Loader partition.");
|
|
break;
|
|
}
|
|
|
|
static esp_app_desc_t app_desc;
|
|
if (!MeshtasticOTA::getAppDesc(part, &app_desc)) {
|
|
suppressRebootBanner = true;
|
|
sendWarningAndLog("Cannot start OTA: Device does have a valid OTA Loader.");
|
|
break;
|
|
}
|
|
|
|
if (!MeshtasticOTA::checkOTACapability(&app_desc, mode)) {
|
|
suppressRebootBanner = true;
|
|
sendWarningAndLog("OTA Loader does not support %s", mode_name);
|
|
break;
|
|
}
|
|
|
|
if (MeshtasticOTA::trySwitchToOTA()) {
|
|
suppressRebootBanner = true;
|
|
if (screen) {
|
|
powerFSM.trigger(EVENT_PRESS);
|
|
screen->startFirmwareUpdateScreen();
|
|
}
|
|
MeshtasticOTA::saveConfig(&config.network, mode, r->ota_request.ota_hash.bytes);
|
|
sendWarningAndLog("Rebooting to %s OTA", mode_name);
|
|
} else {
|
|
sendWarningAndLog("Unable to switch to the OTA partition.");
|
|
}
|
|
#endif
|
|
int s = 1; // Reboot in 1 second, hard coded
|
|
LOG_INFO("Reboot in %d seconds", s);
|
|
rebootAtMsec = (s < 0) ? 0 : Time::timerEndsAtMillis(s * 1000);
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_shutdown_seconds_tag: {
|
|
int32_t s = r->shutdown_seconds;
|
|
LOG_INFO("Shutdown in %d seconds", s);
|
|
shutdownAtMsec = (s < 0) ? 0 : Time::timerEndsAtMillis(s * 1000);
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_get_device_metadata_request_tag: {
|
|
LOG_INFO("Client got device metadata");
|
|
handleGetDeviceMetadata(mp);
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_factory_reset_config_tag: {
|
|
LOG_INFO("Initiate factory config reset");
|
|
// Keep BLE active while reset cleanup performs nRF flash operations.
|
|
nodeDB->factoryReset();
|
|
LOG_INFO("Factory config reset finished, rebooting soon");
|
|
disableBluetooth();
|
|
reboot(DEFAULT_REBOOT_SECONDS);
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_factory_reset_device_tag: {
|
|
LOG_INFO("Initiate full factory reset");
|
|
nodeDB->factoryReset(true);
|
|
disableBluetooth();
|
|
reboot(DEFAULT_REBOOT_SECONDS);
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_nodedb_reset_tag: {
|
|
LOG_INFO("Initiate node-db reset");
|
|
// CLIENT_BASE, ROUTER and ROUTER_LATE are able to preserve the remaining hop count when relaying a packet via a
|
|
// favorited node, so ensure that their favorites are kept on reset
|
|
bool rolePreference =
|
|
isOneOf(config.device.role, meshtastic_Config_DeviceConfig_Role_CLIENT_BASE,
|
|
meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE);
|
|
nodeDB->resetNodes(rolePreference ? rolePreference : r->nodedb_reset);
|
|
disableBluetooth();
|
|
reboot(DEFAULT_REBOOT_SECONDS);
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_store_ui_config_tag: {
|
|
LOG_INFO("Storing device-ui config");
|
|
handleStoreDeviceUIConfig(r->store_ui_config);
|
|
handled = true;
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_begin_edit_settings_tag: {
|
|
LOG_INFO("Begin settings edit transaction");
|
|
hasOpenEditTransaction = true;
|
|
editTransactionActivityMs = millis();
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_commit_edit_settings_tag: {
|
|
disableBluetooth();
|
|
LOG_INFO("Commit settings edit transaction");
|
|
hasOpenEditTransaction = false;
|
|
deferredEditSegments = 0;
|
|
saveChanges(SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS | SEGMENT_NODEDATABASE);
|
|
flushChannelWarnings(); // one coalesced message for everything edited in this transaction
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_get_device_connection_status_request_tag: {
|
|
LOG_INFO("Client got device connection status");
|
|
handleGetDeviceConnectionStatus(mp);
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_get_module_config_response_tag: {
|
|
LOG_INFO("Client got get_module_config response");
|
|
// which_payload_variant is the ModuleConfig oneof tag, so compare against that tag, not the
|
|
// AdminMessage ModuleConfigType enum (whose REMOTEHARDWARE value is a different number).
|
|
if (fromOthers && r->get_module_config_response.which_payload_variant == meshtastic_ModuleConfig_remote_hardware_tag) {
|
|
handleGetModuleConfigResponse(mp, r);
|
|
}
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_remove_by_nodenum_tag: {
|
|
LOG_INFO("Client got remove_nodenum");
|
|
nodeDB->removeNodeByNum(r->remove_by_nodenum);
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_add_contact_tag: {
|
|
LOG_INFO("Client got add_contact");
|
|
nodeDB->addFromContact(r->add_contact);
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_set_favorite_node_tag: {
|
|
LOG_INFO("Client got set_favorite_node");
|
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->set_favorite_node);
|
|
if (node != NULL) {
|
|
if (nodeDB->setProtectedFlag(node, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true)) {
|
|
saveChanges(SEGMENT_NODEDATABASE, false);
|
|
if (screen)
|
|
screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens
|
|
} else if (mp.from == 0) { // local request from the phone - tell the user why it didn't take
|
|
sendWarning(NodeDB::PROTECTED_CAP_WARN_FMT, "favorite", r->set_favorite_node, MAX_NUM_NODES - 2);
|
|
} else {
|
|
LOG_WARN("Remote set_favorite_node for 0x%08x refused: protected-node cap", r->set_favorite_node);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_remove_favorite_node_tag: {
|
|
LOG_INFO("Client got remove_favorite_node");
|
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->remove_favorite_node);
|
|
if (node != NULL) {
|
|
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_FAVORITE_MASK, false);
|
|
saveChanges(SEGMENT_NODEDATABASE, false);
|
|
if (screen)
|
|
screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens
|
|
}
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_set_ignored_node_tag: {
|
|
LOG_INFO("Client got set_ignored_node");
|
|
// Unlike the sibling node-targeted admin commands, create the entry if
|
|
// it's absent so the block sticks for a node we've not heard from yet
|
|
// (e.g. one a remote admin asks us to block) with no NodeInfo or key.
|
|
meshtastic_NodeInfoLite *node = nodeDB->getOrCreateMeshNode(r->set_ignored_node);
|
|
if (node != NULL) {
|
|
if (nodeDB->setProtectedFlag(node, NODEINFO_BITFIELD_IS_IGNORED_MASK, true)) {
|
|
nodeDB->eraseNodeSatellites(node->num);
|
|
#if HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
|
|
messageStore.deleteAllMessagesFromNode(node->num);
|
|
#endif
|
|
saveChanges(SEGMENT_NODEDATABASE, false);
|
|
#if HAS_SCREEN
|
|
if (screen)
|
|
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
|
|
#endif
|
|
} else if (mp.from == 0) { // local request from the phone - tell the user why it didn't take
|
|
sendWarning(NodeDB::PROTECTED_CAP_WARN_FMT, "ignore", r->set_ignored_node, MAX_NUM_NODES - 2);
|
|
} else {
|
|
LOG_WARN("Remote set_ignored_node for 0x%08x refused: protected-node cap", r->set_ignored_node);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_remove_ignored_node_tag: {
|
|
LOG_INFO("Client got remove_ignored_node");
|
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->remove_ignored_node);
|
|
if (node != NULL) {
|
|
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_IGNORED_MASK, false);
|
|
saveChanges(SEGMENT_NODEDATABASE, false);
|
|
}
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_toggle_muted_node_tag: {
|
|
LOG_INFO("Client got toggle_muted_node");
|
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->toggle_muted_node);
|
|
if (node != NULL) {
|
|
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_MUTED_MASK, !nodeInfoLiteIsMuted(node));
|
|
saveChanges(SEGMENT_NODEDATABASE, false);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case meshtastic_AdminMessage_set_fixed_position_tag: {
|
|
LOG_INFO("Client got set_fixed_position");
|
|
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
|
// Route the fixed position through updatePosition so it lands in the
|
|
// satellite map (or, on builds with PositionDB excluded, just sets
|
|
// localPosition for the local broadcast path).
|
|
nodeDB->updatePosition(node->num, r->set_fixed_position, RX_SRC_LOCAL);
|
|
nodeDB->setLocalPosition(r->set_fixed_position);
|
|
config.position.fixed_position = true;
|
|
saveChanges(SEGMENT_NODEDATABASE | SEGMENT_CONFIG, false);
|
|
#if !MESHTASTIC_EXCLUDE_GPS
|
|
if (gps != nullptr)
|
|
gps->enable();
|
|
// Send our new fixed position to the mesh for good measure
|
|
positionModule->sendOurPosition();
|
|
#endif
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_remove_fixed_position_tag: {
|
|
LOG_INFO("Client got remove_fixed_position");
|
|
nodeDB->clearLocalPosition();
|
|
config.position.fixed_position = false;
|
|
saveChanges(SEGMENT_NODEDATABASE | SEGMENT_CONFIG, false);
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_set_time_only_tag: {
|
|
LOG_INFO("Client got set_time_only");
|
|
struct timeval tv;
|
|
tv.tv_sec = r->set_time_only;
|
|
tv.tv_usec = 0;
|
|
|
|
perhapsSetRTC(RTCQualityNTP, &tv, false);
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_enter_dfu_mode_request_tag: {
|
|
LOG_INFO("Client requests DFU mode");
|
|
#if HAS_SCREEN
|
|
IF_SCREEN(screen->showSimpleBanner("Device is rebooting\ninto DFU mode.", 0));
|
|
#endif
|
|
#if defined(ARCH_STM32)
|
|
// Delay the jump so this ACK reaches the client and it releases the port before the
|
|
// STM32WL ROM bootloader takes the UART and autobauds off the next byte it sees.
|
|
LOG_INFO("Entering DFU in %us - disconnect now", (STM32_DFU_DETACH_DELAY_MS + 999) / 1000);
|
|
// timerEndsAtMillis() dodges 0, the sentinel powerCommandsCheck() reads as unarmed.
|
|
enterDfuAtMsec = Time::timerEndsAtMillis(STM32_DFU_DETACH_DELAY_MS);
|
|
#elif defined(ARCH_NRF52) || defined(ARCH_RP2040)
|
|
enterDfuMode();
|
|
#endif
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_delete_file_request_tag: {
|
|
LOG_DEBUG("Client requests delete file: %s", r->delete_file_request);
|
|
|
|
#ifdef FSCom
|
|
spiLock->lock();
|
|
if (FSCom.remove(r->delete_file_request)) {
|
|
LOG_DEBUG("Deleted file");
|
|
} else {
|
|
LOG_DEBUG("File delete failed");
|
|
}
|
|
spiLock->unlock();
|
|
#endif
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_backup_preferences_tag: {
|
|
LOG_INFO("Client requests preferences backup");
|
|
if (nodeDB->backupPreferences(r->backup_preferences)) {
|
|
myReply = allocErrorResponse(meshtastic_Routing_Error_NONE, &mp);
|
|
} else {
|
|
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
|
|
}
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_restore_preferences_tag: {
|
|
LOG_INFO("Client requests preferences restore");
|
|
if (nodeDB->restorePreferences(r->backup_preferences,
|
|
SEGMENT_DEVICESTATE | SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_CHANNELS)) {
|
|
myReply = allocErrorResponse(meshtastic_Routing_Error_NONE, &mp);
|
|
LOG_DEBUG("Rebooting after preferences restore");
|
|
disableBluetooth();
|
|
reboot(DEFAULT_REBOOT_SECONDS);
|
|
} else {
|
|
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
|
|
}
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_remove_backup_preferences_tag: {
|
|
LOG_INFO("Client requests preferences backup removal");
|
|
#ifdef FSCom
|
|
if (r->remove_backup_preferences == meshtastic_AdminMessage_BackupLocation_FLASH) {
|
|
spiLock->lock();
|
|
FSCom.remove(backupFileName);
|
|
spiLock->unlock();
|
|
} else if (r->remove_backup_preferences == meshtastic_AdminMessage_BackupLocation_SD) {
|
|
// TODO: After more mainline SD card support
|
|
LOG_ERROR("SD backup removal not implemented yet");
|
|
}
|
|
#endif
|
|
break;
|
|
}
|
|
case meshtastic_AdminMessage_send_input_event_tag: {
|
|
LOG_INFO("Client requests send input event");
|
|
handleSendInputEvent(r->send_input_event);
|
|
break;
|
|
}
|
|
#ifdef ARCH_PORTDUINO
|
|
case meshtastic_AdminMessage_exit_simulator_tag:
|
|
LOG_INFO("Exiting simulator");
|
|
exit(0);
|
|
break;
|
|
#endif
|
|
|
|
default:
|
|
handleViaModuleApi(mp, r);
|
|
break;
|
|
}
|
|
|
|
// Allow any observers (e.g. the UI) to handle/respond
|
|
handleViaObservers(r);
|
|
|
|
// If asked for a response and it is not yet set, generate an 'ACK' response
|
|
if (mp.decoded.want_response && !myReply) {
|
|
myReply = allocErrorResponse(meshtastic_Routing_Error_NONE, &mp);
|
|
}
|
|
if (mp.pki_encrypted && myReply) {
|
|
myReply->pki_encrypted = true;
|
|
}
|
|
return handled;
|
|
}
|
|
|
|
void AdminModule::handleViaModuleApi(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *r)
|
|
{
|
|
meshtastic_AdminMessage res = meshtastic_AdminMessage_init_default;
|
|
AdminMessageHandleResult handleResult = MeshModule::handleAdminMessageForAllModules(mp, r, &res);
|
|
|
|
if (handleResult == AdminMessageHandleResult::HANDLED_WITH_RESPONSE) {
|
|
setPassKey(&res);
|
|
myReply = allocDataProtobuf(res);
|
|
} else if (mp.decoded.want_response) {
|
|
LOG_DEBUG("Module API didn't respond to admin msg. req.variant=%d", r->which_payload_variant);
|
|
} else if (handleResult != AdminMessageHandleResult::HANDLED) {
|
|
// Probably a message sent by us or sent to our local node. FIXME, we should avoid scanning these messages
|
|
LOG_DEBUG("Module API didn't handle admin msg %d", r->which_payload_variant);
|
|
}
|
|
}
|
|
|
|
void AdminModule::handleViaObservers(const meshtastic_AdminMessage *r)
|
|
{
|
|
AdminMessageHandleResult observerResult = AdminMessageHandleResult::NOT_HANDLED;
|
|
meshtastic_AdminMessage observerResponse = meshtastic_AdminMessage_init_default;
|
|
AdminModule_ObserverData observerData = {
|
|
.request = r,
|
|
.response = &observerResponse,
|
|
.result = &observerResult,
|
|
};
|
|
|
|
notifyObservers(&observerData);
|
|
|
|
if (observerResult == AdminMessageHandleResult::HANDLED_WITH_RESPONSE) {
|
|
setPassKey(&observerResponse);
|
|
myReply = allocDataProtobuf(observerResponse);
|
|
LOG_DEBUG("Observer responded to admin message");
|
|
} else if (observerResult == AdminMessageHandleResult::HANDLED) {
|
|
LOG_DEBUG("Observer handled admin message");
|
|
}
|
|
}
|
|
|
|
void AdminModule::handleGetModuleConfigResponse(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *r)
|
|
{
|
|
// Skip if it's disabled or no pins are exposed
|
|
if (!r->get_module_config_response.payload_variant.remote_hardware.enabled ||
|
|
r->get_module_config_response.payload_variant.remote_hardware.available_pins_count == 0) {
|
|
LOG_DEBUG("Remote hardware module disabled or no available_pins. Skip");
|
|
return;
|
|
}
|
|
for (uint8_t i = 0; i < devicestate.node_remote_hardware_pins_count; i++) {
|
|
if (devicestate.node_remote_hardware_pins[i].node_num == 0 || !devicestate.node_remote_hardware_pins[i].has_pin) {
|
|
continue;
|
|
}
|
|
for (uint8_t j = 0; j < r->get_module_config_response.payload_variant.remote_hardware.available_pins_count; j++) {
|
|
auto availablePin = r->get_module_config_response.payload_variant.remote_hardware.available_pins[j];
|
|
if (i < devicestate.node_remote_hardware_pins_count) {
|
|
devicestate.node_remote_hardware_pins[i].node_num = mp.from;
|
|
devicestate.node_remote_hardware_pins[i].pin = availablePin;
|
|
}
|
|
i++;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Setter methods
|
|
*/
|
|
|
|
void AdminModule::handleSetOwner(const meshtastic_User &o)
|
|
{
|
|
int changed = 0;
|
|
bool identityUpdated = false;
|
|
bool channelsSanitized = false;
|
|
|
|
if (*o.long_name) {
|
|
// Apps built against the older 39-byte limit may send longer names; clamp
|
|
// before the changed-compare so re-sending the same long name is a no-op.
|
|
char longName[sizeof(o.long_name)];
|
|
strncpy(longName, o.long_name, sizeof(longName));
|
|
longName[sizeof(longName) - 1] = '\0';
|
|
clampLongName(longName);
|
|
changed |= strcmp(owner.long_name, longName);
|
|
strncpy(owner.long_name, longName, sizeof(owner.long_name));
|
|
owner.long_name[sizeof(owner.long_name) - 1] = '\0';
|
|
}
|
|
if (*o.short_name) {
|
|
changed |= strcmp(owner.short_name, o.short_name);
|
|
strncpy(owner.short_name, o.short_name, sizeof(owner.short_name));
|
|
owner.short_name[sizeof(owner.short_name) - 1] = '\0';
|
|
sanitizeUtf8(owner.short_name, sizeof(owner.short_name));
|
|
}
|
|
if (owner.is_licensed != o.is_licensed) {
|
|
changed = 1;
|
|
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
|
const bool identityWillMigrate =
|
|
o.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && licensedIdentityWillMigrate();
|
|
if (identityWillMigrate)
|
|
sendWarning(licensedIdentityMigrationMessage);
|
|
#endif
|
|
owner.is_licensed = o.is_licensed;
|
|
if (channels.ensureLicensedOperation()) {
|
|
warnLicensedMode();
|
|
channelsSanitized = true;
|
|
}
|
|
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
|
if (owner.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
|
identityUpdated = nodeDB->generateCryptoKeyPair();
|
|
if (identityWillMigrate)
|
|
nodeDB->licensedIdentityMigrationPending = false;
|
|
}
|
|
#endif
|
|
}
|
|
snprintf(owner.id, sizeof(owner.id), "!%08x", nodeDB->getNodeNum());
|
|
if (owner.has_is_unmessagable != o.has_is_unmessagable ||
|
|
(o.has_is_unmessagable && owner.is_unmessagable != o.is_unmessagable)) {
|
|
changed = 1;
|
|
owner.has_is_unmessagable = owner.has_is_unmessagable || o.has_is_unmessagable;
|
|
owner.is_unmessagable = o.is_unmessagable;
|
|
}
|
|
|
|
if (changed) { // If nothing really changed, don't broadcast on the network or write to flash
|
|
service->reloadOwner(!hasOpenEditTransaction);
|
|
saveChanges(SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE | (identityUpdated ? SEGMENT_CONFIG : 0) |
|
|
(channelsSanitized ? SEGMENT_CHANNELS : 0));
|
|
}
|
|
}
|
|
|
|
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && \
|
|
!MESHTASTIC_EXCLUDE_ACCELEROMETER
|
|
// Shared by double-tap-as-button (device) and wake-on-motion (display). Start on either flag's
|
|
// off->on edge; stop on the on->off edge once neither needs it (disable() clears `enabled` so a
|
|
// later re-enable restarts). Skip the stop if the sensor also drives the compass, else the heading
|
|
// freezes until reboot. wasOn/nowOn = old/new of the changed flag; otherFeatureOn = the other flag.
|
|
static void reconcileAccelerometerThread(bool wasOn, bool nowOn, bool otherFeatureOn)
|
|
{
|
|
if (!accelerometerThread) // null unless a sensor was detected at boot
|
|
return;
|
|
|
|
if (!wasOn && nowOn && accelerometerThread->enabled == false) {
|
|
accelerometerThread->enabled = true;
|
|
accelerometerThread->start();
|
|
} else if (wasOn && !nowOn && !otherFeatureOn && accelerometerThread->enabled == true &&
|
|
!accelerometerThread->providesHeading()) {
|
|
accelerometerThread->disable();
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// A "regenerate keys" client sends a blank SecurityConfig holding only the new private key, rather than the
|
|
// config it read from us. Detect that shape - new private key, every other field at its proto default - so it
|
|
// isn't mistaken for "and clear everything else".
|
|
static bool isBareKeypairRotation(const meshtastic_Config_SecurityConfig &incoming,
|
|
const meshtastic_Config_SecurityConfig ¤t)
|
|
{
|
|
if (incoming.private_key.size != 32)
|
|
return false;
|
|
if (current.private_key.size == 32 && memcmp(incoming.private_key.bytes, current.private_key.bytes, 32) == 0)
|
|
return false;
|
|
|
|
return incoming.admin_key_count == 0 && !incoming.is_managed && !incoming.serial_enabled && !incoming.debug_log_api_enabled &&
|
|
!incoming.admin_channel_enabled &&
|
|
incoming.packet_signature_policy ==
|
|
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE;
|
|
}
|
|
|
|
void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
|
|
{
|
|
auto changes = SEGMENT_CONFIG;
|
|
auto existingRole = config.device.role;
|
|
bool isRegionUnset = (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET);
|
|
bool requiresReboot = true;
|
|
bool loraPresetWarnPending = false;
|
|
meshtastic_Config_LoRaConfig pendingOldLora = {}, pendingNewLora = {};
|
|
|
|
switch (c.which_payload_variant) {
|
|
case meshtastic_Config_device_tag: {
|
|
LOG_INFO("Set config: Device");
|
|
config.has_device = true;
|
|
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && \
|
|
!MESHTASTIC_EXCLUDE_ACCELEROMETER
|
|
reconcileAccelerometerThread(config.device.double_tap_as_button_press,
|
|
c.payload_variant.device.double_tap_as_button_press, config.display.wake_on_tap_or_motion);
|
|
#endif
|
|
if (config.device.button_gpio == c.payload_variant.device.button_gpio &&
|
|
config.device.buzzer_gpio == c.payload_variant.device.buzzer_gpio &&
|
|
config.device.role == c.payload_variant.device.role &&
|
|
config.device.rebroadcast_mode == c.payload_variant.device.rebroadcast_mode) {
|
|
requiresReboot = false;
|
|
}
|
|
config.device = c.payload_variant.device;
|
|
if (config.device.rebroadcast_mode == meshtastic_Config_DeviceConfig_RebroadcastMode_NONE &&
|
|
(config.device.role == meshtastic_Config_DeviceConfig_Role_ROUTER ||
|
|
config.device.role == meshtastic_Config_DeviceConfig_Role_ROUTER_LATE)) {
|
|
config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL;
|
|
const char *warning = "Rebroadcast mode can't be set to NONE for a router role";
|
|
LOG_WARN(warning);
|
|
sendWarning(warning);
|
|
}
|
|
// If we're setting router role for the first time, install its intervals
|
|
if (existingRole != c.payload_variant.device.role) {
|
|
nodeDB->installRoleDefaults(c.payload_variant.device.role);
|
|
changes |= SEGMENT_NODEDATABASE | SEGMENT_DEVICESTATE; // Some role defaults affect owner
|
|
}
|
|
if (config.device.node_info_broadcast_secs < min_node_info_broadcast_secs) {
|
|
LOG_DEBUG("node_info_broadcast_secs too low, set to %d", min_node_info_broadcast_secs);
|
|
config.device.node_info_broadcast_secs = min_node_info_broadcast_secs;
|
|
}
|
|
// Router Client and Repeater deprecated; Set it to client
|
|
if (IS_ONE_OF(c.payload_variant.device.role, meshtastic_Config_DeviceConfig_Role_ROUTER_CLIENT,
|
|
meshtastic_Config_DeviceConfig_Role_REPEATER)) {
|
|
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
|
if (moduleConfig.store_forward.enabled && !moduleConfig.store_forward.is_server) {
|
|
moduleConfig.store_forward.is_server = true;
|
|
changes |= SEGMENT_MODULECONFIG;
|
|
requiresReboot = true;
|
|
}
|
|
}
|
|
#if USERPREFS_EVENT_MODE
|
|
// If we're in event mode, nobody is a Router or Router Late
|
|
if (config.device.role == meshtastic_Config_DeviceConfig_Role_ROUTER ||
|
|
config.device.role == meshtastic_Config_DeviceConfig_Role_ROUTER_LATE) {
|
|
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
|
}
|
|
#endif
|
|
break;
|
|
} // case meshtastic_Config_device_tag
|
|
case meshtastic_Config_position_tag:
|
|
LOG_INFO("Set config: Position");
|
|
config.has_position = true;
|
|
// If we have turned off the GPS (disabled or not present) and we're not using fixed position,
|
|
// clear the stored position since it may not get updated
|
|
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED &&
|
|
c.payload_variant.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED &&
|
|
config.position.fixed_position == false && c.payload_variant.position.fixed_position == false) {
|
|
nodeDB->clearLocalPosition();
|
|
saveChanges(SEGMENT_NODEDATABASE | SEGMENT_CONFIG, false);
|
|
}
|
|
config.position = c.payload_variant.position;
|
|
|
|
// Save nodedb as well in case we got a fixed position packet
|
|
break;
|
|
case meshtastic_Config_power_tag:
|
|
LOG_INFO("Set config: Power");
|
|
config.has_power = true;
|
|
// Really just the adc override is the only thing that can change without a reboot
|
|
if (config.power.device_battery_ina_address == c.payload_variant.power.device_battery_ina_address &&
|
|
config.power.is_power_saving == c.payload_variant.power.is_power_saving &&
|
|
config.power.ls_secs == c.payload_variant.power.ls_secs &&
|
|
config.power.min_wake_secs == c.payload_variant.power.min_wake_secs &&
|
|
config.power.on_battery_shutdown_after_secs == c.payload_variant.power.on_battery_shutdown_after_secs &&
|
|
config.power.sds_secs == c.payload_variant.power.sds_secs &&
|
|
config.power.wait_bluetooth_secs == c.payload_variant.power.wait_bluetooth_secs) {
|
|
requiresReboot = false;
|
|
}
|
|
config.power = c.payload_variant.power;
|
|
if (c.payload_variant.power.on_battery_shutdown_after_secs > 0 &&
|
|
c.payload_variant.power.on_battery_shutdown_after_secs < 30) {
|
|
LOG_WARN("on_battery_shutdown_after_secs too low, set to min 30 sec");
|
|
config.power.on_battery_shutdown_after_secs = 30;
|
|
}
|
|
break;
|
|
case meshtastic_Config_network_tag: {
|
|
LOG_INFO("Set config: WiFi");
|
|
config.has_network = true;
|
|
char prevPsk[sizeof(config.network.wifi_psk)];
|
|
memcpy(prevPsk, config.network.wifi_psk, sizeof(prevPsk));
|
|
config.network = c.payload_variant.network;
|
|
writeSecret(config.network.wifi_psk, sizeof(config.network.wifi_psk), prevPsk);
|
|
break;
|
|
}
|
|
case meshtastic_Config_display_tag:
|
|
LOG_INFO("Set config: Display");
|
|
config.has_display = true;
|
|
if (config.display.screen_on_secs == c.payload_variant.display.screen_on_secs &&
|
|
config.display.flip_screen == c.payload_variant.display.flip_screen &&
|
|
config.display.oled == c.payload_variant.display.oled &&
|
|
config.display.displaymode == c.payload_variant.display.displaymode) {
|
|
requiresReboot = false;
|
|
} else if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR &&
|
|
c.payload_variant.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
|
|
config.bluetooth.enabled = false;
|
|
}
|
|
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && \
|
|
!MESHTASTIC_EXCLUDE_ACCELEROMETER
|
|
reconcileAccelerometerThread(config.display.wake_on_tap_or_motion, c.payload_variant.display.wake_on_tap_or_motion,
|
|
config.device.double_tap_as_button_press);
|
|
#endif
|
|
config.display = c.payload_variant.display;
|
|
break;
|
|
|
|
case meshtastic_Config_lora_tag: {
|
|
// Wrap the entire case in a block to scope variables and avoid crossing initialization
|
|
auto oldLoraConfig = config.lora;
|
|
auto validatedLora = c.payload_variant.lora;
|
|
|
|
LOG_INFO("Set config: LoRa");
|
|
config.has_lora = true;
|
|
|
|
if (validatedLora.coding_rate != clampCodingRate(validatedLora.coding_rate)) {
|
|
LOG_WARN("Invalid coding_rate %d, set to %d", validatedLora.coding_rate, LORA_CR_DEFAULT);
|
|
validatedLora.coding_rate = LORA_CR_DEFAULT;
|
|
}
|
|
|
|
if (validatedLora.spread_factor != clampSpreadFactor(validatedLora.spread_factor)) {
|
|
LOG_WARN("Invalid spread_factor %d, set to %d", validatedLora.spread_factor, LORA_SF_DEFAULT);
|
|
validatedLora.spread_factor = LORA_SF_DEFAULT;
|
|
}
|
|
|
|
// A custom (non-preset) config that leaves bandwidth at its proto zero-value otherwise slips
|
|
// through validateConfigLora() and persists as 0, while the radio silently falls back to the
|
|
// default (config.lora.bandwidth then reads back 0 even though the radio runs at 250kHz).
|
|
// Coerce it here like coding_rate/spread_factor so the stored config matches the radio. In
|
|
// preset mode bandwidth 0 is expected (the preset supplies it), so leave it untouched.
|
|
const uint16_t clampedBandwidth = clampBandwidthCode(validatedLora.bandwidth);
|
|
if (!validatedLora.use_preset && validatedLora.bandwidth != clampedBandwidth) {
|
|
LOG_WARN("Invalid bandwidth %d, set to %d", validatedLora.bandwidth, clampedBandwidth);
|
|
validatedLora.bandwidth = clampedBandwidth;
|
|
}
|
|
|
|
// If we're setting a new region, check the region is valid and then init the region or discard the change
|
|
if (validatedLora.region != myRegion->code) {
|
|
// Region has changed so check whether it is valid for e.g. licensing conditions and if the lora config is valid
|
|
if (RadioInterface::validateConfigRegion(validatedLora) && RadioInterface::validateConfigLora(validatedLora)) {
|
|
// If we're setting region for the first time, init the region and regenerate the keys
|
|
if (isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
|
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
|
// Minting the key moves our node num with it (my_node_num == crc32(public_key)), so
|
|
// persist devicestate + the node DB too - exactly as the licensed branch below does.
|
|
if (!owner.is_licensed && nodeDB->ensurePkiIdentity()) {
|
|
changes |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE;
|
|
}
|
|
#endif
|
|
// new region is valid and we're coming from an unset region, so enable tx
|
|
validatedLora.tx_enabled = true;
|
|
}
|
|
// If we're unsetting the region for some reason, disable tx
|
|
if (!isRegionUnset && validatedLora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
|
validatedLora.tx_enabled = false;
|
|
}
|
|
// Ensure initRegion() uses the newly validated region
|
|
config.lora.region = validatedLora.region;
|
|
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
|
if (owner.is_licensed && isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
|
const bool identityWillMigrate = licensedIdentityWillMigrate();
|
|
if (identityWillMigrate)
|
|
sendWarning(licensedIdentityMigrationMessage);
|
|
nodeDB->generateCryptoKeyPair();
|
|
changes |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE;
|
|
if (identityWillMigrate)
|
|
nodeDB->licensedIdentityMigrationPending = false;
|
|
}
|
|
#endif
|
|
initRegion();
|
|
if (getEffectiveDutyCycle() < 100) {
|
|
validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
|
|
}
|
|
#if !MESHTASTIC_EXCLUDE_MQTT
|
|
if (MQTT::applyRegionRootTopic(myRegion->name))
|
|
changes |= SEGMENT_MODULECONFIG;
|
|
#endif
|
|
changes |= SEGMENT_CONFIG;
|
|
} else {
|
|
// Region validation has failed, so just copy all of the old config over the new config
|
|
validatedLora = oldLoraConfig;
|
|
}
|
|
} // end of new region handling
|
|
|
|
if (!RadioInterface::validateConfigLora(validatedLora)) {
|
|
if (fromOthers) {
|
|
// A preset locked to a sibling EU region still swaps the region for remote admin;
|
|
// any other invalid config is rejected outright.
|
|
const RegionInfo *swapRegion =
|
|
validatedLora.use_preset
|
|
? RadioInterface::regionSwapForPreset(validatedLora.region, validatedLora.modem_preset)
|
|
: NULL;
|
|
if (swapRegion) {
|
|
validatedLora.region = swapRegion->code;
|
|
}
|
|
if (!swapRegion || !RadioInterface::validateConfigLora(validatedLora)) {
|
|
LOG_WARN("Invalid LoRa config from another node, rejecting changes");
|
|
// Rejecting means rejecting everything: a partial restore of region/preset
|
|
// could still apply other fields the validation already deemed invalid.
|
|
validatedLora = oldLoraConfig;
|
|
}
|
|
} else {
|
|
LOG_WARN("Invalid LoRa config from client, using corrected values");
|
|
RadioInterface::clampConfigLora(validatedLora);
|
|
}
|
|
// A preset locked to a sibling EU region swaps the region during the clamp;
|
|
// apply the same housekeeping as an explicit region change.
|
|
if (validatedLora.region != oldLoraConfig.region) {
|
|
config.lora.region = validatedLora.region;
|
|
initRegion();
|
|
if (getEffectiveDutyCycle() < 100) {
|
|
validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
|
|
}
|
|
#if !MESHTASTIC_EXCLUDE_MQTT
|
|
if (MQTT::applyRegionRootTopic(myRegion->name))
|
|
changes |= SEGMENT_MODULECONFIG;
|
|
#endif
|
|
changes |= SEGMENT_CONFIG;
|
|
}
|
|
// use_preset and bandwidth are coerced into valid values by the check.
|
|
}
|
|
|
|
// All LoRa radio changes apply live via configChanged observer → reconfigure().
|
|
// reconfigure() puts the radio in standby, reprograms all modem parameters, and restarts receive.
|
|
requiresReboot = false;
|
|
|
|
#if defined(ARCH_PORTDUINO)
|
|
// If running on portduino and using SimRadio, do not require reboot
|
|
if (SimRadio::instance) {
|
|
requiresReboot = false;
|
|
}
|
|
#endif
|
|
|
|
#ifdef RF95_FAN_EN
|
|
// Turn PA off if disabled by config
|
|
if (c.payload_variant.lora.pa_fan_disabled) {
|
|
digitalWrite(RF95_FAN_EN, LOW ^ 0);
|
|
} else {
|
|
digitalWrite(RF95_FAN_EN, HIGH ^ 0);
|
|
}
|
|
#endif
|
|
|
|
#if HAS_LORA_FEM
|
|
// Apply FEM LNA mode from config (only meaningful on hardware that supports it)
|
|
// Note that a rejected lora config will revert this as well.
|
|
if (loraFEMInterface.isLnaCanControl()) {
|
|
loraFEMInterface.setLNAEnable(validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED);
|
|
} else if (validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) {
|
|
// Hardware FEM does not support LNA control; normalize stored config to match actual capability
|
|
LOG_WARN("FEM LNA mode set but FEM lacks LNA control; normalizing to NOT_PRESENT");
|
|
validatedLora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT;
|
|
}
|
|
#endif
|
|
|
|
#if !MESHTASTIC_EXCLUDE_GPS
|
|
// Enable gps if it was previously disabled due to region not being set. Only then: a probe that
|
|
// gave up also leaves it disabled, and re-enabling on every LoRa save re-runs the blocking probe.
|
|
if (!requiresReboot && oldLoraConfig.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET &&
|
|
config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && gps != nullptr && !gps->isEnabled() &&
|
|
config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
|
|
gps->enable();
|
|
}
|
|
#endif
|
|
|
|
config.lora = validatedLora; // Finally, return the validated config back to the main config
|
|
if (validatedLora.modem_preset != oldLoraConfig.modem_preset) {
|
|
pendingOldLora = oldLoraConfig;
|
|
pendingNewLora = validatedLora;
|
|
loraPresetWarnPending = true;
|
|
}
|
|
|
|
break;
|
|
}
|
|
case meshtastic_Config_bluetooth_tag:
|
|
LOG_INFO("Set config: Bluetooth");
|
|
config.has_bluetooth = true;
|
|
config.bluetooth = c.payload_variant.bluetooth;
|
|
break;
|
|
case meshtastic_Config_security_tag: {
|
|
LOG_INFO("Set config: Security");
|
|
meshtastic_Config_SecurityConfig incoming = c.payload_variant.security;
|
|
// Preserve our keypair when a SET omits the private key but we already hold one: regenerating would
|
|
// change our NodeNum (== crc32(public_key)) and orphan us on the mesh. A SET without the key is a
|
|
// partial/legacy client, not an identity reset (that goes through factory_reset). Done outside the
|
|
// PKI guard so non-PKI builds keep their key bytes too.
|
|
if (incoming.private_key.size != 32 && config.security.private_key.size == 32) {
|
|
LOG_WARN("Security set omitted private key; keeping identity keypair");
|
|
incoming.private_key = config.security.private_key;
|
|
incoming.public_key = config.security.public_key;
|
|
}
|
|
// Rotating the keypair must not drop the admin keys - that locks the owner out of remote admin with no
|
|
// recourse but a physical connection. Clearing admin keys still works via a SET that leaves the private
|
|
// key alone and sends an empty list.
|
|
if (isBareKeypairRotation(incoming, config.security)) {
|
|
LOG_INFO("Security set is bare keypair rotation; keeping other security config");
|
|
meshtastic_Config_SecurityConfig rotated = config.security;
|
|
rotated.public_key = incoming.public_key; // usually empty; derived from the private key below
|
|
rotated.private_key = incoming.private_key;
|
|
incoming = rotated;
|
|
}
|
|
#if MESHTASTIC_EXCLUDE_PKI || MESHTASTIC_EXCLUDE_XEDDSA
|
|
if (incoming.packet_signature_policy !=
|
|
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE) {
|
|
incoming.packet_signature_policy =
|
|
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE;
|
|
const char *warning = "Packet authenticity policy is unavailable on this firmware build";
|
|
LOG_WARN(warning);
|
|
sendWarning(warning);
|
|
}
|
|
#endif
|
|
config.security = incoming;
|
|
// A remote admin does not get to drop the host's own admin keys.
|
|
AdminKeys::applyHostKeys();
|
|
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI)
|
|
// First provisioning (no key) generates one; a private key supplied without its public key derives it.
|
|
// A supplied public key that is itself blacklisted is re-derived too, so a restore carrying a whole
|
|
// low-entropy pair cannot skip the check just by populating both fields.
|
|
if (config.security.private_key.size != 32) {
|
|
nodeDB->generateCryptoKeyPair();
|
|
} else if (config.security.public_key.size == 0 || nodeDB->checkLowEntropyPublicKey(config.security.public_key)) {
|
|
// Warn at set time, not after the next reboot, and only when the key really was replaced: a
|
|
// blacklisted public key whose private key derives a clean one is re-derived, and that stuck.
|
|
uint8_t priorPrivateKey[32];
|
|
memcpy(priorPrivateKey, config.security.private_key.bytes, 32);
|
|
const bool keygenSucceeded = nodeDB->generateCryptoKeyPair(priorPrivateKey);
|
|
const bool keyWasReplaced = memcmp(priorPrivateKey, config.security.private_key.bytes, 32) != 0;
|
|
if (keygenSucceeded && keyWasReplaced && nodeDB->keyIsLowEntropy) {
|
|
sendWarning(LOW_ENTROPY_RESTORE_WARNING);
|
|
}
|
|
}
|
|
#endif
|
|
if (config.security.is_managed && !AdminKeys::any()) {
|
|
config.security.is_managed = false;
|
|
const char *warning = "You must provide at least one admin public key to enable managed mode";
|
|
LOG_WARN(warning);
|
|
sendWarning(warning);
|
|
}
|
|
|
|
changes = SEGMENT_CONFIG | SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE;
|
|
|
|
requiresReboot = true;
|
|
|
|
break;
|
|
}
|
|
case meshtastic_Config_device_ui_tag:
|
|
// NOOP! This is handled by handleStoreDeviceUIConfig
|
|
break;
|
|
}
|
|
if (requiresReboot && !hasOpenEditTransaction) {
|
|
disableBluetooth();
|
|
} // end of switch case which_payload_variant
|
|
|
|
saveChanges(changes, requiresReboot);
|
|
if (loraPresetWarnPending)
|
|
warnOnLoraPresetChange(pendingOldLora, pendingNewLora);
|
|
// Inside an edit transaction the queued warnings are flushed once at commit; otherwise emit now.
|
|
if (!hasOpenEditTransaction)
|
|
flushChannelWarnings();
|
|
} // end of handleSetConfig
|
|
|
|
bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c)
|
|
{
|
|
bool shouldReboot = true;
|
|
// Skip the variants that must not lose BLE here: MQTT and Serial validate first and disable it
|
|
// themselves, and statusmessage/mesh_beacon never reboot, so a disable would strand BLE until the
|
|
// next PowerFSM transition. Everything else reboots, so take BLE down before the phone interferes.
|
|
if (!hasOpenEditTransaction &&
|
|
!IS_ONE_OF(c.which_payload_variant, meshtastic_ModuleConfig_mqtt_tag, meshtastic_ModuleConfig_serial_tag,
|
|
meshtastic_ModuleConfig_statusmessage_tag, meshtastic_ModuleConfig_mesh_beacon_tag)) {
|
|
disableBluetooth();
|
|
}
|
|
|
|
switch (c.which_payload_variant) {
|
|
case meshtastic_ModuleConfig_mqtt_tag:
|
|
#if MESHTASTIC_EXCLUDE_MQTT
|
|
LOG_WARN("Set module config: MESHTASTIC_EXCLUDE_MQTT defined, skip MQTT config");
|
|
return false;
|
|
#else
|
|
LOG_INFO("Set module config: MQTT");
|
|
if (!MQTT::isValidConfig(c.payload_variant.mqtt)) {
|
|
return false;
|
|
}
|
|
// Disable Bluetooth to prevent interference during MQTT configuration, except inside an edit
|
|
// transaction: saveChanges() defers the reboot there, so nothing would bring BLE back.
|
|
if (!hasOpenEditTransaction)
|
|
disableBluetooth();
|
|
moduleConfig.has_mqtt = true;
|
|
{
|
|
char prevPass[sizeof(moduleConfig.mqtt.password)];
|
|
memcpy(prevPass, moduleConfig.mqtt.password, sizeof(prevPass));
|
|
moduleConfig.mqtt = c.payload_variant.mqtt;
|
|
writeSecret(moduleConfig.mqtt.password, sizeof(moduleConfig.mqtt.password), prevPass);
|
|
}
|
|
#endif
|
|
break;
|
|
case meshtastic_ModuleConfig_serial_tag:
|
|
LOG_INFO("Set module config: Serial");
|
|
// No architecture guard: the check and the store below must agree on every platform.
|
|
// disableBluetooth() self-guards on HAS_BLUETOOTH, so it is empty where there is no radio.
|
|
if (!serialConfigIsValid(c.payload_variant.serial)) {
|
|
LOG_ERROR("Invalid serial config");
|
|
return false;
|
|
}
|
|
// Same transaction caveat as MQTT above: a deferred reboot would leave BLE down with no restore.
|
|
if (!hasOpenEditTransaction)
|
|
disableBluetooth(); // Disable Bluetooth to prevent interference during Serial configuration
|
|
moduleConfig.has_serial = true;
|
|
moduleConfig.serial = c.payload_variant.serial;
|
|
break;
|
|
case meshtastic_ModuleConfig_external_notification_tag:
|
|
LOG_INFO("Set module config: External Notification");
|
|
moduleConfig.has_external_notification = true;
|
|
moduleConfig.external_notification = c.payload_variant.external_notification;
|
|
break;
|
|
case meshtastic_ModuleConfig_store_forward_tag:
|
|
LOG_INFO("Set module config: Store & Forward");
|
|
moduleConfig.has_store_forward = true;
|
|
moduleConfig.store_forward = c.payload_variant.store_forward;
|
|
break;
|
|
case meshtastic_ModuleConfig_range_test_tag:
|
|
LOG_INFO("Set module config: Range Test");
|
|
moduleConfig.has_range_test = true;
|
|
moduleConfig.range_test = c.payload_variant.range_test;
|
|
break;
|
|
case meshtastic_ModuleConfig_telemetry_tag:
|
|
LOG_INFO("Set module config: Telemetry");
|
|
moduleConfig.has_telemetry = true;
|
|
moduleConfig.telemetry = c.payload_variant.telemetry;
|
|
break;
|
|
case meshtastic_ModuleConfig_canned_message_tag:
|
|
LOG_INFO("Set module config: Canned Message");
|
|
moduleConfig.has_canned_message = true;
|
|
moduleConfig.canned_message = c.payload_variant.canned_message;
|
|
break;
|
|
case meshtastic_ModuleConfig_audio_tag:
|
|
LOG_INFO("Set module config: Audio");
|
|
moduleConfig.has_audio = true;
|
|
moduleConfig.audio = c.payload_variant.audio;
|
|
break;
|
|
case meshtastic_ModuleConfig_remote_hardware_tag:
|
|
LOG_INFO("Set module config: Remote Hardware");
|
|
moduleConfig.has_remote_hardware = true;
|
|
moduleConfig.remote_hardware = c.payload_variant.remote_hardware;
|
|
break;
|
|
case meshtastic_ModuleConfig_neighbor_info_tag:
|
|
LOG_INFO("Set module config: Neighbor Info");
|
|
moduleConfig.has_neighbor_info = true;
|
|
moduleConfig.neighbor_info = c.payload_variant.neighbor_info;
|
|
if (moduleConfig.neighbor_info.update_interval < min_neighbor_info_broadcast_secs) {
|
|
LOG_DEBUG("update_interval too low, set to %d", default_neighbor_info_broadcast_secs);
|
|
moduleConfig.neighbor_info.update_interval = default_neighbor_info_broadcast_secs;
|
|
}
|
|
break;
|
|
case meshtastic_ModuleConfig_detection_sensor_tag:
|
|
LOG_INFO("Set module config: Detection Sensor");
|
|
moduleConfig.has_detection_sensor = true;
|
|
moduleConfig.detection_sensor = c.payload_variant.detection_sensor;
|
|
break;
|
|
case meshtastic_ModuleConfig_ambient_lighting_tag:
|
|
LOG_INFO("Set module config: Ambient Lighting");
|
|
moduleConfig.has_ambient_lighting = true;
|
|
moduleConfig.ambient_lighting = c.payload_variant.ambient_lighting;
|
|
break;
|
|
case meshtastic_ModuleConfig_paxcounter_tag:
|
|
LOG_INFO("Set module config: Paxcounter");
|
|
moduleConfig.has_paxcounter = true;
|
|
moduleConfig.paxcounter = c.payload_variant.paxcounter;
|
|
break;
|
|
case meshtastic_ModuleConfig_statusmessage_tag:
|
|
LOG_INFO("Set module config: StatusMessage");
|
|
moduleConfig.has_statusmessage = true;
|
|
moduleConfig.statusmessage = c.payload_variant.statusmessage;
|
|
shouldReboot = false;
|
|
break;
|
|
case meshtastic_ModuleConfig_traffic_management_tag:
|
|
LOG_INFO("Set module config: Traffic Management");
|
|
moduleConfig.has_traffic_management = true;
|
|
moduleConfig.traffic_management = c.payload_variant.traffic_management;
|
|
break;
|
|
case meshtastic_ModuleConfig_tak_tag:
|
|
LOG_INFO("Set module config: TAK");
|
|
moduleConfig.has_tak = true;
|
|
moduleConfig.tak = c.payload_variant.tak;
|
|
break;
|
|
#if !MESHTASTIC_EXCLUDE_BEACON
|
|
case meshtastic_ModuleConfig_mesh_beacon_tag: {
|
|
LOG_INFO("Set module config: MeshBeacon");
|
|
// Sanitize a local copy rather than const_cast-ing the const input (UB if a truly-const
|
|
// object is ever passed); the validated copy is assigned into moduleConfig below.
|
|
auto beaconCfg = c.payload_variant.mesh_beacon;
|
|
// Cap at the generated field size, so the limit follows the proto's max_size.
|
|
beaconCfg.broadcast_message[sizeof(beaconCfg.broadcast_message) - 1] = '\0';
|
|
// Enforce interval minimum (0 means unset/use default).
|
|
if (beaconCfg.broadcast_interval_secs != 0 &&
|
|
beaconCfg.broadcast_interval_secs < default_mesh_beacon_min_broadcast_interval_secs)
|
|
beaconCfg.broadcast_interval_secs = default_mesh_beacon_min_broadcast_interval_secs;
|
|
// Validate broadcast_offer_preset against broadcast_offer_region (or current region if unset).
|
|
if (beaconCfg.has_broadcast_offer_preset) {
|
|
meshtastic_Config_LoRaConfig probe = config.lora;
|
|
probe.use_preset = true;
|
|
probe.modem_preset = beaconCfg.broadcast_offer_preset;
|
|
if (beaconCfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET)
|
|
probe.region = beaconCfg.broadcast_offer_region;
|
|
if (!RadioInterface::validateConfigLora(probe)) {
|
|
LOG_WARN("Beacon: broadcast_offer_preset %d invalid for region, clearing", beaconCfg.broadcast_offer_preset);
|
|
beaconCfg.has_broadcast_offer_preset = false;
|
|
}
|
|
}
|
|
// Validate broadcast_offer_region is a known region code.
|
|
if (beaconCfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
|
const RegionInfo *r = getRegion(beaconCfg.broadcast_offer_region);
|
|
if (r->code != beaconCfg.broadcast_offer_region) {
|
|
LOG_WARN("Beacon: broadcast_offer_region %d invalid, clearing", beaconCfg.broadcast_offer_region);
|
|
beaconCfg.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
|
}
|
|
}
|
|
// Validate each broadcast target so a bad preset/region is cleared on write rather than
|
|
// relying on the runtime TX drop.
|
|
for (pb_size_t i = 0; i < beaconCfg.broadcast_targets_count; i++) {
|
|
auto &t = beaconCfg.broadcast_targets[i];
|
|
// Region must be a known region code (UNSET = use running config at TX time).
|
|
if (t.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
|
const RegionInfo *r = getRegion(t.region);
|
|
if (r->code != t.region) {
|
|
LOG_WARN("Beacon: broadcast_targets[%u] region %d invalid, clearing", i, t.region);
|
|
t.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
|
}
|
|
}
|
|
// Preset must be valid for the target region (or current region if unset).
|
|
if (t.has_preset) {
|
|
meshtastic_Config_LoRaConfig probe = config.lora;
|
|
probe.use_preset = true;
|
|
probe.modem_preset = t.preset;
|
|
if (t.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET)
|
|
probe.region = t.region;
|
|
if (!RadioInterface::validateConfigLora(probe)) {
|
|
LOG_WARN("Beacon: broadcast_targets[%u] preset %d invalid for region, clearing", i, t.preset);
|
|
t.has_preset = false;
|
|
t.has_channel_index = false;
|
|
}
|
|
}
|
|
// channel_index must reference a real channel-table slot.
|
|
if (t.has_channel_index && t.channel_index >= MAX_NUM_CHANNELS) {
|
|
LOG_WARN("Beacon: broadcast_targets[%u] channel_index %u out of range, clearing", i, t.channel_index);
|
|
t.has_channel_index = false;
|
|
}
|
|
}
|
|
moduleConfig.has_mesh_beacon = true;
|
|
moduleConfig.mesh_beacon = beaconCfg;
|
|
shouldReboot = false;
|
|
// Payload content changed - invalidate the broadcaster's cache.
|
|
if (meshBeaconBroadcastModule)
|
|
meshBeaconBroadcastModule->invalidateCache();
|
|
break;
|
|
}
|
|
#endif
|
|
}
|
|
saveChanges(SEGMENT_MODULECONFIG, shouldReboot);
|
|
return true;
|
|
}
|
|
|
|
void AdminModule::handleSetChannel(const meshtastic_Channel &cc)
|
|
{
|
|
channels.setChannel(cc);
|
|
if (channels.ensureLicensedOperation()) {
|
|
warnLicensedMode();
|
|
}
|
|
// Refresh derived state (primaryIndex in particular) BEFORE the precision clamp below. usesPublicKey()
|
|
// resolves a secondary channel's key against the primary, so it must see the post-update primaryIndex;
|
|
// running the clamp first could evaluate secondaries against the previous primary and skip the clamp/warning.
|
|
channels.onConfigChanged(); // tell the radios about this change
|
|
|
|
// Persist the public-key precision clamp for all channels that may be affected (e.g. secondaries
|
|
// that inherit a now-public primary key) and warn the client once if anything was coarsened.
|
|
bool clamped = false;
|
|
for (uint8_t i = 0; i < channels.getNumChannels(); i++) {
|
|
meshtastic_Channel &ch = channels.getByIndex(i);
|
|
if (ch.role == meshtastic_Channel_Role_DISABLED || !ch.settings.has_module_settings)
|
|
continue;
|
|
uint32_t allowed = getPositionPrecisionForChannel(i);
|
|
if (allowed != ch.settings.module_settings.position_precision) {
|
|
ch.settings.module_settings.position_precision = allowed;
|
|
clamped = true;
|
|
}
|
|
}
|
|
if (clamped)
|
|
sendWarning(publicChannelPrecisionMessage);
|
|
saveChanges(SEGMENT_CHANNELS, false);
|
|
warnOnChannelSet(channels.getByIndex(cc.index)); // passes the saved channel
|
|
// Inside an edit transaction the queued warnings are flushed once at commit; otherwise emit now.
|
|
if (!hasOpenEditTransaction)
|
|
flushChannelWarnings();
|
|
}
|
|
|
|
/**
|
|
* Getters
|
|
*/
|
|
|
|
void AdminModule::handleGetOwner(const meshtastic_MeshPacket &req)
|
|
{
|
|
if (req.decoded.want_response) {
|
|
// We create the reply here
|
|
meshtastic_AdminMessage res = meshtastic_AdminMessage_init_default;
|
|
res.get_owner_response = owner;
|
|
|
|
res.which_payload_variant = meshtastic_AdminMessage_get_owner_response_tag;
|
|
setPassKey(&res);
|
|
myReply = allocDataProtobuf(res);
|
|
if (!myReply) {
|
|
return;
|
|
}
|
|
if (req.pki_encrypted) {
|
|
myReply->pki_encrypted = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
void AdminModule::handleGetConfig(const meshtastic_MeshPacket &req, const uint32_t configType)
|
|
{
|
|
meshtastic_AdminMessage res = meshtastic_AdminMessage_init_default;
|
|
|
|
if (req.decoded.want_response) {
|
|
switch (configType) {
|
|
case meshtastic_AdminMessage_ConfigType_DEVICE_CONFIG:
|
|
LOG_INFO("Get config: Device");
|
|
res.get_config_response.which_payload_variant = meshtastic_Config_device_tag;
|
|
res.get_config_response.payload_variant.device = config.device;
|
|
break;
|
|
case meshtastic_AdminMessage_ConfigType_POSITION_CONFIG:
|
|
LOG_INFO("Get config: Position");
|
|
res.get_config_response.which_payload_variant = meshtastic_Config_position_tag;
|
|
res.get_config_response.payload_variant.position = config.position;
|
|
break;
|
|
case meshtastic_AdminMessage_ConfigType_POWER_CONFIG:
|
|
LOG_INFO("Get config: Power");
|
|
res.get_config_response.which_payload_variant = meshtastic_Config_power_tag;
|
|
res.get_config_response.payload_variant.power = config.power;
|
|
break;
|
|
case meshtastic_AdminMessage_ConfigType_NETWORK_CONFIG:
|
|
LOG_INFO("Get config: Network");
|
|
res.get_config_response.which_payload_variant = meshtastic_Config_network_tag;
|
|
res.get_config_response.payload_variant.network = config.network;
|
|
if (req.from != 0)
|
|
strncpy(res.get_config_response.payload_variant.network.wifi_psk, secretReserved,
|
|
sizeof(res.get_config_response.payload_variant.network.wifi_psk));
|
|
break;
|
|
case meshtastic_AdminMessage_ConfigType_DISPLAY_CONFIG:
|
|
LOG_INFO("Get config: Display");
|
|
res.get_config_response.which_payload_variant = meshtastic_Config_display_tag;
|
|
res.get_config_response.payload_variant.display = config.display;
|
|
break;
|
|
case meshtastic_AdminMessage_ConfigType_LORA_CONFIG:
|
|
LOG_INFO("Get config: LoRa");
|
|
res.get_config_response.which_payload_variant = meshtastic_Config_lora_tag;
|
|
res.get_config_response.payload_variant.lora = config.lora;
|
|
break;
|
|
case meshtastic_AdminMessage_ConfigType_BLUETOOTH_CONFIG:
|
|
LOG_INFO("Get config: Bluetooth");
|
|
res.get_config_response.which_payload_variant = meshtastic_Config_bluetooth_tag;
|
|
res.get_config_response.payload_variant.bluetooth = config.bluetooth;
|
|
break;
|
|
case meshtastic_AdminMessage_ConfigType_SECURITY_CONFIG:
|
|
LOG_INFO("Get config: Security");
|
|
res.get_config_response.which_payload_variant = meshtastic_Config_security_tag;
|
|
res.get_config_response.payload_variant.security = config.security;
|
|
// The device identity private key is backup material for the local owner only. A local
|
|
// admin client sets from == 0 (BLE/USB/TCP); never return the key to a remote requester,
|
|
// even an authorized one, since it would travel over the air. public_key/admin_key are
|
|
// public and stay put.
|
|
if (req.from != 0) {
|
|
auto &sec = res.get_config_response.payload_variant.security;
|
|
memset(sec.private_key.bytes, 0, sizeof(sec.private_key.bytes));
|
|
sec.private_key.size = 0;
|
|
}
|
|
break;
|
|
case meshtastic_AdminMessage_ConfigType_SESSIONKEY_CONFIG:
|
|
LOG_INFO("Get config: Sessionkey");
|
|
res.get_config_response.which_payload_variant = meshtastic_Config_sessionkey_tag;
|
|
break;
|
|
case meshtastic_AdminMessage_ConfigType_DEVICEUI_CONFIG:
|
|
// NOOP! This is handled by handleGetDeviceUIConfig
|
|
res.get_config_response.which_payload_variant = meshtastic_Config_device_ui_tag;
|
|
break;
|
|
}
|
|
// NOTE: The phone app needs to know the ls_secs value so it can properly expect sleep behavior.
|
|
// So even if we internally use 0 to represent 'use default' we still need to send the value we are
|
|
// using to the app (so that even old phone apps work with new device loads).
|
|
// r.get_radio_response.preferences.ls_secs = getPref_ls_secs();
|
|
// hideSecret(r.get_radio_response.preferences.wifi_ssid); // hmm - leave public for now, because only minimally
|
|
// private and useful for users to know current provisioning)
|
|
// hideSecret(r.get_radio_response.preferences.wifi_password); r.get_config_response.which_payloadVariant =
|
|
// Config_ModuleConfig_telemetry_tag;
|
|
res.which_payload_variant = meshtastic_AdminMessage_get_config_response_tag;
|
|
setPassKey(&res);
|
|
myReply = allocDataProtobuf(res);
|
|
if (!myReply) {
|
|
return;
|
|
}
|
|
if (req.pki_encrypted) {
|
|
myReply->pki_encrypted = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const uint32_t configType)
|
|
{
|
|
meshtastic_AdminMessage res = meshtastic_AdminMessage_init_default;
|
|
|
|
if (req.decoded.want_response) {
|
|
const char *configName = "?";
|
|
switch (configType) {
|
|
case meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG:
|
|
configName = "MQTT";
|
|
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_mqtt_tag;
|
|
res.get_module_config_response.payload_variant.mqtt = moduleConfig.mqtt;
|
|
if (req.from != 0)
|
|
strncpy(res.get_module_config_response.payload_variant.mqtt.password, secretReserved,
|
|
sizeof(res.get_module_config_response.payload_variant.mqtt.password));
|
|
break;
|
|
case meshtastic_AdminMessage_ModuleConfigType_SERIAL_CONFIG:
|
|
configName = "Serial";
|
|
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_serial_tag;
|
|
res.get_module_config_response.payload_variant.serial = moduleConfig.serial;
|
|
break;
|
|
case meshtastic_AdminMessage_ModuleConfigType_EXTNOTIF_CONFIG:
|
|
configName = "External Notification";
|
|
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_external_notification_tag;
|
|
res.get_module_config_response.payload_variant.external_notification = moduleConfig.external_notification;
|
|
break;
|
|
case meshtastic_AdminMessage_ModuleConfigType_STOREFORWARD_CONFIG:
|
|
configName = "Store & Forward";
|
|
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_store_forward_tag;
|
|
res.get_module_config_response.payload_variant.store_forward = moduleConfig.store_forward;
|
|
break;
|
|
case meshtastic_AdminMessage_ModuleConfigType_RANGETEST_CONFIG:
|
|
configName = "Range Test";
|
|
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_range_test_tag;
|
|
res.get_module_config_response.payload_variant.range_test = moduleConfig.range_test;
|
|
break;
|
|
case meshtastic_AdminMessage_ModuleConfigType_TELEMETRY_CONFIG:
|
|
configName = "Telemetry";
|
|
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_telemetry_tag;
|
|
res.get_module_config_response.payload_variant.telemetry = moduleConfig.telemetry;
|
|
break;
|
|
case meshtastic_AdminMessage_ModuleConfigType_CANNEDMSG_CONFIG:
|
|
configName = "Canned Message";
|
|
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_canned_message_tag;
|
|
res.get_module_config_response.payload_variant.canned_message = moduleConfig.canned_message;
|
|
break;
|
|
case meshtastic_AdminMessage_ModuleConfigType_AUDIO_CONFIG:
|
|
configName = "Audio";
|
|
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_audio_tag;
|
|
res.get_module_config_response.payload_variant.audio = moduleConfig.audio;
|
|
break;
|
|
case meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG:
|
|
configName = "Remote Hardware";
|
|
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_remote_hardware_tag;
|
|
res.get_module_config_response.payload_variant.remote_hardware = moduleConfig.remote_hardware;
|
|
break;
|
|
case meshtastic_AdminMessage_ModuleConfigType_NEIGHBORINFO_CONFIG:
|
|
configName = "Neighbor Info";
|
|
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_neighbor_info_tag;
|
|
res.get_module_config_response.payload_variant.neighbor_info = moduleConfig.neighbor_info;
|
|
break;
|
|
case meshtastic_AdminMessage_ModuleConfigType_DETECTIONSENSOR_CONFIG:
|
|
configName = "Detection Sensor";
|
|
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_detection_sensor_tag;
|
|
res.get_module_config_response.payload_variant.detection_sensor = moduleConfig.detection_sensor;
|
|
break;
|
|
case meshtastic_AdminMessage_ModuleConfigType_AMBIENTLIGHTING_CONFIG:
|
|
configName = "Ambient Lighting";
|
|
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_ambient_lighting_tag;
|
|
res.get_module_config_response.payload_variant.ambient_lighting = moduleConfig.ambient_lighting;
|
|
break;
|
|
case meshtastic_AdminMessage_ModuleConfigType_PAXCOUNTER_CONFIG:
|
|
configName = "Paxcounter";
|
|
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag;
|
|
res.get_module_config_response.payload_variant.paxcounter = moduleConfig.paxcounter;
|
|
break;
|
|
case meshtastic_AdminMessage_ModuleConfigType_STATUSMESSAGE_CONFIG:
|
|
configName = "StatusMessage";
|
|
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_statusmessage_tag;
|
|
res.get_module_config_response.payload_variant.statusmessage = moduleConfig.statusmessage;
|
|
break;
|
|
case meshtastic_AdminMessage_ModuleConfigType_TRAFFICMANAGEMENT_CONFIG:
|
|
configName = "Traffic Management";
|
|
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag;
|
|
res.get_module_config_response.payload_variant.traffic_management = moduleConfig.traffic_management;
|
|
break;
|
|
case meshtastic_AdminMessage_ModuleConfigType_TAK_CONFIG:
|
|
configName = "TAK";
|
|
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_tak_tag;
|
|
res.get_module_config_response.payload_variant.tak = moduleConfig.tak;
|
|
break;
|
|
#if !MESHTASTIC_EXCLUDE_BEACON
|
|
case meshtastic_AdminMessage_ModuleConfigType_MESHBEACON_CONFIG:
|
|
configName = "MeshBeacon";
|
|
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_mesh_beacon_tag;
|
|
res.get_module_config_response.payload_variant.mesh_beacon = moduleConfig.mesh_beacon;
|
|
break;
|
|
#endif
|
|
}
|
|
LOG_INFO("Get module config: %s", configName);
|
|
|
|
// NOTE: The phone app needs to know the ls_secsvalue so it can properly expect sleep behavior.
|
|
// So even if we internally use 0 to represent 'use default' we still need to send the value we are
|
|
// using to the app (so that even old phone apps work with new device loads).
|
|
// r.get_radio_response.preferences.ls_secs = getPref_ls_secs();
|
|
// hideSecret(r.get_radio_response.preferences.wifi_ssid); // hmm - leave public for now, because only minimally
|
|
// private and useful for users to know current provisioning)
|
|
// hideSecret(r.get_radio_response.preferences.wifi_password); r.get_config_response.which_payloadVariant =
|
|
// Config_ModuleConfig_telemetry_tag;
|
|
res.which_payload_variant = meshtastic_AdminMessage_get_module_config_response_tag;
|
|
setPassKey(&res);
|
|
myReply = allocDataProtobuf(res);
|
|
if (!myReply) {
|
|
return;
|
|
}
|
|
if (req.pki_encrypted) {
|
|
myReply->pki_encrypted = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
void AdminModule::handleGetNodeRemoteHardwarePins(const meshtastic_MeshPacket &req)
|
|
{
|
|
meshtastic_AdminMessage r = meshtastic_AdminMessage_init_default;
|
|
r.which_payload_variant = meshtastic_AdminMessage_get_node_remote_hardware_pins_response_tag;
|
|
for (uint8_t i = 0; i < devicestate.node_remote_hardware_pins_count; i++) {
|
|
if (devicestate.node_remote_hardware_pins[i].node_num == 0 || !devicestate.node_remote_hardware_pins[i].has_pin) {
|
|
continue;
|
|
}
|
|
r.get_node_remote_hardware_pins_response.node_remote_hardware_pins[i] = devicestate.node_remote_hardware_pins[i];
|
|
}
|
|
for (uint8_t i = 0; i < moduleConfig.remote_hardware.available_pins_count; i++) {
|
|
if (!moduleConfig.remote_hardware.available_pins[i].gpio_pin) {
|
|
continue;
|
|
}
|
|
meshtastic_NodeRemoteHardwarePin nodePin = meshtastic_NodeRemoteHardwarePin_init_default;
|
|
nodePin.node_num = nodeDB->getNodeNum();
|
|
nodePin.pin = moduleConfig.remote_hardware.available_pins[i];
|
|
r.get_node_remote_hardware_pins_response.node_remote_hardware_pins[i + 12] = nodePin;
|
|
}
|
|
setPassKey(&r);
|
|
myReply = allocDataProtobuf(r);
|
|
if (!myReply) {
|
|
return;
|
|
}
|
|
if (req.pki_encrypted) {
|
|
myReply->pki_encrypted = true;
|
|
}
|
|
}
|
|
|
|
void AdminModule::handleGetDeviceMetadata(const meshtastic_MeshPacket &req)
|
|
{
|
|
#if WARM_NODE_COUNT > 0 && MESHTASTIC_NODEDB_MIGRATION_VERBOSE
|
|
// Debug aid: dump the warm tier to the console on a local metadata request
|
|
// (e.g. `meshtastic --info` over USB/BLE). Gated to req.from == 0 so remote
|
|
// or admin polling can't spam the console.
|
|
if (nodeDB && req.from == 0)
|
|
nodeDB->warmStore.dumpToLog("admin get_metadata");
|
|
#endif
|
|
meshtastic_AdminMessage r = meshtastic_AdminMessage_init_default;
|
|
r.get_device_metadata_response = getDeviceMetadata();
|
|
r.which_payload_variant = meshtastic_AdminMessage_get_device_metadata_response_tag;
|
|
setPassKey(&r);
|
|
myReply = allocDataProtobuf(r);
|
|
if (!myReply) {
|
|
return;
|
|
}
|
|
if (req.pki_encrypted) {
|
|
myReply->pki_encrypted = true;
|
|
}
|
|
}
|
|
|
|
void AdminModule::handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &req)
|
|
{
|
|
meshtastic_AdminMessage r = meshtastic_AdminMessage_init_default;
|
|
|
|
meshtastic_DeviceConnectionStatus conn = meshtastic_DeviceConnectionStatus_init_zero;
|
|
|
|
#if HAS_WIFI
|
|
conn.has_wifi = true;
|
|
conn.wifi.has_status = true;
|
|
#ifdef ARCH_PORTDUINO
|
|
conn.wifi.status.is_connected = true;
|
|
#else
|
|
conn.wifi.status.is_connected = WiFi.status() == WL_CONNECTED;
|
|
#endif
|
|
strncpy(conn.wifi.ssid, config.network.wifi_ssid, 33);
|
|
if (conn.wifi.status.is_connected) {
|
|
conn.wifi.rssi = WiFi.RSSI();
|
|
conn.wifi.status.ip_address = WiFi.localIP();
|
|
#ifndef MESHTASTIC_EXCLUDE_MQTT
|
|
conn.wifi.status.is_mqtt_connected = mqtt && mqtt->isConnectedDirectly();
|
|
#endif
|
|
conn.wifi.status.is_syslog_connected = false; // FIXME wire this up
|
|
}
|
|
#endif
|
|
|
|
#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D)
|
|
conn.has_ethernet = true;
|
|
conn.ethernet.has_status = true;
|
|
if (Ethernet.linkStatus() == LinkON) {
|
|
conn.ethernet.status.is_connected = true;
|
|
conn.ethernet.status.ip_address = Ethernet.localIP();
|
|
#if !MESHTASTIC_EXCLUDE_MQTT
|
|
conn.ethernet.status.is_mqtt_connected = mqtt && mqtt->isConnectedDirectly();
|
|
#endif
|
|
conn.ethernet.status.is_syslog_connected = false; // FIXME wire this up
|
|
} else {
|
|
conn.ethernet.status.is_connected = false;
|
|
}
|
|
#endif
|
|
|
|
#if HAS_BLUETOOTH
|
|
conn.has_bluetooth = true;
|
|
conn.bluetooth.pin = config.bluetooth.fixed_pin;
|
|
#ifdef ARCH_ESP32
|
|
if (config.bluetooth.enabled && nimbleBluetooth) {
|
|
conn.bluetooth.is_connected = nimbleBluetooth->isConnected();
|
|
conn.bluetooth.rssi = nimbleBluetooth->getRssi();
|
|
}
|
|
#elif defined(ARCH_NRF52)
|
|
if (config.bluetooth.enabled && nrf52Bluetooth) {
|
|
conn.bluetooth.is_connected = nrf52Bluetooth->isConnected();
|
|
}
|
|
#elif defined(MESHTASTIC_LINUX_BLE)
|
|
if (config.bluetooth.enabled && linuxBluetooth) {
|
|
conn.bluetooth.is_connected = linuxBluetooth->isConnected();
|
|
conn.bluetooth.rssi = linuxBluetooth->getRssi();
|
|
}
|
|
#endif
|
|
#endif
|
|
conn.has_serial = true; // No serial-less devices
|
|
#if !MESHTASTIC_EXCLUDE_POWER_FSM
|
|
conn.serial.is_connected = powerFSM.getState() == &stateSERIAL;
|
|
#else
|
|
conn.serial.is_connected = powerFSM.getState();
|
|
#endif
|
|
conn.serial.baud = SERIAL_BAUD;
|
|
|
|
r.get_device_connection_status_response = conn;
|
|
r.which_payload_variant = meshtastic_AdminMessage_get_device_connection_status_response_tag;
|
|
setPassKey(&r);
|
|
myReply = allocDataProtobuf(r);
|
|
if (!myReply) {
|
|
return;
|
|
}
|
|
if (req.pki_encrypted) {
|
|
myReply->pki_encrypted = true;
|
|
}
|
|
}
|
|
|
|
void AdminModule::handleGetChannel(const meshtastic_MeshPacket &req, uint32_t channelIndex)
|
|
{
|
|
if (req.decoded.want_response) {
|
|
// We create the reply here
|
|
meshtastic_AdminMessage r = meshtastic_AdminMessage_init_default;
|
|
r.get_channel_response = channels.getByIndex(channelIndex);
|
|
r.which_payload_variant = meshtastic_AdminMessage_get_channel_response_tag;
|
|
setPassKey(&r);
|
|
myReply = allocDataProtobuf(r);
|
|
if (!myReply) {
|
|
return;
|
|
}
|
|
if (req.pki_encrypted) {
|
|
myReply->pki_encrypted = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
void AdminModule::handleGetDeviceUIConfig(const meshtastic_MeshPacket &req)
|
|
{
|
|
meshtastic_AdminMessage r = meshtastic_AdminMessage_init_default;
|
|
r.which_payload_variant = meshtastic_AdminMessage_get_ui_config_response_tag;
|
|
r.get_ui_config_response = uiconfig;
|
|
myReply = allocDataProtobuf(r);
|
|
if (!myReply) {
|
|
return;
|
|
}
|
|
if (req.pki_encrypted) {
|
|
myReply->pki_encrypted = true;
|
|
}
|
|
}
|
|
|
|
void AdminModule::reboot(int32_t seconds)
|
|
{
|
|
LOG_INFO("Reboot in %d seconds", seconds);
|
|
if (screen)
|
|
screen->showSimpleBanner("Rebooting...", 0); // stays on screen
|
|
rebootAtMsec = (seconds < 0) ? 0 : Time::timerEndsAtMillis(seconds * 1000);
|
|
}
|
|
|
|
// Without this, a commit that never arrives leaves the transaction open forever and every later
|
|
// config write from any client is applied, acknowledged, and then never saved.
|
|
void AdminModule::expireStaleEditTransaction()
|
|
{
|
|
if (!hasOpenEditTransaction || Throttle::isWithinTimespanMs(editTransactionActivityMs, EDIT_TRANSACTION_IDLE_MS))
|
|
return;
|
|
|
|
LOG_WARN("Edit transaction abandoned for %us; committing what it applied", EDIT_TRANSACTION_IDLE_MS / 1000);
|
|
hasOpenEditTransaction = false;
|
|
int segments = deferredEditSegments;
|
|
deferredEditSegments = 0;
|
|
// No reboot: the settings are already live in RAM and the client that would expect one is gone.
|
|
if (segments)
|
|
saveChanges(segments, false);
|
|
flushChannelWarnings();
|
|
}
|
|
|
|
void AdminModule::saveChanges(int saveWhat, bool shouldReboot)
|
|
{
|
|
#ifdef PIO_UNIT_TESTING
|
|
lastSaveWhatForTest = saveWhat;
|
|
#endif
|
|
if (!hasOpenEditTransaction) {
|
|
LOG_INFO("Save changes to disk");
|
|
service->reloadConfig(saveWhat); // Calls saveToDisk among other things
|
|
} else {
|
|
LOG_INFO("Delay disk save until open transaction commits");
|
|
editTransactionActivityMs = millis(); // still in use, so not the abandoned kind we time out
|
|
deferredEditSegments |= saveWhat;
|
|
}
|
|
if (shouldReboot && !hasOpenEditTransaction) {
|
|
reboot(DEFAULT_REBOOT_SECONDS);
|
|
}
|
|
}
|
|
|
|
void AdminModule::handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uicfg)
|
|
{
|
|
#if HAS_SCREEN
|
|
nodeDB->saveProto("/prefs/uiconfig.proto", meshtastic_DeviceUIConfig_size, &meshtastic_DeviceUIConfig_msg, &uicfg);
|
|
#endif
|
|
}
|
|
|
|
// Unset, or set to nothing but whitespace - the two ways a client can leave a name field empty.
|
|
static bool isBlankName(const char *start)
|
|
{
|
|
while (*start && isspace((unsigned char)*start))
|
|
start++;
|
|
return *start == '\0';
|
|
}
|
|
|
|
bool AdminModule::handleSetHamMode(const meshtastic_HamParameters &p)
|
|
{
|
|
// Validate ham parameters before setting since this would bypass validation in the owner struct.
|
|
|
|
// The call sign is the station ID the whole licensed mode is built around, so it is required;
|
|
// without it we would license a node that never identifies itself on the air.
|
|
if (isBlankName(p.call_sign)) {
|
|
LOG_WARN("Rejected ham call_sign: needs 1+ non-whitespace char");
|
|
return false;
|
|
}
|
|
|
|
// Set call sign and override lora limitations for licensed use. An optional long_name rides
|
|
// behind the call sign with the "//" separator hams already use on the air.
|
|
// e.g. call_sign "N0CALL" plus long_name "Attic Heltec" becomes "N0CALL//Attic Heltec".
|
|
if (!isBlankName(p.long_name))
|
|
snprintf(owner.long_name, sizeof(owner.long_name), "%s//%s", p.call_sign, p.long_name);
|
|
else
|
|
strncpy(owner.long_name, p.call_sign, sizeof(owner.long_name));
|
|
owner.long_name[sizeof(owner.long_name) - 1] = '\0';
|
|
clampLongName(owner.long_name);
|
|
// short_name is optional per the schema, so a blank one keeps the name the node already had
|
|
if (!isBlankName(p.short_name)) {
|
|
strncpy(owner.short_name, p.short_name, sizeof(owner.short_name));
|
|
owner.short_name[sizeof(owner.short_name) - 1] = '\0';
|
|
sanitizeUtf8(owner.short_name, sizeof(owner.short_name));
|
|
}
|
|
owner.is_licensed = true;
|
|
config.lora.override_duty_cycle = true;
|
|
config.lora.tx_power = p.tx_power;
|
|
config.lora.override_frequency = p.frequency;
|
|
// Set node info broadcast interval to 10 minutes
|
|
// For FCC minimum call-sign announcement
|
|
config.device.node_info_broadcast_secs = 600;
|
|
|
|
config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY;
|
|
// Remove PSK of primary channel for plaintext amateur usage
|
|
|
|
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
|
if (config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
|
const bool identityWillMigrate = licensedIdentityWillMigrate();
|
|
if (identityWillMigrate)
|
|
sendWarning(licensedIdentityMigrationMessage);
|
|
nodeDB->generateCryptoKeyPair();
|
|
if (identityWillMigrate)
|
|
nodeDB->licensedIdentityMigrationPending = false;
|
|
}
|
|
#endif
|
|
|
|
if (channels.ensureLicensedOperation()) {
|
|
warnLicensedMode();
|
|
}
|
|
channels.onConfigChanged();
|
|
|
|
if (strcmp(p.call_sign, "N0CALL") == 0) {
|
|
config.lora.tx_enabled = false;
|
|
}
|
|
|
|
service->reloadOwner(false);
|
|
saveChanges(SEGMENT_CONFIG | SEGMENT_NODEDATABASE | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS);
|
|
return true;
|
|
}
|
|
|
|
AdminModule::AdminModule() : ProtobufModule("Admin", meshtastic_PortNum_ADMIN_APP, &meshtastic_AdminMessage_msg)
|
|
{
|
|
// restrict to the admin channel for rx
|
|
// boundChannel = Channels::adminChannel;
|
|
}
|
|
|
|
void AdminModule::setPassKey(meshtastic_AdminMessage *res)
|
|
{
|
|
// Regenerate once there is no session yet or the current key is older than 150s. session_time
|
|
// holds millis(); the Throttle check is rollover-safe, unlike the previous seconds comparison.
|
|
if (!sessionPasskeyValid || !Throttle::isWithinTimespanMs(session_time, 150 * 1000UL)) {
|
|
// Session passkey authenticates admin replies, so it must be unpredictable: prefer the
|
|
// hardware RNG, falling back to the seeded CSPRNG only when no hardware source exists.
|
|
// Hold cryptLock like the signing path does: this runs on the admin receive path, which on
|
|
// nRF52 is the BLE task, and the fill toggles the CC310 that packet crypto also uses while
|
|
// the CryptRNG state is shared with signing.
|
|
{
|
|
concurrency::LockGuard g(cryptLock);
|
|
if (!HardwareRNG::fill(session_passkey, sizeof(session_passkey)))
|
|
CryptRNG.rand(session_passkey, sizeof(session_passkey));
|
|
}
|
|
session_time = millis();
|
|
sessionPasskeyValid = true;
|
|
}
|
|
memcpy(res->session_passkey.bytes, session_passkey, 8);
|
|
res->session_passkey.size = 8;
|
|
printBytes("Set admin key to ", res->session_passkey.bytes, 8);
|
|
// if halfway to session_expire, regenerate session_passkey, reset the timeout
|
|
// set the key in the packet
|
|
}
|
|
|
|
bool AdminModule::checkPassKey(meshtastic_AdminMessage *res)
|
|
{ // check that the key in the packet is still valid
|
|
printBytes("Incoming session key: ", res->session_passkey.bytes, 8);
|
|
printBytes("Expected session key: ", session_passkey, 8);
|
|
// Key is valid for 300s from issue; sessionPasskeyValid guards an unissued session.
|
|
return (sessionPasskeyValid && Throttle::isWithinTimespanMs(session_time, 300 * 1000UL) && res->session_passkey.size == 8 &&
|
|
memcmp(res->session_passkey.bytes, session_passkey, 8) == 0);
|
|
}
|
|
|
|
bool AdminModule::messageIsResponse(const meshtastic_AdminMessage *r)
|
|
{
|
|
if (r->which_payload_variant == meshtastic_AdminMessage_get_channel_response_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_owner_response_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_config_response_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_module_config_response_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_canned_message_module_messages_response_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_device_metadata_response_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_ringtone_response_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_device_connection_status_response_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_node_remote_hardware_pins_response_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_ui_config_response_tag)
|
|
return true;
|
|
else
|
|
return false;
|
|
}
|
|
|
|
// The response variant that answers a getter request, or 0 if the request has none.
|
|
static pb_size_t adminResponseForRequest(pb_size_t requestVariant)
|
|
{
|
|
switch (requestVariant) {
|
|
case meshtastic_AdminMessage_get_channel_request_tag:
|
|
return meshtastic_AdminMessage_get_channel_response_tag;
|
|
case meshtastic_AdminMessage_get_owner_request_tag:
|
|
return meshtastic_AdminMessage_get_owner_response_tag;
|
|
case meshtastic_AdminMessage_get_config_request_tag:
|
|
return meshtastic_AdminMessage_get_config_response_tag;
|
|
case meshtastic_AdminMessage_get_module_config_request_tag:
|
|
return meshtastic_AdminMessage_get_module_config_response_tag;
|
|
case meshtastic_AdminMessage_get_canned_message_module_messages_request_tag:
|
|
return meshtastic_AdminMessage_get_canned_message_module_messages_response_tag;
|
|
case meshtastic_AdminMessage_get_device_metadata_request_tag:
|
|
return meshtastic_AdminMessage_get_device_metadata_response_tag;
|
|
case meshtastic_AdminMessage_get_ringtone_request_tag:
|
|
return meshtastic_AdminMessage_get_ringtone_response_tag;
|
|
case meshtastic_AdminMessage_get_device_connection_status_request_tag:
|
|
return meshtastic_AdminMessage_get_device_connection_status_response_tag;
|
|
case meshtastic_AdminMessage_get_node_remote_hardware_pins_request_tag:
|
|
return meshtastic_AdminMessage_get_node_remote_hardware_pins_response_tag;
|
|
case meshtastic_AdminMessage_get_ui_config_request_tag:
|
|
return meshtastic_AdminMessage_get_ui_config_response_tag;
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p)
|
|
{
|
|
if (p.which_payload_variant != meshtastic_MeshPacket_decoded_tag || p.decoded.portnum != meshtastic_PortNum_ADMIN_APP)
|
|
return;
|
|
// Local admin is answered in-process, and a broadcast is never a request to one remote.
|
|
if (p.to == 0 || isBroadcast(p.to) || p.to == nodeDB->getNodeNum())
|
|
return;
|
|
|
|
meshtastic_AdminMessage admin = meshtastic_AdminMessage_init_zero;
|
|
if (!pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_AdminMessage_msg, &admin))
|
|
return;
|
|
const pb_size_t responseVariant = adminResponseForRequest(admin.which_payload_variant);
|
|
if (!responseVariant)
|
|
return; // not a getter whose response we can pair
|
|
|
|
// Pin the key perhapsEncode will actually encrypt to, resolved the same way it resolves it.
|
|
// p.public_key is NOT it: nothing populates that field on the outgoing path (only perhapsDecode
|
|
// sets it, on RX), so reading it here pinned nothing and left `from` as the sole check.
|
|
bool keyValid = false;
|
|
meshtastic_NodeInfoLite_public_key_t destKey = {0, {0}};
|
|
#if !(MESHTASTIC_EXCLUDE_PKI)
|
|
const bool haveDestKey = nodeDB->copyPublicKey(p.to, destKey);
|
|
keyValid = haveDestKey && wouldEncryptWithPKC(&p, p.channel, haveDestKey);
|
|
#endif
|
|
|
|
// One entry per request (a client sends N indexed get_channel requests, each answered once, so
|
|
// entries must not merge). Free slot, else evict the oldest by rollover-safe elapsed time.
|
|
OutstandingAdminRequest *slot = nullptr;
|
|
for (auto &o : outstandingAdminRequests)
|
|
if (o.to == 0) {
|
|
slot = &o;
|
|
break;
|
|
}
|
|
if (!slot) {
|
|
const uint32_t now = millis();
|
|
slot = &outstandingAdminRequests[0];
|
|
for (auto &o : outstandingAdminRequests)
|
|
if ((uint32_t)(now - o.sentAtMs) > (uint32_t)(now - slot->sentAtMs))
|
|
slot = &o;
|
|
}
|
|
|
|
slot->to = p.to;
|
|
slot->requestId = p.id;
|
|
slot->sentAtMs = millis();
|
|
slot->expectedResponse = responseVariant;
|
|
slot->moduleConfigType = admin.which_payload_variant == meshtastic_AdminMessage_get_module_config_request_tag
|
|
? (uint8_t)admin.get_module_config_request
|
|
: 0;
|
|
slot->keyValid = keyValid;
|
|
if (keyValid)
|
|
memcpy(slot->key, destKey.bytes, 32);
|
|
else
|
|
memset(slot->key, 0, 32);
|
|
LOG_DEBUG("Admin request sent to 0x%08x, expect response", p.to);
|
|
}
|
|
|
|
bool AdminModule::responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t responseVariant, pb_size_t moduleConfigTag)
|
|
{
|
|
// Scan every entry: several requests for the same variant may differ only in pinning, and an
|
|
// unpinned one still authorizes the response even if a pinned one does not.
|
|
for (auto &o : outstandingAdminRequests) {
|
|
if (o.to != mp.from || o.expectedResponse != responseVariant)
|
|
continue;
|
|
// mp.from is unauthenticated, so also require the response to echo our request's packet id
|
|
// (setReplyTo puts it in decoded.request_id). A blind injector must now guess it. Id 0 is
|
|
// no token at all - an omitted request_id decodes to 0 - so such a slot never matches.
|
|
if (o.requestId == 0 || mp.decoded.request_id != o.requestId)
|
|
continue;
|
|
if (!Throttle::isWithinTimespanMs(o.sentAtMs, kOutstandingAdminRequestMs)) {
|
|
o.to = 0; // lapsed; free the slot and keep looking for another live match
|
|
continue;
|
|
}
|
|
// A request pinned to a PKC key must be answered over PKC by that same key.
|
|
if (o.keyValid && (!mp.pki_encrypted || mp.public_key.size != 32 || memcmp(mp.public_key.bytes, o.key, 32) != 0))
|
|
continue;
|
|
// remote_hardware is the only module config that mutates state (the pin table), so require
|
|
// it to answer a request for that exact subtype, not just any get_module_config_request.
|
|
if (moduleConfigTag == meshtastic_ModuleConfig_remote_hardware_tag &&
|
|
o.moduleConfigType != meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG)
|
|
continue;
|
|
o.to = 0; // consume: one request authorizes one response, no replay within the window
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool AdminModule::messageIsRequest(const meshtastic_AdminMessage *r)
|
|
{
|
|
if (r->which_payload_variant == meshtastic_AdminMessage_get_channel_request_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_owner_request_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_config_request_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_module_config_request_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_canned_message_module_messages_request_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_device_metadata_request_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_ringtone_request_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_device_connection_status_request_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_node_remote_hardware_pins_request_tag ||
|
|
r->which_payload_variant == meshtastic_AdminMessage_get_ui_config_request_tag)
|
|
return true;
|
|
else
|
|
return false;
|
|
}
|
|
|
|
void AdminModule::handleSendInputEvent(const meshtastic_AdminMessage_InputEvent &inputEvent)
|
|
{
|
|
LOG_TRACE("Processing input event: event_code=%u, kb_char=%u, touch_x=%u, touch_y=%u", inputEvent.event_code,
|
|
inputEvent.kb_char, inputEvent.touch_x, inputEvent.touch_y);
|
|
|
|
// Create InputEvent for injection.
|
|
//
|
|
// `.source` MUST be a non-null C string: the LOG_INFO below formats it
|
|
// with %s, and passing NULL to the esp-log formatter crashes with
|
|
// Guru Meditation LoadProhibited at strlen(NULL). Other InputBroker
|
|
// sources (buttons, rotary) always set this; the admin path was the
|
|
// only one leaving it default-null.
|
|
InputEvent event = {.source = "admin",
|
|
.inputEvent = (input_broker_event)inputEvent.event_code,
|
|
.kbchar = (unsigned char)inputEvent.kb_char,
|
|
.touchX = inputEvent.touch_x,
|
|
.touchY = inputEvent.touch_y};
|
|
|
|
// Log the event being injected
|
|
LOG_INFO("Injecting input event from admin: source=%s, event=%u, char=%c(%u), touch=(%u,%u)", event.source, event.inputEvent,
|
|
(event.kbchar >= 32 && event.kbchar <= 126) ? event.kbchar : '?', event.kbchar, event.touchX, event.touchY);
|
|
|
|
// Wake the device if asleep
|
|
powerFSM.trigger(EVENT_INPUT);
|
|
#if !defined(MESHTASTIC_EXCLUDE_INPUTBROKER)
|
|
// Inject the event through InputBroker
|
|
if (inputBroker) {
|
|
inputBroker->injectInputEvent(&event);
|
|
} else {
|
|
LOG_ERROR("No InputBroker for event injection");
|
|
}
|
|
#endif
|
|
}
|
|
|
|
void AdminModule::sendWarning(const char *format, ...)
|
|
{
|
|
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
|
if (!cn)
|
|
return;
|
|
|
|
cn->level = meshtastic_LogRecord_Level_WARNING;
|
|
cn->time = getValidTime(RTCQualityFromNet);
|
|
|
|
va_list args;
|
|
va_start(args, format);
|
|
// Format the arguments directly into the notification object
|
|
vsnprintf(cn->message, sizeof(cn->message), format, args);
|
|
va_end(args);
|
|
|
|
service->sendClientNotification(cn);
|
|
}
|
|
|
|
void AdminModule::sendWarningAndLog(const char *format, ...)
|
|
{
|
|
// We need a temporary buffer to hold the formatted text so we can log it
|
|
// Using 250 bytes as a safe upper limit for typical text notifications
|
|
char buf[250];
|
|
|
|
va_list args;
|
|
va_start(args, format);
|
|
vsnprintf(buf, sizeof(buf), format, args);
|
|
va_end(args);
|
|
|
|
// SECURITY NOTE: Both LOG_WARN and sendWarning are printf-style, so we pass
|
|
// "%s", buf rather than 'buf' directly. If 'buf' contained a % symbol (e.g. a
|
|
// user-supplied channel name like "50%"), passing it as the format string would
|
|
// read bogus varargs and could crash. "%s" treats it purely as text.
|
|
LOG_WARN("%s", buf);
|
|
sendWarning("%s", buf);
|
|
}
|
|
|
|
// Strip spaces and fold to lowercase for loose preset-name comparison.
|
|
static void normalizePresetName(const char *src, char *dst, size_t dstLen)
|
|
{
|
|
size_t j = 0;
|
|
for (size_t i = 0; src[i] && j + 1 < dstLen; i++) {
|
|
if (src[i] != ' ')
|
|
dst[j++] = (char)tolower((unsigned char)src[i]);
|
|
}
|
|
dst[j] = '\0';
|
|
}
|
|
|
|
// Record one channel-configuration warning. The first message is kept verbatim in case it
|
|
// turns out to be the only one; nameIssue/pskIssue and the channel bitmask feed the catch-all
|
|
// wording if more than one channel ends up flagged. flushChannelWarnings() emits the result.
|
|
void AdminModule::queueChannelWarning(uint8_t channelIndex, bool nameIssue, bool pskIssue, const char *format, ...)
|
|
{
|
|
if (pendingWarningCount == 0) {
|
|
va_list args;
|
|
va_start(args, format);
|
|
vsnprintf(pendingWarningText, sizeof(pendingWarningText), format, args);
|
|
va_end(args);
|
|
}
|
|
if (channelIndex < MAX_NUM_CHANNELS)
|
|
pendingWarningChannels |= (1u << channelIndex);
|
|
pendingWarningNameIssue |= nameIssue;
|
|
pendingWarningPskIssue |= pskIssue;
|
|
pendingWarningCount++;
|
|
}
|
|
|
|
// Queue the fixed "licensed mode activated" notice, deferring it to commit during an edit
|
|
// transaction so repeated triggers collapse to a single message.
|
|
void AdminModule::warnLicensedMode()
|
|
{
|
|
if (hasOpenEditTransaction)
|
|
pendingLicenseWarning = true;
|
|
else
|
|
sendWarning(licensedModeMessage);
|
|
}
|
|
|
|
// Emit the coalesced channel warning(s): nothing if none queued, the lone message verbatim if
|
|
// exactly one, otherwise a single catch-all naming every flagged channel. The licensed-mode
|
|
// notice, if queued, is emitted once alongside. Always resets state.
|
|
void AdminModule::flushChannelWarnings()
|
|
{
|
|
if (pendingLicenseWarning)
|
|
sendWarning(licensedModeMessage);
|
|
|
|
if (pendingWarningCount == 1) {
|
|
sendWarningAndLog("%s", pendingWarningText);
|
|
} else if (pendingWarningCount > 1) {
|
|
char list[48] = {};
|
|
for (uint8_t i = 0; i < MAX_NUM_CHANNELS; i++) {
|
|
if (!(pendingWarningChannels & (1u << i)))
|
|
continue;
|
|
char num[8];
|
|
snprintf(num, sizeof(num), "%s%u", *list ? ", " : "", i);
|
|
strncat(list, num, sizeof(list) - strlen(list) - 1);
|
|
}
|
|
if (pendingWarningNameIssue && pendingWarningPskIssue)
|
|
sendWarningAndLog("There may be name and PSK issues on channels %s", list); // max 60 bytes
|
|
else if (pendingWarningNameIssue)
|
|
sendWarningAndLog("There may be name issues on channels %s", list); // max 52 bytes
|
|
else
|
|
sendWarningAndLog("There may be PSK issues on channels %s", list); // max 51 bytes
|
|
}
|
|
pendingWarningText[0] = '\0';
|
|
pendingWarningChannels = 0;
|
|
pendingWarningCount = 0;
|
|
pendingWarningNameIssue = false;
|
|
pendingWarningPskIssue = false;
|
|
pendingLicenseWarning = false;
|
|
}
|
|
|
|
/**
|
|
* @brief Emit client warnings for common misconfigurations on a newly committed channel.
|
|
*
|
|
* Called from handleSetChannel() after the channel has been saved. The following checks
|
|
* are performed:
|
|
*
|
|
* - Blank PSK (size == 0) on a non-licensed device: the channel has no encryption.
|
|
* - Blank name with a non-default key (not the default key, 0x01): the name will auto-resolve to
|
|
* the current modem preset name, but the key mismatch means other preset nodes cannot
|
|
* decode traffic on this channel.
|
|
* - Named channel whose name is a case/space variant of the current modem preset: the
|
|
* explicit name prevents auto-resolution; client should clear it.
|
|
* - Same variant match but PSK is not the default key: looks like the preset channel but
|
|
* is incompatible with nodes using the preset's default key.
|
|
*
|
|
* @param cc The channel that was written.
|
|
*/
|
|
void AdminModule::warnOnChannelSet(const meshtastic_Channel &cc)
|
|
{
|
|
if (cc.role == meshtastic_Channel_Role_DISABLED || !cc.has_settings) // don't check unused channels
|
|
return;
|
|
|
|
bool blankPsk = (cc.settings.psk.size == 0 && !owner.is_licensed);
|
|
|
|
if (!config.lora.use_preset) { // custom or unset preset can mistype things too
|
|
const char *mistypePreset = nullptr;
|
|
if (*cc.settings.name) {
|
|
char normChan[32];
|
|
normalizePresetName(cc.settings.name, normChan, sizeof(normChan));
|
|
for (auto preset = _meshtastic_Config_LoRaConfig_ModemPreset_MIN;
|
|
preset <= _meshtastic_Config_LoRaConfig_ModemPreset_MAX;
|
|
preset = (meshtastic_Config_LoRaConfig_ModemPreset)(preset + 1)) {
|
|
const char *name = DisplayFormatters::getModemPresetDisplayName(preset, false, true);
|
|
if (strcmp(name, "Invalid") == 0)
|
|
continue; // skip preset slots without a real display name
|
|
if (strcmp(cc.settings.name, name) == 0)
|
|
break; // exact match - not a mistype, no warning
|
|
char normPreset[32];
|
|
normalizePresetName(name, normPreset, sizeof(normPreset));
|
|
if (strcmp(normChan, normPreset) == 0) {
|
|
mistypePreset = name;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// At most one warning: collapse blank-PSK + mistype into a single catch-all.
|
|
if (blankPsk && mistypePreset)
|
|
queueChannelWarning(cc.index, true, true, "There may be name and PSK issues on channel %d", cc.index);
|
|
else if (mistypePreset)
|
|
queueChannelWarning(cc.index, true, false,
|
|
"Channel %d name '%s' looks like a mistype of '%s' - "
|
|
"make sure to type it exactly!", // max 90 bytes
|
|
cc.index, cc.settings.name, mistypePreset);
|
|
else if (blankPsk)
|
|
queueChannelWarning(cc.index, false, true,
|
|
"Channel %d '%s' has a blank PSK (no encryption) instead of default key", // max 100 bytes
|
|
cc.index, cc.settings.name);
|
|
return;
|
|
}
|
|
|
|
const char *presetName = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true);
|
|
bool isDefaultKey = (cc.settings.psk.size == 1 && cc.settings.psk.bytes[0] == 0x01);
|
|
char normPreset[32], normChan[32]; // max size is 11 plus nul, but allow for future expansion
|
|
normalizePresetName(presetName, normPreset, sizeof(normPreset));
|
|
normalizePresetName(cc.settings.name, normChan, sizeof(normChan));
|
|
|
|
if (!*cc.settings.name) {
|
|
// Blank name resolves to the preset name - A and B are mutually exclusive (psk.size can't be both 0 and >0)
|
|
if (blankPsk)
|
|
queueChannelWarning(cc.index, false, true,
|
|
"Channel %d '%s' has a blank PSK (no encryption) instead of default key", // max 100 bytes
|
|
cc.index, cc.settings.name);
|
|
else if (!isDefaultKey && cc.settings.psk.size > 0)
|
|
queueChannelWarning(cc.index, false, true,
|
|
"Channel %d will resolve to preset '%s' but uses a non-default key - "
|
|
"default-key nodes can't decode it.", // max 102 bytes
|
|
cc.index, presetName);
|
|
return;
|
|
}
|
|
|
|
if (strcmp(normChan, normPreset) != 0) { // name unrelated to preset
|
|
if (blankPsk)
|
|
queueChannelWarning(cc.index, false, true,
|
|
"Channel %d '%s' has a blank PSK (no encryption) instead of default key", // max 100 bytes
|
|
cc.index, cc.settings.name);
|
|
return;
|
|
}
|
|
|
|
bool variantName = (strcmp(cc.settings.name, presetName) != 0);
|
|
bool keyMismatch = (!isDefaultKey && cc.settings.psk.size > 0);
|
|
int issues = (int)blankPsk + (int)variantName + (int)keyMismatch;
|
|
|
|
if (issues > 1) {
|
|
bool hasNameIssue = variantName;
|
|
bool hasPskIssue = blankPsk || keyMismatch;
|
|
if (hasNameIssue && hasPskIssue)
|
|
queueChannelWarning(cc.index, true, true, "There may be name and PSK issues on channel %d", cc.index);
|
|
else if (hasNameIssue)
|
|
queueChannelWarning(cc.index, true, false, "There may be name issues on channel %d", cc.index);
|
|
else
|
|
queueChannelWarning(cc.index, false, true, "There may be PSK issues on channel %d", cc.index);
|
|
return;
|
|
}
|
|
|
|
if (blankPsk)
|
|
queueChannelWarning(cc.index, false, true,
|
|
"Channel %d '%s' has a blank PSK (no encryption) instead of default key", // max 100 bytes
|
|
cc.index, cc.settings.name);
|
|
if (variantName)
|
|
queueChannelWarning(cc.index, true, false,
|
|
"Channel %d name '%s' looks like a mistype of '%s' - "
|
|
"clear the name to use the preset name automatically.", // max 113 bytes
|
|
cc.index, cc.settings.name, presetName);
|
|
if (keyMismatch)
|
|
queueChannelWarning(cc.index, false, true,
|
|
"Channel %d '%s' matches preset '%s' but uses a non-default key - "
|
|
"default-key nodes can't decode it.", // max 108 bytes
|
|
cc.index, cc.settings.name, presetName);
|
|
} // warnOnChannelSet
|
|
|
|
/**
|
|
* @brief Scan all channels for preset-name conflicts after a modem preset change is committed.
|
|
*
|
|
* Called from handleSetConfig() after the LoRa config has been saved, and only when
|
|
* modem_preset actually changed (rejected configs are never passed here). For every
|
|
* named, non-disabled channel two checks are performed:
|
|
*
|
|
* - Name matches the *old* preset (case-insensitive, spaces stripped): the channel
|
|
* was likely tracking the previous preset; the user should rename it if it should
|
|
* follow the new one.
|
|
* - Name matches the *new* preset: the channel name collides with the auto-generated
|
|
* preset name but won't resolve automatically because the name is set explicitly.
|
|
*
|
|
* No-ops if the new config does not use a preset.
|
|
*
|
|
* @param oldLora LoRa config before the update.
|
|
* @param newLora LoRa config after the update.
|
|
*/
|
|
void AdminModule::warnOnLoraPresetChange(const meshtastic_Config_LoRaConfig &oldLora, const meshtastic_Config_LoRaConfig &newLora)
|
|
{
|
|
if (!newLora.use_preset || newLora.modem_preset == oldLora.modem_preset)
|
|
return;
|
|
|
|
char normOld[32] = {}, normNew[32];
|
|
if (oldLora.use_preset) {
|
|
const char *oldName = DisplayFormatters::getModemPresetDisplayName(oldLora.modem_preset, false, true);
|
|
normalizePresetName(oldName, normOld, sizeof(normOld));
|
|
}
|
|
const char *newName = DisplayFormatters::getModemPresetDisplayName(newLora.modem_preset, false, true);
|
|
normalizePresetName(newName, normNew, sizeof(normNew));
|
|
|
|
// Queue one (name) warning per affected channel; flushChannelWarnings() collapses them
|
|
// into a single message - either the lone warning verbatim or a catch-all listing indices.
|
|
for (int i = 0; i < channels.getNumChannels(); i++) {
|
|
const meshtastic_Channel &ch = channels.getByIndex(i);
|
|
if (ch.role == meshtastic_Channel_Role_DISABLED || !ch.has_settings || !*ch.settings.name)
|
|
continue;
|
|
char normChan[32];
|
|
normalizePresetName(ch.settings.name, normChan, sizeof(normChan));
|
|
if (*normOld && strcmp(normChan, normOld) == 0)
|
|
queueChannelWarning(i, true, false,
|
|
"Channel %d name '%s' matches the old preset. "
|
|
"Rename it manually if it should track the new preset.", // max 98 bytes
|
|
i, ch.settings.name);
|
|
else if (strcmp(normChan, normNew) == 0)
|
|
queueChannelWarning(i, true, false,
|
|
"Channel %d '%s' looks like preset '%s' but won't auto-resolve - "
|
|
"clear the name to fix it.", // max 98 bytes
|
|
i, ch.settings.name, newName);
|
|
}
|
|
} // warnOnLoraPresetChange
|
|
|
|
void disableBluetooth()
|
|
{
|
|
#if HAS_BLUETOOTH
|
|
#ifdef ARCH_ESP32
|
|
if (nimbleBluetooth)
|
|
nimbleBluetooth->deinit();
|
|
esp32ReleaseBluetoothMemoryIfUnused();
|
|
#elif defined(ARCH_NRF52)
|
|
if (nrf52Bluetooth)
|
|
nrf52Bluetooth->shutdown();
|
|
#elif defined(MESHTASTIC_LINUX_BLE)
|
|
if (linuxBluetooth)
|
|
linuxBluetooth->deinit();
|
|
#endif
|
|
#endif
|
|
}
|