Skip to content

Instantly share code, notes, and snippets.

@ivan4th
Created December 4, 2024 14:59
Show Gist options
  • Save ivan4th/4a807971dc92b5a4eb39f2476910750f to your computer and use it in GitHub Desktop.
Save ivan4th/4a807971dc92b5a4eb39f2476910750f to your computer and use it in GitHub Desktop.
izba control system test
(defpackage :izba
(:use :cl :alexandria :i4-diet-utils :iterate :cs-common :cs-io :cs-cells :cs-core :cs-mqtt))
(in-package :izba)
(defconf *mqtt-server* "mqtt")
(defvar *pmc*)
(defvar *ds*)
(defvar *mhdc*)
(defvar *to-create* (make-hash-table))
(defun running-p ()
(cl-async-repl:event-thread-running-p))
(defun get-parent-from-initargs (default-parent initargs)
(destructuring-bind (&key parent-name parent-class &allow-other-keys) initargs
(cond ((and parent-name parent-class)
(error "can't specify both :PARENT-NAME and :PARENT-CLASS"))
(parent-name
(object-by-name *root* parent-name))
(parent-class
(object-by-class *root* parent-class))
(t default-parent))))
(defun reg (class-name &rest initargs)
(let ((name (or (getf initargs :name)
(setf (getf initargs :name) class-name))))
(setf (gethash name *to-create*) (cons class-name initargs))
(when (running-p)
(cl-async-repl::sync-action
#'(lambda ()
(when-let ((existing (object-by-name *root* name nil)))
(remove-object existing)
;; TBD: this shouldn't be needed!
(remhash existing cs-cells::*active-cell-contexts*))
(let* ((umbrella (object-by-class *root* 'timed-umbrella))
(created (apply #'make-instance
class-name
:parent (get-parent-from-initargs umbrella initargs)
:model (cell-model-of (cs-view umbrella))
:name name
(remove-from-plist initargs :name :parent-name :parent-class))))
(dbg "Created: ~s" created)
(process-events *root*)))))))
(defun remove-auto-create (name)
(remhash name *to-create*))
(defun time-between (current-time a b)
(flet ((parse-tspec (tspec)
(destructuring-bind (hour &optional (min 0) (sec 0))
(ensure-list tspec)
(+ (* hour 3600) (* min 60) sec))))
(let ((t-cur (multiple-value-bind (sec min hour)
(decode-universal-time
(unix-timestamp->universal-time current-time))
(+ (* hour 3600) (* min 60) sec)))
(t-a (parse-tspec a))
(t-b (parse-tspec b)))
(cond ((< t-a t-b)
;; if A is before B, then CURRENT-TIME must be between A and B.
;; A=B means CU
(<= t-a t-cur t-b))
((= t-a t-b)
(warn "TIME-BETWEEN will not work correctly for A == B")
nil)
;; if A is after B, then CURRENT-TIME must be between A and the midnight,
;; or between the midnight and B
((or (<= t-a t-cur)
(<= t-cur t-b)))))))
(define-cell-class ticker (cell-context)
((tick-interval :accessor tick-interval-of
:initform 10d0
:initarg :tick-interval))
(:global-cells
(.real-time "Current real time")))
(defmethod initialize-instance :after ((ticker ticker) &key &allow-other-keys)
(set-timeout ticker
#'(lambda () (wake-once ticker))
(tick-interval-of ticker)
:repeat t))
(defclass timed-umbrella (umbrella)
((ticker :accessor ticker-of)))
(defmethod initialize-instance :after ((umbrella timed-umbrella)
&key (tick-interval nil tick-interval-p)
(model (error "must specify :MODEL"))
&allow-other-keys)
(setf (ticker-of umbrella)
(apply #'make-instance 'ticker
:name 'ticker
:parent umbrella
:model model
(when tick-interval-p
(list :tick-interval tick-interval)))))
(defmethod run-object :before ((umbrella timed-umbrella))
(setf (cell-value 'real-time (ticker-of umbrella))
(current-real-time umbrella)))
(define-hass-local-device local-dev-test (hass-local-device)
()
(:desc "Local device for testing")
(:node-id "local-dev-test")
(:global-cells
(.testswitch1 "Test Switch 1")
(.testlight1 "Test Light 1")
(.climate-mode "Climate mode")
(.climate-set-temp "Climate target temperature" (:value 20d0)))
(:objects
(:switch "testswitch1" .testswitch1)
(:light "testlight1" .testlight1)
(:climate "thermostat1"
:modes '(:off :heat)
:mode-cell .climate-mode
:set-temperature-cell .climate-set-temp)))
(define-hass-local-device local-dev-test-2 (hass-local-device)
()
(:desc "Local device for testing")
(:node-id "local-dev-test-2")
(:global-cells
(.testswitch2 "Test Switch 2")
(.testlight2 "Test Light 2")
(.testlight3 "Test Light 3")
(.climate-mode2 "Climate mode 2")
(.climate-set-temp2 "Climate target temperature 2" (:value 21d0)))
(:objects
(:switch "testswitch2" .testswitch2)
(:light "testlight2" .testlight2)
(:light "testlight3" .testlight3)
(:climate "thermostat2"
:modes '(:off :heat)
:mode-cell .climate-mode2
:set-temperature-cell .climate-set-temp2)))
(define-cell-class multi-switch (cell-fsm)
((cells :accessor cells-of :initarg :cells :initform (error "must specify :CELLS")))
(:cells
(is-on (:type 'boolean) :switch)))
(define-dynamic-cell-method .any-active ((switch multi-switch))
`(or ,@(mapcar #'make-cell-ref (cells-of switch))))
(define-dynamic-cell-method .all-active ((switch multi-switch))
`(and ,@(mapcar #'make-cell-ref (cells-of switch))))
(define-dynamic-cell-method .set-on ((switch multi-switch) on)
`(setf ,@(iter (for cell in (cells-of switch))
(collect (make-cell-ref cell))
(collect 'on))))
;; Need to have fallback cells so that the FSM is in the
;; :INCOMPLETE state till all of the needed cells appear
(defmethod context-cell-specs :around ((switch multi-switch))
(append
(iter (for cell in (cells-of switch))
(collect (list cell :value-type 'boolean :fallback t)))
(call-next-method)))
(defmethod initial-state ((switch multi-switch)) :decide)
(define-state/cells ((switch multi-switch) :decide (cell-wait-state))
(:transitions
((.any-active)
(setf .is-on t)
:on)
((not (.any-active))
(setf .is-on nil)
:off)))
(define-state/cells ((switch multi-switch) :off (cell-wait-state))
(:enter
(.set-on nil))
(:transitions
(.is-on :on)
((.any-active)
:off)))
(define-state/cells ((switch multi-switch) :on (cell-wait-state))
(:enter
(.set-on t))
(:transitions
((not .is-on)
:off)
((not (.all-active))
:on)))
;; FIXME: should use a common base class
(define-cell-class relaxed-multi-switch (multi-switch) ())
;; FIXME: rm (same as one for multi-switch)
#++
(define-state/cells ((switch relaxed-multi-switch) :decide (cell-wait-state))
(:transitions
((.any-active)
(setf .is-on t)
:on)
((not (.any-active))
(setf .is-on nil)
:off)))
(define-state/cells ((switch relaxed-multi-switch) :off (cell-wait-state))
(:transitions
(.is-on
(.set-on t)
:on)
((.any-active)
(setf .is-on t)
:on)))
(define-state/cells ((switch relaxed-multi-switch) :on (cell-wait-state))
(:transitions
((not .is-on)
(.set-on nil)
:off)
((not (.any-active))
(setf .is-on nil)
:off)))
(define-cell-class boiler (mqtt-remote-device)
()
(:cells
(temp-burner
"Burner temperature"
(:address (:mqtt :real-number "/devices/boiler/controls/temp-burner"
nil "/devices/boiler/avail")))
(temp-burner-deriv
"Burner temperature time derivative"
(:address (:mqtt :real-number "/devices/boiler/controls/temp-deriv"
nil "/devices/boiler/avail")))
(temp-tank-a
"Tank A"
(:address (:mqtt :real-number "/devices/boiler/controls/temp-tank-a"
nil "/devices/boiler/avail")))
(temp-tank-b
"Tank B"
(:address (:mqtt :real-number "/devices/boiler/controls/temp-tank-b"
nil "/devices/boiler/avail")))
(temp-tank-c
"Tank C"
(:address (:mqtt :real-number "/devices/boiler/controls/temp-tank-c"
nil "/devices/boiler/avail")))
(temp-boiler-to-tank
"Boiler to tank"
(:address (:mqtt :real-number "/devices/boiler/controls/temp-boiler-to-tank"
nil "/devices/boiler/avail")))
(temp-tank-to-boiler
"Tank to boiler"
(:address (:mqtt :real-number "/devices/boiler/controls/temp-tank-to-boiler"
nil "/devices/boiler/avail")))
(temp-tank-to-house
"Tank to house"
(:address (:mqtt :real-number "/devices/boiler/controls/temp-tank-to-house"
nil "/devices/boiler/avail")))
(temp-house-to-tank
"House to tank"
(:address (:mqtt :real-number "/devices/boiler/controls/temp-house-to-tank"
nil "/devices/boiler/avail")))
(valve-x
"Valve X"
(:address (:mqtt :real-number "/devices/boiler/controls/valveX"
nil "/devices/boiler/avail")))
(valve-y
"Valve Y"
(:address (:mqtt :real-number
"/devices/boiler/controls/valveY"
"/devices/boiler/controls/valveY/on"
"/devices/boiler/avail")))
(target-temp
"Target radiator water temperature"
(:address (:mqtt :real-number
"/devices/boiler/controls/targetTemp"
"/devices/boiler/controls/targetTemp/on"
"/devices/boiler/avail")))
(target-temp
"Target radiator water temperature"
(:address (:mqtt :real-number
"/devices/boiler/controls/targetTemp"
"/devices/boiler/controls/targetTemp/on"
"/devices/boiler/avail")))
(radiator-circulation-relay
"Radiator circulation relay"
(:type 'boolean)
(:address (:mqtt :simple-switch
"/devices/boiler/controls/radiator-circulation-relay"
"/devices/boiler/controls/radiator-circulation-relay/on"
"/devices/boiler/avail")))
(boiler-circulation-relay
"Boiler circulation relay"
(:type 'boolean)
(:address (:mqtt :simple-switch
"/devices/boiler/controls/boiler-circulation-relay"
"/devices/boiler/controls/boiler-circulation-relay/on"
"/devices/boiler/avail")))
(enable-valve-control
(:type 'boolean)
(:address (:mqtt :simple-switch
"/devices/boiler/controls/enable-valve-control"
"/devices/boiler/controls/enable-valve-control/on"
"/devices/boiler/avail")))
(radiator-1-kitchen
(:type 'boolean)
(:address (:mqtt :simple-switch
"/devices/boiler/controls/radiatorValve1Open"
"/devices/boiler/controls/radiatorValve1Open/on"
"/devices/boiler/avail")))
(radiator-1-hall
(:type 'boolean)
(:address (:mqtt :simple-switch
"/devices/boiler/controls/radiatorValve2Open"
"/devices/boiler/controls/radiatorValve2Open/on"
"/devices/boiler/avail")))
(radiator-1-bedroom-east
(:type 'boolean)
(:address (:mqtt :simple-switch
"/devices/boiler/controls/radiatorValve3Open"
"/devices/boiler/controls/radiatorValve3Open/on"
"/devices/boiler/avail")))
(radiator-1-bedroom-north
(:type 'boolean)
(:address (:mqtt :simple-switch
"/devices/boiler/controls/radiatorValve4Open"
"/devices/boiler/controls/radiatorValve4Open/on"
"/devices/boiler/avail")))
(radiator-1-lobby
(:type 'boolean)
(:address (:mqtt :simple-switch
"/devices/boiler/controls/radiatorValve5Open"
"/devices/boiler/controls/radiatorValve5Open/on"
"/devices/boiler/avail")))
(radiator-1-dining-room-south-west
(:type 'boolean)
(:address (:mqtt :simple-switch
"/devices/boiler/controls/radiatorValve6Open"
"/devices/boiler/controls/radiatorValve6Open/on"
"/devices/boiler/avail")))
(radiator-1-dining-room-north-west
(:type 'boolean)
(:address (:mqtt :simple-switch
"/devices/boiler/controls/radiatorValve7Open"
"/devices/boiler/controls/radiatorValve7Open/on"
"/devices/boiler/avail")))
(radiator-2-mishas-room-south
(:type 'boolean)
(:address (:mqtt :simple-switch
"/devices/boiler/controls/radiatorValve8Open"
"/devices/boiler/controls/radiatorValve8Open/on"
"/devices/boiler/avail")))
(radiator-2-mishas-room-west
(:type 'boolean)
(:address (:mqtt :simple-switch
"/devices/boiler/controls/radiatorValve9Open"
"/devices/boiler/controls/radiatorValve9Open/on"
"/devices/boiler/avail")))
(radiator-2-bedroom-west
(:type 'boolean)
(:address (:mqtt :simple-switch
"/devices/boiler/controls/radiatorValve10Open"
"/devices/boiler/controls/radiatorValve10Open/on"
"/devices/boiler/avail")))
(radiator-2-bedroom-north
(:type 'boolean)
(:address (:mqtt :simple-switch
"/devices/boiler/controls/radiatorValve11Open"
"/devices/boiler/controls/radiatorValve11Open/on"
"/devices/boiler/avail")))
(radiator-2-homeoffice
(:type 'boolean)
(:address (:mqtt :simple-switch
"/devices/boiler/controls/radiatorValve12Open"
"/devices/boiler/controls/radiatorValve12Open/on"
"/devices/boiler/avail")))
(radiator-2-hall
(:type 'boolean)
(:address (:mqtt :simple-switch
"/devices/boiler/controls/radiatorValve13Open"
"/devices/boiler/controls/radiatorValve13Open/on"
"/devices/boiler/avail")))
(all-radiators-off
(:type 'boolean)
(:formula
(not (or .radiator-1-bedroom-north
.radiator-1-lobby
.radiator-1-hall
.radiator-1-dining-room-south-west
.radiator-1-dining-room-north-west
.radiator-2-mishas-room-south
.radiator-2-mishas-room-west
.radiator-2-bedroom-west
.radiator-2-bedroom-north
.radiator-2-homeoffice
.radiator-2-hall))))
(circulation-not-needed-p
(:type 'boolean)
(:formula (and .all-radiators-off
(not .boiler-circulation-relay))))
(temp-diff
(:formula (- .temp-tank-to-house .temp-house-to-tank)))
(temp-diff-a
(:formula (- .temp-tank-to-house .temp-tank-a)))
(mix-factor
(:formula (let ((a .temp-house-to-tank)
(b .temp-tank-a)
(c (+ .temp-tank-to-house 0.33d0)))
(if (= a b)
0d0 ;; avoid dividing by zero
(/ (- c b) (- a b)))))))
(:default-initargs :raw-name "boiler"))
(define-cell-class timed-on-off (cell-fsm)
((time-a :accessor time-a-of :initarg :time-a
:initform (error "must specify TIME-A"))
(time-b :accessor time-b-of :initarg :time-b
:initform (error "must specify TIME-B"))
(back-to-off-delay :accessor back-to-off-delay-of
:initarg :back-to-off-delay
:initform 1500d0))
(:io
(.target-cell (:type 'boolean))))
(defmethod initial-state ((tonoff timed-on-off)) :off)
(define-state/cells ((tonoff timed-on-off) :off)
(:enter
(setf .target-cell nil))
(:transitions
((time-between .real-time (time-a-of tonoff) (time-b-of tonoff)) :on)
(.target-cell :temporary-on)))
(define-state/cells ((tonoff timed-on-off) :temporary-on)
(:enter
(fsm-timeout tonoff (back-to-off-delay-of tonoff)))
(:transitions
(:timeout :off)
((not .target-cell) :off)))
(define-state/cells ((tonoff timed-on-off) :on)
(:enter
(setf .target-cell t))
(:transitions
((not (time-between .real-time (time-a-of tonoff) (time-b-of tonoff))) :off)))
;; TBD: trying to use these hass-rules causes endless loop
#++
(define-tag-rules hass-rules
(:real-number => (:type 'double-float))
(:temperature => (:units "degC") :real-number)
(:climate-mode => (:type '((0 :off) (1 :heat))))
(:switch => (:type 'boolean)))
;; TBD: MODE should be replaced with ACTIVE (boolean)
;; and there should be derived CLIMATE-ON-OFF-CONTROLLER class
(define-cell-class on-off-controller (cell-fsm)
()
(:io
(mode "Mode" (:type '((0 :off) (1 :heat))))
(switch "Switch" (:type 'boolean)))
(:input
(target "Target value")
(sensor "Sensor"))
(:cells
(diff "Diff" (:formula (- .sensor .target)))
(tolerance "Tolerance" (:value 0.1d0)))
#++
(:tag-rules hass-rules))
(defmethod initial-state ((onoff on-off-controller)) :off)
(define-state/cells ((onoff on-off-controller) :off)
(:enter
(setf .switch nil))
(:transitions
((and (eq :heat .mode)
(> .sensor (+ .target .tolerance)))
:heat)
((eq :heat .mode) :idle)))
(define-state/cells ((onoff on-off-controller) :idle)
(:enter
(setf .switch nil))
(:transitions
((equal :off .mode) :off)
((< .sensor (- .target .tolerance)) :heat)
(.switch (setf .switch nil) :idle)))
(define-state/cells ((onoff on-off-controller) :heat)
(:enter
(setf .switch t))
(:transitions
((equal :off .mode) :off)
((> .sensor (+ .target .tolerance)) :idle)
((not .switch) (setf .switch t) :heat)))
(define-cell-class timed-condition (cell-fsm)
((period :accessor period-of
:initform (error "must specify :PERIOD")
:initarg :period)
(active-value :accessor active-value-of
:initform t
:initarg :active-value)
(one-way-p :accessor one-way-p
:initform nil
:initarg :one-way-p)
(reactivate-delay :accessor reactivate-delay-of
:initform nil
:initarg :reactivate-delay))
(:input
(condition "Condition" (:type 'boolean)))
(:io
(output "Output" (:type 'boolean))))
(defmethod initial-state ((tcond timed-condition)) :decide)
(define-cell-method .activated ((tcond timed-condition))
(equal .output (active-value-of tcond)))
(define-state/cells ((tcond timed-condition) :decide (cell-wait-state))
(:transitions
((and (one-way-p tcond) .condition (.activated)) :active)
(.condition :wait)
((not .condition) :inactive)))
(define-state/cells ((tcond timed-condition) :inactive (cell-wait-state))
(:enter
(unless (one-way-p tcond)
(setf .output (not (active-value-of tcond)))))
(:transitions
((and (one-way-p tcond) .condition (.activated))
:active)
(.condition :wait)
((and (not (one-way-p tcond)) (.activated))
:inactive)))
(define-state/cells ((tcond timed-condition) :wait (cell-wait-state))
(:enter
(unless (one-way-p tcond)
(setf .output (not (active-value-of tcond))))
(fsm-timeout tcond (period-of tcond)))
(:transitions
((not .condition) :inactive)
(:timeout :active)))
(define-state/cells ((tcond timed-condition) :active (cell-wait-state))
(:enter
(setf .output (active-value-of tcond)))
(:transitions
((not .condition) :inactive)
((and (one-way-p tcond) (not (.activated)) (not (reactivate-delay-of tcond))) :wait)
((and (one-way-p tcond) (not (.activated)) (reactivate-delay-of tcond)) :delay)
((not (.activated)) :active)))
(define-state/cells ((tcond timed-condition) :delay (cell-wait-state))
(:enter
(fsm-timeout tcond (or (reactivate-delay-of tcond) 0)))
(:transitions
(:timeout :wait)))
(define-cell-class cell-adjuster (cell-fsm)
((delay :accessor delay-of
:initarg :delay
:initform 0.1d0))
(:input
(enabled "Adjuster enabled" (:type 'boolean))
(input "Input cell"))
(:io
(output "Output cell")))
(defmethod initial-state ((adjuster cell-adjuster)) :inactive)
(define-state/cells ((adjuster cell-adjuster) :inactive (cell-wait-state))
(:transitions
(.enabled :active)))
(define-state/cells ((adjuster cell-adjuster) :active (cell-wait-state))
(:transitions
((not .enabled) :inactive)
((not (equal .input .output))
;; TBD: better rate limiting
(setf .output .input)
:pause)))
(define-state/cells ((adjuster cell-adjuster) :pause (cell-wait-state))
(:enter
(fsm-timeout adjuster (delay-of adjuster)))
(:transitions
((not .enabled) :inactive)
(:timeout :active)))
(define-cell-class mqtt-action-handler (cell-context)
((action-mappings :accessor action-mappings-of :initform (make-hash-table :test #'equal)))
(:cells
;; TBD: implement action queue
;; and feedback via the .ACTION-HANDLED cell
(pending-action (:type 'symbol))))
(defmethod initialize-instance :after ((handler mqtt-action-handler)
&key action-mappings parent
(pmc (object-by-class parent 'persistent-mqtt-client))
&allow-other-keys)
;; TODO: auto-unobserve upon unparenting (removal)
(observe-pmc-message-received pmc handler)
(iter (for (topic payload action) in action-mappings)
(setf (gethash (cons topic payload) (action-mappings-of handler)) action)
(pmc-subscribe pmc topic)))
(defmethod pmc-message-received ((pmc persistent-mqtt-client) (handler mqtt-action-handler)
topic payload retain)
(when-let ((action (gethash (cons topic payload) (action-mappings-of handler))))
(cond (retain
(warn "Not running action ~s for _retained_ message ~s on topic ~s"
action payload topic))
(t
(dbg "MQTT-ACTION-HANDLER: topic ~s payload ~s -> action ~s"
topic payload action)
(setf (cell-value 'pending-action handler) action)))))
(define-cell-class izba-commander (cell-fsm)
()
(:io (.pending-action (:type 'symbol)))
(:bind :pending-action :mqtt-action-handler.pending-action))
(defmethod initial-state ((commander izba-commander)) :initial)
(define-state/cells ((commander izba-commander) :initial (cell-wait-state))
(:transitions
((eq :outdoor-lights-on .pending-action)
;; only turn on the outdoor lights on the house and in front of it
;; but not at the stable
(setf .house-outdoor-lights.is-on t
.pending-action nil)
:initial)
((eq :outdoor-lights-off .pending-action)
;; Turn off all of the lights, including those at the stable
;; (later should also turn on the side floodlight on the house)
(setf .outdoor-lights.is-on nil
.pending-action nil
.izba.lights-turned-off t)
:initial)
((eq :away-mode-on .pending-action)
(mqtt-notify "Away Mode On" "Away Mode Turned On")
(setf .izba.away t
.pending-action nil)
:initial)
((eq :away-mode-off .pending-action)
(mqtt-notify "Away Mode Off" "Away Mode Turned Off")
(setf .izba.away nil
.pending-action nil)
:initial)
((and (not .izba.dark) .izba.lights-turned-off)
(setf .izba.lights-turned-off nil)
:initial)))
#++
(define-cell-class rmme (cell-context)
()
(:cells
(a)
(b)
(enabled (:type 'boolean))
(condition (:type 'boolean))
(cond-output (:type 'boolean))))
#++
(reg 'rmme)
#++
(reg 'timed-condition
:name 'rmme-condition
:period 10d0
:bind '(:condition :rmme.condition
:output :rmme.cond-output))
#++
(reg 'cell-adjuster
:name 'rmme-adjuster
:bind '(:input :rmme.a
:output :rmme.b
:enabled :rmme.enabled))
(define-hass-local-device room-temps (hass-local-device)
()
(:desc "Room temperatures")
(:node-id "eacs-room-temps")
(:cells
(.lobby-climate-mode "Lobby climate mode" :climate-mode (:value :heat))
(.lobby-climate-temp "Lobby" (:value 20d0))
(.dining-room-climate-mode "Dining room climate mode" :climate-mode (:value :heat))
(.dining-room-climate-temp "Dining room" (:value 20d0))
(.hall1-climate-mode "Hall 1 climate mode" :climate-mode (:value :heat))
(.hall1-climate-temp "Hall 1" (:value 20d0))
(.bedroom1-climate-mode "Bedroom 1 climate mode" :climate-mode (:value :heat))
(.bedroom1-climate-temp "Bedroom 1" (:value 20d0))
(.bedroom2-climate-mode "Bedroom 2 climate mode" :climate-mode (:value :heat))
(.bedroom2-climate-temp "Bedroom 2" (:value 20d0))
(.mishas-room-climate-mode "Misha's room climate mode" :climate-mode (:value :heat))
(.mishas-room-climate-temp "Misha's room" (:value 20d0)))
(:bind
;; These cells are added as :IO fallback cells
;; by the DEFINE-HASS-LOCAL-DEVICE macro as they're present
;; in :OBJECTS but are not mentioned in any of cell sections:
;; :CELLS :GLOBAL-CELLS :INPUT :OUTPUT :IO
:lobby-actual-temp :hass.octopus-1stfloor.lobby
:dining-room-actual-temp :hass.octopus-1stfloor.dining-room-temp
:hall1-actual-temp :hass.octopus-1stfloor.hall
:bedroom1-actual-temp :hass.octopus-1stfloor.bedroom
:bedroom2-actual-temp :hass.freshair.2nd-floor-bedroom-temperature
:mishas-room-actual-temp :hass.light-floor-2-1.temperature-in-mishas-room)
(:objects
(:climate "lobby"
:mode-cell .lobby-climate-mode
:set-temperature-cell .lobby-climate-temp
:current-temperature-cell .lobby-actual-temp)
(:climate "dining-room"
:mode-cell .dining-room-climate-mode
:set-temperature-cell .dining-room-climate-temp
:current-temperature-cell .dining-room-actual-temp)
(:climate "hall-1"
:mode-cell .hall1-climate-mode
:set-temperature-cell .hall1-climate-temp
:current-temperature-cell .hall1-actual-temp)
(:climate "bedroom-1"
:mode-cell .bedroom1-climate-mode
:set-temperature-cell .bedroom1-climate-temp
:current-temperature-cell .bedroom1-actual-temp)
(:climate "bedoom-2"
:mode-cell .bedroom2-climate-mode
:set-temperature-cell .bedroom2-climate-temp
:current-temperature-cell .bedroom2-actual-temp
:precision 0.1d0)
(:climate "mishas-room"
:mode-cell .mishas-room-climate-mode
:set-temperature-cell .mishas-room-climate-temp
:current-temperature-cell .mishas-room-actual-temp))
#++
(:tag-rules hass-rules))
(define-cell-class izba (cell-context)
()
(:cells
;; ZZZZZ !!! fixme
(night (:type 'boolean) (:formula (time-between .real-time '(19 30) #++ '(23 00) '(10 00))))
(dark (:type 'boolean)
#++(:formula
;; FIXME: UTC
(time-between .real-time '(15 00) #++ '(23 00) '(5 00))
#++(time-between .real-time '(18 00) #++ '(23 00) '(8 00)))
(:formula (< .hass.weather.illumination 100d0)))
(away (:type 'boolean))
(lights-turned-off (:type 'boolean))
(need-outdoor-lights
(:type 'boolean)
(:formula (and .dark (not .lights-turned-off))))
(auto-temps (:type 'boolean) (:value t))
(bedroom-2-night-temp "Bedroom 2 night temperature" (:value 18d0))
(low-room-temp "Low room temperature" (:value 18d0))
(normal-room-temp "Normal room temperature" (:value 22d0))
(lobby-target-temp "Lobby temp" (:value 15d0))
(away-temp "Away temp" (:value 20d0)) ;; FIXME: 15d0
(dining-room-target-temp
(:value 18d0)
#++
(:formula (cond (.away .away-temp)
(.night .low-room-temp)
(t (+ .normal-room-temp 1d0)))))
(hall-1-target-temp
;; FIXME
(:value -1d0)
#++
(:formula
;; unfortunate sensor placement
(cond (.away .away-temp)
(.night .low-room-temp)
(t (+ .normal-room-temp 1.5d0)))))
(bedroom-1-target-temp
(:formula 23.5d0)
#++
(:formula -1d0 #++(cond (.away .away-temp)
(.night .normal-room-temp)
(t .low-room-temp))))
(bedroom-2-target-temp
(:value 15d0)
#++
(:formula (cond (.away .away-temp)
(.night .bedroom-2-night-temp)
(t .low-room-temp))))
(mishas-room-target-temp
(:value 15d0)
;; FIXME: weekends
#++
(:formula (cond (.away .away-temp)
(.night .low-room-temp)
(t 18d0 #++ .normal-room-temp))))
(min-temp-diff
(:formula
(min .bedroom1-temp-control.diff
.dining-room-temp-control.diff
.hall1-temp-control.diff
.lobby-temp-control.diff
;; FIXME: inconsistent naming
.bedroom-2-temp-control.diff
.mishas-room-temp-control.diff)))
(radiator-temp
(:formula
;; TBD: use average temperature over some period
;; or at least use longer interval for the cell-adjuster
;; See MAVP "moving average with variable period"
(if (not .hass.boiler.radiator-circulation-relay)
10d0
(let ((temp
(cond ((not .hass.boiler.radiator-circulation-relay) 10d0)
;; make it warmer when possible
((and .hass.boiler.boiler-circulation-relay
(> .hass.boiler.temp-burner 200d0))
70d0)
;; not using a simple linear formula for the
;; sake of easier adjustability
((> .hass.weather.temperature 25d0 ) 10d0)
((> .hass.weather.temperature 10d0 ) 30d0)
((> .hass.weather.temperature 5d0 ) 35d0)
((> .hass.weather.temperature 0d0 ) 40d0)
((> .hass.weather.temperature -5d0 ) 45d0)
((> .hass.weather.temperature -10d0) 50d0)
((> .hass.weather.temperature -15d0) 55d0)
((> .hass.weather.temperature -20d0) 60d0)
((> .hass.weather.temperature -25d0) 65d0)
(t 70d0))))
(min 70d0
(cond ((<= .izba.min-temp-diff -5d0) 70d0)
((<= .izba.min-temp-diff -2d0) (+ temp 20d0))
((<= .izba.min-temp-diff -1d0) (+ temp 10d0))
((<= .izba.min-temp-diff -0.5d0) (+ temp 5d0))
(t temp))))))))
;; Planned child specs.
;; Child objects can reference cells relative to this context.
;; Also, there can be cell declarations among bindings.
#++
(:children
(relaxed-multi-switch
(:name 'outdoor-lights
:cells '(:hass.light-floor-2-2.front-floodlight
:hass.light-bbq.bbq-light
:hass.light-shack.shack-light
:hass.light-floor-1-2.porch-light
:hass.stablelight.stable-outdoor-light)))
(timed-condition
(:name 'outdoor-lights-cond
:one-way-p t :period 600
:reactivate-delay (* 3600 12))
(:bind
;; Could also be just (:condition .dark)
;; to refer to a previously defined cell.
;; The new cell can only be declared if it's not
;; declared elsewhere, so with below line, "dark"
;; above would have to be removed
(:condition dark (:type 'boolean) (:formula (< .hass.weather.illumination 100d0)))
;; Note that this cell reference can be resolved relative
;; to this context. That feature should be added to EXPRESSO
(:output .outdoor-lights.is-on)))))
(define-hass-local-device livestock-temps (hass-local-device)
()
(:desc "Livestock building temperatures")
(:node-id "livestock-temps")
(:cells
(.stable-climate-mode "Stable climate mode" :climate-mode (:value :heat))
(.stable-climate-temp "Stable" (:value 8d0)))
(:bind
:stable-actual-temp :hass.stable-climate.stable-climate-temperature)
(:objects
(:climate "stable"
:mode-cell .stable-climate-mode
:set-temperature-cell .stable-climate-temp
:current-temperature-cell .stable-actual-temp))
#++
(:tag-rules hass-rules))
(define-cell-class stable-co2 (cell-fsm)
()
(:io
(switch "Switch" (:type 'boolean)))
(:input
(sensor "Sensor"))
(:cells
(enabled "Enabled" (:type 'boolean) (:value t))
(low-co2-value (:value 1100d0) "Low CO2 value")
(high-co2-value (:value 1500d0) "High CO2 value"))
(:bind
:sensor :hass.stable-climate.stable-climate-co2
:switch :hass.stablelight.stable-fans)
#++
(:tag-rules hass-rules))
(defmethod initial-state ((ctl stable-co2)) :disabled)
(define-state/cells ((ctl stable-co2) :disabled)
(:transitions
((and .enabled (< .sensor .high-co2-value)) :off)
(.enabled :on)))
(define-state/cells ((ctl stable-co2) :off (cell-wait-state))
(:enter
(setf .switch nil))
(:transitions
((not .enabled) :disabled)
(.switch :off)
((>= .sensor .high-co2-value) :on)))
(define-state/cells ((ctl stable-co2) :on (cell-wait-state))
(:enter
(setf .switch t))
(:transitions
((not .enabled) :disabled)
((not .switch) :on)
((< .sensor .low-co2-value) :off)))
#++
(define-cell-class stable-co2 (on-off-controller)
()
(:cells
(mode "Mode" (:type '((0 :off) (1 :heat))) (:value :heat)) ;; FIXME
(tolerance (:value 200d0))
(target (:value 1100d0)))
(:bind
:sensor :hass.stable-climate.stable-climate-co2
:switch :hass.stablelight.stable-fans))
(defun reg-adjuster (enabled input output &optional (delay nil delay-p))
(apply #'reg 'cell-adjuster
:name (symbolicate output 'adjuster)
:bind (list :enabled enabled
:input input
:output output)
(when delay-p (list :delay delay))))
(defun reg-adjusters (enabled &rest pairs)
(doplist (input output pairs)
(reg-adjuster enabled input output)))
(defparameter *action-mappings*
'(("light_floor_2_1/home_office_button_action" "doubleclick" :away-mode-off )
("light_floor_2_1/home_office_button_action" "longclick" :away-mode-on )
("light_floor_1_1/dining_room_1_button_action" "doubleclick" :outdoor-lights-on )
("light_floor_1_1/dining_room_1_button_action" "longclick" :outdoor-lights-off)))
(reg 'izba)
(reg 'izba-commander)
(reg 'livestock-temps :id "ba58e7ab-c647-44c1-b62c-01e28a2eecdd")
(reg 'stable-co2)
(reg 'mqtt-action-handler :action-mappings *action-mappings*)
;; (reg 'local-dev-test :id "584b13ba-6057-4b26-92c7-936c13a535c3")
;; (reg 'local-dev-test-2 :id "d3b0f8b2-93df-4ee8-a211-4ab92a39de3c")
#++
(reg 'multi-switch
:name 'tst-light
:cells '(:hass.light-floor-2-1.home-office-light
:hass.light-floor-2-2.hall-2nd-floor-light))
(reg 'relaxed-multi-switch
:name 'outdoor-lights
:cells '(:hass.light-floor-2-2.front-floodlight
#++ :hass.light-bbq.bbq-light
:hass.light-shack.shack-light
:hass.light-floor-1-2.porch-light
:hass.stablelight.stable-outdoor-light))
(reg 'relaxed-multi-switch
:name 'house-outdoor-lights
:cells '(:hass.light-floor-2-2.front-floodlight
#++ :hass.light-bbq.bbq-light
:hass.light-shack.shack-light
:hass.light-floor-1-2.porch-light))
(reg 'timed-condition
:name 'outdoor-lights-cond
:one-way-p t
:period 600
;; Only reactivate during the next day
;; FIXME: this is not very good in case of an accidental activation
;; :reactivate-delay (* 3600 12)
:bind '(.condition .izba.need-outdoor-lights
.output .outdoor-lights.is-on))
(reg 'boiler :parent-class 'mqtt-hass-discovery-client)
(reg 'multi-switch
:name 'dining-room-radiators
:cells '(:hass.boiler.radiator-1-dining-room-south-west
:hass.boiler.radiator-1-dining-room-north-west
:hass.boiler.radiator-1-kitchen))
(reg 'multi-switch
:name 'bedroom-1-radiators
:cells '(:hass.boiler.radiator-1-bedroom-north
:hass.boiler.radiator-1-bedroom-east))
(reg 'multi-switch
:name 'mishas-room-radiators
:cells '(:hass.boiler.radiator-2-mishas-room-south
:hass.boiler.radiator-2-mishas-room-west))
(reg 'multi-switch
:name 'bedroom-2-radiators
:cells '(:hass.boiler.radiator-2-bedroom-west
:hass.boiler.radiator-2-bedroom-north))
(reg 'timed-condition
:name 'no-radiator-circulation-cond
:period 1800d0
:active-value nil
;; TBD: should actually turn them off only when
;; bathroom temperature is above the specified threshold
:bind '(.condition .hass.boiler.circulation-not-needed-p
.output .hass.boiler.radiator-circulation-relay))
(reg 'timed-on-off
:name 'timed-on-off
:time-a '(8 0)
:time-b '(22 0)
:back-to-off-delay 1500d0
:bind '(:target-cell :hass.coop4ch.coop-light))
(reg 'room-temps :id "5727070d-4ac4-4ab0-aed6-f7deee843713")
(reg 'on-off-controller
:name 'lobby-temp-control
;; TBD: rm .izba-umbrella prefix!!!!
:bind '(:mode :room-temps.lobby-climate-mode
:target :room-temps.lobby-climate-temp
:sensor :hass.octopus-1stfloor.lobby
:switch :hass.boiler.radiator-1-lobby))
(reg 'on-off-controller
:name 'dining-room-temp-control
:bind '(:mode :room-temps.dining-room-climate-mode
:target :room-temps.dining-room-climate-temp
:sensor :hass.octopus-1stfloor.dining-room-temp
:switch :dining-room-radiators.is-on))
(reg 'on-off-controller
:name 'bedroom1-temp-control
:bind '(:mode :room-temps.bedroom1-climate-mode
:target :room-temps.bedroom1-climate-temp
:sensor :hass.octopus-1stfloor.bedroom
:switch :bedroom-1-radiators.is-on))
(reg 'on-off-controller
:name 'hall1-temp-control
:bind '(:mode :room-temps.hall1-climate-mode
:target :room-temps.hall1-climate-temp
:sensor :hass.octopus-1stfloor.hall
:switch :hass.boiler.radiator-1-hall))
(reg 'on-off-controller
:name 'mishas-room-temp-control
:bind '(:mode :room-temps.mishas-room-climate-mode
:target :room-temps.mishas-room-climate-temp
:sensor :hass.light-floor-2-1.temperature-in-mishas-room
:switch :mishas-room-radiators.is-on))
(reg 'on-off-controller
:name 'bedroom-2-temp-control
:bind '(:mode :room-temps.bedroom2-climate-mode
:target :room-temps.bedroom2-climate-temp
:sensor :hass.freshair.2nd-floor-bedroom-temperature
:switch :bedroom-2-radiators.is-on))
(reg 'on-off-controller
;; TBD: more tolerance
:name 'stable-temp-control
:bind '(:mode :livestock-temps.stable-climate-mode
:target :livestock-temps.stable-climate-temp
:sensor :hass.stable-climate.stable-climate-temperature
:switch :hass.socket1.smart-socket-1))
(reg-adjusters
:izba.auto-temps
:izba.dining-room-target-temp :room-temps.dining-room-climate-temp
:izba.hall-1-target-temp :room-temps.hall1-climate-temp
:izba.bedroom-1-target-temp :room-temps.bedroom1-climate-temp
:izba.bedroom-2-target-temp :room-temps.bedroom2-climate-temp
:izba.mishas-room-target-temp :room-temps.mishas-room-climate-temp
:izba.lobby-target-temp :room-temps.lobby-climate-temp
:izba.radiator-temp :hass.boiler.target-temp
;; FIXME: shouldn't be disabled with auto-temps
:hass.light-floor-1-1.dining-room-light-1 :hass.light-floor-1-2.dining-room-light-2)
;; the following is not good due to circulation control
;; (reg-adjuster :izba.auto-temps :izba.radiator-temp :hass.boiler.target-temp 1800d0)
(defun izba-startup (event-loop &optional (configs '()))
(apply #'load-config (alexandria:ensure-list configs))
(let* ((cs-view (make-instance 'cs-view :name 'izba-view :parent event-loop))
(umbrella (make-instance 'timed-umbrella
:name 'izba-umbrella
:parent cs-view
:model (cell-model-of cs-view))))
;; FIXME!!!!! umbrella setup should work w/o IN-CS-VIEW!!!
(in-cs-view cs-view)
(setf *pmc* (make-instance 'persistent-mqtt-client
:parent cs-view
:client-id "izba2"
:host *mqtt-server*
:port 1883)
*ds* (make-instance 'mqtt-data-source
:parent cs-view
:pmc *pmc*)
*mhdc* (make-instance 'mqtt-hass-discovery-client
:name 'hass
:pmc *pmc*
:parent cs-view
:model (cell-model-of cs-view)
;; TBD: this shouldn't be needed!
:ignore-devices '("local-dev-test"
"local-dev-test-2"
"eacs-room-temps"
"livestock-temps"))
;; *boiler* (make-instance 'boiler
;; :name 'boiler
;; :parent *mhdc*
;; :model (cell-model-of cs-view))
)
(setf (data-sink-model (make-instance 'mqtt-data-sink
:parent cs-view
:pmc *pmc*))
(cell-model-of cs-view))
(dolist (name (sort (hash-table-keys *to-create*) #'string<))
(destructuring-bind (class-name &rest initargs)
(gethash name *to-create*)
(apply #'make-instance
class-name
:parent (get-parent-from-initargs umbrella initargs)
:model (cell-model-of cs-view)
:name (getf initargs :name class-name)
(remove-from-plist initargs :name :parent-name :parent-class))))
#++
(pmc-subscribe *pmc* "/devices/milur305_255/controls/Phase A reactive power")
(cs-view-add-data-source cs-view *ds*)
#++
(make-instance 'roomtemp-control
:name 'roomtemp-control
:model *current-cell-model*
:parent cs-view)
(setf (active-p *pmc*) t)
#++
(untrace-cells 'room-temp 'water-temp)
#++
(trace-cells 'room-temp 'water-temp 'sum 'prev-room-temp 'target-room-temp)
#++
(fsm-trace 'roomtemp-control)
(fsm-trace 'timed-condition
'on-off-controller
'multi-switch
'relaxed-multi-switch
'timed-on-off)
(process-events *root*)))
(defun toplevel-izba (&optional conf-file)
(setf cs-common::*object-loop-guard* t
*fsm-trace-time* :real)
(as-repl:start-async-repl
#'(lambda ()
;; FIXME
(as:with-delay (0.3)
(setf #++ swank::*slime-repl-eval-hooks*
(symbol-value (cl-async-repl::repl-hook-sym))
(cons 'repl-hook
(remove 'repl-hook
(symbol-value (cl-async-repl::repl-hook-sym))
#++ swank::*slime-repl-eval-hooks*))))
(izba-startup
(setf *root* (get-async-event-loop))
conf-file)
;; FIXME: rm !!!
#++
(as:with-delay (10)
(all-visible nil)))))
;; FIXME
(defun repl-hook (form)
(declare (ignore form))
(when (running-p)
(cl-async-repl::sync-eval
'(ignore-errors
(as:with-delay (0.1)
(when *root*
(process-events *root*))))))
#++ (swank:repl-eval-hook-pass)
(funcall (find-symbol "REPL-EVAL-HOOK-PASS" :swank)))
(defun all-visible (&optional trace)
(let ((all-cells (all-cells)))
(dolist (c all-cells)
(setf (visible-via c t) t))
(apply (if trace
#'trace-cells
#'untrace-cells)
all-cells)))
(defun print-online (&optional substring (model *current-cell-model*))
(with-cell-model (model)
(format t "~:{~a~60t= ~a~%~}"
(iter (for c in (sort (all-cells) #'string<))
(when (and (or (null substring)
(search substring (cell-title c) :test #'equalp)
(search substring (string c) :test #'equalp))
(eq :online (cell-state c)))
(collect (list c
(let ((v (cell-value c)))
(if (floatp v)
(format nil "~,4f" v)
v)))))))))
#++
(pushnew 'repl-hook swank:*slime-repl-eval-hooks*)
#++
(toplevel-izba)
#++
(dolist (c (all-cells)) (setf (visible-via c t) t))
#++
(apply #'trace-cells (all-cells))
#++
(avadakedavra)
#++
(observe-cell :hass.boiler.mix-factor :watch
#'(lambda (c)
(declare (ignore c))
(print-online ".boiler.")))
(defun mqtt-notify (title message)
(pmc-publish *pmc* "hass-notify/ivan4th"
(st-json:write-json-to-string
(st-json:jso "title" title "message" message))))
;; valveY 15.04 mixFactor ~0.990
;; valveY 20.00 mixFactor ~0.986
;; valveY 30.00 mixFactor ~0.980
;; valveY 40.00 mixFactor ~0.974 ?? 0.879??
;; valveY 50.00 mixFactor ~0.923 (~0.938??)
;; valveY 60.00 mixFactor ~0.574 (?? too hot)
;; (- .temp-house-to-tank .temp-tank-to-house) = ~ 0.28 ~44 degC
;; (- .temp-house-to-tank .temp-tank-to-house) = ~ 0.33 ~33 degC
;; (- .temp-house-to-tank .temp-tank-to-house) = ~ 0.40 ~29 degC
;; (- .temp-tank-to-house .temp-tank-a) = ~ -1.06 -1.22 ~52.17 degC temp
;; make sure mqtt reconnect is working!
(defun intern-cells ()
(dolist (c (all-cells))
(intern (format nil ".~a" c))))
(defun o (type)
(object-by-class *root* type))
(defun oo (type)
(objects-by-class *root* type))
(defun obn (name)
(object-by-name *root* name))
(defun dump-object-tree (&optional (start-from *root*))
(labels ((rec (object level)
(format t "~v@T~s: ~s~@[ (state: ~s)~]~%"
(* level 2)
(type-of object)
(name object)
(when (typep object 'state-machine)
(fsm-state-name object (fsm-state object))))
(dolist (child (sort (copy-list (children object)) #'string<
:key #'(lambda (object)
(concat (type-of object)
" "
(name object)))))
(rec child (1+ level)))))
(rec start-from 0)))
(defvar *default-conf-file* #p"/etc/eacs.conf")
(defun save-server-image (path)
(save-image
path 4005
#'(lambda ()
(toplevel-izba
#++ ;; FIXME
(or (second #+ccl ccl:*command-line-argument-list*
#+sbcl sb-ext:*posix-argv*
#-(or sbcl ccl) nil)
*default-conf-file*)))))
;; TBD: !!!!! Rm ALL-VISIBLE call from TOPLEVEL-IZBA !!!!!
;; TBD: calculated cell deps not updated !!!!
;; .izba.radiator-temp ==> nil deps
;; (even though it does depend on a number of cells)
;; TBD: !!! stable climate: tolerance !!!
;; TBD: make it possible to specify initargs as cell values in DEFINE-CELL-CLASS
;; TBD: fix remote device naming (?)
;; Perhaps if the name has already hass.smth prefix,
;; don't add same hass. prefix to the cells
;; (but need to think about it)
;; TBD: in bind exprs, use dot notation instead of keywords
;; TBD: rm 2nd return value from (cx .smth)
;; also have multiple return values for each cell in (cx (dbg-show* .a .b .c))
;; TBD: replacing mqtt-action-handler breaks (observation on PMC)
;; TBD: redefining boiler shouldn't break radiator multi-switches
;; TBD: the context should be considered complete if only unaliased
;; fallback cells are present. Incomplete = aliased fallback cells
;; missing or they don't match
;; TBD: precision somehow not working for climate
;; TBD: action_topic support for climate (off, heating, cooling, drying, idle, fan)
;; TBD: add proper avail topic to the boiler
;; TBD: make REG safe to call from the repl!
;; TBD: ON-OFF-CONTROLLER: detect abnormal values
;; (e.g. zero), go to off (updating .MODE) and send an alert
;; TBD: don't use global vars!
;; TBD: call process-events
;; TBD: will topic for the boiler
;; TBD: :ignore-devices shouldn't be needed for MHDC!
;; TBD: get rid of MQTT-CLIENT error that pops up after calling REG on TIMED-ON-OFF
;; TBD: add a mode for auto-internining cells
;; TBD: T as avail topic to mark the cell "always avail"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment