Repository navigation
Expand file tree
/
Copy pathAG-SpdGa.lua
More file actions
1643 lines (1528 loc) · 57.9 KB
/
Copy pathAG-SpdGa.lua
File metadata and controls
1643 lines (1528 loc) · 57.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- AG-SpdGa.lua — Speed Gauge: speed callouts, stall/overspeed warnings and a speedometer telemetry window.
-- Copyright (c) 2026 Aaron George
-- SPDX-License-Identifier: MIT
--
-- Based on DFM Speed Announcer v2.1 by DFM (Dave McQueeney),
-- https://github.com/davidmcq137/JetiLuaDFM
-- Portions Copyright (c) 2018, 2019 DFM (Dave McQueeney), MIT License.
-- Full license text in CREDITS.md.
--
-- Listen-and-inform only: it reads a speed sensor and switches, speaks,
-- vibrates the sticks and draws. It never controls the model
-- (constitution principle I).
--
-- Design: specs/001-speed-gauge/ (plan.md, research.md, data-model.md,
-- contracts/). Research decisions are cited as R1..R17.
local gauge = require("ag_gauge")
local dens = require("ag_dens")
local APP_NAME = "Speed Gauge"
local APP_VERSION = "0.3.0"
local AUDIO_DIR = "/Apps/AG-SpdGa/"
local TICK_MS = 100 -- loop logic runs at most 10x per second (R9)
-- The JETI Studio emulator draws each window's title bar inside the reported
-- canvas and clips the bottom, so about 25 px of h never shows (2026-09-27:
-- 60 -> ~34, 127 -> ~101, 260 -> ~236). The transmitter draws the title
-- above the window and reports only what is visible: 150 x 23, 150 x 68,
-- 316 x 159 (2026-10-03). The emulator's widths (157, 320) tell them apart.
local TITLE_H = 26
local HOLD_MS = 1000 -- a reading held this long counts as real for the max (R5)
local SND_STALL = AUDIO_DIR .. "stall_warning.wav"
local SND_OVER = AUDIO_DIR .. "overspeed.wav"
local SND_ALIVE = AUDIO_DIR .. "airspeed_alive.wav"
local SND_CAL = AUDIO_DIR .. "airspeed_cal_factor.wav"
local SND_STALL_AT = AUDIO_DIR .. "stall_speed_warning_at.wav"
-- App voice (Piper "Amy"), generated by tools/voice/ (FR-030, FR-035,
-- R13; the files are CC BY-SA 4.0). DFM's files above are the fallback
-- (FR-037).
-- Grouped in one table: the main chunk is close to Lua's 200-local limit.
local VOICE = { dir = AUDIO_DIR .. "voice/", maxNum = 500 } -- maxNum: highest number in the set
VOICE.units = { -- indexed by units, like UNITS_TEXT
VOICE.dir .. "mph.wav", VOICE.dir .. "kmh.wav", VOICE.dir .. "kt.wav",
VOICE.dir .. "ms.wav", VOICE.dir .. "fts.wav",
}
VOICE.pct = VOICE.dir .. "pct.wav"
VOICE.stall = VOICE.dir .. "stall.wav"
VOICE.over = VOICE.dir .. "over.wav"
VOICE.alive = VOICE.dir .. "alive.wav"
VOICE.stallAt = VOICE.dir .. "stallat.wav"
VOICE.cal = VOICE.dir .. "cal.wav"
VOICE.max = VOICE.dir .. "max.wav" -- max-speed callout (FR-019a)
-- Units. The sensor value is m/s, as in DFM v2.1 (R3).
local UNITS_TEXT = { "mph", "km/h", "kt", "m/s", "ft/s" }
local UNITS_SPOKEN = { "mph", "km/h", "kt.", "m/s", "ft./s" } -- must match numbers.jsn
local UNITS_MULT = { 2.23694, 3.6, 1.94384, 1.0, 3.28084 }
local UNITS_IMPERIAL = { true, false, true, false, true }
-- Color presets for a dark dial face; none red, orange or yellow (R8).
-- Yellow was added last so earlier saved indices keep their color.
local COLOR_NAMES = { "Cyan", "Blue", "White", "Green", "Lime", "Magenta", "Purple", "Grey", "Yellow" }
local COLORS = {
{ 0, 190, 255 }, { 40, 110, 255 }, { 255, 255, 255 }, { 0, 210, 100 },
{ 170, 240, 0 }, { 230, 60, 230 }, { 150, 100, 255 }, { 170, 170, 170 },
{ 255, 225, 0 },
}
-- Fixed dial colors (R6).
local C_BG = { 8, 10, 14 } -- window background behind the dial face
local C_FACE = { 20, 24, 32 }
local C_TRACK = { 70, 78, 90 }
local C_ZONE = { 255, 80, 0 }
local C_SCALE = { 200, 200, 200 }
local C_MINOR = { 110, 118, 130 }
local C_TEXT = { 255, 255, 255 }
-- Glow inside the value arc: bands fading toward the dial center (reference
-- image), from GLOW[1] alpha at the arc to GLOW[2] at the innermost band.
-- Many thin bands with a gentle fade read as a gradient; each band is a
-- renderer polyline, and the glow is the biggest drawing cost (R6).
local GLOW = { 0.36, 0.02 }
-- Diagnostic, off for release: true shows this call's and the worst
-- system.getCPU() on the full-screen gauge (used to measure the glow on the
-- transmitter, 2026-10-03; worst 14% at speed).
local DEBUG_CPU = false -- off after transmitter testing (T080, 2026-10-04)
local TXT_NOTICE = "Speed Gauge needs DS-24 II"
local TXT_MAX = "MAX"
-- Full-screen panel labels: short, because label and value share one line
-- in a ~124 px panel (transmitter 316 x 159, 2026-10-03).
local TXT_STALL = "STALL"
local TXT_OVERSPEED = "OVER"
local TXT_DENSITY = "DENSITY"
local TXT_SENSOR = "RAW"
local TXT_ELEVATION = "ELEV"
local TXT_OFF = "OFF"
local TXT_GPS = "GPS"
local TXT_NO_DATA = "---"
------------------------------------------------------------------------------
-- Settings (persisted per model; data-model.md). Integers, strings and
-- SwitchItems only; booleans are stored as 0/1 (constitution IV).
------------------------------------------------------------------------------
local KEYS = {
"sId", "sPar", "sLbl", "sType", "swOn", "swCont", "tMin", "tMax", "sens",
"vLand", "vStall", "vOver", "cal", "units", "numOnly", "startAnn", "densOn",
"elev", "temp", "tSrc", "tId", "tPar", "tLbl", "colCur", "colMax", "fScale",
"voice", "vArm", "landOn", "cfgV",
} -- 30 keys: the limit (constitution IV). Make room before adding another.
local DEFAULTS = {
sId = 0, sPar = 0, sLbl = "", sType = 1, tMin = 2, tMax = 40, sens = 10,
vLand = 60, vStall = 45, vOver = 200, cal = 100, units = 1, numOnly = 0,
startAnn = 1, densOn = 0, elev = 0, tSrc = 1, tId = 0, tPar = 0, tLbl = "",
colCur = 1, colMax = 9, fScale = 0, voice = 0, vArm = 30, landOn = 1, cfgV = 2,
} -- swOn / swCont default to nil; temp depends on units
-- Input limits (FR-020, R16). One source for the intboxes, the units
-- conversion and the clamp on load.
local LIM = {
elevFt = { -300, 10000 }, elevM = { -90, 3050 },
tempF = { -20, 130 }, tempC = { -29, 54 },
}
local RANGES = {
sType = { 1, 2 }, tMin = { 1, 10 }, tMax = { 2, 60 }, sens = { 1, 100 },
vLand = { 0, 1000 }, vStall = { 0, 1000 }, vOver = { 0, 1000 },
cal = { 1, 200 }, units = { 1, 5 }, numOnly = { 0, 1 }, startAnn = { 0, 1 },
densOn = { 0, 1 }, tSrc = { 1, 3 }, voice = { 0, 2 }, colCur = { 1, 9 },
colMax = { 1, 9 }, fScale = { 0, 2000 }, vArm = { 0, 1000 }, landOn = { 0, 1 },
}
local cfg = {}
local function save(key, value)
cfg[key] = value
system.pSave(key, value)
end
local function clamp(v, lo, hi)
if v < lo then
return lo
elseif v > hi then
return hi
end
return v
end
local function clampKey(key, lo, hi)
local v = clamp(cfg[key], lo, hi)
if v ~= cfg[key] then
save(key, v)
end
end
local function loadSettings()
for _, key in ipairs(KEYS) do
cfg[key] = system.pLoad(key, DEFAULTS[key])
end
if cfg.temp == nil then
cfg.temp = UNITS_IMPERIAL[cfg.units] and 59 or 15
end
-- cfgV 1 -> 2 (0.1.0 had a "Use standard temperature" checkbox, R16). A
-- fresh install runs this too and ends with the default, Standard.
if system.pLoad("cfgV", 1) < 2 then
save("tSrc", system.pLoad("tStd", 1) == 1 and 1 or 2)
system.pSave("tStd", nil)
save("cfgV", 2)
end
-- Values saved by an older version may be outside today's limits (Edge Cases).
for key, r in pairs(RANGES) do
clampKey(key, r[1], r[2])
end
if cfg.fScale ~= 0 and cfg.fScale < 10 then
save("fScale", 10)
end
if cfg.tMax < cfg.tMin then
save("tMax", cfg.tMin) -- longest is never below shortest (FR-005)
end
if UNITS_IMPERIAL[cfg.units] then
clampKey("elev", LIM.elevFt[1], LIM.elevFt[2])
clampKey("temp", LIM.tempF[1], LIM.tempF[2])
else
clampKey("elev", LIM.elevM[1], LIM.elevM[2])
clampKey("temp", LIM.tempC[1], LIM.tempC[2])
end
end
------------------------------------------------------------------------------
-- Derived values: recomputed only when a setting changes, never per tick.
------------------------------------------------------------------------------
local kSensor = 1 -- sensor m/s -> calibrated speed in the user's units
local kDens = 1 -- true airspeed / sensor speed (R1)
local fullScale = 230
local fStall, fLand, fOver = 0, 0, 1 -- dial fractions (FR-016a)
local scaleStep = 50
local scaleLabels = {} -- major tick label strings
local scaleVer = 0 -- bumped whenever the dial scale changes
local unitText = "mph"
local unitSpoken = "mph"
local densPctText = "+0%"
local densActive = false -- correction on and an airspeed sensor
local densRowText = "OFF" -- full-screen AIR DENSITY row: "+8%", "OFF" or "GPS"
local elevText, elevUnitText = "0", "ft"
local stallText, overText = "45", "200"
-- Callout floor and max-speed callout (FR-008b, FR-019a, 2026-10-04), in
-- one table because the main chunk is near Lua's 200-local limit.
local pk = {
minSpoken = 5, -- no callouts below this (5 mph in the user's units)
holdMs = 1000, -- announce a new max once it hasn't risen for this long
at = nil, -- when the session max last rose; nil = nothing pending
said = 0, -- last max announced (rounded), 0 = none yet
}
local function recomputeSensor()
kSensor = UNITS_MULT[cfg.units] * cfg.cal / 100
end
-- Temperature from a sensor (R15). Declared here because recomputeDensity
-- reads it; reset by resetTempSensor, advanced by readTemp in the loop.
local ts = {
periodMs = 5000, -- read the sensor at most every 5 s (FR-041)
stat = 0, -- 0 not in use, 1 waiting (rejected), 2 in use
useC = nil, -- accepted sensor temperature, deg C
good = 0, -- consecutive good reads while waiting
ever = false, -- a read was accepted or rejected since (re)start
nextRead = 0,
}
local TXT_TEMP = {
std = "TEMP STD", man = "TEMP MAN", sens = "TEMP SENS", out = "SENSOR OUT",
}
local tempText, tempUnitText = "59", "°F" -- full-screen TEMPERATURE row (FR-044)
local tempLabel = TXT_TEMP.std
local tStatText = "" -- settings status line (FR-042)
local function recomputeDensity()
local imperial = UNITS_IMPERIAL[cfg.units]
densActive = cfg.densOn == 1 and cfg.sType == 1
local elevM = imperial and dens.ftToM(cfg.elev) or cfg.elev
local tempC = nil -- nil = standard temperature for the elevation
if cfg.tSrc == 2 then
tempC = imperial and dens.fToC(cfg.temp) or cfg.temp
elseif cfg.tSrc == 3 and ts.stat == 2 then
tempC = ts.useC
end
if densActive then
kDens = dens.factor(elevM, tempC)
else
kDens = 1
end
-- Temperature in use, for the full-screen row and the settings status.
local t = tempC or dens.stdTempC(elevM)
if imperial then
t = dens.cToF(t)
end
tempText = tostring(math.floor(t + 0.5))
tempUnitText = imperial and "°F" or "°C"
local inUse = "Temp: " .. tempText .. " " .. tempUnitText
if cfg.tSrc == 1 then
tempLabel, tStatText = TXT_TEMP.std, inUse .. " standard"
elseif cfg.tSrc == 2 then
tempLabel, tStatText = TXT_TEMP.man, inUse .. " manual"
elseif ts.stat == 2 then
tempLabel, tStatText = TXT_TEMP.sens, inUse .. " from " .. cfg.tLbl
else
tempLabel, tStatText = TXT_TEMP.out, "Sensor not available - using standard"
end
local pct = math.floor((kDens - 1) * 100 + 0.5)
densPctText = (pct >= 0 and "+" or "") .. pct .. "%"
if cfg.sType == 2 then
densRowText = TXT_GPS
elseif densActive then
densRowText = densPctText
else
densRowText = TXT_OFF
end
elevText = tostring(cfg.elev)
elevUnitText = imperial and "ft" or "m"
end
local function recomputeScale()
local fs = cfg.fScale
if fs == 0 then
fs = math.ceil(cfg.vOver * 1.15 / 10) * 10
end
fs = math.max(fs, 10)
fullScale = fs
fStall = clamp(cfg.vStall * kDens / fs, 0, 1)
fLand = clamp(cfg.vLand * kDens / fs, 0, 1)
fOver = clamp(cfg.vOver / fs, 0, 1)
scaleStep = gauge.scaleStep(fs)
local labels = {}
for i = 0, fs // scaleStep do
labels[i + 1] = tostring(math.floor(i * scaleStep))
end
scaleLabels = labels
stallText = tostring(cfg.vStall)
overText = tostring(cfg.vOver)
scaleVer = scaleVer + 1
end
local function recomputeUnitText()
unitText = UNITS_TEXT[cfg.units]
unitSpoken = UNITS_SPOKEN[cfg.units]
-- No callout is ever spoken below 5 mph, in any units (FR-008b, 2026-10-04).
pk.minSpoken = 2.2352 * UNITS_MULT[cfg.units]
end
-- Which voice speaks (R14). voiceOk is set once in init().
local voiceOk = false
local useAppVoice = false
local snd = { stall = SND_STALL, over = SND_OVER, alive = SND_ALIVE, unit = VOICE.units[1] }
-- Jeti's io is function-style (constitution III).
local function fileExists(path)
local f = io.open(path, "r")
if f then
io.close(f)
return true
end
return false
end
-- Every phrase and unit file plus the ends of the number range (R14).
local function checkVoiceFiles()
for _, p in ipairs(VOICE.units) do
if not fileExists(p) then
return false
end
end
for _, p in ipairs({ VOICE.pct, VOICE.stall, VOICE.over, VOICE.alive, VOICE.stallAt, VOICE.cal, VOICE.max,
VOICE.dir .. "0.wav", VOICE.dir .. VOICE.maxNum .. ".wav" }) do
if not fileExists(p) then
return false
end
end
return true
end
local function recomputeVoice()
-- voice: 0 = not chosen (app voice if installed), 1 = Speed Gauge, 2 = Transmitter
useAppVoice = voiceOk and cfg.voice ~= 2
if useAppVoice then
snd.stall, snd.over, snd.alive = VOICE.stall, VOICE.over, VOICE.alive
else
snd.stall, snd.over, snd.alive = SND_STALL, SND_OVER, SND_ALIVE
end
snd.unit = VOICE.units[cfg.units]
end
-- App-voice number file for n, or nil to fall back to the transmitter voice
-- for this phrase (over 500, or the file is missing: FR-033). Builds a path
-- and opens a file, so call it only when something is about to be spoken.
local function numFile(n)
if not useAppVoice or n < 0 or n > VOICE.maxNum then
return nil
end
local path = VOICE.dir .. n .. ".wav"
if fileExists(path) then
return path
end
return nil
end
local function recomputeAll()
recomputeSensor()
recomputeDensity()
recomputeScale()
recomputeUnitText()
recomputeVoice()
end
-- Start over with the temperature sensor: on load, and whenever the source,
-- the sensor or whether correction is active changes (R15). Callers then
-- recompute density.
local function resetTempSensor()
ts.stat = (cfg.tSrc == 3 and densActive) and 1 or 0
ts.useC, ts.good, ts.ever = nil, 0, false
ts.nextRead = system.getTimeCounter()
end
------------------------------------------------------------------------------
-- Session state (not persisted; reset by init on every model load).
------------------------------------------------------------------------------
local sensorSpd = nil -- calibrated sensor speed; nil = no data
local shownSpd = nil -- sensorSpd * kDens: shown, spoken, max, overspeed
local maxSpd = 0
local prevDistinct = nil -- previous distinct shownSpd, for spike filtering (R5)
local distinctSince = 0 -- when shownSpd last changed
local armed = false -- sensor speed has exceeded vArm this session (latched)
local everAboveLanding = false
local belowLanding = false
local aliveSaid = false
local stallArmed = true
local STALL_MAX = 2 -- stall warnings per slowdown (FR-010, 2026-10-03)
local stallCount = 0 -- stall warnings since last above landing speed
local overArmed = true
local lastSpokenSpd = 0
local lastSpokenAt = nil -- nil = nothing spoken yet this session
local lastTick = 0
local curRounded, maxRounded, sensRounded = nil, 0, nil
local curText, maxText, sensText = TXT_NO_DATA, "0", TXT_NO_DATA
local gaugeOk = false -- device is a DS-24 II (R12)
local function resetSession()
sensorSpd, shownSpd = nil, nil
maxSpd, prevDistinct, distinctSince = 0, nil, 0
armed, everAboveLanding, belowLanding, aliveSaid = false, false, false, false
stallArmed, overArmed, stallCount = true, true, 0
lastSpokenSpd, lastSpokenAt = 0, nil
lastTick = system.getTimeCounter()
curRounded, maxRounded, sensRounded = nil, 0, nil
curText, maxText, sensText = TXT_NO_DATA, "0", TXT_NO_DATA
pk.at, pk.said = nil, 0
resetTempSensor()
recomputeDensity()
end
local function resetMax()
maxSpd, prevDistinct = 0, nil
maxRounded, maxText = 0, "0"
pk.at, pk.said = nil, 0
end
------------------------------------------------------------------------------
-- Loop logic
------------------------------------------------------------------------------
-- Unassigned switch = off. "On" is above half travel (R10).
local function switchOn(item)
if item == nil then
return false
end
local v = system.getInputsVal(item)
return v ~= nil and v > 0.5
end
-- Session max, filtered so a single-sample spike is ignored (R5): a value
-- counts once two distinct readings in a row reach it, or once it has held
-- for HOLD_MS (a steady speed). Also rebuilds the cached number strings, only
-- when their rounded value changes.
local function updateMax(now)
local confirmed = nil
if shownSpd ~= prevDistinct then
if prevDistinct ~= nil then
confirmed = math.min(prevDistinct, shownSpd)
end
prevDistinct = shownSpd
distinctSince = now
elseif now - distinctSince >= HOLD_MS then
confirmed = shownSpd
end
if confirmed ~= nil and confirmed > maxSpd then
maxSpd = confirmed
pk.at = now -- max-speed callout waits until it stops rising
end
local r = math.floor(shownSpd + 0.5)
if r ~= curRounded then
curRounded = r
curText = tostring(r)
end
r = math.floor(maxSpd + 0.5)
if r ~= maxRounded then
maxRounded = r
maxText = tostring(r)
end
r = math.floor(sensorSpd + 0.5)
if r ~= sensRounded then
sensRounded = r
sensText = tostring(r)
end
end
-- Stall, overspeed and "airspeed alive" (data-model.md state transitions).
-- Stall uses sensor speed, overspeed uses shown speed (FR-011).
local function checkWarnings(anySwitch)
-- Stall (FR-010, changed 2026-10-03): at most STALL_MAX warnings per
-- slowdown, so speed wobbling around the stall setting on landing can't
-- keep triggering it. Back above landing speed (flying again, e.g. a
-- go-around) resets the count.
if sensorSpd > cfg.vLand then
stallCount = 0
end
if sensorSpd > cfg.vStall and stallCount < STALL_MAX then
stallArmed = true
end
if shownSpd <= cfg.vOver then
overArmed = true
end
if not anySwitch then
return
end
if stallArmed and everAboveLanding and sensorSpd <= cfg.vStall then
stallArmed = false
stallCount = stallCount + 1
system.playFile(snd.stall, AUDIO_IMMEDIATE)
system.vibration(true, 4)
end
if overArmed and shownSpd > cfg.vOver then
overArmed = false
system.playFile(snd.over, AUDIO_IMMEDIATE)
system.vibration(true, 3)
end
if armed and not aliveSaid then
aliveSaid = true
system.playFile(snd.alive, AUDIO_IMMEDIATE)
end
end
-- Variable-interval callouts, formula from DFM v2.1 (R9).
local function checkCallout(now, onSw, contSw)
-- Continuous callouts speak only while above "Callouts start above"
-- (FR-009, changed 2026-10-03): they stop when slowing below it.
-- Below it, the on/off switch's normal callouts still apply.
contSw = contSw and sensorSpd > cfg.vArm
if not (onSw or contSw) then
return
end
if not armed then
return -- FR-009: silent until first above the arming speed
end
-- Max speed (FR-019a): once the session max hasn't risen for pk.holdMs, and
-- beats the last announced max by at least the callout sensitivity (so
-- small creeps in cruise don't chatter), say "max", the number and unit.
if pk.at ~= nil and now - pk.at >= pk.holdMs and maxRounded >= pk.said + cfg.sens
and not system.isPlayback() then
pk.at, pk.said = nil, maxRounded
lastSpokenAt = now
local nf = numFile(maxRounded)
if nf then
system.playFile(VOICE.max, AUDIO_QUEUE)
system.playFile(nf, AUDIO_QUEUE)
system.playFile(snd.unit, AUDIO_QUEUE)
else
system.playNumber(maxRounded, 0, unitSpoken) -- the voice pack has no "max"
end
return
end
if shownSpd < pk.minSpoken then
return -- FR-008b: never a callout below 5 mph
end
local interval
-- Landing speed callouts (landOn, added 2026-10-03): when off, landing
-- speed doesn't change the callouts; it still arms the stall warning.
local landing = cfg.landOn == 1
if contSw or (landing and belowLanding) then
interval = cfg.tMin * 1000
else
local d = math.abs(shownSpd - lastSpokenSpd) / cfg.sens
d = clamp(d, 0.5, 10)
interval = math.min(cfg.tMin * 10000 / d, cfg.tMax * 1000)
end
-- Compare time differences only: the ms counter is a 32-bit integer that
-- wraps and can be negative (seen in the emulator), so now - then is safe
-- where now < then + interval is not.
if (lastSpokenAt ~= nil and now - lastSpokenAt < interval) or system.isPlayback() then
return
end
local n = math.floor(shownSpd + 0.5)
lastSpokenSpd = n
lastSpokenAt = now
local short = cfg.numOnly == 1 or contSw
or (landing and (belowLanding or not everAboveLanding))
-- App voice: number file, then unit file, queued as one phrase (FR-031).
-- Either the whole phrase is in the app voice or none of it is (R14).
local nf = numFile(n)
if nf then
system.playFile(nf, AUDIO_QUEUE)
if not short then
system.playFile(snd.unit, AUDIO_QUEUE)
end
elseif short then
system.playNumber(n, 0)
else
system.playNumber(n, 0, unitSpoken, "Speed")
end
end
------------------------------------------------------------------------------
-- Settings form (contracts/settings-form.md)
------------------------------------------------------------------------------
local sensorLabels, sensorIds, sensorPars = {}, {}, {}
local notFoundIdx = 0
local tempLabels, tempIds, tempPars = {}, {}, {}
local tempNotFoundIdx = 0
local formOpen = false -- loop may update the temperature status only while true (R17)
local idxGpsHint, idxDensOn, idxDensStatus = nil, nil, nil
local idxTMan, idxTSens, idxTStat = nil, nil, nil
local idxAutoHint, idxOrder = nil, nil
local LABEL_W = 210
local CHECK_W = 270
-- A temperature value reports "°C" or "°F": 3 bytes in UTF-8, 2 in Latin-1
-- (the transmitter's encoding is unverified, R15).
local function isTempUnit(unit)
local last = string.sub(unit, -1)
return (#unit == 2 or #unit == 3) and (last == "C" or last == "F")
end
-- Adds the "<label> (not found)" entry for a saved sensor that isn't present
-- (R4). Returns its index.
local function addNotFound(labels, ids, pars, label, id, par)
local n = #labels + 1
labels[n], ids[n], pars[n] = label .. " (not found)", id, par
return n
end
-- Rebuilds the speed and temperature sensor lists in one pass (late sensors
-- appear, R4, R15). Returns both selections.
local function buildSensorList()
sensorLabels, sensorIds, sensorPars = { "(none)" }, { 0 }, { 0 }
tempLabels, tempIds, tempPars = { "(none)" }, { 0 }, { 0 }
notFoundIdx, tempNotFoundIdx = 0, 0
local selected, tSelected = 1, 1
local parent = ""
for _, s in ipairs(system.getSensors()) do
if s.param == 0 then
parent = s.label
elseif s.type ~= 5 and s.type ~= 9 then
local text = (parent ~= "") and (parent .. " / " .. s.label) or s.label
local n = #sensorLabels + 1
sensorLabels[n], sensorIds[n], sensorPars[n] = text, s.id, s.param
if s.id == cfg.sId and s.param == cfg.sPar then
selected = n
end
if isTempUnit(s.unit or "") then
n = #tempLabels + 1
tempLabels[n], tempIds[n], tempPars[n] = text, s.id, s.param
if s.id == cfg.tId and s.param == cfg.tPar then
tSelected = n
end
end
end
end
if cfg.sId ~= 0 and selected == 1 then
notFoundIdx = addNotFound(sensorLabels, sensorIds, sensorPars, cfg.sLbl, cfg.sId, cfg.sPar)
selected = notFoundIdx
end
if cfg.tId ~= 0 and tSelected == 1 then
tempNotFoundIdx = addNotFound(tempLabels, tempIds, tempPars, cfg.tLbl, cfg.tId, cfg.tPar)
tSelected = tempNotFoundIdx
end
return selected, tSelected
end
local function heading(text)
form.addRow(1)
form.addLabel({ label = text, font = FONT_BOLD })
end
local function hint(text, visible)
form.addRow(1)
return form.addLabel({ label = text, font = FONT_MINI, visible = visible ~= false })
end
local function addInt(label, key, lo, hi, default, step, after, params)
form.addRow(2)
form.addLabel({ label = label, width = LABEL_W })
return form.addIntbox(clamp(cfg[key], lo, hi), lo, hi, default, 0, step, function(value)
save(key, value)
if after then
after()
end
end, params)
end
local function addCheck(label, key, after, params)
form.addRow(2)
form.addLabel({ label = label, width = CHECK_W })
local idx
idx = form.addCheckbox(cfg[key] == 1, function(value)
-- The callback gets the old state; toggle it, as DFM v2.1 does.
local on = not value
if idx then
form.setValue(idx, on)
end
save(key, on and 1 or 0)
if after then
after()
end
end, params)
return idx
end
local function orderIsBad()
return cfg.vStall >= cfg.vLand or cfg.vLand >= cfg.vOver
or (cfg.fScale ~= 0 and cfg.vOver > cfg.fScale)
end
-- FR-026: flag, don't block, an illogical threshold order.
local function checkOrder()
if idxOrder then
form.setProperties(idxOrder, { visible = orderIsBad() })
end
end
local function updateAutoHint()
if idxAutoHint then
local text = ""
if cfg.fScale == 0 then
text = "Auto (overspeed + 15%): " .. fullScale
end
form.setProperties(idxAutoHint, { label = text })
end
end
local function updateDensityRows()
if idxDensStatus then
local text = ""
if cfg.sType == 2 then
text = "Not used with GPS"
elseif cfg.densOn == 1 then
text = "Correction: " .. densPctText
end
form.setProperties(idxDensStatus, { label = text })
end
if idxDensOn then
form.setProperties(idxDensOn, { enabled = cfg.sType == 1 })
end
if idxTMan then
form.setProperties(idxTMan, { enabled = cfg.tSrc == 2 })
end
if idxTSens then
form.setProperties(idxTSens, { enabled = cfg.tSrc == 3 })
end
if idxTStat then
form.setProperties(idxTStat, { label = densActive and tStatText or "" })
end
end
local function onThresholdChanged()
recomputeScale()
checkOrder()
updateAutoHint()
end
local function onDensityChanged()
recomputeDensity()
recomputeScale()
updateDensityRows()
end
-- Source, sensor or "correction active" changed: the sensor starts over (R15).
local function onTempSourceChanged()
recomputeDensity() -- densActive first: resetTempSensor reads it
resetTempSensor()
onDensityChanged()
end
-- Units change converts every unit-bearing setting (data-model.md).
local function onUnitsChanged(newUnits)
local old = cfg.units
if newUnits == old then
return
end
local ratio = UNITS_MULT[newUnits] / UNITS_MULT[old]
local function convert(key, lo, hi)
save(key, clamp(math.floor(cfg[key] * ratio + 0.5), lo, hi))
end
convert("sens", 1, 100)
convert("vLand", 0, 1000)
convert("vArm", 0, 1000)
convert("vStall", 0, 1000)
convert("vOver", 0, 1000)
if cfg.fScale ~= 0 then
save("fScale", clamp(math.floor(cfg.fScale * ratio / 10 + 0.5) * 10, 10, 2000))
end
if UNITS_IMPERIAL[newUnits] ~= UNITS_IMPERIAL[old] then
if UNITS_IMPERIAL[newUnits] then
save("elev", clamp(math.floor(dens.mToFt(cfg.elev) / 10 + 0.5) * 10, LIM.elevFt[1], LIM.elevFt[2]))
save("temp", clamp(math.floor(dens.cToF(cfg.temp) + 0.5), LIM.tempF[1], LIM.tempF[2]))
else
save("elev", clamp(math.floor(dens.ftToM(cfg.elev) / 10 + 0.5) * 10, LIM.elevM[1], LIM.elevM[2]))
save("temp", clamp(math.floor(dens.fToC(cfg.temp) + 0.5), LIM.tempC[1], LIM.tempC[2]))
end
end
save("units", newUnits)
recomputeAll()
form.reinit(1)
end
local function initForm()
formOpen = true
local u = UNITS_TEXT[cfg.units]
local imperial = UNITS_IMPERIAL[cfg.units]
-- Sensor and switches
heading("Sensor and switches")
form.addRow(2)
form.addLabel({ label = "Speed sensor", width = 120 })
local selected, tSelected = buildSensorList() -- rebuilds both lists; call before passing them
form.addSelectbox(sensorLabels, selected, true, function(i)
save("sId", sensorIds[i])
save("sPar", sensorPars[i])
if i == 1 then
save("sLbl", "")
elseif i ~= notFoundIdx then
save("sLbl", string.sub(sensorLabels[i], 1, 63))
end
end, { width = 200 })
form.addRow(2)
form.addLabel({ label = "Sensor type", width = LABEL_W })
form.addSelectbox({ "Airspeed (pitot)", "GPS" }, cfg.sType, false, function(i)
save("sType", i)
onTempSourceChanged()
if idxGpsHint then
form.setProperties(idxGpsHint, { visible = i == 2 })
end
end)
idxGpsHint = hint("GPS: warnings use ground speed, wind shifts them", cfg.sType == 2)
form.addRow(2)
form.addLabel({ label = "Units", width = LABEL_W })
form.addSelectbox(UNITS_TEXT, cfg.units, false, onUnitsChanged)
addInt("Sensor calibration (%)", "cal", 1, 200, 100, 1, recomputeSensor)
hint("100 = unchanged")
form.addRow(2)
form.addLabel({ label = "Callouts on/off switch", width = LABEL_W })
form.addInputbox(cfg.swOn, true, function(v) save("swOn", v) end)
hint("Speaks speed: more often as it changes, every")
hint("shortest time below landing speed")
form.addRow(2)
form.addLabel({ label = "Continuous callouts switch", width = LABEL_W })
form.addInputbox(cfg.swCont, true, function(v) save("swCont", v) end)
hint("Number only, every shortest time, while above")
hint("'Callouts start above'; works without on/off")
hint("Either switch also turns on the warnings")
-- Callouts
heading("Callouts")
addInt("Callout sensitivity (" .. u .. " change)", "sens", 1, 100, 10, 1)
hint("Speak sooner when speed changes by this much")
-- Longest 2-60 s, never below shortest (FR-005, changed 2026-10-04):
-- it stops at the shortest, and raising the shortest past it raises it.
local idxTMax
local function keepTMax()
if cfg.tMax < cfg.tMin then
save("tMax", cfg.tMin)
if idxTMax then
form.setValue(idxTMax, cfg.tMax)
end
end
end
addInt("Shortest time between callouts (s)", "tMin", 1, 10, 2, 1, keepTMax)
idxTMax = addInt("Longest time between callouts (s)", "tMax", 2, 60, 40, 1, keepTMax)
addInt("Callouts start above (" .. u .. ")", "vArm", 0, 1000, 30, 1)
hint("No callouts or 'airspeed alive' until first this fast")
addCheck("Landing speed callouts", "landOn")
addInt("Landing speed (" .. u .. ")", "vLand", 0, 1000, 60, 1, onThresholdChanged)
hint("If on: short callouts every shortest time below")
hint("this. Also arms the stall warning once exceeded")
addCheck("Speak number only (no units)", "numOnly")
addCheck("Announce stall speed at startup", "startAnn")
-- Voice (FR-032): an unset choice shows what is actually used.
form.addRow(2)
form.addLabel({ label = "Voice", width = LABEL_W })
local vSel = cfg.voice
if vSel == 0 then
vSel = voiceOk and 1 or 2
end
form.addSelectbox({ "Speed Gauge", "Transmitter" }, vSel, false, function(i)
save("voice", i)
recomputeVoice()
end)
hint("Voice files missing - using transmitter voice", not voiceOk)
-- Warnings
heading("Warnings")
idxOrder = hint("Check: stall < landing < overspeed < full scale", false)
addInt("Stall warning at (" .. u .. ")", "vStall", 0, 1000, 45, 1, onThresholdChanged)
addInt("Overspeed warning at (" .. u .. ")", "vOver", 0, 1000, 200, 1, onThresholdChanged)
-- Air density
heading("Air density")
idxDensOn = addCheck("Correct for air density", "densOn", onTempSourceChanged,
{ enabled = cfg.sType == 1 })
idxDensStatus = hint("")
if imperial then
addInt("Field elevation (ft)", "elev", LIM.elevFt[1], LIM.elevFt[2], 0, 10, onDensityChanged)
else
addInt("Field elevation (m)", "elev", LIM.elevM[1], LIM.elevM[2], 0, 10, onDensityChanged)
end
form.addRow(2)
form.addLabel({ label = "Temperature source", width = LABEL_W })
form.addSelectbox({ "Standard", "Manual", "Sensor" }, cfg.tSrc, false, function(i)
save("tSrc", i)
onTempSourceChanged()
end)
if imperial then
idxTMan = addInt("Temperature, manual (°F)", "temp", LIM.tempF[1], LIM.tempF[2], 59, 1,
onDensityChanged, { enabled = cfg.tSrc == 2 })
else
idxTMan = addInt("Temperature, manual (°C)", "temp", LIM.tempC[1], LIM.tempC[2], 15, 1,
onDensityChanged, { enabled = cfg.tSrc == 2 })
end
form.addRow(2)
form.addLabel({ label = "Temperature sensor", width = 120 })
idxTSens = form.addSelectbox(tempLabels, tSelected, true, function(i)
save("tId", tempIds[i])
save("tPar", tempPars[i])
if i == 1 then
save("tLbl", "")
elseif i ~= tempNotFoundIdx then
save("tLbl", string.sub(tempLabels[i], 1, 63))
end
onTempSourceChanged()
end, { width = 200, enabled = cfg.tSrc == 3 })
hint("Sensors inside the model can read warmer than outside air")
idxTStat = hint("")
hint("Leave correction off if your sensor already corrects for air density")
-- Gauge
heading("Gauge")
-- Renamed from "Gauge full scale" after transmitter testing (2026-10-03).
addInt("Gauge max limit (" .. u .. ", 0 = auto)", "fScale", 0, 2000, 0, 10, onThresholdChanged)
hint("Highest speed on the dial. To use your sensor's")
hint("whole range, enter its top speed:")
hint("MSpeed: 20-350 km/h (12-215 mph)")
hint("MSpeed 450 EX: 80-450 km/h (50-280 mph)")
idxAutoHint = hint("")
form.addRow(2)
form.addLabel({ label = "Current speed color", width = LABEL_W })
form.addSelectbox(COLOR_NAMES, cfg.colCur, false, function(i) save("colCur", i) end)
form.addRow(2)
form.addLabel({ label = "Max speed color", width = LABEL_W })
form.addSelectbox(COLOR_NAMES, cfg.colMax, false, function(i) save("colMax", i) end)
form.addRow(1)
form.addLink(resetMax, { label = "Reset max speed" })
form.addRow(1)
form.addLabel({ label = APP_NAME .. " " .. APP_VERSION .. " - Based on DFM Speed Announcer by Dave McQueeney",
font = FONT_MINI, alignRight = true })
checkOrder()
updateAutoHint()
updateDensityRows()
end
local function keyForm()
end
-- The form is gone: drop its indices so nothing calls form.* on them (R17).
local function closeForm()
formOpen = false
idxGpsHint, idxDensOn, idxDensStatus = nil, nil, nil
idxTMan, idxTSens, idxTStat = nil, nil, nil
idxAutoHint, idxOrder = nil, nil
end
------------------------------------------------------------------------------
-- Telemetry windows (contracts/telemetry-window.md). The only place lcd is used.
------------------------------------------------------------------------------
local roundDial, faceCircle, glowDial = nil, nil, nil -- glowDial: coarse copy for the soft glow
local rend = nil -- one renderer, created lazily and reused (R6)
local cpuPeak = 0 -- DEBUG_CPU only
-- JETI Studio's simulator crashes (access violation in Qt6Core) once the
-- speed arc and its glow are drawn as filled polygons (2026-10-03); the
-- transmitter draws them fine. Its windows (widths 157, 320) therefore use
-- polylines for arcs and glow, as 0.1.0 did. Set per frame in printGauge.
local liveLines = false
-- One layout cache per layout, rebuilt when the window size or scale changes.
local layStrip = { w = 0 }
local layRound = { w = 0 }
local layFull = { w = 0 }
local function setColor(c)
lcd.setColor(c[1], c[2], c[3])
end
local function round(v)
return math.floor(v + 0.5)
end
-- Largest of the given fonts where sample text fits in maxW x maxH.
local function fitFont(fonts, sample, maxW, maxH)
for i = 1, #fonts do
local f = fonts[i]
if lcd.getTextWidth(f, sample) <= maxW and lcd.getTextHeight(f) <= maxH then
return f
end
end
return fonts[#fonts]
end
local NUM_FONTS = { FONT_MAXI, FONT_BIG, FONT_NORMAL, FONT_MINI }
-- Tick end points and label positions for a dial layout.
local function buildScale(L, dial, minorPer, withLabels)
local mx1, my1, mx2, my2 = {}, {}, {}, {}