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..2ae04fe8 --- /dev/null +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/TirePressureStrings.kt @@ -0,0 +1,29 @@ +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 + + /** 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 new file mode 100644 index 00000000..45675dc4 --- /dev/null +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/de/DeTirePressure.kt @@ -0,0 +1,21 @@ +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" + 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/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..f2435e40 --- /dev/null +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/en/EnTirePressure.kt @@ -0,0 +1,21 @@ +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" + 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/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/Lighting.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/Lighting.kt index 5d02b998..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 @@ -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,124 @@ 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 + // 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 ?: tyres.bar, 1)} / ${strings.numbers.fixed(rear?.bar ?: tyres.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 new file mode 100644 index 00000000..46134263 --- /dev/null +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/TirePressurePanel.kt @@ -0,0 +1,242 @@ +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) + + /** + * 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() + 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..e3fd985e --- /dev/null +++ b/VDTerminal/app/src/commonTest/kotlin/net/vertexdezign/vdt/app/panels/TireRigTest.kt @@ -0,0 +1,81 @@ +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.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +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) + } + + @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) + } +} 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..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 @@ -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,79 @@ 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 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, +} + +enum class TireChange { + INFLATING, + DEFLATING, +} + // --------------------------------------------------------------------------- // Wear // --------------------------------------------------------------------------- @@ -1385,6 +1460,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..2c954019 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,75 @@ 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 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( + """ + {"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/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
  • 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..7d7ba9d2 --- /dev/null +++ b/vdTelemetry/spec/VariableTirePressure_spec.lua @@ -0,0 +1,247 @@ +-- 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(InputAction.CALLBACK_STATE_START, callbackState) + -- 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, + }, + } + 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("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) + -- 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) + + 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..3ebff484 --- /dev/null +++ b/vdTelemetry/src/command/TirePressureControl.lua @@ -0,0 +1,150 @@ +-- 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 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. +-- +-- 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 + 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:warn("setTirePressure: could not set a straggling implement to %s, the rig stays mixed", wanted) + 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..eea0c2f7 --- /dev/null +++ b/vdTelemetry/src/integrations/VariableTirePressure.lua @@ -0,0 +1,143 @@ +-- 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. 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. +---@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