From d658a84d562c7656cfc54c578a3e7166e516a4e4 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 22 Aug 2026 19:56:53 -0400 Subject: [PATCH] fix: reject implausible RCC6 battery readings --- .github/workflows/rcc6-release-build.yml | 6 +++--- README.md | 24 ++++++++++++------------ docs/releases/1.3-RC3.md | 15 +++++++++++++++ examples/simple_repeater/MyMesh.cpp | 3 ++- examples/simple_room_server/MyMesh.cpp | 3 ++- examples/simple_room_server/UITask.cpp | 13 ++++++++++--- scripts/verify_rcc6_room_server.py | 14 ++++++++++++-- src/helpers/bridges/MQTTBridge.cpp | 10 ++++++++-- variants/heltec_rcc6/heltec_rcc6.cpp | 3 ++- variants/heltec_rcc6/platformio.ini | 4 ++-- webui/index.html | 19 ++++++++++--------- 11 files changed, 78 insertions(+), 36 deletions(-) create mode 100644 docs/releases/1.3-RC3.md diff --git a/.github/workflows/rcc6-release-build.yml b/.github/workflows/rcc6-release-build.yml index 69434851..eb87e7fe 100644 --- a/.github/workflows/rcc6-release-build.yml +++ b/.github/workflows/rcc6-release-build.yml @@ -85,9 +85,9 @@ jobs: out = Path("release") out.mkdir() - app_name = "NeonPocketMC-RCC6-Ultimate-Observer-v1.3.0-rc.2-app.bin" - recovery_name = "NeonPocketMC-RCC6-Ultimate-Observer-v1.3.0-rc.2-full-recovery-preserves-meshcore-settings.bin" - configurator_name = "NeonPocketMC-RCC6-Ultimate-Server-v1.3.0-rc.2-configurator.zip" + app_name = "NeonPocketMC-RCC6-Ultimate-Observer-v1.3.0-rc.3-app.bin" + recovery_name = "NeonPocketMC-RCC6-Ultimate-Observer-v1.3.0-rc.3-full-recovery-preserves-meshcore-settings.bin" + configurator_name = "NeonPocketMC-RCC6-Ultimate-Server-v1.3.0-rc.3-configurator.zip" (out / app_name).write_bytes(app) (out / recovery_name).write_bytes(merged) diff --git a/README.md b/README.md index 3570d97c..825e7072 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ The USB configurator accepts only the two full Room Server profiles (and the exi ## What each role does -- **RCC6 Ultimate observer/repeater:** forwards mesh traffic, observes packets, joins 2.4 GHz Wi-Fi, publishes to at most two MQTT brokers, and serves the full Ultimate dashboard. The human-first analytics and Nearby overhaul arrived in RC1; current `v1.3.0-rc.2` adds explicit saved-location advert sharing. Prior server/repeater releases remain available. +- **RCC6 Ultimate observer/repeater:** forwards mesh traffic, observes packets, joins 2.4 GHz Wi-Fi, publishes to at most two MQTT brokers, and serves the full Ultimate dashboard. Current `v1.3.0-rc.3` rejects impossible RCC6 battery readings while retaining explicit saved-location advert sharing. Prior server/repeater releases remain available. - **Room Server, both sizes:** hosts the standard MeshCore room/client protocol with 32 recent posts held in RAM. A reboot clears those buffered posts. Repeating is optional but defaults off; a separate repeater is recommended. - **Room Server, minimal:** LoRa room service and USB CLI only. It has no Wi-Fi, Web dashboard, or MQTT code to configure. - **Room Server, full:** adds 2.4 GHz AP/STA onboarding, the authenticated dashboard, and one-way MQTT observation. MQTT data is never injected into RF. @@ -65,15 +65,15 @@ All roles use fail-closed MeshCore storage and default their own adverts to **3- | Image | Setup after flashing | |---|---| -| Ultimate repeater/observer `v1.3.0-rc.2` | Supplied Windows/Linux network wizard | -| Ultimate Room Server full headless or full TFT | `v1.3.0-rc.2` network wizard; it also changes both room passwords | +| Ultimate repeater/observer `v1.3.0-rc.3` | Supplied Windows/Linux network wizard | +| Ultimate Room Server full headless or full TFT | `v1.3.0-rc.3` network wizard; it also changes both room passwords | | Room Server minimal headless or minimal TFT | USB serial CLI or the generic MeshCore USB configurator; no network wizard | Always attach a tuned LoRa antenna before powering or transmitting. Flash the selected application image at `0x10000`, leave USB connected, and do not deploy until the radio settings and passwords have been changed. ### Guided setup for observer and full profiles -Download `NeonPocketMC-RCC6-Ultimate-Server-v1.3.0-rc.2-configurator.zip` from the same release as the selected firmware. +Download `NeonPocketMC-RCC6-Ultimate-Server-v1.3.0-rc.3-configurator.zip` from the same release as the selected firmware. - **Windows:** double-click `Configure-RCC6-Windows.cmd`. - **Linux:** open the extracted folder in a terminal and run `sh configure-rcc6-linux.sh`. @@ -264,18 +264,18 @@ Install [esptool](https://docs.espressif.com/projects/esptool/en/latest/esp32c6/ The current RCC6 Ultimate server release files are: -- `NeonPocketMC-RCC6-Ultimate-Observer-v1.3.0-rc.2-app.bin` -- `NeonPocketMC-RCC6-Ultimate-Observer-v1.3.0-rc.2-full-recovery-preserves-meshcore-settings.bin` -- `NeonPocketMC-RCC6-Ultimate-Server-v1.3.0-rc.2-configurator.zip` +- `NeonPocketMC-RCC6-Ultimate-Observer-v1.3.0-rc.3-app.bin` +- `NeonPocketMC-RCC6-Ultimate-Observer-v1.3.0-rc.3-full-recovery-preserves-meshcore-settings.bin` +- `NeonPocketMC-RCC6-Ultimate-Server-v1.3.0-rc.3-configurator.zip` -Room Server `v1.3.0-rc.2` uses these profile-specific names: +Room Server `v1.3.0-rc.3` uses these profile-specific names: | Profile | Application update | Recovery image | |---|---|---| -| Minimal headless | `NeonPocketMC-RCC6-Ultimate-Room-Server-minimal-headless-v1.3.0-rc.2-app.bin` | `NeonPocketMC-RCC6-Ultimate-Room-Server-minimal-headless-v1.3.0-rc.2-full-recovery-preserves-meshcore-settings.bin` | -| Minimal TFT | `NeonPocketMC-RCC6-Ultimate-Room-Server-minimal-tft-v1.3.0-rc.2-app.bin` | `NeonPocketMC-RCC6-Ultimate-Room-Server-minimal-tft-v1.3.0-rc.2-full-recovery-preserves-meshcore-settings.bin` | -| Full headless | `NeonPocketMC-RCC6-Ultimate-Room-Server-full-headless-v1.3.0-rc.2-app.bin` | `NeonPocketMC-RCC6-Ultimate-Room-Server-full-headless-v1.3.0-rc.2-full-recovery-preserves-meshcore-settings.bin` | -| Full TFT | `NeonPocketMC-RCC6-Ultimate-Room-Server-full-tft-v1.3.0-rc.2-app.bin` | `NeonPocketMC-RCC6-Ultimate-Room-Server-full-tft-v1.3.0-rc.2-full-recovery-preserves-meshcore-settings.bin` | +| Minimal headless | `NeonPocketMC-RCC6-Ultimate-Room-Server-minimal-headless-v1.3.0-rc.3-app.bin` | `NeonPocketMC-RCC6-Ultimate-Room-Server-minimal-headless-v1.3.0-rc.3-full-recovery-preserves-meshcore-settings.bin` | +| Minimal TFT | `NeonPocketMC-RCC6-Ultimate-Room-Server-minimal-tft-v1.3.0-rc.3-app.bin` | `NeonPocketMC-RCC6-Ultimate-Room-Server-minimal-tft-v1.3.0-rc.3-full-recovery-preserves-meshcore-settings.bin` | +| Full headless | `NeonPocketMC-RCC6-Ultimate-Room-Server-full-headless-v1.3.0-rc.3-app.bin` | `NeonPocketMC-RCC6-Ultimate-Room-Server-full-headless-v1.3.0-rc.3-full-recovery-preserves-meshcore-settings.bin` | +| Full TFT | `NeonPocketMC-RCC6-Ultimate-Room-Server-full-tft-v1.3.0-rc.3-app.bin` | `NeonPocketMC-RCC6-Ultimate-Room-Server-full-tft-v1.3.0-rc.3-full-recovery-preserves-meshcore-settings.bin` | Do not substitute a similarly named observer, profile, or development artifact. diff --git a/docs/releases/1.3-RC3.md b/docs/releases/1.3-RC3.md new file mode 100644 index 00000000..21c4f867 --- /dev/null +++ b/docs/releases/1.3-RC3.md @@ -0,0 +1,15 @@ +# RCC6 Ultimate Observer & Room Server v1.3.0 RC3 + +This experimental RCC6-L62/SX1262 safety update contains the Ultimate MQTT observer/repeater and all four Room Server profiles. + +## Battery-reading correction + +RCC6 ADC samples above the plausible 4.50 V ceiling for a single-cell battery are now treated as unavailable. TFT and Web dashboards show an unknown state instead of an impossible voltage, stale low-battery warnings are cleared, and MQTT status omits an unavailable measurement. + +## Files and flashing + +The release includes observer and minimal/full Room Server app images, matching settings-preserving recovery images, the Windows/Linux configurator, exact source/licenses, and an aggregate SHA-256 manifest. + +Normal updates use the selected app image at `0x10000`. Use a matching recovery image at `0x0` only for bootloader/partition recovery. Every recovery image stops before SPIFFS; never erase the whole chip. + +MQTT remains observation-only and never injects broker traffic into the mesh. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 6d1fe816..b032058e 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -299,7 +299,8 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t uint8_t perm_mask = ~(payload[1]); // NEW: first reserved byte (of 4), is now inverse mask to apply to permissions telemetry.reset(); - telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); + const uint16_t battery_mv = board.getBattMilliVolts(); + if (battery_mv) telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)battery_mv / 1000.0f); // query other sensors -- target specific if ((sender->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) { diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index ef3cc155..48840cfe 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -196,7 +196,8 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t uint8_t perm_mask = ~(payload[1]); // NEW: first reserved byte (of 4), is now inverse mask to apply to permissions telemetry.reset(); - telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); + const uint16_t battery_mv = board.getBattMilliVolts(); + if (battery_mv) telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)battery_mv / 1000.0f); // query other sensors -- target specific if ((sender->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) { perm_mask = 0x00; // just base telemetry allowed diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index b5a01e5f..ab6f7e29 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -105,6 +105,8 @@ void UITask::renderCurrScreen() { if (room.batt_mv != 0) { if (!_battery_low && room.batt_mv <= ROOM_LOW_BATTERY_MV) _battery_low = true; else if (_battery_low && room.batt_mv >= ROOM_LOW_BATTERY_CLEAR_MV) _battery_low = false; + } else { + _battery_low = false; } char value[48]; @@ -144,9 +146,14 @@ void UITask::renderCurrScreen() { _display->print(value); _display->setCursor(3, 86); - snprintf(value, sizeof(value), "TXQ %u ERR %04X BAT %.2fV%s", - (unsigned)room.tx_queue, (unsigned)room.error_flags, - room.batt_mv / 1000.0f, _battery_low ? " LOW" : ""); + if (room.batt_mv) { + snprintf(value, sizeof(value), "TXQ %u ERR %04X BAT %.2fV%s", + (unsigned)room.tx_queue, (unsigned)room.error_flags, + room.batt_mv / 1000.0f, _battery_low ? " LOW" : ""); + } else { + snprintf(value, sizeof(value), "TXQ %u ERR %04X BAT --", + (unsigned)room.tx_queue, (unsigned)room.error_flags); + } _display->setColor(room.error_flags || _battery_low ? UIColor::warning_txt : UIColor::primary_txt); _display->print(value); diff --git a/scripts/verify_rcc6_room_server.py b/scripts/verify_rcc6_room_server.py index 2ac52244..5fdb5c2e 100644 --- a/scripts/verify_rcc6_room_server.py +++ b/scripts/verify_rcc6_room_server.py @@ -69,9 +69,11 @@ def main() -> None: "WiFi.disconnect(true", "next_memory_probe = millis() + 30000") ui_cpp = read("examples/simple_room_server/UITask.cpp") + board_cpp = read("variants/heltec_rcc6/heltec_rcc6.cpp") boot_h = read("examples/simple_room_server/NeonPocketSplash.h") require(ui_cpp, "NeonPocketSplash::drawFrame", "NeonPocketSplash::FRAME_MILLIS", - "NEONPOCKET ROOM") + "NEONPOCKET ROOM", '"TXQ %u ERR %04X BAT --"') + require(board_cpp, "measured <= 4500U") require(boot_h, "DURATION_MILLIS = 3200", "FRAME_MILLIS = 125", "NEONPOCKETMC", "VECTOR BOOT", "RADIO LINK", "ROOM SERVICES", "MESH READY", "MAGENTA", "drawPocket", "MESHCORE ROOM SERVER") @@ -100,7 +102,8 @@ def main() -> None: "What this RCC6 can hear", "Signal view", "drawNeighborMap", "renderTrafficMix", "drawSignalBars", "renderFreshBars", "/api/neighbors", "No advertised repeater locations yet", - 'data-k="gps.adv_loc"', "Share saved coordinates") + 'data-k="gps.adv_loc"', "Share saved coordinates", + 'battValid?battVolts.toFixed(2):"โ€”"') web_server = read("src/helpers/esp32/WebConfigServer.cpp") web_keys = read("src/helpers/WebConfigKeys.h") require(web_server, 'radio["advert_loc_policy"]', "wcIsValidAdvertLocationPolicy", @@ -108,6 +111,13 @@ def main() -> None: require(web_keys, '"gps.adv_loc"', "wcIsValidAdvertLocationPolicy") require(mesh_h, "buildNeighborsJson", "has_location", "latitude_e6", "longitude_e6") require(mesh_cpp, "buildNeighborsJson", "has_location", "latitude_e6", "longitude_e6") + repeater_mesh = read("examples/simple_repeater/MyMesh.cpp") + require(mesh_cpp, "if (battery_mv) telemetry.addVoltage") + require(repeater_mesh, "if (battery_mv) telemetry.addVoltage") + mqtt = read("src/helpers/bridges/MQTTBridge.cpp") + require(mqtt, "if (measured) battery_mv = measured;") + require(web, "push(st.hist.batt,battValid?battVolts:null)", + "data.filter(Number.isFinite)", "if(!Number.isFinite(v))") for unsafe_route in ('/api/room/post', '/api/room/delete', '/api/room/client'): if unsafe_route in web: raise AssertionError(f"unsafe room mutation route present: {unsafe_route}") diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 4b3f6256..6fdf1aeb 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -2487,7 +2487,10 @@ void MQTTBridge::publishStatusToSlot(int index) { int packets_sent = -1; int packets_received = -1; - if (_board) battery_mv = _board->getBattMilliVolts(); + if (_board) { + const uint16_t measured = _board->getBattMilliVolts(); + if (measured) battery_mv = measured; + } if (_ms) uptime_secs = _ms->getMillis() / 1000; if (_dispatcher) { errors = _dispatcher->getErrFlags(); @@ -3365,7 +3368,10 @@ bool MQTTBridge::publishStatus() { int packets_sent = -1; int packets_received = -1; - if (_board) battery_mv = _board->getBattMilliVolts(); + if (_board) { + const uint16_t measured = _board->getBattMilliVolts(); + if (measured) battery_mv = measured; + } if (_ms) uptime_secs = _ms->getMillis() / 1000; if (_dispatcher) { errors = _dispatcher->getErrFlags(); diff --git a/variants/heltec_rcc6/heltec_rcc6.cpp b/variants/heltec_rcc6/heltec_rcc6.cpp index d0a90fd7..640d957b 100644 --- a/variants/heltec_rcc6/heltec_rcc6.cpp +++ b/variants/heltec_rcc6/heltec_rcc6.cpp @@ -45,5 +45,6 @@ uint16_t HeltecRCC6Board::getBattMilliVolts() { raw /= 8; digitalWrite(PIN_ADC_CTRL, !ADC_CTRL_ENABLED); - return adcMultiplier * raw; + const uint32_t measured = static_cast(adcMultiplier * raw); + return measured <= 4500U ? static_cast(measured) : 0; } diff --git a/variants/heltec_rcc6/platformio.ini b/variants/heltec_rcc6/platformio.ini index 60124371..b05e3312 100644 --- a/variants/heltec_rcc6/platformio.ini +++ b/variants/heltec_rcc6/platformio.ini @@ -51,7 +51,7 @@ board_build.embed_files = src/certs/x509_crt_bundle.bin build_flags = ${heltec_rcc6.build_flags} -D NEONPOCKET_RCC6_REPEATER=1 - -D FIRMWARE_VERSION='"v1.17.0-rxfix-rcc6-ultimate-v1.3.0-rc.2"' + -D FIRMWARE_VERSION='"v1.17.0-rxfix-rcc6-ultimate-v1.3.0-rc.3"' -D ADVERT_NAME='"RCC6 Ultimate Observer"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 @@ -90,7 +90,7 @@ build_flags = ${heltec_rcc6.build_flags} -D NEONPOCKET_RCC6_ROOM_SERVER=1 -D NEONPOCKET_SAFE_SPIFFS_BOOTSTRAP=1 - -D FIRMWARE_VERSION='"v1.17.0-rcc6-room-v1.3.0-rc.2"' + -D FIRMWARE_VERSION='"v1.17.0-rcc6-room-v1.3.0-rc.3"' -D ADVERT_NAME='"RCC6 Room Server"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 diff --git a/webui/index.html b/webui/index.html index 382e75ff..fedef620 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1320,9 +1320,10 @@

Rebooting…

tile("Post deliveries",s.room_pushes,"pushes queued")+ tile("Build profile",s.profile||"full-headless",s.experimental?"experimental":"supported"): tile("Neighbours",s.neighbors,"recently heard"); + var battValid=Number(s.batt_mv)>0,battVolts=battValid?s.batt_mv/1000:null; $("#tiles").innerHTML= tile("Uptime",fmtUp(s.uptime_s))+ - tile("Battery",(s.batt_mv/1000).toFixed(2),"V")+ + tile("Battery",battValid?battVolts.toFixed(2):"โ€”",battValid?"V":"unavailable")+ roleTiles+ tile("Last RF packet",s.recv?fmtAge(s.last_rx_age):"never")+ tile("Packet rate",rxrate.toFixed(1)+" RX","/min ยท "+txrate.toFixed(1)+" TX")+ @@ -1350,7 +1351,7 @@

Rebooting…

push(st.hist.noise,s.noise);push(st.hist.txair,txload);push(st.hist.rxair,rxload); push(st.hist.txq,s.tx_queue);push(st.hist.mqttq,s.mqtt_queue);push(st.hist.heap,kb); push(st.hist.mqttok,mqttRate);push(st.hist.mqtterr,mqttErrRate); - push(st.hist.heapmin,minKb);push(st.hist.batt,s.batt_mv/1000);push(st.hist.wifi,s.wifi_rssi); + push(st.hist.heapmin,minKb);push(st.hist.batt,battValid?battVolts:null);push(st.hist.wifi,s.wifi_rssi); renderTrafficMix(s);drawSignalBars();renderFreshBars(); var blue=color("--acc","#2b7de9"),green=color("--ok","#1d9d5f"),red=color("--err","#d64545"),orange=color("--warn","#c8871a"),mut=color("--mut","#66738a"); legend("#leg-packets",[{n:"RX",v:rxrate.toFixed(1)+"/min",c:blue},{n:"TX",v:txrate.toFixed(1)+"/min",c:green}]); @@ -1365,7 +1366,7 @@

Rebooting…

plot($("#spark-mqtt"),[{d:st.hist.mqttok,c:green},{d:st.hist.mqtterr,c:red}],false,0); legend("#leg-heap",[{n:"free",v:kb+" KB",c:green},{n:"minimum",v:minKb+" KB",c:red}]); plot($("#spark-heap"),[{d:st.hist.heap,c:green},{d:st.hist.heapmin,c:red}],false,0); - legend("#leg-link",[{n:"battery",v:(s.batt_mv/1000).toFixed(2)+" V",c:green},{n:"WiFi",v:s.wifi_rssi+" dBm",c:blue}]); + legend("#leg-link",[{n:"battery",v:battValid?battVolts.toFixed(2)+" V":"โ€”",c:green},{n:"WiFi",v:s.wifi_rssi+" dBm",c:blue}]); plot($("#spark-link"),[{d:st.hist.batt,c:green},{d:st.hist.wifi,c:blue}],true); $("#stat-slots").innerHTML=sl.length?sl.map(function(x){ // "filt" is sent only when the slot is not on the all-types default. A @@ -1384,7 +1385,7 @@

Rebooting…

if(st.statsOn)st.statsTimer=setTimeout(pollStats,3000); }); } -function push(a,v){a.push(Number(v)||0);if(a.length>120)a.shift()} +function push(a,v){var n=Number(v);a.push(v==null||!Number.isFinite(n)?null:n);if(a.length>120)a.shift()} function color(name,fallback){return getComputedStyle(document.documentElement).getPropertyValue(name).trim()||fallback} function plot(cv,series,perSeries,fixedMin,fixedMax){ var dpr=window.devicePixelRatio||1,w=cv.clientWidth,h=90; @@ -1392,14 +1393,14 @@

Rebooting…

var g=cv.getContext("2d");g.scale(dpr,dpr);g.clearRect(0,0,w,h); g.strokeStyle=color("--line","#e2e7ee");g.lineWidth=1; [0.25,0.5,0.75].forEach(function(p){g.beginPath();g.moveTo(0,h*p);g.lineTo(w,h*p);g.stroke()}); - var all=[];series.forEach(function(s){all=all.concat(s.d)}); + var all=[];series.forEach(function(s){all=all.concat(s.d.filter(Number.isFinite))}); if(all.length<2)return; var commonMin=fixedMin!=null?fixedMin:Math.min.apply(null,all),commonMax=fixedMax!=null?fixedMax:Math.max.apply(null,all); - series.forEach(function(s){var data=s.d;if(data.length<2)return; - var mn=perSeries?Math.min.apply(null,data):commonMin,mx=perSeries?Math.max.apply(null,data):commonMax; + series.forEach(function(s){var data=s.d,valid=data.filter(Number.isFinite);if(valid.length<2)return; + var mn=perSeries?Math.min.apply(null,valid):commonMin,mx=perSeries?Math.max.apply(null,valid):commonMax; if(fixedMin!=null)mn=fixedMin;if(fixedMax!=null)mx=fixedMax;if(mx===mn){mx+=1;mn-=1} - g.strokeStyle=s.c;g.lineWidth=2;g.lineJoin="round";g.beginPath(); - data.forEach(function(v,i){var x=i/(data.length-1)*(w-4)+2,y=h-4-(v-mn)/(mx-mn)*(h-10);i?g.lineTo(x,y):g.moveTo(x,y)}); + g.strokeStyle=s.c;g.lineWidth=2;g.lineJoin="round";g.beginPath();var drawing=false; + data.forEach(function(v,i){if(!Number.isFinite(v)){drawing=false;return}var x=i/(data.length-1)*(w-4)+2,y=h-4-(v-mn)/(mx-mn)*(h-10);drawing?g.lineTo(x,y):g.moveTo(x,y);drawing=true}); g.stroke(); }); }