Skip to content
Open
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
222 changes: 222 additions & 0 deletions drivers/place/demo/hvac.cr
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions drivers/place/demo/hvac_spec.cr
Original file line number Diff line number Diff line change
@@ -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
Loading