Skip to content

Refactor of the thermal task - #2609

Open
jamesmunns wants to merge 34 commits into
masterfrom
james/thermal-noodling
Open

Refactor of the thermal task#2609
jamesmunns wants to merge 34 commits into
masterfrom
james/thermal-noodling

Conversation

@jamesmunns

@jamesmunns jamesmunns commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

This is a WIP refactor in service of simplifying the current state tracking performed by the thermal task.

Some of this is still Not Great, particularly the places where I pass 2-4 closures into a single function.

However, by pushing most state tracking down into the BSP, I hope to make it easier to Trait-ify the BSP interface and make it easier to build a "host native" thermal loop for testing purposes.

(most of the builds are failing because I've only refactored cosmo and none of the other bsps yet)

@labbott labbott left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I forgot to hit submit yesterday, I'll take another pass today

Comment on lines +110 to +111
// When we first start up, try to initialize the fan controller a few
// times, in case there's a transient I2C error.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this still the case? I see this was originally added for grapefruit as part of 725bdfdd8b01de8e095199ee3f0ee218ffe71414

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure! I haven't actually tested this on hardware yet, and tried as best as possible to preserve the existing behavior, whatever it was.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also worth noting, thermal is only active for grapefruit-ruby, and I'm not sure where to even obtain one of those for testing.

Comment on lines +177 to +221
// This is mimicing the old state value logic for deciding if
// we persist the data in `run_control`, that ONLY cleared the
// persisted value if:
//
// - The sensor is not present AND removable
// - The sensor is error prone
//
// Replicate that logic here, doing some type shenanigans
// because we aren't round-tripping through the Sensor API
// anymore.
let se = SensorError::from(NoData::from(e));
match (self.metadata.ty, se) {
(ChannelType::Removable, SensorError::NotPresent) => {
self.last_reading = TemperatureReading::Inactive;
}
(ChannelType::RemovableAndErrorProne, _) => {
self.last_reading = TemperatureReading::Inactive;
}
_ => {
// In all other cases, just leave whatever the last
// present value was so that the state estimation
// can continue estimating state.
}
}

// This logic comes from what was done in `read_sensors`,
// which is only deciding whether it's worth logging about.
// In either case, it will push NoData to the sensor api.
//
// This is *not* the same logic that is used above to decide
// whether we clear the previous state or not, despite being
// *very* similar!
let removable = matches!(
self.metadata.ty,
ChannelType::Removable
| ChannelType::RemovableAndErrorProne
);
let removed =
e == SensorReadError::I2cError(ResponseCode::NoDevice);
let unexpected_failure = !(removable && removed);
if unexpected_failure {
InputReadingOutcome::UnacceptableMissing { id, err: e }
} else {
InputReadingOutcome::AcceptableMissing { id, err: e }
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comments are very good but I still find this tricky to follow. I don't have any great ideas here (might take a pass later)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could be simplified IMO, this is a little awkward in service of preserving the old behavior.

@jamesmunns

Copy link
Copy Markdown
Contributor Author

@labbott if you're making another pass, 5c803a7 added some trait docs as well as some overall concepts, which might be useful to read first!

@jamesmunns jamesmunns changed the title WIP: Refactor of the thermal task Refactor of the thermal task Jul 28, 2026
@jamesmunns
jamesmunns marked this pull request as ready for review July 28, 2026 13:26
@jamesmunns

Copy link
Copy Markdown
Contributor Author

Taking this out of draft/WIP state, and marking it as ready for review. Builds are now passing for all targets, and I think this change is Good Actually, even if a bit large.

It definitely need on-target testing still, but I'll need to coordinate that. It would be good to make sure there aren't any major review items to address first.

@jamesmunns

Copy link
Copy Markdown
Contributor Author

Smoke testing on Hubris seems to work, the system is unloaded so there's not a lot of heat to react to, but the current software handles the transitions from A2 -> A0 and A0 -> A2 as I expect, and ControlPwm is going up:

humility: ring buffer task_thermal::__RINGBUF in thermal:
   TOTAL VARIANT
     632 ControlPwm
       4 AutoState(Boot)
       3 AutoState(Running)
       6 FanAdded
       3 PowerModeChanged
       1 Start
       1 ThermalMode(Auto)
       1 SensorReadFailed
       1 FanControllerInitialized
       1 SetFanWatchdogOk
 NDX LINE      GEN    COUNT PAYLOAD
   0  393        1        1 Start
   1   72        1        1 FanControllerInitialized
   2  198        1        1 ThermalMode(Auto)
   3  676        1        1 AutoState(Boot)
   4  692        1        1 SetFanWatchdogOk
   5  704        1        1 FanAdded(0x0)
   6  704        1        1 FanAdded(0x1)
   7  704        1        1 FanAdded(0x2)
   8  704        1        1 FanAdded(0x3)
   9  704        1        1 FanAdded(0x4)
  10  704        1        1 FanAdded(0x5)
  11  808        1        1 PowerModeChanged(PowerBitmask(0b1))
  12  676        1        1 AutoState(Boot)
  13 1000        1        1 AutoState(Running)
  14  967        1      322 ControlPwm(0x0)
  15  775        1        1 SensorReadFailed(SensorId(0x37), I2cError(NoDevice))
  16  808        1        1 PowerModeChanged(PowerBitmask(0b10))
  17  676        1        1 AutoState(Boot)
  18  967        1        1 ControlPwm(0x64)
  19 1000        1        1 AutoState(Running)
  20  967        1      303 ControlPwm(0x0)
  21  808        1        1 PowerModeChanged(PowerBitmask(0b1))
  22  676        1        1 AutoState(Boot)
  23 1000        1        1 AutoState(Running)
  24  967        1        6 ControlPwm(0x0)

@labbott labbott left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a big diff but I do think it's an improvement. I think keeping the existing behavior is the correct approach. I would also like to capture areas that are particularly weird and you think should be improved (maybe a single issue with some links code lines?)

Comment on lines +158 to +159
if idx >= MAX_FANS {
panic!("Out of range!");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love the extra potential runtime panic but it also didn't seem to increase the code size so 🤷

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a const-fn, and is only currently used to generate constants, which means that any panics that occur would be compile-time errors.

You are right, if someone inadvertently calls it at runtime, it would potentially panic in that case.

Comment thread task/thermal/src/bsp/common/emc2305.rs Outdated
Comment thread task/thermal/src/bsp/common/max31790.rs Outdated
Comment thread task/thermal/src/bsp/minibar.rs
Comment thread task/thermal/src/control.rs
@jamesmunns

Copy link
Copy Markdown
Contributor Author

Capturing a list of "some things that are 'eh' about the current state, and would like to fix later, but didn't because I didn't want to churn the diff even more":

All the dead_code annotations

Right now, we don't use proper features for a bunch of things, like fans, fan controller types, etc., and some things are only used in some bsps.

I think this would be fixed by either:

  1. Splitting thermal-core out into a crate with pub items, making the dead-code no longer relevant
  2. Adding proper feature flagging to thermal

The #[path] module stuff for bsps/common

https://github.com/oxidecomputer/hubris/pull/2609/changes#diff-a9b9fa16760e6bde87da1614252f333f4eef08e39dba9c5e1697fc1fc3e65f05R20-R29

This is awkward. It should probably be feature-flagged or somewhere that multiple BSPs can use it. For now it's an awkward pseudo thing.

tbf the thermal task was already doing this for bsps. I think that need to be fixed too, same options as above

Be okay with some changes to ringbufs

Some of the trait API surface was made pretty much exclusively to keep us able to do ringbuf logging in the same/place way as we currently do. A lot of this is "log an event when updating status/presence". We might decide it is okay to ringbuf this differently, or let some of that information live in the bsps (or the state types like TemperatureInputs/Fans) themselves).

This also includes things like this.

That being said, where we ringbuf today, we probably want to ereport tomorrow, so that might inform what is/isn't reasonable.

Revisit how we do state transitions

https://github.com/oxidecomputer/hubris/pull/2609/changes#diff-f4eab2a6583ffdb099d7d21aff862a465dc6db83665b99618329f357f8a1cad2R891-R893

This could be a bit simpler I think, where we check for the "deviant" states in descending importance, and go directly there vs matching on the current state.

This isn't that big of a deal though, might just dedupe some repeated code.

Duplication between emc2305 and max31790

These are very similar, there might be a way to dedupe these better with a trait or something, and have the bsps use that. Idk, I ran out of time, but it's also not hugely problematic IMO.

Duplication/verbosity across bsps

They are currently a little wordy, with a couple of impls being almost the same. Again, with some more time we might find a better to "compose" the common parts, but the total volume of bsps is pretty low, and it seemed like the current amount of duplication was within the threshold of acceptable.

Things that still need to be done to make this portable

  1. I need to do something with SeqError and PowerBitmask, these are bsp-specific types, and they probably need to be surfaced as associated types on the trait.
  2. I probably need to make the sensor-api stuff and maybe ringbufs a separate trait so we can replace them in hosted configurations.
  3. Actually pull out most of control.rs into a thermal-core crate, use that from the thermal task

Once we do all that, I think we will be at a place where the logic of thermal is largely testable!

@jamesmunns

Copy link
Copy Markdown
Contributor Author

I would also like to capture areas that are particularly weird and you think should be improved (maybe a single issue with some links code lines?)

Wrote up #2609 (comment) for now, I can turn that into an issue (or issues) with correct line links once this merges!

@jamesmunns

Copy link
Copy Markdown
Contributor Author

@labbott addressed most of the comments so far!

@hawkw hawkw left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't fully read through this change, but I wanted to leave the notes I have so far. Overall, I like the refactor, but have some nitpicks.

use task_thermal_api::{SensorReadError, ThermalError};

use crate::{
__RINGBUF, Trace,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, the ringbuf crate also has a ringbuf_entry_root! macro which is intended to allow submodules to add entries to ringbufs declared in the crate root without having to import __RINGBUF. Not a big deal, as what you're doing also works fine in this case (as there is no additional ringbuf declared in this module, which ringbuf_entry_root! helps with). I just figured I'd mention it.

I've also seen code use ringbuf::ringbuf_entry_root as ringbuf_entry; in cases where a module doesn't have its own ringbuf. I dunno whether or not I love that pattern, but it's an existing convention... 🤷‍♀️

Comment on lines +26 to +35
#[allow(dead_code, clippy::upper_case_acronyms)]
pub enum Device {
Tmp117,
Tmp451(drv_i2c_devices::tmp451::Target),
CPU,
Dimm,
U2,
M2,
LM75,
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

turbo nitpick: I think the two "upper case acronyms" clippy doesn't like here are LM75 and CPU; what do you think about just doing this instead:

Suggested change
#[allow(dead_code, clippy::upper_case_acronyms)]
pub enum Device {
Tmp117,
Tmp451(drv_i2c_devices::tmp451::Target),
CPU,
Dimm,
U2,
M2,
LM75,
}
#[allow(dead_code)]
pub enum Device {
Tmp117,
Tmp451(drv_i2c_devices::tmp451::Target),
Cpu,
Dimm,
U2,
M2,
Lm75,
}

I note that every other acronym in this enum (TMP117, TMP451, DIMM) are camel-cased...we could also fully embrace upper-case acronyms here, but if we're going to do that, I'd kinda like to see DIMM and CPU in the same case?)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, take it or leave it, but I kind of wonder if we ought to change this so that it's the name of the actual I2C device we use for each thing (i.e. Tse2005Av instead of Dimm, Sbtsi instead of CPU, and NvmeBmc instead of U2 and M2)? i'm thinking that because this enum is really being used to determine which I2C device's driver to use, rather than which component's thermal model to use (AFAICT?), we might be better off making that a bit more explicit --- in this case, the U2 and M2 variants don't actually seem necessary, since they're both just NVME BMC devices, and I can kind of imagine some future product where DIMM and/or CPU temperatures are read through a different I2C device (though we're probably not going to release, say, an intel compute sled any time soon, so that's probably not worth optimizing for...)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, i see that this was copied directly from the old code, it just lived in a different file. in that case, feel free to ignore my nits for now, though it might be worth cleaning up later...

/// Thermal properties of the associated component
model: ThermalProperties,

/// Mask with bits set based on the Bsp's `power_mode` bits

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick:

Suggested change
/// Mask with bits set based on the Bsp's `power_mode` bits
/// Mask with bits set based on the BSP's `power_mode` bits

Comment on lines +98 to +106
/// InputChannelMetadata is the constant description portion of an InputChannel.
///
/// We split it off because InputChannel is mutable to contain the last state,
/// and if we left it inlined, then we would end up including all of this
/// metadata in RAM, despite never changing it! So instead, we break it off and
/// have InputChannel hold an `&'static` reference instead, so we only waste
/// a wee little pointer (4 bytes) to flash space in each InputChannel entry,
/// instead of (at the time of writing) 36 bytes, which for example in Cosmo
/// that has 14 input channels, is over 500 bytes of RAM!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style nit: this feels like it ought to be on the type declaration, rather than on the impl block?

/// instead of (at the time of writing) 36 bytes, which for example in Cosmo
/// that has 14 input channels, is over 500 bytes of RAM!
impl InputChannelMetadata {
pub const fn new(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style nit: the type is pub(crate) but the constructor is pub (i thought clippy didn't like this, but i guess it doesn't seem to care...)

Suggested change
pub const fn new(
pub(crate) const fn new(

}
}

pub fn do_reading(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: IMO, it would be nice if there was a doc comment on this explaining its relationship with status() --- it looks like do_reading actually asks the temperature sensor for what temperature it is, but returns a thing that represents the result of the I2C request, and status() returns a thing representing the current temperature or its non-existence, but I'd like to see that written down.

Comment on lines +116 to +129
/// Sensor was not read because the power mode indicated that it would not
/// be enabled in this state.
Unpowered { id: SensorId },
/// This sensor was missing, but it's okay because it was either Removable
/// and not there, or Removable and Error Prone and had any kind of error.
AcceptableMissing { id: SensorId, err: SensorReadError },
/// Any read error that didn't match the "Acceptable" cases listed above.
UnacceptableMissing { id: SensorId, err: SensorReadError },
/// We read the data! Hooray!
Success {
id: SensorId,
now: u64,
value: Celsius,
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the type of thing where normally I would say "huh, it seems kind of like it might be easier to use this if we pulled the SensorId out into a struct so that you didn't have to unpack it from each variant"...but after actually looking at the code, I see that it doesn't matter and it's probably simpler to just leave it this way. carry on.

Comment on lines +56 to +68
pub enum FanStatus {
PresentSuccess {
rpm: Rpm,
sensor_id: SensorId,
},
PresentError {
error: SensorReadError,
sensor_id: SensorId,
},
#[allow(dead_code)] // Some bsps don't have removable fans
NotPresent {
sensor_id: SensorId,
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very unimportant style nit: in InputReadingOutcome and InputStatus, the sensor ID field is called id; here, it's called sensor_id. would maybe be worth making it consistent everywhere

#[allow(dead_code)] // Not all bsps have dynamic inputs
pub(crate) struct DynamicInputChannel {
model: ThermalProperties,
pub sensor_id: SensorId,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similarly, InputStatus calls this id, while DynamicInputChannel calls it id...maybe worth using the same name?

Comment on lines +181 to +186
model: ThermalProperties {
target_temperature: Celsius(0.),
critical_temperature: Celsius(0.),
power_down_temperature: Celsius(0.),
temperature_slew_deg_per_sec: 0.,
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get why we're representing the thermal model of a dynamic input which hasn't been added as a ThermalProperties where everything is 0, and it saves at least a byte of enum discriminant and probably some padding versus making this an Option. But, it really doesn't feel good. We are initializing this to an invalid state: an input channel that is at its target temperature, its critical temperature, and its power-down temperature, all at the same time. And we're just relying on other code respecting the has_been_queried flag to avoid making the system thermal shutdown immediately...which it does, but it feels sketchy to me.

If we're going to do this, I'd feel a bit less sketched out if we initialized these to NaN instead of 0. That way, at least the comparisons against the threshold values would always compare false while we're in the uninitialized state, which feels less scary than initializing all dynamic channels to always be over the power-down temp...

enum TemperatureReading {
/// Normal reading, timestamped using monotonic system time
Valid(TimestampedTemperatureReading),
#[allow(dead_code)] // Not all bsps have inputs!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

diversity is an Oxide value :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants