From c6c0f684184db4160e27e3a124d00a96cf33f2de Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Tue, 27 Oct 2026 14:35:32 +1100 Subject: [PATCH] feat(place/demo/hvac): add a simulated HVAC demo driver Place::Demo::HVAC needs no hardware or transport. The temperature eases toward the setpoint, it publishes mode, fan, humidity, CO2, power draw and power, and an unoccupied room widens the target (occupancy setback). Controls: power(state) (on restores the last non-off mode), set_temperature, set_mode, set_fan, set_occupied. Gives demo and test rooms something to switch on and off alongside Place::Demo::Display, e.g. for the n8n nodes. Co-Authored-By: Claude Opus 5.5 --- drivers/place/demo/hvac.cr | 222 ++++++++++++++++++++++++++++++++ drivers/place/demo/hvac_spec.cr | 51 ++++++++ 2 files changed, 273 insertions(+) create mode 100644 drivers/place/demo/hvac.cr create mode 100644 drivers/place/demo/hvac_spec.cr diff --git a/drivers/place/demo/hvac.cr b/drivers/place/demo/hvac.cr new file mode 100644 index 00000000000..1c73731cd24 --- /dev/null +++ b/drivers/place/demo/hvac.cr @@ -0,0 +1,222 @@ +require "placeos-driver" +require "placeos-driver/interface/powerable" + +# Simulated HVAC unit for demos: no hardware and no transport. +# The temperature eases toward the setpoint, and an unoccupied room widens the +# target by `setback_offset` (occupancy setback). +class Place::Demo::HVAC < PlaceOS::Driver + include Interface::Powerable + + descriptive_name "PlaceOS Demo HVAC" + generic_name :HVAC + description %(simulates an HVAC unit: temperature eases toward the setpoint \ +with noise, exposes mode/fan/humidity/CO2/power, and applies occupancy setback. \ +Accepts control commands. For demos. No hardware.) + + enum Mode + Off + Heat + Cool + Auto + end + + enum Fan + Auto + Low + Medium + High + end + + default_settings({ + initial_temperature: 22.5, # deg C + setpoint: 21.5, # deg C + mode: "cool", + fan: "auto", + ambient_temperature: 28.0, # deg C the room drifts toward when not conditioning + humidity: 50.0, # % relative humidity at start + deadband: 0.4, # deg C either side of the target + tick_interval: 5, # seconds between simulated samples + occupancy_setback: true, + setback_offset: 3.0, # deg C + occupied: true, + }) + + @temperature : Float64 = 22.5 + @setpoint : Float64 = 21.5 + @ambient : Float64 = 28.0 + @humidity : Float64 = 50.0 + @co2 : Int32 = 500 + @deadband : Float64 = 0.4 + @mode : Mode = Mode::Cool + @last_on_mode : Mode = Mode::Cool + @fan : Fan = Fan::Auto + @occupied : Bool = true + @occupancy_setback : Bool = true + @setback_offset : Float64 = 3.0 + @tick : Int32 = 5 + + MIN_SETPOINT = 16.0 + MAX_SETPOINT = 30.0 + MIN_TEMP = 10.0 + MAX_TEMP = 40.0 + MIN_CO2 = 420 + MAX_CO2 = 1400 + + # on_update is not called at startup when on_load is defined + def on_load + apply_settings + @temperature = setting?(Float64, :initial_temperature) || @setpoint + @occupied = setting?(Bool, :occupied) != false + publish_state + schedule.every(@tick.seconds) { simulate } + end + + # Keeps the simulated temperature; only the tunables are re-read + def on_update + previous_tick = @tick + apply_settings + if previous_tick != @tick + schedule.clear + schedule.every(@tick.seconds) { simulate } + end + publish_state + end + + private def apply_settings + @setpoint = (setting?(Float64, :setpoint) || @setpoint).clamp(MIN_SETPOINT, MAX_SETPOINT) + @ambient = setting?(Float64, :ambient_temperature) || @ambient + @humidity = setting?(Float64, :humidity) || @humidity + @deadband = setting?(Float64, :deadband) || @deadband + @mode = Mode.parse(setting?(String, :mode) || @mode.to_s) + @last_on_mode = @mode unless @mode.off? + @fan = Fan.parse(setting?(String, :fan) || @fan.to_s) + @occupancy_setback = setting?(Bool, :occupancy_setback) != false + @setback_offset = setting?(Float64, :setback_offset) || @setback_offset + @tick = setting?(Int32, :tick_interval) || @tick + end + + # Returns the target temperature for this tick and whether setback applies. + # Heating targets move down when empty, cooling targets move up. + private def effective_target : {Float64, Bool} + return {@ambient, false} if @mode.off? + setback = @occupancy_setback && !@occupied + return {@setpoint, false} unless setback + if @mode.heat? + {(@setpoint - @setback_offset).clamp(MIN_SETPOINT - 5, MAX_SETPOINT), true} + else + {(@setpoint + @setback_offset).clamp(MIN_SETPOINT, MAX_SETPOINT + 5), true} + end + end + + private def simulate + target, setback = effective_target + + heating = !@mode.off? && !@mode.cool? && @temperature < target - @deadband + cooling = !@mode.off? && !@mode.heat? && @temperature > target + @deadband + conditioning = heating || cooling + + rate = conditioning ? 0.22 : 0.04 + noise = (rand - 0.5) * 0.2 + drift_target = conditioning ? target : @ambient + @temperature = (@temperature + (drift_target - @temperature) * rate + noise).clamp(MIN_TEMP, MAX_TEMP) + + hnoise = (rand - 0.5) * 1.5 + hnoise -= 0.4 if cooling + @humidity = (@humidity + hnoise).clamp(30.0, 70.0) + + if @occupied + @co2 = (@co2 + 12 + rand(8)).clamp(MIN_CO2, MAX_CO2) + else + @co2 = (@co2 - 20).clamp(MIN_CO2, MAX_CO2) + end + + publish_state(heating, cooling, setback) + end + + # 0 = off, 1 = low, 2 = medium, 3 = high + private def running_fan_level(conditioning : Bool) : Int32 + return 0 if @mode.off? + return @fan.value if @fan != Fan::Auto + return 1 unless conditioning + diff = (@setpoint - @temperature).abs + diff < 1.0 ? 2 : 3 + end + + # Watts + private def power_draw(heating : Bool, cooling : Bool, fan_level : Int32) : Int32 + watts = fan_level * 120 + watts += 2800 if cooling + watts += 2400 if heating + watts + end + + private def publish_state(heating : Bool = false, cooling : Bool = false, setback : Bool = false) + fan_level = running_fan_level(heating || cooling) + self[:temperature] = @temperature.round(1) + self[:setpoint] = @setpoint.round(1) + self[:mode] = @mode.to_s.downcase + self[:fan] = @fan.to_s.downcase + self[:fan_level] = fan_level + self[:fan_running] = fan_level > 0 + self[:humidity] = @humidity.round(0).to_i + self[:co2] = @co2 + self[:occupied] = @occupied + self[:heating] = heating + self[:cooling] = cooling + self[:setback_active] = setback + self[:power_draw] = power_draw(heating, cooling, fan_level) + self[:power] = !@mode.off? + self[:connected] = true + self[:last_updated] = Time.utc.to_unix + end + + # On restores the last mode other than off + @[Security(Level::Support)] + def power(state : Bool) + @mode = state ? @last_on_mode : Mode::Off + simulate + state + end + + @[Security(Level::Support)] + def set_temperature(value : Float64) + @setpoint = value.clamp(MIN_SETPOINT, MAX_SETPOINT) + simulate + @setpoint + end + + @[Security(Level::Support)] + def set_mode(mode : Mode) + @mode = mode + @last_on_mode = mode unless mode.off? + simulate + @mode.to_s.downcase + end + + @[Security(Level::Support)] + def set_fan(speed : Fan) + @fan = speed + simulate + @fan.to_s.downcase + end + + @[Security(Level::Support)] + def set_occupied(state : Bool) + @occupied = state + simulate + @occupied + end + + def state + { + temperature: @temperature.round(1), + setpoint: @setpoint.round(1), + mode: @mode.to_s.downcase, + fan: @fan.to_s.downcase, + humidity: @humidity.round(0).to_i, + co2: @co2, + occupied: @occupied, + setback_active: @occupancy_setback && !@occupied && !@mode.off?, + } + end +end diff --git a/drivers/place/demo/hvac_spec.cr b/drivers/place/demo/hvac_spec.cr new file mode 100644 index 00000000000..bb3f6567197 --- /dev/null +++ b/drivers/place/demo/hvac_spec.cr @@ -0,0 +1,51 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Place::Demo::HVAC" do + # on_load has published an initial frame + status[:mode].should eq("cool") + status[:connected].should eq(true) + status[:power].should eq(true) + status[:temperature].as_f.should be_a(Float64) + status[:occupied].should eq(true) + + # setpoint control + clamping + exec(:set_temperature, 19.0).get + status[:setpoint].as_f.should eq(19.0) + exec(:set_temperature, 99.0).get + status[:setpoint].as_f.should eq(30.0) # MAX_SETPOINT + + # mode control (string arg deserialises into the Mode enum) + exec(:set_mode, "heat").get + status[:mode].should eq("heat") + exec(:set_mode, "off").get + status[:mode].should eq("off") + status[:power].should eq(false) + status[:heating].should eq(false) + status[:cooling].should eq(false) + + # fan control + exec(:set_mode, "cool").get + exec(:set_fan, "high").get + status[:fan].should eq("high") + status[:fan_level].as_i.should eq(3) + + # occupancy setback: emptying the room activates setback and widens the + # cooling target above the comfort setpoint + exec(:set_temperature, 21.0).get + exec(:set_occupied, false).get + status[:occupied].should eq(false) + status[:setback_active].should eq(true) + + # re-occupying clears setback + exec(:set_occupied, true).get + status[:setback_active].should eq(false) + + # power off then on restores the previous mode + exec(:set_mode, "heat").get + exec(:power, false).get + status[:power].should eq(false) + status[:mode].should eq("off") + exec(:power, true).get + status[:power].should eq(true) + status[:mode].should eq("heat") +end