From 6bb607db06c1f9b761e6d2219f10477a8be828bc Mon Sep 17 00:00:00 2001 From: Benjamin Leber Date: Sat, 24 Oct 2026 12:56:37 +0200 Subject: [PATCH 1/6] =?UTF-8?q?#193=20=E2=9C=A8=20[mod][term][app][doc]=20?= =?UTF-8?q?Variable=20Tire=20Pressure:=20the=20tyre=20pressure=20of=20the?= =?UTF-8?q?=20tractor=20and=20its=20implements,=20and=20a=20road=20/=20fie?= =?UTF-8?q?ld=20switch=20for=20each?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mod exports `tirePressure` (format 36) on every vehicle and implement whose wheels have the system: the pressure now and its target, road / field / mixed, the two setpoints, and each axle while the tractor runs them at different pressures. Read through the getters the mod registers for Dashboard Live, so the numbers are its HUD's. The new setTirePressure command is absolute (whole rig, tractor or implements, to road or field) and presses the mod's own tractor or implements key only where the machines are not already there, through its key handler, so it takes the same path a key press does in multiplayer. The terminal gets a Tyre pressure widget in the Vehicle app. Covered by two singleplayer captures: a slurry tanker mid-deflation, and a tractor with its axles split. Co-Authored-By: Claude Opus 5.5 (1M context) --- FUTURE.md | 5 + .../vertexdezign/vdt/app/apps/VehicleApp.kt | 7 +- .../net/vertexdezign/vdt/app/i18n/Strings.kt | 1 + .../vdt/app/i18n/TirePressureStrings.kt | 25 ++ .../vdt/app/i18n/de/DeTirePressure.kt | 19 + .../vertexdezign/vdt/app/i18n/de/German.kt | 2 + .../vdt/app/i18n/en/EnTirePressure.kt | 19 + .../vertexdezign/vdt/app/i18n/en/English.kt | 2 + .../vdt/app/panels/TirePressurePanel.kt | 235 ++++++++++++ .../vdt/app/widgets/BuiltinWidgets.kt | 19 + .../vdt/app/panels/TireRigTest.kt | 68 ++++ .../vertexdezign/vdt/server/CommandWriter.kt | 4 + .../vdt/server/CommandWriterTest.kt | 9 + .../kotlin/net/vertexdezign/vdt/ModFormat.kt | 2 +- .../kotlin/net/vertexdezign/vdt/Protocol.kt | 21 ++ .../net/vertexdezign/vdt/model/Vehicle.kt | 72 ++++ .../net/vertexdezign/vdt/VdtModelTest.kt | 72 ++++ .../tractor_withDifferentPressure.json | 338 ++++++++++++++++++ .../tractor_withLiquidManureBarrel.json | 330 +++++++++++++++++ vdTelemetry/Readme.md | 10 + vdTelemetry/VDTelemetry.lua | 10 +- .../spec/VariableTirePressure_spec.lua | 246 +++++++++++++ .../src/command/TirePressureControl.lua | 144 ++++++++ .../src/integrations/VariableTirePressure.lua | 140 ++++++++ vdTelemetry/src/integrations/registry.lua | 2 + 25 files changed, 1798 insertions(+), 4 deletions(-) create mode 100644 VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/TirePressureStrings.kt create mode 100644 VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/de/DeTirePressure.kt create mode 100644 VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/en/EnTirePressure.kt create mode 100644 VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/TirePressurePanel.kt create mode 100644 VDTerminal/app/src/commonTest/kotlin/net/vertexdezign/vdt/app/panels/TireRigTest.kt create mode 100644 examples/json/telemetry/modded/variableTirePressure/tractor_withDifferentPressure.json create mode 100644 examples/json/telemetry/modded/variableTirePressure/tractor_withLiquidManureBarrel.json create mode 100644 vdTelemetry/spec/VariableTirePressure_spec.lua create mode 100644 vdTelemetry/src/command/TirePressureControl.lua create mode 100644 vdTelemetry/src/integrations/VariableTirePressure.lua diff --git a/FUTURE.md b/FUTURE.md index 2038699c..d7d9ebbd 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -688,6 +688,11 @@ them. (The schema and selection aspects were in this list until #116 and #119 ca - **AutoDrive (#97), two states no capture has yet.** Every command is checked in singleplayer and from a multiplayer client, and `examples/json/autodrive/` pins the reads. Not yet seen in a file: a round counter that is not endless (`loops` > 0 with `loopsDone` counting) and a moment where `heading` differs from `target`. +- **Variable Tire Pressure (#193), from a multiplayer client.** Singleplayer is confirmed in game — the reads and all + three switches — and `examples/json/telemetry/modded/variableTirePressure/` pins a tanker mid-deflation and a tractor + with its axles split. Still wanted: the switches tried from a client, where the press has to travel through the + mod's own event, and ideally a capture with the axles in *different modes* (`MIXED`, covered by inline JSON in + `VdtModelTest.decodesTheTirePressureBlock` meanwhile) if the mod offers a way to get there. - **A tipping trailer.** It covers `tipping` and `discharge`, and would give `jointDescIndex` its first real chain. (The balers are captured, all singleplayer: `examples/json/telemetry/vanilla/baler/`.) - **A baler from a multiplayer client.** Every baler command was driven from a client and works, but no committed file diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/apps/VehicleApp.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/apps/VehicleApp.kt index 0bddd33f..badd29f6 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/apps/VehicleApp.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/apps/VehicleApp.kt @@ -23,11 +23,12 @@ import net.vertexdezign.vdt.app.widgets.LightingWidget import net.vertexdezign.vdt.app.widgets.NavigationWidget import net.vertexdezign.vdt.app.widgets.RigSlotWidget import net.vertexdezign.vdt.app.widgets.TelltaleWidget +import net.vertexdezign.vdt.app.widgets.TirePressureWidget import net.vertexdezign.vdt.app.widgets.Widget /** - * The vehicle itself: owns everything about the machine you're driving. Provides the engine, rig-slot - * and lighting tiles, and a full page laying the rig out the way it sits: front, the machine, rear. + * The vehicle itself: owns everything about the machine you're driving. Provides the engine, rig-slot, + * lighting and tyre-pressure tiles, and a full page laying the rig out the way it sits: front, the machine, rear. */ object VehicleApp : VdtApp { /** Below this the low-fuel alert fires… */ @@ -47,6 +48,8 @@ object VehicleApp : VdtApp { RigSlotWidget, NavigationWidget, LightingWidget, + // Only says anything with Variable Tire Pressure installed; without it, the tile says so. + TirePressureWidget, // The pillar-cluster four. They belong to the vehicle like everything else here; what makes them // a cluster is the page they are stacked on, not anything they know about each other. TelltaleWidget, diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/Strings.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/Strings.kt index 2c5c39a7..22d8195f 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/Strings.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/Strings.kt @@ -41,6 +41,7 @@ interface Strings { val rotation: RotationStrings val fields: FieldsStrings val autoDrive: AutoDriveStrings + val tirePressure: TirePressureStrings val diagnostics: DiagnosticsStrings val vehicle: VehicleStrings val cluster: ClusterStrings diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/TirePressureStrings.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/TirePressureStrings.kt new file mode 100644 index 00000000..77a70acd --- /dev/null +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/TirePressureStrings.kt @@ -0,0 +1,25 @@ +package net.vertexdezign.vdt.app.i18n + +/** The tyre-pressure widget (Variable Tire Pressure). */ +interface TirePressureStrings { + val title: String + val notInVehicle: String + val noSystem: String + + /** The three scopes: the mod's whole-combination, tractor and implements keys. */ + val wholeRig: String + val implements: String + + val road: String + val field: String + val mixed: String + val front: String + val rear: String + + /** A pressure, already formatted, with its unit. */ + fun bar(value: String): String + + /** Said for the arrow beside a pressure that is moving: where to, already formatted. */ + fun inflatingTo(value: String): String + fun deflatingTo(value: String): String +} diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/de/DeTirePressure.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/de/DeTirePressure.kt new file mode 100644 index 00000000..fea14870 --- /dev/null +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/de/DeTirePressure.kt @@ -0,0 +1,19 @@ +package net.vertexdezign.vdt.app.i18n.de + +import net.vertexdezign.vdt.app.i18n.TirePressureStrings + +internal object DeTirePressure : TirePressureStrings { + override val title = "Reifendruck" + override val notInVehicle = "Nicht in einem Fahrzeug" + override val noSystem = "Keine Reifendruckregelanlage am Gespann" + override val wholeRig = "GANZES GESPANN" + override val implements = "GERÄTE" + override val road = "Straße" + override val field = "Feld" + override val mixed = "Gemischt" + override val front = "Vorne" + override val rear = "Hinten" + override fun bar(value: String): String = "$value bar" + override fun inflatingTo(value: String): String = "pumpt auf $value bar" + override fun deflatingTo(value: String): String = "lässt ab auf $value bar" +} diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/de/German.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/de/German.kt index bfee484c..e2065e37 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/de/German.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/de/German.kt @@ -32,6 +32,7 @@ import net.vertexdezign.vdt.app.i18n.ShellStrings import net.vertexdezign.vdt.app.i18n.StorageStrings import net.vertexdezign.vdt.app.i18n.Strings import net.vertexdezign.vdt.app.i18n.TasksStrings +import net.vertexdezign.vdt.app.i18n.TirePressureStrings import net.vertexdezign.vdt.app.i18n.VehicleStrings import net.vertexdezign.vdt.app.i18n.WidgetsStrings @@ -62,6 +63,7 @@ object German : Strings { override val rotation: RotationStrings = DeRotation override val fields: FieldsStrings = DeFields override val autoDrive: AutoDriveStrings = DeAutoDrive + override val tirePressure: TirePressureStrings = DeTirePressure override val diagnostics: DiagnosticsStrings = DeDiagnostics override val vehicle: VehicleStrings = DeVehicle override val cluster: ClusterStrings = DeCluster diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/en/EnTirePressure.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/en/EnTirePressure.kt new file mode 100644 index 00000000..659b2367 --- /dev/null +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/en/EnTirePressure.kt @@ -0,0 +1,19 @@ +package net.vertexdezign.vdt.app.i18n.en + +import net.vertexdezign.vdt.app.i18n.TirePressureStrings + +internal object EnTirePressure : TirePressureStrings { + override val title = "Tyre pressure" + override val notInVehicle = "Not in a vehicle" + override val noSystem = "No tyre inflation system on this rig" + override val wholeRig = "WHOLE RIG" + override val implements = "IMPLEMENTS" + override val road = "Road" + override val field = "Field" + override val mixed = "Mixed" + override val front = "Front" + override val rear = "Rear" + override fun bar(value: String): String = "$value bar" + override fun inflatingTo(value: String): String = "inflating to $value bar" + override fun deflatingTo(value: String): String = "deflating to $value bar" +} diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/en/English.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/en/English.kt index c56afd43..555177fd 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/en/English.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/en/English.kt @@ -32,6 +32,7 @@ import net.vertexdezign.vdt.app.i18n.ShellStrings import net.vertexdezign.vdt.app.i18n.StorageStrings import net.vertexdezign.vdt.app.i18n.Strings import net.vertexdezign.vdt.app.i18n.TasksStrings +import net.vertexdezign.vdt.app.i18n.TirePressureStrings import net.vertexdezign.vdt.app.i18n.VehicleStrings import net.vertexdezign.vdt.app.i18n.WidgetsStrings @@ -59,6 +60,7 @@ object English : Strings { override val rotation: RotationStrings = EnRotation override val fields: FieldsStrings = EnFields override val autoDrive: AutoDriveStrings = EnAutoDrive + override val tirePressure: TirePressureStrings = EnTirePressure override val diagnostics: DiagnosticsStrings = EnDiagnostics override val vehicle: VehicleStrings = EnVehicle override val cluster: ClusterStrings = EnCluster diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/TirePressurePanel.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/TirePressurePanel.kt new file mode 100644 index 00000000..ef27f29d --- /dev/null +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/TirePressurePanel.kt @@ -0,0 +1,235 @@ +package net.vertexdezign.vdt.app.panels + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.TireRepair +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.vertexdezign.vdt.ClientMessage +import net.vertexdezign.vdt.TirePressureScope +import net.vertexdezign.vdt.app.components.FilterChip +import net.vertexdezign.vdt.app.components.Panel +import net.vertexdezign.vdt.app.components.ProgressBar +import net.vertexdezign.vdt.app.i18n.LocalStrings +import net.vertexdezign.vdt.app.i18n.TirePressureStrings +import net.vertexdezign.vdt.app.theme.VdtColors +import net.vertexdezign.vdt.model.Implement +import net.vertexdezign.vdt.model.TireAxle +import net.vertexdezign.vdt.model.TireChange +import net.vertexdezign.vdt.model.TireMode +import net.vertexdezign.vdt.model.TirePressure +import net.vertexdezign.vdt.model.Vehicle + +/** One machine on the rig whose tyres have the inflation system. */ +data class TireMachine(val name: String, val pressure: TirePressure) + +/** + * The rig as the tyre widget groups it: the vehicle's own tyres, and every implement that has the system, + * depth-first — the order the mod's implements key walks them in. Null when nothing on the rig has it. + */ +data class TireRig(val vehicle: TireMachine?, val implements: List) { + /** The whole rig in one mode, or null while its parts disagree (a [TireMode.MIXED] tractor counts as disagreeing). */ + val mode: TireMode? get() = groupMode(listOfNotNull(vehicle) + implements) + + val implementsMode: TireMode? get() = groupMode(implements) + + companion object { + fun of(vehicle: Vehicle): TireRig? { + val implements = mutableListOf() + fun walk(list: List) { + for (implement in list) { + implement.tirePressure?.let { implements += TireMachine(implement.name, it) } + walk(implement.implement) + } + } + walk(vehicle.implement) + val own = vehicle.tirePressure?.let { TireMachine(vehicle.name, it) } + return if (own == null && implements.isEmpty()) null else TireRig(own, implements) + } + + private fun groupMode(machines: List): TireMode? = + machines.map { it.pressure.mode }.distinct().singleOrNull()?.takeIf { it != TireMode.MIXED } + } +} + +/** + * Variable Tire Pressure on the terminal (issue #193): the tyre pressure of the machine and of everything + * hitched to it, and the road / field switch for each — the mod's tractor key, its implements key, and + * the whole rig at once when both have the system. + * + * Every switch sends an absolute mode ([ClientMessage.SetTirePressure]); the mod presses its own key + * only where the machines are not already there. A pair with neither lit is a group whose machines + * disagree — a tractor running its axles apart, or a trailer hitched at another pressure — and either + * button brings it together. + * + * The mode is the chip's fill **and** its word, never a colour on its own; the direction of a change is + * an arrow [Icon], with the target in its description and beside it. + */ +@Composable +fun TirePressurePanel(vehicle: Vehicle?, modifier: Modifier = Modifier, onCommand: (ClientMessage) -> Unit = {}) { + val s = LocalStrings.current.tirePressure + val rig = vehicle?.let { TireRig.of(it) } + + Panel(title = s.title, icon = Icons.Filled.TireRepair, modifier = modifier) { + when { + vehicle == null -> Empty(s.notInVehicle) + + rig == null -> Empty(s.noSystem) + + else -> Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + val set = { scope: TirePressureScope, road: Boolean -> onCommand(ClientMessage.SetTirePressure(scope, road)) } + if (rig.vehicle != null && rig.implements.isNotEmpty()) { + Group(s.wholeRig, rig.mode, onSet = { set(TirePressureScope.ALL, it) }) + } + rig.vehicle?.let { machine -> + Group(machine.name, machine.pressure.mode.takeIf { it != TireMode.MIXED }, onSet = { + set(TirePressureScope.VEHICLE, it) + }) { + Reading(machine.pressure, showMode = machine.pressure.mode == TireMode.MIXED) + machine.pressure.front?.let { AxleLine(s.front, it) } + machine.pressure.rear?.let { AxleLine(s.rear, it) } + } + } + if (rig.implements.isNotEmpty()) { + Group(s.implements, rig.implementsMode, onSet = { set(TirePressureScope.IMPLEMENTS, it) }) { + for (machine in rig.implements) { + // Named here, since the group's title is the scope; and the mode said in words, because one + // trailer can sit at the other pressure from the rest. + Label(machine.name) + Reading(machine.pressure, showMode = rig.implements.size > 1) + } + } + } + } + } + } +} + +@Composable +private fun Empty(text: String) { + Text(text, fontSize = 12.sp, color = VdtColors.DarkGray) +} + +/** A scope's title and its road / field pair, with what it switches below. */ +@Composable +private fun Group( + title: String, + mode: TireMode?, + onSet: (road: Boolean) -> Unit, + content: @Composable () -> Unit = {}, +) { + val s = LocalStrings.current.tirePressure + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + title.uppercase(), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = VdtColors.DarkGray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + // Tapping the lit one re-sends it, which the mod settles as already there. + FilterChip(s.road, active = mode == TireMode.ROAD, onClick = { onSet(true) }) + FilterChip(s.field, active = mode == TireMode.FIELD, onClick = { onSet(false) }) + } + content() + } +} + +@Composable +private fun Label(text: String) { + Text(text, fontSize = 11.sp, color = VdtColors.TextDark, maxLines = 1, overflow = TextOverflow.Ellipsis) +} + +/** + * The pressure now, an arrow toward its target while it moves, and where it sits between the two + * setpoints. [showMode] adds the mode in words, for a reading whose group chips cannot say it. + */ +@Composable +private fun Reading(pressure: TirePressure, showMode: Boolean) { + val strings = LocalStrings.current + val s = strings.tirePressure + val numbers = strings.numbers + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + s.bar(numbers.fixed(pressure.bar, 1)), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = VdtColors.TextDark, + ) + pressure.change?.let { change -> + val target = numbers.fixed(pressure.targetBar, 1) + Icon( + if (change == TireChange.INFLATING) Icons.Filled.ArrowUpward else Icons.Filled.ArrowDownward, + contentDescription = if (change == TireChange.INFLATING) s.inflatingTo(target) else s.deflatingTo(target), + tint = VdtColors.DarkGray, + modifier = Modifier.size(16.dp), + ) + Text(target, fontSize = 12.sp, color = VdtColors.DarkGray) + } + if (showMode) { + Text( + modeWord(s, pressure.mode).uppercase(), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = VdtColors.DarkGray, + ) + } + } + val road = pressure.roadBar + val field = pressure.fieldBar + if (road != null && field != null && road > field) { + ProgressBar( + fraction = (pressure.bar - field) / (road - field), + leftLabel = "${s.field} ${numbers.fixed(field, 1)}", + rightLabel = "${s.road} ${numbers.fixed(road, 1)}", + ) + } + } +} + +/** One axle of a tractor running them apart: its pressure and its mode, in words. */ +@Composable +private fun AxleLine(name: String, axle: TireAxle) { + val strings = LocalStrings.current + val s = strings.tirePressure + Text( + "$name ${s.bar(strings.numbers.fixed(axle.bar, 1))} · ${modeWord(s, axle.mode)}", + fontSize = 11.sp, + color = VdtColors.TextDark, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +private fun modeWord(s: TirePressureStrings, mode: TireMode): String = when (mode) { + TireMode.ROAD -> s.road + TireMode.FIELD -> s.field + TireMode.MIXED -> s.mixed +} diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/BuiltinWidgets.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/BuiltinWidgets.kt index 680430c0..cbdce71b 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/BuiltinWidgets.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/BuiltinWidgets.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Lightbulb import androidx.compose.material.icons.filled.Map import androidx.compose.material.icons.filled.Memory import androidx.compose.material.icons.filled.Route +import androidx.compose.material.icons.filled.TireRepair import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -44,6 +45,7 @@ import net.vertexdezign.vdt.app.panels.Navigation import net.vertexdezign.vdt.app.panels.RigSlot import net.vertexdezign.vdt.app.panels.RigSlotPanel import net.vertexdezign.vdt.app.panels.TaskListPanel +import net.vertexdezign.vdt.app.panels.TirePressurePanel import net.vertexdezign.vdt.app.panels.WeatherIcons import net.vertexdezign.vdt.app.panels.WeatherSummary import net.vertexdezign.vdt.app.state.LocalVdtStore @@ -345,6 +347,23 @@ object LightingWidget : Widget { } } +/** + * Variable Tire Pressure's road / field switch for the machine and its implements (issue #193); the + * panel renders its own empty states — off the vehicle, and a rig without the system. + */ +object TirePressureWidget : Widget { + override val id = "tirePressure" + override fun title(strings: Strings) = strings.tirePressure.title + override val icon: ImageVector = Icons.Filled.TireRepair + + @Composable + override fun Content(modifier: Modifier, config: WidgetConfig) { + val store = LocalVdtStore.current + val telemetry by store.telemetry.collectAsState() + TirePressurePanel(telemetry?.vehicle, modifier, store.onCommand) + } +} + /** * The farm's fields that are asking for something — the overview's headline, tile-sized. * diff --git a/VDTerminal/app/src/commonTest/kotlin/net/vertexdezign/vdt/app/panels/TireRigTest.kt b/VDTerminal/app/src/commonTest/kotlin/net/vertexdezign/vdt/app/panels/TireRigTest.kt new file mode 100644 index 00000000..d32f5ea1 --- /dev/null +++ b/VDTerminal/app/src/commonTest/kotlin/net/vertexdezign/vdt/app/panels/TireRigTest.kt @@ -0,0 +1,68 @@ +package net.vertexdezign.vdt.app.panels + +import net.vertexdezign.vdt.model.Implement +import net.vertexdezign.vdt.model.TireMode +import net.vertexdezign.vdt.model.TirePressure +import net.vertexdezign.vdt.model.Vehicle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TireRigTest { + private fun tyres(mode: TireMode) = TirePressure(mode = mode, bar = 1f, targetBar = 1f) + + @Test + fun nothingOnTheRigWithTheSystemIsNoRig() { + assertNull(TireRig.of(Vehicle(name = "Fendt", implement = listOf(Implement(name = "Plough"))))) + } + + @Test + fun collectsTheImplementsDepthFirst() { + val vehicle = + Vehicle( + name = "Fendt", + tirePressure = tyres(TireMode.ROAD), + implement = + listOf( + Implement( + name = "Dolly", + tirePressure = tyres(TireMode.ROAD), + implement = listOf(Implement(name = "Tanker", tirePressure = tyres(TireMode.FIELD))), + ), + Implement(name = "Front weight"), + ), + ) + val rig = TireRig.of(vehicle)!! + assertEquals("Fendt", rig.vehicle?.name) + assertEquals(listOf("Dolly", "Tanker"), rig.implements.map { it.name }) + // The trailers disagree, so neither the implements nor the whole rig has one mode. + assertNull(rig.implementsMode) + assertNull(rig.mode) + } + + @Test + fun aRigInOneModeHasThatMode() { + val rig = + TireRig.of( + Vehicle( + tirePressure = tyres(TireMode.FIELD), + implement = listOf(Implement(tirePressure = tyres(TireMode.FIELD))), + ), + )!! + assertEquals(TireMode.FIELD, rig.mode) + assertEquals(TireMode.FIELD, rig.implementsMode) + } + + @Test + fun aTractorWithItsAxlesApartIsInNoOneMode() { + val rig = TireRig.of(Vehicle(tirePressure = tyres(TireMode.MIXED)))!! + assertNull(rig.mode) + } + + @Test + fun implementsWithoutTheTractorStillMakeARig() { + val rig = TireRig.of(Vehicle(implement = listOf(Implement(name = "Tanker", tirePressure = tyres(TireMode.ROAD)))))!! + assertNull(rig.vehicle) + assertEquals(TireMode.ROAD, rig.mode) + } +} diff --git a/VDTerminal/server/src/main/kotlin/net/vertexdezign/vdt/server/CommandWriter.kt b/VDTerminal/server/src/main/kotlin/net/vertexdezign/vdt/server/CommandWriter.kt index 27f85362..c6fa5d69 100644 --- a/VDTerminal/server/src/main/kotlin/net/vertexdezign/vdt/server/CommandWriter.kt +++ b/VDTerminal/server/src/main/kotlin/net/vertexdezign/vdt/server/CommandWriter.kt @@ -154,6 +154,10 @@ class CommandWriter(private val path: Path, private val ringSize: Int = 16) { """""" } + is ClientMessage.SetTirePressure -> { + """""" + } + is ClientMessage.SetAutoDriveActive -> { """""" } diff --git a/VDTerminal/server/src/test/kotlin/net/vertexdezign/vdt/server/CommandWriterTest.kt b/VDTerminal/server/src/test/kotlin/net/vertexdezign/vdt/server/CommandWriterTest.kt index 21089a27..d3c4c698 100644 --- a/VDTerminal/server/src/test/kotlin/net/vertexdezign/vdt/server/CommandWriterTest.kt +++ b/VDTerminal/server/src/test/kotlin/net/vertexdezign/vdt/server/CommandWriterTest.kt @@ -8,6 +8,7 @@ import net.vertexdezign.vdt.CruiseAction import net.vertexdezign.vdt.InvoiceLineInput import net.vertexdezign.vdt.OutputMode import net.vertexdezign.vdt.TaskInput +import net.vertexdezign.vdt.TirePressureScope import net.vertexdezign.vdt.model.AutoDriveMode import net.vertexdezign.vdt.model.BaleUnloadAction import java.nio.file.Files @@ -63,6 +64,14 @@ class CommandWriterTest { assertTrue(xml.contains("""type="setSpeedSync" active="true""""), xml) } + @Test + fun `writes the tyre pressure as a scope and an absolute mode`() { + val path = Files.createTempDirectory("vdt-cmd").resolve("commands.xml") + CommandWriter(path).submit(ClientMessage.SetTirePressure(TirePressureScope.IMPLEMENTS, road = false)) + val xml = path.readText() + assertTrue(xml.contains("""type="setTirePressure" scope="implements" road="false""""), xml) + } + @Test fun `writes complete and delete task commands with their ids`() { val path = Files.createTempDirectory("vdt-cmd").resolve("commands.xml") diff --git a/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/ModFormat.kt b/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/ModFormat.kt index 74a40746..5584c762 100644 --- a/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/ModFormat.kt +++ b/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/ModFormat.kt @@ -22,7 +22,7 @@ object ModFormat { /** Channel → the format version this build reads, as the mod writes it (a decimal string). */ val expected: Map = mapOf( - "telemetry" to 35, + "telemetry" to 36, "map" to 2, "mapVehicles" to 2, "mapLayers" to 5, diff --git a/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/Protocol.kt b/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/Protocol.kt index 3d5b110e..29091156 100644 --- a/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/Protocol.kt +++ b/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/Protocol.kt @@ -610,6 +610,16 @@ sealed interface ClientMessage { @SerialName("setSpeedSync") data class SetSpeedSync(val active: Boolean) : ClientMessage + /** + * Variable Tire Pressure to road (`road = true`) or field pressure, for the tractor, its implements, + * or both (issue #193). The mod's own keys toggle; this is an absolute state, and the mod presses a + * key only where the machine is not already there, so a resent command settles. See + * [net.vertexdezign.vdt.model.TirePressure]. + */ + @Serializable + @SerialName("setTirePressure") + data class SetTirePressure(val scope: TirePressureScope, val road: Boolean) : ClientMessage + // ---- AutoDrive (issue #97). Every one is an absolute state; the mod drives AutoDrive's own events, and // works out the button presses where AutoDrive only offers a step (the mode, the loop counter). @@ -1043,6 +1053,17 @@ enum class AutoDriveSlot(val token: String) { UNLOAD("unload"), } +/** + * What a [ClientMessage.SetTirePressure] switches: the vehicle's own tyres, everything hitched to it, or + * both — the mod's tractor key, its implements key, and the two in turn. [token] is the wire vocabulary. + */ +@Serializable +enum class TirePressureScope(val token: String) { + ALL("all"), + VEHICLE("vehicle"), + IMPLEMENTS("implements"), +} + /** AutoDrive's loop counter ceiling — `ADStateModule:changeLoopCounter` keeps it at 99. */ const val AUTO_DRIVE_MAX_LOOPS = 99 diff --git a/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/model/Vehicle.kt b/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/model/Vehicle.kt index 99a0ef88..0dd5c1e1 100644 --- a/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/model/Vehicle.kt +++ b/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/model/Vehicle.kt @@ -21,6 +21,8 @@ data class Vehicle( val speedSync: SpeedSync? = null, /** AutoDrive, when that mod is installed and drives this machine (mod version 34). */ val autoDrive: AutoDrive? = null, + /** Variable Tire Pressure, when that mod is installed and this machine's wheels have it (mod version 36). */ + val tirePressure: TirePressure? = null, val isTurnedOn: Boolean? = null, val foldable: FoldableState? = null, val lowered: Boolean? = null, @@ -420,6 +422,74 @@ data class SpeedSync( val available: Boolean = false, ) +// --------------------------------------------------------------------------- +// Tyre pressure (mod version 36) +// --------------------------------------------------------------------------- + +/** + * **Variable Tire Pressure**'s inflation system on one machine (FS25_VariableTirePressure, issue #193): + * the tyres run at a road pressure or a lower field pressure, and the compressor moves them between the + * two over a few seconds. Null when the mod isn't installed *or* this machine's wheels were not bought + * with the system — the mod rides on every wheeled machine but works only on those. + * + * Both a vehicle and its implements carry one; the tractor's key and the implements' key are the two + * scopes [net.vertexdezign.vdt.ClientMessage.SetTirePressure] switches. See the mod's + * `src/integrations/VariableTirePressure.lua`. + */ +@Serializable +data class TirePressure( + val mode: TireMode = TireMode.ROAD, + /** Pressure now, in bar — the mean of the axles while they run apart ([front]/[rear]). */ + val bar: Float = 0f, + /** Where the compressor is taking it; equal to [bar] once it has settled. */ + val targetBar: Float = 0f, + /** The road setpoint, always the higher of the two — the axles' mean while they run apart. Null on a file that lacked it. */ + val roadBar: Float? = null, + /** The field setpoint. */ + val fieldBar: Float? = null, + /** + * Each axle, only while they run apart: the player gave front and rear different setpoints in the + * mod's window. Otherwise the mod moves both as one. + */ + val front: TireAxle? = null, + val rear: TireAxle? = null, +) { + /** + * Which way the pressure is moving, or null when it has settled. Off [bar] against [targetBar] + * rather than a flag of the mod's: the two meet exactly when the transition ends, and a pressure the + * player froze half-way (the mod's "pressure memory") has its target pulled to where it stopped. + */ + val change: TireChange? + get() = when { + targetBar - bar > SETTLED_BAR -> TireChange.INFLATING + bar - targetBar > SETTLED_BAR -> TireChange.DEFLATING + else -> null + } + + private companion object { + /** Below this the two are the same pressure, rounded apart (the mod writes two decimals). */ + const val SETTLED_BAR = 0.015f + } +} + +/** One axle of a tractor that runs them apart. */ +@Serializable +data class TireAxle(val bar: Float = 0f, val mode: TireMode = TireMode.ROAD) + +@Serializable +enum class TireMode { + ROAD, + FIELD, + + /** A tractor's axles apart, one at each pressure. Never on an axle itself. */ + MIXED, +} + +enum class TireChange { + INFLATING, + DEFLATING, +} + // --------------------------------------------------------------------------- // Wear // --------------------------------------------------------------------------- @@ -1385,6 +1455,8 @@ data class Implement( /** A loader's cylinders, or a tool's own clamp (mod version 30). See [LoaderCylinder]. */ val loaderCylinders: List = emptyList(), val precisionFarming: PrecisionFarming? = null, + /** Variable Tire Pressure, when this implement's wheels have it (mod version 36). See [TirePressure]. */ + val tirePressure: TirePressure? = null, /** Index into the *parent's* [Schema.attacherJoint] list — where this implement hangs off it. */ val jointDescIndex: Int? = null, /** Null unless the parent's joint lowers this implement. */ diff --git a/VDTerminal/shared/src/jvmTest/kotlin/net/vertexdezign/vdt/VdtModelTest.kt b/VDTerminal/shared/src/jvmTest/kotlin/net/vertexdezign/vdt/VdtModelTest.kt index 78488c92..f37500f1 100644 --- a/VDTerminal/shared/src/jvmTest/kotlin/net/vertexdezign/vdt/VdtModelTest.kt +++ b/VDTerminal/shared/src/jvmTest/kotlin/net/vertexdezign/vdt/VdtModelTest.kt @@ -18,6 +18,10 @@ import net.vertexdezign.vdt.model.SprayerKind import net.vertexdezign.vdt.model.SteeringLayout import net.vertexdezign.vdt.model.TillageKind import net.vertexdezign.vdt.model.TipState +import net.vertexdezign.vdt.model.TireAxle +import net.vertexdezign.vdt.model.TireChange +import net.vertexdezign.vdt.model.TireMode +import net.vertexdezign.vdt.model.TirePressure import net.vertexdezign.vdt.model.VdtData import net.vertexdezign.vdt.model.Vehicle import java.io.File @@ -633,6 +637,74 @@ class VdtModelTest { assertNull(without.vehicle?.speedSync) } + @Test + fun decodesTheTirePressureCapture() { + // Variable Tire Pressure 1.0.0.13, singleplayer: a John Deere 6R settled at road, and the Kaweco slurry + // tanker behind it caught on its way down to field after the implements key. + val data = model("telemetry/modded/variableTirePressure/tractor_withLiquidManureBarrel.json") + assertJsonRoundTrips(data) + assertEquals("36", data.version) + val tractor = assertNotNull(data.vehicle?.tirePressure) + assertEquals(TirePressure(TireMode.ROAD, bar = 2.2f, targetBar = 2.2f, roadBar = 2.2f, fieldBar = 0.9f), tractor) + assertNull(tractor.change) + val tanker = assertNotNull(data.vehicle.implement.single().tirePressure) + assertEquals(TireMode.FIELD, tanker.mode) + assertEquals(1.71f, tanker.bar) + assertEquals(0.9f, tanker.targetBar) + assertEquals(TireChange.DEFLATING, tanker.change) + } + + @Test + fun decodesTheSplitAxleCapture() { + // The same rig with different front and rear road pressures set in the mod's window, which splits the + // tractor's axles: both at road, front 2.0 and rear 2.2, and the vehicle's own figures their means. + val data = model("telemetry/modded/variableTirePressure/tractor_withDifferentPressure.json") + assertJsonRoundTrips(data) + val tractor = assertNotNull(data.vehicle?.tirePressure) + assertEquals(TireMode.ROAD, tractor.mode) + assertEquals(TireAxle(bar = 2.0f, mode = TireMode.ROAD), tractor.front) + assertEquals(TireAxle(bar = 2.2f, mode = TireMode.ROAD), tractor.rear) + assertEquals(2.1f, tractor.bar) + assertEquals(2.1f, tractor.roadBar) + assertEquals(1.05f, tractor.fieldBar) + assertNull(tractor.change) + // The tanker settled at field, with no axles of its own to report. + val tanker = assertNotNull(data.vehicle.implement.single().tirePressure) + assertEquals(TirePressure(TireMode.FIELD, bar = 0.9f, targetBar = 0.9f, roadBar = 2.2f, fieldBar = 0.9f), tanker) + } + + @Test + fun decodesTheTirePressureBlock() { + // Variable Tire Pressure (mod version 36), the shape no capture has yet: a tractor whose axles sit in + // different modes (MIXED), deflating the rear, with a trailer hitched that has the system too. + val data = + VdtParser.parseJson( + """ + {"version":"36","vehicle":{"name":"Fendt", + "tirePressure":{"mode":"MIXED","bar":1.6,"targetBar":1.4,"roadBar":2.0,"fieldBar":0.8, + "front":{"bar":2.0,"mode":"ROAD"},"rear":{"bar":1.2,"mode":"FIELD"}}, + "implement":[{"position":"BACK","name":"Joskin", + "tirePressure":{"mode":"ROAD","bar":2.2,"targetBar":2.2,"roadBar":2.2,"fieldBar":1.0}}]}} + """.trimIndent(), + ) + assertJsonRoundTrips(data) + val tractor = assertNotNull(data.vehicle?.tirePressure) + assertEquals(TireMode.MIXED, tractor.mode) + assertEquals(TireAxle(bar = 1.2f, mode = TireMode.FIELD), tractor.rear) + assertEquals(TireChange.DEFLATING, tractor.change) + val trailer = assertNotNull(data.vehicle.implement.single().tirePressure) + assertNull(trailer.change) + assertNull(trailer.front) + + assertEquals(TireChange.INFLATING, TirePressure(bar = 1.0f, targetBar = 2.0f).change) + // Two decimals apart is rounding, not a transition. + assertNull(TirePressure(bar = 1.21f, targetBar = 1.2f).change) + + // Without the mod — or on wheels bought without it — there is no block at all. + val without = VdtParser.parseJson("""{"version":"36","vehicle":{"name":"Valtra"}}""") + assertNull(without.vehicle?.tirePressure) + } + @Test fun leavesTheV27ReadsAbsentOnAnOlderCapture() { // A pre-27 file says nothing about any of them, and must not read as "no limit / no hitch / not diff --git a/examples/json/telemetry/modded/variableTirePressure/tractor_withDifferentPressure.json b/examples/json/telemetry/modded/variableTirePressure/tractor_withDifferentPressure.json new file mode 100644 index 00000000..6e5e2ac1 --- /dev/null +++ b/examples/json/telemetry/modded/variableTirePressure/tractor_withDifferentPressure.json @@ -0,0 +1,338 @@ +{ + "vehicle": { + "brand": { + "image": "Z:/home/benjamin/.local/share/Steam/steamapps/common/Farming Simulator 25/data/store/brands/brand_johnDeere.png", + "name": "JOHNDEERE", + "title": "John Deere" + }, + "speedSync": { + "available": false, + "waiting": false, + "active": false + }, + "implement": [ + { + "brand": { + "name": "KAWECO", + "title": "Kaweco" + }, + "type": "manureBarrel", + "tirePressure": { + "targetBar": 0.9, + "fieldBar": 0.9, + "mode": "FIELD", + "roadBar": 2.2, + "bar": 0.9 + }, + "precisionFarming": { + "auto": true, + "canToggleAuto": true, + "mode": "OTHER", + "spotSpray": false + }, + "isTurnedOn": false, + "selection": { + "selectable": true, + "selected": true + }, + "fillUnits": { + "fillUnit": [ + { + "type": "", + "title": "", + "fillLevelPercentage": 0, + "value": 0, + "capacity": 20000, + "unit": "" + } + ] + }, + "wearable": { + "wear": 0, + "damage": 0, + "dirt": 0, + "unit": "%" + }, + "mass": { + "value": 7.402, + "empty": 5.95 + }, + "name": "Kaweco Profi II", + "position": "BACK", + "spraying": { + "doubledAmountAvailable": false, + "allowsSpraying": true, + "active": false, + "kind": "SLURRY_TANKER", + "doubledAmount": false + }, + "discharge": { + "fillUnitIndex": 1, + "canToggle": true, + "hasObject": false, + "allowed": true, + "state": "OFF", + "nodeIndex": 1, + "hitTerrain": false + }, + "jointDescIndex": 5, + "schema": { + "name": "IMPLEMENT", + "attacherJoint": [ + { + "y": 0, + "x": 1, + "liftedOffsetY": 5, + "liftedOffsetX": 0, + "rotation": 0, + "invertX": false + } + ], + "borderLeft": 0.05, + "offsetY": 0, + "borderRight": 0.05, + "offsetX": 0 + } + } + ], + "odometer": { + "value": 0.1, + "unit": "km" + }, + "mass": { + "value": 15.343, + "empty": 6.365 + }, + "speed": { + "value": 0, + "direction": "STOPPED", + "unit": "km/h" + }, + "cruiseControl": { + "active": false, + "targetSpeed": 53 + }, + "wearable": { + "wear": 0, + "damage": 0, + "dirt": 0, + "unit": "%" + }, + "gps": { + "enabled": false, + "heading": 302, + "linesVisible": true, + "active": false, + "headingUnit": "°" + }, + "type": "tractor", + "motor": { + "rpm": { + "value": 900, + "max": 2200, + "min": 0 + }, + "direction": "FORWARD", + "parkingBrake": false, + "temperatur": { + "value": 90, + "min": 20, + "max": 120, + "unit": "°C" + }, + "state": "ON", + "maxSpeed": { + "backward": 22, + "forward": 53 + }, + "awd": true, + "gear": { + "value": "D", + "isNeutral": false + }, + "diffLock": { + "front": false, + "back": false + }, + "load": { + "value": 10.04, + "min": 0, + "max": 100, + "unit": "%" + }, + "fillUnits": { + "air": { + "fillLevelPercentage": 86, + "title": "Luft", + "value": 3431, + "capacity": 4000, + "unit": "l" + }, + "fuel": { + "type": "diesel", + "title": "Diesel", + "fillLevelPercentage": 100, + "value": 322, + "usage": 5.7, + "capacity": 322, + "unit": "l" + }, + "def": { + "fillLevelPercentage": 100, + "title": "Synthetische Harnstofflösung", + "value": 16, + "capacity": 16, + "unit": "l" + } + } + }, + "tirePressure": { + "targetBar": 2.1, + "fieldBar": 1.05, + "front": { + "mode": "ROAD", + "bar": 2 + }, + "rear": { + "mode": "ROAD", + "bar": 2.2 + }, + "mode": "ROAD", + "roadBar": 2.1, + "bar": 2.1 + }, + "lights": { + "beaconLight": false, + "indicator": { + "right": false, + "left": false, + "hazard": false + }, + "workLight": { + "front": false, + "back": false + }, + "light": { + "highBeam": false, + "lowBeam": false + } + }, + "name": "John Deere 6R 145", + "ads": { + "electrical": { + "lowBelow": 12, + "unit": "V", + "systemVoltage": 14.1 + }, + "transmissionTemperatur": { + "value": 58, + "min": 20, + "max": 120, + "unit": "°C" + }, + "lamps": { + "coolant": "OFF", + "warning": "OFF", + "oil": "OFF", + "transmission": "OFF", + "battery": "OFF", + "service": "OFF", + "engine": "OFF", + "brakes": "OFF" + }, + "load": { + "value": 10, + "unit": "%", + "overloadAt": 85 + }, + "service": { + "interval": 5, + "hours": 0 + } + }, + "operatingTime": { + "value": "0.04", + "unit": "h" + }, + "ai": { + "active": false + }, + "schema": { + "name": "VEHICLE", + "attacherJoint": [ + { + "y": 0, + "x": 1, + "liftedOffsetY": 5, + "liftedOffsetX": 0, + "rotation": 0, + "invertX": true + }, + { + "y": 0, + "x": 1, + "liftedOffsetY": 5, + "liftedOffsetX": 0, + "rotation": 0, + "invertX": true + }, + { + "y": 0, + "x": 1, + "liftedOffsetY": 5, + "liftedOffsetX": 0, + "rotation": 0, + "invertX": false + }, + { + "y": 0, + "x": 1, + "liftedOffsetY": 5, + "liftedOffsetX": 0, + "rotation": 0, + "invertX": false + }, + { + "y": 0, + "x": 1, + "liftedOffsetY": 5, + "liftedOffsetX": 0, + "rotation": 0, + "invertX": false + } + ], + "borderLeft": 0.05, + "offsetY": 0, + "borderRight": 0.05, + "offsetX": 0 + }, + "selection": { + "selectable": true, + "selected": false + } + }, + "version": "36", + "environment": { + "time": "09:03", + "pda": { + "filename": "Z:/home/benjamin/.local/share/Steam/steamapps/common/Farming Simulator 25/data/maps/mapUS/textures/ui/overview.png", + "player": { + "heading": 302, + "posX": 0.29983818531036, + "farmId": 1, + "posZ": 0.32312335073948, + "headingUnit": "°" + } + }, + "date": "01.09.2024", + "weather": { + "temperature": { + "min": 7, + "current": 11, + "max": 17, + "unit": "°C" + } + }, + "language": "de" + } +} diff --git a/examples/json/telemetry/modded/variableTirePressure/tractor_withLiquidManureBarrel.json b/examples/json/telemetry/modded/variableTirePressure/tractor_withLiquidManureBarrel.json new file mode 100644 index 00000000..4741546b --- /dev/null +++ b/examples/json/telemetry/modded/variableTirePressure/tractor_withLiquidManureBarrel.json @@ -0,0 +1,330 @@ +{ + "vehicle": { + "brand": { + "image": "Z:/home/benjamin/.local/share/Steam/steamapps/common/Farming Simulator 25/data/store/brands/brand_johnDeere.png", + "name": "JOHNDEERE", + "title": "John Deere" + }, + "speedSync": { + "available": false, + "waiting": false, + "active": false + }, + "implement": [ + { + "brand": { + "name": "KAWECO", + "title": "Kaweco" + }, + "type": "manureBarrel", + "tirePressure": { + "targetBar": 0.9, + "fieldBar": 0.9, + "mode": "FIELD", + "roadBar": 2.2, + "bar": 1.71 + }, + "precisionFarming": { + "auto": true, + "canToggleAuto": true, + "mode": "OTHER", + "spotSpray": false + }, + "isTurnedOn": false, + "selection": { + "selectable": true, + "selected": true + }, + "fillUnits": { + "fillUnit": [ + { + "type": "", + "title": "", + "fillLevelPercentage": 0, + "value": 0, + "capacity": 20000, + "unit": "" + } + ] + }, + "wearable": { + "wear": 0, + "damage": 0, + "dirt": 0, + "unit": "%" + }, + "mass": { + "value": 7.402, + "empty": 5.95 + }, + "name": "Kaweco Profi II", + "position": "BACK", + "spraying": { + "doubledAmountAvailable": false, + "allowsSpraying": true, + "active": false, + "kind": "SLURRY_TANKER", + "doubledAmount": false + }, + "discharge": { + "fillUnitIndex": 1, + "canToggle": true, + "hasObject": false, + "allowed": true, + "state": "OFF", + "nodeIndex": 1, + "hitTerrain": false + }, + "jointDescIndex": 5, + "schema": { + "name": "IMPLEMENT", + "attacherJoint": [ + { + "y": 0, + "x": 1, + "liftedOffsetY": 5, + "liftedOffsetX": 0, + "rotation": 0, + "invertX": false + } + ], + "borderLeft": 0.05, + "offsetY": 0, + "borderRight": 0.05, + "offsetX": 0 + } + } + ], + "odometer": { + "value": 0.1, + "unit": "km" + }, + "mass": { + "value": 15.343, + "empty": 6.365 + }, + "speed": { + "value": 0, + "direction": "STOPPED", + "unit": "km/h" + }, + "cruiseControl": { + "active": false, + "targetSpeed": 53 + }, + "wearable": { + "wear": 0, + "damage": 0, + "dirt": 0, + "unit": "%" + }, + "gps": { + "enabled": false, + "heading": 302, + "linesVisible": true, + "active": false, + "headingUnit": "°" + }, + "type": "tractor", + "motor": { + "rpm": { + "value": 900, + "max": 2200, + "min": 0 + }, + "direction": "FORWARD", + "parkingBrake": false, + "temperatur": { + "value": 79, + "min": 20, + "max": 120, + "unit": "°C" + }, + "state": "ON", + "maxSpeed": { + "backward": 22, + "forward": 53 + }, + "awd": true, + "gear": { + "value": "D", + "isNeutral": false + }, + "diffLock": { + "front": false, + "back": false + }, + "load": { + "value": 9.63, + "min": 0, + "max": 100, + "unit": "%" + }, + "fillUnits": { + "air": { + "fillLevelPercentage": 86, + "title": "Luft", + "value": 3431, + "capacity": 4000, + "unit": "l" + }, + "fuel": { + "type": "diesel", + "title": "Diesel", + "fillLevelPercentage": 100, + "value": 322, + "usage": 5.7, + "capacity": 322, + "unit": "l" + }, + "def": { + "fillLevelPercentage": 100, + "title": "Synthetische Harnstofflösung", + "value": 16, + "capacity": 16, + "unit": "l" + } + } + }, + "tirePressure": { + "targetBar": 2.2, + "fieldBar": 0.9, + "mode": "ROAD", + "roadBar": 2.2, + "bar": 2.2 + }, + "lights": { + "beaconLight": false, + "indicator": { + "right": false, + "left": false, + "hazard": false + }, + "workLight": { + "front": false, + "back": false + }, + "light": { + "highBeam": false, + "lowBeam": false + } + }, + "name": "John Deere 6R 145", + "ads": { + "electrical": { + "lowBelow": 12, + "unit": "V", + "systemVoltage": 14.1 + }, + "transmissionTemperatur": { + "value": 52, + "min": 20, + "max": 120, + "unit": "°C" + }, + "lamps": { + "coolant": "OFF", + "warning": "OFF", + "oil": "OFF", + "transmission": "OFF", + "battery": "OFF", + "service": "OFF", + "engine": "OFF", + "brakes": "OFF" + }, + "load": { + "value": 11, + "unit": "%", + "overloadAt": 85 + }, + "service": { + "interval": 5, + "hours": 0 + } + }, + "operatingTime": { + "value": "0.02", + "unit": "h" + }, + "ai": { + "active": false + }, + "schema": { + "name": "VEHICLE", + "attacherJoint": [ + { + "y": 0, + "x": 1, + "liftedOffsetY": 5, + "liftedOffsetX": 0, + "rotation": 0, + "invertX": true + }, + { + "y": 0, + "x": 1, + "liftedOffsetY": 5, + "liftedOffsetX": 0, + "rotation": 0, + "invertX": true + }, + { + "y": 0, + "x": 1, + "liftedOffsetY": 5, + "liftedOffsetX": 0, + "rotation": 0, + "invertX": false + }, + { + "y": 0, + "x": 1, + "liftedOffsetY": 5, + "liftedOffsetX": 0, + "rotation": 0, + "invertX": false + }, + { + "y": 0, + "x": 1, + "liftedOffsetY": 5, + "liftedOffsetX": 0, + "rotation": 0, + "invertX": false + } + ], + "borderLeft": 0.05, + "offsetY": 0, + "borderRight": 0.05, + "offsetX": 0 + }, + "selection": { + "selectable": true, + "selected": false + } + }, + "version": "36", + "environment": { + "time": "09:03", + "pda": { + "filename": "Z:/home/benjamin/.local/share/Steam/steamapps/common/Farming Simulator 25/data/maps/mapUS/textures/ui/overview.png", + "player": { + "heading": 302, + "posX": 0.29983726143837, + "farmId": 1, + "posZ": 0.32312412559986, + "headingUnit": "°" + } + }, + "date": "01.09.2024", + "weather": { + "temperature": { + "min": 7, + "current": 11, + "max": 17, + "unit": "°C" + } + }, + "language": "de" + } +} diff --git a/vdTelemetry/Readme.md b/vdTelemetry/Readme.md index f06a0ab7..a73898e1 100644 --- a/vdTelemetry/Readme.md +++ b/vdTelemetry/Readme.md @@ -412,6 +412,16 @@ version renames costs you that panel, never a Lua error. copy nobody drives with. Where AutoDrive offers only a step (the mode button, the loop counter's +/-), the mod works out the presses from the state it finds. A destination travels with its name, and is refused if the marker list shifted under the tap +* [FS25_VariableTirePressure](https://www.farming-simulator.com/mod.php?mod_id=347991&title=fs2025) `1.0.0.13` — + a central tyre inflation system: road or field pressure per machine, moved between by a compressor + (`src/integrations/VariableTirePressure.lua`). **Read and write:** + * `tirePressure` on the vehicle and on every implement whose wheels were bought with the system: the pressure + now and where it is heading, road / field / mixed, the two setpoints, and each axle while a tractor runs them + apart — read through the getters the mod registers for Dashboard Live, so the numbers are its HUD's + * One command, `setTirePressure` (`src/command/TirePressureControl.lua`) — road or field for the tractor, the + implements, or both. The mod only offers toggles; the command is absolute and presses the mod's own tractor or + implements key only where the machines are not already there, through the mod's key handler — so it works from + a multiplayer client the way the key does * [FS25_TaskList](https://www.farming-simulator.com/mod.php?mod_id=312938&title=fs2025) `1.2.0.1` ([source](https://github.com/Ozz-Modding/FS25_TaskList)) — the farm task list, in its own `taskList.json` channel (`src/integrations/TaskList.lua`). **Read and write:** VDTerminal can diff --git a/vdTelemetry/VDTelemetry.lua b/vdTelemetry/VDTelemetry.lua index b8d4c40d..25d7b5e7 100644 --- a/vdTelemetry/VDTelemetry.lua +++ b/vdTelemetry/VDTelemetry.lua @@ -120,6 +120,7 @@ local sourceFiles = { -- AutoDrive: the vehicle block (object stage) and the autoDrive.json destinations channel, which -- needs MapExporter's world-size helpers at collect time. "src/integrations/AutoDrive.lua", + "src/integrations/VariableTirePressure.lua", "src/integrations/registry.lua", "src/integrations/TaskList.lua", "src/integrations/CropRotation.lua", @@ -169,6 +170,9 @@ local sourceFiles = { -- AutoDrive start/stop, mode, destinations, fill type and loop counter, through AutoDrive's own -- events; reads the integration sourced above. "src/command/AutoDriveControl.lua", + -- Variable Tire Pressure road/field, through that mod's own key handler; reads the integration + -- sourced above. + "src/command/TirePressureControl.lua", -- Precision Farming application rate (auto/manual + the manual step). Resolves which machine on the -- rig to drive through src/integrations/PrecisionFarming.lua, sourced above. "src/command/PrecisionFarmingControl.lua", @@ -367,7 +371,11 @@ VDTelemetry.TELEMETRY_CHANNEL = "telemetry" -- itself is its own channel, autoDrive.json. -- 35: `environment.language` -- the game's language code, which the terminal follows by default (issue -- #132). Additive. -VDTelemetry.VERSION = 35 +-- 36: `tirePressure` on the vehicle and on every implement Variable Tire Pressure works on (issue #193): +-- the pressure now and where it is heading, road / field / mixed, the two setpoints, and each axle +-- while a tractor runs them apart. Absent without the mod. Written to by the setTirePressure +-- command (command/TirePressureControl.lua). +VDTelemetry.VERSION = 36 VDTelemetry.SETTINGS_XML = "vdTelemetrySettings.xml" VDTelemetry.SETTINGS_XML_VERSION = 3 -- Everything lives under modSettings//: the settings XML at its root and the telemetry diff --git a/vdTelemetry/spec/VariableTirePressure_spec.lua b/vdTelemetry/spec/VariableTirePressure_spec.lua new file mode 100644 index 00000000..903fdcc9 --- /dev/null +++ b/vdTelemetry/spec/VariableTirePressure_spec.lua @@ -0,0 +1,246 @@ +-- Unit tests for the Variable Tire Pressure integration (src/integrations/VariableTirePressure.lua) and +-- its write side (src/command/TirePressureControl.lua). +-- +-- Run with `busted` from the vdTelemetry/ directory. The machines are shaped like the mod's own +-- (1.0.0.13): a `spec_variableTirePressure` alias set in its onLoad, the dashboard getters it registers +-- on the vehicle type, and its specialization class in the type's `specializationsByName` under the +-- mod-qualified name, which is where the control finds the key handler. + +if ValueMapper == nil then + dofile("src/mapper/ValueMapper.lua") +end +if VDT == nil or VDT.CommandRegistry == nil then + dofile("src/command/CommandRegistry.lua") +end +dofile("src/integrations/VariableTirePressure.lua") +dofile("src/command/TirePressureControl.lua") + +-- The pressures are rounded through ValueMapper.mapFloat, which needs the engine's MathUtil (stubbed +-- the same way GpsCourse_spec does). +rawset(_G, "MathUtil", { + round = function(value, decimals) + local mult = 10 ^ (decimals or 0) + return math.floor(value * mult + 0.5) / mult + end, +}) + +local debugger = { debug = function() end, warn = function() end } + +local MODE_CODES = { ROAD = 0, FIELD = 1, MIXED_FRONT_ROAD = 2, MIXED_FRONT_FIELD = 3 } + +---A machine with the mod working on it. `o` overrides: mode (a MODE_CODES key), bar, target, road, +---field, split (axles apart, with front/rear { bar, road }), enabled, implements, isServer. +local function machine(o) + o = o or {} + local mode = o.mode or "ROAD" + local spec = { + isConfigEnabled = o.enabled ~= false, + isRoadMode = mode == "ROAD" or mode == "MIXED_FRONT_ROAD", + tractorAxleSplitActive = o.split ~= nil, + } + local implements = {} + for _, child in ipairs(o.implements or {}) do + implements[#implements + 1] = { object = child } + end + local object = { + spec_variableTirePressure = spec, + isServer = o.isServer, + set = {}, + vtpGetDashboardMode = function() + return MODE_CODES[mode] + end, + vtpGetDashboardPressureBar = function() + return o.bar or 2.0 + end, + vtpGetDashboardPressureTarget = function() + return o.target or o.bar or 2.0 + end, + vtpGetRoadPressure = function() + return o.road or 2.0 + end, + vtpGetFieldPressure = function() + return o.field or 0.8 + end, + getAttachedImplements = function() + return implements + end, + } + if o.split ~= nil then + object.vtpGetDashboardPressureBarFront = function() + return o.split.front.bar + end + object.vtpGetDashboardPressureBarRear = function() + return o.split.rear.bar + end + object.vtpGetDashboardModeFront = function() + return o.split.front.road and 0 or 1 + end + object.vtpGetDashboardModeRear = function() + return o.split.rear.road and 0 or 1 + end + end + function object:vtpSetRoadMode(isRoad) + self.set[#self.set + 1] = isRoad + end + return object +end + +---The class as the vehicle type holds it, recording the key presses its handler receives. +local function withClass(vehicle, folder) + local presses = {} + vehicle.specializationsByName = { + wheels = {}, + [(folder or "FS25_VariableTirePressure") .. ".variableTirePressure"] = { + actionEventTogglePressure = function(self, action, inputValue, callbackState) + assert.are.equal(vehicle, self) + assert.are.equal(InputAction.CALLBACK_STATE_START, callbackState) + presses[#presses + 1] = action + end, + }, + } + return presses +end + +describe("VariableTirePressure.contributeObject", function() + it("exports the pressure, its target, the mode and the setpoints", function() + local model = {} + VDT.VariableTirePressure.contributeObject(machine({ mode = "FIELD", bar = 1.234, target = 0.8 }), model) + assert.are.same({ mode = "FIELD", bar = 1.23, targetBar = 0.8, roadBar = 2.0, fieldBar = 0.8 }, model.tirePressure) + end) + + it("puts the higher setpoint on road, as the mod's own HUD does", function() + local model = {} + VDT.VariableTirePressure.contributeObject(machine({ road = 0.6, field = 1.8 }), model) + assert.are.equal(1.8, model.tirePressure.roadBar) + assert.are.equal(0.6, model.tirePressure.fieldBar) + end) + + it("exports each axle only while they run apart", function() + local model = {} + VDT.VariableTirePressure.contributeObject(machine(), model) + assert.is_nil(model.tirePressure.front) + + local split = { front = { bar = 2.0, road = true }, rear = { bar = 0.9, road = false } } + VDT.VariableTirePressure.contributeObject(machine({ mode = "MIXED_FRONT_ROAD", bar = 1.45, split = split }), model) + assert.are.equal("MIXED", model.tirePressure.mode) + assert.are.same({ bar = 2.0, mode = "ROAD" }, model.tirePressure.front) + assert.are.same({ bar = 0.9, mode = "FIELD" }, model.tirePressure.rear) + end) + + it("skips a machine whose wheels were not bought with the system", function() + local model = {} + VDT.VariableTirePressure.contributeObject(machine({ enabled = false }), model) + assert.is_nil(model.tirePressure) + end) + + it("skips a machine without the mod, and one whose getters are missing", function() + local model = {} + VDT.VariableTirePressure.contributeObject({}, model) + assert.is_nil(model.tirePressure) + + VDT.VariableTirePressure.contributeObject({ spec_variableTirePressure = { isConfigEnabled = true } }, model) + assert.is_nil(model.tirePressure) + end) +end) + +describe("TirePressureControl.setTirePressure", function() + setup(function() + rawset(_G, "InputAction", { CALLBACK_STATE_START = 1 }) + end) + + it("presses the tractor key only when the tractor is not already there", function() + local tractor = machine({ mode = "ROAD" }) + local presses = withClass(tractor) + VDT.TirePressureControl.setTirePressure(tractor, "vehicle", true, debugger) + assert.are.same({}, presses) + + VDT.TirePressureControl.setTirePressure(tractor, "vehicle", false, debugger) + assert.are.same({ "VTP_SET_TIRE_PRESSURE_TRACTOR" }, presses) + end) + + it("presses the tractor key on axles that disagree, whichever way was asked", function() + local split = { front = { bar = 2.0, road = true }, rear = { bar = 0.9, road = false } } + local tractor = machine({ mode = "MIXED_FRONT_ROAD", split = split }) + local presses = withClass(tractor) + VDT.TirePressureControl.setTirePressure(tractor, "vehicle", true, debugger) + assert.are.same({ "VTP_SET_TIRE_PRESSURE_TRACTOR" }, presses) + end) + + it("toggles the implements from the first one, depth-first", function() + local behind = machine({ mode = "FIELD" }) + local trailer = machine({ mode = "ROAD", implements = { behind } }) + local tractor = machine({ enabled = false, implements = { trailer } }) + local presses = withClass(tractor) + + VDT.TirePressureControl.setTirePressure(tractor, "implements", false, debugger) + assert.are.same({ "VTP_SET_TIRE_PRESSURE_TOOLS" }, presses) + end) + + it("sets straggling implements directly on the server, where the key would turn them all", function() + local behind = machine({ mode = "FIELD" }) + local trailer = machine({ mode = "ROAD", implements = { behind } }) + local tractor = machine({ implements = { trailer }, isServer = true }) + local presses = withClass(tractor) + + VDT.TirePressureControl.setTirePressure(tractor, "implements", true, debugger) + assert.are.same({}, presses) + assert.are.same({}, trailer.set) + assert.are.same({ true }, behind.set) + end) + + it("leaves straggling implements alone on a multiplayer client", function() + local behind = machine({ mode = "FIELD" }) + local trailer = machine({ mode = "ROAD", implements = { behind } }) + local tractor = machine({ implements = { trailer }, isServer = false }) + local presses = withClass(tractor) + + VDT.TirePressureControl.setTirePressure(tractor, "implements", true, debugger) + assert.are.same({}, presses) + assert.are.same({}, behind.set) + end) + + it("brings a whole rig that disagrees to one mode, a key per part that needs it", function() + local trailer = machine({ mode = "FIELD" }) + local tractor = machine({ mode = "ROAD", implements = { trailer } }) + local presses = withClass(tractor) + + VDT.TirePressureControl.setTirePressure(tractor, "all", true, debugger) + assert.are.same({ "VTP_SET_TIRE_PRESSURE_TOOLS" }, presses) + + presses = withClass(tractor) + VDT.TirePressureControl.setTirePressure(tractor, "all", false, debugger) + assert.are.same({ "VTP_SET_TIRE_PRESSURE_TRACTOR" }, presses) + end) + + it("finds the class under whatever folder the mod was installed as", function() + local tractor = machine({ mode = "ROAD" }) + local presses = withClass(tractor, "FS25_VariableTirePressure_update") + VDT.TirePressureControl.setTirePressure(tractor, "vehicle", false, debugger) + assert.are.same({ "VTP_SET_TIRE_PRESSURE_TRACTOR" }, presses) + end) + + it("ignores a vehicle without the mod, a bad scope and a missing mode", function() + VDT.TirePressureControl.setTirePressure(machine(), "vehicle", false, debugger) + + local tractor = machine({ mode = "ROAD" }) + local presses = withClass(tractor) + VDT.TirePressureControl.setTirePressure(tractor, "front", false, debugger) + VDT.TirePressureControl.setTirePressure(tractor, "vehicle", nil, debugger) + assert.are.same({}, presses) + end) + + it("parses the scope and the absolute mode off the command", function() + local handler = VDT.CommandRegistry.get("setTirePressure") + local xml = { + getString = function(_, key) + assert.are.equal("c#scope", key) + return "all" + end, + getBool = function(_, key) + assert.are.equal("c#road", key) + return false + end, + } + assert.are.same({ scope = "all", road = false }, handler.parse(xml, "c")) + end) +end) diff --git a/vdTelemetry/src/command/TirePressureControl.lua b/vdTelemetry/src/command/TirePressureControl.lua new file mode 100644 index 00000000..59f56803 --- /dev/null +++ b/vdTelemetry/src/command/TirePressureControl.lua @@ -0,0 +1,144 @@ +-- Executes the tyre-pressure command from the app -> mod back-channel: the write side of the Variable +-- Tire Pressure integration (src/integrations/VariableTirePressure.lua, issue #193). +-- +-- Road or field pressure for the tractor, for everything hitched to it, or for both -- the mod's own +-- two keys, tractor (Alt+T) and implements (Alt+O). The whole-combination key (Alt+V) is not used: +-- "both" is the two keys in turn, each only where it is needed, which also covers the rig whose +-- tractor and trailer already disagree -- a toggle of the whole would flip both and keep them apart. +-- +-- Through the mod's OWN key handler, called the way its multiplayer event calls it, rather than +-- vtpSetRoadMode: the handler is where the mod keeps everything a key press means -- the axles run +-- apart and brought back together, the sounds, and on a multiplayer client the event that hands the +-- press to the server, which runs the transitions. Re-implementing that would drift with every update. +-- +-- The keys toggle, but the command is ABSOLUTE, so a resent command settles rather than flipping back: +-- a key is pressed only when the state it toggles from is not the one asked for. That state is the one +-- the mod's handler reads: the tractor's mode, and for the implements the FIRST one with the mod, +-- depth-first, which the handler toggles every implement away from. +-- +-- Two cases a single press does not settle, both left to the next tap rather than guessed at: +-- * the tractor's axles apart, one at each pressure: the mod's tractor key brings the stray axle to +-- the other one's mode first. A tap that asked for the other mode is then one tap short. +-- * implements that disagree among themselves, the first already right: its key would turn them all +-- the wrong way. On the server (single player, the host) the stragglers are set directly, the call +-- the mod's handler makes for each implement; a multiplayer client has no such call, and logs. +-- +-- The class is looked up off the vehicle's own type, under whatever folder name the mod was installed +-- as, so nothing is read from the mod's environment. +-- +-- Namespaced under VDT.* (see aspects/TurnOn.lua). + +VDT = VDT or {} +VDT.TirePressureControl = {} + +-- The mod's input actions (its modDesc), which its handler dispatches on. +VDT.TirePressureControl.ACTION_TRACTOR = "VTP_SET_TIRE_PRESSURE_TRACTOR" +VDT.TirePressureControl.ACTION_TOOLS = "VTP_SET_TIRE_PRESSURE_TOOLS" + +local SCOPES = { all = true, vehicle = true, implements = true } + +---The mod's specialization class, off the vehicle type's own table. Keyed by the mod-qualified name +---(`.variableTirePressure`), so matched on the suffix. +---@param object table +---@return table|nil +local function specClass(object) + local byName = object.specializationsByName + if type(byName) ~= "table" then + return nil + end + for name, class in pairs(byName) do + if + type(name) == "string" + and (name == "variableTirePressure" or name:find("%.variableTirePressure$") ~= nil) + and type(class) == "table" + and type(class.actionEventTogglePressure) == "function" + then + return class + end + end + return nil +end + +---Every implement with the mod working on it, depth-first: the order the mod's handler walks them in, +---so the first is the one it toggles from. +---@param object table +---@param out table[]|nil +---@return table[] +function VDT.TirePressureControl.implementsOf(object, out) + out = out or {} + if type(object.getAttachedImplements) ~= "function" then + return out + end + for _, implement in ipairs(object:getAttachedImplements() or {}) do + local child = implement ~= nil and implement.object or nil + if child ~= nil then + if VDT.VariableTirePressure.specOf(child) ~= nil then + out[#out + 1] = child + end + VDT.TirePressureControl.implementsOf(child, out) + end + end + return out +end + +---Press one of the mod's keys, the way its own multiplayer event replays a press. +local function press(class, vehicle, action, debugger) + class.actionEventTogglePressure(vehicle, action, 0, InputAction.CALLBACK_STATE_START, false) + debugger:debug("setTirePressure: pressed %s", action) +end + +---@param vehicle Vehicle the controlled vehicle +---@param scope string|nil all | vehicle | implements +---@param road boolean|nil true for road pressure, false for field +---@param debugger GrisuDebug +function VDT.TirePressureControl.setTirePressure(vehicle, scope, road, debugger) + if road == nil or not SCOPES[scope] then + debugger:warn("setTirePressure: bad scope '%s' or no mode given, ignoring", tostring(scope)) + return + end + local class = specClass(vehicle) + if class == nil then + debugger:debug("setTirePressure: Variable Tire Pressure is not on this vehicle, ignoring") + return + end + local wanted = road and "ROAD" or "FIELD" + + if scope ~= "implements" then + if VDT.VariableTirePressure.specOf(vehicle) == nil then + debugger:debug("setTirePressure: the vehicle's own tyres have no inflation system") + elseif VDT.VariableTirePressure.modeOf(vehicle) ~= wanted then + press(class, vehicle, VDT.TirePressureControl.ACTION_TRACTOR, debugger) + end + end + + if scope ~= "vehicle" then + local implements = VDT.TirePressureControl.implementsOf(vehicle) + local first = implements[1] + if first == nil then + debugger:debug("setTirePressure: no implement with an inflation system") + elseif (VDT.VariableTirePressure.specOf(first).isRoadMode == true) ~= road then + press(class, vehicle, VDT.TirePressureControl.ACTION_TOOLS, debugger) + else + for i = 2, #implements do + local implement = implements[i] + if (VDT.VariableTirePressure.specOf(implement).isRoadMode == true) ~= road then + if vehicle.isServer == true and type(implement.vtpSetRoadMode) == "function" then + implement:vtpSetRoadMode(road, false) + debugger:debug("setTirePressure: set a straggling implement to %s", wanted) + else + debugger:debug("setTirePressure: an implement disagrees and only the server can set it alone") + end + end + end + end + end +end + +VDT.CommandRegistry.register("setTirePressure", { + parse = function(xml, key) + return { scope = xml:getString(key .. "#scope"), road = xml:getBool(key .. "#road") } + end, + execute = function(vehicle, params, debugger) + VDT.TirePressureControl.setTirePressure(vehicle, params.scope, params.road, debugger) + end, +}) diff --git a/vdTelemetry/src/integrations/VariableTirePressure.lua b/vdTelemetry/src/integrations/VariableTirePressure.lua new file mode 100644 index 00000000..72521de2 --- /dev/null +++ b/vdTelemetry/src/integrations/VariableTirePressure.lua @@ -0,0 +1,140 @@ +-- Optional integration: Variable Tire Pressure (FS25_VariableTirePressure, by HotShotPepper). Namespaced +-- under VDT.* (see aspects/TurnOn.lua). Issue #193. +-- +-- **Written against Variable Tire Pressure 1.0.0.13.** The mod is a central tyre inflation system: each +-- machine runs at a road pressure or a lower field pressure, and the compressor moves it between the two +-- over a few seconds. A tractor's axles run apart once the player gives front and rear different +-- setpoints in the mod's window, and can then even sit in different modes. This exports, per machine +-- that has it, what its own dashboard values show: the pressure now and where it is heading, the mode, +-- and the two setpoints the pressure moves between -- plus each axle while they run apart. The write side is src/command/TirePressureControl.lua. +-- +-- Every machine with wheels CARRIES the mod's spec (it injects itself into every such vehicle type), +-- but it only works on one whose wheel configuration was bought with it -- `isConfigEnabled`. That +-- flag, not the spec's presence, is the "has it" check, so an ordinary trailer exports nothing. +-- +-- Read through the getters the mod registers on the vehicle type for Dashboard Live +-- (vtpGetDashboard*), not the spec's fields: they hold the interpolation from deformation factor to bar +-- that the mod's own HUD uses, so the two cannot disagree. Reached through `spec_variableTirePressure`, +-- a plain alias the mod sets in its onLoad (the real key is mod-name-prefixed). +-- +-- MULTIPLAYER: the server runs the transitions and streams the factors and modes to every client +-- (onWriteUpdateStream), so a client's getters report the host's tyres. +-- +-- Deliberately NOT collected: the deformation and minimum-radius tuning from the mod's settings +-- window. It is how the tyre looks, not what it is doing. + +---@class TirePressureAxleModel +---@field bar number the axle's pressure now, in bar +---@field mode string ROAD | FIELD + +---@class TirePressureModel +---@field mode string ROAD | FIELD | MIXED (axles apart, one at each) +---@field bar number the pressure now, in bar; the mean of the axles while they run apart +---@field targetBar number where the compressor is taking it; equal to `bar` once it settles +---@field roadBar number the road setpoint; the mean of the axles' while they run apart +---@field fieldBar number the field setpoint, likewise +---@field front TirePressureAxleModel? only while the axles run apart +---@field rear TirePressureAxleModel? + +---@class VehicleModel +---@field tirePressure TirePressureModel? + +---@class ImplementModel +---@field tirePressure TirePressureModel? + +VDT = VDT or {} +VDT.VariableTirePressure = {} + +-- vtpGetDashboardMode's codes: 2 is front at road and rear at field, 3 the reverse. +local MODES = { [0] = "ROAD", [1] = "FIELD", [2] = "MIXED", [3] = "MIXED" } + +---The machine's spec when the mod works on it: installed, and the wheel configuration bought with it. +---@param object table a vehicle or implement +---@return table|nil +function VDT.VariableTirePressure.specOf(object) + local spec = object.spec_variableTirePressure + if type(spec) ~= "table" or spec.isConfigEnabled ~= true then + return nil + end + return spec +end + +---The mode the machine is in, as vtpGetDashboardMode reports it; nil when the getter is missing. +---@param object table +---@return string|nil ROAD | FIELD | MIXED +function VDT.VariableTirePressure.modeOf(object) + if type(object.vtpGetDashboardMode) ~= "function" then + return nil + end + return MODES[object:vtpGetDashboardMode()] +end + +---@param value any +---@return number|nil +local function bar(value) + if type(value) ~= "number" or value ~= value then + return nil + end + return tonumber(ValueMapper.mapFloat(value, 2)) +end + +local function axle(object, getBar, getMode) + if type(object[getBar]) ~= "function" or type(object[getMode]) ~= "function" then + return nil + end + local value = bar(object[getBar](object)) + if value == nil then + return nil + end + return { bar = value, mode = object[getMode](object) == 0 and "ROAD" or "FIELD" } +end + +-- Object stage: runs per vehicle/implement during the walk (see registry.lua), so the tractor and +-- every machine hitched behind it decorate themselves alike. +---@param object table a vehicle or implement +---@param model table the object's already core-collected model +function VDT.VariableTirePressure.contributeObject(object, model) + local spec = VDT.VariableTirePressure.specOf(object) + if spec == nil then + return + end + local getBar = object.vtpGetDashboardPressureBar + local getTarget = object.vtpGetDashboardPressureTarget + local getRoad = object.vtpGetRoadPressure + local getField = object.vtpGetFieldPressure + if + type(getBar) ~= "function" + or type(getTarget) ~= "function" + or type(getRoad) ~= "function" + or type(getField) ~= "function" + then + return + end + local mode = VDT.VariableTirePressure.modeOf(object) + local now = bar(getBar(object)) + if mode == nil or now == nil then + return + end + + -- The mod's HUD swaps the two when a player sets road below field; so does its own interpolation. + local roadBar, fieldBar = bar(getRoad(object)), bar(getField(object)) + if roadBar ~= nil and fieldBar ~= nil and roadBar < fieldBar then + roadBar, fieldBar = fieldBar, roadBar + end + + ---@type TirePressureModel + local tirePressure = { + mode = mode, + bar = now, + targetBar = bar(getTarget(object)) or now, + roadBar = roadBar, + fieldBar = fieldBar, + } + -- The axles only while they run apart (different front and rear setpoints): otherwise the mod moves + -- both as one and the per-axle getters just mirror the unified value. + if spec.tractorAxleSplitActive == true then + tirePressure.front = axle(object, "vtpGetDashboardPressureBarFront", "vtpGetDashboardModeFront") + tirePressure.rear = axle(object, "vtpGetDashboardPressureBarRear", "vtpGetDashboardModeRear") + end + model.tirePressure = tirePressure +end diff --git a/vdTelemetry/src/integrations/registry.lua b/vdTelemetry/src/integrations/registry.lua index 81dac9e2..82d3a026 100644 --- a/vdTelemetry/src/integrations/registry.lua +++ b/vdTelemetry/src/integrations/registry.lua @@ -40,6 +40,8 @@ VDT.Integrations.all = { -- AutoDrive: its HUD's state on the machine, and the AI lamp while it drives. After the core AI -- collector by construction (every integration is), since it raises `ai.active` rather than setting it. "AutoDrive", + -- Variable Tire Pressure: the tyre pressure of the tractor and of every implement that has it. + "VariableTirePressure", } -- "." -> true once its failure has been logged, so a hook that throws on every From 6ed7eff5df34a938ed2d3b15e7fcebd63016b976 Mon Sep 17 00:00:00 2001 From: Benjamin Leber Date: Sat, 24 Oct 2026 13:04:23 +0200 Subject: [PATCH 2/6] =?UTF-8?q?#193=20=E2=9C=A8=20[app]=20the=20tractor's?= =?UTF-8?q?=20tyre=20pressure=20on=20the=20lighting=20schematic's=20hubs,?= =?UTF-8?q?=20a=20tap=20switching=20the=20whole=20rig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a cab tablet with no room for the tyre tile beside the lighting panel. Each hub shows its axle's pressure and mode in a word, with an arrow while the compressor moves it; under the button grid it becomes a one-line strip. A tap takes a rig all at road down to field and anything else up to road, as the mod's whole-combination key does; the tile keeps the per-scope pairs. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../vdt/app/i18n/TirePressureStrings.kt | 4 + .../vdt/app/i18n/de/DeTirePressure.kt | 2 + .../vdt/app/i18n/en/EnTirePressure.kt | 2 + .../vertexdezign/vdt/app/panels/Lighting.kt | 191 +++++++++++++++++- .../vdt/app/panels/TirePressurePanel.kt | 7 + .../vdt/app/panels/TireRigTest.kt | 13 ++ 6 files changed, 218 insertions(+), 1 deletion(-) diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/TirePressureStrings.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/TirePressureStrings.kt index 77a70acd..2ae04fe8 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/TirePressureStrings.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/TirePressureStrings.kt @@ -22,4 +22,8 @@ interface TirePressureStrings { /** Said for the arrow beside a pressure that is moving: where to, already formatted. */ fun inflatingTo(value: String): String fun deflatingTo(value: String): String + + /** What tapping the readout on the lighting schematic does: the whole rig to one pressure. */ + val switchRigToRoad: String + val switchRigToField: String } diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/de/DeTirePressure.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/de/DeTirePressure.kt index fea14870..45675dc4 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/de/DeTirePressure.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/de/DeTirePressure.kt @@ -16,4 +16,6 @@ internal object DeTirePressure : TirePressureStrings { override fun bar(value: String): String = "$value bar" override fun inflatingTo(value: String): String = "pumpt auf $value bar" override fun deflatingTo(value: String): String = "lässt ab auf $value bar" + override val switchRigToRoad = "Gespann auf Straßendruck" + override val switchRigToField = "Gespann auf Felddruck" } diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/en/EnTirePressure.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/en/EnTirePressure.kt index 659b2367..f2435e40 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/en/EnTirePressure.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/en/EnTirePressure.kt @@ -16,4 +16,6 @@ internal object EnTirePressure : TirePressureStrings { override fun bar(value: String): String = "$value bar" override fun inflatingTo(value: String): String = "inflating to $value bar" override fun deflatingTo(value: String): String = "deflating to $value bar" + override val switchRigToRoad = "switch the rig to road pressure" + override val switchRigToField = "switch the rig to field pressure" } diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/Lighting.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/Lighting.kt index 5d02b998..bc1ff6bf 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/Lighting.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/Lighting.kt @@ -1,6 +1,9 @@ package net.vertexdezign.vdt.app.panels import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -8,29 +11,50 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Lightbulb +import androidx.compose.material.icons.filled.TireRepair +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import net.vertexdezign.vdt.ClientMessage import net.vertexdezign.vdt.LightTarget +import net.vertexdezign.vdt.TirePressureScope import net.vertexdezign.vdt.TurnLightState import net.vertexdezign.vdt.app.components.Panel import net.vertexdezign.vdt.app.components.StatusColor import net.vertexdezign.vdt.app.components.StatusIconButton import net.vertexdezign.vdt.app.i18n.LocalStrings +import net.vertexdezign.vdt.app.i18n.TirePressureStrings import net.vertexdezign.vdt.app.resources.Res import net.vertexdezign.vdt.app.resources.mb_trac +import net.vertexdezign.vdt.app.theme.VdtColors +import net.vertexdezign.vdt.model.TireChange +import net.vertexdezign.vdt.model.TireMode +import net.vertexdezign.vdt.model.TirePressure import net.vertexdezign.vdt.model.Vehicle import org.jetbrains.compose.resources.painterResource @@ -44,6 +68,9 @@ private val MAX_BUTTON = 48.dp */ private val MIN_BUTTON = 32.dp +/** The tyre readout's strip under the grid, when the tile is too small for the schematic. */ +private val TYRE_STRIP = 28.dp + /** Aspect ratio (w/h) of the trimmed `mb_trac.png` (1429×1259, padding cropped off). */ private const val IMAGE_ASPECT = 1429f / 1259f @@ -74,6 +101,9 @@ private class Lamp( * The buttons are sized from the art, not fixed: at a fixed 48dp the three indicators overlapped once * the art was narrower than ~300dp, which is most tiles on a phone. When even [MIN_BUTTON] no longer * fits between the tightest pair, the picture goes and the eight switches are laid out as a grid. + * + * With Variable Tire Pressure on the tractor its pressure sits on the two hubs (issue #193) — tapped, it + * switches the whole rig between road and field; under the grid it becomes a strip below the switches. */ @Composable fun Lighting(vehicle: Vehicle, modifier: Modifier = Modifier, onCommand: (ClientMessage) -> Unit = {}) { @@ -126,6 +156,20 @@ fun Lighting(vehicle: Vehicle, modifier: Modifier = Modifier, onCommand: (Client }, ) + // Variable Tire Pressure (issue #193): the tractor's own pressure on its hubs, for a cab with no room + // for the tyre tile beside this one. A tap switches the whole rig, as the mod's whole-combination key + // does: there is no room here for the tile's three pairs, and the hubs only show the tractor's tyres + // in any case, the tanker behind being off the picture. + val tyres = vehicle.tirePressure + val rig = TireRig.of(vehicle) + val switchTyres = { + if (rig != + null + ) { + onCommand(ClientMessage.SetTirePressure(TirePressureScope.ALL, rig.switchesToRoad)) + } + } + Panel(title = s.lighting, icon = Icons.Filled.Lightbulb, modifier = modifier) { BoxWithConstraints(Modifier.fillMaxSize()) { // Largest box with the image's aspect that fits the panel (letterbox on the long axis). @@ -141,7 +185,16 @@ fun Lighting(vehicle: Vehicle, modifier: Modifier = Modifier, onCommand: (Client val button = minOf(boxW * TIGHTEST_PAIR - 4.dp, MAX_BUTTON) if (button < MIN_BUTTON) { - LampGrid(lamps, maxWidth, maxHeight) + if (tyres == null || rig == null) { + LampGrid(lamps, maxWidth, maxHeight) + } else { + val gridW = maxWidth + val gridH = maxHeight - TYRE_STRIP - 6.dp + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Box(Modifier.weight(1f).fillMaxWidth()) { LampGrid(lamps, gridW, gridH) } + TyreStrip(tyres, rig.switchesToRoad, switchTyres) + } + } } else { Box(Modifier.size(boxW, boxH).align(Alignment.Center)) { Image( @@ -157,6 +210,22 @@ fun Lighting(vehicle: Vehicle, modifier: Modifier = Modifier, onCommand: (Client val y = (boxH * lamp.fy - button / 2).coerceIn(0.dp, boxH - button) LampButton(lamp, button, Modifier.align(Alignment.TopStart).offset(x, y)) } + if (tyres != null && rig != null) { + // As wide as a pressure and its mode word need, as tall as a lamp button. + val w = button * 1.5f + for (hub in hubsOf(tyres)) { + val x = (boxW * hub.fx - w / 2).coerceIn(0.dp, boxW - w) + val y = (boxH * hub.fy - button / 2).coerceIn(0.dp, boxH - button) + TyreBadge( + hub, + tyres.change, + rig.switchesToRoad, + button, + switchTyres, + Modifier.align(Alignment.TopStart).offset(x, y).size(w, button), + ) + } + } } } } @@ -195,3 +264,123 @@ private fun LampButton(lamp: Lamp, diameter: Dp, modifier: Modifier = Modifier) onClick = lamp.onClick, ) } + +/** One wheel's readout on the schematic: where its hub is (fractions of the art's box), and what it reads. */ +private class Hub(val fx: Float, val fy: Float, val bar: Float, val mode: TireMode) + +/** + * The two hubs, front wheel then rear. Each shows its own axle while the tractor runs them at different + * pressures, and both the one pressure otherwise — every wheel on the picture has a pressure. + */ +private fun hubsOf(tyres: TirePressure): List = listOf( + Hub(0.282f, 0.681f, tyres.front?.bar ?: tyres.bar, tyres.front?.mode ?: tyres.mode), + Hub(0.796f, 0.658f, tyres.rear?.bar ?: tyres.bar, tyres.rear?.mode ?: tyres.mode), +) + +private fun modeWord(s: TirePressureStrings, mode: TireMode): String = when (mode) { + TireMode.ROAD -> s.road + TireMode.FIELD -> s.field + TireMode.MIXED -> s.mixed +} + +@Composable +private fun Modifier.tyreSwitch(toRoad: Boolean, description: String, onClick: () -> Unit): Modifier { + val s = LocalStrings.current.tirePressure + val shape = RoundedCornerShape(6.dp) + return clip(shape) + .background(VdtColors.Surface) + .border(1.dp, VdtColors.PanelBorder, shape) + .clickable( + role = Role.Button, + onClickLabel = if (toRoad) s.switchRigToRoad else s.switchRigToField, + onClick = onClick, + ) + // One announcement for the badge rather than its pieces; the click's own semantics stay. + .clearAndSetSemantics { contentDescription = description } +} + +@Composable +private fun ChangeArrow(change: TireChange, size: Dp) { + Icon( + if (change == TireChange.INFLATING) Icons.Filled.ArrowUpward else Icons.Filled.ArrowDownward, + contentDescription = null, + tint = VdtColors.DarkGray, + modifier = Modifier.size(size), + ) +} + +/** + * A hub's pressure and its mode in a word, sized with the lamp buttons ([scale]); an arrow beside the + * figure while the compressor is moving it. Tapping it switches the whole rig ([TireRig.switchesToRoad]). + */ +@Composable +private fun TyreBadge( + hub: Hub, + change: TireChange?, + toRoad: Boolean, + scale: Dp, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val strings = LocalStrings.current + val s = strings.tirePressure + val value = strings.numbers.fixed(hub.bar, 1) + val word = modeWord(s, hub.mode) + Column( + modifier.tyreSwitch(toRoad, "${s.title} ${s.bar(value)}, $word", onClick), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + change?.let { ChangeArrow(it, scale * 0.3f) } + Text( + value, + fontSize = (scale.value * 0.36f).sp, + fontWeight = FontWeight.Bold, + color = VdtColors.TextDark, + maxLines = 1, + ) + } + Text( + word.uppercase(), + fontSize = (scale.value * 0.2f).sp, + fontWeight = FontWeight.Bold, + color = VdtColors.DarkGray, + maxLines = 1, + ) + } +} + +/** The readout in one line under the grid, for a tile too small for the schematic: the same tap. */ +@Composable +private fun TyreStrip(tyres: TirePressure, toRoad: Boolean, onClick: () -> Unit) { + val strings = LocalStrings.current + val s = strings.tirePressure + val front = tyres.front + val rear = tyres.rear + val figures = + if (front != null && rear != null) { + "${strings.numbers.fixed(front.bar, 1)} / ${strings.numbers.fixed(rear.bar, 1)}" + } else { + strings.numbers.fixed(tyres.bar, 1) + } + val line = "${s.bar(figures)} · ${modeWord(s, tyres.mode)}" + Row( + Modifier.fillMaxWidth().height( + TYRE_STRIP, + ).tyreSwitch(toRoad, "${s.title} $line", onClick).padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(Icons.Filled.TireRepair, contentDescription = null, tint = VdtColors.DarkGray, modifier = Modifier.size(14.dp)) + tyres.change?.let { ChangeArrow(it, 14.dp) } + Text( + line, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = VdtColors.TextDark, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/TirePressurePanel.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/TirePressurePanel.kt index ef27f29d..46134263 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/TirePressurePanel.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/TirePressurePanel.kt @@ -49,6 +49,13 @@ data class TireRig(val vehicle: TireMachine?, val implements: List) val implementsMode: TireMode? get() = groupMode(implements) + /** + * Which way one tap on the whole rig goes, where there is no room for a pair: down to field from a rig + * all at road, and up to road from anything else — a rig whose parts disagree comes up together, + * since road is the pressure nothing is harmed by. + */ + val switchesToRoad: Boolean get() = mode != TireMode.ROAD + companion object { fun of(vehicle: Vehicle): TireRig? { val implements = mutableListOf() diff --git a/VDTerminal/app/src/commonTest/kotlin/net/vertexdezign/vdt/app/panels/TireRigTest.kt b/VDTerminal/app/src/commonTest/kotlin/net/vertexdezign/vdt/app/panels/TireRigTest.kt index d32f5ea1..e3fd985e 100644 --- a/VDTerminal/app/src/commonTest/kotlin/net/vertexdezign/vdt/app/panels/TireRigTest.kt +++ b/VDTerminal/app/src/commonTest/kotlin/net/vertexdezign/vdt/app/panels/TireRigTest.kt @@ -6,7 +6,9 @@ import net.vertexdezign.vdt.model.TirePressure import net.vertexdezign.vdt.model.Vehicle import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class TireRigTest { private fun tyres(mode: TireMode) = TirePressure(mode = mode, bar = 1f, targetBar = 1f) @@ -65,4 +67,15 @@ class TireRigTest { assertNull(rig.vehicle) assertEquals(TireMode.ROAD, rig.mode) } + + @Test + fun oneTapTakesARigAtRoadDownAndAnythingElseUp() { + fun rig(tractor: TireMode, tanker: TireMode) = + TireRig.of(Vehicle(tirePressure = tyres(tractor), implement = listOf(Implement(tirePressure = tyres(tanker)))))!! + assertFalse(rig(TireMode.ROAD, TireMode.ROAD).switchesToRoad) + assertTrue(rig(TireMode.FIELD, TireMode.FIELD).switchesToRoad) + // Parts that disagree come up together rather than being guessed at. + assertTrue(rig(TireMode.ROAD, TireMode.FIELD).switchesToRoad) + assertTrue(rig(TireMode.MIXED, TireMode.ROAD).switchesToRoad) + } } From a3d325984a80533d4c205a79efa62448c803e7ba Mon Sep 17 00:00:00 2001 From: Benjamin Leber Date: Sat, 24 Oct 2026 13:15:08 +0200 Subject: [PATCH 3/6] =?UTF-8?q?#193=20=F0=9F=93=96=20[doc]=20the=20site=20?= =?UTF-8?q?lists=20Variable=20Tire=20Pressure=20among=20the=20supported=20?= =?UTF-8?q?mods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- site/de/index.html | 1 + site/index.html | 1 + 2 files changed, 2 insertions(+) diff --git a/site/de/index.html b/site/de/index.html index 093af620..d60beb2b 100644 --- a/site/de/index.html +++ b/site/de/index.html @@ -116,6 +116,7 @@

In der Kabine

  • CombineXPdie echte Leistung des Dreschers
  • Enhanced VehicleDifferentialsperren, Allrad und Feststellbremse
  • More Vehicle ControlsDifferentialsperren, Allrad und Handbremse
  • +
  • Variable Tire PressureReifendruck, Straße oder Feld
  • Vehicle Speed Syncdie Sync-Leuchte und der Schalter
  • diff --git a/site/index.html b/site/index.html index d9a95df4..28b5f58a 100644 --- a/site/index.html +++ b/site/index.html @@ -116,6 +116,7 @@

    In the cab

  • CombineXPthe combine's real capacity
  • Enhanced Vehiclediff locks, AWD and parking brake
  • More Vehicle Controlsdiff locks, AWD and handbrake
  • +
  • Variable Tire Pressuretyre pressure, road or field
  • Vehicle Speed Syncthe sync lamp and toggle
  • From f1ef7a1ca859f6e47f659cc2bf1333951dff285a Mon Sep 17 00:00:00 2001 From: Benjamin Leber Date: Sat, 24 Oct 2026 13:19:56 +0200 Subject: [PATCH 4/6] =?UTF-8?q?#193=20=F0=9F=9A=91=20[mod]=20a=20multiplay?= =?UTF-8?q?er=20client=20sets=20a=20straggling=20implement's=20tyres=20too?= =?UTF-8?q?,=20through=20the=20mod's=20own=20event?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the first implement was already in the asked-for mode but a later one was not, a client only logged and left the rig mixed. It now presses the mod's own-tyres key with the straggler as the machine; the handler sends that to the server, which toggles that implement alone. The host keeps setting it directly, and an implement that cannot be set at all is now a warning. Co-Authored-By: Claude Opus 5.5 (1M context) --- vdTelemetry/spec/VariableTirePressure_spec.lua | 9 +++++---- vdTelemetry/src/command/TirePressureControl.lua | 10 ++++++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/vdTelemetry/spec/VariableTirePressure_spec.lua b/vdTelemetry/spec/VariableTirePressure_spec.lua index 903fdcc9..7d7ba9d2 100644 --- a/vdTelemetry/spec/VariableTirePressure_spec.lua +++ b/vdTelemetry/spec/VariableTirePressure_spec.lua @@ -92,9 +92,9 @@ local function withClass(vehicle, folder) wheels = {}, [(folder or "FS25_VariableTirePressure") .. ".variableTirePressure"] = { actionEventTogglePressure = function(self, action, inputValue, callbackState) - assert.are.equal(vehicle, self) assert.are.equal(InputAction.CALLBACK_STATE_START, callbackState) - presses[#presses + 1] = action + -- A press on the vehicle is recorded by its action alone; on another machine, with that machine. + presses[#presses + 1] = self == vehicle and action or { on = self, action = action } end, }, } @@ -188,14 +188,15 @@ describe("TirePressureControl.setTirePressure", function() assert.are.same({ true }, behind.set) end) - it("leaves straggling implements alone on a multiplayer client", function() + it("presses the own-tyres key on each straggling implement from a multiplayer client", function() local behind = machine({ mode = "FIELD" }) local trailer = machine({ mode = "ROAD", implements = { behind } }) local tractor = machine({ implements = { trailer }, isServer = false }) local presses = withClass(tractor) VDT.TirePressureControl.setTirePressure(tractor, "implements", true, debugger) - assert.are.same({}, presses) + -- Through the mod's handler, which sends the press to the server; never the server-only setter. + assert.are.same({ { on = behind, action = "VTP_SET_TIRE_PRESSURE_TRACTOR" } }, presses) assert.are.same({}, behind.set) end) diff --git a/vdTelemetry/src/command/TirePressureControl.lua b/vdTelemetry/src/command/TirePressureControl.lua index 59f56803..3ebff484 100644 --- a/vdTelemetry/src/command/TirePressureControl.lua +++ b/vdTelemetry/src/command/TirePressureControl.lua @@ -21,7 +21,10 @@ -- the other one's mode first. A tap that asked for the other mode is then one tap short. -- * implements that disagree among themselves, the first already right: its key would turn them all -- the wrong way. On the server (single player, the host) the stragglers are set directly, the call --- the mod's handler makes for each implement; a multiplayer client has no such call, and logs. +-- the mod's handler makes for each implement. A multiplayer client presses the tractor key on each +-- straggler instead, with the straggler as the machine: the handler sends that press to the server +-- as its own event, and there the "tractor" is the implement, so it alone is toggled -- towards the +-- mode asked for, since only one in the other mode is pressed. -- -- The class is looked up off the vehicle's own type, under whatever folder name the mod was installed -- as, so nothing is read from the mod's environment. @@ -122,11 +125,14 @@ function VDT.TirePressureControl.setTirePressure(vehicle, scope, road, debugger) for i = 2, #implements do local implement = implements[i] if (VDT.VariableTirePressure.specOf(implement).isRoadMode == true) ~= road then + local own = specClass(implement) or class if vehicle.isServer == true and type(implement.vtpSetRoadMode) == "function" then implement:vtpSetRoadMode(road, false) debugger:debug("setTirePressure: set a straggling implement to %s", wanted) + elseif vehicle.isServer ~= true then + press(own, implement, VDT.TirePressureControl.ACTION_TRACTOR, debugger) else - debugger:debug("setTirePressure: an implement disagrees and only the server can set it alone") + debugger:warn("setTirePressure: could not set a straggling implement to %s, the rig stays mixed", wanted) end end end From 6724d783b6befa9743d03d953ffd7796785b70dd Mon Sep 17 00:00:00 2001 From: Benjamin Leber Date: Sat, 24 Oct 2026 18:57:10 +0200 Subject: [PATCH 5/6] =?UTF-8?q?#193=20=F0=9F=93=96=20[doc][mod][term]=20Va?= =?UTF-8?q?riable=20Tire=20Pressure=20is=20confirmed=20in=20multiplayer,?= =?UTF-8?q?=20and=20its=20mixed-axle=20mode=20is=20unreachable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both singleplayer and a multiplayer client are confirmed in game, so the FUTURE.md entry goes. The one open point, a capture with the axles in different modes, cannot be had: the mod binds no front- or rear-only key, and every bound key moves both axles together. That is a limitation rather than future work, so it now lives as a comment on the MIXED mapping and on TireMode.MIXED, which stay so a later version that binds those keys reads right. Co-Authored-By: Claude Opus 5.5 (1M context) --- FUTURE.md | 5 ----- .../kotlin/net/vertexdezign/vdt/model/Vehicle.kt | 7 ++++++- .../jvmTest/kotlin/net/vertexdezign/vdt/VdtModelTest.kt | 5 +++-- vdTelemetry/src/integrations/VariableTirePressure.lua | 5 ++++- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/FUTURE.md b/FUTURE.md index d7d9ebbd..2038699c 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -688,11 +688,6 @@ them. (The schema and selection aspects were in this list until #116 and #119 ca - **AutoDrive (#97), two states no capture has yet.** Every command is checked in singleplayer and from a multiplayer client, and `examples/json/autodrive/` pins the reads. Not yet seen in a file: a round counter that is not endless (`loops` > 0 with `loopsDone` counting) and a moment where `heading` differs from `target`. -- **Variable Tire Pressure (#193), from a multiplayer client.** Singleplayer is confirmed in game — the reads and all - three switches — and `examples/json/telemetry/modded/variableTirePressure/` pins a tanker mid-deflation and a tractor - with its axles split. Still wanted: the switches tried from a client, where the press has to travel through the - mod's own event, and ideally a capture with the axles in *different modes* (`MIXED`, covered by inline JSON in - `VdtModelTest.decodesTheTirePressureBlock` meanwhile) if the mod offers a way to get there. - **A tipping trailer.** It covers `tipping` and `discharge`, and would give `jointDescIndex` its first real chain. (The balers are captured, all singleplayer: `examples/json/telemetry/vanilla/baler/`.) - **A baler from a multiplayer client.** Every baler command was driven from a client and works, but no committed file diff --git a/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/model/Vehicle.kt b/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/model/Vehicle.kt index 0dd5c1e1..7c8c7fc7 100644 --- a/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/model/Vehicle.kt +++ b/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/model/Vehicle.kt @@ -481,7 +481,12 @@ enum class TireMode { ROAD, FIELD, - /** A tractor's axles apart, one at each pressure. Never on an axle itself. */ + /** + * A tractor's axles in different modes, one at road and one at field. Never on an axle itself. Not + * reachable in Variable Tire Pressure 1.0.0.13, whose bound keys move both axles together (see the mod's + * `src/integrations/VariableTirePressure.lua`); handled so a later version that binds its axle keys + * reads right. + */ MIXED, } diff --git a/VDTerminal/shared/src/jvmTest/kotlin/net/vertexdezign/vdt/VdtModelTest.kt b/VDTerminal/shared/src/jvmTest/kotlin/net/vertexdezign/vdt/VdtModelTest.kt index f37500f1..2c954019 100644 --- a/VDTerminal/shared/src/jvmTest/kotlin/net/vertexdezign/vdt/VdtModelTest.kt +++ b/VDTerminal/shared/src/jvmTest/kotlin/net/vertexdezign/vdt/VdtModelTest.kt @@ -675,8 +675,9 @@ class VdtModelTest { @Test fun decodesTheTirePressureBlock() { - // Variable Tire Pressure (mod version 36), the shape no capture has yet: a tractor whose axles sit in - // different modes (MIXED), deflating the rear, with a trailer hitched that has the system too. + // Variable Tire Pressure (mod version 36), the one shape no capture can have: a tractor whose axles sit + // in different modes (MIXED, unreachable in 1.0.0.13, see TireMode), deflating the rear, with a trailer + // hitched that has the system too. val data = VdtParser.parseJson( """ diff --git a/vdTelemetry/src/integrations/VariableTirePressure.lua b/vdTelemetry/src/integrations/VariableTirePressure.lua index 72521de2..eea0c2f7 100644 --- a/vdTelemetry/src/integrations/VariableTirePressure.lua +++ b/vdTelemetry/src/integrations/VariableTirePressure.lua @@ -45,7 +45,10 @@ VDT = VDT or {} VDT.VariableTirePressure = {} --- vtpGetDashboardMode's codes: 2 is front at road and rear at field, 3 the reverse. +-- vtpGetDashboardMode's codes: 2 is front at road and rear at field, 3 the reverse. Kept although no +-- player can get there in 1.0.0.13: the mod's handler has front-only and rear-only keys, but its modDesc +-- binds neither, and every bound key moves both axles together. Mapped all the same, so a later version +-- that binds them reads as mixed rather than as road. local MODES = { [0] = "ROAD", [1] = "FIELD", [2] = "MIXED", [3] = "MIXED" } ---The machine's spec when the mod works on it: installed, and the wheel configuration bought with it. From 8d8f13c42cb1b58b744fac50715ec339d9e942c3 Mon Sep 17 00:00:00 2001 From: Benjamin Leber Date: Sun, 25 Oct 2026 08:03:08 +0100 Subject: [PATCH 6/6] =?UTF-8?q?#193=20=F0=9F=9A=91=20[app]=20the=20lightin?= =?UTF-8?q?g=20panel's=20tyre=20strip=20reads=20each=20axle=20on=20its=20o?= =?UTF-8?q?wn,=20as=20the=20hubs=20do?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mod sets the front and rear readings independently, so a file can carry one without the other. The strip under the button grid then fell back to the unified pressure for both; it now shows each axle it has and the unified pressure only for the missing one. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../kotlin/net/vertexdezign/vdt/app/panels/Lighting.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/Lighting.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/Lighting.kt index bc1ff6bf..baddd899 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/Lighting.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/Lighting.kt @@ -358,9 +358,10 @@ private fun TyreStrip(tyres: TirePressure, toRoad: Boolean, onClick: () -> Unit) val s = strings.tirePressure val front = tyres.front val rear = tyres.rear + // Per axle, as the hubs are: one axle missing from the file falls back to the unified pressure alone. val figures = - if (front != null && rear != null) { - "${strings.numbers.fixed(front.bar, 1)} / ${strings.numbers.fixed(rear.bar, 1)}" + if (front != null || rear != null) { + "${strings.numbers.fixed(front?.bar ?: tyres.bar, 1)} / ${strings.numbers.fixed(rear?.bar ?: tyres.bar, 1)}" } else { strings.numbers.fixed(tyres.bar, 1) }