From 85d8c80bd08f70e33ba9877823c43ed1011c04ba Mon Sep 17 00:00:00 2001 From: Rob Thomson Date: Tue, 7 Apr 2026 16:53:11 +0100 Subject: [PATCH 1/3] wip --- .../subprocess_conout.cpython-314.pyc | Bin 0 -> 11644 bytes src/dashx/lib/events.lua | 119 ++ src/dashx/{tasks/logging => lib}/logging.lua | 8 +- src/dashx/lib/logs.lua | 90 + src/dashx/lib/runtime.lua | 625 ++++++ src/dashx/lib/sensors.lua | 301 +++ .../{tasks/sensors => }/lib/smartfuel.lua | 35 +- .../sensors => }/lib/smartfuelvoltage.lua | 30 +- .../{tasks/telemetry => lib}/telemetry.lua | 42 +- src/dashx/lib/utils.lua | 22 +- src/dashx/main.lua | 354 ++-- src/dashx/tasks/callback/callback.lua | 49 - src/dashx/tasks/callback/init.lua | 10 - src/dashx/tasks/developer/developer.lua | 26 - src/dashx/tasks/developer/init.lua | 10 - src/dashx/tasks/events/events.lua | 47 - src/dashx/tasks/events/init.lua | 9 - src/dashx/tasks/events/tasks/flightmode.lua | 73 - src/dashx/tasks/events/tasks/rxmap.lua | 48 - src/dashx/tasks/events/tasks/stats.lua | 81 - src/dashx/tasks/events/tasks/switches.lua | 86 - src/dashx/tasks/events/tasks/telemetry.lua | 90 - src/dashx/tasks/events/tasks/timer.lua | 54 - src/dashx/tasks/logger/init.lua | 10 - src/dashx/tasks/logger/lib/log.lua | 95 - src/dashx/tasks/logger/logger.lua | 31 - src/dashx/tasks/logging/init.lua | 10 - src/dashx/tasks/onconnect/init.lua | 9 - src/dashx/tasks/onconnect/tasks.lua | 141 -- .../tasks/onconnect/tasks/high/apiversion.lua | 16 - .../onconnect/tasks/high/sensorstats.lua | 23 - .../tasks/onconnect/tasks/high/timer.lua | 26 - src/dashx/tasks/onconnect/tasks/high/uid.lua | 39 - .../tasks/onconnect/tasks/low/battery.lua | 41 - src/dashx/tasks/onconnect/tasks/low/rxmap.lua | 36 - .../tasks/medium/modelpreferences.lua | 51 - src/dashx/tasks/sensors/frsky.lua | 178 -- src/dashx/tasks/sensors/init.lua | 10 - src/dashx/tasks/sensors/sensors.lua | 79 - src/dashx/tasks/sensors/sim.lua | 150 -- src/dashx/tasks/sensors/smart.lua | 169 -- src/dashx/tasks/simevent/init.lua | 10 - src/dashx/tasks/simevent/simevent.lua | 40 - src/dashx/tasks/tasks.lua | 643 ------- src/dashx/tasks/telemetry/init.lua | 9 - src/dashx/tasks/timer/init.lua | 9 - src/dashx/tasks/timer/timer.lua | 98 - src/dashx/tools/logs.lua | 1268 +++++++++++++ src/dashx/widgets/dashboard/configure.lua | 236 +++ src/dashx/widgets/dashboard/dashboard.lua | 1685 ++++++----------- src/dashx/widgets/dashboard/lib/toolbar.lua | 180 ++ 51 files changed, 3708 insertions(+), 3793 deletions(-) create mode 100644 .vscode/scripts/__pycache__/subprocess_conout.cpython-314.pyc create mode 100644 src/dashx/lib/events.lua rename src/dashx/{tasks/logging => lib}/logging.lua (95%) create mode 100644 src/dashx/lib/logs.lua create mode 100644 src/dashx/lib/runtime.lua create mode 100644 src/dashx/lib/sensors.lua rename src/dashx/{tasks/sensors => }/lib/smartfuel.lua (89%) rename src/dashx/{tasks/sensors => }/lib/smartfuelvoltage.lua (91%) rename src/dashx/{tasks/telemetry => lib}/telemetry.lua (92%) delete mode 100644 src/dashx/tasks/callback/callback.lua delete mode 100644 src/dashx/tasks/callback/init.lua delete mode 100644 src/dashx/tasks/developer/developer.lua delete mode 100644 src/dashx/tasks/developer/init.lua delete mode 100644 src/dashx/tasks/events/events.lua delete mode 100644 src/dashx/tasks/events/init.lua delete mode 100644 src/dashx/tasks/events/tasks/flightmode.lua delete mode 100644 src/dashx/tasks/events/tasks/rxmap.lua delete mode 100644 src/dashx/tasks/events/tasks/stats.lua delete mode 100644 src/dashx/tasks/events/tasks/switches.lua delete mode 100644 src/dashx/tasks/events/tasks/telemetry.lua delete mode 100644 src/dashx/tasks/events/tasks/timer.lua delete mode 100644 src/dashx/tasks/logger/init.lua delete mode 100644 src/dashx/tasks/logger/lib/log.lua delete mode 100644 src/dashx/tasks/logger/logger.lua delete mode 100644 src/dashx/tasks/logging/init.lua delete mode 100644 src/dashx/tasks/onconnect/init.lua delete mode 100644 src/dashx/tasks/onconnect/tasks.lua delete mode 100644 src/dashx/tasks/onconnect/tasks/high/apiversion.lua delete mode 100644 src/dashx/tasks/onconnect/tasks/high/sensorstats.lua delete mode 100644 src/dashx/tasks/onconnect/tasks/high/timer.lua delete mode 100644 src/dashx/tasks/onconnect/tasks/high/uid.lua delete mode 100644 src/dashx/tasks/onconnect/tasks/low/battery.lua delete mode 100644 src/dashx/tasks/onconnect/tasks/low/rxmap.lua delete mode 100644 src/dashx/tasks/onconnect/tasks/medium/modelpreferences.lua delete mode 100644 src/dashx/tasks/sensors/frsky.lua delete mode 100644 src/dashx/tasks/sensors/init.lua delete mode 100644 src/dashx/tasks/sensors/sensors.lua delete mode 100644 src/dashx/tasks/sensors/sim.lua delete mode 100644 src/dashx/tasks/sensors/smart.lua delete mode 100644 src/dashx/tasks/simevent/init.lua delete mode 100644 src/dashx/tasks/simevent/simevent.lua delete mode 100644 src/dashx/tasks/tasks.lua delete mode 100644 src/dashx/tasks/telemetry/init.lua delete mode 100644 src/dashx/tasks/timer/init.lua delete mode 100644 src/dashx/tasks/timer/timer.lua create mode 100644 src/dashx/tools/logs.lua create mode 100644 src/dashx/widgets/dashboard/configure.lua create mode 100644 src/dashx/widgets/dashboard/lib/toolbar.lua diff --git a/.vscode/scripts/__pycache__/subprocess_conout.cpython-314.pyc b/.vscode/scripts/__pycache__/subprocess_conout.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9ddb9bde78d5106cf901ad4c75a9fc72ac3f7c4b GIT binary patch literal 11644 zcmb_CYj7LKd3(SCIJ`j;6d&LNd_N>n4~mi{QHm)NG%1lFas)vsCWU~&kwgRn@ZG_b zDwB-q{xQ{dCTb=#P7}9L8hdI_J1HTM0(xernl>{X|6@!gcGAqW z-`?Q>5CKJ}ljU%4_uFs3-Tij=doA{xj5-AAH%;}yyLAZt7p}-jl__jrS0fZhmk~`2 zBOZSh!^Dt+SJX=|C9f3QNS+kGDqbai37!zYYF<4}xJ)wH+_wSIN}8lqClnHlr)V{= zr8T_nGQsOFD|iEZjnfKOO|Fkzg1-BX5mLLJ@*~6>>kmbXT0pEKV6s zRUqCIu3{q=A4MKvS`n_4dp&`|Q5h%q&7)8) zL(B0B^%aOV0IlOPH1|EGl*)XKh-m~&(;k?$yUwaso&jG6Ydo79MK1C7>GEmHbxyA5 zzTxcNK+hukyGySonL=@%b`!hwYtHt=^Eofqa^KG}pJKiCm{0LpPTO~Z7v*8MYL^-3 z)?@{Yk=$D3(w=Lo>sIX2b0=-5OfmQUoV1Je+)3M&qbZm6q^)q#GH&h*JUy1H$)#>3 ztgzS^J7?jR@pIqLF$+%)TQ2Rgvo!!eEaT+9;@%b;d*|#f=6cAb&oR3%$+)@i|6_K= z#?H--bWP=`%O%=ef#j{=4^0=xsiG|vC`4rO4$~zdVUN(IbQx`>$wLad9NHE9QJQE% z9n~LW;?532pcaE+{)H&R3Dgya4Ktzcqiz73VjSZQ`8eLouxx}4WyBm(pdfU7+Ar5J z8LT#NeH-)|iX)Gr3Ed@}9ZEs%&rqHWh8>uUV47q2JhFEIhh?}}h!@D&SlBO+K6Z}d zFtOIwR^e3tsi|jz{7`IWD&XVhuO4Lh`3N`F^*rZ~1eht#&jzDBH^s$fqHM&^aGcj4 z2}fdlSM&uz@Ac0!{wv;V42)|5g5~hfX~Y?(_2&46Z78B0>mUIjt|KgBc~EXIo`P)) zbYi76l~Gj)xiEe~YRGTL>?lYL*^QJ%{XBvB#o~!|kb-sC>FFC89`FwJIR{1@UZ_ks zSPB|hEzYoY!RjHCdBmEbBA7CZ4Thihg@OUr3P7v`xOT|mD6KKBm$a;FS~gAAn~~Qd z-bw8-k0&(ds97;-{yxy>Hf$@3;o!7e% z3B*FUZ18%Yi}^yC7PHqo8)P{?6bv)rh}X;FNeZg*p)uMG1ZIOwD8PAza<7-;eSFaG z_3=C#oQd%a2gq2#iwhPR$X4Jg&aef-Vf*JBC|zQ`HNOtLN3jD3!=c2)1T_a7 zip;XjPyvZRa!hEJ#YaIPuPg>QLF@JUK`_H1=CA;2?K}{d&hR0O)AA(#cgZ6?1*oEI z_$-b#DdW1Sb9LYWb?Qe0Yt-<4C=GA!t65rjePLxFxv%2}`4@Weymt`Nq%5or`mb7Ny8f=ZMxtQ~j4b7mVL6G}46 z3IzSUp9zKFz8f*>7I~arH=Ba_gz3 zv$xN#QH^OR_pDJ>X{~K}VvTA_YxgZvZyN6y*QnOCw(Qo$RC#-%ynT)8NNej>R3g;2 zeDTfcJJV~_{WQJkoUuk0%YKnj6c%Jy+(4_p?M zRXX(uA42?ZNA4VjeSZvvAXosWm9;2H^eBUbq_~tUUX)bzBAaZ`|{u=EkrR5n_102@)6 zG-c10vYv5%J55YA%NR6qEOTd~8XKBwmEkUIJryM2o@T032D%Q&rQA2KwJQgeOZjt< zxxZtr#dcJ)=Zl8~VuWjK4r^3H3KSV#lJqAtU!fODHYfpE5p`h>;e zBD-K>#Z!>->C@y1EJz^1OtUeVypwQRYR%7KKlVEB#kiAG;b-!xNo9|Q-uo6Qi}}a$ z8J6QhJB)=d6dg%DxkEPO_6_eqcIQa>jP9U_g=InVXyiH6V|}5P^u-=L+5fDN6&;+L=LPk- z2+v0r+ya3*nna6GG*8)+Kr&;hJj3wgd|=2I4uGjrGVUJmj!n2-6Yh-XEz|%)#&{VQ z{fTq2*;$4i4$np$S0zhbHS+??%(5+*C@==GSeFSpk1rHsq;N-7Kg)o{Q-qFSc*fy$ z(8K*;8TSncM!7ag54#Y(BjPIZKu|7Y~u#@=&`}}3& z{gDs{AH4kn8N`E7VP+8=Cb6{026>(d3z~V@OBhkm&IY`a`-frTzH!*AYHhWPNisT3 z8Cx@h%FYAEb@=CA23t@xIrVKxeMfv?)4V@vJ{TX`v{WvcZktw09$Ffc<`w{$_N7cs z2~*R0GY+A|pV=f0DY7F$cC5Dj@c28uKj^(rCc7Q$B-i*-qVTfbzyZM>l> zguJRsYfZPT->lg*RjyUZ5gCg$J}i!MIAJ=xNm<|c+6L9UNmZ;@cWzLfY09uk)kD6m zSDV%GXSPh$s?JSob;{cDz>4Q^^qrG`d-7fOr!W59iyPhNlhzA2w40PAMIB5~2Uq8Q z`20Ix{lQo74<%2Gu2as<>iQK`vijf+>YL`xn&wo^p$9dG(j}#7OG&z{a{0)&Po}Mv zOQW|(Q`Yu`wSD#2_s`rtliJ^#*x$Qh?R~6O)#`3g>r~Za1Jai4G6WvGB|){U%&qc2 z^t?0mgQ@%NsdM9rbK}V#_c}EpPVPX0I*>M$-EuGYeDBQPwA>@^9r^41_pUxxDzv)C z>OA7>)bTBaN_#q8YI|+$m)Hq7k}w>(*Y@u5pZ5N^_X9F{dUW036g`2CgaP<*@7TNh zep>V6n)`u&TKL&Q@}zs+Fd;Te{Wc7z9_g(Q^!81I>E_UDL$@wHG}Mc9v2(Ff@zBum z*<&*@mj4L``k#W4Hg=4(Vd(fhhsD&({ZBc2iJzZ#v^q8D!$xw%gg$KTI|h}1wsk|x zznJ=tJ4xj)NF{)Np*iY2srS&w$+LE^tB5D#QRh?7^XD{ESQUuLm9=1H2*UPsmao_f$@-L39&c-N_;P8o&7+>G zmuovLLn_ZQY{+4FsLSX;-V8jXY)@Ixgf^z=LNUc4cu~(SP2Ta4`D-VT3;VzY3A`k% zks&!17r^JzqB<1SMYSFx8;O9?!5h>8eSL<08PNZU3v0-NL}#rZpa!9NOf9B}>YJc{ z6O`(4O&gR;`XK61vi(4W9fs_hHpb5$JaYVCi0LR}CjcTC@ykM1+etbf`x5L3&ae(( zM{$zFq zQo8ztu0GzMHkK|;+@ARJ>E-b^C+f(K(?rTqw?W+~{o_{xX|BD|GKY8(kzID>MdHnQ``hN7>{gS`u z?@z2BzmPPHB#vLWS8}Ulx%}Ig){ONjV{5|Ly23v+9)O0pW6dz~9~$Go9XggY_uP%G zHY`!Msb$+kOUvC@!VGb{p0wT)ANlPk8e|+Le*2&19+745X&N2nBDELp7cOXlOz?xvhLcGGd8y={@ z?|JgPAlO`kah}L2Hz~=q+M;tQK0nzXvZMXkSnV8~+!_=F`B)2)WBfPrtQ3uXG8SHm zgx8_H6Tk_LL6Gs!sfLd&=(du|fRO0?rwhb9R8n7ML(^4+S~sfH6gD{*>JrTbN;3 zdt}xwI(IM)dBj;ZvS8=unS$Z$?Ez-i2am<}ASe2O9TGiB@}9Dvi$MI?7q-KbQ4j*n z!SI}YaUPzGVGtlLVeLiM&BmAv+0M=@i^bP;g?V8y!UhW0rlT2PSUAHEH=e)5Lg1wo z+|Kgtnr=L}U}vrdAsi0RDp+PY=e~0Xrw_kyO)b8;pSq^PTJEW9^%z(5j{^2Uj1d=u zU(-+7b+%`_k1SjxZOSww z#`e|WO>14s+Wf%Uyb@Tm_N>k(tUWgzTMC1*Ila%e^p)FR`Bvz`K5&C7m(IU_ewlk? zY_qm~wJKSA`qtn__SR)GZEITL-+u9}7w?@&ww-=xJDsj+c=P<7^WPmww~c(@{?}(e ze0J^9my@HujW*xf%$1F{E6YPdO~bYpHMDJMP{sbOQe>@KI(7Thw|XCxwWXna{`UDd zMu6_8waCTH97NvbKL(1waw9Wk&{UeC#uzvI~K4jTI&FJCO!3;CCV& zP`MdWzvC_e-@aY$XjlDJg`-^iGew_%z)JmN>2b(^u5AL)&kyLJ^*sfJb8DNURR7*- z0^slUV)*+d1VG*|RRZMwa=oL)_`V%uzTd8QoYa2&zmS5z#@S=YIuuT4M>qQ-3@O=C zSRqP=mk#!wXRg9K{0uvZq1cla)DRXJ7#R_REvyTJzlbv$XX7|?Lnf%DpE(FB_}M~? zVX*|2Vl<$wkYK@483#>|(V3u(_&W~q#|_}!`#AidFKAq2!%nw@X2FO=ERJLddRN|? zqjsEUV}2~iq*zB2#;A44(UaoO)FUp>*zkZ*KH?g8^iM!Y=90J1?WTv%O}HK7?0HOr zf;%ey!bH%(FIe#JTm%ZFh4_OPb_~yD1ZQ8u*|Rvqp>GyH(+R4{eu(Tm$;6x#)FZCR zaW^gCPzC1hf-mfwgCYAc6&$Y+FRyri3VzEVP`%PGCeE_o13b7_i2De%#+H&Gh>uXo zza!g6$OwgxkX9_{AAyB;=Cw1+)%OP0^(W)hBXd>UkhWCEjp@>|xH(;BT|<_1wQUVm zq$}!|zW(~xSJ-4lXR6|GqT=uxvZk#yOU~DwE8R(J+ZuwHfot|P*l4Y&X@e?&k zSUxc+3Cphxh%(=}x?GjgG(FHXr7LQeuD^bLW&Z8(Tj69yPkd;dw5DwhZ=U_m+12(` z=b1$3nWXJ323I~-6U5mYgDFEz!cg-VLFF-JCOTJ+r<%GGP2GDoo{!&)wR}eGzYAE z)YXe?jS1Aaj`pVy_I%%a*PDZMJ|YdTIA3z6K|W6j7ZZ zs(+Ia A@c;k- literal 0 HcmV?d00001 diff --git a/src/dashx/lib/events.lua b/src/dashx/lib/events.lua new file mode 100644 index 0000000..58a3b80 --- /dev/null +++ b/src/dashx/lib/events.lua @@ -0,0 +1,119 @@ +--[[ + Copyright (C) 2026 Rob Thomson + GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html +]] -- + +local dashx = require("dashx") + +local events = {} + +local lastEventTimes = {} +local lastValues = {} + +local function telemetry() + return dashx.telemetry +end + +local eventTable = { + { + key = "voltage", + interval = 10, + getter = function() + local source = telemetry() + return source and source.getSensor("voltage") or nil + end, + event = function(value) + local battery = dashx.session and dashx.session.batteryConfig or {} + local cellCount = battery.batteryCellCount + local warnVoltage = battery.vbatwarningcellvoltage + local minVoltage = battery.vbatmincellvoltage + + if not (cellCount and warnVoltage and minVoltage) then + return + end + + local cellVoltage = value / cellCount + if cellVoltage >= 0 and cellVoltage < (minVoltage / 2) then + return + end + + if cellVoltage < warnVoltage then + dashx.utils.playFile("events", "alerts/lowvoltage.wav") + end + end + }, + { + key = "fuel", + interval = 10, + getter = function() + local source = telemetry() + return source and (source.getSensor("smartfuel") or source.getSensor("fuel")) or nil + end, + event = function(value) + if value and value <= 10 then + dashx.utils.playFile("events", "alerts/lowfuel.wav") + end + end + }, + { + key = "armed", + debounce = 0.25, + getter = function() + local source = telemetry() + return source and source.getSensor("armed") or nil + end, + event = function(value) + if value == 0 then + dashx.utils.playFile("events", "alerts/armed.wav") + elseif value == 1 then + dashx.utils.playFile("events", "alerts/disarmed.wav") + end + end + } +} + +function events.reset() + lastEventTimes = {} + lastValues = {} +end + +function events.wakeup() + local enabledEvents = dashx.preferences and dashx.preferences.events or {} + local now = os.clock() + + for _, item in ipairs(eventTable) do + if not enabledEvents[item.key] then + goto continue + end + + local value = item.getter and item.getter() or nil + if value == nil then + goto continue + end + + local lastValue = lastValues[item.key] + if lastValue ~= nil and value == lastValue then + goto continue + end + + local lastTime = lastEventTimes[item.key] or 0 + local debounce = item.debounce or 0 + local interval = item.interval or 0 + + if debounce > 0 and (now - lastTime) < debounce then + goto continue + end + + if interval > 0 and (now - lastTime) < interval then + goto continue + end + + item.event(value) + lastValues[item.key] = value + lastEventTimes[item.key] = now + + ::continue:: + end +end + +return events diff --git a/src/dashx/tasks/logging/logging.lua b/src/dashx/lib/logging.lua similarity index 95% rename from src/dashx/tasks/logging/logging.lua rename to src/dashx/lib/logging.lua index 893440b..02bd031 100644 --- a/src/dashx/tasks/logging/logging.lua +++ b/src/dashx/lib/logging.lua @@ -65,7 +65,7 @@ end function logging.writeLogs(forcewrite) local max_lines = forcewrite and #log_queue or 10 if #log_queue > 0 and logFileName then - local filePath = "LOGS:dashx/telemetry/" .. logFileName + local filePath = "LOGS:/dashx/telemetry/" .. logFileName dashx.utils.log(string.format("Write %d (of %d) lines to %s", math.min(#log_queue, max_lines), #log_queue, logFileName), "info") @@ -123,7 +123,7 @@ function logging.wakeup() if not dashx.session.mcu_id then return end if not telemetry then - telemetry = dashx.tasks.telemetry + telemetry = dashx.telemetry return end @@ -142,7 +142,7 @@ function logging.wakeup() logFileName = generateLogFilename() dashx.utils.log("Logging triggered by inFlight() - " .. logFileName, "info") - local iniName = "LOGS:dashx/telemetry/logs.ini" + local iniName = "LOGS:/dashx/telemetry/logs.ini" local iniData = dashx.ini.load_ini_file(iniName) or {} if not iniData.model then iniData.model = {} end iniData.model.name = dashx.session.craftName or model.name() or "Unknown" @@ -150,7 +150,7 @@ function logging.wakeup() end if not logHeader then - local filePath = "LOGS:dashx/telemetry/" .. logFileName + local filePath = "LOGS:/dashx/telemetry/" .. logFileName local f = io.open(filePath, 'w') if f then io.write(f, logging.getLogHeader(), "\n") diff --git a/src/dashx/lib/logs.lua b/src/dashx/lib/logs.lua new file mode 100644 index 0000000..418d668 --- /dev/null +++ b/src/dashx/lib/logs.lua @@ -0,0 +1,90 @@ +--[[ + Copyright (C) 2026 Rob Thomson + GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html +]] -- + +local dashx = require("dashx") + +local logs = {} + +local ROOT_DIR = "LOGS:" +local BASE_DIR = "LOGS:/dashx" +local TELEMETRY_DIR = "LOGS:/dashx/telemetry" + +local function ensureDirectories() + os.mkdir(ROOT_DIR) + os.mkdir(BASE_DIR) + os.mkdir(TELEMETRY_DIR) +end + +local function extractSortKey(name) + if type(name) ~= "string" then + return "" + end + + local date, time, unique = name:match("^(%d%d%d%d%-%d%d%-%d%d)_(%d%d%-%d%d%-%d%d)_?(%d*)%.csv$") + if date and time then + return string.format("%sT%s_%s", date, time, unique or "") + end + + return name +end + +function logs.getDirectory() + ensureDirectories() + return TELEMETRY_DIR +end + +function logs.getRecentEntries(limit) + ensureDirectories() + + local files = system.listFiles(TELEMETRY_DIR) or {} + local entries = {} + + for _, name in ipairs(files) do + if type(name) == "string" and name:match("%.csv$") then + entries[#entries + 1] = { + name = name, + sortKey = extractSortKey(name) + } + end + end + + table.sort(entries, function(a, b) + return (a.sortKey or "") > (b.sortKey or "") + end) + + if limit and #entries > limit then + for index = #entries, limit + 1, -1 do + entries[index] = nil + end + end + + return entries +end + +function logs.formatDuration(seconds) + local total = math.max(0, math.floor(tonumber(seconds) or 0)) + local hours = math.floor(total / 3600) + local minutes = math.floor((total % 3600) / 60) + local secs = total % 60 + + if hours > 0 then + return string.format("%dh %02dm %02ds", hours, minutes, secs) + end + + return string.format("%dm %02ds", minutes, secs) +end + +function logs.getSummary() + local prefs = dashx.session and dashx.session.modelPreferences or nil + + return { + craftName = (dashx.session and dashx.session.craftName) or (model.name and model.name()) or "Model", + flightCount = tonumber(dashx.ini.getvalue(prefs, "general", "flightcount")) or 0, + lastFlightTime = tonumber(dashx.ini.getvalue(prefs, "general", "lastflighttime")) or 0, + totalFlightTime = tonumber(dashx.ini.getvalue(prefs, "general", "totalflighttime")) or 0 + } +end + +return logs diff --git a/src/dashx/lib/runtime.lua b/src/dashx/lib/runtime.lua new file mode 100644 index 0000000..ac799b1 --- /dev/null +++ b/src/dashx/lib/runtime.lua @@ -0,0 +1,625 @@ +--[[ + Copyright (C) 2026 Rob Thomson + GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html +]] -- + +local dashx = require("dashx") + +local runtime = {} + +local trackedStats = {"rssi", "voltage", "rpm", "current", "temp_esc", "consumption", "smartconsumption", "smartfuel"} +local FLIGHT_COUNT_MIN_SECONDS = 10 + +local modelPreferenceDefaults = { + dashboard = { + theme_preflight = "nil", + theme_inflight = "nil", + theme_postflight = "nil" + }, + general = { + flightcount = 0, + totalflighttime = 0, + lastflighttime = 0 + }, + model = { + armswitch = false, + inflightswitch = false, + inflightswitch_delay = 10, + rateswitch = false + }, + battery = { + calc_local = 0, + batteryCapacity = 2200, + batteryCellCount = 3, + vbatwarningcellvoltage = 35, + vbatmincellvoltage = 33, + vbatmaxcellvoltage = 43, + vbatfullcellvoltage = 41, + lvcPercentage = 30, + consumptionWarningPercentage = 30 + } +} + +local currentModelKey = nil +local currentFlightMode = "preflight" +local currentTelemetryType = nil +local hasBeenInFlight = false +local lastStatsAt = 0 +local inflightStartTime = nil +local channelSources = {} + +local function telemetry() + return dashx.telemetry +end + +local function copyTable(input) + local output = {} + for key, value in pairs(input or {}) do + if type(value) == "table" then + output[key] = copyTable(value) + else + output[key] = value + end + end + return output +end + +local function clamp(value, minimum, maximum) + if value < minimum then + return minimum + end + if value > maximum then + return maximum + end + return value +end + +local function getModelKey() + local path = model.path and model.path() or "" + local name = model.name and model.name() or "" + local raw = path ~= "" and path or name + return dashx.utils.sanitize_filename(raw) +end + +local function resolvePreferencePaths(modelKey) + local prefDir = "SCRIPTS:/" .. dashx.config.preferences + local modelsDir = prefDir .. "/models" + local prefFile = modelsDir .. "/" .. modelKey .. ".ini" + + os.mkdir(prefDir) + os.mkdir(modelsDir) + + return prefFile +end + +local function loadModelPreferencesData(modelKey) + local prefFile = resolvePreferencePaths(modelKey) + local existing = dashx.ini.load_ini_file(prefFile) or {} + local merged = dashx.ini.merge_ini_tables(existing, modelPreferenceDefaults) + + if not dashx.ini.ini_tables_equal(existing, merged) then + dashx.ini.save_ini_file(prefFile, merged) + end + + return merged, prefFile +end + +local function buildBatteryConfig(prefs) + local battery = prefs and prefs.battery or {} + + return { + calc_local = tonumber(battery.calc_local) or 0, + batteryCapacity = tonumber(battery.batteryCapacity) or 2200, + batteryCellCount = tonumber(battery.batteryCellCount) or 3, + vbatwarningcellvoltage = (tonumber(battery.vbatwarningcellvoltage) or 35) / 10, + vbatmincellvoltage = (tonumber(battery.vbatmincellvoltage) or 33) / 10, + vbatmaxcellvoltage = (tonumber(battery.vbatmaxcellvoltage) or 43) / 10, + vbatfullcellvoltage = (tonumber(battery.vbatfullcellvoltage) or 41) / 10, + lvcPercentage = tonumber(battery.lvcPercentage) or 30, + consumptionWarningPercentage = tonumber(battery.consumptionWarningPercentage) or 30 + } +end + +local function loadModelPreferences(modelKey) + local prefs, prefFile = loadModelPreferencesData(modelKey) + dashx.session.modelPreferences = prefs + dashx.session.modelPreferencesFile = prefFile + dashx.session.batteryConfig = buildBatteryConfig(prefs) +end + +local function resetTimer() + local total = 0 + if dashx.session.modelPreferences then + total = tonumber(dashx.ini.getvalue(dashx.session.modelPreferences, "general", "totalflighttime")) or 0 + end + + dashx.session.timer = { + start = nil, + live = 0, + session = 0, + lifetime = total, + baseLifetime = total + } + dashx.session.flightCounted = false +end + +local function saveTimerTotals() + local prefs = dashx.session.modelPreferences + local prefFile = dashx.session.modelPreferencesFile + + if not prefs or not prefFile then + return + end + + dashx.ini.setvalue(prefs, "general", "totalflighttime", dashx.session.timer.baseLifetime or 0) + dashx.ini.setvalue(prefs, "general", "lastflighttime", dashx.session.timer.session or 0) + dashx.ini.save_ini_file(prefFile, prefs) +end + +local function initializeRxMap() + dashx.session.rx = dashx.session.rx or {map = {}, values = {}} + dashx.session.rx.map = dashx.session.rx.map or {} + dashx.session.rx.values = dashx.session.rx.values or {} + + local map = dashx.session.rx.map + map.aileron = 0 + map.elevator = 1 + map.collective = 2 + map.rudder = 3 + map.arm = 4 + map.throttle = 5 + map.mode = 6 + map.headspeed = 7 + + channelSources = {} +end + +local function updateRxValues(protocol) + if protocol == nil or protocol == "sim" then + return + end + + if not dashx.utils.rxmapReady() then + initializeRxMap() + end + + local map = dashx.session.rx and dashx.session.rx.map or nil + if not map then + return + end + + for name, member in pairs(map) do + if channelSources[name] == nil and member ~= nil then + channelSources[name] = system.getSource({category = CATEGORY_CHANNEL, member = member, options = 0}) + end + end + + for name, source in pairs(channelSources) do + if source and source.value then + local value = source:value() + if value ~= nil then + dashx.session.rx.values[name] = value + end + end + end +end + +local function initializeModel(modelKey) + dashx.utils.session() + dashx.session.mcu_id = modelKey + dashx.session.craftName = model.name and model.name() or "Model" + + loadModelPreferences(modelKey) + resetTimer() + + if telemetry() and telemetry().reset then + telemetry().reset() + end + if dashx.sensors and dashx.sensors.reset then + dashx.sensors.reset() + end + if dashx.events and dashx.events.reset then + dashx.events.reset() + end + + dashx.flightmode.current = "preflight" + currentFlightMode = "preflight" + currentTelemetryType = nil + hasBeenInFlight = false + inflightStartTime = nil + lastStatsAt = 0 + channelSources = {} + initializeRxMap() +end + +local function detectProtocol() + if system.getVersion().simulation then + local internalModule = model.getModule and model.getModule(0) or nil + local rootSource = system.getSource({appId = 0xF101, subId = 0}) or system.getSource({appId = 0xF101}) or system.getSource("RSSI") + return "sim", rootSource, internalModule + end + + local internalModule = model.getModule and model.getModule(0) or nil + local externalModule = model.getModule and model.getModule(1) or nil + + if internalModule and internalModule.enable and internalModule:enable() then + local sportSource = system.getSource({appId = 0xF101, subId = 0}) or system.getSource({appId = 0xF101}) + if sportSource then + return "sport", sportSource, internalModule + end + end + + if externalModule and externalModule.enable and externalModule:enable() then + local crsfSource = system.getSource({crsfId = 0x14, subIdStart = 0, subIdEnd = 1}) + if crsfSource then + return "crsf", crsfSource, externalModule + end + + local sportSource = system.getSource({appId = 0xF101, subId = 0}) or system.getSource({appId = 0xF101}) + if sportSource then + return "sport", sportSource, externalModule + end + end + + return nil, nil, nil +end + +local function updateTelemetryState() + local protocol, rootSource, moduleRef = detectProtocol() + local sessionProtocol = protocol == "sim" and "sport" or protocol + local changed = sessionProtocol ~= currentTelemetryType + + if changed then + currentTelemetryType = sessionProtocol + if telemetry() and telemetry().reset then + telemetry().reset() + end + if dashx.sensors and dashx.sensors.reset then + dashx.sensors.reset() + end + if dashx.events and dashx.events.reset then + dashx.events.reset() + end + channelSources = {} + end + + local available = protocol == "sim" or (sessionProtocol ~= nil and rootSource ~= nil) + + dashx.session.telemetryTypeChanged = changed + dashx.session.telemetryType = sessionProtocol + dashx.session.telemetryProtocol = protocol + dashx.session.telemetrySensor = rootSource + dashx.session.telemetryModule = moduleRef + dashx.session.telemetryState = available + dashx.session.isConnected = available + dashx.session.isConnectedHigh = available + dashx.session.isConnectedMedium = available + dashx.session.isConnectedLow = available + + if not available then + dashx.session.isArmed = false + end + + return protocol, changed +end + +local function determineFlightMode() + local telemetryModule = telemetry() + local rpm = telemetryModule and telemetryModule.getSensor("rpm") or 0 + local armed = telemetryModule and telemetryModule.getSensor("armed") or nil + local inflight = telemetryModule and telemetryModule.getSensor("inflight") or nil + local delay = tonumber(dashx.ini.getvalue(dashx.session.modelPreferences, "model", "inflightswitch_delay")) or 10 + + if armed == nil then + armed = rpm > 1000 and 0 or 1 + end + + if inflight == nil then + inflight = rpm > 1000 and 0 or 1 + end + + if currentFlightMode == "inflight" and not dashx.session.isConnected then + hasBeenInFlight = true + inflightStartTime = nil + end + + if armed == 0 and inflight == 0 then + if not inflightStartTime then + inflightStartTime = os.time() + elseif os.difftime(os.time(), inflightStartTime) >= delay then + hasBeenInFlight = true + return "inflight" + end + else + inflightStartTime = nil + end + + if hasBeenInFlight then + return "postflight" + end + + return "preflight" +end + +local function updateTimer() + local timer = dashx.session.timer + if not timer then + resetTimer() + timer = dashx.session.timer + end + + local now = os.time() + if currentFlightMode == "inflight" then + if not timer.start then + timer.start = now + end + + local currentSegment = now - timer.start + timer.live = (timer.session or 0) + currentSegment + timer.lifetime = (timer.baseLifetime or 0) + currentSegment + + if dashx.session.modelPreferences then + dashx.ini.setvalue(dashx.session.modelPreferences, "general", "totalflighttime", timer.lifetime) + end + + if timer.live >= FLIGHT_COUNT_MIN_SECONDS and not dashx.session.flightCounted and dashx.session.modelPreferences then + dashx.session.flightCounted = true + local count = tonumber(dashx.ini.getvalue(dashx.session.modelPreferences, "general", "flightcount")) or 0 + dashx.ini.setvalue(dashx.session.modelPreferences, "general", "flightcount", count + 1) + dashx.ini.save_ini_file(dashx.session.modelPreferencesFile, dashx.session.modelPreferences) + end + else + timer.live = timer.session or 0 + end + + if currentFlightMode == "postflight" and timer.start then + local segment = now - timer.start + timer.session = (timer.session or 0) + segment + timer.start = nil + timer.baseLifetime = (timer.baseLifetime or 0) + segment + timer.lifetime = timer.baseLifetime + saveTimerTotals() + end +end + +local function updateStats() + local telemetryModule = telemetry() + if not telemetryModule or currentFlightMode ~= "inflight" or not dashx.session.isConnected then + return + end + + local now = os.clock() + if now - lastStatsAt < 0.25 then + return + end + lastStatsAt = now + + telemetryModule.sensorStats = telemetryModule.sensorStats or {} + + for _, sensorKey in ipairs(trackedStats) do + local value = telemetryModule.getSensor(sensorKey) + if type(value) == "number" then + local stats = telemetryModule.sensorStats[sensorKey] + if not stats then + stats = {min = math.huge, max = -math.huge, sum = 0, count = 0, avg = 0} + telemetryModule.sensorStats[sensorKey] = stats + end + + stats.min = math.min(stats.min, value) + stats.max = math.max(stats.max, value) + stats.sum = stats.sum + value + stats.count = stats.count + 1 + stats.avg = stats.sum / stats.count + end + end +end + +local function normalizeSwitchValue(value) + if type(value) == "string" then + return value ~= "" and value or false + end + + if type(value) == "boolean" or value == nil then + return false + end + + if value.category and value.member and value.options then + local category = value:category() + local member = value:member() + local options = value:options() + return table.concat({category, member, options}, ":") + end + + return false +end + +local function normalizeThemeValue(value) + if value == nil or value == false or value == "" or value == "nil" then + return "nil" + end + + if value == "Default" then + return "system/default" + end + + if value == "RT-RC" or value == "@RT-RC" then + return "system/@rt-rc" + end + + if value == "system/rt-rc" then + return "system/@rt-rc" + end + + return value +end + +local function normalizeWidgetSettings(widget) + widget.theme_preflight = normalizeThemeValue(widget.theme_preflight) + widget.theme_inflight = normalizeThemeValue(widget.theme_inflight) + widget.theme_postflight = normalizeThemeValue(widget.theme_postflight) + + widget.calc_local = clamp(math.floor(tonumber(widget.calc_local) or 0), 0, 1) + widget.batteryCapacity = clamp(math.floor(tonumber(widget.batteryCapacity) or 2200), 0, 10000000) + widget.batteryCellCount = clamp(math.floor(tonumber(widget.batteryCellCount) or 3), 1, 24) + widget.vbatwarningcellvoltage = clamp(math.floor(tonumber(widget.vbatwarningcellvoltage) or 35), 5, 600) + widget.vbatmincellvoltage = clamp(math.floor(tonumber(widget.vbatmincellvoltage) or 33), 5, 600) + widget.vbatmaxcellvoltage = clamp(math.floor(tonumber(widget.vbatmaxcellvoltage) or 43), 5, 600) + widget.vbatfullcellvoltage = clamp(math.floor(tonumber(widget.vbatfullcellvoltage) or 41), 5, 600) + widget.consumptionWarningPercentage = clamp(math.floor(tonumber(widget.consumptionWarningPercentage) or 30), 0, 100) + widget.inflightswitch_delay = clamp(math.floor(tonumber(widget.inflightswitch_delay) or 10), 0, 120) + widget.armswitch = normalizeSwitchValue(widget.armswitch) + widget.inflightswitch = normalizeSwitchValue(widget.inflightswitch) +end + +function runtime.readWidgetSettings(widget) + local modelKey = getModelKey() + local prefs, prefFile = loadModelPreferencesData(modelKey) + local dashboardPrefs = prefs.dashboard or {} + local modelPrefs = prefs.model or {} + local batteryPrefs = prefs.battery or {} + + if widget then + widget.theme_preflight = dashboardPrefs.theme_preflight or "nil" + widget.theme_inflight = dashboardPrefs.theme_inflight or "nil" + widget.theme_postflight = dashboardPrefs.theme_postflight or "nil" + widget.calc_local = tonumber(batteryPrefs.calc_local) or 0 + widget.batteryCapacity = tonumber(batteryPrefs.batteryCapacity) or 2200 + widget.batteryCellCount = tonumber(batteryPrefs.batteryCellCount) or 3 + widget.vbatwarningcellvoltage = tonumber(batteryPrefs.vbatwarningcellvoltage) or 35 + widget.vbatmincellvoltage = tonumber(batteryPrefs.vbatmincellvoltage) or 33 + widget.vbatmaxcellvoltage = tonumber(batteryPrefs.vbatmaxcellvoltage) or 43 + widget.vbatfullcellvoltage = tonumber(batteryPrefs.vbatfullcellvoltage) or 41 + widget.consumptionWarningPercentage = tonumber(batteryPrefs.consumptionWarningPercentage) or 30 + widget.armswitch = modelPrefs.armswitch or false + widget.inflightswitch = modelPrefs.inflightswitch or false + widget.inflightswitch_delay = tonumber(modelPrefs.inflightswitch_delay) or 10 + widget._modelKey = modelKey + widget._preferencesFile = prefFile + normalizeWidgetSettings(widget) + end + + if currentModelKey == modelKey then + dashx.session.modelPreferences = prefs + dashx.session.modelPreferencesFile = prefFile + dashx.session.batteryConfig = buildBatteryConfig(prefs) + end + + return prefs, prefFile, modelKey +end + +function runtime.writeWidgetSettings(widget) + local modelKey = widget and widget._modelKey or getModelKey() + local prefs, prefFile = loadModelPreferencesData(modelKey) + + widget = widget or {} + normalizeWidgetSettings(widget) + + prefs.dashboard = prefs.dashboard or {} + prefs.model = prefs.model or {} + prefs.battery = prefs.battery or {} + + prefs.dashboard.theme_preflight = widget.theme_preflight + prefs.dashboard.theme_inflight = widget.theme_inflight + prefs.dashboard.theme_postflight = widget.theme_postflight + + prefs.model.armswitch = widget.armswitch + prefs.model.inflightswitch = widget.inflightswitch + prefs.model.inflightswitch_delay = widget.inflightswitch_delay + + prefs.battery.calc_local = widget.calc_local + prefs.battery.batteryCapacity = widget.batteryCapacity + prefs.battery.batteryCellCount = widget.batteryCellCount + prefs.battery.vbatwarningcellvoltage = widget.vbatwarningcellvoltage + prefs.battery.vbatmincellvoltage = widget.vbatmincellvoltage + prefs.battery.vbatmaxcellvoltage = widget.vbatmaxcellvoltage + prefs.battery.vbatfullcellvoltage = widget.vbatfullcellvoltage + prefs.battery.consumptionWarningPercentage = widget.consumptionWarningPercentage + + dashx.ini.save_ini_file(prefFile, prefs) + + if currentModelKey == modelKey then + dashx.session.modelPreferences = prefs + dashx.session.modelPreferencesFile = prefFile + dashx.session.batteryConfig = buildBatteryConfig(prefs) + if dashx.sensors and dashx.sensors.reset then + dashx.sensors.reset() + end + dashx.session.dashboardThemeReloadPending = true + end + + return true +end + +function runtime.resetFlight() + hasBeenInFlight = false + inflightStartTime = nil + currentFlightMode = "preflight" + dashx.flightmode.current = "preflight" + lastStatsAt = 0 + + local telemetryModule = telemetry() + if telemetryModule then + telemetryModule.sensorStats = {} + end + resetTimer() + + if dashx.events and dashx.events.reset then + dashx.events.reset() + end + + return true +end + +function runtime.wakeup() + local modelKey = getModelKey() + local modelChanged = modelKey ~= currentModelKey + + if modelChanged then + currentModelKey = modelKey + initializeModel(modelKey) + end + + dashx.session.craftName = model.name and model.name() or dashx.session.craftName + + local protocol, telemetryChanged = updateTelemetryState() + updateRxValues(protocol) + + if dashx.sensors and dashx.sensors.wakeup then + dashx.sensors.wakeup(protocol, dashx.session.telemetrySensor) + end + + local telemetryModule = telemetry() + if protocol == "sim" and not dashx.session.telemetrySensor and telemetryModule and telemetryModule.getSensorSource then + dashx.session.telemetrySensor = telemetryModule.getSensorSource("rssi") or telemetryModule.getSensorSource("voltage") + end + + if telemetryModule and telemetryModule.wakeup then + telemetryModule.wakeup() + end + + if dashx.logging and dashx.logging.wakeup then + dashx.logging.wakeup() + end + + if dashx.events and dashx.events.wakeup then + dashx.events.wakeup() + end + + local nextFlightMode = determineFlightMode() + local flightModeChanged = nextFlightMode ~= currentFlightMode + currentFlightMode = nextFlightMode + dashx.flightmode.current = nextFlightMode + + updateTimer() + updateStats() + + dashx.session.telemetryTypeChanged = false + + return { + model_changed = modelChanged, + telemetry_changed = telemetryChanged, + flightmode_changed = flightModeChanged + } +end + +return runtime diff --git a/src/dashx/lib/sensors.lua b/src/dashx/lib/sensors.lua new file mode 100644 index 0000000..dd792ae --- /dev/null +++ b/src/dashx/lib/sensors.lua @@ -0,0 +1,301 @@ +--[[ + Copyright (C) 2026 Rob Thomson + GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html +]] -- + +local dashx = require("dashx") + +local sensors = {} + +local smartfuel = assert(loadfile("lib/smartfuel.lua"))() +local smartfuelvoltage = assert(loadfile("lib/smartfuelvoltage.lua"))() +local useRawValue = dashx.utils.ethosVersionAtLeast({26, 1, 0}) + +local cachedSensors = {} +local createRetryAt = {} +local cacheExpireTime = 30 +local lastCacheFlushTime = os.clock() +local wakeupInterval = 1 +local lastWakeupTime = 0 +local switchCache = {} + +local derivedDefinitions = { + armed = {name = "Armed", appId = 0x5FE0, unit = UNIT_RAW, minimum = 0, maximum = 1}, + inflight = {name = "Inflight", appId = 0x5FDF, unit = UNIT_RAW, minimum = 0, maximum = 1}, + smartfuel = {name = "Smart Fuel", appId = 0x5FE1, unit = UNIT_PERCENT, minimum = 0, maximum = 100}, + smartconsumption = {name = "Smart Consumption", appId = 0x5FDE, unit = UNIT_MILLIAMPERE_HOUR, minimum = 0, maximum = 1000000000} +} + +local function sourceExists(source) + return source ~= nil +end + +local function telemetry() + return dashx.telemetry +end + +local function getModuleId(rootSource) + if rootSource and rootSource.module then + return rootSource:module() + end + if system.getVersion().simulation then + return 0 + end + if dashx.session.telemetrySensor and dashx.session.telemetrySensor.module then + return dashx.session.telemetrySensor:module() + end + return nil +end + +local function callSensorMethod(sensor, methodName, ...) + if not sensor then + return false + end + + local method = sensor[methodName] + if type(method) ~= "function" then + return false + end + + local ok, result = pcall(method, sensor, ...) + if not ok then + return false, result + end + + return true, result +end + +local function ensureSensor(definition, rootSource) + local appId = definition.appId + local existing = cachedSensors[appId] + if sourceExists(existing) then + return existing + end + + existing = system.getSource({category = CATEGORY_TELEMETRY_SENSOR, appId = appId}) + if sourceExists(existing) then + cachedSensors[appId] = existing + return existing + end + + local moduleId = getModuleId(rootSource) + if moduleId == nil then + return nil + end + + local now = os.clock() + if createRetryAt[appId] and now < createRetryAt[appId] then + return nil + end + + if not model.createSensor then + createRetryAt[appId] = now + 5 + return nil + end + + local sensor = model.createSensor({type = SENSOR_TYPE_DIY}) + if not sensor then + createRetryAt[appId] = now + 5 + return nil + end + + sensor:name(definition.name) + sensor:appId(appId) + sensor:physId(0) + sensor:module(moduleId) + sensor:minimum(definition.minimum or -1000000000) + sensor:maximum(definition.maximum or 1000000000) + + if definition.decimals and definition.decimals >= 1 then + sensor:decimals(definition.decimals) + sensor:protocolDecimals(definition.decimals) + end + + if definition.unit then + sensor:unit(definition.unit) + sensor:protocolUnit(definition.unit) + end + + cachedSensors[appId] = sensor + createRetryAt[appId] = nil + return sensor +end + +local function setSensorValue(definition, value, rootSource) + local sensor = ensureSensor(definition, rootSource) + if not sensor then + return + end + + if value == nil then + if sensor.reset then + sensor:reset() + end + return + end + + if useRawValue and sensor.rawValue then + sensor:rawValue(value) + else + sensor:value(value) + end +end + +local function parseSwitchSpec(spec) + if type(spec) ~= "string" or spec == "" or spec == "false" then + return nil + end + + local category, member, options = spec:match("([^:]+):([^:]+):([^:]+)") + if not category or not member then + return nil + end + + return { + category = tonumber(category) or category, + member = tonumber(member) or member, + options = tonumber(options) or options + } +end + +local function getSwitchSource(key, spec) + if not spec or spec == false then + switchCache[key] = nil + return nil + end + + local cached = switchCache[key] + if cached and cached.spec == spec then + return cached.source + end + + local query = parseSwitchSpec(spec) + if not query then + switchCache[key] = nil + return nil + end + + local source = system.getSource(query) + switchCache[key] = {spec = spec, source = source} + return source +end + +local function readSwitchState(key, spec) + local source = getSwitchSource(key, spec) + if not source or type(source.state) ~= "function" then + return nil + end + + local ok, state = pcall(source.state, source) + if not ok then + return nil + end + + if type(state) == "boolean" then + return state + end + + if type(state) == "number" then + return state ~= 0 + end + + return nil +end + +local function rpmBinaryValue() + local telemetryModule = telemetry() + local rpm = telemetryModule and telemetryModule.getSensor("rpm") or 0 + return rpm > 1000 and 0 or 1 +end + +local function deriveArmedValue() + local modelPrefs = dashx.session.modelPreferences and dashx.session.modelPreferences.model or {} + local state = readSwitchState("armed", modelPrefs.armswitch) + if state ~= nil then + return state and 0 or 1 + end + return rpmBinaryValue() +end + +local function deriveInflightValue() + local modelPrefs = dashx.session.modelPreferences and dashx.session.modelPreferences.model or {} + local state = readSwitchState("inflight", modelPrefs.inflightswitch) + if state ~= nil then + return state and 0 or 1 + end + return rpmBinaryValue() +end + +local function shouldUseVoltageFuel() + local batteryPrefs = dashx.session.modelPreferences and dashx.session.modelPreferences.battery or {} + if tonumber(batteryPrefs.calc_local) == 1 then + return true + end + + local telemetryModule = telemetry() + return not (telemetryModule and telemetryModule.getSensorSource and telemetryModule.getSensorSource("consumption")) +end + +local function calculateFuel() + if shouldUseVoltageFuel() then + return smartfuelvoltage.calculate() + end + return smartfuel.calculate() +end + +local function calculateConsumption() + if shouldUseVoltageFuel() then + local capacity = (dashx.session.batteryConfig and dashx.session.batteryConfig.batteryCapacity) or 1000 + local smartfuelPercent = telemetry() and telemetry().getSensor and telemetry().getSensor("smartfuel") or nil + local warningPercentage = (dashx.session.batteryConfig and dashx.session.batteryConfig.consumptionWarningPercentage) or 30 + if smartfuelPercent then + local usableCapacity = capacity * (1 - warningPercentage / 100) + local usedPercent = 100 - smartfuelPercent + return (usedPercent / 100) * usableCapacity + end + end + + return telemetry() and telemetry().getSensor and telemetry().getSensor("consumption") or 0 +end + +local function updateDerivedSensors(rootSource) + local armed = deriveArmedValue() + local inflight = deriveInflightValue() + dashx.session.isArmed = armed == 0 + + setSensorValue(derivedDefinitions.armed, armed, rootSource) + setSensorValue(derivedDefinitions.inflight, inflight, rootSource) + setSensorValue(derivedDefinitions.smartfuel, calculateFuel(), rootSource) + setSensorValue(derivedDefinitions.smartconsumption, calculateConsumption(), rootSource) +end + +function sensors.reset() + cachedSensors = {} + createRetryAt = {} + lastCacheFlushTime = os.clock() + lastWakeupTime = 0 + switchCache = {} + smartfuel.reset() + smartfuelvoltage.reset() +end + +function sensors.wakeup(protocol, rootSource) + if not protocol then + return + end + + local now = os.clock() + if now - lastWakeupTime < wakeupInterval then + return + end + lastWakeupTime = now + + if now - lastCacheFlushTime >= cacheExpireTime then + cachedSensors = {} + lastCacheFlushTime = now + end + + updateDerivedSensors(rootSource) +end + +return sensors diff --git a/src/dashx/tasks/sensors/lib/smartfuel.lua b/src/dashx/lib/smartfuel.lua similarity index 89% rename from src/dashx/tasks/sensors/lib/smartfuel.lua rename to src/dashx/lib/smartfuel.lua index bc82f17..1051db1 100644 --- a/src/dashx/tasks/sensors/lib/smartfuel.lua +++ b/src/dashx/lib/smartfuel.lua @@ -18,8 +18,6 @@ local voltageThreshold = 0.15 local preStabiliseDelay = 1.5 local telemetry -local lastMode = dashx.flightmode.current or "preflight" -local currentMode = dashx.flightmode.current or "preflight" local lastSensorMode local dischargeCurveTable = {} @@ -59,6 +57,15 @@ local function resetVoltageTracking() voltageStabilised = false end +local function resetState() + batteryConfigCache = nil + fuelStartingPercent = nil + fuelStartingConsumption = nil + lastSensorMode = nil + stabilizeNotBefore = nil + resetVoltageTracking() +end + local function isVoltageStable() if #lastVoltages < maxVoltageSamples then return false end local vmin, vmax = lastVoltages[1], lastVoltages[1] @@ -71,7 +78,7 @@ end local function smartFuelCalc() - if not telemetry then telemetry = dashx.tasks.telemetry end + if not telemetry then telemetry = dashx.telemetry end if not dashx.session.isConnected or not dashx.session.batteryConfig then resetVoltageTracking() @@ -105,25 +112,7 @@ local function smartFuelCalc() return nil end - local now = os.clock() - - if currentMode ~= lastMode then - dashx.utils.log("Flight mode changed – resetting voltage & fuel state", "info") - - fuelStartingPercent = nil - fuelStartingConsumption = nil - - resetVoltageTracking() - - stabilizeNotBefore = now + preStabiliseDelay - - lastMode = currentMode - return nil - end - - lastMode = currentMode - - if stabilizeNotBefore and now < stabilizeNotBefore then return nil end + if stabilizeNotBefore and os.clock() < stabilizeNotBefore then return nil end table.insert(lastVoltages, voltage) if #lastVoltages > maxVoltageSamples then table.remove(lastVoltages, 1) end @@ -198,4 +187,4 @@ local function smartFuelCalc() end end -return {calculate = smartFuelCalc, reset = resetVoltageTracking} +return {calculate = smartFuelCalc, reset = resetState} diff --git a/src/dashx/tasks/sensors/lib/smartfuelvoltage.lua b/src/dashx/lib/smartfuelvoltage.lua similarity index 91% rename from src/dashx/tasks/sensors/lib/smartfuelvoltage.lua rename to src/dashx/lib/smartfuelvoltage.lua index 88f8b49..92d23e8 100644 --- a/src/dashx/tasks/sensors/lib/smartfuelvoltage.lua +++ b/src/dashx/lib/smartfuelvoltage.lua @@ -15,8 +15,6 @@ local voltageThreshold = 0.15 local preStabiliseDelay = 1.5 local telemetry -local currentMode = dashx.flightmode.current or "preflight" -local lastMode = currentMode local lastSensorMode local lastFuelPercent = nil @@ -52,6 +50,17 @@ local function resetVoltageTracking() voltageStabilised = false end +local function resetState() + batteryConfigCache = nil + lastSensorMode = nil + lastFuelPercent = nil + lastFuelTimestamp = nil + lastFilteredVoltage = nil + lastRpm = nil + stabilizeNotBefore = nil + resetVoltageTracking() +end + local function isVoltageStable() if #lastVoltages < maxVoltageSamples then return false end local vmin, vmax = lastVoltages[1], lastVoltages[1] @@ -115,7 +124,7 @@ local function fuelPercentageCalcByVoltage(voltage, cellCount) end local function smartFuelCalc() - if not telemetry then telemetry = dashx.tasks.telemetry end + if not telemetry then telemetry = dashx.telemetry end if not dashx.session.isConnected or not dashx.session.batteryConfig then resetVoltageTracking() @@ -145,18 +154,7 @@ local function smartFuelCalc() return nil end - local now = os.clock() - - if currentMode ~= lastMode then - dashx.utils.log("Flight mode changed – resetting voltage state", "info") - resetVoltageTracking() - stabilizeNotBefore = now + preStabiliseDelay - lastMode = currentMode - return nil - end - lastMode = currentMode - - if stabilizeNotBefore and now < stabilizeNotBefore then return nil end + if stabilizeNotBefore and os.clock() < stabilizeNotBefore then return nil end table.insert(lastVoltages, voltage) if #lastVoltages > maxVoltageSamples then table.remove(lastVoltages, 1) end @@ -204,4 +202,4 @@ local function smartFuelCalc() return percent end -return {calculate = smartFuelCalc, reset = resetVoltageTracking} +return {calculate = smartFuelCalc, reset = resetState} diff --git a/src/dashx/tasks/telemetry/telemetry.lua b/src/dashx/lib/telemetry.lua similarity index 92% rename from src/dashx/tasks/telemetry/telemetry.lua rename to src/dashx/lib/telemetry.lua index 13809a5..4a710e1 100644 --- a/src/dashx/tasks/telemetry/telemetry.lua +++ b/src/dashx/lib/telemetry.lua @@ -58,7 +58,19 @@ local onchangeInitialized = false local sensorTable = { - rssi = {name = "@i18n(telemetry.sensors.rssi)@", mandatory = true, stats = true, switch_alerts = true, unit = UNIT_PERCENT, unit_string = "%", sensors = {sim = {{appId = 0xF010, subId = 0}}, sport = {{appId = 0xF010, subId = 0}}, crsf = {"Rx Quality"}}}, + rssi = { + name = "@i18n(telemetry.sensors.rssi)@", + mandatory = true, + stats = true, + switch_alerts = true, + unit = UNIT_PERCENT, + unit_string = "%", + sensors = { + sim = {{appId = 0xF010, subId = 0}}, + sport = {{appId = 0xF010, subId = 0}}, + crsf = {{crsfId = 0x14, subId = 2}} + } + }, link = {name = "@i18n(telemetry.sensors.link)@", mandatory = true, stats = true, switch_alerts = false, unit = UNIT_DB, unit_string = "dB", sensors = {sim = {{appId = 0xF101, subId = 0}}, sport = {{appId = 0xF101, subId = 0}}, crsf = {"Rx RSSI1"}}}, @@ -182,7 +194,10 @@ local sensorTable = { switch_alerts = true, unit = UNIT_MILLIAMPERE_HOUR, unit_string = "mAh", - sensors = {sim = {{uid = 0x5008, unit = UNIT_MILLIAMPERE_HOUR, dec = 0, value = function() return dashx.utils.simSensors('consumption') end, min = 0, max = 5000}}, sport = {{appId = 0x0B60, subId = 1}, {appId = 0x0B30, subId = 0}}, crsf = {"Rx Cons"}} + sensors = { + sim = {{uid = 0x5008, unit = UNIT_MILLIAMPERE_HOUR, dec = 0, value = function() return dashx.utils.simSensors('consumption') end, min = 0, max = 5000}}, + sport = {{appId = 0x0B60, subId = 1}, {appId = 0x0B30, subId = 0}}, + crsf = {"Rx Cons"}} }, armed = { @@ -193,7 +208,11 @@ local sensorTable = { switch_alerts = false, unit = UNIT_RAW, unit_string = nil, - sensors = {sim = {{appId = 0x5FE0, subId = 0}}, sport = {{appId = 0x5FE0, subId = 0}}, crsf = {{appId = 0x5FE0, subId = 0}}} + sensors = { + sim = {{category = CATEGORY_TELEMETRY_SENSOR, appId = 0x5FE0}}, + sport = {{category = CATEGORY_TELEMETRY_SENSOR, appId = 0x5FE0}}, + crsf = {{category = CATEGORY_TELEMETRY_SENSOR, appId = 0x5FE0}} + } }, inflight = { @@ -204,7 +223,11 @@ local sensorTable = { switch_alerts = false, unit = UNIT_RAW, unit_string = nil, - sensors = {sim = {{appId = 0x5FDF, subId = 0}}, sport = {{appId = 0x5FDF, subId = 0}}, crsf = {{appId = 0x5FDF, subId = 0}}} + sensors = { + sim = {{category = CATEGORY_TELEMETRY_SENSOR, appId = 0x5FDF}}, + sport = {{category = CATEGORY_TELEMETRY_SENSOR, appId = 0x5FDF}}, + crsf = {{category = CATEGORY_TELEMETRY_SENSOR, appId = 0x5FDF}} + } }, accx = { @@ -433,6 +456,17 @@ end function telemetry.getSensor(sensorKey) local entry = sensorTable[sensorKey] + local simEntry = entry and entry.sensors and entry.sensors.sim and entry.sensors.sim[1] or nil + + if system.getVersion().simulation == true and simEntry and type(simEntry.value) == "function" then + local value = simEntry.value() + local major = entry and entry.unit or simEntry.unit or nil + local minor = nil + + if entry and entry.localizations and type(entry.localizations) == "function" then value, major, minor = entry.localizations(value) end + + return value, major, minor + end if entry and type(entry.source) == "function" then local src = entry.source() diff --git a/src/dashx/lib/utils.lua b/src/dashx/lib/utils.lua index be54fd0..7d9df92 100644 --- a/src/dashx/lib/utils.lua +++ b/src/dashx/lib/utils.lua @@ -139,8 +139,9 @@ end function utils.getGovernorState(value) local returnvalue + local telemetry = dashx.telemetry - if not dashx.tasks.telemetry then return "@i18n(widgets.governor.UNKNOWN)@" end + if not telemetry then return "@i18n(widgets.governor.UNKNOWN)@" end local map = { [0] = "@i18n(widgets.governor.OFF)@", @@ -157,7 +158,7 @@ function utils.getGovernorState(value) } if dashx.session and dashx.session.apiVersion and dashx.session.apiVersion > 12.07 then - local armflags = dashx.tasks.telemetry.getSensor("armflags") + local armflags = telemetry.getSensor("armflags") if armflags == 0 or armflags == 2 then value = 101 end end @@ -167,7 +168,7 @@ function utils.getGovernorState(value) returnvalue = "@i18n(widgets.governor.UNKNOWN)@" end - local armdisableflags = dashx.tasks.telemetry.getSensor("armdisableflags") + local armdisableflags = telemetry.getSensor("armdisableflags") if armdisableflags ~= nil then armdisableflags = math.floor(armdisableflags) local armstring = utils.armingDisableFlagsToString(armdisableflags) @@ -267,8 +268,13 @@ function utils.playFileCommon(file) system.playFile("audio/" .. file) end function utils.getCurrentProfile() - local pidProfile = dashx.tasks.telemetry.getSensor("pid_profile") - local rateProfile = dashx.tasks.telemetry.getSensor("rate_profile") + local telemetry = dashx.telemetry + if not telemetry then + return + end + + local pidProfile = telemetry.getSensor("pid_profile") + local rateProfile = telemetry.getSensor("rate_profile") if (pidProfile ~= nil and rateProfile ~= nil) then @@ -355,7 +361,11 @@ function utils.joinTableItems(tbl, delimiter) return table.concat(paddedTable, delimiter, startIndex, #tbl) end -function utils.log(msg, level) if dashx.tasks and dashx.tasks.logger then dashx.tasks.logger.add(msg, level or "debug") end end +function utils.log(msg, level) + if dashx.logger and dashx.logger.add then + dashx.logger.add(msg, level or "debug") + end +end function utils.print_r(node, maxDepth, currentDepth) maxDepth = maxDepth or 5 diff --git a/src/dashx/main.lua b/src/dashx/main.lua index f9d3b5f..6da35ea 100644 --- a/src/dashx/main.lua +++ b/src/dashx/main.lua @@ -1,160 +1,280 @@ --[[ - Copyright (C) 2025 Rob Thomson + Copyright (C) 2026 Rob Thomson GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html ]] -- -local dashx = {} -dashx.session = {} +local dashx = { + session = {}, + widgets = {}, + tools = {}, + theme = {version = 0}, + flightmode = {current = "preflight"}, + app = {guiIsRunning = false} +} -local dashx = {} package.loaded.dashx = dashx -local _ENV = setmetatable({dashx = dashx}, {__index = _G, __newindex = function(_, k) print("attempt to create global '" .. tostring(k) .. "'", 2) end}) +if not FONT_M then + FONT_M = FONT_STD +end -if not FONT_M then FONT_M = FONT_STD end +dashx.config = { + toolName = "DashX", + baseDir = "dashx", + preferences = "dashx.user", + version = {major = 2, minor = 3, revision = 0, suffix = "DEV"}, + ethosVersion = {1, 6, 2}, + supportedMspApiVersion = {"12.07", "12.08", "12.09"} +} -local config = {} +local userPreferenceDefaults = { + general = { + iconsize = 2, + syncname = false, + gimbalsupression = 0.85 + }, + localizations = { + temperature_unit = 0, + altitude_unit = 0 + }, + dashboard = { + theme_preflight = "system/default", + theme_inflight = "system/default", + theme_postflight = "system/default" + }, + events = { + armed = true, + voltage = true, + fuel = true, + profile = true, + inflight = true + }, + switches = {}, + developer = { + compile = true, + devtools = false, + logtofile = false, + loglevel = "off", + logmsp = false, + logmspQueue = false, + memstats = false, + mspexpbytes = 8, + apiversion = 2, + overlaygrid = false, + overlaystats = false, + logobjprof = false, + telemetrytrace = false + }, + menulastselected = {} +} -config.toolName = "DashX" -config.icon = lcd.loadMask("app/gfx/icon.png") -config.icon_logtool = lcd.loadMask("app/gfx/icon_logtool.png") -config.icon_unsupported = lcd.loadMask("app/gfx/unsupported.png") -config.version = {major = 2, minor = 3, revision = 0, suffix = "DEV"} -config.ethosVersion = {1, 6, 2} -config.supportedMspApiVersion = {"12.07", "12.08", "12.09"} -config.baseDir = "dashx" -config.preferences = config.baseDir .. ".user" -config.defaultRateProfile = 4 -config.watchdogParam = 10 +local function ensureSharedModules() + if not dashx.ini then + dashx.ini = assert(loadfile("lib/ini.lua"))() + end -dashx.config = config + if not dashx.utils then + dashx.utils = assert(loadfile("lib/utils.lua"))(dashx.config) + end -dashx.ini = assert(loadfile("lib/ini.lua", "t", _ENV))(config) + if not dashx._sessionInitialized then + dashx.utils.session() + dashx._sessionInitialized = true + end -local userpref_defaults = { - general = {iconsize = 2, syncname = false, gimbalsupression = 0.85}, - localizations = {temperature_unit = 0, altitude_unit = 0}, - dashboard = {theme_preflight = "system/default", theme_inflight = "system/default", theme_postflight = "system/default"}, - events = {armed = true, voltage = true, fuel = true, profile = true, inflight = true}, - switches = {}, - developer = {compile = true, devtools = false, logtofile = false, loglevel = "off", logmsp = false, logmspQueue = false, memstats = false, mspexpbytes = 8, apiversion = 2}, - menulastselected = {} -} + if not dashx.preferences then + local prefDir = "SCRIPTS:/" .. dashx.config.preferences + local prefFile = prefDir .. "/preferences.ini" + os.mkdir(prefDir) -os.mkdir("SCRIPTS:/" .. dashx.config.preferences) -local userpref_file = "SCRIPTS:/" .. dashx.config.preferences .. "/preferences.ini" -local slave_ini = userpref_defaults -local master_ini = dashx.ini.load_ini_file(userpref_file) or {} + local existing = dashx.ini.load_ini_file(prefFile) or {} + local merged = dashx.ini.merge_ini_tables(existing, userPreferenceDefaults) + dashx.preferences = merged -local updated_ini = dashx.ini.merge_ini_tables(master_ini, slave_ini) -dashx.preferences = updated_ini + if not dashx.ini.ini_tables_equal(existing, merged) then + dashx.ini.save_ini_file(prefFile, merged) + end + end +end -if not dashx.ini.ini_tables_equal(master_ini, slave_ini) then dashx.ini.save_ini_file(userpref_file, updated_ini) end +local function ensureWidgetModules() + ensureSharedModules() -dashx.config.bgTaskName = dashx.config.toolName .. " [Background]" -dashx.config.bgTaskKey = "dshxbg" + dashx.tasks = dashx.tasks or {} -dashx.utils = assert(loadfile("lib/utils.lua"))(dashx.config) + if not dashx.telemetry then + dashx.telemetry = assert(loadfile("lib/telemetry.lua"))(dashx.config) + end + dashx.tasks.telemetry = dashx.telemetry -dashx.app = assert(loadfile("app/app.lua"))(dashx.config) + if not dashx.logging then + dashx.logging = assert(loadfile("lib/logging.lua"))(dashx.config) + end + dashx.tasks.logging = dashx.logging -dashx.tasks = assert(loadfile("tasks/tasks.lua"))(dashx.config) + if not dashx.sensors then + dashx.sensors = assert(loadfile("lib/sensors.lua"))(dashx.config) + end -dashx.flightmode = {current = "preflight"} + if not dashx.events then + dashx.events = assert(loadfile("lib/events.lua"))(dashx.config) + end -dashx.utils.session() + if not dashx.runtime then + dashx.runtime = assert(loadfile("lib/runtime.lua"))(dashx.config) + end -dashx.simevent = {telemetry_state = true} + if not dashx.widgets.dashboard then + dashx.widgets.dashboard = assert(loadfile("widgets/dashboard/dashboard.lua"))(dashx.config) + end -function dashx.version() - local v = dashx.config.version - return {version = string.format("%d.%d.%d-%s", v.major, v.minor, v.revision, v.suffix), major = v.major, minor = v.minor, revision = v.revision, suffix = v.suffix} + if not dashx.widgets.dashboardConfigure then + dashx.widgets.dashboardConfigure = assert(loadfile("widgets/dashboard/configure.lua"))(dashx.config) + end end -local function init() +local function ensureLogsTool() + ensureSharedModules() - if not dashx.utils.ethosVersionAtLeast() then - system.registerSystemTool({ - name = dashx.config.toolName, - icon = dashx.config.icon_unsupported, - create = function() end, - wakeup = function() - lcd.invalidate(); - return - end, - paint = function() - local w, h = lcd.getWindowSize() - local textColor = lcd.RGB(255, 255, 255, 1) - lcd.color(textColor) - lcd.font(FONT_M) - local badVersionMsg = string.format("ETHOS < V%d.%d.%d", table.unpack(config.ethosVersion)) - local textWidth, textHeight = lcd.getTextSize(badVersionMsg) - local x = (w - textWidth) / 2 - local y = (h - textHeight) / 2 - lcd.drawText(x, y, badVersionMsg) - return - end, - close = function() end - }) - return + if not dashx.logs then + dashx.logs = assert(loadfile("lib/logs.lua"))(dashx.config) + end + + if not dashx.tools.logs then + dashx.tools.logs = assert(loadfile("tools/logs.lua"))(dashx.config) end +end - system.registerSystemTool({event = dashx.app.event, name = dashx.config.toolName, icon = dashx.config.icon, create = dashx.app.create, wakeup = dashx.app.wakeup, paint = dashx.app.paint, close = dashx.app.close}) +function dashx.version() + local version = dashx.config.version + return { + version = string.format("%d.%d.%d-%s", version.major, version.minor, version.revision, version.suffix), + major = version.major, + minor = version.minor, + revision = version.revision, + suffix = version.suffix + } +end - system.registerTask({name = dashx.config.bgTaskName, key = dashx.config.bgTaskKey, wakeup = dashx.tasks.wakeup, event = dashx.tasks.event, init = dashx.tasks.init}) +local function callWidget(method, ...) + ensureWidgetModules() + return dashx.widgets.dashboard[method](...) +end - local cacheFile = "widgets.lua" - local cachePath = "cache/" .. cacheFile - local widgetList +local function callWidgetConfigure(method, ...) + ensureWidgetModules() + return dashx.widgets.dashboardConfigure[method](...) +end - local loadf, loadErr = loadfile(cachePath) - if loadf then - local ok, cached = pcall(loadf) - if ok and type(cached) == "table" then - widgetList = cached - dashx.utils.log("[cache] Loaded widget list from cache", "info") - else - dashx.utils.log("[cache] Bad cache, rebuilding: " .. tostring(cached), "info") - end +local function callLogsTool(method, ...) + ensureLogsTool() + local tool = dashx.tools.logs + local handler = tool and tool[method] + if handler then + return handler(...) end +end + +local function closeLogsTool(...) + local tool = dashx.tools and dashx.tools.logs + if tool and tool.close then + return tool.close(...) + end +end - if not widgetList then - widgetList = dashx.utils.findWidgets() - dashx.utils.createCacheFile(widgetList, cacheFile, true) - dashx.utils.log("[cache] Created new widgets cache file", "info") +local function loadToolIcon(path) + if not lcd or not path then + return nil end - dashx.widgets = {} - for _, v in ipairs(widgetList) do - if v.script then - local scriptModule = assert(loadfile("widgets/" .. v.folder .. "/" .. v.script))(config) - local varname = v.varname or v.script:gsub("%.lua$", "") - if dashx.widgets[varname] then - math.randomseed(os.time()) - local rand = math.random() - dashx.widgets[varname .. rand] = scriptModule - else - dashx.widgets[varname] = scriptModule + local candidates = { + path, + "SCRIPTS:/" .. dashx.config.baseDir .. "/" .. path + } + + for _, candidate in ipairs(candidates) do + if lcd.loadMask then + local ok, loaded = pcall(lcd.loadMask, candidate) + if ok and loaded then + return loaded end + end - system.registerWidget({ - name = v.name, - key = v.key, - event = scriptModule.event, - create = scriptModule.create, - paint = scriptModule.paint, - wakeup = scriptModule.wakeup, - build = scriptModule.build, - close = scriptModule.close, - configure = scriptModule.configure, - read = scriptModule.read, - write = scriptModule.write, - persistent = scriptModule.persistent or false, - menu = scriptModule.menu, - title = scriptModule.title - }) + if lcd.loadBitmap then + local ok, loaded = pcall(lcd.loadBitmap, candidate) + if ok and loaded then + return loaded + end end end + + return nil +end + +local function registerWidget() + system.registerWidget({ + key = "dshxdsh", + name = "DashX", + create = function(...) + return callWidget("create", ...) + end, + configure = function(...) + return callWidgetConfigure("configure", ...) + end, + paint = function(...) + return callWidget("paint", ...) + end, + event = function(...) + return callWidget("event", ...) + end, + menu = function(...) + return callWidget("menu", ...) + end, + wakeup = function(...) + return callWidget("wakeup", ...) + end, + read = function(...) + return callWidgetConfigure("read", ...) + end, + write = function(...) + return callWidgetConfigure("write", ...) + end, + title = false, + persistent = false + }) +end + +local function registerLogsTool() + if not system.registerSystemTool then + return + end + + system.registerSystemTool({ + name = "DashX Logs", + icon = loadToolIcon("app/gfx/icon.png"), + create = function(...) + return callLogsTool("create", ...) + end, + wakeup = function(...) + return callLogsTool("wakeup", ...) + end, + paint = function(...) + return callLogsTool("paint", ...) + end, + event = function(...) + return callLogsTool("event", ...) + end, + close = function(...) + return closeLogsTool(...) + end + }) +end + +local function init() + ensureSharedModules() + dashx.simevent = dashx.simevent or {telemetry_state = true} + registerWidget() + registerLogsTool() end return {init = init} diff --git a/src/dashx/tasks/callback/callback.lua b/src/dashx/tasks/callback/callback.lua deleted file mode 100644 index add56f1..0000000 --- a/src/dashx/tasks/callback/callback.lua +++ /dev/null @@ -1,49 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local arg = {...} -local config = arg[1] - -local callback = {} -local loadedSensorModule = nil - -callback._queue = {} - -local function get_time() return os.clock() end - -function callback.now(callbackParam) table.insert(callback._queue, {time = nil, func = callbackParam, repeat_interval = nil}) end - -function callback.inSeconds(seconds, callbackParam) table.insert(callback._queue, {time = get_time() + seconds, func = callbackParam, repeat_interval = nil}) end - -function callback.every(seconds, callbackParam) table.insert(callback._queue, {time = get_time() + seconds, func = callbackParam, repeat_interval = seconds}) end - -function callback.wakeup() - local now = get_time() - local i = 1 - while i <= #callback._queue do - local entry = callback._queue[i] - if not entry.time or entry.time <= now then - entry.func() - if entry.repeat_interval then - entry.time = now + entry.repeat_interval - i = i + 1 - else - table.remove(callback._queue, i) - end - else - i = i + 1 - end - end -end - -function callback.clear(callbackParam) for i = #callback._queue, 1, -1 do if callback._queue[i].func == callbackParam then table.remove(callback._queue, i) end end end - -function callback.clearAll() callback._queue = {} end - -function callback.reset() callback.clearAll() end - -return callback diff --git a/src/dashx/tasks/callback/init.lua b/src/dashx/tasks/callback/init.lua deleted file mode 100644 index 0919688..0000000 --- a/src/dashx/tasks/callback/init.lua +++ /dev/null @@ -1,10 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local init = {interval = 0.1, script = "callback.lua", spreadschedule = false, simulatoronly = false} - -return init diff --git a/src/dashx/tasks/developer/developer.lua b/src/dashx/tasks/developer/developer.lua deleted file mode 100644 index 44c322a..0000000 --- a/src/dashx/tasks/developer/developer.lua +++ /dev/null @@ -1,26 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local ENABLE_TASK = false - -local arg = {...} - -local developer = {} - -function developer.wakeup() - - if ENABLE_TASK == false then return end - - dashx.utils.log("API Debug Task: TELEMETRY_CONFIG", "info") - local API = dashx.tasks.msp.api.load("TELEMETRY_CONFIG") - API.setCompleteHandler(function(self, buf) end) - API.setUUID("123e4567-e89b-12d3-a456-426614174000") - API.read() - -end - -return developer diff --git a/src/dashx/tasks/developer/init.lua b/src/dashx/tasks/developer/init.lua deleted file mode 100644 index 10c255e..0000000 --- a/src/dashx/tasks/developer/init.lua +++ /dev/null @@ -1,10 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local init = {interval = 5, script = "developer.lua", linkrequired = false, spreadschedule = true, simulatoronly = true} - -return init diff --git a/src/dashx/tasks/events/events.lua b/src/dashx/tasks/events/events.lua deleted file mode 100644 index fedfc44..0000000 --- a/src/dashx/tasks/events/events.lua +++ /dev/null @@ -1,47 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local arg = {...} -local config = arg[1] -local events = {} -local telemetryStartTime = nil -local wakeupStep = 0 -local wakeupHandlers = {} - -local taskNames = {"telemetry", "switches", "flightmode", "stats", "rxmap", "timer"} -local taskExecutionPercent = 50 - -for _, name in ipairs(taskNames) do - events[name] = assert(loadfile("tasks/events/tasks/" .. name .. ".lua"))(dashx.config) - table.insert(wakeupHandlers, function() events[name].wakeup() end) -end - -function events.wakeup() - local currentTime = os.clock() - - if dashx.session.isConnected and dashx.session.telemetryState then - if telemetryStartTime == nil then telemetryStartTime = currentTime end - - if (currentTime - telemetryStartTime) < 2.5 then return end - - local percent = taskExecutionPercent or 25 - local tasksPerWakeup = math.max(1, math.floor((percent / 100) * #wakeupHandlers)) - - for i = 1, tasksPerWakeup do - wakeupStep = (wakeupStep % #wakeupHandlers) + 1 - wakeupHandlers[wakeupStep]() - end - else - telemetryStartTime = nil - wakeupStep = 0 - end -end - -function events.reset() telemetryStartTime = nil end - -return events - diff --git a/src/dashx/tasks/events/init.lua b/src/dashx/tasks/events/init.lua deleted file mode 100644 index 44c19b1..0000000 --- a/src/dashx/tasks/events/init.lua +++ /dev/null @@ -1,9 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local init = {interval = 0.1, script = "events.lua", linkrequired = true, spreadschedule = true, simulatoronly = false} -return init diff --git a/src/dashx/tasks/events/tasks/flightmode.lua b/src/dashx/tasks/events/tasks/flightmode.lua deleted file mode 100644 index d5eb12d..0000000 --- a/src/dashx/tasks/events/tasks/flightmode.lua +++ /dev/null @@ -1,73 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local arg = {...} -local config = arg[1] - -local flightmode = {} -local lastFlightMode = nil -local hasBeenInFlight = false -local inflight_start_time = nil - -function flightmode.inFlight() - local telemetry = dashx.tasks.telemetry - - if not telemetry.active() then return false end - - local inflight = telemetry.getSensor("inflight") - local armed = telemetry.getSensor("armed") - local delay = dashx.session.modelPreferences.model.inflightswitch_delay or 10 - - if armed == 0 and inflight == 0 then - if not inflight_start_time then - - inflight_start_time = os.time() - print("Starting inflight timer") - elseif os.difftime(os.time(), inflight_start_time) >= delay then - - print("In flight confirmed after delay") - return true - end - else - - inflight_start_time = nil - end - - return false -end - -function flightmode.reset() - lastFlightMode = nil - hasBeenInFlight = false - inflight_start_time = nil -end - -local function determineMode() - if dashx.flightmode.current == "inflight" and not dashx.session.isConnected then - hasBeenInFlight = false - return "postflight" - end - if flightmode.inFlight() then - print("In flight") - hasBeenInFlight = true - return "inflight" - end - - return hasBeenInFlight and "postflight" or "preflight" -end - -function flightmode.wakeup() - local mode = determineMode() - - if lastFlightMode ~= mode then - dashx.utils.log("Flight mode: " .. mode, "info") - dashx.flightmode.current = mode - lastFlightMode = mode - end -end - -return flightmode diff --git a/src/dashx/tasks/events/tasks/rxmap.lua b/src/dashx/tasks/events/tasks/rxmap.lua deleted file mode 100644 index 05021e0..0000000 --- a/src/dashx/tasks/events/tasks/rxmap.lua +++ /dev/null @@ -1,48 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local arg = {...} -local config = arg[1] - -local rxmap = {} - -local channelNames = {"aileron", "elevator", "collective", "rudder", "arm", "throttle", "headspeed", "mode"} - -local channelSources = {} -local initialized = false - -local function initChannelSources() - local rxMap = dashx.session.rx.map - for _, name in ipairs(channelNames) do - local member = rxMap[name] - if member then - local src = system.getSource({category = CATEGORY_CHANNEL, member = member, options = 0}) - if src then channelSources[name] = src end - end - end - initialized = true -end - -function rxmap.wakeup() - if not dashx.utils.rxmapReady() then return end - - if not initialized then initChannelSources() end - - for name, src in pairs(channelSources) do - if src then - local val = src:value() - if val ~= nil then dashx.session.rx.values[name] = val end - end - end -end - -function rxmap.reset() - channelSources = {} - initialized = false -end - -return rxmap diff --git a/src/dashx/tasks/events/tasks/stats.lua b/src/dashx/tasks/events/tasks/stats.lua deleted file mode 100644 index bee0d65..0000000 --- a/src/dashx/tasks/events/tasks/stats.lua +++ /dev/null @@ -1,81 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local arg = {...} -local config = arg[1] - -local stats = {} - -local fullSensorTable = nil -local filteredSensors = nil -local lastTrackTime = 0 - -local telemetry - -local function buildFilteredList() - filteredSensors = {} - - for sensorKey, sensorDef in pairs(fullSensorTable) do - local mt = sensorDef.stats - - if mt == true then - filteredSensors[sensorKey] = sensorDef - - elseif type(mt) == "function" then - local ok, result = pcall(mt) - if ok and result then filteredSensors[sensorKey] = sensorDef end - end - end -end - -function stats.wakeup() - - if not telemetry then - telemetry = dashx.tasks.telemetry - return - end - - if dashx.flightmode.current ~= "inflight" then return end - - local now = os.clock() - if now - lastTrackTime < 0.25 then return end - lastTrackTime = now - - if not fullSensorTable then - fullSensorTable = telemetry.sensorTable - if not fullSensorTable then return end - buildFilteredList() - end - - if not telemetry.sensorStats then telemetry.sensorStats = {} end - - local statsTable = telemetry.sensorStats - - for sensorKey, _ in pairs(filteredSensors) do - local val = telemetry.getSensor(sensorKey) - if val and type(val) == "number" then - if not statsTable[sensorKey] then statsTable[sensorKey] = {min = math.huge, max = -math.huge, sum = 0, count = 0, avg = 0} end - - local entry = statsTable[sensorKey] - entry.min = math.min(entry.min, val) - entry.max = math.max(entry.max, val) - entry.sum = entry.sum + val - entry.count = entry.count + 1 - entry.avg = entry.sum / entry.count - end - end -end - -function stats.reset() - telemetry.sensorStats = {} - fullSensorTable = nil - filteredSensors = nil - lastTrackTime = 0 -end - -return stats - diff --git a/src/dashx/tasks/events/tasks/switches.lua b/src/dashx/tasks/events/tasks/switches.lua deleted file mode 100644 index 3e08e9e..0000000 --- a/src/dashx/tasks/events/tasks/switches.lua +++ /dev/null @@ -1,86 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local arg = {...} -local config = arg[1] - -local switches = {} - -local switchTable = {switches = {}, units = {}} - -local lastPlayTime = {} -local lastSwitchState = {} -local switchStartTime = nil - -local function initializeSwitches() - local prefs = dashx.preferences.switches - if not prefs then return end - - for key, v in pairs(prefs) do - if v then - local scategory, smember = v:match("([^,]+),([^,]+)") - scategory = tonumber(scategory) - smember = tonumber(smember) - if scategory and smember then switchTable.switches[key] = system.getSource({category = scategory, member = smember}) end - end - end - - switchTable.units = dashx.tasks.telemetry.listSensorAudioUnits() -end - -function switches.wakeup() - local now = os.clock() - - if next(switchTable.switches) == nil then initializeSwitches() end - - if not switchStartTime then switchStartTime = now end - - if (now - switchStartTime) <= 5 then return end - - for key, sensor in pairs(switchTable.switches) do - local currentState = sensor:state() - if currentState == nil then goto continue end - - local prevState = lastSwitchState[key] or false - local lastTime = lastPlayTime[key] or 0 - local playNow = false - - if not currentState then - goto skip_play - elseif not prevState or (now - lastTime) >= 10 then - playNow = true - end - - if playNow then - local sensorSrc = dashx.tasks.telemetry.getSensorSource(key) - if sensorSrc then - local value = sensorSrc:value() - if value and type(value) == "number" then - local unit = switchTable.units[key] - local decimals = tonumber(sensorSrc:decimals()) - system.playNumber(value, unit, decimals) - lastPlayTime[key] = now - end - end - end - - ::skip_play:: - lastSwitchState[key] = currentState - ::continue:: - end -end - -function switches.resetSwitchStates() - switchTable.switches = {} - lastPlayTime = {} - lastSwitchState = {} - switchStartTime = nil -end - -switches.switchTable = switchTable - -return switches diff --git a/src/dashx/tasks/events/tasks/telemetry.lua b/src/dashx/tasks/events/tasks/telemetry.lua deleted file mode 100644 index fa1cfb9..0000000 --- a/src/dashx/tasks/events/tasks/telemetry.lua +++ /dev/null @@ -1,90 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local arg = {...} -local config = arg[1] - -local telemetry = {} - -local lastEventTimes = {} -local lastValues = {} -local lastPlayTime = {} - -local userpref = dashx.preferences -local enabledEvents = (userpref and userpref.events) or {} - -local eventTable = { - { - sensor = "voltage", - event = function(value) - local session = dashx.session - if not session.batteryConfig then return end - - local cellCount = session.batteryConfig.batteryCellCount - local warnVoltage = session.batteryConfig.vbatwarningcellvoltage - local minVoltage = session.batteryConfig.vbatmincellvoltage - - local collective = session.rx.values['collective'] or 0 - local aileron = session.rx.values['aileron'] or 0 - local elevator = session.rx.values['elevator'] or 0 - local rudder = session.rx.values['rudder'] or 0 - - if not (cellCount and warnVoltage and minVoltage) then return end - - local cellVoltage = value / cellCount - if cellVoltage >= 0 and cellVoltage < (minVoltage / 2) then return end - - local suppressionPercent = userpref.general.gimbalsupression or 0.85 - local suppressionLimit = suppressionPercent * 1024 - - if cellVoltage < warnVoltage then dashx.utils.playFile("events", "alerts/lowvoltage.wav") end - end, - interval = 10 - }, {sensor = "smartfuel", event = function(value) if value and value <= 10 then dashx.utils.playFile("events", "alerts/lowfuel.wav") end end, interval = 10}, { - sensor = "armed", - event = function(value) - if value == 0 then dashx.utils.playFile("events", "alerts/armed.wav") end - if value == 1 then dashx.utils.playFile("events", "alerts/disarmed.wav") end - end, - debounce = 0.25 - }, {sensor = "inflight", event = function(value) if dashx.tasks.telemetry.getSensorSource("armed"):value() == 0 then end end, debounce = 0.25} -} - -function telemetry.wakeup() - local now = os.clock() - - for _, item in ipairs(eventTable) do - local key = item.sensor - if not enabledEvents[key] then goto continue end - - local source = dashx.tasks.telemetry.getSensorSource(key) - if not source then goto continue end - - local value = source:value() - if not value then goto continue end - - local lastVal = lastValues[key] - if lastVal and value == lastVal then goto continue end - - local lastTime = lastEventTimes[key] or 0 - local debounce = item.debounce or 0 - local interval = item.interval or 0 - - if debounce > 0 and (now - lastTime) < debounce then goto continue end - if interval > 0 and (now - lastTime) < interval then goto continue end - - item.event(value) - lastValues[key] = value - lastEventTimes[key] = now - - ::continue:: - end -end - -telemetry.eventTable = eventTable - -return telemetry diff --git a/src/dashx/tasks/events/tasks/timer.lua b/src/dashx/tasks/events/tasks/timer.lua deleted file mode 100644 index 0ea29b3..0000000 --- a/src/dashx/tasks/events/tasks/timer.lua +++ /dev/null @@ -1,54 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local arg = {...} -local config = arg[1] -local timer = {} - -local triggered = false -local lastBeepTime = nil - -function timer.wakeup() - local session = dashx.session - local modelFlightTime = session and session.modelFlightTime - local batteryConfig = session and session.batteryConfig - local targetSeconds = batteryConfig and batteryConfig.modelFlightTime or 0 - - if not targetSeconds or targetSeconds == 0 or not modelFlightTime or modelFlightTime == 0 then - triggered = false - lastBeepTime = nil - return - end - - if dashx.flightmode.current ~= "inflight" then - triggered = false - lastBeepTime = nil - return - end - - if modelFlightTime >= targetSeconds then - local now = os.clock() - if not triggered then - dashx.utils.playFileCommon("beep.wav") - triggered = true - lastBeepTime = now - elseif lastBeepTime and (now - lastBeepTime) >= 10 then - dashx.utils.playFileCommon("beep.wav") - lastBeepTime = now - end - else - triggered = false - lastBeepTime = nil - end -end - -function timer.reset() - triggered = false - lastBeepTime = nil -end - -return timer diff --git a/src/dashx/tasks/logger/init.lua b/src/dashx/tasks/logger/init.lua deleted file mode 100644 index 60715d3..0000000 --- a/src/dashx/tasks/logger/init.lua +++ /dev/null @@ -1,10 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local init = {interval = 0.25, script = "logger.lua", linkrequired = false, spreadschedule = true, simulatoronly = true} - -return init diff --git a/src/dashx/tasks/logger/lib/log.lua b/src/dashx/tasks/logger/lib/log.lua deleted file mode 100644 index 903fe9d..0000000 --- a/src/dashx/tasks/logger/lib/log.lua +++ /dev/null @@ -1,95 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local logs = {} - -logs.config = {enabled = true, log_to_file = true, print_interval = 0.5, disk_write_interval = 5.0, max_line_length = 100, min_print_level = "info", log_file = "log.txt", prefix = ""} - -if system:getVersion().simulation == true then logs.config.print_interval = 0.025 end - -logs.queue = {} -logs.disk_queue = {} -logs.last_print_time = os.clock() -logs.last_disk_write_time = os.clock() - -logs.levels = {debug = 0, info = 1, off = 2} - -local function split_message(message, max_length, prefix) - local lines = {} - while #message > max_length do - table.insert(lines, message:sub(1, max_length)) - message = prefix .. message:sub(max_length + 1) - end - if #message > 0 then table.insert(lines, message) end - return lines -end - -function logs.add(message, level) - if not logs.config.enabled or logs.config.min_print_level == "off" then return end - - level = level or "info" - if logs.levels[level] == nil then return end - if logs.levels[level] < logs.levels[logs.config.min_print_level] then return end - - local max_message_length = logs.config.max_line_length * 10 - if #message > max_message_length then message = message:sub(1, max_message_length) .. " [truncated]" end - - local prefix = logs.config.prefix .. " [" .. level .. "] " - local log_entry = prefix .. message - local lines = {} - - if system:getVersion().simulation then - table.insert(lines, log_entry) - else - lines = split_message(log_entry, logs.config.max_line_length, string.rep(" ", #prefix)) - end - - for _, line in ipairs(lines) do table.insert(logs.queue, line) end - - if logs.config.log_to_file then table.insert(logs.disk_queue, log_entry) end -end - -local function process_console_queue() - if not logs.config.enabled or logs.config.min_print_level == "off" then return end - - local now = os.clock() - if now - logs.last_print_time >= logs.config.print_interval and #logs.queue > 0 then - logs.last_print_time = now - - local MAX_CONSOLE_MESSAGES = 5 - for i = 1, math.min(MAX_CONSOLE_MESSAGES, #logs.queue) do - local message = table.remove(logs.queue, 1) - print(message) - end - end -end - -local function process_disk_queue() - if not logs.config.enabled or logs.config.min_print_level == "off" or not logs.config.log_to_file then return end - - local now = os.clock() - if now - logs.last_disk_write_time >= logs.config.disk_write_interval and #logs.disk_queue > 0 then - logs.last_disk_write_time = now - - local MAX_DISK_MESSAGES = 20 - local file = io.open(logs.config.log_file, "a") - if file then - for i = 1, math.min(MAX_DISK_MESSAGES, #logs.disk_queue) do - local message = table.remove(logs.disk_queue, 1) - file:write(message .. "\n") - end - file:close() - end - end -end - -function logs.process() - process_console_queue() - process_disk_queue() -end - -return logs diff --git a/src/dashx/tasks/logger/logger.lua b/src/dashx/tasks/logger/logger.lua deleted file mode 100644 index 7c45c59..0000000 --- a/src/dashx/tasks/logger/logger.lua +++ /dev/null @@ -1,31 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local arg = {...} -local config = arg[1] - -local logger = {} - -os.mkdir("LOGS:") -os.mkdir("LOGS:/dashx") -os.mkdir("LOGS:/dashx/logs") -logger.queue = assert(loadfile("tasks/logger/lib/log.lua"))(config) -logger.queue.config.log_file = "LOGS:/dashx/logs/dashx_" .. os.date("%Y-%m-%d_%H-%M-%S") .. ".log" -logger.queue.config.min_print_level = dashx.preferences.developer.loglevel -logger.queue.config.log_to_file = tostring(dashx.preferences.developer.logtofile) - -function logger.wakeup() logger.queue.process() end - -function logger.reset() end - -function logger.add(message, level) - logger.queue.config.min_print_level = dashx.preferences.developer.loglevel - logger.queue.config.log_to_file = tostring(dashx.preferences.developer.logtofile) - logger.queue.add(message, level) -end - -return logger diff --git a/src/dashx/tasks/logging/init.lua b/src/dashx/tasks/logging/init.lua deleted file mode 100644 index 342304e..0000000 --- a/src/dashx/tasks/logging/init.lua +++ /dev/null @@ -1,10 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local init = {interval = 0.5, script = "logging.lua", linkrequired = true, spreadschedule = true, simulatoronly = false} - -return init diff --git a/src/dashx/tasks/onconnect/init.lua b/src/dashx/tasks/onconnect/init.lua deleted file mode 100644 index 12a767c..0000000 --- a/src/dashx/tasks/onconnect/init.lua +++ /dev/null @@ -1,9 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local init = {interval = 0.25, script = "tasks.lua", linkrequired = false, spreadschedule = true, simulatoronly = false} -return init diff --git a/src/dashx/tasks/onconnect/tasks.lua b/src/dashx/tasks/onconnect/tasks.lua deleted file mode 100644 index fce4286..0000000 --- a/src/dashx/tasks/onconnect/tasks.lua +++ /dev/null @@ -1,141 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local tasks = {} -local tasksList = {} -local tasksLoaded = false - -local TASK_TIMEOUT_SECONDS = 10 - -local BASE_PATH = "tasks/onconnect/tasks/" -local PRIORITY_LEVELS = {"high", "medium", "low"} - -local function resetSessionFlags() - dashx.session.onConnect = dashx.session.onConnect or {} - for _, level in ipairs(PRIORITY_LEVELS) do dashx.session.onConnect[level] = false end - - dashx.session.isConnected = false -end - -function tasks.findTasks() - if tasksLoaded then return end - - resetSessionFlags() - - for _, level in ipairs(PRIORITY_LEVELS) do - local dirPath = BASE_PATH .. level .. "/" - local files = system.listFiles(dirPath) or {} - for _, file in ipairs(files) do - if file:match("%.lua$") then - local fullPath = dirPath .. file - local name = level .. "/" .. file:gsub("%.lua$", "") - local chunk, err = loadfile(fullPath) - if not chunk then - dashx.utils.log("Error loading task " .. fullPath .. ": " .. err, "error") - else - local module = assert(chunk()) - if type(module) == "table" and type(module.wakeup) == "function" then - tasksList[name] = {module = module, priority = level, initialized = false, complete = false, startTime = nil} - else - dashx.utils.log("Invalid task file: " .. fullPath, "info") - end - end - end - end - end - - tasksLoaded = true -end - -function tasks.resetAllTasks() - for _, task in pairs(tasksList) do - if type(task.module.reset) == "function" then task.module.reset() end - task.initialized = false - task.complete = false - task.startTime = nil - end - - resetSessionFlags() - dashx.tasks.reset() - dashx.session.resetMSPSensors = true -end - -function tasks.wakeup() - local telemetryActive = dashx.session.telemetryState - - if dashx.session.telemetryTypeChanged then - dashx.utils.logRotorFlightBanner() - - dashx.session.telemetryTypeChanged = false - tasks.resetAllTasks() - tasksLoaded = false - return - end - - if not telemetryActive then - tasks.resetAllTasks() - tasksLoaded = false - return - end - - if not tasksLoaded then tasks.findTasks() end - - local now = os.clock() - - for name, task in pairs(tasksList) do - if not task.initialized then - task.initialized = true - task.startTime = now - end - if not task.complete then - dashx.utils.log("Waking up " .. name, "debug") - task.module.wakeup() - if task.module.isComplete and task.module.isComplete() then - task.complete = true - task.startTime = nil - dashx.utils.log("Completed " .. name, "debug") - elseif task.startTime and (now - task.startTime) > TASK_TIMEOUT_SECONDS then - dashx.utils.log("Task '" .. name .. "' timed out.", "info") - task.startTime = nil - end - end - end - - for _, level in ipairs(PRIORITY_LEVELS) do - if not dashx.session.onConnect[level] then - local levelDone = true - for _, task in pairs(tasksList) do - if task.priority == level and not task.complete then - levelDone = false - break - end - end - if levelDone then - dashx.session.onConnect[level] = true - dashx.utils.log("All '" .. level .. "' tasks complete.", "info") - - if level == "high" then - dashx.utils.playFileCommon("beep.wav") - dashx.flightmode.current = "preflight" - dashx.tasks.events.flightmode.reset() - dashx.session.isConnectedHigh = true - return - elseif level == "medium" then - dashx.session.isConnectedMedium = true - return - elseif level == "low" then - dashx.session.isConnectedLow = true - dashx.session.isConnected = true - collectgarbage() - return - end - end - end - end -end - -return tasks diff --git a/src/dashx/tasks/onconnect/tasks/high/apiversion.lua b/src/dashx/tasks/onconnect/tasks/high/apiversion.lua deleted file mode 100644 index 160f7d3..0000000 --- a/src/dashx/tasks/onconnect/tasks/high/apiversion.lua +++ /dev/null @@ -1,16 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local apiversion = {} - -function apiversion.wakeup() dashx.session.apiVersion = 12.07 end - -function apiversion.reset() dashx.session.apiVersion = nil end - -function apiversion.isComplete() if dashx.session.apiVersion ~= nil then return true end end - -return apiversion diff --git a/src/dashx/tasks/onconnect/tasks/high/sensorstats.lua b/src/dashx/tasks/onconnect/tasks/high/sensorstats.lua deleted file mode 100644 index ae23142..0000000 --- a/src/dashx/tasks/onconnect/tasks/high/sensorstats.lua +++ /dev/null @@ -1,23 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local sensorstats = {} - -local runOnce = false - -function sensorstats.wakeup() - if dashx.tasks.telemetry then - dashx.tasks.telemetry.sensorStats = {} - runOnce = true - end -end - -function sensorstats.reset() runOnce = false end - -function sensorstats.isComplete() return runOnce end - -return sensorstats diff --git a/src/dashx/tasks/onconnect/tasks/high/timer.lua b/src/dashx/tasks/onconnect/tasks/high/timer.lua deleted file mode 100644 index 286889d..0000000 --- a/src/dashx/tasks/onconnect/tasks/high/timer.lua +++ /dev/null @@ -1,26 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local timer = {} - -local runOnce = false - -function timer.wakeup() - dashx.session.timer = {} - dashx.session.timer.start = nil - dashx.session.timer.live = nil - dashx.session.timer.lifetime = nil - dashx.session.timer.session = 0 - runOnce = true - -end - -function timer.reset() runOnce = false end - -function timer.isComplete() return runOnce end - -return timer diff --git a/src/dashx/tasks/onconnect/tasks/high/uid.lua b/src/dashx/tasks/onconnect/tasks/high/uid.lua deleted file mode 100644 index 1b6b5d4..0000000 --- a/src/dashx/tasks/onconnect/tasks/high/uid.lua +++ /dev/null @@ -1,39 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local uid = {} - -local function fnv1a32(s) - local hash = 0x811C9DC5 - for i = 1, #s do - hash = (hash ~ s:byte(i)) & 0xffffffff - hash = (hash * 0x01000193) & 0xffffffff - end - return hash -end - -local function path_to_uuid(path) - - local parts = {} - for i = 1, 4 do - local h = fnv1a32(path .. "\0" .. i) - parts[i] = string.format("%08x", h) - end - local full = table.concat(parts) - - return string.format("%s-%s-%s-%s-%s", full:sub(1, 8), full:sub(9, 12), full:sub(13, 16), full:sub(17, 20), full:sub(21, 32)) -end - -local function path_to_id32(path) return string.format("%08x", fnv1a32(path)) end - -function uid.wakeup() if dashx.session.mcu_id == nil then dashx.session.mcu_id = path_to_uuid(model.path()) end end - -function uid.reset() dashx.session.mcu_id = nil end - -function uid.isComplete() if dashx.session.mcu_id ~= nil then return true end end - -return uid diff --git a/src/dashx/tasks/onconnect/tasks/low/battery.lua b/src/dashx/tasks/onconnect/tasks/low/battery.lua deleted file mode 100644 index 54bade9..0000000 --- a/src/dashx/tasks/onconnect/tasks/low/battery.lua +++ /dev/null @@ -1,41 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local battery = {} - -function battery.wakeup() - - if dashx.session.apiVersion == nil then return end - - if (dashx.session.batteryConfig == nil and dashx.session.mcu_id) then - - local modelpref_file = "SCRIPTS:/" .. dashx.config.preferences .. "/models/" .. dashx.session.mcu_id .. ".ini" - - os.mkdir("SCRIPTS:/" .. dashx.config.preferences) - os.mkdir("SCRIPTS:/" .. dashx.config.preferences .. "/models") - local master_ini = dashx.ini.load_ini_file(modelpref_file) or {} - local preferences = master_ini.battery or {} - - dashx.session.batteryConfig = {} - dashx.session.batteryConfig.batteryCapacity = preferences.batteryCapacity - dashx.session.batteryConfig.batteryCellCount = preferences.batteryCellCount - dashx.session.batteryConfig.vbatwarningcellvoltage = preferences.vbatwarningcellvoltage / 10 - dashx.session.batteryConfig.vbatmincellvoltage = preferences.vbatmincellvoltage / 10 - dashx.session.batteryConfig.vbatmaxcellvoltage = preferences.vbatmaxcellvoltage / 10 - dashx.session.batteryConfig.vbatfullcellvoltage = preferences.vbatfullcellvoltage / 10 - dashx.session.batteryConfig.lvcPercentage = preferences.lvcPercentage - dashx.session.batteryConfig.consumptionWarningPercentage = preferences.consumptionWarningPercentage - - end - -end - -function battery.reset() dashx.session.batteryConfig = nil end - -function battery.isComplete() if dashx.session.batteryConfig ~= nil then return true end end - -return battery diff --git a/src/dashx/tasks/onconnect/tasks/low/rxmap.lua b/src/dashx/tasks/onconnect/tasks/low/rxmap.lua deleted file mode 100644 index 74afb69..0000000 --- a/src/dashx/tasks/onconnect/tasks/low/rxmap.lua +++ /dev/null @@ -1,36 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local rxmap = {} - -function rxmap.wakeup() - - if dashx.session.apiVersion == nil then return end - - if not dashx.utils.rxmapReady() then - - dashx.session.rx.map.aileron = 0 - dashx.session.rx.map.elevator = 1 - dashx.session.rx.map.collective = 2 - dashx.session.rx.map.rudder = 3 - dashx.session.rx.map.arm = 4 - dashx.session.rx.map.throttle = 5 - dashx.session.rx.map.mode = 6 - dashx.session.rx.map.headspeed = 7 - - end - -end - -function rxmap.reset() - dashx.session.rxmap = {} - dashx.session.rxvalues = {} -end - -function rxmap.isComplete() return dashx.utils.rxmapReady() end - -return rxmap diff --git a/src/dashx/tasks/onconnect/tasks/medium/modelpreferences.lua b/src/dashx/tasks/onconnect/tasks/medium/modelpreferences.lua deleted file mode 100644 index 520ef2e..0000000 --- a/src/dashx/tasks/onconnect/tasks/medium/modelpreferences.lua +++ /dev/null @@ -1,51 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local modelpreferences = {} - -local modelpref_defaults = { - dashboard = {theme_preflight = "nil", theme_inflight = "nil", theme_postflight = "nil"}, - general = {flightcount = 0, totalflighttime = 0, lastflighttime = 0}, - model = {armswitch = false, inflightswitch = false, inflightswitch_delay = 10, rateswitch = false}, - battery = {calc_local = 0, batteryCapacity = 2200, batteryCellCount = 3, vbatwarningcellvoltage = 35, vbatmincellvoltage = 33, vbatmaxcellvoltage = 43, vbatfullcellvoltage = 41, lvcPercentage = 30, consumptionWarningPercentage = 30} -} - -function modelpreferences.wakeup() - - if dashx.session.apiVersion == nil then return end - - if not dashx.session.mcu_id then return end - - if (dashx.session.modelPreferences == nil) then - - if dashx.config.preferences and dashx.session.mcu_id then - - local modelpref_file = "SCRIPTS:/" .. dashx.config.preferences .. "/models/" .. dashx.session.mcu_id .. ".ini" - dashx.utils.log("Preferences file: " .. modelpref_file, "info") - - os.mkdir("SCRIPTS:/" .. dashx.config.preferences) - os.mkdir("SCRIPTS:/" .. dashx.config.preferences .. "/models") - - local slave_ini = modelpref_defaults - local master_ini = dashx.ini.load_ini_file(modelpref_file) or {} - - local updated_ini = dashx.ini.merge_ini_tables(master_ini, slave_ini) - dashx.session.modelPreferences = updated_ini - dashx.session.modelPreferencesFile = modelpref_file - - if not dashx.ini.ini_tables_equal(master_ini, slave_ini) then dashx.ini.save_ini_file(modelpref_file, updated_ini) end - - end - end - -end - -function modelpreferences.reset() dashx.session.modelPreferences = nil end - -function modelpreferences.isComplete() if dashx.session.modelPreferences ~= nil then return true end end - -return modelpreferences diff --git a/src/dashx/tasks/sensors/frsky.lua b/src/dashx/tasks/sensors/frsky.lua deleted file mode 100644 index 1430b23..0000000 --- a/src/dashx/tasks/sensors/frsky.lua +++ /dev/null @@ -1,178 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local arg = {...} -local config = arg[1] - -local sensorTlm - -local frsky = {} - -frsky.name = "frsky" - -local MAX_FRAMES_PER_WAKEUP = 32 -local MAX_TIME_BUDGET = 0.004 - -local telemetryStartTime = os.clock() -local TELEMETRY_TIMEOUT = 20 - -local createSensorList = {} -createSensorList[0x0430] = {name = "Pitch", unit = UNIT_DEGREE, decimals = 1} -createSensorList[0x0440] = {name = "Roll", unit = UNIT_DEGREE, decimals = 1} -createSensorList[0x0480] = {name = "GPS Sats", unit = UNIT_RAW, decimals = 0} - -local dropSensorList = {} -dropSensorList[0x0400] = {name = "Temp1"} -dropSensorList[0x0410] = {name = "Temp1"} - -local renameSensorList = {} - -frsky.createSensorCache = {} -frsky.dropSensorCache = {} -frsky.renameSensorCache = {} - -frsky.renamed = {} -frsky.dropped = {} - -local function createSensor(physId, primId, appId, frameValue) - if dashx.session.apiVersion == nil then return "skip" end - local v = createSensorList[appId] - if not v then return "skip" end - - if frsky.createSensorCache[appId] == nil then - frsky.createSensorCache[appId] = system.getSource({category = CATEGORY_TELEMETRY_SENSOR, appId = appId}) - if frsky.createSensorCache[appId] == nil then - local s = model.createSensor() - s:name(v.name) - s:appId(appId) - s:physId(physId) - s:module(dashx.session.telemetrySensor:module()) - s:minimum(min or -1000000000) - s:maximum(max or 2147483647) - if v.unit then - s:unit(v.unit); - s:protocolUnit(v.unit) - end - if v.decimals then - s:decimals(v.decimals); - s:protocolDecimals(v.decimals) - end - if v.minimum then s:minimum(v.minimum) end - if v.maximum then s:maximum(v.maximum) end - frsky.createSensorCache[appId] = s - return "created" - end - end - - return "noop" -end - -local function dropSensor(physId, primId, appId, frameValue) - if dashx.session.apiVersion == nil then return "skip" end - if not dropSensorList or not dropSensorList[appId] then return "skip" end - - if frsky.dropSensorCache[appId] == nil then - local src = system.getSource({category = CATEGORY_TELEMETRY_SENSOR, appId = appId}) - frsky.dropSensorCache[appId] = src or false - end - local src = frsky.dropSensorCache[appId] - if src and src ~= false then - if not frsky.dropped[appId] then - src:drop() - frsky.dropped[appId] = true - return "dropped" - end - return "noop" - end - return "skip" -end - -local function renameSensor(physId, primId, appId, frameValue) - if dashx.session.apiVersion == nil then return "skip" end - local v = renameSensorList[appId] - if not v then return "skip" end - if frsky.renamed[appId] then return "noop" end - - if frsky.renameSensorCache[appId] == nil then - local src = system.getSource({category = CATEGORY_TELEMETRY_SENSOR, appId = appId}) - frsky.renameSensorCache[appId] = src or false - end - local src = frsky.renameSensorCache[appId] - if src and src ~= false then - if src:name() == v.onlyifname then - src:name(v.name) - frsky.renamed[appId] = true - return "renamed" - end - return "noop" - end - return "skip" -end - -local function telemetryPop() - - if not sensorTlm then return false end - - local frame = sensorTlm:popFrame() - if frame == nil then return false end - if not frame.physId or not frame.primId then return false end - - local physId, primId, appId, value = frame:physId(), frame:primId(), frame:appId(), frame:value() - - local cs = createSensor(physId, primId, appId, value) - if cs ~= "skip" then return true end - - local ds = dropSensor(physId, primId, appId, value) - if ds ~= "skip" then return true end - - renameSensor(physId, primId, appId, value) - return true -end - -function frsky.wakeup() - - if not sensorTlm then sensorTlm = sport.getSensor() end - - local function clearCaches() - frsky.createSensorCache = {} - frsky.renameSensorCache = {} - frsky.dropSensorCache = {} - end - - if not dashx.session.telemetryState or not dashx.session.telemetrySensor then - clearCaches() - return - end - - if not dashx.tasks and dashx.tasks.telemetry then return end - - if os.clock() - telemetryStartTime > TELEMETRY_TIMEOUT then - - clearCaches() - return - end - - if (dashx.app and dashx.app.guiIsRunning == false) or dashx.tasks.telemetry then - - local start = os.clock() - local count = 0 - while count < MAX_FRAMES_PER_WAKEUP and (os.clock() - start) <= MAX_TIME_BUDGET do - if not telemetryPop() then break end - count = count + 1 - end - end -end - -function frsky.reset() - frsky.createSensorCache = {} - frsky.renameSensorCache = {} - frsky.dropSensorCache = {} - frsky.renamed = {} - frsky.dropped = {} -end - -return frsky diff --git a/src/dashx/tasks/sensors/init.lua b/src/dashx/tasks/sensors/init.lua deleted file mode 100644 index 54c3914..0000000 --- a/src/dashx/tasks/sensors/init.lua +++ /dev/null @@ -1,10 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local init = {interval = 0.1, script = "sensors.lua", linkrequired = true, spreadschedule = true, simulatoronly = false} - -return init diff --git a/src/dashx/tasks/sensors/sensors.lua b/src/dashx/tasks/sensors/sensors.lua deleted file mode 100644 index 93c83ac..0000000 --- a/src/dashx/tasks/sensors/sensors.lua +++ /dev/null @@ -1,79 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local arg = {...} -local config = arg[1] - -local sensors = {} -local loadedSensorModule = nil - -local delayDuration = 2 -local delayStartTime = nil -local delayPending = false - -local smart = assert(loadfile("tasks/sensors/smart.lua"))(config) - -local log = dashx.utils.log -local tasks = dashx.tasks - -local telemetryStartTime = os.clock() -local TELEMETRY_TIMEOUT = 20 - -local function loadSensorModule() - if not tasks.active() then return nil end - if not dashx.session.apiVersion then return nil end - - local protocol = dashx.session.telemetryType or "sport" - - if system:getVersion().simulation == true then - if not loadedSensorModule or loadedSensorModule.name ~= "sim" then loadedSensorModule = {name = "sim", module = assert(loadfile("tasks/sensors/sim.lua"))(config)} end - elseif protocol == "sport" then - if not loadedSensorModule or loadedSensorModule.name ~= "frsky" then loadedSensorModule = {name = "frsky", module = assert(loadfile("tasks/sensors/frsky.lua"))(config)} end - else - loadedSensorModule = nil - end -end - -function sensors.wakeup() - - if dashx.session.resetSensors and not delayPending then - delayStartTime = os.clock() - delayPending = true - dashx.session.resetSensors = false - log("Delaying sensor wakeup for " .. delayDuration .. " seconds", "info") - return - end - - if delayPending then - if os.clock() - delayStartTime >= delayDuration then - log("Delay complete; resuming sensor wakeup", "info") - delayPending = false - else - local module = model.getModule(dashx.session.telemetrySensor:module()) - if module ~= nil and module.muteSensorLost ~= nil then module:muteSensorLost(5.0) end - return - end - end - - loadSensorModule() - if loadedSensorModule and loadedSensorModule.module.wakeup then loadedSensorModule.module.wakeup() end - - if smart and smart.wakeup then if dashx.session.isConnected then smart.wakeup() end end - -end - -function sensors.reset() - - if loadedSensorModule and loadedSensorModule.module and loadedSensorModule.module.reset then loadedSensorModule.module.reset() end - - smart.reset() - - loadedSensorModule = nil - -end - -return sensors diff --git a/src/dashx/tasks/sensors/sim.lua b/src/dashx/tasks/sensors/sim.lua deleted file mode 100644 index de42d28..0000000 --- a/src/dashx/tasks/sensors/sim.lua +++ /dev/null @@ -1,150 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local arg = {...} -local config = arg[1] - -local cacheExpireTime = 30 -local lastCacheFlushTime = os.clock() - -local lastWakeupTime = 0 -local wakeupInterval = 1 -local lastWakeupTimeDrop = 0 -local wakeupIntervalDrop = 120 -local firstRun = true - -local sim = {} -sim.name = "sim" - -local sensorList = dashx.tasks.telemetry.simSensors() - -local dropList = { - ["0xF104"] = true, - ["0x0300"] = true, - ["0x0301"] = true, - ["0x0100"] = true, - ["0x0110"] = true, - ["0x0500"] = true, - ["0x0200"] = true, - ["0x0800"] = true, - ["0x0850"] = true, - ["0x0830"] = true, - ["0x0820"] = true, - ["0x0840"] = true, - ["0xF103"] = true, - ["0x0A00"] = true, - ["0x0210"] = true, - ["0x0B20"] = true, - ["0x0730"] = true, - ["0xF108"] = true, - ["0x0B60"] = true, - ["0x0D50"] = true, - ["0x0D10"] = true, - ["0x0D20"] = true, - ["0x0D40"] = true, - ["0x0D00"] = true, - ["0x0D30"] = true, - ["0x0D60"] = true, - ["0x0D70"] = true, - ["0x0E60"] = true, - ["0x7360"] = true -} - -local sensors = {uid = {}, lastvalue = {}} - -local function createSensor(uid, name, unit, dec, value, min, max) - local sensor = model.createSensor({type = SENSOR_TYPE_DIY}) - sensor:name(name) - sensor:appId(uid) - sensor:module(dashx.session.telemetrySensor:module()) - sensor:minimum(min or -1000000000) - sensor:maximum(max or 2147483647) - - if dec and dec >= 1 then - sensor:decimals(dec) - sensor:protocolDecimals(dec) - end - - if unit then - sensor:unit(unit) - sensor:protocolUnit(unit) - end - - if value then sensor:value(value) end - - sensors.uid[uid] = sensor -end - -local function dropSensor(uid) - local src = system.getSource({category = CATEGORY_TELEMETRY_SENSOR, appId = uid}) - if src then src:drop() end -end - -local function ensureSensorExists(uid, name, unit, dec, value, min, max) - if not sensors.uid[uid] then - local existingSensor = system.getSource({category = CATEGORY_TELEMETRY_SENSOR, appId = uid}) - if existingSensor then - sensors.uid[uid] = existingSensor - else - dashx.utils.log("Create sensor: " .. uid, "info") - createSensor(uid, name, unit, dec, value, min, max) - end - end -end - -local function updateSensorValue(uid, value) - if sensors.uid[uid] then - if type(value) == "function" then value = value() end - sensors.uid[uid]:value(value) - end -end - -local function flushCacheIfNeeded() - if os.clock() - lastCacheFlushTime >= cacheExpireTime then - sensors.uid = {} - sensors.lastvalue = {} - lastCacheFlushTime = os.clock() - end -end - -local function dropAutoDiscoveredSensors() for uid in pairs(dropList) do dropSensor(uid) end end - -local function handleSensors() - for _, v in ipairs(sensorList) do - local uid, name, unit, dec, value, min, max = v.sensor.uid, v.name, v.sensor.unit, v.sensor.dec, v.sensor.value, v.sensor.min, v.sensor.max - - if uid and min and max and value then - ensureSensorExists(uid, name, unit, dec, value, min, max) - updateSensorValue(uid, value) - end - end -end - -local function wakeup() - local now = os.clock() - - if now - lastWakeupTime >= wakeupInterval then - handleSensors() - lastWakeupTime = now - end - - if firstRun or now - lastWakeupTimeDrop >= wakeupIntervalDrop then - dropAutoDiscoveredSensors() - lastWakeupTimeDrop = now - firstRun = false - end - - flushCacheIfNeeded() -end - -function sim.reset() - sensors.uid = {} - sensors.lastvalue = {} -end - -sim.wakeup = wakeup -return sim diff --git a/src/dashx/tasks/sensors/smart.lua b/src/dashx/tasks/sensors/smart.lua deleted file mode 100644 index f1d636a..0000000 --- a/src/dashx/tasks/sensors/smart.lua +++ /dev/null @@ -1,169 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local smart = {} - -local smartfuel = assert(loadfile("tasks/sensors/lib/smartfuel.lua"))() -local smartfuelvoltage = assert(loadfile("tasks/sensors/lib/smartfuelvoltage.lua"))() - -local log -local tasks - -local interval = 1 -local lastWake = os.clock() - -local firstWakeup = true - -local function calculateFuel() - - if dashx.session.modelPreferences and dashx.session.modelPreferences.battery and dashx.session.modelPreferences.battery.calc_local then - - if dashx.session.modelPreferences.battery.calc_local == 1 or not dashx.tasks.telemetry.getSensorSource("consumption") then - return smartfuelvoltage.calculate() - else - return smartfuel.calculate() - end - else - return smartfuel.calculate() - end - -end - -local function calculateConsumption() - - if dashx.session.modelPreferences and dashx.session.modelPreferences.battery and dashx.session.modelPreferences.battery.calc_local then - if dashx.session.modelPreferences.battery.calc_local == 1 or not dashx.tasks.telemetry.getSensorSource("consumption") then - local capacity = (dashx.session.batteryConfig and dashx.session.batteryConfig.batteryCapacity) or 1000 - local smartfuelPct = dashx.tasks.telemetry.getSensor("smartfuel") - local warningPercentage = (dashx.session.batteryConfig and dashx.session.batteryConfig.consumptionWarningPercentage) or 30 - if smartfuelPct then - local usableCapacity = capacity * (1 - warningPercentage / 100) - local usedPercent = 100 - smartfuelPct - return (usedPercent / 100) * usableCapacity - end - else - - return dashx.tasks.telemetry.getSensor("consumption") or 0 - end - else - - return dashx.tasks.telemetry.getSensor("consumption") or 0 - end -end - -local switchCache = {} - -local smart_sensors = { - armed = { - name = "Armed", - appId = 0x5FE0, - unit = UNIT_RAW, - minimum = 0, - maximum = 1, - value = function() - if dashx.session.modelPreferences.model then - local settings = dashx.session.modelPreferences.model - if settings.armswitch then - local category, member, options = settings.armswitch:match("([^:]+):([^:]+):([^:]+)") - - if not switchCache["armed"] then switchCache["armed"] = system.getSource({category = category, member = member, options = options}) end - local state = switchCache["armed"]:state() - - return (state and 0 or 1) - end - end - return false - end - }, - inflight = { - name = "Inflight", - appId = 0x5FDF, - unit = UNIT_RAW, - minimum = 0, - maximum = 1, - value = function() - if dashx.session.modelPreferences.model then - local settings = dashx.session.modelPreferences.model - if settings.inflightswitch then - local category, member, options = settings.inflightswitch:match("([^:]+):([^:]+):([^:]+)") - - if not switchCache["inflight"] then switchCache["inflight"] = system.getSource({category = category, member = member, options = options}) end - local state = switchCache["inflight"]:state() - return (state and 0 or 1) - end - end - return false - end - }, - smartfuel = {name = "Smart Fuel", appId = 0x5FE1, unit = UNIT_PERCENT, minimum = 0, maximum = 100, value = calculateFuel}, - - smartconsumption = {name = "Smart Consumption", appId = 0x5FDE, unit = UNIT_MILLIAMPERE_HOUR, minimum = 0, maximum = 1000000000, value = calculateConsumption} -} - -smart.sensors = msp_sensors -local sensorCache = {} - -local function createOrUpdateSensor(appId, fieldMeta, value) - if not sensorCache[appId] then - local existingSensor = system.getSource({category = CATEGORY_TELEMETRY_SENSOR, appId = appId}) - - if existingSensor then - sensorCache[appId] = existingSensor - else - local sensor = model.createSensor({type = SENSOR_TYPE_DIY}) - sensor:name(fieldMeta.name) - sensor:appId(appId) - sensor:physId(0) - sensor:module(dashx.session.telemetrySensor:module()) - - if fieldMeta.unit then - sensor:unit(fieldMeta.unit) - sensor:protocolUnit(fieldMeta.unit) - end - sensor:minimum(fieldMeta.minimum or -1000000000) - sensor:maximum(fieldMeta.maximum or 1000000000) - - sensorCache[appId] = sensor - end - end - - if value then - sensorCache[appId]:value(value) - else - sensorCache[appId]:reset() - end -end - -local lastWakeupTime = 0 -function smart.wakeup() - - if firstWakeup then - log = dashx.utils.log - tasks = dashx.tasks - firstWakeup = false - end - - if (os.clock() - lastWake) < interval then return end - lastWake = os.clock() - - for name, meta in pairs(smart_sensors) do - local value - if type(meta.value) == "function" then - value = meta.value() - else - value = meta.value - end - createOrUpdateSensor(meta.appId, meta, value) - end -end - -function smart.reset() - sensorCache = {} - switchCache = {} -end - -return smart diff --git a/src/dashx/tasks/simevent/init.lua b/src/dashx/tasks/simevent/init.lua deleted file mode 100644 index 0d46a92..0000000 --- a/src/dashx/tasks/simevent/init.lua +++ /dev/null @@ -1,10 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local init = {interval = 1, script = "simevent.lua", linkrequired = false, spreadschedule = true, simulatoronly = true} - -return init diff --git a/src/dashx/tasks/simevent/simevent.lua b/src/dashx/tasks/simevent/simevent.lua deleted file mode 100644 index 6ab6109..0000000 --- a/src/dashx/tasks/simevent/simevent.lua +++ /dev/null @@ -1,40 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local simevent = {} - -local source = "SCRIPTS:/" .. dashx.config.baseDir .. "/sim/sensors/" - -local handlers = {simevent_telemetry_state = function(value) dashx.simevent.telemetry_state = (value == 0) end} - -local lastValues = {} - -function simevent.wakeup() - - if not system.getVersion().simulation then return end - - for name, handler in pairs(handlers) do - local path = source .. name .. ".lua" - - local chunk, loadErr = loadfile(path) - if not chunk then - print(("sim: could not load %s.lua: %s"):format(name, loadErr)) - else - - local ok, result = pcall(chunk) - if not ok then - print(("sim: error running %s.lua: %s"):format(name, result)) - elseif result ~= lastValues[name] then - - lastValues[name] = result - handler(result) - end - end - end -end - -return simevent diff --git a/src/dashx/tasks/tasks.lua b/src/dashx/tasks/tasks.lua deleted file mode 100644 index 47b96dc..0000000 --- a/src/dashx/tasks/tasks.lua +++ /dev/null @@ -1,643 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local utils = dashx.utils -local compiler = loadfile - -local currentTelemetrySensor -local tasksPerCycle -local taskSchedulerPercentage - -local schedulerTick -local lastSensorName -local tasks, tasksList = {}, {} -tasks.heartbeat, tasks.begin, tasks.wasOn = nil, nil, false - -local currentSensor, currentModuleId, currentTelemetryType -local internalModule, externalModule - -tasks._justInitialized = false -tasks._initState = "start" -tasks._initMetadata = nil -tasks._initKeys = nil -tasks._initIndex = 1 - -local ethosVersionGood -local telemetryCheckScheduler = os.clock - -local lastCheckAt -local lastTelemetryType - -local lastNameCheckAt = 0 -local NAME_CHECK_INTERVAL = 2.0 - -local usingSimulator = system.getVersion().simulation - -local tlm - -local CPU_TICK_HZ = 20 -local SCHED_DT = 1 / CPU_TICK_HZ -local OVERDUE_TOL = SCHED_DT * 0.25 - -local last_wakeup_start -local CPU_TICK_BUDGET -local CPU_ALPHA -local cpu_avg - -local MEM_ALPHA -local mem_avg_kb -local last_mem_t -local MEM_PERIOD - -tasks.profile = {enabled = false, dumpInterval = 5, minDuration = 0, include = nil, exclude = nil, onDump = nil} - -local function profWanted(name) - if not tasks.profile.enabled then return false end - local inc, exc = tasks.profile.include, tasks.profile.exclude - if inc and not inc[name] then return false end - if exc and exc[name] then return false end - return true -end - -local function profRecord(task, dur) - if dur < (tasks.profile.minDuration or 0) then return end - task.duration = dur - task.totalDuration = (task.totalDuration or 0) + dur - task.runs = (task.runs or 0) + 1 - task.maxDuration = math.max(task.maxDuration or 0, dur) -end - -function tasks.isTaskActive(name) - for _, t in ipairs(tasksList) do - if t.name == name then - local age = os.clock() - t.last_run - if name == "msp" then - return dashx.app.triggers.mspBusy - elseif name == "callback" then - return age <= 2 - else - return age <= t.interval - end - end - end - return false -end - -local function taskOffset(name, interval) - local hash = 0 - for i = 1, #name do hash = (hash * 31 + name:byte(i)) % 100000 end - local base = (hash % (interval * 1000)) / 1000 - local jitter = math.random() * interval - return (base + jitter) % interval -end - -function tasks.dumpSchedule() - local now = os.clock() - utils.log("====== Task Schedule Dump ======", "info") - for _, t in ipairs(tasksList) do - local next_run = t.last_run + t.interval - local in_secs = next_run - now - utils.log(string.format("%-15s | interval: %4.3fs | last_run: %8.3f | next in: %6.3fs", t.name, t.interval, t.last_run, in_secs), "info") - end - utils.log("================================", "info") -end - -function tasks.initialize() - local cacheFile, cachePath = "tasks.lua", "cache/tasks.lua" - if io.open(cachePath, "r") then - local ok, cached = pcall(dofile, cachePath) - if ok and type(cached) == "table" then - tasks._initMetadata = cached - utils.log("[cache] Loaded task metadata from cache", "info") - else - utils.log("[cache] Failed to load tasks cache", "info") - end - end - if not tasks._initMetadata then - local taskPath, taskMetadata = "tasks/", {} - for _, dir in pairs(system.listFiles(taskPath)) do - if dir ~= "." and dir ~= ".." and not dir:match("%.%a+$") then - local initPath = taskPath .. dir .. "/init.lua" - local func, err = loadfile(initPath) - if err then - utils.log("Error loading " .. initPath .. ": " .. err, "info") - elseif func then - local tconfig = func() - if type(tconfig) == "table" and tconfig.interval and tconfig.script then - taskMetadata[dir] = {interval = tconfig.interval, script = tconfig.script, linkrequired = tconfig.linkrequired or false, connected = tconfig.connected or false, simulatoronly = tconfig.simulatoronly or false, spreadschedule = tconfig.spreadschedule or false, init = initPath} - end - end - end - end - tasks._initMetadata = taskMetadata - utils.createCacheFile(taskMetadata, cacheFile) - utils.log("[cache] Created new tasks cache file", "info") - end - tasks._initKeys = utils.keys(tasks._initMetadata) - tasks._initState = "loadNextTask" -end - -function tasks.findTasks() - local taskPath, taskMetadata = "tasks/", {} - - for _, dir in pairs(system.listFiles(taskPath)) do - if dir ~= "." and dir ~= ".." and not dir:match("%.%a+$") then - local initPath = taskPath .. dir .. "/init.lua" - local func, err = loadfile(initPath) - if err then - utils.log("Error loading " .. initPath .. ": " .. err, "info") - elseif func then - local tconfig = func() - if type(tconfig) ~= "table" or not tconfig.interval or not tconfig.script then - utils.log("Invalid configuration in " .. initPath, "debug") - else - local scriptPath = taskPath .. dir .. "/" .. tconfig.script - local fn, loadErr = loadfile(scriptPath) - if fn then - tasks[dir] = fn(config) - else - utils.log("Failed to load task script " .. scriptPath .. ": " .. loadErr, "warn") - end - - local baseInterval = tconfig.interval or 1 - local interval = baseInterval + (math.random() * 0.1) - local offset = taskOffset(dir, interval) - - local task = { - name = dir, - interval = interval, - script = tconfig.script, - linkrequired = tconfig.linkrequired or false, - connected = tconfig.connected or false, - spreadschedule = tconfig.spreadschedule or false, - simulatoronly = tconfig.simulatoronly or false, - last_run = os.clock() - offset, - - duration = 0, - totalDuration = 0, - runs = 0, - maxDuration = 0 - } - table.insert(tasksList, task) - - taskMetadata[dir] = {interval = task.interval, script = task.script, linkrequired = task.linkrequired, connected = task.connected, simulatoronly = task.simulatoronly, spreadschedule = task.spreadschedule} - end - end - end - end - return taskMetadata -end - -local function clearSessionAndQueue() - tasks.setTelemetryTypeChanged() - utils.session() - local q = dashx.tasks and dashx.tasks.msp and dashx.tasks.msp.mspQueue - if q then q:clear() end - - internalModule = nil - externalModule = nil - currentSensor = nil - currentModuleId = nil - currentTelemetryType = nil - -end - -function tasks.telemetryCheckScheduler() - - local now = os.clock() - - local telemetryState = (tlm and tlm:state()) or false - if system.getVersion().simulation and dashx.simevent.telemetry_state == false then telemetryState = false end - - if not telemetryState then return clearSessionAndQueue() end - - if currentSensor then - dashx.session.telemetryState = true - dashx.session.telemetrySensor = currentSensor - dashx.session.telemetryModule = currentModuleId - dashx.session.telemetryType = currentTelemetryType - - if now - lastNameCheckAt >= NAME_CHECK_INTERVAL then - lastNameCheckAt = now - if currentSensor:name() ~= lastSensorName then - utils.log("Telemetry sensor changed to " .. tostring(currentSensor:name()), "info") - lastSensorName = currentSensor:name() - currentSensor = nil - end - end - - return - end - - if not internalModule or not externalModule then - internalModule = model.getModule(0) - externalModule = model.getModule(1) - end - - if internalModule and internalModule:enable() then - currentSensor = system.getSource({appId = 0xF101}) - currentModuleId = internalModule - currentTelemetryType = "sport" - elseif externalModule and externalModule:enable() then - currentSensor = system.getSource({crsfId = 0x14, subIdStart = 0, subIdEnd = 1}) - currentModuleId = externalModule - currentTelemetryType = "crsf" - if not currentSensor then - currentSensor = system.getSource({appId = 0xF101}) - currentTelemetryType = "sport" - end - end - - if not currentSensor then return clearSessionAndQueue() end - - dashx.session.telemetryState = true - dashx.session.telemetrySensor = currentSensor - dashx.session.telemetryModule = currentModuleId - dashx.session.telemetryType = currentTelemetryType - - if currentTelemetryType ~= lastTelemetryType then - dashx.utils.log("Telemetry type changed to " .. tostring(currentTelemetryType), "info") - tasks.setTelemetryTypeChanged() - lastTelemetryType = currentTelemetryType - clearSessionAndQueue() - end - -end - -function tasks.active() - if not tasks.heartbeat then return false end - - local age = os.clock() - tasks.heartbeat - tasks.wasOn = age >= 2 - if dashx.app.triggers and dashx.app.triggers.mspBusy or age <= 2 then return true end - - return false -end - -local function overdue_seconds(task, now, grace_s) return (now - task.last_run) - (task.interval + (grace_s or 0)) end - -local function canRunTask(task, now) - local hf = task.interval < SCHED_DT - local grace = hf and OVERDUE_TOL or (task.interval * 0.25) - - local od = overdue_seconds(task, now, grace) - - local priorityTask = task.name == "msp" or task.name == "callback" - - local linkOK = not task.linkrequired or dashx.session.telemetryState - local connOK = not task.connected or dashx.session.isConnected - - local ok = linkOK and connOK and (priorityTask or od >= 0 or not (dashx.app.triggers and dashx.app.triggers.mspBusy)) and (not task.simulatoronly or usingSimulator) - - return ok, od -end - -function tasks.wakeup() - - schedulerTick = schedulerTick + 1 - tasks.heartbeat = os.clock() - - tasks.profile.enabled = dashx.preferences and dashx.preferences.developer and dashx.preferences.developer.taskprofiler - - if ethosVersionGood == nil then ethosVersionGood = utils.ethosVersionAtLeast() end - if not ethosVersionGood then return end - - if tasks.begin == true then - tasks.begin = false - tasks._justInitialized = true - tasks.initialize() - return - end - - if tasks._justInitialized then - tasks._justInitialized = false - return - end - - if tasks._initState == "loadNextTask" then - local key = tasks._initKeys[tasks._initIndex] - if key then - local meta = tasks._initMetadata[key] - if meta.init then - local initFn, err = loadfile(meta.init) - if initFn then - pcall(initFn) - else - utils.log("Failed to load init for " .. key .. ": " .. (err or "unknown error"), "info") - end - end - local script = "tasks/" .. key .. "/" .. meta.script - local module = assert(loadfile(script))(config) - tasks[key] = module - - if meta.interval >= 0 then - local baseInterval = meta.interval or 1 - local interval = baseInterval + (math.random() * 0.1) - local offset = math.random() * interval - table.insert(tasksList, { - name = key, - interval = interval, - script = meta.script, - spreadschedule = meta.spreadschedule, - linkrequired = meta.linkrequired or false, - connected = meta.connected or false, - simulatoronly = meta.simulatoronly or false, - last_run = os.clock() - offset, - - duration = 0, - totalDuration = 0, - runs = 0, - maxDuration = 0 - }) - end - - tasks._initIndex = tasks._initIndex + 1 - return - else - tasks._initState = nil - tasks._initMetadata = nil - tasks._initKeys = nil - tasks._initIndex = 1 - utils.log("All tasks initialized.", "info") - return - end - end - - tasks.telemetryCheckScheduler() - - local now = os.clock() - - local function runNonSpreadTasks() - for _, task in ipairs(tasksList) do - if not task.spreadschedule and tasks[task.name].wakeup then - local okToRun, od = canRunTask(task, now) - if okToRun then - local elapsed = now - task.last_run - if elapsed + OVERDUE_TOL >= task.interval then - if (od or 0) > 0 then utils.log(string.format("[scheduler] %s overdue by %.3fs", task.name, od), "debug") end - local fn = tasks[task.name].wakeup - if fn then - if profWanted(task.name) then - local t0 = os.clock() - local ok, err = pcall(fn, tasks[task.name]) - local t1 = os.clock() - profRecord(task, t1 - t0) - if not ok then - print(("Error in task %q wakeup: %s"):format(task.name, err)) - collectgarbage("collect") - end - else - local ok, err = pcall(fn, tasks[task.name]) - if not ok then - print(("Error in task %q wakeup: %s"):format(task.name, err)) - collectgarbage("collect") - end - end - end - task.last_run = now - end - end - end - end - end - - local function runSpreadTasks() - local normalEligibleTasks, mustRunTasks = {}, {} - - for _, task in ipairs(tasksList) do - if task.spreadschedule then - local okToRun, od = canRunTask(task, now) - if okToRun then - local elapsed = now - task.last_run - if elapsed >= 2 * task.interval then - table.insert(mustRunTasks, task) - utils.log(string.format("[scheduler] %s hard overdue by %.3fs", task.name, elapsed - 2 * task.interval), "debug") - elseif elapsed + OVERDUE_TOL >= task.interval then - table.insert(normalEligibleTasks, task) - if elapsed - task.interval > 0 then utils.log(string.format("[scheduler] %s overdue by %.3fs", task.name, elapsed - task.interval), "debug") end - end - end - end - end - - table.sort(mustRunTasks, function(a, b) return a.last_run < b.last_run end) - table.sort(normalEligibleTasks, function(a, b) return a.last_run < b.last_run end) - - local nonSpreadCount = 0 - for _, task in ipairs(tasksList) do if not task.spreadschedule then nonSpreadCount = nonSpreadCount + 1 end end - - tasksPerCycle = math.ceil(nonSpreadCount * taskSchedulerPercentage) - - for _, task in ipairs(mustRunTasks) do - local fn = tasks[task.name].wakeup - if fn then - if profWanted(task.name) then - local t0 = os.clock() - local ok, err = pcall(fn, tasks[task.name]) - local t1 = os.clock() - profRecord(task, t1 - t0) - if not ok then - print(("Error in task %q wakeup (must-run): %s"):format(task.name, err)) - collectgarbage("collect") - end - else - local ok, err = pcall(fn, tasks[task.name]) - if not ok then - print(("Error in task %q wakeup (must-run): %s"):format(task.name, err)) - collectgarbage("collect") - end - end - end - task.last_run = now - end - - for i = 1, math.min(tasksPerCycle, #normalEligibleTasks) do - local task = normalEligibleTasks[i] - local fn = tasks[task.name].wakeup - if fn then - if profWanted(task.name) then - local t0 = os.clock() - local ok, err = pcall(fn, tasks[task.name]) - local t1 = os.clock() - profRecord(task, t1 - t0) - if not ok then - print(("Error in task %q wakeup: %s"):format(task.name, err)) - collectgarbage("collect") - end - else - local ok, err = pcall(fn, tasks[task.name]) - if not ok then - print(("Error in task %q wakeup: %s"):format(task.name, err)) - collectgarbage("collect") - end - end - end - task.last_run = now - end - end - - local cycleFlip = schedulerTick % 2 - if cycleFlip == 0 then - runNonSpreadTasks() - else - runSpreadTasks() - end - - if tasks.profile.enabled then - tasks._lastProfileDump = tasks._lastProfileDump or now - local dumpEvery = tasks.profile.dumpInterval or 5 - if (now - tasks._lastProfileDump) >= dumpEvery then - if tasks.dumpProfile then tasks.dumpProfile() end - tasks._lastProfileDump = now - end - end - - local t_end = os.clock() - local work_elapsed = t_end - now - - local dt - if last_wakeup_start ~= nil then - dt = now - last_wakeup_start - else - dt = (1 / CPU_TICK_HZ) - end - - if dt < (0.25 * (1 / CPU_TICK_HZ)) then dt = (1 / CPU_TICK_HZ) end - - local instant_util = work_elapsed / dt - - if usingSimulator then - - local SIM_TARGET_UTIL = 0.50 - local SIM_MAX_UTIL = 0.80 - - if instant_util < SIM_TARGET_UTIL then - - local BLEND = 0.55 - instant_util = math.min(SIM_MAX_UTIL, instant_util + (SIM_TARGET_UTIL - instant_util) * BLEND) - end - end - - cpu_avg = CPU_ALPHA * instant_util + (1 - CPU_ALPHA) * cpu_avg - dashx.session.cpuload = math.min(100, math.max(0, cpu_avg * 100)) - - last_wakeup_start = now - - do - local now2 = os.clock() - if (now2 - last_mem_t) >= MEM_PERIOD then - last_mem_t = now2 - - local m = (system.getMemoryUsage and system.getMemoryUsage()) or nil - if m and m.luaRamAvailable then - - local free_now_kb = (m.luaRamAvailable or 0) / 1000 - if mem_avg_kb == nil then - mem_avg_kb = free_now_kb - else - mem_avg_kb = MEM_ALPHA * free_now_kb + (1 - MEM_ALPHA) * mem_avg_kb - end - dashx.session.freeram = mem_avg_kb - dashx.session.luaUsedKb = collectgarbage and collectgarbage("count") or nil - dashx.session.memSource = "system" - else - - local used_kb = collectgarbage and collectgarbage("count") or nil - dashx.session.luaUsedKb = used_kb - - dashx.session.freeram = mem_avg_kb - dashx.session.memSource = "lua" - end - end - end - -end - -function tasks.reset() - - for _, task in ipairs(tasksList) do if tasks[task.name].reset then tasks[task.name].reset() end end - dashx.utils.session() -end - -function tasks.dumpProfile(opts) - if not tasks.profile.enabled then return end - local sortKey = (opts and opts.sort) or "avg" - local snapshot = {} - for _, t in ipairs(tasksList) do - local runs = t.runs or 0 - local avg = runs > 0 and ((t.totalDuration or 0) / runs) or 0 - snapshot[#snapshot + 1] = {name = t.name, last = t.duration or 0, max = t.maxDuration or 0, total = t.totalDuration or 0, runs = runs, avg = avg, interval = t.interval or 0} - end - local order = {avg = function(a, b) return a.avg > b.avg end, last = function(a, b) return a.last > b.last end, max = function(a, b) return a.max > b.max end, total = function(a, b) return a.total > b.total end, runs = function(a, b) return a.runs > b.runs end} - table.sort(snapshot, order[sortKey] or order.avg) - - if tasks.profile.onDump and tasks.profile.onDump(snapshot) then return end - - utils.log("====== Task Profile ======", "info") - for _, p in ipairs(snapshot) do utils.log(string.format("%-15s | avg:%8.5fs | last:%8.5fs | max:%8.5fs | total:%8.3fs | runs:%6d | int:%4.3fs", p.name, p.avg, p.last, p.max, p.total, p.runs, p.interval), "info") end - utils.log("================================", "info") -end - -function tasks.resetProfile() - for _, t in ipairs(tasksList) do - t.duration = 0 - t.totalDuration = 0 - t.runs = 0 - t.maxDuration = 0 - end - utils.log("[profile] Cleared profiling stats", "info") -end - -function tasks.event(widget, category, value, x, y) print("Event:", widget, category, value, x, y) end - -function tasks.init() - - currentTelemetrySensor = nil - tasksPerCycle = 1 - taskSchedulerPercentage = 0.5 - schedulerTick = 0 - - ethosVersionGood = nil - lastSensorName = nil - lastCheckAt = nil - - CPU_TICK_BUDGET = 1 / CPU_TICK_HZ - CPU_ALPHA = 0.2 - cpu_avg = 0 - last_wakeup_start = nil - - MEM_ALPHA = 0.2 - mem_avg_kb = nil - last_mem_t = 0 - MEM_PERIOD = 2.0 - - tasks.heartbeat = nil - tasks.wasOn = false - tasks._justInitialized = false - - tasksList = {} - tasks._initState = "start" - tasks._initMetadata = nil - tasks._initKeys = nil - tasks._initIndex = 1 - - tasks.begin = true - - tlm = system.getSource({category = CATEGORY_SYSTEM_EVENT, member = TELEMETRY_ACTIVE}) - -end - -function tasks.setTelemetryTypeChanged() - for _, task in ipairs(tasksList) do if tasks[task.name].setTelemetryTypeChanged then tasks[task.name].setTelemetryTypeChanged() end end - dashx.utils.session() -end - -function tasks.read() end - -function tasks.write() end - -return tasks diff --git a/src/dashx/tasks/telemetry/init.lua b/src/dashx/tasks/telemetry/init.lua deleted file mode 100644 index db12470..0000000 --- a/src/dashx/tasks/telemetry/init.lua +++ /dev/null @@ -1,9 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local init = {interval = 0.5, script = "telemetry.lua", linkrequired = false, spreadschedule = true, simulatoronly = false} -return init diff --git a/src/dashx/tasks/timer/init.lua b/src/dashx/tasks/timer/init.lua deleted file mode 100644 index e72a285..0000000 --- a/src/dashx/tasks/timer/init.lua +++ /dev/null @@ -1,9 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local init = {interval = 0.025, script = "timer.lua", linkrequired = true, spreadschedule = false, simulatoronly = false} -return init diff --git a/src/dashx/tasks/timer/timer.lua b/src/dashx/tasks/timer/timer.lua deleted file mode 100644 index 26cc713..0000000 --- a/src/dashx/tasks/timer/timer.lua +++ /dev/null @@ -1,98 +0,0 @@ ---[[ - Copyright (C) 2025 Rob Thomson - GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html -]] -- - -local dashx = require("dashx") - -local arg = {...} -local config = arg[1] - -local timer = {} -local lastFlightMode = nil - -function timer.reset() - dashx.utils.log("Resetting flight timers", "info") - lastFlightMode = nil - - local timerSession = {} - dashx.session.timer = timerSession - dashx.session.flightCounted = false - - timerSession.baseLifetime = tonumber(dashx.ini.getvalue(dashx.session.modelPreferences, "general", "totalflighttime")) or 0 - - timerSession.session = 0 - timerSession.lifetime = timerSession.baseLifetime -end - -function timer.save() - local prefs = dashx.session.modelPreferences - local prefsFile = dashx.session.modelPreferencesFile - - if not prefsFile then - dashx.utils.log("No model preferences file set, cannot save flight timers", "info") - return - end - - dashx.utils.log("Saving flight timers to INI: " .. prefsFile, "info") - - if prefs then - dashx.ini.setvalue(prefs, "general", "totalflighttime", dashx.session.timer.baseLifetime or 0) - dashx.ini.setvalue(prefs, "general", "lastflighttime", dashx.session.timer.session or 0) - dashx.ini.save_ini_file(prefsFile, prefs) - end -end - -local function finalizeFlightSegment(now) - local timerSession = dashx.session.timer - local prefs = dashx.session.modelPreferences - - local segment = now - timerSession.start - timerSession.session = (timerSession.session or 0) + segment - timerSession.start = nil - - if timerSession.baseLifetime == nil then timerSession.baseLifetime = tonumber(dashx.ini.getvalue(prefs, "general", "totalflighttime")) or 0 end - - timerSession.baseLifetime = timerSession.baseLifetime + segment - timerSession.lifetime = timerSession.baseLifetime - - timer.save() -end - -function timer.wakeup() - local now = os.time() - local timerSession = dashx.session.timer - local prefs = dashx.session.modelPreferences - local flightMode = dashx.flightmode.current - - lastFlightMode = flightMode - - if flightMode == "inflight" then - if not timerSession.start then timerSession.start = now end - - local currentSegment = now - timerSession.start - timerSession.live = (timerSession.session or 0) + currentSegment - - local computedLifetime = (timerSession.baseLifetime or 0) + currentSegment - timerSession.lifetime = computedLifetime - - if prefs then dashx.ini.setvalue(prefs, "general", "totalflighttime", computedLifetime) end - - if timerSession.live >= 25 and not dashx.session.flightCounted then - dashx.session.flightCounted = true - - if prefs and dashx.ini.section_exists(prefs, "general") then - local count = dashx.ini.getvalue(prefs, "general", "flightcount") or 0 - dashx.ini.setvalue(prefs, "general", "flightcount", count + 1) - dashx.ini.save_ini_file(dashx.session.modelPreferencesFile, prefs) - end - end - - else - timerSession.live = timerSession.session or 0 - end - - if flightMode == "postflight" and timerSession.start then finalizeFlightSegment(now) end -end - -return timer diff --git a/src/dashx/tools/logs.lua b/src/dashx/tools/logs.lua new file mode 100644 index 0000000..f73c142 --- /dev/null +++ b/src/dashx/tools/logs.lua @@ -0,0 +1,1268 @@ +--[[ + Copyright (C) 2026 Rob Thomson + GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html +]] -- + +local dashx = require("dashx") + +local math_abs = math.abs +local math_floor = math.floor +local math_max = math.max +local math_min = math.min +local tonumber = tonumber +local tostring = tostring +local ipairs = ipairs +local pairs = pairs +local pcall = pcall +local os_date = os.date +local os_time = os.time +local string_format = string.format + +local PEN_SOLID = rawget(_G, "SOLID") +local PEN_DOTTED = rawget(_G, "DOTTED") +local COLOR_BLACK_SAFE = rawget(_G, "COLOR_BLACK") or lcd.RGB(0, 0, 0) +local COLOR_WHITE_SAFE = rawget(_G, "COLOR_WHITE") or lcd.RGB(255, 255, 255) +local COLOR_GREY_SAFE = rawget(_G, "COLOR_GREY") or lcd.RGB(160, 160, 160) +local HEADER_NAV_HEIGHT_REDUCTION = 4 +local HEADER_NAV_Y_SHIFT = 6 +local NOOP_PAINT = function() end + +local SUPPORTED_RADIOS = { + ["784x406"] = { + buttonWidth = 120, + buttonHeight = 120, + buttonPadding = 10, + buttonWidthSmall = 105, + buttonHeightSmall = 110, + buttonPaddingSmall = 6, + buttonsPerRow = 6, + buttonsPerRowSmall = 7, + linePaddingTop = 8, + menuButtonWidth = 100, + navbuttonHeight = 40, + logGraphHeightOffset = -15, + logGraphMenuOffset = 70, + logGraphWidthPercentage = 0.79, + logKeyFont = FONT_S, + logKeyFontSmall = FONT_XS, + logShowAvg = true, + logSliderPaddingLeft = 42 + }, + ["472x288"] = { + buttonWidth = 110, + buttonHeight = 110, + buttonPadding = 8, + buttonWidthSmall = 89, + buttonHeightSmall = 95, + buttonPaddingSmall = 5, + buttonsPerRow = 4, + buttonsPerRowSmall = 5, + linePaddingTop = 6, + menuButtonWidth = 60, + navbuttonHeight = 30, + logGraphHeightOffset = 10, + logGraphMenuOffset = 55, + logGraphWidthPercentage = 0.72, + logKeyFont = FONT_XS, + logKeyFontSmall = FONT_XXS, + logShowAvg = false, + logSliderPaddingLeft = 30 + }, + ["632x314"] = { + buttonWidth = 118, + buttonHeight = 120, + buttonPadding = 7, + buttonWidthSmall = 97, + buttonHeightSmall = 115, + buttonPaddingSmall = 8, + buttonsPerRow = 5, + buttonsPerRowSmall = 6, + linePaddingTop = 6, + menuButtonWidth = 80, + navbuttonHeight = 35, + logGraphHeightOffset = 0, + logGraphMenuOffset = 60, + logGraphWidthPercentage = 0.76, + logKeyFont = FONT_XXS, + logKeyFontSmall = FONT_XXS, + logShowAvg = false, + logSliderPaddingLeft = 30 + } +} + +local ZOOM_LEVEL_TO_TIME = {[1] = 600, [2] = 300, [3] = 120, [4] = 60, [5] = 30} +local ZOOM_LEVEL_TO_DECIMATION = {[1] = 5, [2] = 4, [3] = 2, [4] = 1, [5] = 1} +local LOAD_READ_CHUNK = 120 +local LOG_PADDING = 5 +local SAMPLE_RATE = 1 + +local function newState() + return { + entries = {}, + lastListSelection = nil, + selectedFile = nil, + rawHeader = {}, + logData = {}, + logLineCount = 0, + loadJob = nil, + loadError = nil, + processedLogData = false, + sliderPosition = 1, + sliderPositionOld = 1, + zoomLevel = 1, + zoomCount = 1 + } +end + +local function newPaintCache() + return { + points = {}, + stepSize = 0, + position = 1, + graphCount = 0, + laneHeight = 0, + currentLane = 0, + decimationFactor = 1, + needsUpdate = true + } +end + +local tool = { + page = "logs", + exitRequested = false, + formFields = {}, + icons = {}, + paintCache = newPaintCache(), + state = newState() +} + +local function buildColorTable() + if lcd.darkMode() then + return { + voltage = lcd.RGB(220, 92, 92), + current = lcd.RGB(255, 168, 58), + rpm = lcd.RGB(102, 214, 129), + temp_esc = lcd.RGB(90, 180, 255), + throttle_percent = lcd.RGB(248, 215, 90) + } + end + + return { + voltage = lcd.RGB(200, 0, 0), + current = lcd.RGB(220, 100, 0), + rpm = lcd.RGB(0, 140, 0), + temp_esc = lcd.RGB(0, 80, 200), + throttle_percent = lcd.RGB(180, 160, 0) + } +end + +local function buildLogColumns() + local colors = buildColorTable() + + return { + {name = "voltage", keyindex = 1, keyname = "Voltage", keyunit = "V", keyminmax = 1, color = colors.voltage, pen = PEN_SOLID, graph = true}, + {name = "current", keyindex = 2, keyname = "Current", keyunit = "A", keyminmax = 1, color = colors.current, pen = PEN_SOLID, graph = true}, + {name = "rpm", keyindex = 3, keyname = "Headspeed", keyunit = "rpm", keyminmax = 1, keyfloor = true, color = colors.rpm, pen = PEN_SOLID, graph = true}, + {name = "temp_esc", keyindex = 4, keyname = "ESC Temp", keyunit = "C", keyminmax = 1, color = colors.temp_esc, pen = PEN_SOLID, graph = true}, + {name = "throttle_percent", keyindex = 5, keyname = "Throttle", keyunit = "%", keyminmax = 1, color = colors.throttle_percent, pen = PEN_SOLID, graph = true} + } +end + +local function invalidate(widget) + if not lcd.invalidate then + return + end + + if widget ~= nil then + lcd.invalidate(widget) + else + lcd.invalidate() + end +end + +local function clearForm() + tool.formFields = {} + if form and form.clear then + form.clear() + end +end + +local function requestExit() + tool.exitRequested = true +end + +local function flushExit() + if not tool.exitRequested then + return false + end + + tool.exitRequested = false + if system.exit then + system.exit() + end + return true +end + +local function matchesKey(value, keyName) + local keyValue = _G[keyName] + return keyValue ~= nil and value == keyValue +end + +local function isExitKey(value) + return matchesKey(value, "KEY_RTN_BREAK") + or matchesKey(value, "KEY_RTN_LONG") + or matchesKey(value, "KEY_SYS_BREAK") + or matchesKey(value, "KEY_SYS_LONG") + or matchesKey(value, "KEY_SYSTEM_BREAK") + or matchesKey(value, "KEY_SYSTEM_LONG") + or matchesKey(value, "KEY_MODEL_BREAK") + or matchesKey(value, "KEY_MODEL_LONG") + or matchesKey(value, "KEY_DOWN_BREAK") +end + +local function parseResolution(key) + local width, height = tostring(key or ""):match("^(%d+)x(%d+)$") + return tonumber(width), tonumber(height) +end + +local function getClosestSupportedResolution(targetW, targetH) + local bestKey + local bestDistance + + for key in pairs(SUPPORTED_RADIOS) do + local width, height = parseResolution(key) + local distance = math_abs((width or 0) - targetW) + math_abs((height or 0) - targetH) + if bestDistance == nil or distance < bestDistance then + bestKey = key + bestDistance = distance + end + end + + return bestKey +end + +local function getRadio() + local width, height = lcd.getWindowSize() + local resolution = width .. "x" .. height + local key = SUPPORTED_RADIOS[resolution] and resolution or getClosestSupportedResolution(width, height) + return SUPPORTED_RADIOS[key] or SUPPORTED_RADIOS["472x288"], width, height +end + +local function getIconSize() + local prefs = dashx.preferences and dashx.preferences.general or nil + return tonumber(prefs and prefs.iconsize) or 2 +end + +local function getButtonLayout() + local radio, width = getRadio() + local icons = getIconSize() + + if icons == 0 then + return { + padding = radio.buttonPaddingSmall, + buttonW = math_floor((width - radio.buttonPaddingSmall) / radio.buttonsPerRow - radio.buttonPaddingSmall), + buttonH = radio.navbuttonHeight, + perRow = radio.buttonsPerRow + } + end + + if icons == 1 then + return { + padding = radio.buttonPaddingSmall, + buttonW = radio.buttonWidthSmall, + buttonH = radio.buttonHeightSmall, + perRow = radio.buttonsPerRowSmall + } + end + + return { + padding = radio.buttonPadding, + buttonW = radio.buttonWidth, + buttonH = radio.buttonHeight, + perRow = radio.buttonsPerRow + } +end + +local function getGraphPos() + local radio = getRadio() + local width, height + + if system and system.getVersion then + local version = system.getVersion() + width = tonumber(version and version.lcdWidth) or nil + height = tonumber(version and version.lcdHeight) or nil + end + + if not width or not height then + local fallbackRadio, fallbackWidth, fallbackHeight = getRadio() + radio = fallbackRadio + width = fallbackWidth + height = fallbackHeight + end + + return { + menu_offset = radio.logGraphMenuOffset, + height_offset = radio.logGraphHeightOffset or 0, + x_start = 0, + y_start = radio.logGraphMenuOffset, + width = math_floor(width * radio.logGraphWidthPercentage), + key_width = width - math_floor(width * radio.logGraphWidthPercentage), + height = height - radio.logGraphMenuOffset - radio.logGraphMenuOffset - 40 + (radio.logGraphHeightOffset or 0), + slider_y = height - (radio.logGraphMenuOffset + 30) + (radio.logGraphHeightOffset or 0), + lcdWidth = width, + lcdHeight = height + } +end + +local function getHeaderNavButtonHeight() + local radio = getRadio() + local base = (radio and radio.navbuttonHeight) or 0 + if base <= 0 then + return base + end + return math_max(20, base - HEADER_NAV_HEIGHT_REDUCTION) +end + +local function getHeaderNavButtonY(baseY) + local y = tonumber(baseY) or 0 + return math_max(0, y - HEADER_NAV_Y_SHIFT) +end + +local function getHeaderTitleY(baseY) + return getHeaderNavButtonY(baseY) +end + +local function getHeaderMetrics() + local radio, width = getRadio() + local padding = 5 + local buttonW = radio.menuButtonWidth or 100 + local buttonH = getHeaderNavButtonHeight() + local navX = width - 5 + local reserved = buttonW + padding + local titleRightEdge = navX - reserved + local titleWidth = math_max(40, titleRightEdge - 8) + + return { + windowWidth = width, + buttonW = buttonW, + buttonH = buttonH, + titleWidth = titleWidth, + padding = padding + } +end + +local function loadIconAsset(path) + if not lcd or not path then + return nil + end + + local candidates = { + path, + "SCRIPTS:/" .. dashx.config.baseDir .. "/" .. path + } + + for _, candidate in ipairs(candidates) do + if lcd.loadMask then + local ok, loaded = pcall(lcd.loadMask, candidate) + if ok and loaded then + return loaded + end + end + + if lcd.loadBitmap then + local ok, loaded = pcall(lcd.loadBitmap, candidate) + if ok and loaded then + return loaded + end + end + end + + return nil +end + +local function ensureIcons() + if tool.icons.folder == nil then + tool.icons.folder = loadIconAsset("widgets/dashboard/gfx/folder.png") + or loadIconAsset("app/modules/logs/gfx/folder.png") + or false + end + + if tool.icons.logs == nil then + tool.icons.logs = loadIconAsset("widgets/dashboard/gfx/logs.png") + or loadIconAsset("app/modules/logs/gfx/logs.png") + or false + end +end + +local function addHeaderRow(title, menuHandler, menuIcon) + local radio = getRadio() + local metrics = getHeaderMetrics() + local line = form.addLine("") + + tool.formFields.headerLine = line + tool.formFields.headerTitle = form.addStaticText(line, { + x = 0, + y = getHeaderTitleY(radio.linePaddingTop or 0), + w = metrics.titleWidth, + h = radio.navbuttonHeight + }, title) + + tool.formFields.menu = form.addButton(nil, { + x = metrics.windowWidth - metrics.buttonW - 10, + y = getHeaderNavButtonY(radio.linePaddingTop or 0), + w = metrics.buttonW, + h = metrics.buttonH + }, { + text = "Menu", + icon = menuIcon, + options = FONT_S, + paint = NOOP_PAINT, + press = menuHandler + }) +end + +local function closeOpenJobHandle() + local job = tool.state.loadJob + if job and job.handle then + pcall(function() + job.handle:close() + end) + job.handle = nil + end +end + +local function resetViewState() + closeOpenJobHandle() + tool.state.selectedFile = nil + tool.state.rawHeader = {} + tool.state.logData = {} + tool.state.logLineCount = 0 + tool.state.loadJob = nil + tool.state.loadError = nil + tool.state.processedLogData = false + tool.state.sliderPosition = 1 + tool.state.sliderPositionOld = 1 + tool.state.zoomLevel = 1 + tool.state.zoomCount = 1 + tool.paintCache = newPaintCache() +end + +local function refreshEntries() + tool.state.entries = dashx.logs.getRecentEntries() or {} +end + +local function extractHourMinute(filename) + local hour, minute = tostring(filename or ""):match(".-%d%d%d%d%-%d%d%-%d%d_(%d%d)%-(%d%d)%-%d%d") + if hour and minute then + return hour .. ":" .. minute + end + + return tostring(filename or "Unknown") +end + +local function extractShortTimestamp(filename) + local date, time = tostring(filename or ""):match(".-(%d%d%d%d%-%d%d%-%d%d)_(%d%d%-%d%d%-%d%d)") + if date and time then + return date:gsub("%-", "/") .. " " .. time:gsub("%-", ":") + end + + return tostring(filename or "Unknown") +end + +local function formatDate(isoDate) + local year, month, day = tostring(isoDate or ""):match("^(%d+)%-(%d+)%-(%d+)$") + if year and month and day then + return os_date("%d %B %Y", os_time({year = tonumber(year), month = tonumber(month), day = tonumber(day)})) + end + + return isoDate or "Unknown Date" +end + +local function groupEntries(entries) + local grouped = {} + local dates = {} + + for _, entry in ipairs(entries or {}) do + local filename = entry and entry.name or entry + local datePart = tostring(filename or ""):match("(%d%d%d%d%-%d%d%-%d%d)_") + if datePart then + if not grouped[datePart] then + grouped[datePart] = {} + dates[#dates + 1] = datePart + end + grouped[datePart][#grouped[datePart] + 1] = entry + end + end + + table.sort(dates, function(a, b) + return a > b + end) + + local result = {} + for _, datePart in ipairs(dates) do + result[#result + 1] = { + date = datePart, + label = formatDate(datePart), + entries = grouped[datePart] + } + end + + return result +end + +local function splitCsvLine(line) + local fields = {} + line = tostring(line or "") + for part in (line .. ","):gmatch("([^,]*),") do + fields[#fields + 1] = part:match("^%s*(.-)%s*$") + end + return fields +end + +local function padTable(values, padCount) + if #values == 0 then + return values + end + + local padded = {} + for index = 1, padCount do + padded[#padded + 1] = values[1] + end + for _, value in ipairs(values) do + padded[#padded + 1] = value + end + for index = 1, padCount do + padded[#padded + 1] = values[#values] + end + + return padded +end + +local function calculateStats(values) + local minimum = math.huge + local maximum = -math.huge + local sum = 0 + local count = 0 + + for _, value in ipairs(values) do + if type(value) == "number" then + minimum = math_min(minimum, value) + maximum = math_max(maximum, value) + sum = sum + value + count = count + 1 + end + end + + if count == 0 then + return 0, 0, 0 + end + + return minimum, maximum, sum / count +end + +local function calculateZoomSteps(logLineCount) + local logDurationSec = math_max(0, tonumber(logLineCount) or 0) / SAMPLE_RATE + + for level = 5, 1, -1 do + local desiredTime = ZOOM_LEVEL_TO_TIME[level] + if logDurationSec >= desiredTime * 1.5 then + return level + end + end + + return 1 +end + +local function queueLogLoad(filename) + tool.state.loadJob = { + filename = filename, + phase = "open" + } + tool.state.loadError = nil + tool.state.processedLogData = false +end + +local function setZoomButtonsEnabled() + local minus = tool.formFields.zoomOut + local plus = tool.formFields.zoomIn + + if not minus or not plus or not minus.enable or not plus.enable then + return + end + + if tool.state.zoomCount <= 1 then + minus:enable(false) + plus:enable(false) + return + end + + minus:enable(tool.state.zoomLevel > 1) + plus:enable(tool.state.zoomLevel < tool.state.zoomCount) +end + +local function processLoadJob() + local job = tool.state.loadJob + if not job then + return false + end + + if job.phase == "open" then + local path = dashx.logs.getDirectory() .. "/" .. tostring(job.filename) + job.handle = io.open(path, "r") + if not job.handle then + tool.state.loadError = "Failed to open log file" + tool.state.loadJob = nil + return true + end + + local headerLine = job.handle:read("*l") + local header = splitCsvLine(headerLine) + if #header == 0 then + pcall(function() + job.handle:close() + end) + job.handle = nil + tool.state.loadError = "Invalid log header" + tool.state.loadJob = nil + return true + end + + local columnIndex = {} + for index, name in ipairs(header) do + columnIndex[name] = index + end + + local parsed = {} + for _, column in ipairs(buildLogColumns()) do + local csvIndex = columnIndex[column.name] + if csvIndex then + parsed[#parsed + 1] = { + name = column.name, + keyindex = column.keyindex, + keyname = column.keyname, + keyunit = column.keyunit, + keyminmax = column.keyminmax, + keyfloor = column.keyfloor, + color = column.color, + pen = column.pen, + graph = column.graph, + csvIndex = csvIndex, + data = {} + } + end + end + + if #parsed == 0 then + pcall(function() + job.handle:close() + end) + job.handle = nil + tool.state.loadError = "Invalid log header" + tool.state.loadJob = nil + return true + end + + job.header = header + job.parsed = parsed + job.phase = "read" + job.readCount = 0 + return true + end + + if job.phase == "read" then + local processed = 0 + + while processed < LOAD_READ_CHUNK do + local line = job.handle:read("*l") + if line == nil then + pcall(function() + job.handle:close() + end) + job.handle = nil + job.phase = "finalize" + job.finalizeIndex = 1 + return true + end + + local parts = splitCsvLine(line) + for _, column in ipairs(job.parsed) do + column.data[#column.data + 1] = tonumber(parts[column.csvIndex]) or 0 + end + + job.readCount = (job.readCount or 0) + 1 + processed = processed + 1 + end + + return true + end + + if job.phase == "finalize" then + local column = job.parsed[job.finalizeIndex] + if column then + column.data = padTable(column.data, LOG_PADDING) + column.minimum, column.maximum, column.average = calculateStats(column.data) + job.finalizeIndex = job.finalizeIndex + 1 + return true + end + + tool.state.rawHeader = job.header or {} + tool.state.logData = job.parsed or {} + tool.state.logLineCount = #((job.parsed and job.parsed[1] and job.parsed[1].data) or {}) + tool.state.loadJob = nil + tool.state.processedLogData = true + tool.state.sliderPosition = 1 + tool.state.sliderPositionOld = 1 + tool.state.zoomCount = calculateZoomSteps(tool.state.logLineCount) + tool.state.zoomLevel = math_min(tool.state.zoomLevel or 1, tool.state.zoomCount) + tool.paintCache = newPaintCache() + setZoomButtonsEnabled() + return true + end + + return false +end + +local openViewPage + +local function openLogsPage() + clearForm() + tool.page = "logs" + resetViewState() + refreshEntries() + ensureIcons() + + local layout = getButtonLayout() + local selectedButton = nil + + addHeaderRow("Logs", requestExit, nil) + + if #tool.state.entries == 0 then + local _, width, height = getRadio() + local msg = "No logs found" + local tw, th = lcd.getTextSize(msg) + local x = math_floor(width / 2 - tw / 2) + local y = math_floor(height / 2 - th / 2) + form.addStaticText(nil, {x = x, y = y, w = tw, h = layout.buttonH}, msg) + invalidate() + return + end + + local buttonIndex = 0 + for _, section in ipairs(groupEntries(tool.state.entries)) do + form.addLine(section.label) + + local column = 0 + local y = 0 + + for _, entry in ipairs(section.entries) do + buttonIndex = buttonIndex + 1 + if column == 0 then + y = form.height() + layout.padding + end + + local x = (layout.buttonW + layout.padding) * column + local button = form.addButton(nil, {x = x, y = y, w = layout.buttonW, h = layout.buttonH}, { + text = extractHourMinute(entry.name), + icon = tool.icons.logs or nil, + options = FONT_S, + press = function() + tool.state.lastListSelection = entry.name + openViewPage(entry.name) + end + }) + + if tool.state.lastListSelection == entry.name and button and button.focus then + selectedButton = button + end + + column = (column + 1) % layout.perRow + end + end + + if selectedButton then + selectedButton:focus() + end + + invalidate() +end + +openViewPage = function(filename) + resetViewState() + clearForm() + ensureIcons() + + tool.page = "view" + tool.state.selectedFile = filename + tool.state.lastListSelection = filename + queueLogLoad(filename) + + addHeaderRow("Logs / " .. extractShortTimestamp(filename), openLogsPage, tool.icons.folder or nil) + + local graphPos = getGraphPos() + local zoomButtonWidth = math_max(48, math_floor(graphPos.key_width / 2) - 20) + + tool.formFields.slider = form.addSliderField(nil, { + x = graphPos.x_start, + y = graphPos.slider_y, + w = graphPos.width - 10, + h = 40 + }, 1, 100, function() + return tool.state.sliderPosition + end, function(newValue) + tool.state.sliderPosition = math_max(1, math_min(100, math_floor(tonumber(newValue) or 1))) + tool.paintCache.needsUpdate = true + invalidate() + end) + + if tool.formFields.slider and tool.formFields.slider.step then + tool.formFields.slider:step(1) + end + + tool.formFields.zoomOut = form.addButton(nil, { + x = graphPos.width, + y = graphPos.slider_y, + w = zoomButtonWidth, + h = 40 + }, { + text = "-", + options = FONT_STD, + press = function() + if tool.state.zoomLevel > 1 then + tool.state.zoomLevel = tool.state.zoomLevel - 1 + tool.paintCache.needsUpdate = true + setZoomButtonsEnabled() + invalidate() + end + end + }) + + tool.formFields.zoomIn = form.addButton(nil, { + x = graphPos.width + zoomButtonWidth + 10, + y = graphPos.slider_y, + w = zoomButtonWidth, + h = 40 + }, { + text = "+", + options = FONT_STD, + press = function() + if tool.state.zoomLevel < tool.state.zoomCount then + tool.state.zoomLevel = tool.state.zoomLevel + 1 + tool.paintCache.needsUpdate = true + setZoomButtonsEnabled() + invalidate() + end + end + }) + + setZoomButtonsEnabled() + invalidate() +end + +local function secondsToSamples(seconds) + return math_floor(seconds * SAMPLE_RATE) +end + +local function map(value, inMin, inMax, outMin, outMax) + if inMax == inMin then + return outMin + end + + return (value - inMin) * (outMax - outMin) / (inMax - inMin) + outMin +end + +local function paginateTable(data, stepSize, position, decimationFactor) + decimationFactor = decimationFactor or 1 + + local startIndex = math_max(1, position) + local endIndex = math_min(startIndex + stepSize - 1, #data) + local page = {} + + for index = startIndex, endIndex, decimationFactor do + page[#page + 1] = data[index] + end + + return page +end + +local function updatePaintCache() + if not tool.state.processedLogData then + return + end + + local graphPos = getGraphPos() + local logDurationSec = math_floor(tool.state.logLineCount / SAMPLE_RATE) + local desiredWinSec = ZOOM_LEVEL_TO_TIME[tool.state.zoomLevel] or ZOOM_LEVEL_TO_TIME[1] + local winSec = math_min(desiredWinSec, logDurationSec) + + tool.paintCache.stepSize = math_max(1, secondsToSamples(winSec)) + + local maxPosition = math_max(1, tool.state.logLineCount - tool.paintCache.stepSize + 1) + tool.paintCache.position = math_floor(map(tool.state.sliderPosition, 1, 100, 1, maxPosition)) + if tool.paintCache.position < 1 then + tool.paintCache.position = 1 + end + + tool.paintCache.graphCount = 0 + for _, column in ipairs(tool.state.logData) do + if column.graph then + tool.paintCache.graphCount = tool.paintCache.graphCount + 1 + end + end + + tool.paintCache.laneHeight = graphPos.height / math_max(1, tool.paintCache.graphCount) + tool.paintCache.currentLane = 0 + tool.paintCache.decimationFactor = ZOOM_LEVEL_TO_DECIMATION[tool.state.zoomLevel] or 1 + tool.paintCache.points = {} + + if tool.state.zoomCount == 1 then + tool.paintCache.decimationFactor = 1 + end + + for _, column in ipairs(tool.state.logData) do + if column.graph then + tool.paintCache.currentLane = tool.paintCache.currentLane + 1 + tool.paintCache.points[tool.paintCache.currentLane] = { + points = paginateTable(column.data, tool.paintCache.stepSize, tool.paintCache.position, tool.paintCache.decimationFactor), + color = column.color, + pen = column.pen, + minimum = column.minimum, + maximum = column.maximum, + keyname = column.keyname, + keyunit = column.keyunit, + keyminmax = column.keyminmax, + keyfloor = column.keyfloor, + name = column.name, + keyindex = column.keyindex + } + end + end + + tool.paintCache.needsUpdate = false +end + +local function formatTime(seconds) + local minutes = math_floor(seconds / 60) + local secondsRemainder = seconds % 60 + return string_format("%02d:%02d", minutes, secondsRemainder) +end + +local function calculateSeconds(totalSeconds, sliderValue) + local clamped = math_max(1, math_min(100, sliderValue)) + return math_floor(((clamped - 1) / 100) * totalSeconds) +end + +local function getValueAtPercentage(array, percentage) + local clamped = math_max(1, math_min(100, percentage)) + local count = #array + if count == 0 then + return 0 + end + + local index = math_max(1, math_min(count, math_floor((clamped / 100) * count + 0.5))) + return array[index] or 0 +end + +local function formatDisplayNumber(value, floorValue) + local number = tonumber(value) or 0 + if floorValue then + return tostring(math_floor(number)) + end + + if math_abs(number) >= 100 then + return string_format("%.0f", number) + end + + if math_abs(number) >= 10 then + return string_format("%.1f", number) + end + + return string_format("%.2f", number) +end + +local function drawGraph(points, color, pen, xStart, yStart, width, height, minimum, maximum) + if #points < 2 then + return + end + + local padding = math_max(5, math_floor(height * 0.1)) + yStart = yStart + (padding / 2) + height = height - padding + + if maximum == minimum then + maximum = maximum + 1 + minimum = minimum - 1 + end + + lcd.color(color or COLOR_GREY_SAFE) + if pen ~= nil and lcd.pen then + lcd.pen(pen) + elseif lcd.pen and PEN_DOTTED ~= nil then + lcd.pen(PEN_DOTTED) + end + + local xScale = width / math_max(1, (#points - 1)) + local yScale = height / (maximum - minimum) + + for index = 1, #points - 1 do + local x1 = xStart + (index - 1) * xScale + local y1 = yStart + height - (points[index] - minimum) * yScale + local x2 = xStart + index * xScale + local y2 = yStart + height - (points[index + 1] - minimum) * yScale + lcd.drawLine(x1, y1, x2, y2) + end +end + +local function drawKey(name, keyunit, keyminmax, keyfloor, color, minimum, maximum, laneY) + local radio = getRadio() + local graphPos = getGraphPos() + local boxPadding = 3 + local width = graphPos.lcdWidth - graphPos.width - 10 + + lcd.font(radio.logKeyFont) + local _, textH = lcd.getTextSize(name) + local boxHeight = textH + boxPadding + + local x = graphPos.width + local y = laneY + + local minText = formatDisplayNumber(minimum, keyfloor) + local maxText = formatDisplayNumber(maximum, keyfloor) + + lcd.color(color) + lcd.drawFilledRectangle(x, y, width, boxHeight) + + lcd.color(COLOR_BLACK_SAFE) + local textY = y + (boxHeight / 2 - textH / 2) + lcd.drawText(x + 5, textY, name, LEFT) + + lcd.font(radio.logKeyFontSmall) + lcd.color(lcd.darkMode() and COLOR_WHITE_SAFE or COLOR_BLACK_SAFE) + + if keyunit == "rpm" and ((minimum >= 10000) or (maximum >= 10000)) then + minText = string_format("%.1fK", minimum / 10000) + maxText = string_format("%.1fK", maximum / 10000) + end + + local minimumLabel = keyminmax == 1 and ("↓ " .. minText .. keyunit) or "" + local maximumLabel = "↑ " .. maxText .. keyunit + local minmaxY = y + boxHeight + 2 + + lcd.drawText(x + 5, minmaxY, minimumLabel, LEFT) + + local maxW = lcd.getTextSize(maximumLabel) + lcd.drawText((graphPos.lcdWidth - maxW) + boxPadding, minmaxY, maximumLabel, LEFT) + + if radio.logShowAvg then + local averageLabel = "Ø " .. formatDisplayNumber((minimum + maximum) / 2, keyfloor) .. keyunit + lcd.drawText(x + 5, minmaxY + textH - 2, averageLabel, LEFT) + end +end + +local function drawCurrentIndex(points, position, totalPoints, keyunit, keyfloor, color, laneY, laneNumber) + local radio = getRadio() + local graphPos = getGraphPos() + local sliderPadding = radio.logSliderPaddingLeft + local width = graphPos.width - sliderPadding + + local linePos = map(position, 1, 100, 1, width - 10) + sliderPadding + if linePos < 1 then + linePos = 0 + end + + local value = getValueAtPercentage(points, position) + local valueLabel = formatDisplayNumber(value, keyfloor) .. keyunit + local boxPadding = 3 + + lcd.font(radio.logKeyFont) + local textW, textH = lcd.getTextSize(valueLabel) + local boxHeight = textH + boxPadding + local boxY = laneY + local textY = boxY + (boxHeight / 2 - textH / 2) + + local textAlign + local textX + local boxX + if position > 50 then + textAlign = RIGHT + textX = linePos - (boxPadding * 2) + boxX = linePos - boxPadding - textW - (boxPadding * 2) + else + textAlign = LEFT + textX = linePos + (boxPadding * 2) + boxX = linePos + boxPadding + end + + lcd.color(color) + lcd.drawFilledRectangle(boxX, boxY, textW + (boxPadding * 2), boxHeight) + + lcd.color(lcd.darkMode() and COLOR_BLACK_SAFE or COLOR_WHITE_SAFE) + lcd.drawText(textX, textY, valueLabel, textAlign) + + if laneNumber == 1 then + local currentSeconds = calculateSeconds(totalPoints, position) + local timeLabel = formatTime(math_floor(currentSeconds)) + + local logDurationSec = math_floor(tool.state.logLineCount / SAMPLE_RATE) + local desiredWinSec = ZOOM_LEVEL_TO_TIME[tool.state.zoomLevel] or ZOOM_LEVEL_TO_TIME[1] + local windowSec = math_min(desiredWinSec, logDurationSec) + local windowLabel + if windowSec < 60 then + windowLabel = string_format("%ds", windowSec) + else + windowLabel = string_format("%d:%02d", math_floor(windowSec / 60), windowSec % 60) + end + + local fullLabel = string_format("%s [+%s]", timeLabel, windowLabel) + local timeY = graphPos.height + graphPos.menu_offset - 10 + + lcd.font(radio.logKeyFont) + lcd.color(COLOR_WHITE_SAFE) + lcd.drawText(textX, timeY, fullLabel, textAlign) + + lcd.color(lcd.darkMode() and COLOR_WHITE_SAFE or COLOR_BLACK_SAFE) + lcd.drawLine(linePos, graphPos.menu_offset - 5, linePos, graphPos.menu_offset + graphPos.height) + + lcd.color(lcd.darkMode() and lcd.RGB(40, 40, 40) or lcd.RGB(240, 240, 240)) + local zoomX = graphPos.lcdWidth - 25 + local zoomY = graphPos.slider_y + local zoomW = 20 + local zoomH = 40 + local zoomLineH = zoomH / math_max(1, tool.state.zoomCount) + local lineOffsetY = (tool.state.zoomCount - tool.state.zoomLevel) * zoomLineH + + lcd.drawFilledRectangle(zoomX, zoomY, zoomW, zoomH) + lcd.color(tool.state.zoomCount > 1 and (lcd.darkMode() and COLOR_WHITE_SAFE or COLOR_BLACK_SAFE) or COLOR_GREY_SAFE) + lcd.drawFilledRectangle(zoomX, zoomY + lineOffsetY, zoomW, zoomLineH) + end +end + +local function paintLoadingMessage(message, detail) + local _, width, height = getRadio() + + lcd.color(lcd.darkMode() and lcd.RGB(22, 28, 34) or lcd.RGB(255, 255, 255)) + lcd.drawFilledRectangle(20, math_floor(height * 0.35), width - 40, 70) + lcd.color(lcd.darkMode() and lcd.RGB(86, 96, 106) or lcd.RGB(196, 202, 208)) + lcd.drawRectangle(20, math_floor(height * 0.35), width - 40, 70, 1) + + lcd.font(FONT_STD) + lcd.color(lcd.darkMode() and COLOR_WHITE_SAFE or COLOR_BLACK_SAFE) + lcd.drawText(math_floor(width / 2), math_floor(height * 0.35) + 14, message, CENTERED) + + if detail then + lcd.font(FONT_XXS) + lcd.color(COLOR_GREY_SAFE) + lcd.drawText(math_floor(width / 2), math_floor(height * 0.35) + 38, tostring(detail), CENTERED) + end +end + +local function paintView() + if tool.state.loadJob then + paintLoadingMessage("Loading log", extractShortTimestamp(tool.state.selectedFile)) + return + end + + if tool.state.loadError then + paintLoadingMessage("Log load failed", tool.state.loadError) + return + end + + if not tool.state.processedLogData then + paintLoadingMessage("Loading log", extractShortTimestamp(tool.state.selectedFile)) + return + end + + if tool.paintCache.needsUpdate or tool.state.sliderPosition ~= tool.state.sliderPositionOld then + updatePaintCache() + tool.state.sliderPositionOld = tool.state.sliderPosition + end + + local graphPos = getGraphPos() + local width = graphPos.width - 10 + local height = graphPos.height + local xStart = graphPos.x_start + local yStart = graphPos.y_start + + if tool.paintCache.points and #tool.paintCache.points > 0 then + for laneNumber, laneData in ipairs(tool.paintCache.points) do + local laneY = yStart + (laneNumber - 1) * tool.paintCache.laneHeight + drawGraph(laneData.points, laneData.color, laneData.pen, xStart, laneY, width, tool.paintCache.laneHeight, laneData.minimum, laneData.maximum) + drawKey(laneData.keyname, laneData.keyunit, laneData.keyminmax, laneData.keyfloor, laneData.color, laneData.minimum, laneData.maximum, laneY) + drawCurrentIndex(laneData.points, tool.state.sliderPosition, tool.state.logLineCount + LOG_PADDING, laneData.keyunit, laneData.keyfloor, laneData.color, laneY, laneNumber) + end + end +end + +function tool.create() + tool.exitRequested = false + tool.state = newState() + tool.icons = {} + tool.paintCache = newPaintCache() + openLogsPage() + return {} +end + +function tool.paint() + if tool.page == "view" then + paintView() + end +end + +function tool.wakeup(widget) + if flushExit() then + return true + end + + if tool.page == "view" then + if tool.state.loadJob then + if processLoadJob() then + invalidate(widget) + end + elseif tool.state.processedLogData and (tool.paintCache.needsUpdate or tool.state.sliderPosition ~= tool.state.sliderPositionOld) then + updatePaintCache() + tool.state.sliderPositionOld = tool.state.sliderPosition + invalidate(widget) + end + end + + return false +end + +function tool.event(widget, category, value) + if category == EVT_CLOSE or isExitKey(value) then + if tool.page == "view" then + openLogsPage() + else + requestExit() + end + + if value ~= nil and system.killEvents then + system.killEvents(value) + end + + invalidate(widget) + return true + end + + return false +end + +function tool.close() + tool.exitRequested = false + closeOpenJobHandle() + clearForm() + + tool.page = "logs" + tool.state = newState() + tool.icons = {} + tool.paintCache = newPaintCache() + + if dashx.tools then + dashx.tools.logs = nil + end + + dashx.logs = nil + + return true +end + +return tool diff --git a/src/dashx/widgets/dashboard/configure.lua b/src/dashx/widgets/dashboard/configure.lua new file mode 100644 index 0000000..bb3acb6 --- /dev/null +++ b/src/dashx/widgets/dashboard/configure.lua @@ -0,0 +1,236 @@ +--[[ + Copyright (C) 2026 Rob Thomson + GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html +]] -- + +local dashx = require("dashx") + +local configui = {} + +local function clamp(value, minimum, maximum) + if value < minimum then + return minimum + end + if value > maximum then + return maximum + end + return value +end + +local function addLine(parent, label) + if parent and parent.addLine then + return parent:addLine(label) + end + return form.addLine(label) +end + +local function ensureWidgetDefaults(widget) + dashx.runtime.readWidgetSettings(widget) +end + +local function getModelThemeValue(widget) + local value = widget.theme_preflight + if value == nil or value == false or value == "nil" then + return "system/default" + end + return value +end + +local function buildModelThemeChoices(themeList) + local choices = {} + local byValue = {} + + for _, theme in ipairs(themeList or {}) do + local value = theme.source .. "/" .. theme.folder + if value == "system/default" or value == "system/@rt-rc" then + local label = value == "system/default" and "Default" or "RT-RC" + choices[#choices + 1] = {label, theme.idx} + byValue[value] = theme.idx + end + end + + return choices, byValue +end + +local function encodeModelThemeChoice(widget, byValue) + local storedValue = getModelThemeValue(widget) + return byValue[storedValue] or byValue["system/default"] +end + +local function applyModelThemeChoice(widget, themeList, selectedValue) + local value = "system/default" + + for _, theme in ipairs(themeList or {}) do + if theme.idx == selectedValue then + value = theme.source .. "/" .. theme.folder + break + end + end + + widget.theme_preflight = value + widget.theme_inflight = value + widget.theme_postflight = value +end + +local function decodeSwitchSpec(spec) + if type(spec) ~= "string" or spec == "" or spec == "false" then + return nil + end + + local category, member, options = spec:match("([^:]+):([^:]+):([^:]+)") + if not category or not member then + return nil + end + + return system.getSource({ + category = tonumber(category) or category, + member = tonumber(member) or member, + options = tonumber(options) or options + }) +end + +local function encodeSwitchSource(source) + if not source then + return false + end + + return table.concat({source:category(), source:member(), source:options()}, ":") +end + +function configui.read(widget) + ensureWidgetDefaults(widget) + return true +end + +function configui.write(widget) + return dashx.runtime.writeWidgetSettings(widget) +end + +function configui.configure(widget) + ensureWidgetDefaults(widget) + + local themeLine = addLine(nil, "Theme for this model") + local themeList = dashx.widgets.dashboard.listThemes() + local themeChoices, themeValueMap = buildModelThemeChoices(themeList) + form.addChoiceField(themeLine, nil, themeChoices, function() + return encodeModelThemeChoice(widget, themeValueMap) + end, function(value) + applyModelThemeChoice(widget, themeList, value) + end) + + local batteryPanel = form.addExpansionPanel("@i18n(app.modules.model.battery)@") + batteryPanel:open(true) + + local fuelModeLine = addLine(batteryPanel, "@i18n(app.modules.model.calcfuel_using)@") + form.addChoiceField(fuelModeLine, nil, { + {"@i18n(app.modules.model.calcfuel_current)@", 0}, + {"@i18n(app.modules.model.calcfuel_voltage)@", 1} + }, function() + return clamp(math.floor(tonumber(widget.calc_local) or 0), 0, 1) + end, function(value) + widget.calc_local = clamp(math.floor(tonumber(value) or 0), 0, 1) + end) + + local capacityLine = addLine(batteryPanel, "@i18n(app.modules.model.battery_capacity)@") + local capacityField = form.addNumberField(capacityLine, nil, 0, 10000000, function() + return math.floor(tonumber(widget.batteryCapacity) or 2200) + end, function(value) + widget.batteryCapacity = clamp(math.floor(tonumber(value) or 2200), 0, 10000000) + end) + if capacityField and capacityField.suffix then + capacityField:suffix("mAh") + end + + local cellsLine = addLine(batteryPanel, "@i18n(app.modules.model.battery_cells)@") + local cellsField = form.addNumberField(cellsLine, nil, 1, 24, function() + return math.floor(tonumber(widget.batteryCellCount) or 3) + end, function(value) + widget.batteryCellCount = clamp(math.floor(tonumber(value) or 3), 1, 24) + end) + if cellsField and cellsField.suffix then + cellsField:suffix("S") + end + + local warnLine = addLine(batteryPanel, "@i18n(app.modules.model.battery_warning_voltage)@") + local warnField = form.addNumberField(warnLine, nil, 5, 600, function() + return math.floor(tonumber(widget.vbatwarningcellvoltage) or 35) + end, function(value) + widget.vbatwarningcellvoltage = clamp(math.floor(tonumber(value) or 35), 5, 600) + end) + if warnField then + warnField:suffix("v") + warnField:decimals(1) + end + + local minLine = addLine(batteryPanel, "@i18n(app.modules.model.battery_min_voltage)@") + local minField = form.addNumberField(minLine, nil, 5, 600, function() + return math.floor(tonumber(widget.vbatmincellvoltage) or 33) + end, function(value) + widget.vbatmincellvoltage = clamp(math.floor(tonumber(value) or 33), 5, 600) + end) + if minField then + minField:suffix("v") + minField:decimals(1) + end + + local maxLine = addLine(batteryPanel, "@i18n(app.modules.model.battery_max_voltage)@") + local maxField = form.addNumberField(maxLine, nil, 5, 600, function() + return math.floor(tonumber(widget.vbatmaxcellvoltage) or 43) + end, function(value) + widget.vbatmaxcellvoltage = clamp(math.floor(tonumber(value) or 43), 5, 600) + end) + if maxField then + maxField:suffix("v") + maxField:decimals(1) + end + + local fullLine = addLine(batteryPanel, "@i18n(app.modules.model.battery_full_voltage)@") + local fullField = form.addNumberField(fullLine, nil, 5, 600, function() + return math.floor(tonumber(widget.vbatfullcellvoltage) or 41) + end, function(value) + widget.vbatfullcellvoltage = clamp(math.floor(tonumber(value) or 41), 5, 600) + end) + if fullField then + fullField:suffix("v") + fullField:decimals(1) + end + + local reserveLine = addLine(batteryPanel, "@i18n(app.modules.model.battery_consumption_warning_percentage)@") + local reserveField = form.addNumberField(reserveLine, nil, 0, 100, function() + return math.floor(tonumber(widget.consumptionWarningPercentage) or 30) + end, function(value) + widget.consumptionWarningPercentage = clamp(math.floor(tonumber(value) or 30), 0, 100) + end) + if reserveField and reserveField.suffix then + reserveField:suffix("%") + end + + local triggersPanel = form.addExpansionPanel("@i18n(app.modules.model.triggers)@") + triggersPanel:open(false) + + local armLine = addLine(triggersPanel, "@i18n(app.modules.model.model_armswitch)@") + form.addSwitchField(armLine, nil, function() + return decodeSwitchSpec(widget.armswitch) + end, function(newValue) + widget.armswitch = encodeSwitchSource(newValue) + end) + + local inflightLine = addLine(triggersPanel, "@i18n(app.modules.model.model_inflightswitch)@") + form.addSwitchField(inflightLine, nil, function() + return decodeSwitchSpec(widget.inflightswitch) + end, function(newValue) + widget.inflightswitch = encodeSwitchSource(newValue) + end) + + local delayLine = addLine(triggersPanel, "@i18n(app.modules.model.model_inflightswitch_delay)@") + local delayField = form.addNumberField(delayLine, nil, 0, 120, function() + return math.floor(tonumber(widget.inflightswitch_delay) or 10) + end, function(value) + widget.inflightswitch_delay = clamp(math.floor(tonumber(value) or 10), 0, 120) + end) + if delayField and delayField.suffix then + delayField:suffix("s") + end +end + +return configui diff --git a/src/dashx/widgets/dashboard/dashboard.lua b/src/dashx/widgets/dashboard/dashboard.lua index 5f9ef9b..6943a60 100644 --- a/src/dashx/widgets/dashboard/dashboard.lua +++ b/src/dashx/widgets/dashboard/dashboard.lua @@ -1,5 +1,5 @@ --[[ - Copyright (C) 2025 Rob Thomson + Copyright (C) 2026 Rob Thomson GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html ]] -- @@ -7,1333 +7,784 @@ local dashx = require("dashx") local dashboard = {} -local compile = loadfile +local supportedResolutions = { + {784, 294}, {784, 316}, {800, 458}, {800, 480}, + {472, 191}, {472, 210}, {480, 301}, {480, 320}, + {630, 236}, {630, 258}, {640, 338}, {640, 360} +} -local baseDir = dashx.config.baseDir -local preferences = dashx.config.preferences -local utils = dashx.utils -local log = utils.log -local tasks = dashx.tasks -local objectProfiler = false -local mod - -local supportedResolutions = {{784, 294}, {784, 316}, {800, 458}, {800, 480}, {472, 191}, {472, 210}, {480, 301}, {480, 320}, {630, 236}, {630, 258}, {640, 338}, {640, 360}} - -local lastFlightMode = nil - -local initTime = os.clock() - -local lastWakeup = os.clock() - -local isSliding = false -local isSlidingStart = 0 - -dashboard.DEFAULT_THEME = "system/default" - -local themesBasePath = "SCRIPTS:/" .. baseDir .. "/widgets/dashboard/themes/" -local themesUserPath = "SCRIPTS:/" .. preferences .. "/dashboard/" - -local loadedStateModules = {} - -local wakeupScheduler = 0 - -local lastModelPath = model.path() -local lastModelPathCheckAt = 0 -local PATH_CHECK_INTERVAL = 1.0 - -local objectWakeupIndex = 1 -local objectWakeupsPerCycle = nil -local objectsThreadedWakeupCount = 0 -local lastLoadedBoxCount = 0 -local lastBoxRectsCount = 0 -local lastLoadedBoxCount = 0 -local lastBoxRectsCount = 0 -local lastLoadedBoxSig = nil - -local moduleState - -local statePreloadQueue = {"inflight", "postflight"} -local statePreloadIndex = 1 +local DEFAULT_THEME = "system/default" +local themesBasePath = "SCRIPTS:/" .. dashx.config.baseDir .. "/widgets/dashboard/themes/" +local themesUserPath = "SCRIPTS:/" .. dashx.config.preferences .. "/dashboard/" +local currentState = nil +local loadedStates = {} +local lastSizeKey = nil +local darkModeState = lcd.darkMode() local unsupportedResolution = false +local forceFullRepaint = true +local lastInvalidateAt = 0 +local invalidateInterval = 0.1 +local lastHiddenWakeAt = 0 +local hiddenWakeInterval = 1.0 +local gestureActive = false +local gestureStartX = 0 +local gestureStartY = 0 +local gestureTriggered = false +local gestureConsumeUntilTouchEnd = false +local GESTURE_MIN_DY = 20 +local GESTURE_MAX_DX = 40 +local TOOLBAR_TIMEOUT = 5.0 -dashboard._objectDirty = {} - -local scheduledBoxIndices = {} - -local firstWakeup = true -local firstWakeupCustomTheme = true - +dashboard.title = false +dashboard.renders = dashboard.renders or {} +dashboard.objectsByType = {} dashboard.boxRects = {} -dashboard.selectedBoxIndex = 1 - +dashboard._moduleCache = dashboard._moduleCache or {} +dashboard.toolbarVisible = false +dashboard.selectedToolbarIndex = nil +dashboard.toolbarLastActivityAt = 0 +dashboard.selectedBoxIndex = nil +dashboard.currentWidgetPath = nil +dashboard.DEFAULT_THEME = DEFAULT_THEME dashboard.themeFallbackUsed = {preflight = false, inflight = false, postflight = false} dashboard.themeFallbackTime = {preflight = 0, inflight = 0, postflight = 0} -dashboard.flightmode = dashx.flightmode.current or "preflight" - -dashboard.currentWidgetPath = nil - -dashboard.overlayMessage = nil - -dashboard.objectsByType = {} - -dashboard.loaderScale = 0.38 -dashboard.overlayScale = 0.38 - -local darkModeState = lcd.darkMode() - -dashboard._moduleCache = dashboard._moduleCache or {} - -dashboard._hg_cycles_required = 2 -dashboard._hg_cycles = 0 +function dashboard.touchToolbar() + dashboard.toolbarLastActivityAt = os.clock() +end -dashboard._loader_min_duration = 1.5 -dashboard._loader_start_time = nil +function dashboard.openToolbar() + dashboard.toolbarVisible = true + dashboard.selectedToolbarIndex = dashboard.selectedToolbarIndex or 1 + dashboard.touchToolbar() +end -dashboard._minPaintInterval = 0.025 -dashboard._lastInvalidateTime = 0 -dashboard._pendingInvalidates = {} +function dashboard.closeToolbar() + dashboard.toolbarVisible = false + dashboard.selectedToolbarIndex = nil + dashboard.toolbarLastActivityAt = 0 +end -local function _queueInvalidateRect(x, y, w, h) - local r = {x = x, y = y, w = w, h = h} - dashboard._pendingInvalidates[#dashboard._pendingInvalidates + 1] = r +local function ensureDashboardLibraries() + dashboard.utils = dashboard.utils or assert(loadfile("SCRIPTS:/" .. dashx.config.baseDir .. "/widgets/dashboard/lib/utils.lua"))() + dashboard.loaders = dashboard.loaders or assert(loadfile("SCRIPTS:/" .. dashx.config.baseDir .. "/widgets/dashboard/lib/loaders.lua"))() + dashboard.toolbar = dashboard.toolbar or assert(loadfile("SCRIPTS:/" .. dashx.config.baseDir .. "/widgets/dashboard/lib/toolbar.lua"))() end -local function _flushInvalidatesRespectingBudget() - local now = os.clock() - if (now - dashboard._lastInvalidateTime) < dashboard._minPaintInterval then return false end +local function consumeTouchSequence(value) + if not system.killEvents then + return + end - if #dashboard._pendingInvalidates == 0 then return false end + if value ~= nil then + system.killEvents(value) + end - if #dashboard._pendingInvalidates > 6 then - lcd.invalidate() - dashboard._pendingInvalidates = {} - dashboard._lastInvalidateTime = now - return true + if TOUCH_START then + system.killEvents(TOUCH_START) end - local x1, y1, x2, y2 = 1e9, 1e9, -1e9, -1e9 - for _, r in ipairs(dashboard._pendingInvalidates) do - if r.x < x1 then x1 = r.x end - if r.y < y1 then y1 = r.y end - if (r.x + r.w) > x2 then x2 = r.x + r.w end - if (r.y + r.h) > y2 then y2 = r.y + r.h end + if value == TOUCH_END and TOUCH_END then + system.killEvents(TOUCH_END) end - lcd.invalidate(x1, y1, x2 - x1, y2 - y1) - dashboard._pendingInvalidates = {} - dashboard._lastInvalidateTime = now - return true end -dashboard.prof = dashboard.prof or {enabled = true, reportEvery = 2.0, lastReport = 0, perId = {}, firstInventoryDone = false} - -local function _profStart() - if not (dashboard.prof and dashboard.prof.enabled) then return 0 end - return os.clock() -end +local function getThemeForState(state) + local modelPrefs = dashx.session.modelPreferences and dashx.session.modelPreferences.dashboard or nil + local userPrefs = dashx.preferences and dashx.preferences.dashboard or {} + local value = modelPrefs and modelPrefs["theme_" .. state] or nil -local function _profStop(kind, id, typ, t0) - if t0 == 0 then return end - local dt = os.clock() - t0 - local rec = dashboard.prof.perId[id] - if not rec then - rec = {type = typ, paint = 0, wakeup = 0, pc = 0, wc = 0} - dashboard.prof.perId[id] = rec + if value == "nil" then + value = nil end - if kind == "paint" then - rec.paint = rec.paint + dt - rec.pc = rec.pc + 1 - else - rec.wakeup = rec.wakeup + dt - rec.wc = rec.wc + 1 - end -end -local function _profIdFromRect(rect) - local b = rect.box + if value == "Default" then + value = "system/default" + elseif value == "RT-RC" or value == "@RT-RC" then + value = "system/@rt-rc" + end - local H = rect.isHeader and "H" or "B" - return string.format("%s@%s:%d,%d,%dx%d", b.type or "?", H, rect.x, rect.y, rect.w, rect.h) + return value or userPrefs["theme_" .. state] or DEFAULT_THEME end -local function _profReportIfDue() - local P = dashboard.prof - if not (P and P.enabled) then return end - local now = os.clock() - if P.lastReport == 0 then - P.lastReport = now - return - end - if (now - P.lastReport) < (P.reportEvery or 2.0) then return end - - local rows, perTypeAgg = {}, {} - for id, v in pairs(P.perId) do - local tot = (v.paint + v.wakeup) - rows[#rows + 1] = {id = id, type = v.type, paint = v.paint, wake = v.wakeup, pc = v.pc, wc = v.wc, tot = tot} - local T = v.type or "?" - local agg = perTypeAgg[T] or {paint = 0, wake = 0, pc = 0, wc = 0, tot = 0} - agg.paint, agg.wake, agg.pc, agg.wc, agg.tot = agg.paint + v.paint, agg.wake + v.wakeup, agg.pc + v.pc, agg.wc + v.wc, agg.tot + tot - perTypeAgg[T] = agg +local function loadStateScript(themeFolder, state, isFallback) + isFallback = isFallback or false + + local sourceType, folder = nil, nil + if type(themeFolder) == "string" then + sourceType, folder = themeFolder:match("([^/]+)/(.+)") end - table.sort(rows, function(a, b) return a.tot > b.tot end) - log("--------------- OBJECT PROFILER (per instance) ---------------", "info") - for _, r in ipairs(rows) do - local pms, wms = r.paint * 1000, r.wake * 1000 - local ap = r.pc > 0 and (pms / r.pc) or 0 - local aw = r.wc > 0 and (wms / r.wc) or 0 - log(string.format("[prof] %-40s | paint:%7.3fms (%4d, avg %6.3f) | wakeup:%7.3fms (%4d, avg %6.3f)", r.id, pms, r.pc, ap, wms, r.wc, aw), "info") + if not sourceType or not folder then + if not isFallback then + return loadStateScript(DEFAULT_THEME, state, true) + end - local rec = P.perId[r.id]; - rec.paint, rec.wakeup, rec.pc, rec.wc = 0, 0, 0, 0 + dashboard.themeFallbackUsed[state] = true + dashboard.themeFallbackTime[state] = os.clock() + return nil end - log("-------------------- per-type summary ------------------------", "info") - for T, a in pairs(perTypeAgg) do log(string.format("[sum ] %-18s | paint:%7.3fms | wakeup:%7.3fms | total:%7.3fms", T, a.paint * 1000, a.wake * 1000, a.tot * 1000), "info") end - log("--------------------------------------------------------------", "info") - - P.lastReport = now -end -function dashboard.loader(x, y, w, h) - dashboard.loaders.staticLoader(dashboard, x, y, w, h) - _queueInvalidateRect(x, y, w, h) - _flushInvalidatesRespectingBudget() -end + local basePath = sourceType == "user" and themesUserPath or themesBasePath + local initPath = basePath .. folder .. "/init.lua" + local initLoader = loadfile(initPath) -local function forceInvalidateAllObjects() - for _, rect in ipairs(dashboard.boxRects) do - local obj = dashboard.objectsByType[rect.box.type] - if obj and obj.dirty and obj.dirty(rect.box) then _queueInvalidateRect(rect.x, rect.y, rect.w, rect.h) end - end - _flushInvalidatesRespectingBudget() -end + if not initLoader then + if not isFallback then + return loadStateScript(DEFAULT_THEME, state, true) + end -function dashboard.overlaymessage(x, y, w, h, txt) dashboard.loaders.staticOverlayMessage(dashboard, x, y, w, h, txt) end - -local function computeObjectSchedulerPercentage(count) - if count <= 10 then - return 0.8 - elseif count <= 15 then - return 0.7 - elseif count <= 25 then - return 0.6 - elseif count <= 40 then - return 0.5 - else - return 0.4 + dashboard.themeFallbackUsed[state] = true + dashboard.themeFallbackTime[state] = os.clock() + return nil end -end -function dashboard.loadObjectType(box) - local typ = box and box.type - if not typ then return end + local okInit, initTable = pcall(initLoader) + if not okInit or type(initTable) ~= "table" then + if not isFallback then + return loadStateScript(DEFAULT_THEME, state, true) + end - if not dashboard._moduleCache[typ] then + dashboard.themeFallbackUsed[state] = true + dashboard.themeFallbackTime[state] = os.clock() + return nil + end - local bdir = baseDir or "default" - local objPath = "SCRIPTS:/" .. bdir .. "/widgets/dashboard/objects/" .. typ .. ".lua" + local scriptName = type(initTable[state]) == "string" and initTable[state] ~= "" and initTable[state] or (state .. ".lua") + local scriptPath = basePath .. folder .. "/" .. scriptName + local loader = loadfile(scriptPath) - local ok, obj = pcall(function() return assert(compile(objPath))() end) - if ok and type(obj) == "table" then - dashboard._moduleCache[typ] = obj - else - log("Failed to load object: " .. tostring(typ), "info") - print("Error detail: " .. tostring(obj)) - dashboard._moduleCache[typ] = false + if not loader then + if not isFallback then + return loadStateScript(DEFAULT_THEME, state, true) end + + dashboard.themeFallbackUsed[state] = true + dashboard.themeFallbackTime[state] = os.clock() + return nil end - if dashboard._moduleCache[typ] then dashboard.objectsByType[typ] = dashboard._moduleCache[typ] end -end + dashboard.themeFallbackUsed[state] = isFallback == true + dashboard.themeFallbackTime[state] = isFallback and os.clock() or 0 -function dashboard.loadAllObjects(boxConfigs) - dashboard.objectsByType = {} + if initTable.standalone then + return loader + end - for _, box in ipairs(boxConfigs or {}) do - local typ = box.type - if typ then + local okModule, module = pcall(loader) + if not okModule then + if not isFallback then + return loadStateScript(DEFAULT_THEME, state, true) + end - if not dashboard._moduleCache[typ] then - local bdir = baseDir or "default" - local objPath = "SCRIPTS:/" .. bdir .. "/widgets/dashboard/objects/" .. typ .. ".lua" + dashboard.themeFallbackUsed[state] = true + dashboard.themeFallbackTime[state] = os.clock() + return nil + end - local ok, obj = pcall(function() return assert(compile(objPath))() end) - if ok and type(obj) == "table" then - dashboard._moduleCache[typ] = obj - else - log("Failed to load object: " .. tostring(typ), "info") - print("Error detail: " .. tostring(obj)) + return module +end - dashboard._moduleCache[typ] = false - end - end +local function loadObjectType(box) + local objectType = box and box.type + if not objectType then + return + end - if dashboard._moduleCache[typ] then dashboard.objectsByType[typ] = dashboard._moduleCache[typ] end + if dashboard._moduleCache[objectType] == nil then + local objectPath = "SCRIPTS:/" .. dashx.config.baseDir .. "/widgets/dashboard/objects/" .. objectType .. ".lua" + local loader = loadfile(objectPath) + if loader then + local ok, module = pcall(loader) + dashboard._moduleCache[objectType] = ok and module or false + else + dashboard._moduleCache[objectType] = false end end -end -local function getOnpressBoxIndices() - local indices = {} - for i, rect in ipairs(dashboard.boxRects) do if rect.box.onpress then indices[#indices + 1] = i end end - return indices + if dashboard._moduleCache[objectType] then + dashboard.objectsByType[objectType] = dashboard._moduleCache[objectType] + end end -function dashboard.computeOverlayMessage() - - local state = dashboard.flightmode or "preflight" - local telemetry = tasks.telemetry - local pad = " " +local function loadObjects(module) + dashboard.objectsByType = {} - if dashboard.themeFallbackUsed and dashboard.themeFallbackUsed[state] and (os.clock() - (dashboard.themeFallbackTime and dashboard.themeFallbackTime[state] or 0)) < 10 then return "@i18n(widgets.dashboard.theme_load_error)@" end + local boxes = type(module.boxes) == "function" and module.boxes() or (module.boxes or {}) + local headerBoxes = module.header_boxes or {} - if not tasks.active() then return "@i18n(widgets.dashboard.check_bg_task)@" end + for _, box in ipairs(boxes) do + loadObjectType(box) + end - if dashx.session.apiVersion and dashx.session.rfVersion and not dashx.session.isConnectedLow and state ~= "postflight" then - if system.getVersion().simulation == true then - return pad .. "SIM " .. dashx.session.apiVersion .. pad - else - return pad .. "RF" .. dashx.session.rfVersion .. pad - end + for _, box in ipairs(headerBoxes) do + loadObjectType(box) end +end - if not dashx.session.isConnectedHigh and state ~= "postflight" then return "@i18n(widgets.dashboard.waiting_for_connection)@" end +local function reloadTheme() + loadedStates = { + preflight = loadStateScript(getThemeForState("preflight"), "preflight"), + inflight = loadStateScript(getThemeForState("inflight"), "inflight"), + postflight = loadStateScript(getThemeForState("postflight"), "postflight") + } - return nil + dashboard.utils.resetImageCache() + dashboard.boxRects = {} + currentState = nil + dashx.theme.version = (dashx.theme.version or 0) + 1 + forceFullRepaint = true end -local function getBoxSize(box, boxWidth, boxHeight, PADDING, WIDGET_W, WIDGET_H) - if box.w_pct and box.h_pct then - local wp = box.w_pct - local hp = box.h_pct - if wp > 1 then wp = wp / 100 end - if hp > 1 then hp = hp / 100 end - local w = math.floor(wp * WIDGET_W) - local h = math.floor(hp * WIDGET_H) - return w, h - elseif box.w and box.h then - return tonumber(box.w) or boxWidth, tonumber(box.h) or boxHeight - elseif box.colspan or box.rowspan then - local w = math.floor((box.colspan or 1) * boxWidth + ((box.colspan or 1) - 1) * PADDING) - local h = math.floor((box.rowspan or 1) * boxHeight + ((box.rowspan or 1) - 1) * PADDING) - return w, h - else - return boxWidth, boxHeight +function dashboard.reload_themes() + ensureDashboardLibraries() + reloadTheme() + if lcd.invalidate then + lcd.invalidate() end end -local function getBoxPosition(box, w, h, boxWidth, boxHeight, PADDING, WIDGET_W, WIDGET_H) - - if box.x_pct and box.y_pct then - local xp = box.x_pct - local yp = box.y_pct - if xp > 1 then xp = xp / 100 end - if yp > 1 then yp = yp / 100 end - - local x = math.floor(xp * (WIDGET_W - (w or boxWidth))) - local y = math.floor(yp * (WIDGET_H - (h or boxHeight))) - return x, y - elseif box.x and box.y then - local x = tonumber(box.x) or 0 - local y = tonumber(box.y) or 0 - return x, y - elseif box.col and box.row then - local col = box.col or 1 - local row = box.row or 1 - local x = math.floor((col - 1) * (boxWidth + PADDING)) + (box.xOffset or 0) - local y = math.floor(PADDING + (row - 1) * (boxHeight + PADDING)) - return x, y - else - return 0, 0 +local function getBoxSize(box, boxWidth, boxHeight, padding, widgetW, widgetH) + if box.w_pct and box.h_pct then + local widthPct = box.w_pct > 1 and (box.w_pct / 100) or box.w_pct + local heightPct = box.h_pct > 1 and (box.h_pct / 100) or box.h_pct + return math.floor(widthPct * widgetW), math.floor(heightPct * widgetH) end -end -function dashboard.renderLayout(widget, config) - local utils = dashboard.utils - local telemetry = tasks.telemetry + if box.w and box.h then + return tonumber(box.w) or boxWidth, tonumber(box.h) or boxHeight + end - dashboard.boxRects = dashboard.boxRects or {} - scheduledBoxIndices = scheduledBoxIndices or {} - dashboard._objectDirty = dashboard._objectDirty or {} + if box.colspan or box.rowspan then + local width = math.floor((box.colspan or 1) * boxWidth + ((box.colspan or 1) - 1) * padding) + local height = math.floor((box.rowspan or 1) * boxHeight + ((box.rowspan or 1) - 1) * padding) + return width, height + end - local function resolve(val, ...) return type(val) == "function" and val(...) or val end + return boxWidth, boxHeight +end - local layout = resolve(config.layout) or {} - local headerLayout = resolve(config.header_layout) or {} - local boxes = resolve(config.boxes or layout.boxes or {}) - local headerBoxes = resolve(config.header_boxes or {}) +local function getBoxPosition(box, width, height, boxWidth, boxHeight, padding, widgetW, widgetH) + if box.x_pct and box.y_pct then + local xPct = box.x_pct > 1 and (box.x_pct / 100) or box.x_pct + local yPct = box.y_pct > 1 and (box.y_pct / 100) or box.y_pct + return math.floor(xPct * (widgetW - width)), math.floor(yPct * (widgetH - height)) + end - if (#boxes + #headerBoxes) ~= lastLoadedBoxCount then - local allBoxes = {} - for _, b in ipairs(boxes) do table.insert(allBoxes, b) end - for _, b in ipairs(headerBoxes) do table.insert(allBoxes, b) end - dashboard.loadAllObjects(allBoxes) - lastLoadedBoxCount = #boxes + #headerBoxes + if box.x and box.y then + return tonumber(box.x) or 0, tonumber(box.y) or 0 end - local function makeBoxesSig(bx, hbx) - local t = {} - for _, b in ipairs(bx or {}) do t[#t + 1] = tostring(b.type or "") end - for _, b in ipairs(hbx or {}) do t[#t + 1] = tostring(b.type or "") end - table.sort(t) - return table.concat(t, "|") + if box.col and box.row then + local x = math.floor((box.col - 1) * (boxWidth + padding)) + (box.xOffset or 0) + local y = math.floor(padding + (box.row - 1) * (boxHeight + padding)) + return x, y end - local thisSig = makeBoxesSig(boxes, headerBoxes) + return 0, 0 +end - if ((#boxes + #headerBoxes) ~= lastLoadedBoxCount) or (thisSig ~= lastLoadedBoxSig) then - local allBoxes = {} - for _, b in ipairs(boxes) do allBoxes[#allBoxes + 1] = b end - for _, b in ipairs(headerBoxes) do allBoxes[#allBoxes + 1] = b end - dashboard.loadAllObjects(allBoxes) - lastLoadedBoxCount = #boxes + #headerBoxes - lastLoadedBoxSig = thisSig - end +local function adjustDimension(dimension, cells, padCount, padding) + return dimension - ((dimension - padCount * padding) % cells) +end - for k in pairs(dashboard._objectDirty) do dashboard._objectDirty[k] = nil end +local function buildRects(module) + local utils = dashboard.utils + local layout = module.layout or {} + local headerLayout = module.header_layout or {} + local boxes = type(module.boxes) == "function" and module.boxes() or (module.boxes or {}) + local headerBoxes = module.header_boxes or {} + + local windowW, windowH = lcd.getWindowSize() + local isFullScreen = utils.isFullScreen(windowW, windowH) - local W_raw, H_raw = lcd.getWindowSize() - local isFullScreen = utils.isFullScreen(W_raw, H_raw) local cols = layout.cols or 1 local rows = layout.rows or 1 - local pad = layout.padding or 0 - - local function adjustDimension(dim, cells, padCount) return dim - ((dim - padCount * pad) % cells) end + local padding = layout.padding or 0 - if isFullScreen and headerLayout and headerLayout.height and type(headerLayout.height) == "number" then H_raw = H_raw - headerLayout.height end + local contentHeight = windowH + if isFullScreen and headerLayout.height then + contentHeight = contentHeight - headerLayout.height + end - local W = adjustDimension(W_raw, cols, cols - 1) - local H = adjustDimension(H_raw, rows, rows + 1) - local xOffset = math.floor((W_raw - W) / 2) + local adjustedW = adjustDimension(windowW, cols, cols - 1, padding) + local adjustedH = adjustDimension(contentHeight, rows, rows + 1, padding) + local xOffset = math.floor((windowW - adjustedW) / 2) - local contentW = W - ((cols - 1) * pad) - local contentH = H - ((rows + 1) * pad) + local contentW = adjustedW - ((cols - 1) * padding) + local contentH = adjustedH - ((rows + 1) * padding) local boxW = contentW / cols local boxH = contentH / rows - utils.setBackgroundColourBasedOnTheme() - - for i = #dashboard.boxRects, 1, -1 do dashboard.boxRects[i] = nil end - for i = #scheduledBoxIndices, 1, -1 do scheduledBoxIndices[i] = nil end + dashboard.boxRects = {} for _, box in ipairs(boxes) do - local w, h = getBoxSize(box, boxW, boxH, pad, W, H) + local width, height = getBoxSize(box, boxW, boxH, padding, adjustedW, adjustedH) box.xOffset = xOffset - local x, y = getBoxPosition(box, w, h, boxW, boxH, pad, W, H) - if isFullScreen and headerLayout and headerLayout.height and type(headerLayout.height) == "number" then y = y + headerLayout.height end - - local rect = {x = x, y = y, w = w, h = h, box = box, isHeader = false} - table.insert(dashboard.boxRects, rect) - - local rectIndex = #dashboard.boxRects - dashboard._objectDirty[rectIndex] = nil - - local obj = dashboard.objectsByType[box.type] - if obj and obj.scheduler and obj.wakeup then table.insert(scheduledBoxIndices, rectIndex) end - end - - if isFullScreen then - local headerGeoms = {} - local rightmost_idx, rightmost_x = 1, 0 - for idx, box in ipairs(headerBoxes) do - local w, h = getBoxSize(box, boxW, boxH, pad, W_raw, headerLayout.height) - local x, y = getBoxPosition(box, w, h, boxW, boxH, pad, W_raw, headerLayout.height) - headerGeoms[idx] = {x = x, y = y, w = w, h = h, box = box} - if x > rightmost_x then - rightmost_idx = idx - rightmost_x = x + local x, y = getBoxPosition(box, width, height, boxW, boxH, padding, adjustedW, adjustedH) + if isFullScreen and headerLayout.height then + y = y + headerLayout.height + end + dashboard.boxRects[#dashboard.boxRects + 1] = {x = x, y = y, w = width, h = height, box = box} + end + + if isFullScreen and #headerBoxes > 0 then + local headerCols = headerLayout.cols or 1 + local headerRows = headerLayout.rows or 1 + local headerPadding = headerLayout.padding or 0 + local headerHeight = headerLayout.height or 0 + + local adjustedHeaderW = adjustDimension(windowW, headerCols, headerCols - 1, headerPadding) + local adjustedHeaderH = adjustDimension(headerHeight, headerRows, headerRows - 1, headerPadding) + local headerContentW = adjustedHeaderW - ((headerCols - 1) * headerPadding) + local headerContentH = adjustedHeaderH - ((headerRows - 1) * headerPadding) + local headerBoxW = headerContentW / headerCols + local headerBoxH = headerContentH / headerRows + + local rightmostIndex = 1 + local rightmostX = 0 + local headerGeometries = {} + + for index, box in ipairs(headerBoxes) do + local width, height = getBoxSize(box, headerBoxW, headerBoxH, headerPadding, adjustedHeaderW, adjustedHeaderH) + local x, y = getBoxPosition(box, width, height, headerBoxW, headerBoxH, headerPadding, adjustedHeaderW, adjustedHeaderH) + headerGeometries[index] = {x = x, y = y, w = width, h = height, box = box} + if x > rightmostX then + rightmostIndex = index + rightmostX = x end end - for idx, geom in ipairs(headerGeoms) do - local w = geom.w - if idx == rightmost_idx then w = W_raw - geom.x end - - local rect = {x = geom.x, y = geom.y, w = w, h = geom.h, box = geom.box, isHeader = true} - table.insert(dashboard.boxRects, rect) - local idx_rect = #dashboard.boxRects - dashboard._objectDirty[idx_rect] = nil - - local obj = dashboard.objectsByType[geom.box.type] - if obj and obj.scheduler and obj.wakeup then table.insert(scheduledBoxIndices, idx_rect) end + for index, geom in ipairs(headerGeometries) do + local width = geom.w + if index == rightmostIndex then + width = windowW - geom.x + end + dashboard.boxRects[#dashboard.boxRects + 1] = {x = geom.x, y = geom.y, w = width, h = geom.h, box = geom.box} end end +end - if not objectWakeupsPerCycle or #dashboard.boxRects ~= lastBoxRectsCount then - local count = #dashboard.boxRects - local percentage = 1.0 - - if objectsThreadedWakeupCount < 1 then - percentage = 1.0 - log("Accelerating first wakeup pass with 100% objects per cycle", "info") +local function ensureState() + local nextState = dashx.flightmode.current or "preflight" + if nextState ~= currentState then + currentState = nextState + local module = loadedStates[currentState] + if module then + loadObjects(module) + else + dashboard.objectsByType = {} + dashboard.boxRects = {} end - - objectWakeupsPerCycle = math.max(1, math.ceil(count * percentage)) - lastBoxRectsCount = count - - log("Object scheduler set to " .. objectWakeupsPerCycle .. " out of " .. count .. " boxes", "info") + dashboard.currentWidgetPath = getThemeForState(currentState) + forceFullRepaint = true end - dashboard._loader_start_time = dashboard._loader_start_time or os.clock() - local loaderElapsed = os.clock() - dashboard._loader_start_time - if objectsThreadedWakeupCount < 1 or loaderElapsed < dashboard._loader_min_duration then - local loaderY = (isFullScreen and headerLayout.height) or 0 - dashboard.loader(0, loaderY, W, H - loaderY) - _queueInvalidateRect(0, loaderY, W, H - loaderY) - _flushInvalidatesRespectingBudget() - return + local width, height = lcd.getWindowSize() + local sizeKey = string.format("%dx%d", width, height) + if sizeKey ~= lastSizeKey then + lastSizeKey = sizeKey + forceFullRepaint = true end - local selColor = layout.selectcolor or utils.resolveColor("yellow") or lcd.RGB(255, 255, 0) - local selBorder = layout.selectborder or 2 - - for i, rect in ipairs(dashboard.boxRects) do - if not rect.isHeader then - local box = rect.box - local obj = dashboard.objectsByType[box.type] - if obj and obj.paint then - if objectProfiler then - local id = _profIdFromRect(rect) - local t0 = _profStart() - obj.paint(rect.x, rect.y, rect.w, rect.h, box) - _profStop("paint", id, box.type, t0) - else - obj.paint(rect.x, rect.y, rect.w, rect.h, box) - end - end - - if dashboard.selectedBoxIndex == i and box.onpress then - lcd.color(selColor) - lcd.drawRectangle(rect.x, rect.y, rect.w, rect.h, selBorder) - end - end + local module = loadedStates[currentState] + if module then + buildRects(module) + else + dashboard.boxRects = {} end + return module +end - if isFullScreen and config.header_layout and #headerBoxes > 0 then - local header = config.header_layout - local h_cols = header.cols or 1 - local h_rows = header.rows or 1 - local h_pad = header.padding or 0 - - local headerW = W_raw - local headerH = header.height or 0 - - local function adjustHeaderDimension(dim, cells, padCount) return dim - ((dim - padCount * h_pad) % cells) end - - local adjustedW = adjustHeaderDimension(headerW, h_cols, h_cols - 1) - local adjustedH = adjustHeaderDimension(headerH, h_rows, h_rows - 1) - - local contentW = adjustedW - ((h_cols - 1) * h_pad) - local contentH = adjustedH - ((h_rows - 1) * h_pad) - local h_boxW = contentW / h_cols - local h_boxH = contentH / h_rows - - local rightmost_idx, rightmost_x = 1, 0 - local headerGeoms = {} - for idx, box in ipairs(headerBoxes) do - local w, h = getBoxSize(box, h_boxW, h_boxH, h_pad, adjustedW, adjustedH) - local x, y = getBoxPosition(box, w, h, h_boxW, h_boxH, h_pad, adjustedW, adjustedH) - headerGeoms[idx] = {x = x, y = y, w = w, h = h, box = box} - if x > rightmost_x then - rightmost_idx = idx - rightmost_x = x - end - end +local function wakeObjects() + local dirty = forceFullRepaint - for idx, geom in ipairs(headerGeoms) do - local w = geom.w - if idx == rightmost_idx then w = W_raw - geom.x end - local obj = dashboard.objectsByType[geom.box.type] - if obj and obj.paint then - if objectProfiler then - local fakeRect = {x = geom.x, y = geom.y, w = w, h = geom.h, box = geom.box, isHeader = true} - local id = _profIdFromRect(fakeRect) - local t0 = _profStart() - obj.paint(geom.x, geom.y, w, geom.h, geom.box) - _profStop("paint", id, geom.box.type, t0) - else - obj.paint(geom.x, geom.y, w, geom.h, geom.box) - end - end + for _, rect in ipairs(dashboard.boxRects) do + local object = dashboard.objectsByType[rect.box.type] + if object and object.wakeup then + object.wakeup(rect.box) end - - if isFullScreen and headerLayout and headerLayout.showgrid then - lcd.color(headerLayout.showgrid) - lcd.pen(1) - - for i = 1, h_cols - 1 do - local x = math.floor(i * (h_boxW + h_pad)) - math.floor(h_pad / 2) - lcd.drawLine(x, 0, x, headerLayout.height) - end - - for i = 1, h_rows - 1 do - local y = math.floor(i * (h_boxH + h_pad)) - math.floor(h_pad / 2) - lcd.drawLine(0, y, W_raw, y) - end - - lcd.pen(SOLID) + if not dirty and object and object.dirty and object.dirty(rect.box) then + dirty = true end - end - if layout.showgrid or dashx.preferences.developer.overlaygrid then - lcd.color(layout.showgrid) - lcd.pen(1) - - local headerOffset = (isFullScreen and headerLayout and headerLayout.height) or 0 - - for i = 1, cols - 1 do - local x = math.floor(i * (boxW + pad)) + xOffset - math.floor(pad / 2) - lcd.drawLine(x, headerOffset, x, H_raw + headerOffset) - end - - for i = 1, rows - 1 do - local y = math.floor(i * (boxH + pad)) + pad + headerOffset - lcd.drawLine(0, y, W_raw, y) - end + return dirty +end - lcd.pen(SOLID) +local function getOverlayMessage(state) + if dashboard.themeFallbackUsed[state] and (os.clock() - (dashboard.themeFallbackTime[state] or 0)) < 10 then + return "@i18n(widgets.dashboard.theme_load_error)@" end - if layout.showstats or dashx.preferences.developer.overlaystats then - local headerOffset = (isFullScreen and headerLayout and headerLayout.height) or 0 - - local cpuUsage = (dashx.performance and dashx.performance.cpuload) or 0 - local loopMs = (dashx.performance and dashx.performance.loop_ms) or 0 - local budgetMs = (dashx.performance and dashx.performance.budget_ms) or 50 - local tickMs = (dashx.performance and dashx.performance.tick_ms) - local headroomPct = math.max(0, 100 - (cpuUsage or 0)) - - local ramFreeKB = (dashx.performance and dashx.performance.luaRamKB) or 0 - local ramUsedGC_KB = (dashx.performance and dashx.performance.usedram) or 0 - local sysRamFreeKB = (dashx.performance and dashx.performance.ramKB) or 0 - local bitmapRamFreeKB = (dashx.performance and dashx.performance.luaBitmapsRamKB) or 0 - local mainStackKB = (dashx.performance and dashx.performance.mainStackKB) or 0 - - lcd.font(FONT_S) - local _, lineH = lcd.getTextSize("A") - - local cfg = {padX = 8, padY = 6, colGap = 10, rowGap = 2, labelW = 170, valueW = 120, unitW = 30, sectionGap = 8, decimalsMS = 1, decimalsKB = 1, boxX = 4, boxY = 4 + headerOffset, bg = {0, 0, 0, 0.9}, fg = {255, 255, 255}, border = true, showActualPeriod = true} - - local function fmtPct(n) return dashx.utils.round(n or 0, 0) end - local function fmtMS(n) return string.format("%." .. cfg.decimalsMS .. "f", n or 0) end - local function fmtKB(n) return string.format("%." .. cfg.decimalsKB .. "f", n or 0) end - - local schedRows = {{"LOAD", fmtPct(cpuUsage), "%"}, {"LOAD (100ms window)", fmtPct(dashx.performance.cpuload_window100 or 0), "%"}, {"HEADROOM", fmtPct(headroomPct), "%"}, {"LOOP / BUDGET", fmtMS(loopMs) .. " / " .. fmtMS(budgetMs), "ms"}} - if cfg.showActualPeriod and tickMs then table.insert(schedRows, {"ACTUAL PERIOD", fmtMS(tickMs), "ms"}) end - - local memRows = {{"LUA RAM FREE", fmtKB(ramFreeKB), "KB"}, {"LUA RAM USED (GC)", fmtKB(ramUsedGC_KB), "KB"}, {"SYSTEM RAM FREE", fmtKB(sysRamFreeKB), "KB"}, {"LUA BITMAP RAM", fmtKB(bitmapRamFreeKB), "KB"}} + if not dashx.session.telemetryState and state ~= "postflight" then + return "@i18n(widgets.dashboard.waiting_for_connection)@" + end - local boxW = cfg.padX * 2 + cfg.labelW + cfg.colGap + cfg.valueW + cfg.colGap + cfg.unitW + return nil +end - local sectionHeaderH = lineH - local totalRows = #schedRows + #memRows - local boxH = cfg.padY * 2 + sectionHeaderH + (#schedRows * (lineH + cfg.rowGap)) + cfg.sectionGap + sectionHeaderH + (#memRows * (lineH + cfg.rowGap)) +local function paintObjects() + local module = loadedStates[currentState] + if not module then + dashboard.utils.screenError("@i18n(widgets.dashboard.theme_load_error)@", true, 0.5) + return + end - local screenW, screenH = lcd.getWindowSize() - local boxX = math.floor((screenW - boxW) / 2) - local boxY = math.floor((screenH - boxH) / 2) - local minY = 4 + headerOffset - if boxY < minY then boxY = minY end + dashboard.utils.setBackgroundColourBasedOnTheme() - lcd.color(lcd.RGB(cfg.bg[1], cfg.bg[2], cfg.bg[3], cfg.bg[4])) - lcd.drawFilledRectangle(boxX, boxY, boxW, boxH) - if cfg.border then - lcd.pen(1) - lcd.color(lcd.RGB(cfg.fg[1], cfg.fg[2], cfg.fg[3])) - lcd.drawRectangle(boxX, boxY, boxW, boxH) - lcd.pen(0) + for _, rect in ipairs(dashboard.boxRects) do + local object = dashboard.objectsByType[rect.box.type] + if object and object.paint then + object.paint(rect.x, rect.y, rect.w, rect.h, rect.box) end + end - local labelX = boxX + cfg.padX - local valueX = labelX + cfg.labelW + cfg.colGap - local unitX = valueX + cfg.valueW + cfg.colGap - local y = boxY + cfg.padY - - local function drawSection(title, rows) - - lcd.color(lcd.RGB(cfg.fg[1], cfg.fg[2], cfg.fg[3])) - lcd.font(FONT_S_BOLD) - lcd.drawText(labelX, y, title) - lcd.font(FONT_S) - y = y + sectionHeaderH + cfg.rowGap - - for i = 1, #rows do - local label, value, unit = rows[i][1], rows[i][2], rows[i][3] - lcd.drawText(labelX, y, label) - - local tw = lcd.getTextSize(tostring(value)) - lcd.drawText(valueX + cfg.valueW - tw, y, tostring(value)) - lcd.drawText(unitX, y, tostring(unit)) - y = y + lineH + cfg.rowGap - end - - y = y + cfg.sectionGap + local overlay = getOverlayMessage(currentState) + if overlay then + local windowW, windowH = lcd.getWindowSize() + local offsetY = 0 + if module.header_layout and dashboard.utils.isFullScreen(windowW, windowH) then + offsetY = module.header_layout.height or 0 end - - drawSection("SCHEDULER", schedRows) - drawSection("MEMORY", memRows) + dashboard.overlaymessage(0, offsetY, windowW, windowH - offsetY, overlay) end +end - if dashboard.overlayMessage then dashboard._hg_cycles = dashboard._hg_cycles_required end - if dashboard._hg_cycles > 0 then - local loaderY = (isFullScreen and headerLayout.height) or 0 - dashboard.overlaymessage(0, loaderY, W, H - loaderY, dashboard.overlayMessage) - dashboard._hg_cycles = dashboard._hg_cycles - 1 - _queueInvalidateRect(0, loaderY, W, H - loaderY) - _flushInvalidatesRespectingBudget() - return - end +function dashboard.loader(x, y, w, h) + dashboard.loaders.staticLoader(dashboard, x, y, w, h) +end - dashboard._forceFullRepaint = true +function dashboard.overlaymessage(x, y, w, h, text) + dashboard.loaders.staticOverlayMessage(dashboard, x, y, w, h, text) end -local function getThemeForState(state) - local prefs = dashx.session.modelPreferences and dashx.session.modelPreferences.dashboard - local fallback = dashx.preferences.dashboard - local val = prefs and prefs["theme_" .. state] - return (val and val ~= "nil" and val) or fallback["theme_" .. state] or dashboard.DEFAULT_THEME +function dashboard.create() + ensureDashboardLibraries() + os.mkdir("SCRIPTS:/" .. dashx.config.preferences .. "/dashboard/") + reloadTheme() + return {} end -local function load_state_script(theme_folder, state, isFallback) - isFallback = isFallback or false +function dashboard.listThemes() + ensureDashboardLibraries() - local src, folder = theme_folder:match("([^/]+)/(.+)") - local base = (src == "user") and themesUserPath or themesBasePath + local themes = {} + local count = 0 - if not src or not folder then - if not isFallback then return load_state_script(dashboard.DEFAULT_THEME, state, true) end + local function scanThemes(basePath, sourceType) + local folders = system.listFiles(basePath) + if not folders then + return + end - dashboard.themeFallbackUsed[state] = true - dashboard.themeFallbackTime[state] = os.clock() - return nil + for _, folder in ipairs(folders) do + if folder ~= "." and folder ~= ".." and not folder:match("%.%a+$") and dashx.utils.dir_exists(basePath, folder) then + local initPath = basePath .. folder .. "/init.lua" + local chunk = loadfile(initPath) + if chunk then + local ok, initTable = pcall(chunk) + if ok and type(initTable) == "table" and type(initTable.name) == "string" then + if not initTable.developer or (dashx.preferences and dashx.preferences.developer and dashx.preferences.developer.devtools == true) then + count = count + 1 + themes[count] = { + name = initTable.name, + configure = initTable.configure, + folder = folder, + idx = count, + source = sourceType + } + end + end + end + end + end end - local function setPath() dashboard.currentWidgetPath = src .. "/" .. folder end + scanThemes(themesBasePath, "system") - local initPath = base .. folder .. "/init.lua" - local initChunk, initErr = compile(initPath) - if not initChunk then - if not isFallback then return load_state_script(dashboard.DEFAULT_THEME, state, true) end - dashboard.themeFallbackUsed[state] = true - dashboard.themeFallbackTime[state] = os.clock() - return nil + local userBasePath = "SCRIPTS:/" .. dashx.config.preferences .. "/" + if dashx.utils.dir_exists(userBasePath, "dashboard") then + scanThemes(themesUserPath, "user") end - local ok, initTable = pcall(initChunk) - if not ok or type(initTable) ~= "table" then - print("Error running init.lua for theme=" .. tostring(theme_folder) .. ", state=" .. tostring(state)) - print("Error detail: " .. tostring(initTable)) - if not isFallback then return load_state_script(dashboard.DEFAULT_THEME, state, true) end - dashboard.themeFallbackUsed[state] = true - dashboard.themeFallbackTime[state] = os.clock() - return nil - end - - local scriptName = (type(initTable[state]) == "string" and initTable[state] ~= "") and initTable[state] or (state .. ".lua") - local scriptPath = base .. folder .. "/" .. scriptName - - local chunk, chunkErr = compile(scriptPath) - if not chunk then - if not isFallback then return load_state_script(dashboard.DEFAULT_THEME, state, true) end + return themes +end - log("dashboard: Could not load " .. scriptName .. " for " .. folder .. " or default: " .. tostring(chunkErr), "info") - dashboard.themeFallbackUsed[state] = true - dashboard.themeFallbackTime[state] = os.clock() +function dashboard.getPreference(key) + if not dashx.session.modelPreferences or not dashboard.currentWidgetPath then return nil end - dashboard.themeFallbackUsed[state] = (isFallback == true) - dashboard.themeFallbackTime[state] = isFallback and os.clock() or 0 - setPath() - - if initTable.standalone then - return chunk - else - local ok2, module = pcall(chunk) - if not ok2 then - print("Error running init.lua for theme=" .. tostring(theme_folder) .. ", state=" .. tostring(state)) - print("Error detail: " .. tostring(module)) - if not isFallback then return load_state_script(dashboard.DEFAULT_THEME, state, true) end - dashboard.themeFallbackUsed[state] = true - dashboard.themeFallbackTime[state] = os.clock() - return nil - end - return module - end + return dashx.ini.getvalue(dashx.session.modelPreferences, dashboard.currentWidgetPath, key) end -local function getThemeForState(state) - local modelPrefs = dashx.session.modelPreferences and dashx.session.modelPreferences.dashboard - local userPrefs = dashx.preferences.dashboard - local val = nil - if modelPrefs then - val = modelPrefs["theme_" .. state] - if val == "nil" then val = nil end +function dashboard.savePreference(key, value) + if not dashx.session.modelPreferences or not dashx.session.modelPreferencesFile or not dashboard.currentWidgetPath then + return false end - return val or userPrefs["theme_" .. state] or dashboard.DEFAULT_THEME -end -local function reload_state_only(state) - dashboard.utils.resetImageCache() - loadedStateModules[state] = load_state_script(getThemeForState(state), state) - lastLoadedBoxCount = 0 - lastBoxRectsCount = 0 - objectWakeupIndex = 1 - objectsThreadedWakeupCount = 0 - objectWakeupsPerCycle = nil - lastLoadedBoxSig = nil - dashboard.boxRects = {} - if dashboard.boxRects then for k in pairs(dashboard.boxRects) do dashboard.boxRects[k] = nil end end - lcd.invalidate() + dashx.ini.setvalue(dashx.session.modelPreferences, dashboard.currentWidgetPath, key, value) + return dashx.ini.save_ini_file(dashx.session.modelPreferencesFile, dashx.session.modelPreferences) end -function dashboard.reload_active_theme_only(force) - dashboard.utils.resetImageCache() - - local state = dashboard.flightmode or "preflight" - local theme = getThemeForState(state) - - if force or not loadedStateModules[state] then - log("Reloading active theme: " .. theme, "info") - loadedStateModules[state] = load_state_script(theme, state) - else - log("Skipped reloading active theme: already loaded", "info") - end - - firstWakeup = true - lcd.invalidate() +function dashboard.resetFlightModeAsk() + local buttons = { + { + label = "@i18n(app.btn_ok)@", + action = function() + if dashx.runtime and dashx.runtime.resetFlight then + dashx.runtime.resetFlight() + end + if model and type(model.resetFlight) == "function" then + pcall(model.resetFlight) + end + dashboard.closeToolbar() + if lcd.invalidate then + lcd.invalidate() + end + return true + end + }, + { + label = "@i18n(app.btn_cancel)@", + action = function() + return true + end + } + } - wakeupScheduler = 0 - dashboard.boxRects = {} - objectsThreadedWakeupCount = 0 - objectWakeupIndex = 1 - lastLoadedBoxCount = 0 - lastBoxRectsCount = 0 - objectWakeupsPerCycle = nil - lastLoadedBoxSig = nil - - lcd.invalidate() + form.openDialog({ + title = "@i18n(widgets.dashboard.reset_flight_ask_title)@", + message = "@i18n(widgets.dashboard.reset_flight_ask_text)@", + buttons = buttons, + options = TEXT_LEFT + }) end -function dashboard.applySchedulerSettings() - - local active = dashboard.flightmode or "preflight" - local mod = loadedStateModules[active] - if mod and mod.scheduler then - local initTable = (type(mod.scheduler) == "function") and mod.scheduler() or mod.scheduler - if type(initTable) == "table" then - - dashboard._useSpreadScheduling = (initTable.spread_scheduling ~= false) - dashboard._useSpreadSchedulingPaint = (initTable.spread_scheduling_paint ~= false) - - dashboard._spreadRatioOverride = (type(initTable.spread_ratio) == "number" and initTable.spread_ratio > 0 and initTable.spread_ratio <= 1) and initTable.spread_ratio or nil - else - dashboard._useSpreadScheduling = true - dashboard._useSpreadSchedulingPaint = true - dashboard._spreadRatioOverride = nil - end - else - dashboard._useSpreadScheduling = true - dashboard._useSpreadSchedulingPaint = true - dashboard._spreadRatioOverride = nil - end - +function dashboard.menu(widget) + return { + { + "@i18n(widgets.dashboard.reset_flight)@", + function() + dashboard.resetFlightModeAsk() + if lcd.invalidate then + lcd.invalidate(widget) + end + end + } + } end -function dashboard.reload_themes(force) - - dashboard.renders = {} - - dashboard.reload_active_theme_only(force) - - statePreloadIndex = 1 - - dashboard.applySchedulerSettings() - - local boxes = {} - if mod and mod.boxes then - local rawBoxes = type(mod.boxes) == "function" and mod.boxes() or mod.boxes - for _, box in ipairs(rawBoxes or {}) do table.insert(boxes, box) end - end - dashboard.loadAllObjects(boxes) - - firstWakeup = true - dashboard._loader_start_time = nil - dashboard._hg_cycles = dashboard._hg_cycles_required - - dashboard._forceFullRepaint = true - if dashboard.boxRects then for k in pairs(dashboard.boxRects) do dashboard.boxRects[k] = nil end end - lastBoxRectsCount = 0 - lastLoadedBoxCount = 0 - objectWakeupIndex = 1 - objectWakeupsPerCycle = nil - objectsThreadedWakeupCount = 0 - - local mod = loadedStateModules[dashboard.flightmode or "preflight"] - if type(mod) == "table" and mod.layout and mod.boxes then - log("Manually triggering renderLayout after theme reload", "info") - dashboard.renderLayout(nil, mod) +function dashboard.paint() + if unsupportedResolution then + dashboard.utils.screenError("@i18n(widgets.dashboard.unsupported_resolution)@", true, 0.5) + return end -end - -local function callStateFunc(funcName, widget, paintFallback) - local state = dashboard.flightmode or "preflight" - local module = loadedStateModules[state] - - if not tasks.active() then return nil end - - if type(module) == "table" and module.layout and funcName == "paint" then return module end - - if module and type(module[funcName]) == "function" then return module[funcName](widget) end - - if paintFallback then - local msg = "dashboard: " .. funcName .. " not implemented for " .. state .. "." - dashboard.utils.screenError(msg) + ensureState() + paintObjects() + if dashboard.toolbar and dashboard.toolbar.draw then + dashboard.toolbar.draw(dashboard) end end -function dashboard.create() - - if not dashboard.utils then dashboard.utils = assert(compile("SCRIPTS:/" .. dashx.config.baseDir .. "/widgets/dashboard/lib/utils.lua"))() end - if not dashboard.loaders then dashboard.loaders = assert(compile("SCRIPTS:/" .. dashx.config.baseDir .. "/widgets/dashboard/lib/loaders.lua"))() end - - os.mkdir("SCRIPTS:/" .. dashx.config.preferences .. "/dashboard/") - - dashboard._pendingInvalidates = {} - dashboard._lastInvalidateTime = 0 - dashboard._hg_cycles = 0 - dashboard.overlayMessage = nil - - firstWakeup = true - firstWakeupCustomTheme = true - wakeupScheduler = 0 - objectWakeupIndex = 1 - objectsThreadedWakeupCount = 0 - objectWakeupsPerCycle = nil - scheduledBoxIndices = {} - dashboard.boxRects = {} - dashboard.selectedBoxIndex = nil - - lcd.invalidate() - - return {value = 0} -end - -function dashboard.paint(widget) +function dashboard.wakeup(widget) + local visible = lcd.isVisible(widget) + local now = os.clock() - local isCompiledCheck = "@i18n(iscompiledcheck)@" - if isCompiledCheck ~= "true" then - dashboard.utils.screenError("i18n not compiled - download a release version", true, 0.6) + if not visible then + if (now - lastHiddenWakeAt) < hiddenWakeInterval then + return + end + lastHiddenWakeAt = now + dashx.runtime.wakeup() return end - if unsupportedResolution then + local runtimeState = dashx.runtime.wakeup() - local W, H = lcd.getWindowSize() - if H < (system.getVersion().lcdHeight / 5) or W < (system.getVersion().lcdWidth / 10) then - dashboard.utils.screenError("@i18n(widgets.dashboard.unsupported_resolution)@", true, 0.4) - else - dashboard.overlaymessage(0, 0, W, H, "@i18n(widgets.dashboard.unsupported_resolution)@") - end + local width, height = lcd.getWindowSize() + unsupportedResolution = not dashboard.utils.supportedResolution(width, height, supportedResolutions) + if unsupportedResolution then + lcd.invalidate(widget) return end - if firstWakeup then - local W, H = lcd.getWindowSize() - local loaderY = (isFullScreen and headerLayout.height) or 0 - dashboard.loader(0, loaderY, W, H - loaderY) - lcd.invalidate() - return + if runtimeState.model_changed or lcd.darkMode() ~= darkModeState then + darkModeState = lcd.darkMode() + reloadTheme() end - if os.clock() - lastModelPathCheckAt >= PATH_CHECK_INTERVAL then - local newModelPath = model.path() - if newModelPath ~= lastModelPath then - lastModelPath = newModelPath - lastModelPathCheckAt = os.clock() - - local W, H = lcd.getWindowSize() - local loaderY = (isFullScreen and headerLayout.height) or 0 - dashboard.loader(0, loaderY, W, H - loaderY) - lcd.invalidate() - return - end + if dashx.session and dashx.session.dashboardThemeReloadPending then + dashx.session.dashboardThemeReloadPending = false + reloadTheme() end - local state = dashboard.flightmode or "preflight" - local module = loadedStateModules[state] + ensureState() - if type(module) == "table" and module.layout and module.boxes then - dashboard.renderLayout(widget, module) - if type(module.paint) == "function" then module.paint(widget, module.layout, module.boxes) end - else - callStateFunc("paint", widget) + if dashboard.toolbarVisible and dashboard.toolbarLastActivityAt > 0 and (now - dashboard.toolbarLastActivityAt) >= TOOLBAR_TIMEOUT then + dashboard.closeToolbar() + lcd.invalidate(widget) end - if objectProfiler then _profReportIfDue() end + if wakeObjects() or runtimeState.flightmode_changed or (now - lastInvalidateAt) >= invalidateInterval then + forceFullRepaint = false + lastInvalidateAt = now + lcd.invalidate(widget) + end end -function dashboard.configure(widget) return callStateFunc("configure", widget) or widget end +function dashboard.event(widget, category, value, x, y) + if gestureConsumeUntilTouchEnd and category == EVT_TOUCH then + consumeTouchSequence(value) + if value == TOUCH_END then + gestureConsumeUntilTouchEnd = false + gestureActive = false + gestureTriggered = false + end + return true + end -function dashboard.read(widget) return callStateFunc("read", widget) end + if dashboard.toolbar and dashboard.toolbar.handleEvent and dashboard.toolbar.handleEvent(dashboard, widget, category, value, x, y) then + return true + end -function dashboard.write(widget) return callStateFunc("write", widget) end + if category == EVT_KEY and value == KEY_PAGE_LONG and lcd.hasFocus() then + dashboard.openToolbar() + lcd.invalidate(widget) + if system.killEvents then + system.killEvents(value) + if KEY_PAGE_UP and KEY_PAGE_UP ~= value then + system.killEvents(KEY_PAGE_UP) + end + end + return true + end -function dashboard.build(widget) return callStateFunc("build", widget) end + if category == EVT_TOUCH and (value == TOUCH_START or value == TOUCH_END) and x and y then + gestureActive = true + gestureStartX = x + gestureStartY = y + gestureTriggered = false + end -function dashboard.event(widget, category, value, x, y) + if category == EVT_TOUCH and value == TOUCH_MOVE then + if not gestureActive and x and y then + gestureActive = true + gestureStartX = x + gestureStartY = y + gestureTriggered = false + end - local state = dashboard.flightmode or "preflight" - local module = loadedStateModules[state] + if gestureActive and not gestureTriggered and x and y then + local dx = x - gestureStartX + local dy = y - gestureStartY + if math.abs(dx) <= GESTURE_MAX_DX then + if dy <= -GESTURE_MIN_DY then + gestureTriggered = true + gestureConsumeUntilTouchEnd = true + consumeTouchSequence(TOUCH_START) + dashboard.openToolbar() + lcd.invalidate(widget) + return true + elseif dy >= GESTURE_MIN_DY then + gestureTriggered = true + gestureConsumeUntilTouchEnd = true + consumeTouchSequence(TOUCH_START) + dashboard.closeToolbar() + lcd.invalidate(widget) + return true + end + end + end + end - if state == "postflight" and category == EVT_KEY and value == 131 then - dashx.widgets.dashboard.flightmode = "preflight" - dashboard.resetFlightModeAsk() + if dashboard.toolbarVisible then + return false end - if category == 1 and value == TOUCH_MOVE then - isSliding = true - isSlidingStart = os.clock() + local indices = {} + for index, rect in ipairs(dashboard.boxRects or {}) do + if rect and rect.box and rect.box.onpress then + indices[#indices + 1] = index + end end if category == EVT_KEY and lcd.hasFocus() then - local indices = getOnpressBoxIndices() local count = #indices - if count == 0 then return end + if count == 0 then + dashboard.selectedBoxIndex = nil + return false + end - local current = dashboard.selectedBoxIndex or 1 + local current = dashboard.selectedBoxIndex or indices[1] local pos = 1 - for i, idx in ipairs(indices) do - if idx == current then - pos = i + for index, rectIndex in ipairs(indices) do + if rectIndex == current then + pos = index break end end - if value == 4099 then + if value == ROTARY_LEFT then pos = pos - 1 - if pos < 1 then pos = count end + if pos < 1 then + pos = count + end dashboard.selectedBoxIndex = indices[pos] lcd.invalidate(widget) return true - elseif value == 4100 then + elseif value == KEY_ROTARY_RIGHT then pos = pos + 1 - if pos > count then pos = 1 end + if pos > count then + pos = 1 + end dashboard.selectedBoxIndex = indices[pos] lcd.invalidate(widget) return true - elseif value == 33 and category == EVT_KEY then - local inIndices = false - for i = 1, #indices do - if indices[i] == dashboard.selectedBoxIndex then - inIndices = true - break - end - end - if not inIndices then + elseif value == KEY_ENTER_BREAK then + local selectedIndex = dashboard.selectedBoxIndex + local rect = selectedIndex and dashboard.boxRects[selectedIndex] or nil + if not rect then dashboard.selectedBoxIndex = indices[1] lcd.invalidate(widget) return true - else - local idx = dashboard.selectedBoxIndex - local rect = dashboard.boxRects[idx] - if rect and rect.box.onpress then - rect.box.onpress(widget, rect.box, rect.x, rect.y, category, value) - system.killEvents(97) - return true - end - end - end - end - if value == 35 and dashboard.selectedBoxIndex then - dashboard.selectedBoxIndex = nil - lcd.invalidate(widget) - return true - end - - if category == 1 and value == 16641 and lcd.hasFocus() then - if x and y then - for i, rect in ipairs(dashboard.boxRects) do - if x >= rect.x and x < rect.x + rect.w and y >= rect.y and y < rect.y + rect.h then - if rect.box.onpress then - dashboard.selectedBoxIndex = i - lcd.invalidate(widget) - rect.box.onpress(widget, rect.box, x, y, category, value) - system.killEvents(16640) - return true - end - end - end - end - end - - if type(module) == "table" and type(module.event) == "function" then return module.event(widget, category, value, x, y) end - -end - -function dashboard.wakeup(widget) - - if dashx.session and dashx.session.mspBusy and not (dashx.session and dashx.session.isConnected) then return end - - local now = os.clock() - local visible = lcd.isVisible() - local admin = dashx.app and dashx.app.guiIsRunning - - if admin or not visible then - - return - elseif isSliding then - - if (now - isSlidingStart) > 1 then - isSliding = false - else - return - end - end - - objectProfiler = dashx.preferences and dashx.preferences.developer and dashx.preferences.developer.logobjprof - - local telemetry = tasks.telemetry - local W, H = lcd.getWindowSize() - - dashboard._lastWH = dashboard._lastWH or {w = nil, h = nil, supported = nil} - - if W ~= dashboard._lastWH.w or H ~= dashboard._lastWH.h then - local supported = dashboard.utils.supportedResolution(W, H, supportedResolutions) - if supported ~= dashboard._lastWH.supported then - unsupportedResolution = not supported - dashboard._lastWH.supported = supported - - lcd.invalidate(widget) - end - dashboard._lastWH.w, dashboard._lastWH.h = W, H - end - - if unsupportedResolution then return end - - if lcd.darkMode() ~= darkModeState then - darkModeState = lcd.darkMode() - dashboard.reload_themes(true) - end - - if firstWakeup then - firstWakeup = false - local theme = getThemeForState("preflight") - log("Initial loading of preflight theme: " .. theme, "info") - loadedStateModules.preflight = load_state_script(theme, "preflight") - dashboard.applySchedulerSettings() - end - - if statePreloadIndex <= #statePreloadQueue then - local state = statePreloadQueue[statePreloadIndex] - if not loadedStateModules[state] then - local theme = getThemeForState(state) - log("Preloading theme: " .. theme .. " for " .. state, "info") - loadedStateModules[state] = load_state_script(theme, state) - - local mod = loadedStateModules[state] - if mod and mod.boxes then - local boxes = type(mod.boxes) == "function" and mod.boxes() or mod.boxes - for _, box in ipairs(boxes or {}) do dashboard.loadObjectType(box) end - end - end - statePreloadIndex = statePreloadIndex + 1 - end - - if firstWakeupCustomTheme and dashx.session.mcu_id and dashx.session.modelPreferences and dashx.session.modelPreferences.dashboard then - - local modelPrefs = dashx.session.modelPreferences.dashboard - local currentPrefs = dashx.preferences.dashboard - - if (modelPrefs.theme_preflight and modelPrefs.theme_preflight ~= "nil" and modelPrefs.theme_preflight ~= currentPrefs.theme_preflight) or (modelPrefs.theme_inflight and modelPrefs.theme_inflight ~= "nil" and modelPrefs.theme_inflight ~= currentPrefs.theme_inflight) or - (modelPrefs.theme_postflight and modelPrefs.theme_postflight ~= "nil" and modelPrefs.theme_postflight ~= currentPrefs.theme_postflight) then - dashboard.reload_themes() - firstWakeupCustomTheme = false - end - end - - local currentFlightMode = dashx.flightmode.current or "preflight" - if lastFlightMode ~= currentFlightMode then - dashboard.flightmode = currentFlightMode - reload_state_only(currentFlightMode) - lastFlightMode = currentFlightMode - if dashboard._useSpreadSchedulingPaint then lcd.invalidate(widget) end - end - - local newMessage = dashboard.computeOverlayMessage() - if dashboard.overlayMessage ~= newMessage then - dashboard.overlayMessage = newMessage - dashboard._hg_cycles = newMessage and dashboard._hg_cycles_required or 0 - lcd.invalidate(widget) - end - - local state = dashboard.flightmode or "preflight" - local module = loadedStateModules[state] - - if module and type(module.wakeup) == "function" then - module.wakeup(widget) - else - callStateFunc("wakeup", widget) - end - - if #dashboard.boxRects > 0 then - - for _, idx in ipairs(scheduledBoxIndices) do - local rect = dashboard.boxRects[idx] - local obj = dashboard.objectsByType[rect.box.type] - if obj and obj.wakeup then - if objectProfiler then - local id = _profIdFromRect(rect) - local t0 = _profStart() - obj.wakeup(rect.box) - _profStop("wakeup", id, rect.box.type, t0) - else - obj.wakeup(rect.box) - end - end - end - - local needsFullInvalidate = dashboard._forceFullRepaint or dashboard.overlayMessage or objectsThreadedWakeupCount < 1 - local dirtyRects = {} - - if dashboard._useSpreadScheduling == false then - - for i, rect in ipairs(dashboard.boxRects) do - local obj = dashboard.objectsByType[rect.box.type] - if obj and obj.wakeup and not obj.scheduler then obj.wakeup(rect.box) end - if not needsFullInvalidate then - local dirtyFn = obj and obj.dirty - if dirtyFn and dirtyFn(rect.box) then table.insert(dirtyRects, {x = rect.x - 1, y = rect.y - 1, w = rect.w + 2, h = rect.h + 2}) end - end end - - else - - for i = 1, objectWakeupsPerCycle do - local idx = objectWakeupIndex - local rect = dashboard.boxRects[idx] - if rect then - local obj = dashboard.objectsByType[rect.box.type] - if obj and obj.wakeup and not obj.scheduler then obj.wakeup(rect.box) end - if not needsFullInvalidate then - local dirtyFn = obj and obj.dirty - if dirtyFn and dirtyFn(rect.box) then table.insert(dirtyRects, {x = rect.x - 1, y = rect.y - 1, w = rect.w + 2, h = rect.h + 2}) end - end + if rect.box and rect.box.onpress then + rect.box.onpress(widget, rect.box, rect.x, rect.y, category, value) + if system.killEvents and KEY_ENTER_FIRST then + system.killEvents(KEY_ENTER_FIRST) end - objectWakeupIndex = (#dashboard.boxRects > 0) and ((objectWakeupIndex % #dashboard.boxRects) + 1) or 1 - end - - end - - objectsThreadedWakeupCount = objectsThreadedWakeupCount + 1 - - if dashboard._useSpreadSchedulingPaint then - if needsFullInvalidate then - - _queueInvalidateRect(0, 0, W, H) - dashboard._forceFullRepaint = false - else - for _, r in ipairs(dirtyRects) do _queueInvalidateRect(r.x, r.y, r.w, r.h) end + return true end - else - _queueInvalidateRect(0, 0, W, H) end - - _flushInvalidatesRespectingBudget() end - if not lcd.hasFocus(widget) and dashboard.selectedBoxIndex ~= nil then - log("Removing focus from box " .. tostring(dashboard.selectedBoxIndex), "info") + if value == KEY_DOWN_BREAK and dashboard.selectedBoxIndex then dashboard.selectedBoxIndex = nil - if dashboard._useSpreadSchedulingPaint then lcd.invalidate(widget) end + lcd.invalidate(widget) + return true end - if not dashboard._useSpreadSchedulingPaint then lcd.invalidate() end -end - -function dashboard.listThemes() - local themes = {} - local num = 0 - - local function scanThemes(basePath, sourceType) - local folders = system.listFiles(basePath) - if not folders then return end - for _, folder in ipairs(folders) do - if folder ~= ".." and folder ~= "." and not folder:match("%.%a+$") then - local themeDir = basePath .. folder .. "/" - local initPath = themeDir .. "init.lua" - if utils.dir_exists(basePath, folder) then - local chunk, err = compile(initPath) - if chunk then - local ok, initTable = pcall(chunk) - if ok and initTable and type(initTable.name) == "string" then - if not initTable.developer or dashx.preferences.developer.devtools == true then - num = num + 1 - themes[num] = {name = initTable.name, configure = initTable.configure, folder = folder, idx = num, source = sourceType} - end - else - print("Error detail: " .. tostring(initTable)) - end + if category == EVT_TOUCH and value == TOUCH_END and lcd.hasFocus() and x and y then + for index, rect in ipairs(dashboard.boxRects or {}) do + if x >= rect.x and x < rect.x + rect.w and y >= rect.y and y < rect.y + rect.h then + if rect.box and rect.box.onpress then + dashboard.selectedBoxIndex = index + lcd.invalidate(widget) + rect.box.onpress(widget, rect.box, x, y, category, value) + if system.killEvents then + system.killEvents(TOUCH_START) end + return true end end end end - scanThemes(themesBasePath, "system") - local basePath = "SCRIPTS:/" .. preferences .. "/" - if utils.dir_exists(basePath, 'dashboard') then scanThemes(themesUserPath, "user") end - - return themes + return false end -function dashboard.getPreference(key) - if not dashx.session.modelPreferences or not dashboard.currentWidgetPath then return nil end - - if not dashx.app.guiIsRunning then - return dashx.ini.getvalue(dashx.session.modelPreferences, dashboard.currentWidgetPath, key) - else - return dashx.ini.getvalue(dashx.session.modelPreferences, dashx.app.dashboardEditingTheme, key) - end -end - -function dashboard.savePreference(key, value) - if not dashx.session.modelPreferences or not dashx.session.modelPreferencesFile or not dashboard.currentWidgetPath then return false end - if not dashx.app.guiIsRunning then - dashx.ini.setvalue(dashx.session.modelPreferences, dashboard.currentWidgetPath, key, value) - return dashx.ini.save_ini_file(dashx.session.modelPreferencesFile, dashx.session.modelPreferences) - else - dashx.ini.setvalue(dashx.session.modelPreferences, dashx.app.dashboardEditingTheme, key, value) - return dashx.ini.save_ini_file(dashx.session.modelPreferencesFile, dashx.session.modelPreferences) - end -end - -function dashboard.resetFlightModeAsk() - - local buttons = { - { - label = "@i18n(app.btn_ok)@", - action = function() - tasks.events.flightmode.reset() - lcd.invalidate() - return true - end - }, {label = "@i18n(app.btn_cancel)@", action = function() return true end} - } - - form.openDialog({width = nil, title = "@i18n(widgets.dashboard.reset_flight_ask_title)@", message = "@i18n(widgets.dashboard.reset_flight_ask_text)@", buttons = buttons, wakeup = function() end, paint = function() end, options = TEXT_LEFT}) - -end - -function dashboard.menu(widget) return {{"@i18n(widgets.dashboard.reset_flight)@", dashboard.resetFlightModeAsk}} end - -dashboard.renders = dashboard.renders or {} - -dashboard.title = false - -dashboard.isSliding = function() return isSliding end - return dashboard diff --git a/src/dashx/widgets/dashboard/lib/toolbar.lua b/src/dashx/widgets/dashboard/lib/toolbar.lua new file mode 100644 index 0000000..f1ca289 --- /dev/null +++ b/src/dashx/widgets/dashboard/lib/toolbar.lua @@ -0,0 +1,180 @@ +--[[ + Copyright (C) 2026 Rob Thomson + GPLv3 — https://www.gnu.org/licenses/gpl-3.0.en.html +]] -- + +local toolbar = {} + +local function getThemeColors() + if lcd.darkMode() then + return { + background = lcd.RGB(18, 22, 26), + panel = lcd.RGB(28, 34, 40), + accent = lcd.RGB(231, 116, 58), + text = lcd.RGB(245, 246, 247), + muted = lcd.RGB(160, 168, 176), + border = lcd.RGB(72, 82, 90) + } + end + + return { + background = lcd.RGB(245, 246, 248), + panel = lcd.RGB(255, 255, 255), + accent = lcd.RGB(215, 98, 38), + text = lcd.RGB(32, 38, 44), + muted = lcd.RGB(108, 116, 124), + border = lcd.RGB(196, 202, 208) + } +end + +local function getToolbarItems(dashboard) + if type(dashboard.toolbarItems) == "table" then + return dashboard.toolbarItems + end + + return { + { + name = "@i18n(widgets.dashboard.reset_flight)@", + subtitle = "", + onClick = function(state) + if type(state.resetFlightModeAsk) == "function" then + state.resetFlightModeAsk() + end + end + } + } +end + +local function getToolbarBounds() + local width, height = lcd.getWindowSize() + local barHeight = math.max(72, math.min(math.floor(height * 0.24), 118)) + return 0, height - barHeight, width, barHeight +end + +local function drawToolbar(dashboard) + if not dashboard.toolbarVisible then + dashboard._toolbarRects = {} + return + end + + local colors = getThemeColors() + local x, y, width, height = getToolbarBounds() + local items = getToolbarItems(dashboard) + local slotWidth = width / math.max(#items, 1) + local rects = {} + + lcd.color(colors.background) + lcd.drawFilledRectangle(x, y, width, height) + lcd.color(colors.accent) + lcd.drawFilledRectangle(x, y, width, 4) + + lcd.font(FONT_XS) + + for index, item in ipairs(items) do + local itemX = math.floor(x + ((index - 1) * slotWidth) + 10) + local itemW = math.floor(slotWidth - 20) + local itemY = y + 14 + local itemH = height - 24 + local selected = dashboard.selectedToolbarIndex == index + + rects[index] = {x = itemX, y = itemY, w = itemW, h = itemH, item = item} + + lcd.color(selected and colors.accent or colors.panel) + lcd.drawFilledRectangle(itemX, itemY, itemW, itemH) + lcd.color(colors.border) + lcd.drawRectangle(itemX, itemY, itemW, itemH, 2) + + lcd.color(selected and colors.panel or colors.text) + lcd.drawText(itemX + math.floor(itemW / 2), itemY + 14, item.name or "@i18n(widgets.dashboard.reset_flight)@", CENTERED) + + lcd.font(FONT_XXS) + lcd.color(selected and colors.panel or colors.muted) + lcd.drawText(itemX + math.floor(itemW / 2), itemY + itemH - 18, item.subtitle or "", CENTERED) + lcd.font(FONT_XS) + end + + dashboard._toolbarRects = rects +end + +function toolbar.draw(dashboard) + drawToolbar(dashboard) +end + +function toolbar.handleEvent(dashboard, widget, category, value, x, y) + if not dashboard.toolbarVisible then + return false + end + + local rects = dashboard._toolbarRects or {} + local count = #rects + if count == 0 then + return false + end + + if category == EVT_KEY and lcd.hasFocus() then + if dashboard.touchToolbar then + dashboard.touchToolbar() + end + local selected = dashboard.selectedToolbarIndex or 1 + + if value == ROTARY_LEFT then + selected = selected - 1 + if selected < 1 then + selected = count + end + dashboard.selectedToolbarIndex = selected + lcd.invalidate(widget) + return true + end + + if value == KEY_ROTARY_RIGHT then + selected = selected + 1 + if selected > count then + selected = 1 + end + dashboard.selectedToolbarIndex = selected + lcd.invalidate(widget) + return true + end + + if value == KEY_ENTER_BREAK then + local rect = rects[selected] + if rect and rect.item and type(rect.item.onClick) == "function" then + rect.item.onClick(dashboard) + lcd.invalidate(widget) + return true + end + end + + if value == KEY_DOWN_BREAK or value == KEY_RTN_BREAK then + if dashboard.closeToolbar then + dashboard.closeToolbar() + else + dashboard.toolbarVisible = false + dashboard.selectedToolbarIndex = nil + end + lcd.invalidate(widget) + return true + end + end + + if category == EVT_TOUCH and (value == TOUCH_END or value == TOUCH_START) and x and y then + if dashboard.touchToolbar then + dashboard.touchToolbar() + end + for index, rect in ipairs(rects) do + if x >= rect.x and x < (rect.x + rect.w) and y >= rect.y and y < (rect.y + rect.h) then + dashboard.selectedToolbarIndex = index + if rect.item and type(rect.item.onClick) == "function" then + rect.item.onClick(dashboard) + end + lcd.invalidate(widget) + return true + end + end + end + + return false +end + +return toolbar From 941456d5191cfb696d031b903548e7d1dd11fc84 Mon Sep 17 00:00:00 2001 From: Rob Thomson Date: Tue, 7 Apr 2026 17:13:31 +0100 Subject: [PATCH 2/3] wip --- src/dashx/lib/sensors.lua | 14 +++++++------ src/dashx/tools/logs.lua | 44 ++++++++++++++++++++++++++------------- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/dashx/lib/sensors.lua b/src/dashx/lib/sensors.lua index dd792ae..206b27c 100644 --- a/src/dashx/lib/sensors.lua +++ b/src/dashx/lib/sensors.lua @@ -243,16 +243,16 @@ local function calculateFuel() return smartfuel.calculate() end -local function calculateConsumption() +local function calculateConsumption(fuelPercent) if shouldUseVoltageFuel() then local capacity = (dashx.session.batteryConfig and dashx.session.batteryConfig.batteryCapacity) or 1000 - local smartfuelPercent = telemetry() and telemetry().getSensor and telemetry().getSensor("smartfuel") or nil local warningPercentage = (dashx.session.batteryConfig and dashx.session.batteryConfig.consumptionWarningPercentage) or 30 - if smartfuelPercent then + if fuelPercent ~= nil then local usableCapacity = capacity * (1 - warningPercentage / 100) - local usedPercent = 100 - smartfuelPercent + local usedPercent = 100 - fuelPercent return (usedPercent / 100) * usableCapacity end + return nil end return telemetry() and telemetry().getSensor and telemetry().getSensor("consumption") or 0 @@ -261,12 +261,14 @@ end local function updateDerivedSensors(rootSource) local armed = deriveArmedValue() local inflight = deriveInflightValue() + local fuel = calculateFuel() + local consumption = calculateConsumption(fuel) dashx.session.isArmed = armed == 0 setSensorValue(derivedDefinitions.armed, armed, rootSource) setSensorValue(derivedDefinitions.inflight, inflight, rootSource) - setSensorValue(derivedDefinitions.smartfuel, calculateFuel(), rootSource) - setSensorValue(derivedDefinitions.smartconsumption, calculateConsumption(), rootSource) + setSensorValue(derivedDefinitions.smartfuel, fuel, rootSource) + setSensorValue(derivedDefinitions.smartconsumption, consumption, rootSource) end function sensors.reset() diff --git a/src/dashx/tools/logs.lua b/src/dashx/tools/logs.lua index f73c142..70535b4 100644 --- a/src/dashx/tools/logs.lua +++ b/src/dashx/tools/logs.lua @@ -351,7 +351,7 @@ local function getHeaderMetrics() } end -local function loadIconAsset(path) +local function loadIconAsset(path, preferBitmap) if not lcd or not path then return nil end @@ -362,17 +362,33 @@ local function loadIconAsset(path) } for _, candidate in ipairs(candidates) do - if lcd.loadMask then - local ok, loaded = pcall(lcd.loadMask, candidate) - if ok and loaded then - return loaded + if preferBitmap then + if lcd.loadMask then + local ok, loaded = pcall(lcd.loadMask, candidate) + if ok and loaded then + return loaded + end end - end - if lcd.loadBitmap then - local ok, loaded = pcall(lcd.loadBitmap, candidate) - if ok and loaded then - return loaded + if lcd.loadBitmap then + local ok, loaded = pcall(lcd.loadBitmap, candidate) + if ok and loaded then + return loaded + end + end + else + if lcd.loadMask then + local ok, loaded = pcall(lcd.loadMask, candidate) + if ok and loaded then + return loaded + end + end + + if lcd.loadBitmap then + local ok, loaded = pcall(lcd.loadBitmap, candidate) + if ok and loaded then + return loaded + end end end end @@ -382,14 +398,14 @@ end local function ensureIcons() if tool.icons.folder == nil then - tool.icons.folder = loadIconAsset("widgets/dashboard/gfx/folder.png") - or loadIconAsset("app/modules/logs/gfx/folder.png") + tool.icons.folder = loadIconAsset("app/modules/logs/gfx/folder.png", true) + or loadIconAsset("widgets/dashboard/gfx/folder.png") or false end if tool.icons.logs == nil then - tool.icons.logs = loadIconAsset("widgets/dashboard/gfx/logs.png") - or loadIconAsset("app/modules/logs/gfx/logs.png") + tool.icons.logs = loadIconAsset("app/modules/logs/gfx/logs.png", true) + or loadIconAsset("widgets/dashboard/gfx/logs.png") or false end end From 4088e32d1f08a20b495db6e851bf45816c2081bd Mon Sep 17 00:00:00 2001 From: Rob Thomson Date: Tue, 7 Apr 2026 17:19:37 +0100 Subject: [PATCH 3/3] workflow --- .../build_locale_package.cpython-312.pyc | Bin 0 -> 6989 bytes .github/scripts/build_locale_package.py | 136 + .github/workflows/pr.yml | 66 +- .github/workflows/push.yml | 64 +- .github/workflows/release.yml | 72 +- .github/workflows/snapshot.yml | 63 +- .github/workflows/testing.yml | 68 +- scripts/dashx/i18n/en.json | 5495 +++++++++++++++++ 8 files changed, 5731 insertions(+), 233 deletions(-) create mode 100644 .github/scripts/__pycache__/build_locale_package.cpython-312.pyc create mode 100644 .github/scripts/build_locale_package.py create mode 100644 scripts/dashx/i18n/en.json diff --git a/.github/scripts/__pycache__/build_locale_package.cpython-312.pyc b/.github/scripts/__pycache__/build_locale_package.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e10842895919d1f29b61cc9e93f8045e689b1d4f GIT binary patch literal 6989 zcmb7IYit|Wm7XDI$RUSMn=+}FJ(gt4v_(0J9mjV4k}Wy5qfm})r?vdxQk;=QnIhRc zLrdaP3DX9MjCNrK?ZOP)AZoEd8CXD7>;lb?Md21GkYX2DPDDf8u@;*}y+86#S$omK z`)AL+!y##}lXfq``~)Qf8DPDLcxN)G}&;)SR$RbE6#T!z66e_EGyZKg!dTiIOP`!c<#ht)3UzX2^`XCAVnF@)RXnaXjjQGDpfb65C0w7ixJ@c97Uf zYB$x|x=5{?)NU5Hh@SVDQJ?4+y^uDE0dW(gTf}B@Go*g8MQjy)@3Er+sab5gv5TUR z6ZX;q|A4V#V^`m!=S;!2FY%Itw8lr1$&?aR;;Ez@hB9+Ls!Rq=nq&0r`N+WF>7o9M zg9Dm%G@d>kPe@;ypqFNrQA}gSs608VnaAQujfwXiOlEy!nRr4B$?@bwLJCdEsbp_@ zE^F<*DaTTxq*>({il-Gh>qC;9O5BtpxK~7pPRO{OTTW$?VrW82N+_zNkY*zl>1gam z49{u=D`dSo6%mh=BT^WN3|7vkA_u(EIc1_ zX0p!ej5IYz1-WpR%_!rcgK!y~l!T*$^UU_{x+x(UF6pYg`zZXc{`HmX^VfDCmowwz z@!4u+NASw^j%&Mjbp&ILCxX|z8K2mB$o%a$oS1Q5cd1+S-LE^>+MZy4<_lEN9R3nd zhVZ?>lGzEA%A`Sn*y*Seo7Ak;b<22Jz$O?KTP-xR55%AR6QUecPIslddN{&t<9Tqrl}e*jc*iXC@i_H-b18G z53^DQAAlBm-v#LA%pi!2A-Xh`GymCfJq@EU7^ouqk-0v?9F?PW1zuezqhR4CVTv`w@28a-pl#msScHxvjG^2pQB9P_~BO$w8fEf29eRM<#QnFXLa3h{h!(hD{8_kIE z)J4FlQ;Z`yBF)BSC6c<4bsM9fN~PnHC_H6^&@ll-)pcR`Qh4Be|Ebp_Bg4ZNHA`HM zKv#`Tqc{M@B2Q+NctYc1sq~zJBni4OiD+^{bIPFErARcLChhI8W)zc1-eD|~lsHj> zHQ?nC0SEc63b+f88L3$Tv>%SV1OD=lAj(tg-fc@=s&{ApT!n91=POOy7wlg&cHo>P&r2)56-(*Bw@a??l)1|l z&h>@6tuUjycNTrBJCr}O&e-1{es_5B+!FUG^MEt{>L`xl5%htNs z0vz2&k9t~zqf5cck^~Vf<#YCpo&p>5d=M?7W!ZMGX5VsP2LW!zKFtc&WF$;&F>>`3 zfMsszi>-}4jPRxqwzLRuG|Tp4rjfOTLaAh8?&2p{mrqKu8=5&bBW8C=vuP=&fJ9gE zFb3#KNWx51CTNR+3Wu+U1+W3ubQEDl3udzNibDZrv7>=C0barT9a742LRK|fcXY(vHV^8tXptI26W%$eZCIL78 z)eXzr8lrvayA7w5{=-D&JY=cdWa%l{(IqUcXX0ceNy3TXkJ8 zao4|KHmxxMl?g2QR+thKC^N5Ad>ut*>1OfL(sk9hFaHL{*U-B|CGYm7qh+QKY!9A-n=+s?O_j^ov-WSTjEc z&IYJ!psnV?I88bAaT;S|g);p^--|Jq!#!(gtM|>(#td~_{O|xn*|7Hmzk2N))k*bH zGCgCWsml}%zd35we3_b|gKSu%XTHP;0-@()sp&Kja?C9?r%^~qH>HF|?+)5D3ju?y zSu#m5NH-AHJqT-R(ETX{*wI)tkw}f9lems0JV)b^lmMRsIKGk!G8&hSrwP^3Y}htL zP%5Qp)+mZiLKBU_B%CzMZ?3 z`Fk8~w;uLnv-KIijlEYQrT0OGfc9W|Ou|%E4;p2hKI5m*AYV0tQ*R}jMes4e^QaMw zuW4t6cHCdk&Iax5e?dE5bcoJn*E93hs8D@!x zE@R{=kCCgV%bo@q_bQu=FMuPTk*}x3vYK9GTkw}D?5;l-j1^++ikomN;^A#3IaaB? z2*7+Vcmb%LgC%E~+V^%h31eiJ#ie$EYd<*T;ZAThrYW7Llpn%5qCx>J{+r_3$-2 zX*{b#wYIF-iq8W295(9esj+vsk)uX!4(_?3zK)Q#oTZU%fi7?Fq?Fx8zb*>xnzuFF zl0Rqr{~_rz*p7MI+qOBcp3cxShPBul&h9;lA9jSOkd{!W%A*G0_2)ap1p$^XjHA@F z0Ix!O$)gas7d()9^4tSv1#>g3_Ah343=ub9!0$oWsj0gAJ%ZFb(F?8$c3?yatP+Q$ z?`Gl%j0-apvpe6?A3_XQ9EOP}r-0)@0(tCN_kw4gwTD7cq{PRgF(v!* za7IaI6alOW%&AQF2!?&as{kh(A}~pK`sf(EO=T1*>j;JP_bg(pSd!1~8GuE_;B{(F z$jCq{vEPOH8urwxn}N+G6-*;BqZFm_XeObc>u^=s?!l3f;gQ4Gdjn5M0MAGOf>#|5 zzcmnF4yY)+!eQP>-X`jF7ko6rEUyU{uxBL-2m?$f!Pe@~_^1s!jMt40;EB1~p?HiT zPU*Z?K-eS&w`p7yO#uCsCC$~3CNlWJ;XEz^r;UnY1iq3w%}yHOvP@jVkyC?_{`2rs zcysO`mK$?v^K={E*=g1Z` zjlD~RwF4ovnr(14CZ+MaB|3sDF^bS}9G!qja}p{(GI(Kl=&ivK;yaS35%SnVC`Hh& zF|d!DnuY8Mk&=wQjeFa55>A$jRgUH~7O2x0u13#!9S;$F>Y7=ajmg;jV?M42{jG+< zH-Ki~%LVw$ESx$Jac9$-vrBb$mHNiY&RG6*g|V+OewFbTxZ?iOfomnkUuLe?Yx$yB zIus?fWA$1`Q7IixlG;?g*0o|S9lJql6BXX8`^SZgt9(a(;1S=v#tSMhl=%+ul1rXf z|It-?^|g}g%s=FnQ;fEqZk z8aTM%{KD#4v-(x5zie$@=Ulg)x15XdGABIJo$Viom)iPI zqpg~?|6q8-X$7aoQ*~;Zc-Hcyg<>}^?!R;BgF}VsGSjopG#5g}-D=DJ74cVzUna`T zsddJ+Fkgt5nO(5JlB2C~tuS1&_mr7Xg?Iev$sWql1Z)#l2Cop-f_X8skovn*KR@@= za}T(^>u%5gY_Rb0sf^z&e|L1tnI7u19^2V=^JlMYJsU9pKEOg=<029AP#6hXkN^z{ zR&)VA0Y%M%Bqf93`8}LUO3(%ai%37hGOM?q!?`R(&wM}q?01Yo`2JR{7K7mV{&W~z z)D(QkdJ^zzbOjvVYeG)Ib0a*9W(EZuY}M?M$an_UB}F0#6IL{aBTSqKly$Tc4FL|f zGMR{v={mp)+L@?5%z%lTfe!%Jz(8?ahfOO{Cukh!`f-%R5w?Unx-kPF`hh^4AP$d` zj84NSn*G9@tVq*?vvK7Uinx~qp77Q@of0z%=@|Mx^v7ID{xw7!W}2q|lj`^b<$g%n z9#YPSl=mTJ|2Hb|?^N(B3&lAW&V2vvHEX+SZC|x+TeEhn*6t#$T6gD}3ZRGmpl!(t z=*hdl;aHSzey)iaoPR=bsiKGoZ|^1A9h zo_9UA@buBgZBF{&W5Gu^Kkl{C{qz%GAMM None: + text = main_lua_path.read_text(encoding="utf-8") + updated_text, replacements = re.subn( + r'(version\s*=\s*\{[^}]*?suffix\s*=\s*")[^"]*(")', + lambda match: f'{match.group(1)}{version}{match.group(2)}', + text, + count=1, + flags=re.DOTALL, + ) + if replacements != 1: + raise RuntimeError(f"Could not update version suffix in {main_lua_path}") + main_lua_path.write_text(updated_text, encoding="utf-8") + + +def copy_soundpack(lang: str, stage_app_dir: Path) -> None: + source_dir = SOUNDPACK_ROOT / lang + if not source_dir.is_dir(): + fallback_dir = SOUNDPACK_ROOT / "en" + print(f"[AUDIO] {source_dir} not found; falling back to {fallback_dir}") + source_dir = fallback_dir + + if not source_dir.is_dir(): + print(f"[AUDIO] No sound pack found for {lang} or fallback locale en. Skipping.") + return + + dest_dir = stage_app_dir / "audio" / lang + shutil.copytree(source_dir, dest_dir, dirs_exist_ok=True) + print(f"[AUDIO] Copied {source_dir} -> {dest_dir}") + + +def build_locale_json(lang: str, stage_i18n_dir: Path) -> Path: + generated_locale = ROOT / "scripts" / "dashx" / "i18n" / f"{lang}.json" + + subprocess.run( + [ + sys.executable, + str(I18N_BUILDER), + "--only", + lang, + ], + check=True, + cwd=ROOT, + ) + + if not generated_locale.is_file(): + raise FileNotFoundError(f"expected locale bundle was not created: {generated_locale}") + + stage_i18n_dir.mkdir(parents=True, exist_ok=True) + staged_locale = stage_i18n_dir / f"{lang}.json" + shutil.copy2(generated_locale, staged_locale) + return staged_locale + + +def create_zip(zip_path: Path, lang_root: Path) -> None: + if zip_path.exists(): + zip_path.unlink() + + with ZipFile(zip_path, "w", compression=ZIP_DEFLATED, compresslevel=9) as archive: + for path in sorted(lang_root.rglob("*")): + if path.is_file(): + archive.write(path, path.relative_to(lang_root)) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Build a per-locale DashX package from src/dashx") + parser.add_argument("--lang", required=True, help="Locale code to package, e.g. en or de") + parser.add_argument("--version", required=True, help="Version suffix to inject into main.lua") + parser.add_argument("--artifact", required=True, help="Output zip path, relative to repo root or absolute") + parser.add_argument("--build-root", default="build", help="Directory used for staging package contents") + args = parser.parse_args(argv) + + if not SOURCE_APP_DIR.is_dir(): + print(f"ERROR: source app directory not found: {SOURCE_APP_DIR}", file=sys.stderr) + return 1 + + lang_root = (ROOT / args.build_root / args.lang).resolve() + stage_root = lang_root / "scripts" + stage_app_dir = stage_root / "dashx" + artifact_path = Path(args.artifact) + if not artifact_path.is_absolute(): + artifact_path = (ROOT / artifact_path).resolve() + + if lang_root.exists(): + shutil.rmtree(lang_root) + + shutil.copytree(SOURCE_APP_DIR, stage_app_dir) + print(f"[BUILD] Staged {SOURCE_APP_DIR} -> {stage_app_dir}") + + i18n_dir = stage_app_dir / "i18n" + try: + locale_json = build_locale_json(args.lang, i18n_dir) + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + subprocess.run( + [ + sys.executable, + str(I18N_RESOLVER), + "--json", + str(locale_json), + "--root", + str(stage_root), + ], + check=True, + cwd=ROOT, + ) + + copy_soundpack(args.lang, stage_app_dir) + update_version_suffix(stage_app_dir / "main.lua", args.version) + create_zip(artifact_path, lang_root) + print(f"[BUILD] Created {artifact_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ffcf837..a95a46e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -5,18 +5,13 @@ on: types: [opened, synchronize, reopened] jobs: - # Per-language PR builds (mirrors push.yml behavior) create-zip: name: Build PR ZIP (${{ matrix.lang }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: - # 👇 keep this in sync with push.yml (add/remove locales as needed) - lang: [en, de , es, fr, it, nl] - - env: - STAGE: build/${{ matrix.lang }}/scripts + lang: [en, de, es, fr, it, nl] steps: - name: Checkout code @@ -30,61 +25,20 @@ jobs: - name: Set build variables (PR version) run: | PR_NUMBER='${{ github.event.pull_request.number }}' - echo "GIT_VER=PR-${PR_NUMBER}" >> $GITHUB_ENV - - # Build merged i18n JSON only for this locale (into src/dashx/i18n/.json) - - name: Build merged i18n JSON (${{ matrix.lang }}) - run: | - python bin/i18n/build-single-json.py --only '${{ matrix.lang }}' - test -f "src/dashx/i18n/${{ matrix.lang }}.json" - - # Stage scripts/ into build//scripts - - name: Stage scripts tree - run: | - rm -rf "${{ env.STAGE }}" - mkdir -p "${{ env.STAGE }}" - cp -a src/. "${{ env.STAGE }}/" - - # Resolve @i18n(...)@ tags in the staged tree using the staged locale file - - name: Resolve i18n tags (locale=${{ matrix.lang }}) - run: | - python .vscode/scripts/resolve_i18n_tags.py \ - --json "${{ env.STAGE }}/dashx/i18n/${{ matrix.lang }}.json" \ - --root "${{ env.STAGE }}" - - # Copy sound pack for this locale into the staged tree (fallback to en) - - name: Copy sound pack (locale=${{ matrix.lang }}) - run: | - SRC="bin/sound-generator/soundpack/${{ matrix.lang }}" - if [ ! -d "$SRC" ]; then - echo "[AUDIO] $SRC not found; falling back to bin/sound-generator/soundpack/en" - SRC="bin/sound-generator/soundpack/en" - fi - if [ -d "$SRC" ]; then - DEST="${{ env.STAGE }}/dashx/audio/${{ matrix.lang }}" - rm -rf "$DEST" - mkdir -p "$DEST" - cp -a "$SRC/." "$DEST/" - echo "[AUDIO] Copied $SRC -> $DEST" - else - echo "[AUDIO] No sound pack found (lang='${{ matrix.lang }}' or 'en'). Skipping." - fi - - # Write PR version suffix into the *staged* main.lua - - name: Update version and config in staged main.lua - run: | - sed -E -i "s/(version[[:space:]]*=[[:space:]]*\{[^}]*suffix[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1${{ env.GIT_VER }}\2/" "${{ env.STAGE }}/dashx/main.lua" - grep 'config\.' "${{ env.STAGE }}/dashx/main.lua" || true + echo "GIT_VER=PR-${PR_NUMBER}" >> "$GITHUB_ENV" - # Zip the staged scripts (includes i18n + audio for this locale) - - name: Create dashx-${{ env.GIT_VER }}-${{ matrix.lang }}.zip + - name: Build package for locale ${{ matrix.lang }} run: | - ( cd "build/${{ matrix.lang }}" && zip -q -r -9 "dashx-${{ env.GIT_VER }}-${{ matrix.lang }}.zip" scripts ) - mv "build/${{ matrix.lang }}/dashx-${{ env.GIT_VER }}-${{ matrix.lang }}.zip" . + ART="dashx-${{ env.GIT_VER }}-${{ matrix.lang }}.zip" + python .github/scripts/build_locale_package.py \ + --lang "${{ matrix.lang }}" \ + --version "${{ env.GIT_VER }}" \ + --artifact "$ART" + echo "ARTIFACT=$ART" >> "$GITHUB_ENV" - name: Upload per-locale ZIP uses: actions/upload-artifact@v4 with: name: dashx-${{ env.GIT_VER }}-${{ matrix.lang }} - path: dashx-${{ env.GIT_VER }}-${{ matrix.lang }}.zip + path: ${{ env.ARTIFACT }} if-no-files-found: error diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index f2e68fa..b09eafc 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -3,8 +3,8 @@ name: Create dashx-lua-ethos ZIP on Push on: push: branches: - - 'master' - - 'RF-*' + - "master" + - "RF-*" jobs: create-zip: @@ -12,8 +12,7 @@ jobs: strategy: fail-fast: false matrix: - # ←—— add/remove locales here - lang: [en, de , es, fr, it, nl] + lang: [en, de, es, fr, it, nl] steps: - name: Checkout code @@ -29,58 +28,18 @@ jobs: shell: bash run: | SHORT_SHA="${GITHUB_SHA::7}" - echo "GIT_VER=commit-${SHORT_SHA}" >> $GITHUB_ENV - echo "sha7=${SHORT_SHA}" >> $GITHUB_OUTPUT + echo "GIT_VER=commit-${SHORT_SHA}" >> "$GITHUB_ENV" + echo "sha7=${SHORT_SHA}" >> "$GITHUB_OUTPUT" - # Build merged JSON for the specific locale (writes to src/dashx/i18n/.json) - - name: Build i18n for this locale - run: python bin/i18n/build-single-json.py --only ${{ matrix.lang }} - - # Stage a per-locale copy to avoid mutating the repo - - name: Stage files for this locale - shell: bash - run: | - STAGE="build/${{ matrix.lang }}/scripts" - mkdir -p "$STAGE" - rsync -a src/ "$STAGE"/ - echo "STAGE=$STAGE" >> $GITHUB_ENV - - # Update version in the staged copy - - name: Update version and config in main.lua (staged) - run: | - sed -E -i "s/(version[[:space:]]*=[[:space:]]*\{[^}]*suffix[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1${{ env.GIT_VER }}\2/" "${{ env.STAGE }}/dashx/main.lua" - grep 'config\.' "${{ env.STAGE }}/dashx/main.lua" || true - - # Resolve @i18n(...)@ tags in the staged tree using the chosen locale - - name: Resolve i18n tags (locale=${{ matrix.lang }}) - run: | - python .vscode/scripts/resolve_i18n_tags.py \ - --json "${{ env.STAGE }}/dashx/i18n/${{ matrix.lang }}.json" \ - --root "${{ env.STAGE }}" - - # Copy sound pack for this locale into the staged tree - - name: Copy sound pack (locale=${{ matrix.lang }}) - shell: bash - run: | - SRC="bin/sound-generator/soundpack/${{ matrix.lang }}" - if [ ! -d "$SRC" ]; then - echo "[AUDIO] $SRC not found; falling back to bin/sound-generator/soundpack/en" - SRC="bin/sound-generator/soundpack/en" - fi - DEST="${{ env.STAGE }}/dashx/audio/${{ matrix.lang }}" - rm -rf "$DEST" - mkdir -p "$DEST" - cp -a "$SRC/." "$DEST/" - echo "[AUDIO] Copied $SRC -> $DEST" - - # Zip only the staged scripts folder for this locale - - name: Create dashx--commit-.zip + - name: Build package for locale ${{ matrix.lang }} shell: bash run: | ART="dashx-${{ matrix.lang }}-${{ env.GIT_VER }}.zip" - (cd build/${{ matrix.lang }} && zip -q -r -9 "../$ART" scripts) - mv "build/${{ matrix.lang }}/../$ART" . - echo "ARTIFACT=$ART" >> $GITHUB_ENV + python .github/scripts/build_locale_package.py \ + --lang "${{ matrix.lang }}" \ + --version "${{ env.GIT_VER }}" \ + --artifact "$ART" + echo "ARTIFACT=$ART" >> "$GITHUB_ENV" - name: Upload per-locale artifact uses: actions/upload-artifact@v4 @@ -88,4 +47,3 @@ jobs: name: dashx-${{ matrix.lang }}-${{ env.GIT_VER }} path: ${{ env.ARTIFACT }} if-no-files-found: error - diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36c5671..db7b6a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Release on: push: tags: - - 'release/*' + - "release/*" permissions: contents: write @@ -15,7 +15,6 @@ jobs: strategy: fail-fast: false matrix: - # keep in sync with pr.yml / push.yml lang: [en, de, es, fr, it, nl] steps: @@ -25,7 +24,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: "3.11" - name: Set build variables (from tag) id: buildvars @@ -34,58 +33,23 @@ jobs: GIT_TAG="${GITHUB_REF_NAME}" GIT_VER="${GIT_TAG#release/}" if [[ $GIT_VER =~ ^[0-9]+\.[0-9]+\.[0-9]+-[A-Za-z0-9]+$ ]]; then - GH_TYPE='Release Candidate' + GH_TYPE="Release Candidate" else - GH_TYPE='Release' + GH_TYPE="Release" fi - echo "GIT_VER=$GIT_VER" >> $GITHUB_ENV - echo "GIT_TAG=$GIT_TAG" >> $GITHUB_ENV - echo "GH_TYPE=$GH_TYPE" >> $GITHUB_ENV + echo "GIT_VER=$GIT_VER" >> "$GITHUB_ENV" + echo "GIT_TAG=$GIT_TAG" >> "$GITHUB_ENV" + echo "GH_TYPE=$GH_TYPE" >> "$GITHUB_ENV" - - name: Build merged i18n JSON (${{ matrix.lang }}) - run: python bin/i18n/build-single-json.py --only '${{ matrix.lang }}' - - - name: Stage files for this locale - shell: bash - run: | - STAGE="build/${{ matrix.lang }}/scripts" - mkdir -p "$STAGE" - rsync -a src/ "$STAGE"/ - echo "STAGE=$STAGE" >> $GITHUB_ENV - - - name: Resolve i18n tags (locale=${{ matrix.lang }}) - run: | - python .vscode/scripts/resolve_i18n_tags.py \ - --json "${{ env.STAGE }}/dashx/i18n/${{ matrix.lang }}.json" \ - --root "${{ env.STAGE }}" - - - name: Copy sound pack (locale=${{ matrix.lang }}) - shell: bash - run: | - SRC="bin/sound-generator/soundpack/${{ matrix.lang }}" - if [ ! -d "$SRC" ]; then - echo "[AUDIO] $SRC not found; falling back to bin/sound-generator/soundpack/en" - SRC="bin/sound-generator/soundpack/en" - fi - DEST="${{ env.STAGE }}/dashx/audio/${{ matrix.lang }}" - rm -rf "$DEST" - mkdir -p "$DEST" - cp -a "$SRC/." "$DEST/" - echo "[AUDIO] Copied $SRC -> $DEST" - - - name: Update version and config in staged main.lua - shell: bash - run: | - sed -E -i 's/(version[[:space:]]*=[[:space:]]*\{[^}]*suffix[[:space:]]*=[[:space:]]*")[^"]*(")/\1'${{ env.GIT_VER }}'\2/' "${{ env.STAGE }}/dashx/main.lua" - grep 'config\.' "${{ env.STAGE }}/dashx/main.lua" || true - - - name: Create release ZIP (locale=${{ matrix.lang }}) + - name: Build package for locale ${{ matrix.lang }} shell: bash run: | ART="dashx-${{ env.GIT_VER }}-${{ matrix.lang }}.zip" - (cd "build/${{ matrix.lang }}" && zip -q -r -9 "../$ART" scripts) - mv "build/${{ matrix.lang }}/../$ART" . - echo "ARTIFACT=$ART" >> $GITHUB_ENV + python .github/scripts/build_locale_package.py \ + --lang "${{ matrix.lang }}" \ + --version "${{ env.GIT_VER }}" \ + --artifact "$ART" + echo "ARTIFACT=$ART" >> "$GITHUB_ENV" - name: Upload per-locale artifact uses: actions/upload-artifact@v4 @@ -109,13 +73,13 @@ jobs: GIT_TAG="${GITHUB_REF_NAME}" GIT_VER="${GIT_TAG#release/}" if [[ $GIT_VER =~ ^[0-9]+\.[0-9]+\.[0-9]+-[A-Za-z0-9]+$ ]]; then - GH_TYPE='Release Candidate' + GH_TYPE="Release Candidate" else - GH_TYPE='Release' + GH_TYPE="Release" fi - echo "GIT_VER=$GIT_VER" >> $GITHUB_ENV - echo "GIT_TAG=$GIT_TAG" >> $GITHUB_ENV - echo "GH_TYPE=$GH_TYPE" >> $GITHUB_ENV + echo "GIT_VER=$GIT_VER" >> "$GITHUB_ENV" + echo "GIT_TAG=$GIT_TAG" >> "$GITHUB_ENV" + echo "GH_TYPE=$GH_TYPE" >> "$GITHUB_ENV" - name: Download all artifacts uses: actions/download-artifact@v4 diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 433f8b6..68c8abe 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -3,20 +3,18 @@ name: Snapshot on: push: tags: - - 'snapshot/*' + - "snapshot/*" permissions: contents: write jobs: - build-zips: name: Build per-locale ZIPs runs-on: ubuntu-latest strategy: fail-fast: false matrix: - # keep in sync with pr.yml / push.yml lang: [en, de, es, fr, it, nl] steps: @@ -26,7 +24,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: "3.11" - name: Set build variables id: buildvars @@ -34,54 +32,19 @@ jobs: run: | GIT_TAG="${GITHUB_REF_NAME}" GIT_VER="${GIT_TAG#snapshot/}" - echo "GIT_VER=$GIT_VER" >> $GITHUB_ENV - echo "GIT_TAG=$GIT_TAG" >> $GITHUB_ENV - echo "GH_TYPE=Snapshot" >> $GITHUB_ENV - - - name: Build merged i18n JSON (${{ matrix.lang }}) - run: python bin/i18n/build-single-json.py --only '${{ matrix.lang }}' - - - name: Stage files for this locale - shell: bash - run: | - STAGE="build/${{ matrix.lang }}/scripts" - mkdir -p "$STAGE" - rsync -a src/ "$STAGE"/ - echo "STAGE=$STAGE" >> $GITHUB_ENV - - - name: Resolve i18n tags (locale=${{ matrix.lang }}) - run: | - python .vscode/scripts/resolve_i18n_tags.py \ - --json "${{ env.STAGE }}/dashx/i18n/${{ matrix.lang }}.json" \ - --root "${{ env.STAGE }}" - - - name: Copy sound pack (locale=${{ matrix.lang }}) - shell: bash - run: | - SRC="bin/sound-generator/soundpack/${{ matrix.lang }}" - if [ ! -d "$SRC" ]; then - echo "[AUDIO] $SRC not found; falling back to bin/sound-generator/soundpack/en" - SRC="bin/sound-generator/soundpack/en" - fi - DEST="${{ env.STAGE }}/dashx/audio/${{ matrix.lang }}" - rm -rf "$DEST" - mkdir -p "$DEST" - cp -a "$SRC/." "$DEST/" - echo "[AUDIO] Copied $SRC -> $DEST" - - - name: Update version and config in staged main.lua - shell: bash - run: | - sed -E -i 's/(version[[:space:]]*=[[:space:]]*\{[^}]*suffix[[:space:]]*=[[:space:]]*")[^"]*(")/\1'${{ env.GIT_VER }}'\2/' "${{ env.STAGE }}/dashx/main.lua" - grep 'config\.' "${{ env.STAGE }}/dashx/main.lua" || true + echo "GIT_VER=$GIT_VER" >> "$GITHUB_ENV" + echo "GIT_TAG=$GIT_TAG" >> "$GITHUB_ENV" + echo "GH_TYPE=Snapshot" >> "$GITHUB_ENV" - - name: Create snapshot ZIP (locale=${{ matrix.lang }}) + - name: Build package for locale ${{ matrix.lang }} shell: bash run: | ART="dashx-${{ env.GIT_VER }}-${{ matrix.lang }}.zip" - (cd "build/${{ matrix.lang }}" && zip -q -r -9 "../$ART" scripts) - mv "build/${{ matrix.lang }}/../$ART" . - echo "ARTIFACT=$ART" >> $GITHUB_ENV + python .github/scripts/build_locale_package.py \ + --lang "${{ matrix.lang }}" \ + --version "${{ env.GIT_VER }}" \ + --artifact "$ART" + echo "ARTIFACT=$ART" >> "$GITHUB_ENV" - name: Upload per-locale artifact uses: actions/upload-artifact@v4 @@ -104,8 +67,8 @@ jobs: run: | GIT_TAG="${GITHUB_REF_NAME}" GIT_VER="${GIT_TAG#snapshot/}" - echo "GIT_VER=$GIT_VER" >> $GITHUB_ENV - echo "GIT_TAG=$GIT_TAG" >> $GITHUB_ENV + echo "GIT_VER=$GIT_VER" >> "$GITHUB_ENV" + echo "GIT_TAG=$GIT_TAG" >> "$GITHUB_ENV" - name: Download all artifacts uses: actions/download-artifact@v4 diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index b1cb190..2d06eb8 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -3,28 +3,56 @@ name: Testing on: push: tags: - - 'testing/*' + - "testing/*" -jobs: - - build: +permissions: + contents: write +jobs: + build-zips: + name: Build testing ZIP (${{ matrix.lang }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + lang: [en, de, es, fr, it, nl] steps: - - uses: actions/checkout@v4 - - - name: Set build variables - run: | - echo "GIT_VER=${GITHUB_REF##*/}" >> ${GITHUB_ENV} - echo "GIT_TAG=${GITHUB_REF##refs/tags/}" >> ${GITHUB_ENV} - cat ${GITHUB_ENV} - - - name: Upload Artifacts - uses: actions/upload-artifact@v4 - with: - name: dashx-testing-${{ env.GIT_VER }} - path: scripts - - - name: Delete tag - run: git push origin :${GITHUB_REF} + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Set build variables + run: | + echo "GIT_VER=${GITHUB_REF##*/}" >> "$GITHUB_ENV" + echo "GIT_TAG=${GITHUB_REF##refs/tags/}" >> "$GITHUB_ENV" + cat "$GITHUB_ENV" + + - name: Build package for locale ${{ matrix.lang }} + run: | + ART="dashx-testing-${{ env.GIT_VER }}-${{ matrix.lang }}.zip" + python .github/scripts/build_locale_package.py \ + --lang "${{ matrix.lang }}" \ + --version "${{ env.GIT_VER }}" \ + --artifact "$ART" + echo "ARTIFACT=$ART" >> "$GITHUB_ENV" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: dashx-testing-${{ env.GIT_VER }}-${{ matrix.lang }} + path: ${{ env.ARTIFACT }} + if-no-files-found: error + + delete-tag: + name: Delete testing tag + runs-on: ubuntu-latest + needs: [build-zips] + steps: + - uses: actions/checkout@v4 + + - name: Delete tag + run: git push origin :${GITHUB_REF} diff --git a/scripts/dashx/i18n/en.json b/scripts/dashx/i18n/en.json new file mode 100644 index 0000000..ba5c7ae --- /dev/null +++ b/scripts/dashx/i18n/en.json @@ -0,0 +1,5495 @@ +{ + "api": { + "ACC_TRIM": { + "pitch": { + "english": "Use to trim if the heli drifts in one of the stabilized modes (angle, horizon, etc.).", + "needs_translation": "false", + "translation": "Use to trim if the heli drifts in one of the stabilized modes (angle, horizon, etc.)." + }, + "roll": { + "english": "Use to trim if the heli drifts in one of the stabilized modes (angle, horizon, etc.).", + "needs_translation": "false", + "translation": "Use to trim if the heli drifts in one of the stabilized modes (angle, horizon, etc.)." + } + }, + "BATTERY_CONFIG": { + "batteryCapacity": { + "english": "The milliamp hour capacity of your battery.", + "needs_translation": "false", + "translation": "The milliamp hour capacity of your battery." + }, + "batteryCellCount": { + "english": "The number of cells in your battery.", + "needs_translation": "false", + "translation": "The number of cells in your battery." + }, + "vbatfullcellvoltage": { + "english": "The nominal voltage of a fully charged cell.", + "needs_translation": "false", + "translation": "The nominal voltage of a fully charged cell." + }, + "vbatmaxcellvoltage": { + "english": "The maximum voltage per cell before the high voltage alarm is triggered.", + "needs_translation": "false", + "translation": "The maximum voltage per cell before the high voltage alarm is triggered." + }, + "vbatmincellvoltage": { + "english": "The minimum voltage per cell before the low voltage alarm is triggered.", + "needs_translation": "false", + "translation": "The minimum voltage per cell before the low voltage alarm is triggered." + }, + "vbatwarningcellvoltage": { + "english": "The voltage per cell at which the low voltage alarm will start to sound.", + "needs_translation": "false", + "translation": "The voltage per cell at which the low voltage alarm will start to sound." + } + }, + "ESC_PARAMETERS_FLYROTOR": { + "buzzer_volume": { + "english": "Buzzer volume", + "needs_translation": "false", + "translation": "Buzzer volume" + }, + "cell_count": { + "english": "Number of cells in the battery", + "needs_translation": "false", + "translation": "Number of cells in the battery" + }, + "current_gain": { + "english": "Gain value for the current sensor", + "needs_translation": "false", + "translation": "Gain value for the current sensor" + }, + "gov_d": { + "english": "Derivative value for the governor", + "needs_translation": "false", + "translation": "Derivative value for the governor" + }, + "gov_i": { + "english": "Integral value for the governor", + "needs_translation": "false", + "translation": "Integral value for the governor" + }, + "gov_p": { + "english": "Proportional value for the governor", + "needs_translation": "false", + "translation": "Proportional value for the governor" + }, + "low_voltage_protection": { + "english": "Voltage at which we cut power by 50%", + "needs_translation": "false", + "translation": "Voltage at which we cut power by 50%" + }, + "motor_erpm_max": { + "english": "Maximum RPM", + "needs_translation": "false", + "translation": "Maximum RPM" + }, + "response_speed": { + "english": "Response speed for the motor", + "needs_translation": "false", + "translation": "Response speed for the motor" + }, + "soft_start": { + "english": "Soft start value", + "needs_translation": "false", + "translation": "Soft start value" + }, + "starting_torque": { + "english": "Starting torque for the motor", + "needs_translation": "false", + "translation": "Starting torque for the motor" + }, + "tbl_alwayson": { + "english": "Always On", + "needs_translation": "false", + "translation": "Always On" + }, + "tbl_auto": { + "english": "Auto", + "needs_translation": "false", + "translation": "Auto" + }, + "tbl_automatic": { + "english": "Automatic", + "needs_translation": "false", + "translation": "Automatic" + }, + "tbl_ccw": { + "english": "CCW", + "needs_translation": "false", + "translation": "CCW" + }, + "tbl_cw": { + "english": "CW", + "needs_translation": "false", + "translation": "CW" + }, + "tbl_disabled": { + "english": "Disabled", + "needs_translation": "false", + "translation": "Disabled" + }, + "tbl_enabled": { + "english": "Enabled", + "needs_translation": "false", + "translation": "Enabled" + }, + "tbl_escgov": { + "english": "Esc Governor", + "needs_translation": "false", + "translation": "Esc Governor" + }, + "tbl_extgov": { + "english": "External Governor", + "needs_translation": "false", + "translation": "External Governor" + }, + "temperature_protection": { + "english": "Temperature at which we cut power by 50%", + "needs_translation": "false", + "translation": "Temperature at which we cut power by 50%" + }, + "throttle_max": { + "english": "Maximum throttle value", + "needs_translation": "false", + "translation": "Maximum throttle value" + }, + "throttle_min": { + "english": "Minimum throttle value", + "needs_translation": "false", + "translation": "Minimum throttle value" + }, + "timing_angle": { + "english": "Timing angle for the motor", + "needs_translation": "false", + "translation": "Timing angle for the motor" + } + }, + "ESC_PARAMETERS_HW5": { + "tbl_autocalculate": { + "english": "Auto Calculate", + "needs_translation": "false", + "translation": "Auto Calculate" + }, + "tbl_ccw": { + "english": "CCW", + "needs_translation": "false", + "translation": "CCW" + }, + "tbl_cw": { + "english": "CW", + "needs_translation": "false", + "translation": "CW" + }, + "tbl_disabled": { + "english": "Disabled", + "needs_translation": "false", + "translation": "Disabled" + }, + "tbl_enabled": { + "english": "Enabled", + "needs_translation": "false", + "translation": "Enabled" + }, + "tbl_fixedwing": { + "english": "Fixed Wing", + "needs_translation": "false", + "translation": "Fixed Wing" + }, + "tbl_hardcutoff": { + "english": "Hard Cutoff", + "needs_translation": "false", + "translation": "Hard Cutoff" + }, + "tbl_heliext": { + "english": "Heli Ext Governor", + "needs_translation": "false", + "translation": "Heli Ext Governor" + }, + "tbl_heligov": { + "english": "Heli Governor", + "needs_translation": "false", + "translation": "Heli Governor" + }, + "tbl_helistore": { + "english": "Heli Governor Store", + "needs_translation": "false", + "translation": "Heli Governor Store" + }, + "tbl_normal": { + "english": "Normal", + "needs_translation": "false", + "translation": "Normal" + }, + "tbl_proportional": { + "english": "Proportional", + "needs_translation": "false", + "translation": "Proportional" + }, + "tbl_reverse": { + "english": "Reverse", + "needs_translation": "false", + "translation": "Reverse" + }, + "tbl_softcutoff": { + "english": "Soft Cutoff", + "needs_translation": "false", + "translation": "Soft Cutoff" + } + }, + "ESC_PARAMETERS_SCORPION": { + "tbl_airplane": { + "english": "Airplane mode", + "needs_translation": "false", + "translation": "Airplane mode" + }, + "tbl_boat": { + "english": "Boat mode", + "needs_translation": "false", + "translation": "Boat mode" + }, + "tbl_ccw": { + "english": "CCW", + "needs_translation": "false", + "translation": "CCW" + }, + "tbl_cw": { + "english": "CW", + "needs_translation": "false", + "translation": "CW" + }, + "tbl_exbus": { + "english": "Jeti Exbus", + "needs_translation": "false", + "translation": "Jeti Exbus" + }, + "tbl_extgov": { + "english": "External Governor", + "needs_translation": "false", + "translation": "External Governor" + }, + "tbl_futsbus": { + "english": "Futaba SBUS", + "needs_translation": "false", + "translation": "Futaba SBUS" + }, + "tbl_heligov": { + "english": "Heli Governor", + "needs_translation": "false", + "translation": "Heli Governor" + }, + "tbl_helistore": { + "english": "Heli Governor (stored)", + "needs_translation": "false", + "translation": "Heli Governor (stored)" + }, + "tbl_off": { + "english": "Off", + "needs_translation": "false", + "translation": "Off" + }, + "tbl_on": { + "english": "On", + "needs_translation": "false", + "translation": "On" + }, + "tbl_quad": { + "english": "Quad mode", + "needs_translation": "false", + "translation": "Quad mode" + }, + "tbl_standard": { + "english": "Standard", + "needs_translation": "false", + "translation": "Standard" + }, + "tbl_unsolicited": { + "english": "Unsolicited", + "needs_translation": "false", + "translation": "Unsolicited" + }, + "tbl_vbar": { + "english": "VBar", + "needs_translation": "false", + "translation": "VBar" + }, + "tbl_vbargov": { + "english": "VBar Governor", + "needs_translation": "false", + "translation": "VBar Governor" + } + }, + "ESC_PARAMETERS_XDFLY": { + "tbl_auto": { + "english": "Auto", + "needs_translation": "false", + "translation": "Auto" + }, + "tbl_blue": { + "english": "BLUE", + "needs_translation": "false", + "translation": "BLUE" + }, + "tbl_ccw": { + "english": "CCW", + "needs_translation": "false", + "translation": "CCW" + }, + "tbl_cw": { + "english": "CW", + "needs_translation": "false", + "translation": "CW" + }, + "tbl_cyan": { + "english": "CYAN", + "needs_translation": "false", + "translation": "CYAN" + }, + "tbl_escgov": { + "english": "ESC Governor", + "needs_translation": "false", + "translation": "ESC Governor" + }, + "tbl_extgov": { + "english": "External Governor", + "needs_translation": "false", + "translation": "External Governor" + }, + "tbl_fast": { + "english": "Fast", + "needs_translation": "false", + "translation": "Fast" + }, + "tbl_fmfw": { + "english": "Fixed Wing", + "needs_translation": "false", + "translation": "Fixed Wing" + }, + "tbl_fmheli": { + "english": "Helicopter", + "needs_translation": "false", + "translation": "Helicopter" + }, + "tbl_fwgov": { + "english": "Fixed Wing", + "needs_translation": "false", + "translation": "Fixed Wing" + }, + "tbl_green": { + "english": "GREEN", + "needs_translation": "false", + "translation": "GREEN" + }, + "tbl_high": { + "english": "High", + "needs_translation": "false", + "translation": "High" + }, + "tbl_jadegreen": { + "english": "JADE GREEN", + "needs_translation": "false", + "translation": "JADE GREEN" + }, + "tbl_low": { + "english": "Low", + "needs_translation": "false", + "translation": "Low" + }, + "tbl_medium": { + "english": "Medium", + "needs_translation": "false", + "translation": "Medium" + }, + "tbl_normal": { + "english": "Normal", + "needs_translation": "false", + "translation": "Normal" + }, + "tbl_off": { + "english": "Off", + "needs_translation": "false", + "translation": "Off" + }, + "tbl_on": { + "english": "On", + "needs_translation": "false", + "translation": "On" + }, + "tbl_orange": { + "english": "ORANGE", + "needs_translation": "false", + "translation": "ORANGE" + }, + "tbl_pink": { + "english": "PINK", + "needs_translation": "false", + "translation": "PINK" + }, + "tbl_purple": { + "english": "PURPLE", + "needs_translation": "false", + "translation": "PURPLE" + }, + "tbl_red": { + "english": "Red", + "needs_translation": "false", + "translation": "Red" + }, + "tbl_reverse": { + "english": "Reverse", + "needs_translation": "false", + "translation": "Reverse" + }, + "tbl_slow": { + "english": "Slow", + "needs_translation": "false", + "translation": "Slow" + }, + "tbl_vslow": { + "english": "Very Slow", + "needs_translation": "false", + "translation": "Very Slow" + }, + "tbl_white": { + "english": "WHITE", + "needs_translation": "false", + "translation": "WHITE" + }, + "tbl_yellow": { + "english": "YELLOW", + "needs_translation": "false", + "translation": "YELLOW" + } + }, + "ESC_PARAMETERS_YGE": { + "tbl_alwayson": { + "english": "Always On", + "needs_translation": "false", + "translation": "Always On" + }, + "tbl_auto": { + "english": "Auto", + "needs_translation": "false", + "translation": "Auto" + }, + "tbl_autoefficient": { + "english": "Auto Efficient", + "needs_translation": "false", + "translation": "Auto Efficient" + }, + "tbl_autoextreme": { + "english": "Auto Extreme", + "needs_translation": "false", + "translation": "Auto Extreme" + }, + "tbl_autonorm": { + "english": "Auto Normal", + "needs_translation": "false", + "translation": "Auto Normal" + }, + "tbl_autopower": { + "english": "Auto Power", + "needs_translation": "false", + "translation": "Auto Power" + }, + "tbl_custom": { + "english": "Custom (PC Defined)", + "needs_translation": "false", + "translation": "Custom (PC Defined)" + }, + "tbl_cutoff": { + "english": "Cutoff", + "needs_translation": "false", + "translation": "Cutoff" + }, + "tbl_fast": { + "english": "Fast", + "needs_translation": "false", + "translation": "Fast" + }, + "tbl_medium": { + "english": "Medium", + "needs_translation": "false", + "translation": "Medium" + }, + "tbl_modeair": { + "english": "Aero Motor", + "needs_translation": "false", + "translation": "Aero Motor" + }, + "tbl_modeext": { + "english": "Heli Ext Governor", + "needs_translation": "false", + "translation": "Heli Ext Governor" + }, + "tbl_modef3a": { + "english": "Aero F3A", + "needs_translation": "false", + "translation": "Aero F3A" + }, + "tbl_modefree": { + "english": "Free (Attention!)", + "needs_translation": "false", + "translation": "Free (Attention!)" + }, + "tbl_modeglider": { + "english": "Aero Glider", + "needs_translation": "false", + "translation": "Aero Glider" + }, + "tbl_modeheli": { + "english": "Heli Governor", + "needs_translation": "false", + "translation": "Heli Governor" + }, + "tbl_modestore": { + "english": "Heli Governor Store", + "needs_translation": "false", + "translation": "Heli Governor Store" + }, + "tbl_normal": { + "english": "Normal", + "needs_translation": "false", + "translation": "Normal" + }, + "tbl_off": { + "english": "Off", + "needs_translation": "false", + "translation": "Off" + }, + "tbl_on": { + "english": "On", + "needs_translation": "false", + "translation": "On" + }, + "tbl_reverse": { + "english": "Reverse", + "needs_translation": "false", + "translation": "Reverse" + }, + "tbl_slow": { + "english": "Slow", + "needs_translation": "false", + "translation": "Slow" + }, + "tbl_slowdown": { + "english": "Slowdown", + "needs_translation": "false", + "translation": "Slowdown" + }, + "tbl_smooth": { + "english": "Smooth", + "needs_translation": "false", + "translation": "Smooth" + }, + "tbl_unused": { + "english": "*Unused*", + "needs_translation": "false", + "translation": "*Unused*" + } + }, + "ESC_SENSOR_CONFIG": { + "consumption_correction": { + "english": "Adjust the consumption correction", + "needs_translation": "false", + "translation": "Adjust the consumption correction" + }, + "current_correction": { + "english": "Adjust current correction", + "needs_translation": "false", + "translation": "Adjust current correction" + }, + "current_offset": { + "english": "Current sensor offset adjustment", + "needs_translation": "false", + "translation": "Current sensor offset adjustment" + }, + "half_duplex": { + "english": "Half duplex mode for ESC telemetry", + "needs_translation": "false", + "translation": "Half duplex mode for ESC telemetry" + }, + "hw4_current_gain": { + "english": "Hobbywing v4 current gain adjustment", + "needs_translation": "false", + "translation": "Hobbywing v4 current gain adjustment" + }, + "hw4_current_offset": { + "english": "Hobbywing v4 current offset adjustment", + "needs_translation": "false", + "translation": "Hobbywing v4 current offset adjustment" + }, + "hw4_voltage_gain": { + "english": "Hobbywing v4 voltage gain adjustment", + "needs_translation": "false", + "translation": "Hobbywing v4 voltage gain adjustment" + }, + "pin_swap": { + "english": "Swap the TX and RX pins for the ESC telemetry", + "needs_translation": "false", + "translation": "Swap the TX and RX pins for the ESC telemetry" + }, + "tbl_off": { + "english": "Off", + "needs_translation": "false", + "translation": "Off" + }, + "tbl_on": { + "english": "On", + "needs_translation": "false", + "translation": "On" + }, + "update_hz": { + "english": "ESC telemetry update rate", + "needs_translation": "false", + "translation": "ESC telemetry update rate" + }, + "voltage_correction": { + "english": "Adjust the voltage correction", + "needs_translation": "false", + "translation": "Adjust the voltage correction" + } + }, + "FILTER_CONFIG": { + "dyn_notch_count": { + "english": "Number of notches to apply.", + "needs_translation": "false", + "translation": "Number of notches to apply." + }, + "dyn_notch_max_hz": { + "english": "Maximum frequency to which the notch is applied.", + "needs_translation": "false", + "translation": "Maximum frequency to which the notch is applied." + }, + "dyn_notch_min_hz": { + "english": "Minimum frequency to which the notch is applied.", + "needs_translation": "false", + "translation": "Minimum frequency to which the notch is applied." + }, + "dyn_notch_q": { + "english": "Quality factor of the notch filter.", + "needs_translation": "false", + "translation": "Quality factor of the notch filter." + }, + "gyro_lpf1_dyn_max_hz": { + "english": "Dynamic filter max cutoff in Hz.", + "needs_translation": "false", + "translation": "Dynamic filter max cutoff in Hz." + }, + "gyro_lpf1_dyn_min_hz": { + "english": "Dynamic filter min cutoff in Hz.", + "needs_translation": "false", + "translation": "Dynamic filter min cutoff in Hz." + }, + "gyro_lpf1_static_hz": { + "english": "Lowpass filter cutoff frequency in Hz.", + "needs_translation": "false", + "translation": "Lowpass filter cutoff frequency in Hz." + }, + "gyro_lpf2_static_hz": { + "english": "Lowpass filter cutoff frequency in Hz.", + "needs_translation": "false", + "translation": "Lowpass filter cutoff frequency in Hz." + }, + "gyro_soft_notch_cutoff_1": { + "english": "Width of the notch filter in Hz.", + "needs_translation": "false", + "translation": "Width of the notch filter in Hz." + }, + "gyro_soft_notch_cutoff_2": { + "english": "Width of the notch filter in Hz.", + "needs_translation": "false", + "translation": "Width of the notch filter in Hz." + }, + "gyro_soft_notch_hz_1": { + "english": "Center frequency to which the notch is applied.", + "needs_translation": "false", + "translation": "Center frequency to which the notch is applied." + }, + "gyro_soft_notch_hz_2": { + "english": "Center frequency to which the notch is applied.", + "needs_translation": "false", + "translation": "Center frequency to which the notch is applied." + }, + "rpm_min_hz": { + "english": "Minimum frequency for the RPM filter.", + "needs_translation": "false", + "translation": "Minimum frequency for the RPM filter." + }, + "tbl_1st": { + "english": "1ST", + "needs_translation": "false", + "translation": "1ST" + }, + "tbl_2nd": { + "english": "2ND", + "needs_translation": "false", + "translation": "2ND" + }, + "tbl_custom": { + "english": "CUSTOM", + "needs_translation": "false", + "translation": "CUSTOM" + }, + "tbl_high": { + "english": "HIGH", + "needs_translation": "false", + "translation": "HIGH" + }, + "tbl_low": { + "english": "LOW", + "needs_translation": "false", + "translation": "LOW" + }, + "tbl_medium": { + "english": "MEDIUM", + "needs_translation": "false", + "translation": "MEDIUM" + }, + "tbl_none": { + "english": "NONE", + "needs_translation": "false", + "translation": "NONE" + } + }, + "GOVERNOR_CONFIG": { + "gov_handover_throttle": { + "english": "Governor activates above this %. Below this the input throttle is passed to the ESC.", + "needs_translation": "false", + "translation": "Governor activates above this %. Below this the input throttle is passed to the ESC." + }, + "gov_recovery_time": { + "english": "Time constant for recovery spoolup, in seconds, measuring the time from zero to full headspeed.", + "needs_translation": "false", + "translation": "Time constant for recovery spoolup, in seconds, measuring the time from zero to full headspeed." + }, + "gov_spoolup_min_throttle": { + "english": "Minimum throttle to use for slow spoolup, in percent. For electric motors the default is 5%, for nitro this should be set so the clutch starts to engage for a smooth spoolup 10-15%.", + "needs_translation": "false", + "translation": "Minimum throttle to use for slow spoolup, in percent. For electric motors the default is 5%, for nitro this should be set so the clutch starts to engage for a smooth spoolup 10-15%." + }, + "gov_spoolup_time": { + "english": "Time constant for slow spoolup, in seconds, measuring the time from zero to full headspeed.", + "needs_translation": "false", + "translation": "Time constant for slow spoolup, in seconds, measuring the time from zero to full headspeed." + }, + "gov_startup_time": { + "english": "Time constant for slow startup, in seconds, measuring the time from zero to full headspeed.", + "needs_translation": "false", + "translation": "Time constant for slow startup, in seconds, measuring the time from zero to full headspeed." + }, + "gov_tracking_time": { + "english": "Time constant for headspeed changes, in seconds, measuring the time from zero to full headspeed.", + "needs_translation": "false", + "translation": "Time constant for headspeed changes, in seconds, measuring the time from zero to full headspeed." + }, + "tbl_govmode_mode1": { + "english": "MODE1", + "needs_translation": "false", + "translation": "MODE1" + }, + "tbl_govmode_mode2": { + "english": "MODE2", + "needs_translation": "false", + "translation": "MODE2" + }, + "tbl_govmode_off": { + "english": "OFF", + "needs_translation": "false", + "translation": "OFF" + }, + "tbl_govmode_passthrough": { + "english": "PASSTHROUGH", + "needs_translation": "false", + "translation": "PASSTHROUGH" + }, + "tbl_govmode_standard": { + "english": "STANDARD", + "needs_translation": "false", + "translation": "STANDARD" + } + }, + "GOVERNOR_PROFILE": { + "governor_collective_ff_weight": { + "english": "Collective precompensation weight - how much collective is mixed into the feedforward.", + "needs_translation": "false", + "translation": "Collective precompensation weight - how much collective is mixed into the feedforward." + }, + "governor_cyclic_ff_weight": { + "english": "Cyclic precompensation weight - how much cyclic is mixed into the feedforward.", + "needs_translation": "false", + "translation": "Cyclic precompensation weight - how much cyclic is mixed into the feedforward." + }, + "governor_d_gain": { + "english": "PID loop D-term gain.", + "needs_translation": "false", + "translation": "PID loop D-term gain." + }, + "governor_f_gain": { + "english": "Feedforward gain.", + "needs_translation": "false", + "translation": "Feedforward gain." + }, + "governor_gain": { + "english": "Master PID loop gain.", + "needs_translation": "false", + "translation": "Master PID loop gain." + }, + "governor_headspeed": { + "english": "Target headspeed for the current profile.", + "needs_translation": "false", + "translation": "Target headspeed for the current profile." + }, + "governor_i_gain": { + "english": "PID loop I-term gain.", + "needs_translation": "false", + "translation": "PID loop I-term gain." + }, + "governor_max_throttle": { + "english": "Maximum output throttle the governor is allowed to use.", + "needs_translation": "false", + "translation": "Maximum output throttle the governor is allowed to use." + }, + "governor_min_throttle": { + "english": "Minimum output throttle the governor is allowed to use.", + "needs_translation": "false", + "translation": "Minimum output throttle the governor is allowed to use." + }, + "governor_p_gain": { + "english": "PID loop P-term gain.", + "needs_translation": "false", + "translation": "PID loop P-term gain." + }, + "governor_tta_gain": { + "english": "TTA gain applied to increase headspeed to control the tail in the negative direction (e.g. motorised tail less than idle speed).", + "needs_translation": "false", + "translation": "TTA gain applied to increase headspeed to control the tail in the negative direction (e.g. motorised tail less than idle speed)." + }, + "governor_tta_limit": { + "english": "TTA max headspeed increase over full headspeed.", + "needs_translation": "false", + "translation": "TTA max headspeed increase over full headspeed." + }, + "governor_yaw_ff_weight": { + "english": "Yaw precompensation weight - how much yaw is mixed into the feedforward.", + "needs_translation": "false", + "translation": "Yaw precompensation weight - how much yaw is mixed into the feedforward." + } + }, + "MIXER_CONFIG": { + "collective_tilt_correction_neg": { + "english": "Adjust the collective tilt correction scaling for negative collective pitch.", + "needs_translation": "false", + "translation": "Adjust the collective tilt correction scaling for negative collective pitch." + }, + "collective_tilt_correction_pos": { + "english": "Adjust the collective tilt correction scaling for positive collective pitch.", + "needs_translation": "false", + "translation": "Adjust the collective tilt correction scaling for positive collective pitch." + }, + "swash_geo_correction": { + "english": "Adjust if there is too much negative collective or too much positive collective.", + "needs_translation": "false", + "translation": "Adjust if there is too much negative collective or too much positive collective." + }, + "swash_phase": { + "english": "Phase offset for the swashplate controls.", + "needs_translation": "false", + "translation": "Phase offset for the swashplate controls." + }, + "swash_pitch_limit": { + "english": "Maximum amount of combined cyclic and collective blade pitch.", + "needs_translation": "false", + "translation": "Maximum amount of combined cyclic and collective blade pitch." + }, + "swash_trim_0": { + "english": "Swash trim to level the swash plate when using fixed links.", + "needs_translation": "false", + "translation": "Swash trim to level the swash plate when using fixed links." + }, + "swash_trim_1": { + "english": "Swash trim to level the swash plate when using fixed links.", + "needs_translation": "false", + "translation": "Swash trim to level the swash plate when using fixed links." + }, + "swash_trim_2": { + "english": "Swash trim to level the swash plate when using fixed links.", + "needs_translation": "false", + "translation": "Swash trim to level the swash plate when using fixed links." + }, + "swash_tta_precomp": { + "english": "Mixer precomp for 0 yaw.", + "needs_translation": "false", + "translation": "Mixer precomp for 0 yaw." + }, + "tail_center_trim": { + "english": "Sets tail rotor trim for 0 yaw for variable pitch, or tail motor throttle for 0 yaw for motorized.", + "needs_translation": "false", + "translation": "Sets tail rotor trim for 0 yaw for variable pitch, or tail motor throttle for 0 yaw for motorized." + }, + "tail_motor_idle": { + "english": "Minimum throttle signal sent to the tail motor. This should be set just high enough that the motor does not stop.", + "needs_translation": "false", + "translation": "Minimum throttle signal sent to the tail motor. This should be set just high enough that the motor does not stop." + }, + "tbl_ccw": { + "english": "CCW", + "needs_translation": "false", + "translation": "CCW" + }, + "tbl_cw": { + "english": "CW", + "needs_translation": "false", + "translation": "CW" + } + }, + "MOTOR_CONFIG": { + "main_rotor_gear_ratio_0": { + "english": "Motor Pinion Gear Tooth Count", + "needs_translation": "false", + "translation": "Motor Pinion Gear Tooth Count" + }, + "main_rotor_gear_ratio_1": { + "english": "Main Gear Tooth Count", + "needs_translation": "false", + "translation": "Main Gear Tooth Count" + }, + "maxthrottle": { + "english": "This PWM value is sent to the ESC/Servo at full throttle", + "needs_translation": "false", + "translation": "This PWM value is sent to the ESC/Servo at full throttle" + }, + "mincommand": { + "english": "This PWM value is sent when the motor is stopped", + "needs_translation": "false", + "translation": "This PWM value is sent when the motor is stopped" + }, + "minthrottle": { + "english": "This PWM value is sent to the ESC/Servo at low throttle", + "needs_translation": "false", + "translation": "This PWM value is sent to the ESC/Servo at low throttle" + }, + "motor_pole_count_0": { + "english": "The number of magnets on the motor bell.", + "needs_translation": "false", + "translation": "The number of magnets on the motor bell." + }, + "motor_pwm_protocol": { + "english": "The protocol used to communicate with the ESC", + "needs_translation": "false", + "translation": "The protocol used to communicate with the ESC" + }, + "motor_pwm_rate": { + "english": "The frequency at which the ESC sends PWM signals to the motor", + "needs_translation": "false", + "translation": "The frequency at which the ESC sends PWM signals to the motor" + }, + "tail_rotor_gear_ratio_0": { + "english": "Tail Gear Tooth Count", + "needs_translation": "false", + "translation": "Tail Gear Tooth Count" + }, + "tail_rotor_gear_ratio_1": { + "english": "Autorotation Gear Tooth Count", + "needs_translation": "false", + "translation": "Autorotation Gear Tooth Count" + } + }, + "PID_PROFILE": { + "angle_level_limit": { + "english": "Limit the maximum angle the helicopter will pitch/roll to while in Angle mode.", + "needs_translation": "false", + "translation": "Limit the maximum angle the helicopter will pitch/roll to while in Angle mode." + }, + "angle_level_strength": { + "english": "Determines how aggressively the helicopter tilts back to level while in Angle Mode.", + "needs_translation": "false", + "translation": "Determines how aggressively the helicopter tilts back to level while in Angle Mode." + }, + "bterm_cutoff_0": { + "english": "B-term cutoff in Hz.", + "needs_translation": "false", + "translation": "B-term cutoff in Hz." + }, + "bterm_cutoff_1": { + "english": "B-term cutoff in Hz.", + "needs_translation": "false", + "translation": "B-term cutoff in Hz." + }, + "bterm_cutoff_2": { + "english": "B-term cutoff in Hz.", + "needs_translation": "false", + "translation": "B-term cutoff in Hz." + }, + "cyclic_cross_coupling_cutoff": { + "english": "Frequency limit for the compensation. Higher value will make the compensation action faster.", + "needs_translation": "false", + "translation": "Frequency limit for the compensation. Higher value will make the compensation action faster." + }, + "cyclic_cross_coupling_gain": { + "english": "Amount of compensation applied for pitch-to-roll decoupling.", + "needs_translation": "false", + "translation": "Amount of compensation applied for pitch-to-roll decoupling." + }, + "cyclic_cross_coupling_ratio": { + "english": "Amount of roll-to-pitch compensation needed, vs. pitch-to-roll.", + "needs_translation": "false", + "translation": "Amount of roll-to-pitch compensation needed, vs. pitch-to-roll." + }, + "dterm_cutoff_0": { + "english": "D-term cutoff in Hz.", + "needs_translation": "false", + "translation": "D-term cutoff in Hz." + }, + "dterm_cutoff_1": { + "english": "D-term cutoff in Hz.", + "needs_translation": "false", + "translation": "D-term cutoff in Hz." + }, + "dterm_cutoff_2": { + "english": "D-term cutoff in Hz.", + "needs_translation": "false", + "translation": "D-term cutoff in Hz." + }, + "error_decay_limit_cyclic": { + "english": "Maximum bleed-off speed for cyclic I-term.", + "needs_translation": "false", + "translation": "Maximum bleed-off speed for cyclic I-term." + }, + "error_decay_time_cyclic": { + "english": "Time constant for bleeding off cyclic I-term. Higher will stabilize hover, lower will drift.", + "needs_translation": "false", + "translation": "Time constant for bleeding off cyclic I-term. Higher will stabilize hover, lower will drift." + }, + "error_decay_time_ground": { + "english": "Bleeds off the current controller error when the craft is not airborne to stop the craft tipping over.", + "needs_translation": "false", + "translation": "Bleeds off the current controller error when the craft is not airborne to stop the craft tipping over." + }, + "error_limit_0": { + "english": "Hard limit for the angle error in the PID loop. The absolute error and thus the I-term will never go above these limits.", + "needs_translation": "false", + "translation": "Hard limit for the angle error in the PID loop. The absolute error and thus the I-term will never go above these limits." + }, + "error_limit_1": { + "english": "Hard limit for the angle error in the PID loop. The absolute error and thus the I-term will never go above these limits.", + "needs_translation": "false", + "translation": "Hard limit for the angle error in the PID loop. The absolute error and thus the I-term will never go above these limits." + }, + "error_limit_2": { + "english": "Hard limit for the angle error in the PID loop. The absolute error and thus the I-term will never go above these limits.", + "needs_translation": "false", + "translation": "Hard limit for the angle error in the PID loop. The absolute error and thus the I-term will never go above these limits." + }, + "error_rotation": { + "english": "Rotates the current roll and pitch error terms around yaw when the craft rotates. This is sometimes called Piro Compensation.", + "needs_translation": "false", + "translation": "Rotates the current roll and pitch error terms around yaw when the craft rotates. This is sometimes called Piro Compensation." + }, + "gyro_cutoff_0": { + "english": "PID loop overall bandwidth in Hz.", + "needs_translation": "false", + "translation": "PID loop overall bandwidth in Hz." + }, + "gyro_cutoff_1": { + "english": "PID loop overall bandwidth in Hz.", + "needs_translation": "false", + "translation": "PID loop overall bandwidth in Hz." + }, + "gyro_cutoff_2": { + "english": "PID loop overall bandwidth in Hz.", + "needs_translation": "false", + "translation": "PID loop overall bandwidth in Hz." + }, + "horizon_level_strength": { + "english": "Determines how aggressively the helicopter tilts back to level while in Horizon Mode.", + "needs_translation": "false", + "translation": "Determines how aggressively the helicopter tilts back to level while in Horizon Mode." + }, + "iterm_relax_cutoff_0": { + "english": "Helps reduce bounce back after fast stick movements. Can cause inconsistency in small stick movements if too low.", + "needs_translation": "false", + "translation": "Helps reduce bounce back after fast stick movements. Can cause inconsistency in small stick movements if too low." + }, + "iterm_relax_cutoff_1": { + "english": "Helps reduce bounce back after fast stick movements. Can cause inconsistency in small stick movements if too low.", + "needs_translation": "false", + "translation": "Helps reduce bounce back after fast stick movements. Can cause inconsistency in small stick movements if too low." + }, + "iterm_relax_cutoff_2": { + "english": "Helps reduce bounce back after fast stick movements. Can cause inconsistency in small stick movements if too low.", + "needs_translation": "false", + "translation": "Helps reduce bounce back after fast stick movements. Can cause inconsistency in small stick movements if too low." + }, + "iterm_relax_type": { + "english": "Choose the axes in which this is active. RP: Roll, Pitch. RPY: Roll, Pitch, Yaw.", + "needs_translation": "false", + "translation": "Choose the axes in which this is active. RP: Roll, Pitch. RPY: Roll, Pitch, Yaw." + }, + "offset_limit_0": { + "english": "Hard limit for the High Speed Integral offset angle in the PID loop. The O-term will never go over these limits.", + "needs_translation": "false", + "translation": "Hard limit for the High Speed Integral offset angle in the PID loop. The O-term will never go over these limits." + }, + "offset_limit_1": { + "english": "Hard limit for the High Speed Integral offset angle in the PID loop. The O-term will never go over these limits.", + "needs_translation": "false", + "translation": "Hard limit for the High Speed Integral offset angle in the PID loop. The O-term will never go over these limits." + }, + "pitch_collective_ff_gain": { + "english": "Increasing will compensate for the pitching up motion caused by tail drag when climbing.", + "needs_translation": "false", + "translation": "Increasing will compensate for the pitching up motion caused by tail drag when climbing." + }, + "tbl_off": { + "english": "OFF", + "needs_translation": "false", + "translation": "OFF" + }, + "tbl_on": { + "english": "ON", + "needs_translation": "false", + "translation": "ON" + }, + "tbl_rp": { + "english": "RP", + "needs_translation": "false", + "translation": "RP" + }, + "tbl_rpy": { + "english": "RPY", + "needs_translation": "false", + "translation": "RPY" + }, + "trainer_angle_limit": { + "english": "Limit the maximum angle the helicopter will pitch/roll to while in Acro Trainer Mode.", + "needs_translation": "false", + "translation": "Limit the maximum angle the helicopter will pitch/roll to while in Acro Trainer Mode." + }, + "trainer_gain": { + "english": "Determines how aggressively the helicopter tilts back to the maximum angle (if exceeded) while in Acro Trainer Mode.", + "needs_translation": "false", + "translation": "Determines how aggressively the helicopter tilts back to the maximum angle (if exceeded) while in Acro Trainer Mode." + }, + "yaw_ccw_stop_gain": { + "english": "Stop gain (PD) for counter-clockwise rotation.", + "needs_translation": "false", + "translation": "Stop gain (PD) for counter-clockwise rotation." + }, + "yaw_collective_dynamic_decay": { + "english": "Decay time for the extra yaw precomp on collective input.", + "needs_translation": "false", + "translation": "Decay time for the extra yaw precomp on collective input." + }, + "yaw_collective_dynamic_gain": { + "english": "An extra boost of yaw precomp on collective input.", + "needs_translation": "false", + "translation": "An extra boost of yaw precomp on collective input." + }, + "yaw_collective_ff_gain": { + "english": "Collective feedforward mixed into yaw (collective-to-yaw precomp).", + "needs_translation": "false", + "translation": "Collective feedforward mixed into yaw (collective-to-yaw precomp)." + }, + "yaw_cw_stop_gain": { + "english": "Stop gain (PD) for clockwise rotation.", + "needs_translation": "false", + "translation": "Stop gain (PD) for clockwise rotation." + }, + "yaw_cyclic_ff_gain": { + "english": "Cyclic feedforward mixed into yaw (cyclic-to-yaw precomp).", + "needs_translation": "false", + "translation": "Cyclic feedforward mixed into yaw (cyclic-to-yaw precomp)." + }, + "yaw_inertia_precomp_cutoff": { + "english": "Cutoff. Derivative cutoff frequency in 1/10Hz steps. Controls how sharp the precomp is. Higher value is sharper.", + "needs_translation": "false", + "translation": "Cutoff. Derivative cutoff frequency in 1/10Hz steps. Controls how sharp the precomp is. Higher value is sharper." + }, + "yaw_inertia_precomp_gain": { + "english": "Scalar gain. The strength of the main rotor inertia. Higher value means more precomp is applied to yaw control.", + "needs_translation": "false", + "translation": "Scalar gain. The strength of the main rotor inertia. Higher value means more precomp is applied to yaw control." + }, + "yaw_precomp_cutoff": { + "english": "Frequency limit for all yaw precompensation actions.", + "needs_translation": "false", + "translation": "Frequency limit for all yaw precompensation actions." + } + }, + "PID_TUNING": { + "pid_0_B": { + "english": "Additional boost on the feedforward to make the heli react more to quick stick movements.", + "needs_translation": "false", + "translation": "Additional boost on the feedforward to make the heli react more to quick stick movements." + }, + "pid_0_D": { + "english": "Strength of dampening to any motion on the system, including external influences. Also reduces overshoot.", + "needs_translation": "false", + "translation": "Strength of dampening to any motion on the system, including external influences. Also reduces overshoot." + }, + "pid_0_F": { + "english": "Helps push P-term based on stick input. Increasing will make response more sharp, but can cause overshoot.", + "needs_translation": "false", + "translation": "Helps push P-term based on stick input. Increasing will make response more sharp, but can cause overshoot." + }, + "pid_0_I": { + "english": "How tightly the system holds its position.", + "needs_translation": "false", + "translation": "How tightly the system holds its position." + }, + "pid_0_O": { + "english": "Used to prevent the craft from rolling when using high collective.", + "needs_translation": "false", + "translation": "Used to prevent the craft from rolling when using high collective." + }, + "pid_0_P": { + "english": "How tightly the system tracks the desired setpoint.", + "needs_translation": "false", + "translation": "How tightly the system tracks the desired setpoint." + }, + "pid_1_B": { + "english": "Additional boost on the feedforward to make the heli react more to quick stick movements.", + "needs_translation": "false", + "translation": "Additional boost on the feedforward to make the heli react more to quick stick movements." + }, + "pid_1_D": { + "english": "Strength of dampening to any motion on the system, including external influences. Also reduces overshoot.", + "needs_translation": "false", + "translation": "Strength of dampening to any motion on the system, including external influences. Also reduces overshoot." + }, + "pid_1_F": { + "english": "Helps push P-term based on stick input. Increasing will make response more sharp, but can cause overshoot.", + "needs_translation": "false", + "translation": "Helps push P-term based on stick input. Increasing will make response more sharp, but can cause overshoot." + }, + "pid_1_I": { + "english": "How tightly the system holds its position.", + "needs_translation": "false", + "translation": "How tightly the system holds its position." + }, + "pid_1_O": { + "english": "Used to prevent the craft from pitching when using high collective.", + "needs_translation": "false", + "translation": "Used to prevent the craft from pitching when using high collective." + }, + "pid_1_P": { + "english": "How tightly the system tracks the desired setpoint.", + "needs_translation": "false", + "translation": "How tightly the system tracks the desired setpoint." + }, + "pid_2_B": { + "english": "Additional boost on the feedforward to make the heli react more to quick stick movements.", + "needs_translation": "false", + "translation": "Additional boost on the feedforward to make the heli react more to quick stick movements." + }, + "pid_2_D": { + "english": "Strength of dampening to any motion on the system, including external influences. Also reduces overshoot.", + "needs_translation": "false", + "translation": "Strength of dampening to any motion on the system, including external influences. Also reduces overshoot." + }, + "pid_2_F": { + "english": "Helps push P-term based on stick input. Increasing will make response more sharp, but can cause overshoot.", + "needs_translation": "false", + "translation": "Helps push P-term based on stick input. Increasing will make response more sharp, but can cause overshoot." + }, + "pid_2_I": { + "english": "How tightly the system holds its position.", + "needs_translation": "false", + "translation": "How tightly the system holds its position." + }, + "pid_2_P": { + "english": "How tightly the system tracks the desired setpoint.", + "needs_translation": "false", + "translation": "How tightly the system tracks the desired setpoint." + } + }, + "PILOT_CONFIG": { + "model_flight_time": { + "english": "Set this to the expected flight time in seconds. The transmitter will beep when the flight time is reached.", + "needs_translation": "false", + "translation": "Set this to the expected flight time in seconds. The transmitter will beep when the flight time is reached." + } + }, + "RC_CONFIG": { + "rc_arm_throttle": { + "english": "Throttle must be at or below this value in microseconds (us) to allow arming. Must be at least 10us lower than minimum throttle.", + "needs_translation": "false", + "translation": "Throttle must be at or below this value in microseconds (us) to allow arming. Must be at least 10us lower than minimum throttle." + }, + "rc_center": { + "english": "Stick center in microseconds (us).", + "needs_translation": "false", + "translation": "Stick center in microseconds (us)." + }, + "rc_deadband": { + "english": "Deadband for cyclic control in microseconds (us).", + "needs_translation": "false", + "translation": "Deadband for cyclic control in microseconds (us)." + }, + "rc_deflection": { + "english": "Stick deflection from center in microseconds (us).", + "needs_translation": "false", + "translation": "Stick deflection from center in microseconds (us)." + }, + "rc_max_throttle": { + "english": "Maximum throttle (100% throttle output) expected from radio, in microseconds (us).", + "needs_translation": "false", + "translation": "Maximum throttle (100% throttle output) expected from radio, in microseconds (us)." + }, + "rc_min_throttle": { + "english": "Minimum throttle (0% throttle output) expected from radio, in microseconds (us).", + "needs_translation": "false", + "translation": "Minimum throttle (0% throttle output) expected from radio, in microseconds (us)." + }, + "rc_yaw_deadband": { + "english": "Deadband for yaw control in microseconds (us).", + "needs_translation": "false", + "translation": "Deadband for yaw control in microseconds (us)." + } + }, + "RC_TUNING": { + "accel_limit_1": { + "english": "Maximum acceleration of the craft in response to a stick movement.", + "needs_translation": "false", + "translation": "Maximum acceleration of the craft in response to a stick movement." + }, + "accel_limit_2": { + "english": "Maximum acceleration of the craft in response to a stick movement.", + "needs_translation": "false", + "translation": "Maximum acceleration of the craft in response to a stick movement." + }, + "accel_limit_3": { + "english": "Maximum acceleration of the craft in response to a stick movement.", + "needs_translation": "false", + "translation": "Maximum acceleration of the craft in response to a stick movement." + }, + "accel_limit_4": { + "english": "Maximum acceleration of the craft in response to a stick movement.", + "needs_translation": "false", + "translation": "Maximum acceleration of the craft in response to a stick movement." + }, + "response_time_1": { + "english": "Increase or decrease the response time of the rate to smooth heli movements.", + "needs_translation": "false", + "translation": "Increase or decrease the response time of the rate to smooth heli movements." + }, + "response_time_2": { + "english": "Increase or decrease the response time of the rate to smooth heli movements.", + "needs_translation": "false", + "translation": "Increase or decrease the response time of the rate to smooth heli movements." + }, + "response_time_3": { + "english": "Increase or decrease the response time of the rate to smooth heli movements.", + "needs_translation": "false", + "translation": "Increase or decrease the response time of the rate to smooth heli movements." + }, + "response_time_4": { + "english": "Increase or decrease the response time of the rate to smooth heli movements.", + "needs_translation": "false", + "translation": "Increase or decrease the response time of the rate to smooth heli movements." + }, + "setpoint_boost_cutoff_1": { + "english": "Boost cutoff for the setpoint.", + "needs_translation": "false", + "translation": "Boost cutoff for the setpoint." + }, + "setpoint_boost_cutoff_2": { + "english": "Boost cutoff for the setpoint.", + "needs_translation": "false", + "translation": "Boost cutoff for the setpoint." + }, + "setpoint_boost_cutoff_3": { + "english": "Boost cutoff for the setpoint.", + "needs_translation": "false", + "translation": "Boost cutoff for the setpoint." + }, + "setpoint_boost_cutoff_4": { + "english": "Boost cutoff for the setpoint.", + "needs_translation": "false", + "translation": "Boost cutoff for the setpoint." + }, + "setpoint_boost_gain_1": { + "english": "Boost gain for the setpoint.", + "needs_translation": "false", + "translation": "Boost gain for the setpoint." + }, + "setpoint_boost_gain_2": { + "english": "Boost gain for the setpoint.", + "needs_translation": "false", + "translation": "Boost gain for the setpoint." + }, + "setpoint_boost_gain_3": { + "english": "Boost gain for the setpoint.", + "needs_translation": "false", + "translation": "Boost gain for the setpoint." + }, + "setpoint_boost_gain_4": { + "english": "Boost gain for the setpoint.", + "needs_translation": "false", + "translation": "Boost gain for the setpoint." + }, + "yaw_dynamic_ceiling_gain": { + "english": "The maximum gain applied to the yaw dynamic ceiling.", + "needs_translation": "false", + "translation": "The maximum gain applied to the yaw dynamic ceiling." + }, + "yaw_dynamic_deadband_filter": { + "english": "The maximum filter applied to the yaw dynamic deadband.", + "needs_translation": "false", + "translation": "The maximum filter applied to the yaw dynamic deadband." + }, + "yaw_dynamic_deadband_gain": { + "english": "The maximum gain applied to the yaw dynamic deadband.", + "needs_translation": "false", + "translation": "The maximum gain applied to the yaw dynamic deadband." + } + }, + "RESCUE_PROFILE": { + "rescue_climb_collective": { + "english": "Collective value for rescue climb.", + "needs_translation": "false", + "translation": "Collective value for rescue climb." + }, + "rescue_climb_time": { + "english": "Length of time the climb collective is applied before switching to hover.", + "needs_translation": "false", + "translation": "Length of time the climb collective is applied before switching to hover." + }, + "rescue_exit_time": { + "english": "This limits rapid application of negative collective if the helicopter has rolled during rescue.", + "needs_translation": "false", + "translation": "This limits rapid application of negative collective if the helicopter has rolled during rescue." + }, + "rescue_flip_gain": { + "english": "Determine how aggressively the heli flips during inverted rescue.", + "needs_translation": "false", + "translation": "Determine how aggressively the heli flips during inverted rescue." + }, + "rescue_flip_mode": { + "english": "If rescue is activated while inverted, flip to upright - or remain inverted.", + "needs_translation": "false", + "translation": "If rescue is activated while inverted, flip to upright - or remain inverted." + }, + "rescue_flip_time": { + "english": "If the helicopter is in rescue and is trying to flip to upright and does not within this time, rescue will be aborted.", + "needs_translation": "false", + "translation": "If the helicopter is in rescue and is trying to flip to upright and does not within this time, rescue will be aborted." + }, + "rescue_hover_collective": { + "english": "Collective value for hover.", + "needs_translation": "false", + "translation": "Collective value for hover." + }, + "rescue_level_gain": { + "english": "Determine how aggressively the heli levels during rescue.", + "needs_translation": "false", + "translation": "Determine how aggressively the heli levels during rescue." + }, + "rescue_max_setpoint_accel": { + "english": "Limit how fast the helicopter accelerates into a roll/pitch. Larger helicopters may need slower acceleration.", + "needs_translation": "false", + "translation": "Limit how fast the helicopter accelerates into a roll/pitch. Larger helicopters may need slower acceleration." + }, + "rescue_max_setpoint_rate": { + "english": "Limit rescue roll/pitch rate. Larger helicopters may need slower rotation rates.", + "needs_translation": "false", + "translation": "Limit rescue roll/pitch rate. Larger helicopters may need slower rotation rates." + }, + "rescue_pull_up_collective": { + "english": "Collective value for pull-up climb.", + "needs_translation": "false", + "translation": "Collective value for pull-up climb." + }, + "rescue_pull_up_time": { + "english": "When rescue is activated, helicopter will apply pull-up collective for this time period before moving to flip or climb stage.", + "needs_translation": "false", + "translation": "When rescue is activated, helicopter will apply pull-up collective for this time period before moving to flip or climb stage." + }, + "tbl_flip": { + "english": "FLIP", + "needs_translation": "false", + "translation": "FLIP" + }, + "tbl_noflip": { + "english": "NO FLIP", + "needs_translation": "false", + "translation": "NO FLIP" + }, + "tbl_off": { + "english": "OFF", + "needs_translation": "false", + "translation": "OFF" + }, + "tbl_on": { + "english": "ON", + "needs_translation": "false", + "translation": "ON" + } + } + }, + "app": { + "btn_cancel": { + "english": "CANCEL", + "needs_translation": "false", + "translation": "CANCEL" + }, + "btn_close": { + "english": "CLOSE", + "needs_translation": "false", + "translation": "CLOSE" + }, + "btn_ok": { + "english": " OK ", + "needs_translation": "false", + "translation": " OK " + }, + "btn_ok_long": { + "english": " OK ", + "needs_translation": "false", + "translation": " OK " + }, + "check_bg_task": { + "english": "Please enable the background task.", + "needs_translation": "false", + "translation": "Please enable the background task." + }, + "check_discovered_sensors": { + "english": "Please check you have discovered all sensors.", + "needs_translation": "false", + "translation": "Please check you have discovered all sensors." + }, + "check_heli_on": { + "english": "Please check your heli is powered up and radio connected.", + "needs_translation": "false", + "translation": "Please check your heli is powered up and radio connected." + }, + "check_msp_version": { + "english": "Unable to determine MSP version in use.", + "needs_translation": "false", + "translation": "Unable to determine MSP version in use." + }, + "check_rf_module_on": { + "english": "Please check your rf module is turned on.", + "needs_translation": "false", + "translation": "Please check your rf module is turned on." + }, + "check_supported_version": { + "english": "This version of the Lua script \ncan't be used with the selected model", + "needs_translation": "false", + "translation": "This version of the Lua script \ncan't be used with the selected model" + }, + "error_timed_out": { + "english": "Error: timed out", + "needs_translation": "false", + "translation": "Error: timed out" + }, + "menu_section_about": { + "english": "About", + "needs_translation": "false", + "translation": "About" + }, + "menu_section_advanced": { + "english": "Advanced", + "needs_translation": "false", + "translation": "Advanced" + }, + "menu_section_developer": { + "english": "Developer", + "needs_translation": "false", + "translation": "Developer" + }, + "menu_section_flight_tuning": { + "english": "Flight Tuning", + "needs_translation": "false", + "translation": "Flight Tuning" + }, + "menu_section_hardware": { + "english": "Hardware", + "needs_translation": "false", + "translation": "Hardware" + }, + "menu_section_tools": { + "english": "Tools", + "needs_translation": "false", + "translation": "Tools" + }, + "modules": { + "about": { + "credits": { + "english": "Notable contributors to both the dashx firmware and this software are: Petri Mattila, Egon Lubbers, Rob Thomson, Rob Gayle, Phil Kaighin, Robert Burrow, Keith Williams, Bertrand Songis, Venbs Zhou... and many more who have spent hours testing and providing feedback!", + "needs_translation": "false", + "translation": "Notable contributors to both the dashx firmware and this software are: Petri Mattila, Egon Lubbers, Rob Thomson, Rob Gayle, Phil Kaighin, Robert Burrow, Keith Williams, Bertrand Songis, Venbs Zhou... and many more who have spent hours testing and providing feedback!" + }, + "ethos_version": { + "english": "Ethos Version", + "needs_translation": "false", + "translation": "Ethos Version" + }, + "help_p1": { + "english": "This page provides some useful information that you may be asked for when requesting support.", + "needs_translation": "false", + "translation": "This page provides some useful information that you may be asked for when requesting support." + }, + "help_p2": { + "english": "For support, please first read the help pages on www.rotorflight.org", + "needs_translation": "false", + "translation": "For support, please first read the help pages on www.rotorflight.org" + }, + "license": { + "english": "You may copy, distribute, and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.", + "needs_translation": "false", + "translation": "You may copy, distribute, and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions." + }, + "msgbox_credits": { + "english": "Credits", + "needs_translation": "false", + "translation": "Credits" + }, + "msp_transport": { + "english": "MSP Transport", + "needs_translation": "false", + "translation": "MSP Transport" + }, + "msp_version": { + "english": "MSP Version", + "needs_translation": "false", + "translation": "MSP Version" + }, + "name": { + "english": "About", + "needs_translation": "false", + "translation": "About" + }, + "opener": { + "english": "dashx is an open source project. Contribution from other like minded people, keen to assist in making this software even better, is welcomed and encouraged. You do not have to be a hardcore programmer to help.", + "needs_translation": "false", + "translation": "dashx is an open source project. Contribution from other like minded people, keen to assist in making this software even better, is welcomed and encouraged. You do not have to be a hardcore programmer to help." + }, + "simulation": { + "english": "Simulation", + "needs_translation": "false", + "translation": "Simulation" + }, + "supported_versions": { + "english": "Supported MSP Versions", + "needs_translation": "false", + "translation": "Supported MSP Versions" + }, + "version": { + "english": "Version", + "needs_translation": "false", + "translation": "Version" + } + }, + "accelerometer": { + "help_p1": { + "english": "The accelerometer is used to measure the angle of the flight controller in relation to the horizon. This data is used to stabilize the aircraft and provide self-leveling functionality.", + "needs_translation": "false", + "translation": "The accelerometer is used to measure the angle of the flight controller in relation to the horizon. This data is used to stabilize the aircraft and provide self-leveling functionality." + }, + "msg_calibrate": { + "english": "Calibrate the accelerometer?", + "needs_translation": "false", + "translation": "Calibrate the accelerometer?" + }, + "name": { + "english": "Accelerometer", + "needs_translation": "false", + "translation": "Accelerometer" + }, + "pitch": { + "english": "Pitch", + "needs_translation": "false", + "translation": "Pitch" + }, + "roll": { + "english": "Roll", + "needs_translation": "false", + "translation": "Roll" + } + }, + "battery": { + "battery_capacity": { + "english": "Battery Capacity", + "needs_translation": "false", + "translation": "Battery Capacity" + }, + "cell_count": { + "english": "Cell Count", + "needs_translation": "false", + "translation": "Cell Count" + }, + "consumption_warning_percentage": { + "english": "Consumption Warning %", + "needs_translation": "false", + "translation": "Consumption Warning %" + }, + "full_cell_voltage": { + "english": "Full Cell Voltage", + "needs_translation": "false", + "translation": "Full Cell Voltage" + }, + "help_p1": { + "english": "The battery settings are used to configure the flight controller to monitor the battery voltage and provide warnings when the voltage drops below a certain level.", + "needs_translation": "false", + "translation": "The battery settings are used to configure the flight controller to monitor the battery voltage and provide warnings when the voltage drops below a certain level." + }, + "max_cell_voltage": { + "english": "Max Cell Voltage", + "needs_translation": "false", + "translation": "Max Cell Voltage" + }, + "min_cell_voltage": { + "english": "Min Cell Voltage", + "needs_translation": "false", + "translation": "Min Cell Voltage" + }, + "name": { + "english": "Battery", + "needs_translation": "false", + "translation": "Battery" + }, + "timer": { + "english": "Flight Time Alarm", + "needs_translation": "false", + "translation": "Flight Time Alarm" + }, + "warn_cell_voltage": { + "english": "Warn Cell Voltage", + "needs_translation": "false", + "translation": "Warn Cell Voltage" + } + }, + "copyprofiles": { + "dest_profile": { + "english": "Dest. Profile", + "needs_translation": "false", + "translation": "Dest. Profile" + }, + "help_p1": { + "english": "Copy PID profile or Rate profile from Source to Destination.", + "needs_translation": "false", + "translation": "Copy PID profile or Rate profile from Source to Destination." + }, + "help_p2": { + "english": "Choose the source and destinations and save to copy the profile.", + "needs_translation": "false", + "translation": "Choose the source and destinations and save to copy the profile." + }, + "msgbox_msg": { + "english": "Save current page to flight controller?", + "needs_translation": "false", + "translation": "Save current page to flight controller?" + }, + "msgbox_save": { + "english": "Save settings", + "needs_translation": "false", + "translation": "Save settings" + }, + "name": { + "english": "Copy Profiles", + "needs_translation": "false", + "translation": "Copy Profiles" + }, + "profile_type": { + "english": "Profile Type", + "needs_translation": "false", + "translation": "Profile Type" + }, + "profile_type_pid": { + "english": "PID", + "needs_translation": "false", + "translation": "PID" + }, + "profile_type_rate": { + "english": "Rate", + "needs_translation": "false", + "translation": "Rate" + }, + "source_profile": { + "english": "Source Profile", + "needs_translation": "false", + "translation": "Source Profile" + } + }, + "esc_motors": { + "consumption_correction": { + "english": "Consumption Correction", + "needs_translation": "false", + "translation": "Consumption Correction" + }, + "current_correction": { + "english": "Current Correction", + "needs_translation": "false", + "translation": "Current Correction" + }, + "front": { + "english": "Front", + "needs_translation": "false", + "translation": "Front" + }, + "help_p1": { + "english": "Configure the motor and speed controller features.", + "needs_translation": "false", + "translation": "Configure the motor and speed controller features." + }, + "main": { + "english": "Main", + "needs_translation": "false", + "translation": "Main" + }, + "main_motor_ratio": { + "english": "Main Motor Ratio", + "needs_translation": "false", + "translation": "Main Motor Ratio" + }, + "max_throttle": { + "english": "100% Throttle PWM value", + "needs_translation": "false", + "translation": "100% Throttle PWM value" + }, + "min_throttle": { + "english": "0% Throttle PWM Value", + "needs_translation": "false", + "translation": "0% Throttle PWM Value" + }, + "mincommand": { + "english": "Motor Stop PWM Value", + "needs_translation": "false", + "translation": "Motor Stop PWM Value" + }, + "motor_pole_count": { + "english": "Motor Pole Count", + "needs_translation": "false", + "translation": "Motor Pole Count" + }, + "name": { + "english": "ESC/Motors", + "needs_translation": "false", + "translation": "ESC/Motors" + }, + "pinion": { + "english": "Pinion", + "needs_translation": "false", + "translation": "Pinion" + }, + "rear": { + "english": "Rear", + "needs_translation": "false", + "translation": "Rear" + }, + "tail_motor_ratio": { + "english": "Tail Motor Ratio", + "needs_translation": "false", + "translation": "Tail Motor Ratio" + }, + "voltage_correction": { + "english": "Voltage Correction", + "needs_translation": "false", + "translation": "Voltage Correction" + } + }, + "esc_tools": { + "mfg": { + "flrtr": { + "advanced": { + "english": "Advanced", + "needs_translation": "false", + "translation": "Advanced" + }, + "basic": { + "english": "Basic", + "needs_translation": "false", + "translation": "Basic" + }, + "battery_capacity": { + "english": "Battery capacity", + "needs_translation": "false", + "translation": "Battery capacity" + }, + "bec_voltage": { + "english": "BEC voltage", + "needs_translation": "false", + "translation": "BEC voltage" + }, + "buzzer_volume": { + "english": "Buzzer volume", + "needs_translation": "false", + "translation": "Buzzer volume" + }, + "cell_count": { + "english": "Cell count", + "needs_translation": "false", + "translation": "Cell count" + }, + "current_gain": { + "english": "Current gain", + "needs_translation": "false", + "translation": "Current gain" + }, + "fan_control": { + "english": "Fan control", + "needs_translation": "false", + "translation": "Fan control" + }, + "gov": { + "english": "Governor", + "needs_translation": "false", + "translation": "Governor" + }, + "gov_d": { + "english": "Gov-D", + "needs_translation": "false", + "translation": "Gov-D" + }, + "gov_i": { + "english": "Gov-I", + "needs_translation": "false", + "translation": "Gov-I" + }, + "gov_p": { + "english": "Gov-P", + "needs_translation": "false", + "translation": "Gov-P" + }, + "governor": { + "english": "Governor", + "needs_translation": "false", + "translation": "Governor" + }, + "led_color": { + "english": "LED color", + "needs_translation": "false", + "translation": "LED color" + }, + "low_voltage_protection": { + "english": "Low voltage protection", + "needs_translation": "false", + "translation": "Low voltage protection" + }, + "motor_direction": { + "english": "Motor direction", + "needs_translation": "false", + "translation": "Motor direction" + }, + "motor_erpm_max": { + "english": "Motor ERPM max", + "needs_translation": "false", + "translation": "Motor ERPM max" + }, + "motor_temp": { + "english": "Motor temperture", + "needs_translation": "false", + "translation": "Motor temperture" + }, + "motor_temp_sensor": { + "english": "Motor temp sensor", + "needs_translation": "false", + "translation": "Motor temp sensor" + }, + "name": { + "english": "FLYROTOR", + "needs_translation": "false", + "translation": "FLYROTOR" + }, + "other": { + "english": "Other", + "needs_translation": "false", + "translation": "Other" + }, + "response_speed": { + "english": "Response speed", + "needs_translation": "false", + "translation": "Response speed" + }, + "soft_start": { + "english": "Soft start", + "needs_translation": "false", + "translation": "Soft start" + }, + "starting_torque": { + "english": "Starting torque", + "needs_translation": "false", + "translation": "Starting torque" + }, + "telemetry_protocol": { + "english": "Telemetry protocol", + "needs_translation": "false", + "translation": "Telemetry protocol" + }, + "temperature_protection": { + "english": "Temperature protection", + "needs_translation": "false", + "translation": "Temperature protection" + }, + "throttle_protocol": { + "english": "Throttle protocol", + "needs_translation": "false", + "translation": "Throttle protocol" + }, + "timing_angle": { + "english": "Timing angle", + "needs_translation": "false", + "translation": "Timing angle" + } + }, + "hw5": { + "active_freewheel": { + "english": "Active Freewheel", + "needs_translation": "false", + "translation": "Active Freewheel" + }, + "advanced": { + "english": "Advanced", + "needs_translation": "false", + "translation": "Advanced" + }, + "auto_restart": { + "english": "Auto Restart", + "needs_translation": "false", + "translation": "Auto Restart" + }, + "basic": { + "english": "Basic", + "needs_translation": "false", + "translation": "Basic" + }, + "bec_voltage": { + "english": "BEC Voltage", + "needs_translation": "false", + "translation": "BEC Voltage" + }, + "brake": { + "english": "Brake", + "needs_translation": "false", + "translation": "Brake" + }, + "brake_force": { + "english": "Brake Force%", + "needs_translation": "false", + "translation": "Brake Force%" + }, + "brake_type": { + "english": "Brake Type", + "needs_translation": "false", + "translation": "Brake Type" + }, + "cutoff_voltage": { + "english": "Cutoff Voltage", + "needs_translation": "false", + "translation": "Cutoff Voltage" + }, + "esc": { + "english": "ESC", + "needs_translation": "false", + "translation": "ESC" + }, + "flight_mode": { + "english": "Flight Mode", + "needs_translation": "false", + "translation": "Flight Mode" + }, + "gov_i_gain": { + "english": "I-Gain", + "needs_translation": "false", + "translation": "I-Gain" + }, + "gov_p_gain": { + "english": "P-Gain", + "needs_translation": "false", + "translation": "P-Gain" + }, + "governor": { + "english": "Governor", + "needs_translation": "false", + "translation": "Governor" + }, + "limits": { + "english": "Limits", + "needs_translation": "false", + "translation": "Limits" + }, + "lipo_cell_count": { + "english": "LiPo Cell Count", + "needs_translation": "false", + "translation": "LiPo Cell Count" + }, + "motor": { + "english": "Motor", + "needs_translation": "false", + "translation": "Motor" + }, + "name": { + "english": "Hobbywing V5", + "needs_translation": "false", + "translation": "Hobbywing V5" + }, + "other": { + "english": "Other", + "needs_translation": "false", + "translation": "Other" + }, + "restart_time": { + "english": "Restart Time", + "needs_translation": "false", + "translation": "Restart Time" + }, + "rotation": { + "english": "Rotation", + "needs_translation": "false", + "translation": "Rotation" + }, + "soft_start": { + "english": "Soft Start", + "needs_translation": "false", + "translation": "Soft Start" + }, + "startup_power": { + "english": "Startup Power", + "needs_translation": "false", + "translation": "Startup Power" + }, + "startup_time": { + "english": "Startup Time", + "needs_translation": "false", + "translation": "Startup Time" + }, + "timing": { + "english": "Timing", + "needs_translation": "false", + "translation": "Timing" + }, + "volt_cutoff_type": { + "english": "Volt Cutoff Type", + "needs_translation": "false", + "translation": "Volt Cutoff Type" + } + }, + "scorp": { + "advanced": { + "english": "Advanced", + "needs_translation": "false", + "translation": "Advanced" + }, + "bailout": { + "english": "Bailout", + "needs_translation": "false", + "translation": "Bailout" + }, + "basic": { + "english": "Basic", + "needs_translation": "false", + "translation": "Basic" + }, + "bec_voltage": { + "english": "BEC Voltage", + "needs_translation": "false", + "translation": "BEC Voltage" + }, + "cutoff_handling": { + "english": "Cutoff Handling", + "needs_translation": "false", + "translation": "Cutoff Handling" + }, + "esc_mode": { + "english": "ESC Mode", + "needs_translation": "false", + "translation": "ESC Mode" + }, + "extra_msg_save": { + "english": "Please reboot the ESC to apply the changes", + "needs_translation": "false", + "translation": "Please reboot the ESC to apply the changes" + }, + "gov_integral": { + "english": "Gov Integral", + "needs_translation": "false", + "translation": "Gov Integral" + }, + "gov_proportional": { + "english": "Gov Proportional", + "needs_translation": "false", + "translation": "Gov Proportional" + }, + "limits": { + "english": "Limits", + "needs_translation": "false", + "translation": "Limits" + }, + "max_current": { + "english": "Max Current", + "needs_translation": "false", + "translation": "Max Current" + }, + "max_temperature": { + "english": "Max Temperature", + "needs_translation": "false", + "translation": "Max Temperature" + }, + "max_used": { + "english": "Max Used", + "needs_translation": "false", + "translation": "Max Used" + }, + "min_voltage": { + "english": "Min Voltage", + "needs_translation": "false", + "translation": "Min Voltage" + }, + "motor_startup_sound": { + "english": "Motor Startup Sound", + "needs_translation": "false", + "translation": "Motor Startup Sound" + }, + "name": { + "english": "Scorpion", + "needs_translation": "false", + "translation": "Scorpion" + }, + "protection_delay": { + "english": "Protection Delay", + "needs_translation": "false", + "translation": "Protection Delay" + }, + "rotation": { + "english": "Rotation", + "needs_translation": "false", + "translation": "Rotation" + }, + "runup_time": { + "english": "Runup Time", + "needs_translation": "false", + "translation": "Runup Time" + }, + "soft_start_time": { + "english": "Soft Start Time", + "needs_translation": "false", + "translation": "Soft Start Time" + }, + "telemetry_protocol": { + "english": "Telemetry Protocol", + "needs_translation": "false", + "translation": "Telemetry Protocol" + } + }, + "xdfly": { + "acceleration": { + "english": "Acceleration", + "needs_translation": "false", + "translation": "Acceleration" + }, + "advanced": { + "english": "Advanced", + "needs_translation": "false", + "translation": "Advanced" + }, + "auto_restart_time": { + "english": "Auto Restart Time", + "needs_translation": "false", + "translation": "Auto Restart Time" + }, + "basic": { + "english": "Basic", + "needs_translation": "false", + "translation": "Basic" + }, + "brake_force": { + "english": "Brake Force", + "needs_translation": "false", + "translation": "Brake Force" + }, + "capacity_correction": { + "english": "Capacity Correction", + "needs_translation": "false", + "translation": "Capacity Correction" + }, + "cell_cutoff": { + "english": "Cell Cutoff", + "needs_translation": "false", + "translation": "Cell Cutoff" + }, + "gov": { + "english": "Governor", + "needs_translation": "false", + "translation": "Governor" + }, + "gov_i": { + "english": "Gov-I", + "needs_translation": "false", + "translation": "Gov-I" + }, + "gov_p": { + "english": "Gov-P", + "needs_translation": "false", + "translation": "Gov-P" + }, + "governor": { + "english": "Governor", + "needs_translation": "false", + "translation": "Governor" + }, + "hv_bec_voltage": { + "english": "HV BEC Voltage", + "needs_translation": "false", + "translation": "HV BEC Voltage" + }, + "led_color": { + "english": "LED Color", + "needs_translation": "false", + "translation": "LED Color" + }, + "lv_bec_voltage": { + "english": "LV BEC Voltage", + "needs_translation": "false", + "translation": "LV BEC Voltage" + }, + "motor_direction": { + "english": "Motor Direction", + "needs_translation": "false", + "translation": "Motor Direction" + }, + "motor_poles": { + "english": "Motor Poles", + "needs_translation": "false", + "translation": "Motor Poles" + }, + "name": { + "english": "XDFLY", + "needs_translation": "false", + "translation": "XDFLY" + }, + "smart_fan": { + "english": "Smart Fan", + "needs_translation": "false", + "translation": "Smart Fan" + }, + "sr_function": { + "english": "SR Function", + "needs_translation": "false", + "translation": "SR Function" + }, + "startup_power": { + "english": "Startup Power", + "needs_translation": "false", + "translation": "Startup Power" + }, + "timing": { + "english": "Timing", + "needs_translation": "false", + "translation": "Timing" + } + }, + "yge": { + "active_freewheel": { + "english": "Active Freewheel", + "needs_translation": "false", + "translation": "Active Freewheel" + }, + "advanced": { + "english": "Advanced", + "needs_translation": "false", + "translation": "Advanced" + }, + "auto_restart_time": { + "english": "Auto Restart Time", + "needs_translation": "false", + "translation": "Auto Restart Time" + }, + "basic": { + "english": "Basic", + "needs_translation": "false", + "translation": "Basic" + }, + "cell_cutoff": { + "english": "Cell Cutoff", + "needs_translation": "false", + "translation": "Cell Cutoff" + }, + "current_limit": { + "english": "Current Limit", + "needs_translation": "false", + "translation": "Current Limit" + }, + "direction": { + "english": "Direction", + "needs_translation": "false", + "translation": "Direction" + }, + "esc": { + "english": "ESC", + "needs_translation": "false", + "translation": "ESC" + }, + "esc_mode": { + "english": "ESC Mode", + "needs_translation": "false", + "translation": "ESC Mode" + }, + "f3c_auto": { + "english": "F3C Autorotation", + "needs_translation": "false", + "translation": "F3C Autorotation" + }, + "gov_i": { + "english": "Gov-I", + "needs_translation": "false", + "translation": "Gov-I" + }, + "gov_p": { + "english": "Gov-P", + "needs_translation": "false", + "translation": "Gov-P" + }, + "limits": { + "english": "Limits", + "needs_translation": "false", + "translation": "Limits" + }, + "lv_bec_voltage": { + "english": "BEC", + "needs_translation": "false", + "translation": "BEC" + }, + "main_teeth": { + "english": "Main Teeth", + "needs_translation": "false", + "translation": "Main Teeth" + }, + "max_start_power": { + "english": "Max Start Power", + "needs_translation": "false", + "translation": "Max Start Power" + }, + "min_start_power": { + "english": "Min Start Power", + "needs_translation": "false", + "translation": "Min Start Power" + }, + "motor_pole_pairs": { + "english": "Motor Pole Pairs", + "needs_translation": "false", + "translation": "Motor Pole Pairs" + }, + "name": { + "english": "YGE", + "needs_translation": "false", + "translation": "YGE" + }, + "other": { + "english": "Other", + "needs_translation": "false", + "translation": "Other" + }, + "pinion_teeth": { + "english": "Pinion Teeth", + "needs_translation": "false", + "translation": "Pinion Teeth" + }, + "stick_range_us": { + "english": "Stick Range", + "needs_translation": "false", + "translation": "Stick Range" + }, + "stick_zero_us": { + "english": "Stick Zero", + "needs_translation": "false", + "translation": "Stick Zero" + }, + "throttle_response": { + "english": "Throttle Response", + "needs_translation": "false", + "translation": "Throttle Response" + }, + "timing": { + "english": "Motor Timing", + "needs_translation": "false", + "translation": "Motor Timing" + } + } + }, + "name": { + "english": "ESC Tools", + "needs_translation": "false", + "translation": "ESC Tools" + }, + "please_powercycle": { + "english": "Please power cycle the ESC...", + "needs_translation": "false", + "translation": "Please power cycle the ESC..." + }, + "searching": { + "english": "Searching", + "needs_translation": "false", + "translation": "Searching" + }, + "unknown": { + "english": "UNKNOWN", + "needs_translation": "false", + "translation": "UNKNOWN" + } + }, + "filters": { + "center": { + "english": "Center", + "needs_translation": "false", + "translation": "Center" + }, + "cutoff": { + "english": "Cutoff", + "needs_translation": "false", + "translation": "Cutoff" + }, + "dyn_notch": { + "english": "Dynamic Filters", + "needs_translation": "false", + "translation": "Dynamic Filters" + }, + "filter_type": { + "english": "Filter type", + "needs_translation": "false", + "translation": "Filter type" + }, + "help_p1": { + "english": "Typically you would not edit this page without checking your Blackbox logs!", + "needs_translation": "false", + "translation": "Typically you would not edit this page without checking your Blackbox logs!" + }, + "help_p2": { + "english": "Gyro lowpass: Lowpass filters for the gyro signal. Typically left at default.", + "needs_translation": "false", + "translation": "Gyro lowpass: Lowpass filters for the gyro signal. Typically left at default." + }, + "help_p3": { + "english": "Gyro notch filters: Use for filtering specific frequency ranges. Typically not needed in most helis.", + "needs_translation": "false", + "translation": "Gyro notch filters: Use for filtering specific frequency ranges. Typically not needed in most helis." + }, + "help_p4": { + "english": "Dynamic Notch Filters: Automatically creates notch filters within the min and max frequency range.", + "needs_translation": "false", + "translation": "Dynamic Notch Filters: Automatically creates notch filters within the min and max frequency range." + }, + "lowpass_1": { + "english": "Lowpass 1", + "needs_translation": "false", + "translation": "Lowpass 1" + }, + "lowpass_1_dyn": { + "english": "Lowpass 1 dyn.", + "needs_translation": "false", + "translation": "Lowpass 1 dyn." + }, + "lowpass_2": { + "english": "Lowpass 2", + "needs_translation": "false", + "translation": "Lowpass 2" + }, + "max_cutoff": { + "english": "Max cutoff", + "needs_translation": "false", + "translation": "Max cutoff" + }, + "min_cutoff": { + "english": "Min cutoff", + "needs_translation": "false", + "translation": "Min cutoff" + }, + "name": { + "english": "Filters", + "needs_translation": "false", + "translation": "Filters" + }, + "notch_1": { + "english": "Notch 1", + "needs_translation": "false", + "translation": "Notch 1" + }, + "notch_2": { + "english": "Notch 2", + "needs_translation": "false", + "translation": "Notch 2" + }, + "notch_c": { + "english": "Notch Count", + "needs_translation": "false", + "translation": "Notch Count" + }, + "notch_max_hz": { + "english": "Max", + "needs_translation": "false", + "translation": "Max" + }, + "notch_min_hz": { + "english": "Min", + "needs_translation": "false", + "translation": "Min" + }, + "notch_q": { + "english": "Notch Q", + "needs_translation": "false", + "translation": "Notch Q" + }, + "rpm_filter": { + "english": "RPM filter", + "needs_translation": "false", + "translation": "RPM filter" + }, + "rpm_min_hz": { + "english": "Min. Frequency", + "needs_translation": "false", + "translation": "Min. Frequency" + }, + "rpm_preset": { + "english": "Type", + "needs_translation": "false", + "translation": "Type" + } + }, + "governor": { + "handover_throttle": { + "english": "Handover throttle%", + "needs_translation": "false", + "translation": "Handover throttle%" + }, + "help_p1": { + "english": "These parameters apply globally to the governor regardless of the profile in use.", + "needs_translation": "false", + "translation": "These parameters apply globally to the governor regardless of the profile in use." + }, + "help_p2": { + "english": "Each parameter is simply a time value in seconds for each governor action.", + "needs_translation": "false", + "translation": "Each parameter is simply a time value in seconds for each governor action." + }, + "mode": { + "english": "Mode", + "needs_translation": "false", + "translation": "Mode" + }, + "name": { + "english": "Governor", + "needs_translation": "false", + "translation": "Governor" + }, + "recovery_time": { + "english": "Recovery time", + "needs_translation": "false", + "translation": "Recovery time" + }, + "spoolup_min_throttle": { + "english": "Spoolup min throttle%", + "needs_translation": "false", + "translation": "Spoolup min throttle%" + }, + "spoolup_time": { + "english": "Spoolup time", + "needs_translation": "false", + "translation": "Spoolup time" + }, + "startup_time": { + "english": "Startup time", + "needs_translation": "false", + "translation": "Startup time" + }, + "tracking_time": { + "english": "Tracking time", + "needs_translation": "false", + "translation": "Tracking time" + } + }, + "logs": { + "help_logs_p1": { + "english": "Please select a log file from the list below.", + "needs_translation": "false", + "translation": "Please select a log file from the list below." + }, + "help_logs_p2": { + "english": "Note. To enable logging it is essential for you to have the following sensors enabled.", + "needs_translation": "false", + "translation": "Note. To enable logging it is essential for you to have the following sensors enabled." + }, + "help_logs_p3": { + "english": "- arm status, voltage, headspeed, current, esc temperature", + "needs_translation": "false", + "translation": "- arm status, voltage, headspeed, current, esc temperature" + }, + "help_logs_tool_p1": { + "english": "Please use the slider to navigate the graph.", + "needs_translation": "false", + "translation": "Please use the slider to navigate the graph." + }, + "msg_no_logs_found": { + "english": "NO LOG FILES FOUND", + "needs_translation": "false", + "translation": "NO LOG FILES FOUND" + }, + "name": { + "english": "Logs", + "needs_translation": "false", + "translation": "Logs" + } + }, + "mixer": { + "collective_tilt_correction": { + "english": "Collective Tilt Correction", + "needs_translation": "false", + "translation": "Collective Tilt Correction" + }, + "collective_tilt_correction_neg": { + "english": "Negative", + "needs_translation": "false", + "translation": "Negative" + }, + "collective_tilt_correction_pos": { + "english": "Positive", + "needs_translation": "false", + "translation": "Positive" + }, + "geo_correction": { + "english": "Geo Correction", + "needs_translation": "false", + "translation": "Geo Correction" + }, + "help_p1": { + "english": "Adust swash plate geometry, phase angles, and limits.", + "needs_translation": "false", + "translation": "Adust swash plate geometry, phase angles, and limits." + }, + "name": { + "english": "Mixer", + "needs_translation": "false", + "translation": "Mixer" + }, + "swash_phase": { + "english": "Phase Angle", + "needs_translation": "false", + "translation": "Phase Angle" + }, + "swash_pitch_limit": { + "english": "Total Pitch Limit", + "needs_translation": "false", + "translation": "Total Pitch Limit" + }, + "swash_tta_precomp": { + "english": "TTA Precomp", + "needs_translation": "false", + "translation": "TTA Precomp" + }, + "tail_motor_idle": { + "english": "Tail Idle Thr%", + "needs_translation": "false", + "translation": "Tail Idle Thr%" + } + }, + "model": { + "battery": { + "english": "Battery", + "needs_translation": "false", + "translation": "Battery" + }, + "battery_capacity": { + "english": "Battery Capacity", + "needs_translation": "false", + "translation": "Battery Capacity" + }, + "battery_cells": { + "english": "Battery Cells", + "needs_translation": "false", + "translation": "Battery Cells" + }, + "battery_consumption_warning_percentage": { + "english": "Consumption Warning %", + "needs_translation": "false", + "translation": "Consumption Warning %" + }, + "battery_full_voltage": { + "english": "Cell Full Voltage", + "needs_translation": "false", + "translation": "Cell Full Voltage" + }, + "battery_max_voltage": { + "english": "Cell Maximum Voltage", + "needs_translation": "false", + "translation": "Cell Maximum Voltage" + }, + "battery_min_voltage": { + "english": "Cell Minimum Voltage", + "needs_translation": "false", + "translation": "Cell Minimum Voltage" + }, + "battery_warning_voltage": { + "english": "Cell Warning Voltage", + "needs_translation": "false", + "translation": "Cell Warning Voltage" + }, + "calcfuel_current": { + "english": "Current Sensor", + "needs_translation": "false", + "translation": "Current Sensor" + }, + "calcfuel_using": { + "english": "Calculate Fuel Using", + "needs_translation": "false", + "translation": "Calculate Fuel Using" + }, + "calcfuel_voltage": { + "english": "Voltage Sensor", + "needs_translation": "false", + "translation": "Voltage Sensor" + }, + "model_armswitch": { + "english": "Arm Switch", + "needs_translation": "false", + "translation": "Arm Switch" + }, + "model_inflightswitch": { + "english": "Inflight Switch", + "needs_translation": "false", + "translation": "Inflight Switch" + }, + "model_inflightswitch_delay": { + "english": "Inflight Switch Delay", + "needs_translation": "false", + "translation": "Inflight Switch Delay" + }, + "model_rateswitch": { + "english": "Rate Switch", + "needs_translation": "false", + "translation": "Rate Switch" + }, + "name": { + "english": "Model", + "needs_translation": "false", + "translation": "Model" + }, + "triggers": { + "english": "Triggers", + "needs_translation": "false", + "translation": "Triggers" + } + }, + "msp_exp": { + "help_p1": { + "english": "This tool provides the ability to send a custom byte string to the flight controller. It is useful for developers when debugging values.", + "needs_translation": "false", + "translation": "This tool provides the ability to send a custom byte string to the flight controller. It is useful for developers when debugging values." + }, + "help_p2": { + "english": "If you do not understand what you are doing, do not use it as bad things can happen.", + "needs_translation": "false", + "translation": "If you do not understand what you are doing, do not use it as bad things can happen." + }, + "name": { + "english": "MSP Expermental", + "needs_translation": "false", + "translation": "MSP Expermental" + } + }, + "msp_speed": { + "avg_query_time": { + "english": "Average query time", + "needs_translation": "false", + "translation": "Average query time" + }, + "checksum_errors": { + "english": "Checksum errors", + "needs_translation": "false", + "translation": "Checksum errors" + }, + "help_p1": { + "english": "This tool attempts to determine the quality of your MSP data link by performing as many large MSP queries within 30 seconds as possible.", + "needs_translation": "false", + "translation": "This tool attempts to determine the quality of your MSP data link by performing as many large MSP queries within 30 seconds as possible." + }, + "max_query_time": { + "english": "Maximum query time", + "needs_translation": "false", + "translation": "Maximum query time" + }, + "memory_free": { + "english": "Memory free", + "needs_translation": "false", + "translation": "Memory free" + }, + "min_query_time": { + "english": "Minimum query time", + "needs_translation": "false", + "translation": "Minimum query time" + }, + "name": { + "english": "MSP Speed", + "needs_translation": "false", + "translation": "MSP Speed" + }, + "retries": { + "english": "Retries", + "needs_translation": "false", + "translation": "Retries" + }, + "rf_protocol": { + "english": "RF protocol", + "needs_translation": "false", + "translation": "RF protocol" + }, + "seconds_120": { + "english": " 120S ", + "needs_translation": "false", + "translation": " 120S " + }, + "seconds_30": { + "english": " 30S ", + "needs_translation": "false", + "translation": " 30S " + }, + "seconds_300": { + "english": " 300S ", + "needs_translation": "false", + "translation": " 300S " + }, + "seconds_600": { + "english": " 600S ", + "needs_translation": "false", + "translation": " 600S " + }, + "start": { + "english": "Start", + "needs_translation": "false", + "translation": "Start" + }, + "start_prompt": { + "english": "Would you like to start the test? Choose the test run time below.", + "needs_translation": "false", + "translation": "Would you like to start the test? Choose the test run time below." + }, + "successful_queries": { + "english": "Successful queries", + "needs_translation": "false", + "translation": "Successful queries" + }, + "test_length": { + "english": "Test length", + "needs_translation": "false", + "translation": "Test length" + }, + "testing": { + "english": "Testing", + "needs_translation": "false", + "translation": "Testing" + }, + "testing_performance": { + "english": "Testing MSP performance...", + "needs_translation": "false", + "translation": "Testing MSP performance..." + }, + "timeouts": { + "english": "Timeouts", + "needs_translation": "false", + "translation": "Timeouts" + }, + "total_queries": { + "english": "Total queries", + "needs_translation": "false", + "translation": "Total queries" + } + }, + "pids": { + "b": { + "english": "B", + "needs_translation": "false", + "translation": "B" + }, + "d": { + "english": "D", + "needs_translation": "false", + "translation": "D" + }, + "f": { + "english": "F", + "needs_translation": "false", + "translation": "F" + }, + "help_p1": { + "english": "FeedForward (Roll/Pitch): Start at 70, increase until stops are sharp with no drift. Keep roll and pitch equal.", + "needs_translation": "false", + "translation": "FeedForward (Roll/Pitch): Start at 70, increase until stops are sharp with no drift. Keep roll and pitch equal." + }, + "help_p2": { + "english": "I Gain (Roll/Pitch): Raise gradually for stable piro pitch pumps. Too high causes wobbles; match roll/pitch values.", + "needs_translation": "false", + "translation": "I Gain (Roll/Pitch): Raise gradually for stable piro pitch pumps. Too high causes wobbles; match roll/pitch values." + }, + "help_p3": { + "english": "Tail P/I/D Gains: Increase P until slight wobble in funnels, then back off slightly. Raise I until tail holds firm in hard moves (too high causes slow wag). Adjust D for smooth stops—higher for slow servos, lower for fast ones.", + "needs_translation": "false", + "translation": "Tail P/I/D Gains: Increase P until slight wobble in funnels, then back off slightly. Raise I until tail holds firm in hard moves (too high causes slow wag). Adjust D for smooth stops—higher for slow servos, lower for fast ones." + }, + "help_p4": { + "english": "Tail Stop Gain (CW/CCW): Adjust separately for clean, bounce-free stops in both directions.", + "needs_translation": "false", + "translation": "Tail Stop Gain (CW/CCW): Adjust separately for clean, bounce-free stops in both directions." + }, + "help_p5": { + "english": "Test & Adjust: Fly, observe, and fine-tune for best performance in real conditions.", + "needs_translation": "false", + "translation": "Test & Adjust: Fly, observe, and fine-tune for best performance in real conditions." + }, + "i": { + "english": "I", + "needs_translation": "false", + "translation": "I" + }, + "name": { + "english": "PIDs", + "needs_translation": "false", + "translation": "PIDs" + }, + "o": { + "english": "O", + "needs_translation": "false", + "translation": "O" + }, + "p": { + "english": "P", + "needs_translation": "false", + "translation": "P" + }, + "pitch": { + "english": "Pitch", + "needs_translation": "false", + "translation": "Pitch" + }, + "roll": { + "english": "Roll", + "needs_translation": "false", + "translation": "Roll" + }, + "yaw": { + "english": "Yaw", + "needs_translation": "false", + "translation": "Yaw" + } + }, + "profile_autolevel": { + "acro_trainer": { + "english": "Acro trainer", + "needs_translation": "false", + "translation": "Acro trainer" + }, + "angle_mode": { + "english": "Angle mode", + "needs_translation": "false", + "translation": "Angle mode" + }, + "gain": { + "english": "Gain", + "needs_translation": "false", + "translation": "Gain" + }, + "help_p1": { + "english": "Acro Trainer: How aggressively the heli tilts back to level when flying in Acro Trainer Mode.", + "needs_translation": "false", + "translation": "Acro Trainer: How aggressively the heli tilts back to level when flying in Acro Trainer Mode." + }, + "help_p2": { + "english": "Angle Mode: How aggressively the heli tilts back to level when flying in Angle Mode.", + "needs_translation": "false", + "translation": "Angle Mode: How aggressively the heli tilts back to level when flying in Angle Mode." + }, + "help_p3": { + "english": "Horizon Mode: How aggressively the heli tilts back to level when flying in Horizon Mode.", + "needs_translation": "false", + "translation": "Horizon Mode: How aggressively the heli tilts back to level when flying in Horizon Mode." + }, + "horizon_mode": { + "english": "Horizon mode", + "needs_translation": "false", + "translation": "Horizon mode" + }, + "max": { + "english": "Max", + "needs_translation": "false", + "translation": "Max" + }, + "name": { + "english": "Autolevel", + "needs_translation": "false", + "translation": "Autolevel" + } + }, + "profile_governor": { + "col": { + "english": "Col", + "needs_translation": "false", + "translation": "Col" + }, + "cyc": { + "english": "Cyc", + "needs_translation": "false", + "translation": "Cyc" + }, + "d": { + "english": "D", + "needs_translation": "false", + "translation": "D" + }, + "disabled_message": { + "english": "dashx governor is not enabled", + "needs_translation": "false", + "translation": "dashx governor is not enabled" + }, + "f": { + "english": "F", + "needs_translation": "false", + "translation": "F" + }, + "full_headspeed": { + "english": "Full headspeed", + "needs_translation": "false", + "translation": "Full headspeed" + }, + "gain": { + "english": "PID master gain", + "needs_translation": "false", + "translation": "PID master gain" + }, + "gains": { + "english": "Gains", + "needs_translation": "false", + "translation": "Gains" + }, + "help_p1": { + "english": "Full headspeed: Headspeed target when at 100% throttle input.", + "needs_translation": "false", + "translation": "Full headspeed: Headspeed target when at 100% throttle input." + }, + "help_p2": { + "english": "PID master gain: How hard the governor works to hold the RPM.", + "needs_translation": "false", + "translation": "PID master gain: How hard the governor works to hold the RPM." + }, + "help_p3": { + "english": "Gains: Fine tuning of the governor.", + "needs_translation": "false", + "translation": "Gains: Fine tuning of the governor." + }, + "help_p4": { + "english": "Precomp: Governor precomp gain for yaw, cyclic, and collective inputs.", + "needs_translation": "false", + "translation": "Precomp: Governor precomp gain for yaw, cyclic, and collective inputs." + }, + "help_p5": { + "english": "Max throttle: The maximum throttle % the governor is allowed to use.", + "needs_translation": "false", + "translation": "Max throttle: The maximum throttle % the governor is allowed to use." + }, + "help_p6": { + "english": "Tail Torque Assist: For motorized tails. Gain and limit of headspeed increase when using main rotor torque for yaw assist.", + "needs_translation": "false", + "translation": "Tail Torque Assist: For motorized tails. Gain and limit of headspeed increase when using main rotor torque for yaw assist." + }, + "i": { + "english": "I", + "needs_translation": "false", + "translation": "I" + }, + "max_throttle": { + "english": "Max throttle", + "needs_translation": "false", + "translation": "Max throttle" + }, + "min_throttle": { + "english": "Min throttle", + "needs_translation": "false", + "translation": "Min throttle" + }, + "name": { + "english": "Governor", + "needs_translation": "false", + "translation": "Governor" + }, + "p": { + "english": "P", + "needs_translation": "false", + "translation": "P" + }, + "precomp": { + "english": "Precomp", + "needs_translation": "false", + "translation": "Precomp" + }, + "tail_torque_assist": { + "english": "Tail Torque Assist", + "needs_translation": "false", + "translation": "Tail Torque Assist" + }, + "tta_gain": { + "english": "Gain", + "needs_translation": "false", + "translation": "Gain" + }, + "tta_limit": { + "english": "Limit", + "needs_translation": "false", + "translation": "Limit" + }, + "yaw": { + "english": "Yaw", + "needs_translation": "false", + "translation": "Yaw" + } + }, + "profile_mainrotor": { + "collective_pitch_comp": { + "english": "Collective Pitch Compensation", + "needs_translation": "false", + "translation": "Collective Pitch Compensation" + }, + "collective_pitch_comp_short": { + "english": "Col. Pitch Compensation", + "needs_translation": "false", + "translation": "Col. Pitch Compensation" + }, + "cutoff": { + "english": "Cutoff", + "needs_translation": "false", + "translation": "Cutoff" + }, + "cyclic_cross_coupling": { + "english": "Cyclic Cross coupling", + "needs_translation": "false", + "translation": "Cyclic Cross coupling" + }, + "gain": { + "english": "Gain", + "needs_translation": "false", + "translation": "Gain" + }, + "help_p1": { + "english": "Collective Pitch Compensation: Increasing will compensate for the pitching motion caused by tail drag when climbing.", + "needs_translation": "false", + "translation": "Collective Pitch Compensation: Increasing will compensate for the pitching motion caused by tail drag when climbing." + }, + "help_p2": { + "english": "Cross Coupling Gain: Removes roll coupling when only elevator is applied.", + "needs_translation": "false", + "translation": "Cross Coupling Gain: Removes roll coupling when only elevator is applied." + }, + "help_p3": { + "english": "Cross Coupling Ratio: Amount of compensation (pitch vs roll) to apply.", + "needs_translation": "false", + "translation": "Cross Coupling Ratio: Amount of compensation (pitch vs roll) to apply." + }, + "help_p4": { + "english": "Cross Coupling Freq. Limit: Frequency limit for the compensation, higher value will make the compensation action faster.", + "needs_translation": "false", + "translation": "Cross Coupling Freq. Limit: Frequency limit for the compensation, higher value will make the compensation action faster." + }, + "name": { + "english": "Main Rotor", + "needs_translation": "false", + "translation": "Main Rotor" + }, + "ratio": { + "english": "Ratio", + "needs_translation": "false", + "translation": "Ratio" + } + }, + "profile_pidbandwidth": { + "bterm_cutoff": { + "english": "B-term cut-off", + "needs_translation": "false", + "translation": "B-term cut-off" + }, + "dterm_cutoff": { + "english": "D-term cut-off", + "needs_translation": "false", + "translation": "D-term cut-off" + }, + "help_p1": { + "english": "PID Bandwidth: Overall bandwidth in HZ used by the PID loop.", + "needs_translation": "false", + "translation": "PID Bandwidth: Overall bandwidth in HZ used by the PID loop." + }, + "help_p2": { + "english": "D-term cutoff: D-term cutoff frequency in HZ.", + "needs_translation": "false", + "translation": "D-term cutoff: D-term cutoff frequency in HZ." + }, + "help_p3": { + "english": "B-term cutoff: B-term cutoff frequency in HZ.", + "needs_translation": "false", + "translation": "B-term cutoff: B-term cutoff frequency in HZ." + }, + "name": { + "english": "PID Bandwidth", + "needs_translation": "false", + "translation": "PID Bandwidth" + }, + "pitch": { + "english": "P", + "needs_translation": "false", + "translation": "P" + }, + "roll": { + "english": "R", + "needs_translation": "false", + "translation": "R" + }, + "yaw": { + "english": "Y", + "needs_translation": "false", + "translation": "Y" + } + }, + "profile_pidcontroller": { + "cutoff_point": { + "english": "Cut-off point", + "needs_translation": "false", + "translation": "Cut-off point" + }, + "error_limit": { + "english": "Error limit", + "needs_translation": "false", + "translation": "Error limit" + }, + "error_rotation": { + "english": "Error rotation", + "needs_translation": "false", + "translation": "Error rotation" + }, + "ground_error_decay": { + "english": "Ground Error Decay", + "needs_translation": "false", + "translation": "Ground Error Decay" + }, + "help_p1": { + "english": "Error decay ground: PID decay to help prevent heli from tipping over when on the ground.", + "needs_translation": "false", + "translation": "Error decay ground: PID decay to help prevent heli from tipping over when on the ground." + }, + "help_p2": { + "english": "Error limit: Angle limit for I-term.", + "needs_translation": "false", + "translation": "Error limit: Angle limit for I-term." + }, + "help_p3": { + "english": "Offset limit: Angle limit for High Speed Integral (O-term).", + "needs_translation": "false", + "translation": "Offset limit: Angle limit for High Speed Integral (O-term)." + }, + "help_p4": { + "english": "Error rotation: Allow errors to be shared between all axes.", + "needs_translation": "false", + "translation": "Error rotation: Allow errors to be shared between all axes." + }, + "help_p5": { + "english": "I-term relax: Limit accumulation of I-term during fast movements - helps reduce bounce back after fast stick movements. Generally needs to be lower for large helis and can be higher for small helis. Best to only reduce as much as is needed for your flying style.", + "needs_translation": "false", + "translation": "I-term relax: Limit accumulation of I-term during fast movements - helps reduce bounce back after fast stick movements. Generally needs to be lower for large helis and can be higher for small helis. Best to only reduce as much as is needed for your flying style." + }, + "hsi_offset_limit": { + "english": "HSI Offset limit", + "needs_translation": "false", + "translation": "HSI Offset limit" + }, + "inflight_error_decay": { + "english": "Inflight Error Decay", + "needs_translation": "false", + "translation": "Inflight Error Decay" + }, + "iterm_relax": { + "english": "I-term relax", + "needs_translation": "false", + "translation": "I-term relax" + }, + "limit": { + "english": "Limit", + "needs_translation": "false", + "translation": "Limit" + }, + "name": { + "english": "PID Controller", + "needs_translation": "false", + "translation": "PID Controller" + }, + "pitch": { + "english": "P", + "needs_translation": "false", + "translation": "P" + }, + "roll": { + "english": "R", + "needs_translation": "false", + "translation": "R" + }, + "time": { + "english": "Time", + "needs_translation": "false", + "translation": "Time" + }, + "yaw": { + "english": "Y", + "needs_translation": "false", + "translation": "Y" + } + }, + "profile_rescue": { + "accel": { + "english": "Accel", + "needs_translation": "false", + "translation": "Accel" + }, + "climb": { + "english": "Climb", + "needs_translation": "false", + "translation": "Climb" + }, + "collective": { + "english": "Collective", + "needs_translation": "false", + "translation": "Collective" + }, + "exit_time": { + "english": "Exit time", + "needs_translation": "false", + "translation": "Exit time" + }, + "fail_time": { + "english": "Fail time", + "needs_translation": "false", + "translation": "Fail time" + }, + "flip": { + "english": "Flip", + "needs_translation": "false", + "translation": "Flip" + }, + "flip_upright": { + "english": "Flip to upright", + "needs_translation": "false", + "translation": "Flip to upright" + }, + "gains": { + "english": "Gains", + "needs_translation": "false", + "translation": "Gains" + }, + "help_p1": { + "english": "Flip to upright: Flip the heli upright when rescue is activated.", + "needs_translation": "false", + "translation": "Flip to upright: Flip the heli upright when rescue is activated." + }, + "help_p2": { + "english": "Pull-up: How much collective and for how long to arrest the fall.", + "needs_translation": "false", + "translation": "Pull-up: How much collective and for how long to arrest the fall." + }, + "help_p3": { + "english": "Climb: How much collective to maintain a steady climb - and how long.", + "needs_translation": "false", + "translation": "Climb: How much collective to maintain a steady climb - and how long." + }, + "help_p4": { + "english": "Hover: How much collective to maintain a steady hover.", + "needs_translation": "false", + "translation": "Hover: How much collective to maintain a steady hover." + }, + "help_p5": { + "english": "Flip: How long to wait before aborting because the flip did not work.", + "needs_translation": "false", + "translation": "Flip: How long to wait before aborting because the flip did not work." + }, + "help_p6": { + "english": "Gains: How hard to fight to keep heli level when engaging rescue mode.", + "needs_translation": "false", + "translation": "Gains: How hard to fight to keep heli level when engaging rescue mode." + }, + "help_p7": { + "english": "Rate and Accel: Max rotation and acceleration rates when leveling during rescue.", + "needs_translation": "false", + "translation": "Rate and Accel: Max rotation and acceleration rates when leveling during rescue." + }, + "hover": { + "english": "Hover", + "needs_translation": "false", + "translation": "Hover" + }, + "level_gain": { + "english": "Level", + "needs_translation": "false", + "translation": "Level" + }, + "mode_enable": { + "english": "Rescue mode enable", + "needs_translation": "false", + "translation": "Rescue mode enable" + }, + "name": { + "english": "Rescue", + "needs_translation": "false", + "translation": "Rescue" + }, + "pull_up": { + "english": "Pull-up", + "needs_translation": "false", + "translation": "Pull-up" + }, + "rate": { + "english": "Rate", + "needs_translation": "false", + "translation": "Rate" + }, + "time": { + "english": "Time", + "needs_translation": "false", + "translation": "Time" + } + }, + "profile_select": { + "cancel": { + "english": "CANCEL", + "needs_translation": "false", + "translation": "CANCEL" + }, + "help_p1": { + "english": "Set the current flight profile or rate profile you would like to use.", + "needs_translation": "false", + "translation": "Set the current flight profile or rate profile you would like to use." + }, + "help_p2": { + "english": "If you use a switch on your radio to change flight or rate modes, this will override this choice as soon as you toggle the switch.", + "needs_translation": "false", + "translation": "If you use a switch on your radio to change flight or rate modes, this will override this choice as soon as you toggle the switch." + }, + "name": { + "english": "Select Profile", + "needs_translation": "false", + "translation": "Select Profile" + }, + "ok": { + "english": "OK", + "needs_translation": "false", + "translation": "OK" + }, + "pid_profile": { + "english": "PID profile", + "needs_translation": "false", + "translation": "PID profile" + }, + "rate_profile": { + "english": "Rate Profile", + "needs_translation": "false", + "translation": "Rate Profile" + }, + "save_prompt": { + "english": "Save current page to flight controller?", + "needs_translation": "false", + "translation": "Save current page to flight controller?" + }, + "save_prompt_local": { + "english": "Save current page to radio?", + "needs_translation": "false", + "translation": "Save current page to radio?" + }, + "save_settings": { + "english": "Save settings", + "needs_translation": "false", + "translation": "Save settings" + } + }, + "profile_tailrotor": { + "ccw": { + "english": "CCW", + "needs_translation": "false", + "translation": "CCW" + }, + "collective_ff_gain": { + "english": "Collective FF gain", + "needs_translation": "false", + "translation": "Collective FF gain" + }, + "collective_impulse_ff": { + "english": "Collective Impulse FF", + "needs_translation": "false", + "translation": "Collective Impulse FF" + }, + "cutoff": { + "english": "Cutoff", + "needs_translation": "false", + "translation": "Cutoff" + }, + "cw": { + "english": "CW", + "needs_translation": "false", + "translation": "CW" + }, + "cyclic_ff_gain": { + "english": "Cyclic FF gain", + "needs_translation": "false", + "translation": "Cyclic FF gain" + }, + "decay": { + "english": "Decay", + "needs_translation": "false", + "translation": "Decay" + }, + "gain": { + "english": "Gain", + "needs_translation": "false", + "translation": "Gain" + }, + "help_p1": { + "english": "Yaw Stop Gain: Higher stop gain will make the tail stop more aggressively but may cause oscillations if too high. Adjust CW or CCW to make the yaw stops even.", + "needs_translation": "false", + "translation": "Yaw Stop Gain: Higher stop gain will make the tail stop more aggressively but may cause oscillations if too high. Adjust CW or CCW to make the yaw stops even." + }, + "help_p2": { + "english": "Precomp Cutoff: Frequency limit for all yaw precompensation actions.", + "needs_translation": "false", + "translation": "Precomp Cutoff: Frequency limit for all yaw precompensation actions." + }, + "help_p3": { + "english": "Cyclic FF Gain: Tail precompensation for cyclic inputs.", + "needs_translation": "false", + "translation": "Cyclic FF Gain: Tail precompensation for cyclic inputs." + }, + "help_p4": { + "english": "Collective FF Gain: Tail precompensation for collective inputs.", + "needs_translation": "false", + "translation": "Collective FF Gain: Tail precompensation for collective inputs." + }, + "help_p5": { + "english": "Collective Impulse FF: Impulse tail precompensation for collective inputs. If you need extra tail precompensation at the beginning of collective input.", + "needs_translation": "false", + "translation": "Collective Impulse FF: Impulse tail precompensation for collective inputs. If you need extra tail precompensation at the beginning of collective input." + }, + "inertia_precomp": { + "english": "Inertia Precomp", + "needs_translation": "false", + "translation": "Inertia Precomp" + }, + "name": { + "english": "Tail Rotor", + "needs_translation": "false", + "translation": "Tail Rotor" + }, + "precomp_cutoff": { + "english": "Precomp Cutoff", + "needs_translation": "false", + "translation": "Precomp Cutoff" + }, + "yaw_stop_gain": { + "english": "Yaw stop gain", + "needs_translation": "false", + "translation": "Yaw stop gain" + } + }, + "radio_config": { + "arming": { + "english": "Arming", + "needs_translation": "false", + "translation": "Arming" + }, + "center": { + "english": "Center", + "needs_translation": "false", + "translation": "Center" + }, + "cyclic": { + "english": "Cyclic", + "needs_translation": "false", + "translation": "Cyclic" + }, + "deadband": { + "english": "Deadband", + "needs_translation": "false", + "translation": "Deadband" + }, + "deflection": { + "english": "Deflection", + "needs_translation": "false", + "translation": "Deflection" + }, + "help_p1": { + "english": "Configure your radio settings. Stick center, arm, throttle hold, and throttle cut.", + "needs_translation": "false", + "translation": "Configure your radio settings. Stick center, arm, throttle hold, and throttle cut." + }, + "max_throttle": { + "english": "Max", + "needs_translation": "false", + "translation": "Max" + }, + "min_throttle": { + "english": "Min", + "needs_translation": "false", + "translation": "Min" + }, + "name": { + "english": "Radio Config", + "needs_translation": "false", + "translation": "Radio Config" + }, + "stick": { + "english": "Stick", + "needs_translation": "false", + "translation": "Stick" + }, + "throttle": { + "english": "Throttle", + "needs_translation": "false", + "translation": "Throttle" + }, + "yaw_deadband": { + "english": "Yaw", + "needs_translation": "false", + "translation": "Yaw" + } + }, + "rates": { + "acroplus": { + "english": "Acro+", + "needs_translation": "false", + "translation": "Acro+" + }, + "actual": { + "english": "ACTUAL", + "needs_translation": "false", + "translation": "ACTUAL" + }, + "betaflight": { + "english": "BETAFLIGHT", + "needs_translation": "false", + "translation": "BETAFLIGHT" + }, + "center_sensitivity": { + "english": "Cntr. Sens.", + "needs_translation": "false", + "translation": "Cntr. Sens." + }, + "collective": { + "english": "Col", + "needs_translation": "false", + "translation": "Col" + }, + "expo": { + "english": "Expo", + "needs_translation": "false", + "translation": "Expo" + }, + "help_default_p1": { + "english": "Default: We keep this to make button appear for rates.", + "needs_translation": "false", + "translation": "Default: We keep this to make button appear for rates." + }, + "help_default_p2": { + "english": "We will use the sub keys below.", + "needs_translation": "false", + "translation": "We will use the sub keys below." + }, + "help_table_0_p1": { + "english": "All values are set to zero because no RATE TABLE is in use.", + "needs_translation": "false", + "translation": "All values are set to zero because no RATE TABLE is in use." + }, + "help_table_1_p1": { + "english": "RC Rate: Maximum rotation rate at full stick deflection.", + "needs_translation": "false", + "translation": "RC Rate: Maximum rotation rate at full stick deflection." + }, + "help_table_1_p2": { + "english": "SuperRate: Increases maximum rotation rate while reducing sensitivity around half stick.", + "needs_translation": "false", + "translation": "SuperRate: Increases maximum rotation rate while reducing sensitivity around half stick." + }, + "help_table_1_p3": { + "english": "Expo: Reduces sensitivity near the stick's center where fine controls are needed.", + "needs_translation": "false", + "translation": "Expo: Reduces sensitivity near the stick's center where fine controls are needed." + }, + "help_table_2_p1": { + "english": "Rate: Maximum rotation rate at full stick deflection in degrees per second.", + "needs_translation": "false", + "translation": "Rate: Maximum rotation rate at full stick deflection in degrees per second." + }, + "help_table_2_p2": { + "english": "Acro+: Increases the maximum rotation rate while reducing sensitivity around half stick.", + "needs_translation": "false", + "translation": "Acro+: Increases the maximum rotation rate while reducing sensitivity around half stick." + }, + "help_table_2_p3": { + "english": "Expo: Reduces sensitivity near the stick's center where fine controls are needed.", + "needs_translation": "false", + "translation": "Expo: Reduces sensitivity near the stick's center where fine controls are needed." + }, + "help_table_3_p1": { + "english": "RC Rate: Maximum rotation rate at full stick deflection.", + "needs_translation": "false", + "translation": "RC Rate: Maximum rotation rate at full stick deflection." + }, + "help_table_3_p2": { + "english": "Rate: Increases maximum rotation rate while reducing sensitivity around half stick.", + "needs_translation": "false", + "translation": "Rate: Increases maximum rotation rate while reducing sensitivity around half stick." + }, + "help_table_3_p3": { + "english": "RC Curve: Reduces sensitivity near the stick's center where fine controls are needed.", + "needs_translation": "false", + "translation": "RC Curve: Reduces sensitivity near the stick's center where fine controls are needed." + }, + "help_table_4_p1": { + "english": "Center Sensitivity: Use to reduce sensitivity around center stick. Set Center Sensitivity to the same as Max Rate for a linear response. A lower number than Max Rate will reduce sensitivity around center stick. Note that higher than Max Rate will increase the Max Rate - not recommended as it causes issues in the Blackbox log.", + "needs_translation": "false", + "translation": "Center Sensitivity: Use to reduce sensitivity around center stick. Set Center Sensitivity to the same as Max Rate for a linear response. A lower number than Max Rate will reduce sensitivity around center stick. Note that higher than Max Rate will increase the Max Rate - not recommended as it causes issues in the Blackbox log." + }, + "help_table_4_p2": { + "english": "Max Rate: Maximum rotation rate at full stick deflection in degrees per second.", + "needs_translation": "false", + "translation": "Max Rate: Maximum rotation rate at full stick deflection in degrees per second." + }, + "help_table_4_p3": { + "english": "Expo: Reduces sensitivity near the stick's center where fine controls are needed.", + "needs_translation": "false", + "translation": "Expo: Reduces sensitivity near the stick's center where fine controls are needed." + }, + "help_table_5_p1": { + "english": "RC Rate: Use to reduce sensitivity around center stick. RC Rate set to one half of the Max Rate is linear. A lower number will reduce sensitivity around center stick. Higher than one half of the Max Rate will also increase the Max Rate.", + "needs_translation": "false", + "translation": "RC Rate: Use to reduce sensitivity around center stick. RC Rate set to one half of the Max Rate is linear. A lower number will reduce sensitivity around center stick. Higher than one half of the Max Rate will also increase the Max Rate." + }, + "help_table_5_p2": { + "english": "Max Rate: Maximum rotation rate at full stick deflection in degrees per second.", + "needs_translation": "false", + "translation": "Max Rate: Maximum rotation rate at full stick deflection in degrees per second." + }, + "help_table_5_p3": { + "english": "Expo: Reduces sensitivity near the stick's center where fine controls are needed.", + "needs_translation": "false", + "translation": "Expo: Reduces sensitivity near the stick's center where fine controls are needed." + }, + "kiss": { + "english": "KISS", + "needs_translation": "false", + "translation": "KISS" + }, + "max_rate": { + "english": "Max Rate", + "needs_translation": "false", + "translation": "Max Rate" + }, + "name": { + "english": "Rates", + "needs_translation": "false", + "translation": "Rates" + }, + "none": { + "english": "NONE", + "needs_translation": "false", + "translation": "NONE" + }, + "pitch": { + "english": "Pitch", + "needs_translation": "false", + "translation": "Pitch" + }, + "quick": { + "english": "QUICK", + "needs_translation": "false", + "translation": "QUICK" + }, + "raceflight": { + "english": "RACEFLIGHT", + "needs_translation": "false", + "translation": "RACEFLIGHT" + }, + "rate": { + "english": "Rate", + "needs_translation": "false", + "translation": "Rate" + }, + "rc_curve": { + "english": "RC Curve", + "needs_translation": "false", + "translation": "RC Curve" + }, + "rc_rate": { + "english": "RC Rate", + "needs_translation": "false", + "translation": "RC Rate" + }, + "roll": { + "english": "Roll", + "needs_translation": "false", + "translation": "Roll" + }, + "superrate": { + "english": "SuperRate", + "needs_translation": "false", + "translation": "SuperRate" + }, + "yaw": { + "english": "Yaw", + "needs_translation": "false", + "translation": "Yaw" + } + }, + "rates_advanced": { + "acc_limit": { + "english": "Accelerometer Limit", + "needs_translation": "false", + "translation": "Accelerometer Limit" + }, + "accel_limit": { + "english": "Accel", + "needs_translation": "false", + "translation": "Accel" + }, + "col": { + "english": "Col", + "needs_translation": "false", + "translation": "Col" + }, + "collective_boost": { + "english": "Collective boost", + "needs_translation": "false", + "translation": "Collective boost" + }, + "collective_dynamics": { + "english": "Collective dynamics", + "needs_translation": "false", + "translation": "Collective dynamics" + }, + "cutoff": { + "english": "Cutoff", + "needs_translation": "false", + "translation": "Cutoff" + }, + "dyn_ceiling_gain": { + "english": "Dynamic ceiling gain", + "needs_translation": "false", + "translation": "Dynamic ceiling gain" + }, + "dyn_deadband_filter": { + "english": "Dynamic deadband filter", + "needs_translation": "false", + "translation": "Dynamic deadband filter" + }, + "dyn_deadband_gain": { + "english": "Dynamic deadband gain", + "needs_translation": "false", + "translation": "Dynamic deadband gain" + }, + "dynamics": { + "english": "Dynamics", + "needs_translation": "false", + "translation": "Dynamics" + }, + "gain": { + "english": "Gain", + "needs_translation": "false", + "translation": "Gain" + }, + "help_p1": { + "english": "Rates type: Choose the rate type you prefer flying with. Raceflight and Actual are the most straightforward.", + "needs_translation": "false", + "translation": "Rates type: Choose the rate type you prefer flying with. Raceflight and Actual are the most straightforward." + }, + "help_p2": { + "english": "Dynamics: Applied regardless of rates type. Typically left on defaults but can be adjusted to smooth heli movements, like with scale helis.", + "needs_translation": "false", + "translation": "Dynamics: Applied regardless of rates type. Typically left on defaults but can be adjusted to smooth heli movements, like with scale helis." + }, + "help_rate_table": { + "english": "Please select the rate you would like to use. Saving will apply the choice to the active profile.", + "needs_translation": "false", + "translation": "Please select the rate you would like to use. Saving will apply the choice to the active profile." + }, + "msg_reset_to_defaults": { + "english": "Rate type changed. Values will be reset to defaults.", + "needs_translation": "false", + "translation": "Rate type changed. Values will be reset to defaults." + }, + "name": { + "english": "Rates", + "needs_translation": "false", + "translation": "Rates" + }, + "pitch": { + "english": "Pitch", + "needs_translation": "false", + "translation": "Pitch" + }, + "pitch_boost": { + "english": "Pitch boost", + "needs_translation": "false", + "translation": "Pitch boost" + }, + "pitch_dynamics": { + "english": "Pitch dynamics", + "needs_translation": "false", + "translation": "Pitch dynamics" + }, + "rate_table": { + "english": "Rate Table", + "needs_translation": "false", + "translation": "Rate Table" + }, + "rates_type": { + "english": "Rates Type", + "needs_translation": "false", + "translation": "Rates Type" + }, + "response_time": { + "english": "Response Time", + "needs_translation": "false", + "translation": "Response Time" + }, + "roll": { + "english": "Roll", + "needs_translation": "false", + "translation": "Roll" + }, + "roll_boost": { + "english": "Roll boost", + "needs_translation": "false", + "translation": "Roll boost" + }, + "roll_dynamics": { + "english": "Roll dynamics", + "needs_translation": "false", + "translation": "Roll dynamics" + }, + "setpoint_boost_cutoff": { + "english": "Setpoint boost cutoff", + "needs_translation": "false", + "translation": "Setpoint boost cutoff" + }, + "setpoint_boost_gain": { + "english": "Setpoint boost gain", + "needs_translation": "false", + "translation": "Setpoint boost gain" + }, + "yaw": { + "english": "Yaw", + "needs_translation": "false", + "translation": "Yaw" + }, + "yaw_boost": { + "english": "Yaw boost", + "needs_translation": "false", + "translation": "Yaw boost" + }, + "yaw_dynamic_ceiling_gain": { + "english": "Ceiling", + "needs_translation": "false", + "translation": "Ceiling" + }, + "yaw_dynamic_deadband_filter": { + "english": "Filter", + "needs_translation": "false", + "translation": "Filter" + }, + "yaw_dynamic_deadband_gain": { + "english": "D. Band", + "needs_translation": "false", + "translation": "D. Band" + }, + "yaw_dynamics": { + "english": "Yaw dynamics", + "needs_translation": "false", + "translation": "Yaw dynamics" + } + }, + "sbusout": { + "cancel": { + "english": "CANCEL", + "needs_translation": "false", + "translation": "CANCEL" + }, + "ch_prefix": { + "english": "CH", + "needs_translation": "false", + "translation": "CH" + }, + "channel_page": { + "english": "Sbus out / CH", + "needs_translation": "false", + "translation": "Sbus out / CH" + }, + "channel_prefix": { + "english": "CHANNEL ", + "needs_translation": "false", + "translation": "CHANNEL " + }, + "help_default_p1": { + "english": "Configure advanced mixing and channel mapping if you have SBUS Out enabled on a serial port.", + "needs_translation": "false", + "translation": "Configure advanced mixing and channel mapping if you have SBUS Out enabled on a serial port." + }, + "help_default_p2": { + "english": "- For RX channels or servos (wideband), use 1000, 2000 or 500,1000 for narrow band servos.", + "needs_translation": "false", + "translation": "- For RX channels or servos (wideband), use 1000, 2000 or 500,1000 for narrow band servos." + }, + "help_default_p3": { + "english": "- For mixer rules, use -1000, 1000.", + "needs_translation": "false", + "translation": "- For mixer rules, use -1000, 1000." + }, + "help_default_p4": { + "english": "- For motors, use 0, 1000.", + "needs_translation": "false", + "translation": "- For motors, use 0, 1000." + }, + "help_default_p5": { + "english": "- Or you can customize your own mapping.", + "needs_translation": "false", + "translation": "- Or you can customize your own mapping." + }, + "help_fields_max": { + "english": "The maximum pwm value to send", + "needs_translation": "false", + "translation": "The maximum pwm value to send" + }, + "help_fields_min": { + "english": "The minimum pwm value to send.", + "needs_translation": "false", + "translation": "The minimum pwm value to send." + }, + "help_fields_source": { + "english": "Source id for the mix, counting from 0-15.", + "needs_translation": "false", + "translation": "Source id for the mix, counting from 0-15." + }, + "max": { + "english": "Max", + "needs_translation": "false", + "translation": "Max" + }, + "min": { + "english": "Min", + "needs_translation": "false", + "translation": "Min" + }, + "mixer": { + "english": "Mixer", + "needs_translation": "false", + "translation": "Mixer" + }, + "motor": { + "english": "Motor", + "needs_translation": "false", + "translation": "Motor" + }, + "name": { + "english": "SBUS Out", + "needs_translation": "false", + "translation": "SBUS Out" + }, + "ok": { + "english": "OK", + "needs_translation": "false", + "translation": "OK" + }, + "receiver": { + "english": "Receiver", + "needs_translation": "false", + "translation": "Receiver" + }, + "save_prompt": { + "english": "Save current page to flight controller?", + "needs_translation": "false", + "translation": "Save current page to flight controller?" + }, + "save_settings": { + "english": "Save settings", + "needs_translation": "false", + "translation": "Save settings" + }, + "saving": { + "english": "Saving", + "needs_translation": "false", + "translation": "Saving" + }, + "saving_data": { + "english": "Saving data...", + "needs_translation": "false", + "translation": "Saving data..." + }, + "servo": { + "english": "Servo", + "needs_translation": "false", + "translation": "Servo" + }, + "source": { + "english": "Source", + "needs_translation": "false", + "translation": "Source" + }, + "title": { + "english": "SBUS Output", + "needs_translation": "false", + "translation": "SBUS Output" + }, + "type": { + "english": "Type", + "needs_translation": "false", + "translation": "Type" + } + }, + "servos": { + "center": { + "english": "Center", + "needs_translation": "false", + "translation": "Center" + }, + "cyc_left": { + "english": "CYC.LEFT", + "needs_translation": "false", + "translation": "CYC.LEFT" + }, + "cyc_pitch": { + "english": "CYC.PITCH", + "needs_translation": "false", + "translation": "CYC.PITCH" + }, + "cyc_right": { + "english": "CYC.RIGHT", + "needs_translation": "false", + "translation": "CYC.RIGHT" + }, + "disable_servo_override": { + "english": "Disable servo override", + "needs_translation": "false", + "translation": "Disable servo override" + }, + "disable_servo_override_msg": { + "english": "Return control of the servos to the flight controller.", + "needs_translation": "false", + "translation": "Return control of the servos to the flight controller." + }, + "disabling_servo_override": { + "english": "Disabling servo override...", + "needs_translation": "false", + "translation": "Disabling servo override..." + }, + "enable_servo_override": { + "english": "Enable servo override", + "needs_translation": "false", + "translation": "Enable servo override" + }, + "enable_servo_override_msg": { + "english": "Servo override allows you to 'trim' your servo center point in real time.", + "needs_translation": "false", + "translation": "Servo override allows you to 'trim' your servo center point in real time." + }, + "enabling_servo_override": { + "english": "Enabling servo override...", + "needs_translation": "false", + "translation": "Enabling servo override..." + }, + "geometry": { + "english": "Geometry", + "needs_translation": "false", + "translation": "Geometry" + }, + "help_default_p1": { + "english": "Please select the servo you would like to configure from the list below.", + "needs_translation": "false", + "translation": "Please select the servo you would like to configure from the list below." + }, + "help_default_p2": { + "english": "Primary flight controls that use the rotorflight mixer will display in the section called 'mixer'.", + "needs_translation": "false", + "translation": "Primary flight controls that use the rotorflight mixer will display in the section called 'mixer'." + }, + "help_default_p3": { + "english": "Any other servos that are not controlled by the primary flight mixer will be displayed in the section called 'Other servos'.", + "needs_translation": "false", + "translation": "Any other servos that are not controlled by the primary flight mixer will be displayed in the section called 'Other servos'." + }, + "help_fields_flags": { + "english": "0 = Default, 1=Reverse, 2 = Geo Correction, 3 = Reverse + Geo Correction", + "needs_translation": "false", + "translation": "0 = Default, 1=Reverse, 2 = Geo Correction, 3 = Reverse + Geo Correction" + }, + "help_fields_max": { + "english": "Servo positive travel limit.", + "needs_translation": "false", + "translation": "Servo positive travel limit." + }, + "help_fields_mid": { + "english": "Servo center position pulse width.", + "needs_translation": "false", + "translation": "Servo center position pulse width." + }, + "help_fields_min": { + "english": "Servo negative travel limit.", + "needs_translation": "false", + "translation": "Servo negative travel limit." + }, + "help_fields_rate": { + "english": "Servo PWM rate.", + "needs_translation": "false", + "translation": "Servo PWM rate." + }, + "help_fields_scale_neg": { + "english": "Servo negative scaling.", + "needs_translation": "false", + "translation": "Servo negative scaling." + }, + "help_fields_scale_pos": { + "english": "Servo positive scaling.", + "needs_translation": "false", + "translation": "Servo positive scaling." + }, + "help_fields_speed": { + "english": "Servo motion speed in milliseconds.", + "needs_translation": "false", + "translation": "Servo motion speed in milliseconds." + }, + "help_tool_p1": { + "english": "Override: [*] Enable override to allow real-time updates of servo center point.", + "needs_translation": "false", + "translation": "Override: [*] Enable override to allow real-time updates of servo center point." + }, + "help_tool_p2": { + "english": "Center: Adjust the center position of the servo.", + "needs_translation": "false", + "translation": "Center: Adjust the center position of the servo." + }, + "help_tool_p3": { + "english": "Minimum/Maximum: Adjust the end points of the selected servo.", + "needs_translation": "false", + "translation": "Minimum/Maximum: Adjust the end points of the selected servo." + }, + "help_tool_p4": { + "english": "Scale: Adjust the amount the servo moves for a given input.", + "needs_translation": "false", + "translation": "Scale: Adjust the amount the servo moves for a given input." + }, + "help_tool_p5": { + "english": "Rate: The frequency the servo runs best at - check with manufacturer.", + "needs_translation": "false", + "translation": "Rate: The frequency the servo runs best at - check with manufacturer." + }, + "help_tool_p6": { + "english": "Speed: The speed the servo moves. Generally only used for the cyclic servos to help the swash move evenly. Optional - leave all at 0 if unsure.", + "needs_translation": "false", + "translation": "Speed: The speed the servo moves. Generally only used for the cyclic servos to help the swash move evenly. Optional - leave all at 0 if unsure." + }, + "maximum": { + "english": "Maximum", + "needs_translation": "false", + "translation": "Maximum" + }, + "minimum": { + "english": "Minimum", + "needs_translation": "false", + "translation": "Minimum" + }, + "name": { + "english": "Servos", + "needs_translation": "false", + "translation": "Servos" + }, + "rate": { + "english": "Rate", + "needs_translation": "false", + "translation": "Rate" + }, + "reverse": { + "english": "Reverse", + "needs_translation": "false", + "translation": "Reverse" + }, + "saving": { + "english": "Saving", + "needs_translation": "false", + "translation": "Saving" + }, + "saving_data": { + "english": "Saving data...", + "needs_translation": "false", + "translation": "Saving data..." + }, + "scale_negative": { + "english": "Scale Negative", + "needs_translation": "false", + "translation": "Scale Negative" + }, + "scale_positive": { + "english": "Scale Positive", + "needs_translation": "false", + "translation": "Scale Positive" + }, + "servo_override": { + "english": "Servo Override", + "needs_translation": "false", + "translation": "Servo Override" + }, + "servo_prefix": { + "english": "SERVO ", + "needs_translation": "false", + "translation": "SERVO " + }, + "speed": { + "english": "Speed", + "needs_translation": "false", + "translation": "Speed" + }, + "tail": { + "english": "TAIL", + "needs_translation": "false", + "translation": "TAIL" + }, + "tbl_no": { + "english": "NO", + "needs_translation": "false", + "translation": "NO" + }, + "tbl_yes": { + "english": "YES", + "needs_translation": "false", + "translation": "YES" + } + }, + "settings": { + "altitude_unit": { + "english": "Altitude Unit", + "needs_translation": "false", + "translation": "Altitude Unit" + }, + "audio": { + "english": "Audio", + "needs_translation": "false", + "translation": "Audio" + }, + "celcius": { + "english": "Celsius", + "needs_translation": "false", + "translation": "Celsius" + }, + "dashboard": { + "english": "Dashboard", + "needs_translation": "false", + "translation": "Dashboard" + }, + "dashboard_settings": { + "english": "Settings", + "needs_translation": "false", + "translation": "Settings" + }, + "dashboard_theme": { + "english": "Theme", + "needs_translation": "false", + "translation": "Theme" + }, + "dashboard_theme_inflight": { + "english": "Inflight Theme", + "needs_translation": "false", + "translation": "Inflight Theme" + }, + "dashboard_theme_panel_global": { + "english": "Default theme for all models", + "needs_translation": "false", + "translation": "Default theme for all models" + }, + "dashboard_theme_panel_model": { + "english": "Optional theme for this model", + "needs_translation": "false", + "translation": "Optional theme for this model" + }, + "dashboard_theme_panel_model_disabled": { + "english": "Disabled", + "needs_translation": "false", + "translation": "Disabled" + }, + "dashboard_theme_postflight": { + "english": "Postflight Theme", + "needs_translation": "false", + "translation": "Postflight Theme" + }, + "dashboard_theme_preflight": { + "english": "Preflight Theme", + "needs_translation": "false", + "translation": "Preflight Theme" + }, + "fahrenheit": { + "english": "Fahrenheit", + "needs_translation": "false", + "translation": "Fahrenheit" + }, + "feet": { + "english": "Feet", + "needs_translation": "false", + "translation": "Feet" + }, + "localizations": { + "english": "Localization", + "needs_translation": "false", + "translation": "Localization" + }, + "meters": { + "english": "Meters", + "needs_translation": "false", + "translation": "Meters" + }, + "model": { + "english": "Model", + "needs_translation": "false", + "translation": "Model" + }, + "name": { + "english": "Settings", + "needs_translation": "false", + "translation": "Settings" + }, + "no_themes_available_to_configure": { + "english": "No configurable themes installed on this device", + "needs_translation": "true", + "translation": "No configurable themes installed on this device" + }, + "temperature_unit": { + "english": "Temperature Unit", + "needs_translation": "false", + "translation": "Temperature Unit" + }, + "txt_apiversion": { + "english": "API Version", + "needs_translation": "false", + "translation": "API Version" + }, + "txt_audio_events": { + "english": "Events", + "needs_translation": "false", + "translation": "Events" + }, + "txt_audio_switches": { + "english": "Switches", + "needs_translation": "false", + "translation": "Switches" + }, + "txt_compilation": { + "english": "Compilation", + "needs_translation": "false", + "translation": "Compilation" + }, + "txt_console": { + "english": "CONSOLE", + "needs_translation": "false", + "translation": "CONSOLE" + }, + "txt_consolefile": { + "english": "CONSOLE & FILE", + "needs_translation": "false", + "translation": "CONSOLE & FILE" + }, + "txt_debug": { + "english": "DEBUG", + "needs_translation": "false", + "translation": "DEBUG" + }, + "txt_development": { + "english": "Development", + "needs_translation": "false", + "translation": "Development" + }, + "txt_devtools": { + "english": "Developer Tools", + "needs_translation": "false", + "translation": "Developer Tools" + }, + "txt_general": { + "english": "General", + "needs_translation": "false", + "translation": "General" + }, + "txt_iconsize": { + "english": "Icon Size", + "needs_translation": "false", + "translation": "Icon Size" + }, + "txt_info": { + "english": "INFO", + "needs_translation": "false", + "translation": "INFO" + }, + "txt_large": { + "english": "LARGE", + "needs_translation": "false", + "translation": "LARGE" + }, + "txt_logging": { + "english": "Logging", + "needs_translation": "false", + "translation": "Logging" + }, + "txt_loglevel": { + "english": "Log level", + "needs_translation": "false", + "translation": "Log level" + }, + "txt_loglocation": { + "english": "Log location", + "needs_translation": "false", + "translation": "Log location" + }, + "txt_memusage": { + "english": "Log memory usage", + "needs_translation": "false", + "translation": "Log memory usage" + }, + "txt_mspdata": { + "english": "Log msp data", + "needs_translation": "false", + "translation": "Log msp data" + }, + "txt_off": { + "english": "OFF", + "needs_translation": "false", + "translation": "OFF" + }, + "txt_queuesize": { + "english": "Log MSP queue size", + "needs_translation": "false", + "translation": "Log MSP queue size" + }, + "txt_small": { + "english": "SMALL", + "needs_translation": "false", + "translation": "SMALL" + }, + "txt_syncname": { + "english": "Sync model name", + "needs_translation": "false", + "translation": "Sync model name" + }, + "txt_text": { + "english": "TEXT", + "needs_translation": "false", + "translation": "TEXT" + } + }, + "status": { + "arming_disable_flag_0": { + "english": "No Gyro", + "needs_translation": "false", + "translation": "No Gyro" + }, + "arming_disable_flag_1": { + "english": "Fail Safe", + "needs_translation": "false", + "translation": "Fail Safe" + }, + "arming_disable_flag_10": { + "english": "No Pre Arm", + "needs_translation": "false", + "translation": "No Pre Arm" + }, + "arming_disable_flag_11": { + "english": "Load", + "needs_translation": "false", + "translation": "Load" + }, + "arming_disable_flag_12": { + "english": "Calibrating", + "needs_translation": "false", + "translation": "Calibrating" + }, + "arming_disable_flag_13": { + "english": "CLI", + "needs_translation": "false", + "translation": "CLI" + }, + "arming_disable_flag_14": { + "english": "CMS Menu", + "needs_translation": "false", + "translation": "CMS Menu" + }, + "arming_disable_flag_15": { + "english": "BST", + "needs_translation": "false", + "translation": "BST" + }, + "arming_disable_flag_16": { + "english": "MSP", + "needs_translation": "false", + "translation": "MSP" + }, + "arming_disable_flag_17": { + "english": "Paralyze", + "needs_translation": "false", + "translation": "Paralyze" + }, + "arming_disable_flag_18": { + "english": "GPS", + "needs_translation": "false", + "translation": "GPS" + }, + "arming_disable_flag_19": { + "english": "Resc", + "needs_translation": "false", + "translation": "Resc" + }, + "arming_disable_flag_2": { + "english": "RX Fail Safe", + "needs_translation": "false", + "translation": "RX Fail Safe" + }, + "arming_disable_flag_20": { + "english": "RPM Filter", + "needs_translation": "false", + "translation": "RPM Filter" + }, + "arming_disable_flag_21": { + "english": "Reboot Required", + "needs_translation": "false", + "translation": "Reboot Required" + }, + "arming_disable_flag_22": { + "english": "DSHOT Bitbang", + "needs_translation": "false", + "translation": "DSHOT Bitbang" + }, + "arming_disable_flag_23": { + "english": "Acc Calibration", + "needs_translation": "false", + "translation": "Acc Calibration" + }, + "arming_disable_flag_24": { + "english": "Motor Protocol", + "needs_translation": "false", + "translation": "Motor Protocol" + }, + "arming_disable_flag_25": { + "english": "Arm Switch", + "needs_translation": "false", + "translation": "Arm Switch" + }, + "arming_disable_flag_3": { + "english": "Bad RX Recovery", + "needs_translation": "false", + "translation": "Bad RX Recovery" + }, + "arming_disable_flag_4": { + "english": "Box Fail Safe", + "needs_translation": "false", + "translation": "Box Fail Safe" + }, + "arming_disable_flag_5": { + "english": "Governor", + "needs_translation": "false", + "translation": "Governor" + }, + "arming_disable_flag_7": { + "english": "Throttle", + "needs_translation": "false", + "translation": "Throttle" + }, + "arming_disable_flag_8": { + "english": "Angle", + "needs_translation": "false", + "translation": "Angle" + }, + "arming_disable_flag_9": { + "english": "Boot Grace Time", + "needs_translation": "false", + "translation": "Boot Grace Time" + }, + "arming_flags": { + "english": "Arming Flags", + "needs_translation": "false", + "translation": "Arming Flags" + }, + "cpu_load": { + "english": "CPU Load", + "needs_translation": "false", + "translation": "CPU Load" + }, + "dataflash_free_space": { + "english": "Dataflash Free Space", + "needs_translation": "false", + "translation": "Dataflash Free Space" + }, + "erase": { + "english": "Erase", + "needs_translation": "false", + "translation": "Erase" + }, + "erase_prompt": { + "english": "Would you like to erase the dataflash?", + "needs_translation": "false", + "translation": "Would you like to erase the dataflash?" + }, + "erasing": { + "english": "Erasing", + "needs_translation": "false", + "translation": "Erasing" + }, + "erasing_dataflash": { + "english": "Erasing dataflash...", + "needs_translation": "false", + "translation": "Erasing dataflash..." + }, + "help_p1": { + "english": "Use this page to view your current flight controller status. This can be useful when determining why your heli will not arm.", + "needs_translation": "false", + "translation": "Use this page to view your current flight controller status. This can be useful when determining why your heli will not arm." + }, + "help_p2": { + "english": "To erase the dataflash for more log file storage, press the button on the menu denoted by a '*'.", + "needs_translation": "false", + "translation": "To erase the dataflash for more log file storage, press the button on the menu denoted by a '*'." + }, + "megabyte": { + "english": "MB", + "needs_translation": "false", + "translation": "MB" + }, + "name": { + "english": "Status", + "needs_translation": "false", + "translation": "Status" + }, + "ok": { + "english": "OK", + "needs_translation": "false", + "translation": "OK" + }, + "real_time_load": { + "english": "Real-time Load", + "needs_translation": "false", + "translation": "Real-time Load" + }, + "unsupported": { + "english": "Unsupported", + "needs_translation": "false", + "translation": "Unsupported" + } + }, + "trim": { + "collective_trim": { + "english": "Col. trim %", + "needs_translation": "false", + "translation": "Col. trim %" + }, + "disable_mixer_message": { + "english": "Return control of the servos to the flight controller.", + "needs_translation": "false", + "translation": "Return control of the servos to the flight controller." + }, + "disable_mixer_override": { + "english": "Disable mixer override", + "needs_translation": "false", + "translation": "Disable mixer override" + }, + "enable_mixer_message": { + "english": "Set all servos to their configured center position. \r\n\r\nThis will result in all values on this page being saved when adjusting the servo trim.", + "needs_translation": "false", + "translation": "Set all servos to their configured center position. \r\n\r\nThis will result in all values on this page being saved when adjusting the servo trim." + }, + "enable_mixer_override": { + "english": "Enable mixer override", + "needs_translation": "false", + "translation": "Enable mixer override" + }, + "help_p1": { + "english": "Link trims: Use to trim out small leveling issues in your swash plate. Typically only used if the swash links are non-adjustable.", + "needs_translation": "false", + "translation": "Link trims: Use to trim out small leveling issues in your swash plate. Typically only used if the swash links are non-adjustable." + }, + "help_p2": { + "english": "Motorised tail: If using a motorised tail, use this to set the minimum idle speed and zero yaw.", + "needs_translation": "false", + "translation": "Motorised tail: If using a motorised tail, use this to set the minimum idle speed and zero yaw." + }, + "mixer_override": { + "english": "Mixer Override", + "needs_translation": "false", + "translation": "Mixer Override" + }, + "mixer_override_disabling": { + "english": "Disabling mixer override...", + "needs_translation": "false", + "translation": "Disabling mixer override..." + }, + "mixer_override_enabling": { + "english": "Enabling mixer override...", + "needs_translation": "false", + "translation": "Enabling mixer override..." + }, + "name": { + "english": "Trim", + "needs_translation": "false", + "translation": "Trim" + }, + "pitch_trim": { + "english": "Pitch trim %", + "needs_translation": "false", + "translation": "Pitch trim %" + }, + "roll_trim": { + "english": "Roll trim %", + "needs_translation": "false", + "translation": "Roll trim %" + }, + "tail_motor_idle": { + "english": "Tail Motor idle %", + "needs_translation": "false", + "translation": "Tail Motor idle %" + }, + "yaw_trim": { + "english": "Yaw. trim %", + "needs_translation": "false", + "translation": "Yaw. trim %" + } + }, + "validate_sensors": { + "help_p1": { + "english": "This tool attempts to list all the sensors that you are not receiving in a concise list.", + "needs_translation": "false", + "translation": "This tool attempts to list all the sensors that you are not receiving in a concise list." + }, + "help_p2": { + "english": "Use this tool to ensure you are sending the correct sensors.", + "needs_translation": "false", + "translation": "Use this tool to ensure you are sending the correct sensors." + }, + "invalid": { + "english": "INVALID", + "needs_translation": "false", + "translation": "INVALID" + }, + "msg_repair": { + "english": "Enable required sensors on flight controller?", + "needs_translation": "false", + "translation": "Enable required sensors on flight controller?" + }, + "msg_repair_fin": { + "english": "The flight controller has been configured? You may need to perform a discover sensors to see the changes.", + "needs_translation": "false", + "translation": "The flight controller has been configured? You may need to perform a discover sensors to see the changes." + }, + "name": { + "english": "Sensors", + "needs_translation": "false", + "translation": "Sensors" + }, + "ok": { + "english": "OK", + "needs_translation": "false", + "translation": "OK" + } + } + }, + "msg_connecting": { + "english": "Connecting", + "needs_translation": "false", + "translation": "Connecting" + }, + "msg_connecting_to_fbl": { + "english": "Connecting...", + "needs_translation": "false", + "translation": "Connecting..." + }, + "msg_loading": { + "english": "Loading...", + "needs_translation": "false", + "translation": "Loading..." + }, + "msg_loading_from_fbl": { + "english": "Loading data from flight controller...", + "needs_translation": "false", + "translation": "Loading data from flight controller..." + }, + "msg_please_disarm_to_save": { + "english": "Please disarm to save", + "needs_translation": "false", + "translation": "Please disarm to save" + }, + "msg_please_disarm_to_save_warning": { + "english": "Settings will only be saved to eeprom on disarm", + "needs_translation": "false", + "translation": "Settings will only be saved to eeprom on disarm" + }, + "msg_rebooting": { + "english": "Rebooting...", + "needs_translation": "false", + "translation": "Rebooting..." + }, + "msg_reload_settings": { + "english": "Reload data from flight controller?", + "needs_translation": "false", + "translation": "Reload data from flight controller?" + }, + "msg_save_current_page": { + "english": "Save current page to flight controller?", + "needs_translation": "false", + "translation": "Save current page to flight controller?" + }, + "msg_save_not_commited": { + "english": "Save not committed to EEPROM", + "needs_translation": "false", + "translation": "Save not committed to EEPROM" + }, + "msg_save_settings": { + "english": "Save settings", + "needs_translation": "false", + "translation": "Save settings" + }, + "msg_saving": { + "english": "Saving...", + "needs_translation": "false", + "translation": "Saving..." + }, + "msg_saving_settings": { + "english": "Saving settings...", + "needs_translation": "false", + "translation": "Saving settings..." + }, + "msg_saving_to_fbl": { + "english": "Saving data to flight controller...", + "needs_translation": "false", + "translation": "Saving data to flight controller..." + }, + "navigation_help": { + "english": "?", + "needs_translation": "false", + "translation": "?" + }, + "navigation_menu": { + "english": "MENU", + "needs_translation": "false", + "translation": "MENU" + }, + "navigation_reload": { + "english": "RELOAD", + "needs_translation": "false", + "translation": "RELOAD" + }, + "navigation_save": { + "english": "SAVE", + "needs_translation": "false", + "translation": "SAVE" + }, + "navigation_tools": { + "english": "*", + "needs_translation": "false", + "translation": "*" + }, + "unit_hertz": { + "english": "Hz", + "needs_translation": "false", + "translation": "Hz" + } + }, + "background_task_disabled": { + "english": "background task disabled", + "needs_translation": "false", + "translation": "background task disabled" + }, + "bg_task_disabled": { + "english": "bg task disabled", + "needs_translation": "false", + "translation": "bg task disabled" + }, + "error": { + "english": "error", + "needs_translation": "false", + "translation": "error" + }, + "ethos": { + "english": "ethos", + "needs_translation": "false", + "translation": "ethos" + }, + "image": { + "english": "image", + "needs_translation": "false", + "translation": "image" + }, + "iscompiledcheck": { + "english": "true", + "needs_translation": false, + "translation": "true" + }, + "no_link": { + "english": "no link", + "needs_translation": "false", + "translation": "no link" + }, + "no_sensor": { + "english": "no sensor", + "needs_translation": "false", + "translation": "no sensor" + }, + "reload": { + "english": "reload", + "needs_translation": "false", + "translation": "reload" + }, + "save": { + "english": "save", + "needs_translation": "false", + "translation": "save" + }, + "telemetry": { + "sensors": { + "accx": { + "english": "Accel X", + "needs_translation": "false", + "translation": "Accel X" + }, + "accy": { + "english": "Accel Y", + "needs_translation": "false", + "translation": "Accel Y" + }, + "accz": { + "english": "Accel Z", + "needs_translation": "false", + "translation": "Accel Z" + }, + "adj_func": { + "english": "Adj (Function)", + "needs_translation": "false", + "translation": "Adj (Function)" + }, + "adj_val": { + "english": "Adj (Value)", + "needs_translation": "false", + "translation": "Adj (Value)" + }, + "altitude": { + "english": "Altitude", + "needs_translation": "false", + "translation": "Altitude" + }, + "armdisableflags": { + "english": "Arming Disable", + "needs_translation": "false", + "translation": "Arming Disable" + }, + "arming_flags": { + "english": "Arming Flags", + "needs_translation": "false", + "translation": "Arming Flags" + }, + "attpitch": { + "english": "P.angle", + "needs_translation": "false", + "translation": "P.angle" + }, + "attroll": { + "english": "R.angle", + "needs_translation": "false", + "translation": "R.angle" + }, + "attyaw": { + "english": "Y.angle", + "needs_translation": "false", + "translation": "Y.angle" + }, + "bec_voltage": { + "english": "Bec voltage", + "needs_translation": "false", + "translation": "Bec Voltage" + }, + "cell_count": { + "english": "Cell count", + "needs_translation": "false", + "translation": "Cell count" + }, + "consumption": { + "english": "Consumption", + "needs_translation": "false", + "translation": "Consumption" + }, + "current": { + "english": "Current", + "needs_translation": "false", + "translation": "Current" + }, + "esc_temp": { + "english": "ESC Temperature", + "needs_translation": "false", + "translation": "ESC Temperature" + }, + "fuel": { + "english": "Fuel", + "needs_translation": "false", + "translation": "Fuel" + }, + "governor": { + "english": "Governor State", + "needs_translation": "false", + "translation": "Governor State" + }, + "groundspeed": { + "english": "Ground Speed", + "needs_translation": "false", + "translation": "Ground Speed" + }, + "headspeed": { + "english": "Headspeed", + "needs_translation": "false", + "translation": "Headspeed" + }, + "inflight": { + "english": "Idle Up", + "needs_translation": "false", + "translation": "Idle Up" + }, + "mcu_temp": { + "english": "MCU Temperature", + "needs_translation": "false", + "translation": "MCU Temperature" + }, + "profile": { + "english": "Profile", + "needs_translation": "false", + "translation": "Profile" + }, + "rate_profile": { + "english": "Rate Profile", + "needs_translation": "false", + "translation": "Rate Profile" + }, + "rssi": { + "english": "RSSI", + "needs_translation": "false", + "translation": "RSSI" + }, + "smartconsumption": { + "english": "Smart Consumption", + "needs_translation": "false", + "translation": "Smart Consumption" + }, + "smartfuel": { + "english": "Smart fuel", + "needs_translation": "false", + "translation": "Smart fuel" + }, + "throttle_pct": { + "english": "Throttle %", + "needs_translation": "false", + "translation": "Throttle %" + }, + "voltage": { + "english": "Voltage", + "needs_translation": "false", + "translation": "Voltage" + } + } + }, + "version": { + "english": "version", + "needs_translation": "false", + "translation": "version" + }, + "widgets": { + "bbl": { + "display": { + "english": "Display", + "needs_translation": "false", + "translation": "Display" + }, + "display_free": { + "english": "Free", + "needs_translation": "false", + "translation": "Free" + }, + "display_outof": { + "english": "Used/Total", + "needs_translation": "false", + "translation": "Used/Total" + }, + "display_used": { + "english": "Used", + "needs_translation": "false", + "translation": "Used" + }, + "erase_dataflash": { + "english": "Erase dataflash", + "needs_translation": "false", + "translation": "Erase dataflash" + }, + "erasing": { + "english": "Erasing...", + "needs_translation": "false", + "translation": "Erasing..." + } + }, + "craftimage": {}, + "craftname": { + "title": { + "english": "CRAFT NAME", + "needs_translation": "false", + "translation": "CRAFT NAME" + }, + "txt_cancel": { + "english": "Cancel", + "needs_translation": "false", + "translation": "Cancel" + }, + "txt_enter_craft_name": { + "english": "Enter Craft Name", + "needs_translation": "false", + "translation": "Enter Craft Name" + }, + "txt_save": { + "english": "Save", + "needs_translation": "false", + "translation": "Save" + } + }, + "dashboard": { + "altitude": { + "english": "ALTITUDE", + "needs_translation": "false", + "translation": "ALTITUDE" + }, + "check_bg_task": { + "english": "BG TASK", + "needs_translation": "false", + "translation": "BG TASK" + }, + "check_discovered_sensors": { + "english": "SENSORS", + "needs_translation": "false", + "translation": "SENSORS" + }, + "check_rf_module_on": { + "english": "RF MODULE", + "needs_translation": "false", + "translation": "RF MODULE" + }, + "flights": { + "english": "FLIGHTS", + "needs_translation": "false", + "translation": "FLIGHTS" + }, + "loading": { + "english": "ROTORFLIGHT", + "needs_translation": "false", + "translation": "ROTORFLIGHT" + }, + "lq": { + "english": "LQ", + "needs_translation": "false", + "translation": "LQ" + }, + "no_link": { + "english": "NO LINK", + "needs_translation": "NO LINK", + "translation": "NO LINK" + }, + "reset_flight": { + "english": "Reset flight", + "needs_translation": "false", + "translation": "Reset flight" + }, + "reset_flight_ask_text": { + "english": "Are you sure you want to reset the flight?", + "needs_translation": "false", + "translation": "Are you sure you want to reset the flight?" + }, + "reset_flight_ask_title": { + "english": "Reset flight", + "needs_translation": "false", + "translation": "Reset flight" + }, + "theme_load_error": { + "english": "Your theme did not load correctly. Falling back to default theme.", + "needs_translation": "false", + "translation": "Your theme did not load correctly. Falling back to default theme." + }, + "time": { + "english": "TIME", + "needs_translation": "false", + "translation": "TIME" + }, + "unsupported_resolution": { + "english": "TO SMALL", + "needs_translation": "false", + "translation": "TO SMALL" + }, + "validate_sensors": { + "english": "PLEASE CHECK SENSORS", + "needs_translation": "false", + "translation": "PLEASE CHECK SENSORS" + }, + "waiting_for_connection": { + "english": "CONNECTING", + "needs_translation": "false", + "translation": "CONNECTING" + } + }, + "governor": { + "ACTIVE": { + "english": "ACTIVE", + "needs_translation": "false", + "translation": "ACTIVE" + }, + "AUTOROT": { + "english": "AUTOROT", + "needs_translation": "false", + "translation": "AUTOROT" + }, + "BAILOUT": { + "english": "BAILOUT", + "needs_translation": "false", + "translation": "BAILOUT" + }, + "DISABLED": { + "english": "DISABLED", + "needs_translation": "false", + "translation": "DISABLED" + }, + "DISARMED": { + "english": "DISARMED", + "needs_translation": "false", + "translation": "DISARMED" + }, + "IDLE": { + "english": "IDLE", + "needs_translation": "false", + "translation": "IDLE" + }, + "LOSTHS": { + "english": "LOST-HS", + "needs_translation": "false", + "translation": "LOST-HS" + }, + "OFF": { + "english": "OFF", + "needs_translation": "false", + "translation": "OFF" + }, + "RECOVERY": { + "english": "RECOVERY", + "needs_translation": "false", + "translation": "RECOVERY" + }, + "SPOOLUP": { + "english": "SPOOLUP", + "needs_translation": "false", + "translation": "SPOOLUP" + }, + "THROFF": { + "english": "THR-OFF", + "needs_translation": "false", + "translation": "THR-OFF" + }, + "UNKNOWN": { + "english": "UNKNOWN", + "needs_translation": "false", + "translation": "UNKNOWN" + } + } + } +} \ No newline at end of file