A lot of internals as I tried to do a switch based logic

This commit is contained in:
2026-08-25 19:05:52 +03:00
parent 2730657952
commit 6bb96833e1
12 changed files with 234 additions and 55 deletions
+51 -1
View File
@@ -1,8 +1,10 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE Arrows #-}
module AFRP
( Mealy(..)
, eff
, withEntities
, Event(..)
, hold
, events
@@ -20,6 +22,7 @@ module AFRP
, lMerge
, Request(..)
, edge
, dropFirst
, duration
, tag
, isEvent
@@ -29,12 +32,14 @@ module AFRP
, sliding
, fixed
, debounce
, currentTime
, onEvent
) where
import Control.Category (Category(..), (>>>))
import Prelude hiding ((.), id)
import Control.Arrow (Arrow(..), ArrowChoice(..), ArrowLoop(..))
import Data.Time (UTCTime, NominalDiffTime, diffUTCTime, addUTCTime)
import Data.Time (UTCTime, NominalDiffTime, diffUTCTime, addUTCTime, TimeZone, LocalTime, utcToLocalTime)
import Control.Monad.Fix (MonadFix (mfix))
import Data.Either (fromLeft)
import Data.Bool (bool)
@@ -45,6 +50,7 @@ import qualified Data.Text as T
data Request = Request
{ requestTime :: !UTCTime
, requestTimeZone :: !TimeZone
, requestTraceId :: !UUID
} deriving Show
@@ -56,10 +62,27 @@ data Mealy eff a b = Mealy
, runMealy :: forall m. MonadFix m => (forall x. eff x -> m x) -> Request -> a -> m (b, Mealy eff a b)
}
instance Semigroup b => Semigroup (Mealy eff a b) where
Mealy ast af <> Mealy bst bf = Mealy (ast <> bst) $ \nt r a -> do
(x, af') <- af nt r a
(x', bf') <- bf nt r a
pure (x <> x', af' <> bf')
instance Monoid b => Monoid (Mealy eff a b) where
mempty = Mealy mempty $ \_ _ _ -> pure (mempty, mempty)
eff :: (Request -> a -> eff b) -> Mealy eff a b
eff f = Mealy mempty $ \nt req x ->
nt (f req x) >>= \b -> pure (b, eff f)
-- | Override the static entity set of an arrow. Use when a combinator
-- (e.g. 'switch') hides continuation entities from the runtime's
-- startup subscription scan.
withEntities :: S.Set T.Text -> Mealy eff a b -> Mealy eff a b
withEntities es (Mealy _ f) = Mealy es f
instance Category (Mealy eff) where
id = Mealy mempty (\_ _ x -> pure (x, id))
(Mealy ast f) . (Mealy bst g) = Mealy (ast <> bst) $ \nt t a -> do
@@ -102,6 +125,12 @@ data Event a
| Event a
deriving (Show, Eq, Functor, Foldable, Traversable)
instance Semigroup (Event a) where
(<>) = lMerge
instance Monoid (Event a) where
mempty = Tick
hold :: a -> Mealy eff (Event a) a
hold a = Mealy mempty $ \_ _ -> \case
Tick -> pure (a, hold a)
@@ -243,6 +272,18 @@ edge = go False
False -> pure (Tick, go False)
-- | Drop the first 'Event' and pass through everything after. Useful for
-- ignoring a self-triggered event (e.g. a service call that changes the
-- very entity the arrow listens to).
dropFirst :: Mealy eff (Event a) (Event a)
dropFirst = go False
where
go seen = Mealy mempty $ \_ _ input ->
case input of
Event _ | not seen -> pure (Tick, go True)
_ -> pure (input, go seen)
duration :: forall eff a. Mealy eff a NominalDiffTime
duration = mapAccumRequest go (Nothing @(UTCTime, NominalDiffTime)) (maybe 0 snd)
where
@@ -295,3 +336,12 @@ fixed seconds = mapAccumRequest go Nothing (maybe [] ((`appEndo` []) . snd))
| otherwise -> Just (end, acc)
Event a | requestTime req >= end -> Just (addUTCTime (fromIntegral seconds) end, e a)
| otherwise -> Just (end, acc <> e a)
currentTime :: Mealy eff a LocalTime
currentTime = Mealy mempty $ \_ Request{requestTime, requestTimeZone} _ ->
pure (utcToLocalTime requestTimeZone requestTime, currentTime)
onEvent :: Mealy eff a () -> Mealy eff (Event a) ()
onEvent f = events >>> (arr (const ()) ||| f)
+24 -5
View File
@@ -23,16 +23,18 @@ module HomeAssistant.Controller
, traceValue
, switch
, Target(..)
, brightness
, Light(..)
) where
import AFRP (Mealy (..), eff, Event(..), events, filterA, (>>|), toEvent, Request)
import Control.Arrow (Arrow(..), returnA)
import Control.Category ((>>>))
import Data.Aeson (Value)
import Data.Aeson (Value, object, (.=))
import qualified Data.Text as T
import qualified Data.Set as S
import Control.Lens (has, only, (^?), to)
import Data.Aeson.Lens (key, _String)
import Data.Aeson.Lens (key, _String, _Integral)
import qualified Data.Text.Lens as TL
import Data.Bool (bool)
@@ -84,12 +86,21 @@ presence entityId =entityBool entityId
data Light
= Off
| On { brightnessPercentage :: Maybe Double }
-- Turn off lights when door is closed
light :: [Target] -> Bool -> Service
light targets b = Service
light :: [Target] -> Light -> Service
light targets (On {brightnessPercentage}) = Service
{ serviceDomain="light"
, serviceName= bool "turn_off" "turn_on" b
, serviceName= "turn_on"
, serviceData=fmap (\pct -> object ["brightness_pct" .= pct]) brightnessPercentage
, serviceTarget=targets
}
light targets Off = Service
{ serviceDomain="light"
, serviceName= "turn_off"
, serviceData=Nothing
, serviceTarget=targets
}
@@ -132,3 +143,11 @@ entityRead entityId = entityRead' entityId >>> toEvent
entityBool :: T.Text -> Mealy eff (Event Value) (Event Bool)
entityBool entityId = entityBool' entityId >>> toEvent
brightness :: T.Text -> HASS (Event Value) (Event Int)
brightness entityId =
entityChangeEvent' entityId
>>| arr (maybe (Left ()) Right . eventBrightness)
>>> AFRP.toEvent
where
eventBrightness v = v ^? key "event" . key "variables" . key "trigger" . key "to_state" . key "attributes" . key "brightness" . _Integral
+3 -3
View File
@@ -46,7 +46,7 @@ bedroomPresenceController :: HASS (Event Value) ()
bedroomPresenceController = proc x -> do
p <- bedroomPresence -< x
case p of
Event Unoccupied -> callService createBedroomScene >>> callService (light bedroomLights False) -< ()
Event Unoccupied -> callService createBedroomScene >>> callService (light bedroomLights Off) -< ()
Event Occupied -> callService (activateScene "makuuhuone_lights_snapshot") -< ()
_ -> returnA -< ()
@@ -101,11 +101,11 @@ bedroomButtonController = proc x -> do
Event (Masse (OnButton ShortRelease)) -> callService (activateScene "scene.makuuhuone_masse") -< ()
Event (Masse (OnButton DoubleClick)) -> callService (activateScene "scene.makuuhuone_keski") -< ()
Event (Masse (OnButton LongClick)) -> callService (activateScene "scene.makuuhuone_kirkas") -< ()
Event (Masse (OffButton _)) -> callService (light [AreaId "makuuhuone"] False) -< ()
Event (Masse (OffButton _)) -> callService (light [AreaId "makuuhuone"] Off) -< ()
Event (Enishen (OnButton ShortRelease)) -> callService (activateScene "scene.makuuhuone_jemina") -< ()
Event (Enishen (OnButton DoubleClick)) -> callService (activateScene "scene.makuuhuone_keski") -< ()
Event (Enishen (OnButton LongClick)) -> callService (activateScene "scene.makuuhuone_kirkas") -< ()
Event (Enishen (OffButton _)) -> callService (light [AreaId "makuuhuone"] False) -< ()
Event (Enishen (OffButton _)) -> callService (light [AreaId "makuuhuone"] Off) -< ()
_ -> returnA -< ()
+75 -7
View File
@@ -1,14 +1,82 @@
{-# LANGUAGE Arrows #-}
{-# LANGUAGE OverloadedStrings #-}
module HomeAssistant.Controller.Children where
import HomeAssistant.Controller (HASS)
import HomeAssistant.Controller (HASS, callService, Target (AreaId), light, Light(..))
import AFRP (Event)
import Data.Aeson (Value)
import Control.Arrow (Arrow(..))
import qualified AFRP
import Control.Arrow (Arrow(..), (>>>))
import Data.Time (Day, TimeOfDay (..), localDay, LocalTime (..))
import Data.Time.Calendar.OrdinalDate (WeekOfYear, mondayStartWeek)
import Data.Functor.Contravariant (Predicate (..), (>$<))
-- Let's see building some reasonable interface for utctime
dow :: Day -> (WeekOfYear, Int)
dow = mondayStartWeek
weekday :: Predicate Day
weekday = Predicate (betweenInclusive 1 5 . snd . dow)
where
betweenInclusive a b c = c >= a && c <= b
time :: (Int, Int) -> Predicate TimeOfDay
time (h,m) = mconcat
[ Predicate (equals h . todHour)
, Predicate (equals m . todMin)
]
where
equals a b = a == b
atTime :: Predicate LocalTime -> HASS a (Event ())
atTime p = AFRP.currentTime
>>> arr (getPredicate p)
>>> AFRP.edge
-- I don't have any proper presence sensors in their bedroom
-- and they are notoriously bad at changing clothes in complete darkness
-- So I have set up an automation that attempts to turn on the lights sometime
-- before they leave for school and turns them off a bit later
--
-- I don't have enough primitives for this yet, leaving as a placeholder
schoolLightController :: HASS (Event Value) ()
schoolLightController = arr (const ())
-- Don't mconcat these predicates they have && behavior
-- if you mconcat the actual arrows, they combine the behaviors of the separate branches
-- essentially becoming || behavior
timersOff :: [Predicate LocalTime]
timersOff =
[ day 1 <> at (08,15)
, day 2 <> at (09,15)
, day 3 <> at (08,15)
, day 4 <> at (08,15)
, day 5 <> at (08,15)
, at (18,57) -- debug
]
where
dayOfWeek = snd . mondayStartWeek . localDay
at (h,m) = localTimeOfDay >$< Predicate (\TimeOfDay{todHour, todMin} -> todHour == h && todMin == m)
day n = dayOfWeek >$< Predicate (== n)
timersOn :: [Predicate LocalTime]
timersOn =
[ day 1 <> at (07,30)
, day 2 <> at (08,30)
, day 3 <> at (07,30)
, day 4 <> at (07,30)
, day 5 <> at (07,30)
, at (18,55) -- debug
]
where
dayOfWeek = snd . mondayStartWeek . localDay
at (h,m) = localTimeOfDay >$< Predicate (\TimeOfDay{todHour, todMin} -> todHour == h && todMin == m)
day n = dayOfWeek >$< Predicate (== n)
schoolLightController :: HASS a ()
schoolLightController = lightsOn <> lightsOff
lightsOn :: HASS a ()
lightsOn = foldMap atTime timersOn
>>> AFRP.onEvent (callService (light [AreaId "lasten_makuuhuone"] On{brightnessPercentage = Just 100}))
lightsOff :: HASS a ()
lightsOff = foldMap atTime timersOff
>>> AFRP.onEvent (callService (light [AreaId "lasten_makuuhuone"] Off))
+7 -4
View File
@@ -18,7 +18,7 @@ import Control.Concurrent.Async (async, waitAny)
import Control.Concurrent.STM (atomically, dupTChan, readTChan)
import Data.Aeson (Value)
import qualified Data.Text as T
import Data.Time (getCurrentTime)
import Data.Time (getCurrentTime, getCurrentTimeZone)
import Data.Void (Void, absurd)
import HomeAssistant.Controller (HASS, HASSEff (..))
import HomeAssistant.Runtime.Bus
@@ -33,11 +33,13 @@ import Katip (runKatipT, logF, sl, Severity (..), ls, Namespace (Namespace), run
import Control.Monad.IO.Class (liftIO, MonadIO)
import Control.Monad.Fix (MonadFix)
import HomeAssistant.Controller.Ruuvi (ruuviController)
import HomeAssistant.Controller.Children (schoolLightController)
step :: (MonadFix m, MonadIO m) => (forall x. eff x -> m x) -> UUID -> Mealy eff a b -> a -> m (b, Mealy eff a b)
step nt trace (Mealy _ f) a = do
now <- liftIO getCurrentTime
f nt (Request now trace) a
tz <- liftIO getCurrentTimeZone
f nt (Request now tz trace) a
data Controller = forall b. Controller T.Text (HASS (Event Value) b) Bool
@@ -47,7 +49,8 @@ controllers =
, Controller "bedroom-button" bedroomButtonController False -- This works but leaving for vacation
, Controller "bedroom-drawer" bedroomDrawerController True
, Controller "bedroom-humidifier" humidifierController False
, Controller "ruuvi-controller" ruuviController True
, Controller "ruuvi-controller" ruuviController False
, Controller "school-light-controller" schoolLightController True
]
-- | Steps the machine for every inbound message; service calls go to the
@@ -62,7 +65,7 @@ runController bus (Controller name machine _enabled) = do
msg <- atomically (readTChan inbound)
uuid <- UUID.V4.nextRandom
let ns = Namespace [name]
(_, f') <- step (runKatipContextT (busLogEnv bus) () ns . channelHassEval bus) uuid f (Event msg)
(_, f') <- step (runKatipContextT (busLogEnv bus) () ns . channelHassEval bus) uuid f msg
go inbound f'
defaultMain :: IO ()
+2 -2
View File
@@ -26,14 +26,14 @@ import Katip (LogEnv, closeScribes, mkHandleScribe, ColorStrategy (..), permitIt
import Control.Exception (bracket)
import System.IO (stdout)
import Data.UUID (toText)
import AFRP (Request(..))
import AFRP (Request(..), Event(..))
import Control.Monad.IO.Class (MonadIO, liftIO)
-- | Shared runtime state: inbound is a broadcast channel (controllers
-- read from 'dupTChan' copies), outbound queues service calls for the
-- writer, conn holds the current websocket (Nothing before first connect).
data Bus = Bus
{ busInbound :: TChan Value
{ busInbound :: TChan (Event Value)
, busOutbound :: TChan (Request, Service)
, busConn :: TVar (Maybe Connection)
, busGen :: CallIdGen
+13 -6
View File
@@ -15,13 +15,14 @@ import Control.Concurrent.STM
, writeTChan
, writeTVar
)
import Control.Concurrent.Async (race)
import Control.Concurrent (threadDelay)
import Control.Exception (onException)
import Control.Exception.Annotated (throw)
import Control.Lens ((^?))
import Control.Monad (forever, forM_)
import Data.Aeson (Value, eitherDecode, encode, object, (.=))
import Data.Aeson.Lens (key, _String)
import qualified Data.ByteString.Lazy as BL
import qualified Data.Set as S
import qualified Data.Text as T
import Data.Void (Void)
@@ -31,7 +32,7 @@ import HomeAssistant.Runtime.Supervisor (Fatal (..))
import qualified Network.WebSockets as WS
import Katip (runKatipContextT, sl, logFM, Severity (..), ls)
import Data.UUID (toText)
import AFRP (Request(..))
import AFRP (Request(..), Event(..))
-- | Connect, authenticate, subscribe, then receive and broadcast forever.
-- Restarting this action reconnects. All setup sends happen before the
@@ -79,12 +80,18 @@ subscribe bus conn ents =
-- | Undecodable messages are skipped: reconnecting cannot fix a decode
-- problem, so crashing here would only produce a hot restart loop.
--
-- Each read races a one-second timeout: a timeout broadcasts 'Tick' so
-- time-based primitives (debounce, rollup, fixed, ...) keep advancing
-- even when no state changes arrive.
receiveLoop :: Bus -> WS.Connection -> IO Void
receiveLoop bus conn = forever $ do
msg <- WS.receiveData conn :: IO BL.ByteString
case eitherDecode msg of
Left err -> putStrLn $ "[reader] skipping undecodable message: " <> err
Right v -> atomically $ writeTChan (busInbound bus) v
winner <- race (threadDelay 1000000) (WS.receiveData conn)
case winner of
Left () -> atomically $ writeTChan (busInbound bus) Tick
Right msg -> case eitherDecode msg of
Left err -> putStrLn $ "[reader] skipping undecodable message: " <> err
Right v -> atomically $ writeTChan (busInbound bus) (Event v)
receiveJSON :: WS.Connection -> IO Value
receiveJSON conn = do