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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/main/decode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,6 @@ describe('Decoder aircraft tracking', () => {
describe('Decoder telemetry', () => {
it('exposes the five readouts', () => {
const ids = new Decoder().telemetrySnapshot().map((r) => r.id)
expect(ids).toEqual(['RPM', 'D_IAS_DEG', 'D_FLAPS_IND', 'D_ALT_NEEDLE', 'D_FUEL'])
expect(ids).toEqual(['RPM', 'D_IAS_DEG', 'D_FLAPS_IND', 'ALT_FT', 'D_FUEL'])
})
})
69 changes: 63 additions & 6 deletions src/main/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,46 @@ interface TelemetrySpec {
unit: string
}

// DCS-BIOS gauge outputs are 0..max needle positions, not engineering units, so
// readouts are reported as percent of full scale (honest, no fake knots/feet).
// Three altimeter drum addresses encode digit positions 0–9.999 as 0–65535.
// ALT_FT is a virtual telemetry id computed from them; it has no DCS-BIOS address.
const ALT_DRUM_IDS = new Set(['D_ALT_10K', 'D_ALT_1K', 'D_ALT_100S'])
const ALT_FT_MAX = 50_000

// Piecewise-linear IAS calibration: [raw, knots] pairs derived from A-4E gauge face.
// The pitot-static scale is nonlinear (angle ∝ v²); raw=0 is init/parked (0 kt).
// Tunable — fly at known speeds and update the table as needed.
const IAS_KT_LUT: [number, number][] = [
[0, 0],
[5461, 80],
[10923, 120],
[16384, 150],
[24576, 200],
[32768, 300],
[40960, 400],
[46432, 500],
[49152, 600]
]
const IAS_MAX_KT = 600

function iasRawToKnots(raw: number): number {
const lut = IAS_KT_LUT
if (raw <= lut[0][0]) return lut[0][1]
if (raw >= lut[lut.length - 1][0]) return lut[lut.length - 1][1]
for (let i = 1; i < lut.length; i++) {
if (raw <= lut[i][0]) {
const [r0, k0] = lut[i - 1]
const [r1, k1] = lut[i]
return k0 + ((raw - r0) / (r1 - r0)) * (k1 - k0)
}
}
return NaN
}

const TELEMETRY: TelemetrySpec[] = [
{ id: 'RPM', label: 'RPM', unit: '% RPM' },
{ id: 'D_IAS_DEG', label: 'IAS', unit: '% FS' },
{ id: 'D_IAS_DEG', label: 'IAS', unit: 'kt' },
{ id: 'D_FLAPS_IND', label: 'Flap', unit: '% DN' },
{ id: 'D_ALT_NEEDLE', label: 'Press Alt', unit: '% FS' },
{ id: 'ALT_FT', label: 'Press Alt', unit: 'ft' },
{ id: 'D_FUEL', label: 'Fuel', unit: '% QTY' }
]

Expand All @@ -35,12 +68,15 @@ export class Decoder {
private readonly name = new StringRegion(ACFT_NAME_ADDRESS, ACFT_NAME_MAX)
private readonly telemetry = new Map<string, number>() // id -> latest value
private readonly telemetryMax = new Map<string, number>()
private readonly altDrums = new Map<string, number>() // D_ALT_10K/1K/100S raw values
private lastName = ''
private sawA4ecAddress = false
private aircraftDirty = true

constructor() {
for (const t of TELEMETRY) this.telemetry.set(t.id, NaN)
for (const t of TELEMETRY) {
if (t.id !== 'ALT_FT') this.telemetry.set(t.id, NaN)
}
}

/** Apply one write; returns the decoded log rows it produced (may be empty). */
Expand Down Expand Up @@ -70,6 +106,9 @@ export class Decoder {
this.telemetry.set(o.id, value)
this.telemetryMax.set(o.id, o.max > 0 ? o.max : 0xffff)
}
if (ALT_DRUM_IDS.has(o.id)) {
this.altDrums.set(o.id, value)
}
}
return rows
}
Expand All @@ -89,10 +128,28 @@ export class Decoder {

telemetrySnapshot(): TelemetryReadout[] {
return TELEMETRY.map((t) => {
if (t.id === 'D_IAS_DEG') {
const raw = this.telemetry.get('D_IAS_DEG') ?? NaN
const kt = Number.isNaN(raw) ? NaN : iasRawToKnots(raw)
const pct = Number.isNaN(kt) ? 0 : Math.max(0, Math.min(1, kt / IAS_MAX_KT))
return { id: 'D_IAS_DEG', label: t.label, value: kt, pct, unit: t.unit }
}
if (t.id === 'ALT_FT') {
const d10k = this.altDrums.get('D_ALT_10K')
const d1k = this.altDrums.get('D_ALT_1K')
const d100s = this.altDrums.get('D_ALT_100S')
const alt =
d10k !== undefined && d1k !== undefined && d100s !== undefined
? Math.floor((d10k / 65535) * 10) * 10000 +
Math.floor((d1k / 65535) * 10) * 1000 +
Math.floor((d100s / 65535) * 10) * 100
: NaN
const pct = Number.isNaN(alt) ? 0 : Math.max(0, Math.min(1, alt / ALT_FT_MAX))
return { id: 'ALT_FT', label: t.label, value: alt, pct, unit: t.unit }
}
const raw = this.telemetry.get(t.id) ?? NaN
const max = this.telemetryMax.get(t.id) ?? 0xffff
const pct = Number.isNaN(raw) ? 0 : Math.max(0, Math.min(1, raw / max))
// value = percent of full scale; NaN => "—" in the UI (not exported yet)
const value = Number.isNaN(raw) ? NaN : Math.round(pct * 100)
return { id: t.id, label: t.label, value, pct, unit: t.unit }
})
Expand Down
39 changes: 39 additions & 0 deletions src/renderer/src/tabs/Connection.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
import { fmtUptime, useStore, type SourceMode, type Transport } from '../store'

const REC_DOT = (
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--red)',
marginRight: 6,
verticalAlign: 'middle'
}}
/>
)

function NodesCard() {
const nodes = useStore((x) => x.nodes)
const sourceMode = useStore((x) => x.sourceMode)
Expand Down Expand Up @@ -67,6 +81,7 @@ export function Connection() {
const setConfigField = useStore((x) => x.setConfigField)
const toggleRelay = useStore((x) => x.toggleRelay)
const openReplay = useStore((x) => x.openReplay)
const toggleCapture = useStore((x) => x.toggleCapture)

// The device details are live: populated once a SimGateway is actually found.
// Before that, only the match target (VID/PID) is shown — not real device data.
Expand Down Expand Up @@ -272,6 +287,30 @@ export function Connection() {
</div>
</div>

{s.sourceMode !== 'replay' && (
<div className="card field">
<div className="card-h" style={{ marginBottom: 12 }}>
Record
</div>
<button className="browse" onClick={() => toggleCapture()} disabled={!s.relaying}>
{s.recording ? (
<>
{REC_DOT}Stop recording · {s.recordEvents ?? 0} events
</>
) : (
'Start recording…'
)}
</button>
<div className="hint">
{s.recording
? 'Recording live DCS-BIOS stream. Stop to save — replay the file on any machine with no DCS.'
: s.relaying
? 'Capture a live session to replay later with no DCS running.'
: 'Start the relay first to enable recording.'}
</div>
</div>
)}

<button
className={`bigbtn${s.relaying ? ' bigbtn--stop' : ''}`}
onClick={() => toggleRelay()}
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/tabs/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function Overview() {

const gaugeColor = (id: string, pct: number): string => {
if (id === 'D_FUEL') return pct < 0.25 ? 'var(--red)' : 'var(--gold)'
return id === 'D_FLAPS_IND' || id === 'D_ALT_NEEDLE' ? 'var(--blue-2)' : 'var(--blue)'
return id === 'D_FLAPS_IND' || id === 'ALT_FT' ? 'var(--blue-2)' : 'var(--blue)'
}
const gauges = s.telemetry.map((r) => ({
label: r.label,
Expand Down
Loading