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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ release version + date and opens a fresh `[Unreleased]`.

## [Unreleased]

## [0.1.40-beta3] - 2026-05-30

### Fixed
- The temperature card mistook the CPU fan speed and voltage sensors for temperatures (showed ~91° / a 1753° "CPU Fan"); it now reads only real temperature sensors.

## [0.1.40-beta2] - 2026-05-30

### Added
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ android {
applicationId = "io.github.nofuturekid.nova"
minSdk = 26
targetSdk = 36
versionCode = 108
versionName = "0.1.40-beta2"
versionCode = 109
versionName = "0.1.40-beta3"

vectorDrawables { useSupportLibrary = true }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,12 @@ query GetMetrics {
cpu { percentTotal }
memory { total used buffcache }
temperature {
summary {
average
hottest {
name
current { value unit }
}
warningCount
criticalCount
sensors {
name
type
current { value unit status }
warning
critical
}
}
}
Expand Down
28 changes: 28 additions & 0 deletions app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,10 @@ type NetworkInterfaceUtilization {

type TemperatureMetrics {
summary: TemperatureSummary!
# The server reports EVERY lm-sensors channel here, including voltage rails
# and fan tachs mislabeled CELSIUS (type CUSTOM). The client selects this
# raw list and recomputes the summary itself (beta3) — see ADR-0043.
sensors: [TemperatureSensor!]!
}

type TemperatureSummary {
Expand All @@ -480,12 +484,16 @@ type TemperatureSummary {

type TemperatureSensor {
name: String!
type: SensorType!
current: TemperatureReading!
warning: Float
critical: Float
}

type TemperatureReading {
value: Float!
unit: TemperatureUnit!
status: TemperatureStatus!
}

enum TemperatureUnit {
Expand All @@ -495,6 +503,26 @@ enum TemperatureUnit {
RANKINE
}

enum SensorType {
CPU_PACKAGE
CPU_CORE
MOTHERBOARD
CHIPSET
GPU
DISK
NVME
AMBIENT
VRM
CUSTOM
}

enum TemperatureStatus {
NORMAL
WARNING
CRITICAL
UNKNOWN
}

type DockerContainerStats {
id: PrefixedID!
cpuPercent: Float!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,16 @@ subscription SystemMetricsMemory {

subscription SystemMetricsTemperature {
systemMetricsTemperature {
summary {
average
hottest {
name
current {
value
unit
}
sensors {
name
type
current {
value
unit
status
}
warningCount
criticalCount
warning
critical
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ import io.github.nofuturekid.nova.data.model.ParityCheck
import io.github.nofuturekid.nova.data.model.Plugin
import io.github.nofuturekid.nova.data.model.PluginInstallOperation
import io.github.nofuturekid.nova.data.model.PluginInstallStatus
import io.github.nofuturekid.nova.data.model.SensorType
import io.github.nofuturekid.nova.data.model.ServerInfo
import io.github.nofuturekid.nova.data.model.Temperature
import io.github.nofuturekid.nova.data.model.TempSummarySample
import io.github.nofuturekid.nova.data.model.TemperatureStatus
import io.github.nofuturekid.nova.data.model.TemperatureUnit
import io.github.nofuturekid.nova.data.model.TempSensorSample
import io.github.nofuturekid.nova.data.model.UnraidNotification
import io.github.nofuturekid.nova.data.model.Vm
import io.github.nofuturekid.nova.data.model.VmState
import io.github.nofuturekid.nova.data.model.toTemperature
import io.github.nofuturekid.nova.data.model.selectTemperature
import io.github.nofuturekid.nova.graphql.GetArrayQuery
import io.github.nofuturekid.nova.graphql.GetDockerContainersQuery
import io.github.nofuturekid.nova.graphql.GetMetricsQuery
Expand All @@ -50,6 +52,8 @@ import io.github.nofuturekid.nova.graphql.type.ContainerState as GContainerState
import io.github.nofuturekid.nova.graphql.type.NotificationImportance as GNotifImportance
import io.github.nofuturekid.nova.graphql.type.NotificationType as GNotifType
import io.github.nofuturekid.nova.graphql.type.PluginInstallStatus as GPluginInstallStatus
import io.github.nofuturekid.nova.graphql.type.SensorType as GSensorType
import io.github.nofuturekid.nova.graphql.type.TemperatureStatus as GTemperatureStatus
import io.github.nofuturekid.nova.graphql.type.TemperatureUnit as GTemperatureUnit
import io.github.nofuturekid.nova.graphql.type.VmState as GVmState

Expand Down Expand Up @@ -332,32 +336,59 @@ fun SystemMetricsMemorySubscription.Data.toMemoryTriple(): Triple<Long, Long, Lo
Triple(systemMetricsMemory.total, systemMetricsMemory.used, systemMetricsMemory.buffcache)

// ── Temperature subscription + poll (shared mapper) ──────────────────
//
// The server mislabels voltage rails and fan tachs as CELSIUS sensors, so the
// server-computed summary is garbage. Both paths select the raw `sensors`
// list, project it to the Apollo-free [TempSensorSample] seam, and let
// [selectTemperature] filter + recompute (beta3, ADR-0043). A null root or an
// empty list degrades to [Temperature.UNKNOWN].

fun SystemMetricsTemperatureSubscription.Data.toTemperature(): Temperature =
toTemperature(systemMetricsTemperature?.summary?.toSample())
selectTemperature(
systemMetricsTemperature?.sensors.orEmpty().map { s ->
TempSensorSample(
name = s.name,
type = s.type.toDomain(),
value = s.current.value,
unit = s.current.unit.toDomain(),
status = s.current.status.toDomain(),
)
},
)

fun GetMetricsQuery.Data.toTemperature(): Temperature =
toTemperature(metrics?.temperature?.summary?.toSample())

private fun SystemMetricsTemperatureSubscription.Summary.toSample(): TempSummarySample =
TempSummarySample(
average = average,
unit = hottest?.current?.unit?.toDomain() ?: TemperatureUnit.Unknown,
hottestName = hottest?.name,
hottestValue = hottest?.current?.value,
warningCount = warningCount,
criticalCount = criticalCount,
selectTemperature(
metrics?.temperature?.sensors.orEmpty().map { s ->
TempSensorSample(
name = s.name,
type = s.type.toDomain(),
value = s.current.value,
unit = s.current.unit.toDomain(),
status = s.current.status.toDomain(),
)
},
)

private fun GetMetricsQuery.Summary.toSample(): TempSummarySample =
TempSummarySample(
average = average,
unit = hottest?.current?.unit?.toDomain() ?: TemperatureUnit.Unknown,
hottestName = hottest?.name,
hottestValue = hottest?.current?.value,
warningCount = warningCount,
criticalCount = criticalCount,
)
private fun GSensorType.toDomain(): SensorType = when (this) {
GSensorType.CPU_PACKAGE -> SensorType.CpuPackage
GSensorType.CPU_CORE -> SensorType.CpuCore
GSensorType.MOTHERBOARD -> SensorType.Motherboard
GSensorType.CHIPSET -> SensorType.Chipset
GSensorType.GPU -> SensorType.Gpu
GSensorType.DISK -> SensorType.Disk
GSensorType.NVME -> SensorType.Nvme
GSensorType.AMBIENT -> SensorType.Ambient
GSensorType.VRM -> SensorType.Vrm
GSensorType.CUSTOM -> SensorType.Custom
else -> SensorType.Unknown // UNKNOWN__
}

private fun GTemperatureStatus.toDomain(): TemperatureStatus = when (this) {
GTemperatureStatus.NORMAL -> TemperatureStatus.Normal
GTemperatureStatus.WARNING -> TemperatureStatus.Warning
GTemperatureStatus.CRITICAL -> TemperatureStatus.Critical
else -> TemperatureStatus.Unknown // UNKNOWN + UNKNOWN__
}

private fun GTemperatureUnit.toDomain(): TemperatureUnit = when (this) {
GTemperatureUnit.CELSIUS -> TemperatureUnit.Celsius
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ package io.github.nofuturekid.nova.data.model
* [Unknown] is the sentinel value used when no reading is available. */
enum class TemperatureUnit { Celsius, Fahrenheit, Kelvin, Rankine, Unknown }

/** What kind of sensor a channel is, per the server's classification.
* [Custom] is the catch-all the Super-I/O chip dumps temps, voltages AND
* fan tachs into — the reason [selectTemperature] needs a plausibility band
* (see its KDoc). [Unknown] tolerates a future/unmapped server enum. */
enum class SensorType {
CpuPackage, CpuCore, Motherboard, Chipset, Gpu, Disk, Nvme, Ambient, Vrm, Custom, Unknown
}

/** Per-sensor health the server attaches to a reading. Drives the card's
* warning/critical accent. [Unknown] tolerates a future/unmapped value. */
enum class TemperatureStatus { Normal, Warning, Critical, Unknown }

/**
* Live temperature summary for the Overview card.
*
Expand All @@ -12,8 +24,8 @@ enum class TemperatureUnit { Celsius, Fahrenheit, Kelvin, Rankine, Unknown }
* representable distinctly from a genuine 0-degree reading. When [available]
* is false the card shows 'unavailable'; otherwise it shows [average] [unit].
* [unit] is carried from the server (never hardcoded Celsius); it MAY be
* [TemperatureUnit.Unknown] on an available reading whose hottest sensor is
* absent — see [toTemperature]/the mapper tests for that intentional case.
* [TemperatureUnit.Unknown] when the kept set is somehow unit-less — see
* [selectTemperature]/the mapper tests for that intentional case.
*/
data class Temperature(
val available: Boolean,
Expand All @@ -25,7 +37,7 @@ data class Temperature(
val criticalCount: Int,
) {
companion object {
/** No-data sentinel for a null temperature root / null summary. */
/** No-data sentinel: null root, or no plausible temperature sensor. */
val UNKNOWN = Temperature(
available = false,
average = 0.0,
Expand All @@ -38,39 +50,64 @@ data class Temperature(
}
}

/** Plain, Apollo-free projection of a TemperatureSummary — the pure seam the
* Apollo mappers feed so [toTemperature] unit-tests without generated types
* (mirrors [IfaceSample] / [selectThroughput]). [unit] is already the domain
* enum so unit-carrying is exercised here, not buried in generated code. */
data class TempSummarySample(
val average: Double?,
/** Plain, Apollo-free projection of one temperature channel — the pure seam
* the Apollo mappers feed so [selectTemperature] unit-tests without generated
* types (mirrors [IfaceSample] / [selectThroughput]). [type], [unit] and
* [status] are already the domain enums so the enum-carrying is exercised
* here, not buried in generated code. */
data class TempSensorSample(
val name: String,
val type: SensorType,
val value: Double,
val unit: TemperatureUnit,
val hottestName: String?,
val hottestValue: Double?,
val warningCount: Int?,
val criticalCount: Int?,
val status: TemperatureStatus,
)

/** Below this a "temperature" reading is really a voltage rail (in0…in5,
* Vbat, +3.3V read ~1–3.5 V). Typed sensors (CPU_CORE/NVME/…) are trusted,
* but a [SensorType.Custom] channel under this floor is junk. */
const val MIN_PLAUSIBLE_C = 8.0

/** Above this a "temperature" reading is really a fan tach (RPM, e.g. the
* it8613 CPU Fan reading 1753). Applies to ALL types — even a typed sensor
* past this sanity cap is not a real ambient/core temperature. */
const val MAX_PLAUSIBLE_C = 130.0

/**
* The single Temperature mapping function, reused by BOTH the subscription
* frame and the GetMetrics poll (zero mapper drift). A null [sample] or a null
* [average] degrades to [Temperature.UNKNOWN] — the root is nullable on the
* live server and a missing average must read as 'no data', never a fake 0.
* A present [average] yields an AVAILABLE reading even when the hottest sensor
* (and thus [unit]) is absent: the headline number, not the sensor, defines
* availability (see usable_average_without_unit_is_available_with_unknown_unit).
* Recompute the Overview temperature summary from the raw sensor list,
* discarding the server-computed summary entirely.
*
* WHY this exists: the Unraid server reports EVERY lm-sensors channel under
* `systemMetricsTemperature`, all mislabeled `CELSIUS` — including a fan tach
* (value = RPM, e.g. 1753) and voltage rails (value = Volts, ~1–3.5), all
* with `type: CUSTOM`. Its server-side summary therefore names the fan as the
* "hottest" sensor and inflates the average. We must never show a fan as the
* hottest temperature.
*
* Filter rule — keep a sample as a real temperature iff:
* `value <= MAX_PLAUSIBLE_C && (type != Custom || value >= MIN_PLAUSIBLE_C)`.
* The upper cap drops fan RPM regardless of type; the lower floor drops
* voltage rails, but only for the untyped [SensorType.Custom] catch-all —
* a typed sensor genuinely reading low (e.g. an 8 °C ambient) is kept.
*
* From the kept set: hottest = max value (carrying its name + value + unit);
* average = mean of kept values; warning/criticalCount = kept samples whose
* [status] is Warning / Critical; unit = the hottest kept sample's unit.
* Empty kept set → [Temperature.UNKNOWN] (the card shows 'unavailable').
*/
fun toTemperature(sample: TempSummarySample?): Temperature {
val avg = sample?.average ?: return Temperature.UNKNOWN
return sample!!.let {
Temperature(
available = true,
average = avg,
unit = it.unit,
hottestName = it.hottestName.orEmpty(),
hottestValue = it.hottestValue ?: 0.0,
warningCount = it.warningCount ?: 0,
criticalCount = it.criticalCount ?: 0,
)
fun selectTemperature(samples: List<TempSensorSample>): Temperature {
val kept = samples.filter { s ->
s.value <= MAX_PLAUSIBLE_C &&
(s.type != SensorType.Custom || s.value >= MIN_PLAUSIBLE_C)
}
val hottest = kept.maxByOrNull { it.value } ?: return Temperature.UNKNOWN
return Temperature(
available = true,
average = kept.sumOf { it.value } / kept.size,
unit = hottest.unit,
hottestName = hottest.name,
hottestValue = hottest.value,
warningCount = kept.count { it.status == TemperatureStatus.Warning },
criticalCount = kept.count { it.status == TemperatureStatus.Critical },
)
}
Loading