1 Commits
Author SHA1 Message Date
MasseR a0030bf9c0 Test the bedroom spec 2026-08-25 11:54:38 +03:00
15 changed files with 176 additions and 589 deletions
+6 -6
View File
@@ -1,6 +1,6 @@
{ mkDerivation, aeson, annotated-exception, async, base, bytestring { mkDerivation, aeson, annotated-exception, async, base, bytestring
, containers, hedgehog, hspec, hspec-hedgehog, katip, lens , hedgehog, hspec, hspec-hedgehog, katip, lens, lens-aeson, lib
, lens-aeson, lib, network, stm, text, time, uuid, websockets , network, stm, text, time, uuid, websockets
}: }:
mkDerivation { mkDerivation {
pname = "home-assistant-controller"; pname = "home-assistant-controller";
@@ -9,13 +9,13 @@ mkDerivation {
isLibrary = true; isLibrary = true;
isExecutable = true; isExecutable = true;
libraryHaskellDepends = [ libraryHaskellDepends = [
aeson annotated-exception async base bytestring containers katip aeson annotated-exception async base bytestring katip lens
lens lens-aeson network stm text time uuid websockets lens-aeson network stm text time uuid websockets
]; ];
executableHaskellDepends = [ base ]; executableHaskellDepends = [ base ];
testHaskellDepends = [ testHaskellDepends = [
aeson annotated-exception async base containers hedgehog hspec aeson annotated-exception async base hedgehog hspec hspec-hedgehog
hspec-hedgehog katip stm text time uuid katip stm text time uuid
]; ];
license = lib.meta.getLicenseFromSpdxId "BSD-3-Clause"; license = lib.meta.getLicenseFromSpdxId "BSD-3-Clause";
mainProgram = "home-assistant-controller"; mainProgram = "home-assistant-controller";
+1 -4
View File
@@ -62,7 +62,6 @@ library
exposed-modules: AFRP exposed-modules: AFRP
, HomeAssistant.Controller , HomeAssistant.Controller
, HomeAssistant.Controller.Bedroom , HomeAssistant.Controller.Bedroom
, HomeAssistant.Controller.Children
, HomeAssistant.Controller.Ruuvi , HomeAssistant.Controller.Ruuvi
, HomeAssistant.Runtime , HomeAssistant.Runtime
, HomeAssistant.Runtime.Bus , HomeAssistant.Runtime.Bus
@@ -90,7 +89,6 @@ library
, annotated-exception , annotated-exception
, uuid , uuid
, katip , katip
, containers
-- Directories containing source files. -- Directories containing source files.
hs-source-dirs: src hs-source-dirs: src
@@ -166,5 +164,4 @@ test-suite home-assistant-controller-test
annotated-exception, annotated-exception,
time, time,
uuid, uuid,
katip, katip
containers
+51 -121
View File
@@ -1,10 +1,8 @@
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE LambdaCase #-}
{-# LANGUAGE Arrows #-}
module AFRP module AFRP
( Mealy(..) ( Mealy(..)
, eff , eff
, withEntities
, Event(..) , Event(..)
, hold , hold
, events , events
@@ -20,10 +18,8 @@ module AFRP
, (>>|) , (>>|)
, toEvent , toEvent
, lMerge , lMerge
, Pair(..)
, Request(..) , Request(..)
, edge , edge
, dropFirst
, duration , duration
, tag , tag
, isEvent , isEvent
@@ -33,122 +29,77 @@ module AFRP
, sliding , sliding
, fixed , fixed
, debounce , debounce
, currentTime
, onEvent
) where ) where
import Control.Category (Category(..), (>>>)) import Control.Category (Category(..), (>>>))
import Prelude hiding ((.), id) import Prelude hiding ((.), id)
import Control.Arrow (Arrow(..), ArrowChoice(..), ArrowLoop(..)) import Control.Arrow (Arrow(..), ArrowChoice(..), ArrowLoop(..))
import Data.Time (UTCTime, NominalDiffTime, diffUTCTime, addUTCTime, TimeZone, LocalTime, utcToLocalTime) import Data.Time (UTCTime, NominalDiffTime, diffUTCTime, addUTCTime)
import Control.Monad.Fix (MonadFix (mfix)) import Control.Monad.Fix (MonadFix (mfix))
import Data.Either (fromLeft) import Data.Either (fromLeft)
import Data.Bool (bool) import Data.Bool (bool)
import Data.Monoid (Endo(..)) import Data.Monoid (Endo(..))
import Data.UUID (UUID) import Data.UUID (UUID)
import qualified Data.Set as S
import qualified Data.Text as T
data Pair a b = Pair !a !b
data Request = Request data Request = Request
{ requestTime :: !UTCTime { requestTime :: !UTCTime
, requestTimeZone :: !TimeZone
, requestTraceId :: !UUID , requestTraceId :: !UUID
} deriving (Show, Eq)
-- | The set of entity ids an arrow subscribes to. Static: it does not
-- change as the machine steps, so the runtime can read it once to build
-- trigger subscriptions.
data Mealy eff a b = Mealy
{ entities :: S.Set T.Text
, runMealy :: forall m. MonadFix m => (forall x. eff x -> m x) -> Request -> a -> m (Pair b (Mealy eff a b))
} }
deriving Show
newtype Mealy eff a b = Mealy
instance Semigroup b => Semigroup (Mealy eff a b) where { runMealy :: forall m. MonadFix m => (forall x. eff x -> m x) -> Request -> a -> m (b, Mealy eff a b) }
Mealy ast af <> Mealy bst bf = Mealy (ast <> bst) $ \nt r a -> do
Pair x af' <- af nt r a
Pair x' bf' <- bf nt r a
pure (Pair (x <> x') (af' <> bf'))
instance Monoid b => Monoid (Mealy eff a b) where
mempty = m
where
m = Mealy mempty $ \_ _ _ -> pure (Pair mempty m)
eff :: (Request -> a -> eff b) -> Mealy eff a b eff :: (Request -> a -> eff b) -> Mealy eff a b
eff f = m eff f = Mealy $ \nt req x ->
where nt (f req x) >>= \b -> pure (b, eff f)
m = Mealy mempty $ \nt req x ->
nt (f req x) >>= \b -> pure (Pair b m)
-- | 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 instance Category (Mealy eff) where
id = Mealy mempty (\_ _ x -> pure (Pair x id)) id = Mealy (\_ _ x -> pure (x, id))
(Mealy ast f) . (Mealy bst g) = Mealy (ast <> bst) $ \nt t a -> do (Mealy f) . (Mealy g) = Mealy $ \nt t a -> do
Pair b g' <- g nt t a (b, g') <- g nt t a
Pair c f' <- f nt t b (c, f') <- f nt t b
pure (Pair c (f' . g')) pure (c, f' . g')
instance Arrow (Mealy eff) where instance Arrow (Mealy eff) where
arr f = mealy arr f = Mealy $ \_ _ b -> pure (f b, arr f)
where first (Mealy f) = Mealy $ \nt t (b,d) -> do
mealy = Mealy mempty $ \_ _ b -> pure (Pair (f b) mealy) (c, f') <- f nt t b
first (Mealy st f) = Mealy st $ \nt t (b,d) -> do pure ((c, d), first f')
Pair c f' <- f nt t b
pure (Pair (c, d) (first f'))
instance ArrowChoice (Mealy eff) where instance ArrowChoice (Mealy eff) where
left (Mealy st f) = lm left (Mealy f) = Mealy $ \nt t -> \case
where
lm = Mealy st $ \nt t -> \case
Left b -> do Left b -> do
Pair c f' <- f nt t b (c, f') <- f nt t b
pure (Pair (Left c) (left f')) pure (Left c, left f')
Right d -> pure (Pair (Right d) lm) Right d -> pure (Right d, left (Mealy f))
-- ArrowLoop is incompatible with strict Pair (strict fields prevent instance ArrowLoop (Mealy eff) where
-- the lazy knot-tying that mfix requires with loop). loop (Mealy f) = Mealy $ \nt t b -> do
-- instance ArrowLoop (Mealy eff) where ((c,_), f') <- mfix $ \((_,d), _) -> f nt t (b,d)
-- loop (Mealy st f) = Mealy st $ \nt t b -> do pure (c, loop f')
-- Pair (c,_) f' <- mfix $ \(Pair (_,d) _) -> f nt t (b,d)
-- pure (Pair c (loop f'))
instance Functor (Mealy eff a) where instance Functor (Mealy eff a) where
fmap f (Mealy st g) = Mealy st $ \nt t a -> do fmap f (Mealy g) = Mealy $ \nt t a -> do
Pair b g' <- g nt t a (b, g') <- g nt t a
pure (Pair (f b) (fmap f g')) pure (f b, fmap f g')
instance Applicative (Mealy eff a) where instance Applicative (Mealy eff a) where
pure b = Mealy mempty $ \_ _ _ -> pure (Pair b (pure b)) pure b = Mealy $ \_ _ _ -> pure (b, pure b)
Mealy ast f <*> Mealy bst x = Mealy (ast <> bst) $ \nt t a -> do Mealy f <*> Mealy x = Mealy $ \nt t a -> do
b' <- x nt t a (f', fNext) <- f nt t a
let Pair x' xNext = b' (x', xNext) <- x nt t a
Pair f' fNext <- f nt t a pure (f' x', fNext <*> xNext)
pure (Pair (f' x') (fNext <*> xNext))
data Event a data Event a
= Tick = Tick
| Event a | Event a
deriving (Show, Eq, Functor, Foldable, Traversable) 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 eff (Event a) a
hold a = Mealy mempty $ \_ _ -> \case hold a = Mealy $ \_ _ -> \case
Tick -> pure (Pair a (hold a)) Tick -> pure (a, hold a)
Event a' -> pure (Pair a' (hold a')) Event a' -> pure (a', hold a')
events :: Mealy eff (Event a) (Either () a) events :: Mealy eff (Event a) (Either () a)
events = arr $ \case events = arr $ \case
@@ -163,10 +114,10 @@ tag :: b -> Event a -> Event b
tag b ev = b <$ ev tag b ev = b <$ ev
switch :: Mealy eff a (b, Event c) -> (c -> Mealy eff a b) -> Mealy eff a b switch :: Mealy eff a (b, Event c) -> (c -> Mealy eff a b) -> Mealy eff a b
switch (Mealy st f) s = Mealy st $ \nt t a -> do switch (Mealy f) s = Mealy $ \nt t a -> do
Pair (b, ev) f' <- f nt t a ((b, ev), f') <- f nt t a
case ev of case ev of
Tick -> pure (Pair b (switch f' s)) Tick -> pure (b, switch f' s)
Event x -> runMealy (s x) nt t a Event x -> runMealy (s x) nt t a
sample :: Mealy eff (a, Event b) (Event a) sample :: Mealy eff (a, Event b) (Event a)
@@ -175,30 +126,30 @@ sample = arr (uncurry tag)
preMapAccum :: (x -> a -> x) -> x -> (x -> b) -> Mealy eff a b preMapAccum :: (x -> a -> x) -> x -> (x -> b) -> Mealy eff a b
preMapAccum f x extract = go x preMapAccum f x extract = go x
where where
go b = Mealy mempty $ \_ _ a -> go b = Mealy $ \_ _ a ->
let next = f b a let next = f b a
in pure (Pair (extract b) (go next)) in pure (extract b, go next)
preMapAccumRequest :: (Request -> x -> a -> x) -> x -> (x -> b) -> Mealy eff a b preMapAccumRequest :: (Request -> x -> a -> x) -> x -> (x -> b) -> Mealy eff a b
preMapAccumRequest f x extract = go x preMapAccumRequest f x extract = go x
where where
go b = Mealy mempty $ \_ t a -> go b = Mealy $ \_ t a ->
let next = f t b a let next = f t b a
in pure (Pair (extract b) (go next)) in pure (extract b, go next)
mapAccum :: (x -> a -> x) -> x -> (x -> b) -> Mealy eff a b mapAccum :: (x -> a -> x) -> x -> (x -> b) -> Mealy eff a b
mapAccum f x extract = go x mapAccum f x extract = go x
where where
go b = Mealy mempty $ \_ _ a -> go b = Mealy $ \_ _ a ->
let next = f b a let next = f b a
in pure (Pair (extract next) (go next)) in pure (extract next, go next)
mapAccumRequest :: (Request -> x -> a -> x) -> x -> (x -> b) -> Mealy eff a b mapAccumRequest :: (Request -> x -> a -> x) -> x -> (x -> b) -> Mealy eff a b
mapAccumRequest f x extract = go x mapAccumRequest f x extract = go x
where where
go b = Mealy mempty $ \_ t a -> go b = Mealy $ \_ t a ->
let next = f t b a let next = f t b a
in pure (Pair (extract next) (go next)) in pure (extract next, go next)
data DelayState x a = DelayState data DelayState x a = DelayState
{ pending :: x { pending :: x
@@ -278,24 +229,12 @@ lMerge Tick (Event a) = Event a
edge :: Mealy eff Bool (Event ()) edge :: Mealy eff Bool (Event ())
edge = go False edge = go False
where where
go True = Mealy mempty $ \_ _ -> \case go True = Mealy $ \_ _ -> \case
True -> pure (Pair Tick (go True)) True -> pure (Tick, go True)
False -> pure (Pair Tick (go False)) False -> pure (Tick, go False)
go False = Mealy mempty $ \_ _ -> \case go False = Mealy $ \_ _ -> \case
True -> pure (Pair (Event ()) (go True)) True -> pure (Event (), go True)
False -> pure (Pair Tick (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 (Pair Tick (go True))
_ -> pure (Pair input (go seen))
duration :: forall eff a. Mealy eff a NominalDiffTime duration :: forall eff a. Mealy eff a NominalDiffTime
@@ -350,12 +289,3 @@ fixed seconds = mapAccumRequest go Nothing (maybe [] ((`appEndo` []) . snd))
| otherwise -> Just (end, acc) | otherwise -> Just (end, acc)
Event a | requestTime req >= end -> Just (addUTCTime (fromIntegral seconds) end, e a) Event a | requestTime req >= end -> Just (addUTCTime (fromIntegral seconds) end, e a)
| otherwise -> Just (end, acc <> e a) | otherwise -> Just (end, acc <> e a)
currentTime :: Mealy eff a LocalTime
currentTime = Mealy mempty $ \_ Request{requestTime, requestTimeZone} _ ->
pure (Pair (utcToLocalTime requestTimeZone requestTime) currentTime)
onEvent :: Mealy eff a () -> Mealy eff (Event a) ()
onEvent f = events >>> (arr (const ()) ||| f)
+14 -36
View File
@@ -23,23 +23,20 @@ module HomeAssistant.Controller
, traceValue , traceValue
, switch , switch
, Target(..) , Target(..)
, brightness
, Light(..)
) where ) where
import AFRP (Mealy (..), Pair (..), eff, Event(..), events, filterA, (>>|), toEvent, Request) import AFRP (Mealy (..), eff, Event(..), events, filterA, (>>|), toEvent, Request)
import Control.Arrow (Arrow(..), returnA) import Control.Arrow (Arrow(..), returnA)
import Control.Category ((>>>)) import Control.Category ((>>>))
import Data.Aeson (Value, object, (.=)) import Data.Aeson (Value)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Set as S
import Control.Lens (has, only, (^?), to) import Control.Lens (has, only, (^?), to)
import Data.Aeson.Lens (key, _String, _Integral) import Data.Aeson.Lens (key, _String)
import qualified Data.Text.Lens as TL import qualified Data.Text.Lens as TL
import Data.Bool (bool) import Data.Bool (bool)
data Target = EntityId !T.Text | AreaId !T.Text data Target = EntityId !T.Text | AreaId !T.Text
deriving (Show,Eq,Ord) deriving (Show,Eq)
data Service = Service data Service = Service
{ serviceDomain :: T.Text { serviceDomain :: T.Text
@@ -65,11 +62,9 @@ debug = proc x -> do
returnA -< x returnA -< x
traceEvent :: Show a => HASS (Event a) (Event a) traceEvent :: Show a => HASS (Event a) (Event a)
traceEvent = m traceEvent = Mealy $ \nt req -> \case
where Event a -> nt (Trace req a) >>= \() -> pure (Event a, traceEvent)
m = Mealy mempty $ \nt req -> \case Tick -> pure (Tick, traceEvent)
Event a -> nt (Trace req a) >>= \() -> pure (Pair (Event a) m)
Tick -> pure (Pair Tick m)
traceValue :: Show a => HASS a a traceValue :: Show a => HASS a a
traceValue = proc x -> do traceValue = proc x -> do
@@ -88,21 +83,12 @@ presence entityId =entityBool entityId
data Light
= Off
| On { brightnessPercentage :: Maybe Double }
-- Turn off lights when door is closed -- Turn off lights when door is closed
light :: [Target] -> Light -> Service light :: [Target] -> Bool -> Service
light targets (On {brightnessPercentage}) = Service light targets b = Service
{ serviceDomain="light" { serviceDomain="light"
, serviceName= "turn_on" , serviceName= bool "turn_off" "turn_on" b
, serviceData=fmap (\pct -> object ["brightness_pct" .= pct]) brightnessPercentage
, serviceTarget=targets
}
light targets Off = Service
{ serviceDomain="light"
, serviceName= "turn_off"
, serviceData=Nothing , serviceData=Nothing
, serviceTarget=targets , serviceTarget=targets
} }
@@ -121,20 +107,20 @@ entityChangeEvent :: T.Text -> Mealy eff (Event Value) (Event Value)
entityChangeEvent entityId = entityChangeEvent' entityId >>> toEvent entityChangeEvent entityId = entityChangeEvent' entityId >>> toEvent
entityChangeEvent' :: T.Text -> Mealy eff (Event Value) (Either () Value) entityChangeEvent' :: T.Text -> Mealy eff (Event Value) (Either () Value)
entityChangeEvent' entityId = Mealy (S.singleton entityId) $ runMealy (events >>| filterA isEntity) entityChangeEvent' entityId = events >>| filterA isEntity
where where
isEntity :: Value -> Bool isEntity :: Value -> Bool
isEntity = has (key "event" . key "variables" . key "trigger" . key "entity_id" . _String . only entityId) isEntity = has (key "event" . key "data" . key "entity_id" . _String . only entityId)
entityRead' :: (Read a) => T.Text -> Mealy eff (Event Value) (Either () a) entityRead' :: (Read a) => T.Text -> Mealy eff (Event Value) (Either () a)
entityRead' entityId = entityChangeEvent' entityId >>| (arr state >>> arr (maybe (Left ()) Right)) entityRead' entityId = entityChangeEvent' entityId >>| (arr state >>> arr (maybe (Left ()) Right))
where where
state v = v ^? key "event" . key "variables" . key "trigger" . key "to_state" . key "state" . _String . TL.unpacked . to read state v = v ^? key "event" . key "data" . key "new_state" . key "state" . _String . TL.unpacked . to read
entityBool' :: T.Text -> Mealy eff (Event Value) (Either () Bool) entityBool' :: T.Text -> Mealy eff (Event Value) (Either () Bool)
entityBool' entityId = entityChangeEvent' entityId >>| (arr state >>> arr (maybe (Left ()) Right)) entityBool' entityId = entityChangeEvent' entityId >>| (arr state >>> arr (maybe (Left ()) Right))
where where
state v = v ^? key "event" . key "variables" . key "trigger" . key "to_state" . key "state" . _String . TL.unpacked . to toBool state v = v ^? key "event" . key "data" . key "new_state" . key "state" . _String . TL.unpacked . to toBool
toBool = \case toBool = \case
"on" -> True "on" -> True
"off" -> False "off" -> False
@@ -145,11 +131,3 @@ entityRead entityId = entityRead' entityId >>> toEvent
entityBool :: T.Text -> Mealy eff (Event Value) (Event Bool) entityBool :: T.Text -> Mealy eff (Event Value) (Event Bool)
entityBool entityId = entityBool' entityId >>> toEvent 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
+4 -4
View File
@@ -46,7 +46,7 @@ bedroomPresenceController :: HASS (Event Value) ()
bedroomPresenceController = proc x -> do bedroomPresenceController = proc x -> do
p <- bedroomPresence -< x p <- bedroomPresence -< x
case p of case p of
Event Unoccupied -> callService createBedroomScene >>> callService (light bedroomLights Off) -< () Event Unoccupied -> callService createBedroomScene >>> callService (light bedroomLights False) -< ()
Event Occupied -> callService (activateScene "makuuhuone_lights_snapshot") -< () Event Occupied -> callService (activateScene "makuuhuone_lights_snapshot") -< ()
_ -> returnA -< () _ -> returnA -< ()
@@ -79,7 +79,7 @@ ikeaQuickButton entityId =
>>| arr (maybe (Left ()) Right . eventType) >>| arr (maybe (Left ()) Right . eventType)
>>> toEvent >>> toEvent
where where
eventType v = v ^? key "event" . key "variables" . key "trigger" . key "to_state" . key "attributes" . key "event_type" . _String . to toIkeaQuickButton . traversed eventType v = v ^? key "event" . key "data" . key "new_state" . key "attributes" . key "event_type" . _String . to toIkeaQuickButton . traversed
data BedroomControls data BedroomControls
@@ -101,11 +101,11 @@ bedroomButtonController = proc x -> do
Event (Masse (OnButton ShortRelease)) -> callService (activateScene "scene.makuuhuone_masse") -< () Event (Masse (OnButton ShortRelease)) -> callService (activateScene "scene.makuuhuone_masse") -< ()
Event (Masse (OnButton DoubleClick)) -> callService (activateScene "scene.makuuhuone_keski") -< () Event (Masse (OnButton DoubleClick)) -> callService (activateScene "scene.makuuhuone_keski") -< ()
Event (Masse (OnButton LongClick)) -> callService (activateScene "scene.makuuhuone_kirkas") -< () Event (Masse (OnButton LongClick)) -> callService (activateScene "scene.makuuhuone_kirkas") -< ()
Event (Masse (OffButton _)) -> callService (light [AreaId "makuuhuone"] Off) -< () Event (Masse (OffButton _)) -> callService (light [AreaId "makuuhuone"] False) -< ()
Event (Enishen (OnButton ShortRelease)) -> callService (activateScene "scene.makuuhuone_jemina") -< () Event (Enishen (OnButton ShortRelease)) -> callService (activateScene "scene.makuuhuone_jemina") -< ()
Event (Enishen (OnButton DoubleClick)) -> callService (activateScene "scene.makuuhuone_keski") -< () Event (Enishen (OnButton DoubleClick)) -> callService (activateScene "scene.makuuhuone_keski") -< ()
Event (Enishen (OnButton LongClick)) -> callService (activateScene "scene.makuuhuone_kirkas") -< () Event (Enishen (OnButton LongClick)) -> callService (activateScene "scene.makuuhuone_kirkas") -< ()
Event (Enishen (OffButton _)) -> callService (light [AreaId "makuuhuone"] Off) -< () Event (Enishen (OffButton _)) -> callService (light [AreaId "makuuhuone"] False) -< ()
_ -> returnA -< () _ -> returnA -< ()
-82
View File
@@ -1,82 +0,0 @@
{-# LANGUAGE Arrows #-}
{-# LANGUAGE OverloadedStrings #-}
module HomeAssistant.Controller.Children where
import HomeAssistant.Controller (HASS, callService, Target (AreaId), light, Light(..))
import AFRP (Event)
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
-- 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))
+8 -13
View File
@@ -13,12 +13,12 @@ module HomeAssistant.Runtime
, runController , runController
) where ) where
import AFRP (Event (..), Mealy (..), Pair (..), Request (..)) import AFRP (Event (..), Mealy (..), Request (..))
import Control.Concurrent.Async (async, waitAny) import Control.Concurrent.Async (async, waitAny)
import Control.Concurrent.STM (atomically, dupTChan, readTChan) import Control.Concurrent.STM (atomically, dupTChan, readTChan)
import Data.Aeson (Value) import Data.Aeson (Value)
import qualified Data.Text as T import qualified Data.Text as T
import Data.Time (getCurrentTime, getCurrentTimeZone) import Data.Time (getCurrentTime)
import Data.Void (Void, absurd) import Data.Void (Void, absurd)
import HomeAssistant.Controller (HASS, HASSEff (..)) import HomeAssistant.Controller (HASS, HASSEff (..))
import HomeAssistant.Runtime.Bus import HomeAssistant.Runtime.Bus
@@ -33,13 +33,11 @@ import Katip (runKatipT, logF, sl, Severity (..), ls, Namespace (Namespace), run
import Control.Monad.IO.Class (liftIO, MonadIO) import Control.Monad.IO.Class (liftIO, MonadIO)
import Control.Monad.Fix (MonadFix) import Control.Monad.Fix (MonadFix)
import HomeAssistant.Controller.Ruuvi (ruuviController) 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 (Pair b (Mealy eff a b)) 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 step nt trace (Mealy f) a = do
now <- liftIO getCurrentTime now <- liftIO getCurrentTime
tz <- liftIO getCurrentTimeZone f nt (Request now trace) a
f nt (Request now tz trace) a
data Controller = forall b. Controller T.Text (HASS (Event Value) b) Bool data Controller = forall b. Controller T.Text (HASS (Event Value) b) Bool
@@ -50,7 +48,6 @@ controllers =
, Controller "bedroom-drawer" bedroomDrawerController True , Controller "bedroom-drawer" bedroomDrawerController True
, Controller "bedroom-humidifier" humidifierController False , Controller "bedroom-humidifier" humidifierController False
, Controller "ruuvi-controller" ruuviController False , Controller "ruuvi-controller" ruuviController False
, Controller "school-light-controller" schoolLightController True
] ]
-- | Steps the machine for every inbound message; service calls go to the -- | Steps the machine for every inbound message; service calls go to the
@@ -65,7 +62,7 @@ runController bus (Controller name machine _enabled) = do
msg <- atomically (readTChan inbound) msg <- atomically (readTChan inbound)
uuid <- UUID.V4.nextRandom uuid <- UUID.V4.nextRandom
let ns = Namespace [name] let ns = Namespace [name]
Pair _ f' <- step (runKatipContextT (busLogEnv bus) () ns . channelHassEval bus) uuid f msg (_, f') <- step (runKatipContextT (busLogEnv bus) () ns . channelHassEval bus) uuid f (Event msg)
go inbound f' go inbound f'
defaultMain :: IO () defaultMain :: IO ()
@@ -74,10 +71,8 @@ defaultMain = withSocketsDo $ do
withBus severity $ \bus -> do withBus severity $ \bus -> do
token <- getEnv "HA_TOKEN" token <- getEnv "HA_TOKEN"
host <- getEnv "HA_HOST" host <- getEnv "HA_HOST"
let active = [c | c@(Controller _ _ True) <- controllers] let workers =
ents = foldMap (\(Controller _ m _) -> entities m) active [ ("reader", readerAction host 8123 token bus)
workers =
[ ("reader", readerAction host 8123 token ents bus)
, ("writer", writerAction bus) , ("writer", writerAction bus)
] ++ [ (name, runController bus c) | c@(Controller name _ True) <- controllers ] ] ++ [ (name, runController bus c) | c@(Controller name _ True) <- controllers ]
as <- mapM (\(name, act) -> async (supervised name defaultBackoff act)) workers as <- mapM (\(name, act) -> async (supervised name defaultBackoff act)) workers
+2 -2
View File
@@ -26,14 +26,14 @@ import Katip (LogEnv, closeScribes, mkHandleScribe, ColorStrategy (..), permitIt
import Control.Exception (bracket) import Control.Exception (bracket)
import System.IO (stdout) import System.IO (stdout)
import Data.UUID (toText) import Data.UUID (toText)
import AFRP (Request(..), Event(..)) import AFRP (Request(..))
import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Monad.IO.Class (MonadIO, liftIO)
-- | Shared runtime state: inbound is a broadcast channel (controllers -- | Shared runtime state: inbound is a broadcast channel (controllers
-- read from 'dupTChan' copies), outbound queues service calls for the -- read from 'dupTChan' copies), outbound queues service calls for the
-- writer, conn holds the current websocket (Nothing before first connect). -- writer, conn holds the current websocket (Nothing before first connect).
data Bus = Bus data Bus = Bus
{ busInbound :: TChan (Event Value) { busInbound :: TChan Value
, busOutbound :: TChan (Request, Service) , busOutbound :: TChan (Request, Service)
, busConn :: TVar (Maybe Connection) , busConn :: TVar (Maybe Connection)
, busGen :: CallIdGen , busGen :: CallIdGen
+18 -73
View File
@@ -5,30 +5,23 @@ module HomeAssistant.Runtime.Connection
( readerAction ( readerAction
, writerAction , writerAction
, encodeService , encodeService
, dedupeBatch
) where ) where
import Control.Concurrent.STM import Control.Concurrent.STM
( TChan ( atomically
, atomically
, readTChan , readTChan
, readTVar , readTVar
, retry , retry
, tryReadTChan
, writeTChan , writeTChan
, writeTVar , writeTVar
) )
import Control.Concurrent.Async (race)
import Control.Concurrent (threadDelay)
import Control.Exception (onException) import Control.Exception (onException)
import Control.Exception.Annotated (throw) import Control.Exception.Annotated (throw)
import Control.Lens ((^?)) import Control.Lens ((^?))
import Control.Monad (forever, forM_) import Control.Monad (forever)
import Data.Aeson (Value, eitherDecode, encode, object, (.=)) import Data.Aeson (Value, eitherDecode, encode, object, (.=))
import Data.Aeson.Lens (key, _String) import Data.Aeson.Lens (key, _String)
import Data.List (sort) import qualified Data.ByteString.Lazy as BL
import qualified Data.Map.Strict as M
import qualified Data.Set as S
import qualified Data.Text as T import qualified Data.Text as T
import Data.Void (Void) import Data.Void (Void)
import HomeAssistant.Controller (Service (..), Target (..)) import HomeAssistant.Controller (Service (..), Target (..))
@@ -37,16 +30,16 @@ import HomeAssistant.Runtime.Supervisor (Fatal (..))
import qualified Network.WebSockets as WS import qualified Network.WebSockets as WS
import Katip (runKatipContextT, sl, logFM, Severity (..), ls) import Katip (runKatipContextT, sl, logFM, Severity (..), ls)
import Data.UUID (toText) import Data.UUID (toText)
import AFRP (Request(..), Event(..)) import AFRP (Request(..))
-- | Connect, authenticate, subscribe, then receive and broadcast forever. -- | Connect, authenticate, subscribe, then receive and broadcast forever.
-- Restarting this action reconnects. All setup sends happen before the -- Restarting this action reconnects. All setup sends happen before the
-- connection is published in the bus, so only the writer sends afterwards. -- connection is published in the bus, so only the writer sends afterwards.
readerAction :: String -> Int -> String -> S.Set T.Text -> Bus -> IO Void readerAction :: String -> Int -> String -> Bus -> IO Void
readerAction host port token ents bus = readerAction host port token bus =
WS.runClient host port "/api/websocket" $ \conn -> do WS.runClient host port "/api/websocket" $ \conn -> do
handshake conn token handshake conn token
subscribe bus conn ents subscribe bus conn
atomically $ writeTVar (busConn bus) (Just conn) atomically $ writeTVar (busConn bus) (Just conn)
putStrLn "[reader] connected" putStrLn "[reader] connected"
-- Unpublish on exit so the writer blocks and the backlog survives the outage. -- Unpublish on exit so the writer blocks and the backlog survives the outage.
@@ -69,34 +62,23 @@ expectType expected msg =
Just t | t == expected -> pure () Just t | t == expected -> pure ()
_ -> throw (Fatal $ "expected " <> expected <> ", got: " <> T.pack (show msg)) _ -> throw (Fatal $ "expected " <> expected <> ", got: " <> T.pack (show msg))
subscribe :: Bus -> WS.Connection -> S.Set T.Text -> IO () subscribe :: Bus -> WS.Connection -> IO ()
subscribe bus conn ents = subscribe bus conn = do
forM_ (S.toList ents) $ \entityId -> do
print entityId
sid <- generateCallId (busGen bus) sid <- generateCallId (busGen bus)
WS.sendTextData conn $ encode $ object WS.sendTextData conn $ encode $ object
[ "id" .= sid [ "id" .= sid
, "type" .= ("subscribe_trigger" :: T.Text) , "type" .= ("subscribe_events" :: T.Text)
, "trigger" .= object , "event_type" .= ("state_changed" :: T.Text)
[ "platform" .= ("state" :: T.Text)
, "entity_id" .= entityId
]
] ]
-- | Undecodable messages are skipped: reconnecting cannot fix a decode -- | Undecodable messages are skipped: reconnecting cannot fix a decode
-- problem, so crashing here would only produce a hot restart loop. -- 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 -> WS.Connection -> IO Void
receiveLoop bus conn = forever $ do receiveLoop bus conn = forever $ do
winner <- race (threadDelay 1_000_000) (WS.receiveData conn) msg <- WS.receiveData conn :: IO BL.ByteString
case winner of case eitherDecode msg of
Left () -> atomically $ writeTChan (busInbound bus) Tick
Right msg -> case eitherDecode msg of
Left err -> putStrLn $ "[reader] skipping undecodable message: " <> err Left err -> putStrLn $ "[reader] skipping undecodable message: " <> err
Right v -> atomically $ writeTChan (busInbound bus) (Event v) Right v -> atomically $ writeTChan (busInbound bus) v
receiveJSON :: WS.Connection -> IO Value receiveJSON :: WS.Connection -> IO Value
receiveJSON conn = do receiveJSON conn = do
@@ -105,41 +87,16 @@ receiveJSON conn = do
Left err -> throw (Fatal $ "Invalid JSON from Home Assistant: " <> T.pack err) Left err -> throw (Fatal $ "Invalid JSON from Home Assistant: " <> T.pack err)
Right x -> pure x Right x -> pure x
-- | Floor between sends within a batch: 100ms, so a many-distinct-target writerAction :: Bus -> IO Void
-- flood still caps at ~10 sends/sec even after dedupe. writerAction bus = forever $ do
minInterval :: Int (request, svc) <- atomically $ readTChan (busOutbound bus)
minInterval = 100000 conn <- atomically $ readTVar (busConn bus) >>= maybe retry pure
-- | Non-blocking drain of everything queued on a channel. Returns items
-- oldest-first (FIFO from the channel), so prepending the blocking
-- `readTChan` item keeps the whole batch oldest-first for `dedupeBatch`.
drainTry :: TChan a -> IO [a]
drainTry chan = go []
where
go acc = do
m <- atomically $ tryReadTChan chan
case m of
Nothing -> pure (reverse acc)
Just x -> go (x : acc)
sendWithId :: Bus -> WS.Connection -> Request -> Service -> IO ()
sendWithId bus conn request svc = do
callId <- generateCallId (busGen bus) callId <- generateCallId (busGen bus)
let textData = encode $ encodeService callId svc let textData = encode $ encodeService callId svc
runKatipContextT (busLogEnv bus) (sl "traceId" (toText (requestTraceId request))) "connection" $ runKatipContextT (busLogEnv bus) (sl "traceId" (toText (requestTraceId request))) "connection" $
logFM DebugS (ls textData) logFM DebugS (ls textData)
WS.sendTextData conn textData WS.sendTextData conn textData
writerAction :: Bus -> IO Void
writerAction bus = forever $ do
first <- atomically $ readTChan (busOutbound bus)
rest <- drainTry (busOutbound bus)
let deduped = dedupeBatch (first : rest)
conn <- atomically $ readTVar (busConn bus) >>= maybe retry pure
forM_ deduped $ \(request, svc) -> do
sendWithId bus conn request svc
threadDelay minInterval
encodeService :: Int -> Service -> Value encodeService :: Int -> Service -> Value
encodeService callId Service{..} = object $ encodeService callId Service{..} = object $
[ "id" .= callId [ "id" .= callId
@@ -149,18 +106,6 @@ encodeService callId Service{..} = object $
, "target" .= targetObject serviceTarget , "target" .= targetObject serviceTarget
] <> maybe [] (\d -> ["service_data" .= d]) serviceData ] <> maybe [] (\d -> ["service_data" .= d]) serviceData
-- | Collapse a drained batch of outbound calls: the newest call per
-- `(domain, service, sorted-targets)` survives; older duplicates are
-- dropped. `serviceData` is not part of the key, so a newer `turn_on`
-- with different brightness supersedes an older one to the same target.
dedupeBatch :: [(Request, Service)] -> [(Request, Service)]
dedupeBatch = M.elems . foldl' ins M.empty
where
ins m (req, svc) = M.insert (dedupeKey svc) (req, svc) m
dedupeKey :: Service -> (T.Text, T.Text, [Target])
dedupeKey Service{..} = (serviceDomain, serviceName, sort serviceTarget)
-- | A single target encodes as a scalar; multiple encode as a list. Empty -- | A single target encodes as a scalar; multiple encode as a list. Empty
-- lists are omitted so Home Assistant receives only populated keys. -- lists are omitted so Home Assistant receives only populated keys.
targetObject :: [Target] -> Value targetObject :: [Target] -> Value
+16 -106
View File
@@ -1,17 +1,13 @@
{-# LANGUAGE OverloadedStrings #-}
module AFRPSpec (spec) where module AFRPSpec (spec) where
import Control.Arrow (arr, (&&&), first, left) import Control.Arrow (arr)
import Control.Category ((>>>)) import Control.Category ((>>>))
import Control.Monad.Fix (MonadFix (..)) import Control.Monad.Fix (MonadFix (..))
import Data.Foldable (for_) import Data.Foldable (for_)
import AFRP import AFRP
import Data.Functor.Identity (Identity (..)) import Data.Functor.Identity (Identity (..))
import Data.List (sort) import Data.List (sort)
import qualified Data.Set as S import Data.Time (NominalDiffTime, UTCTime (..), addUTCTime, diffUTCTime)
import qualified Data.Text as T
import Data.Time (NominalDiffTime, UTCTime (..), utc)
import Data.UUID (nil) import Data.UUID (nil)
import Hedgehog import Hedgehog
import qualified Hedgehog.Gen as Gen import qualified Hedgehog.Gen as Gen
@@ -20,7 +16,7 @@ import Test.Hspec
import Test.Hspec.Hedgehog import Test.Hspec.Hedgehog
fakeRequest :: Request fakeRequest :: Request
fakeRequest = Request (sec 0) utc nil fakeRequest = Request (sec 0) nil
sec :: Integer -> UTCTime sec :: Integer -> UTCTime
sec n = UTCTime (toEnum 0) (fromIntegral n) sec n = UTCTime (toEnum 0) (fromIntegral n)
@@ -28,14 +24,14 @@ sec n = UTCTime (toEnum 0) (fromIntegral n)
runPure :: Mealy Identity a b -> [a] -> [b] runPure :: Mealy Identity a b -> [a] -> [b]
runPure _ [] = [] runPure _ [] = []
runPure m (a : as) = case runIdentity (AFRP.runMealy m id fakeRequest a) of runPure m (a : as) = case runIdentity (AFRP.runMealy m id fakeRequest a) of
Pair b m' -> b : runPure m' as (b, m') -> b : runPure m' as
-- | Run a Mealy with a per-step wall clock (seconds since the day-0 epoch). -- | Run a Mealy with a per-step wall clock (seconds since the day-0 epoch).
runTimed :: Mealy Identity a b -> [(Integer, a)] -> [b] runTimed :: Mealy Identity a b -> [(Integer, a)] -> [b]
runTimed _ [] = [] runTimed _ [] = []
runTimed m ((s, a) : as) = runTimed m ((s, a) : as) =
case runIdentity (AFRP.runMealy m id (Request (sec s) utc nil) a) of case runIdentity (AFRP.runMealy m id (Request (sec s) nil) a) of
Pair b m' -> b : runTimed m' as (b, m') -> b : runTimed m' as
-- | A minimal State monad for observing effectful arrows (e.g. whenA gating). -- | A minimal State monad for observing effectful arrows (e.g. whenA gating).
newtype St a = St { unSt :: Int -> (a, Int) } newtype St a = St { unSt :: Int -> (a, Int) }
@@ -59,11 +55,10 @@ runStEff m s0 as = go m s0 as
go _ s [] = ([], s) go _ s [] = ([], s)
go m' s (a : rest) = go m' s (a : rest) =
case unSt (AFRP.runMealy m' id fakeRequest a) s of case unSt (AFRP.runMealy m' id fakeRequest a) s of
(Pair b m'', s') -> let (bs, s'') = go m'' s' rest in (b : bs, s'') ((b, m''), s') -> let (bs, s'') = go m'' s' rest in (b : bs, s'')
spec :: Spec spec :: Spec
spec = describe "AFRP" $ do spec = describe "AFRP" $ do
entitiesSpec
holdSpec holdSpec
eventsSpec eventsSpec
isEventSpec isEventSpec
@@ -72,7 +67,6 @@ spec = describe "AFRP" $ do
lMergeSpec lMergeSpec
changesSpec changesSpec
edgeSpec edgeSpec
dropFirstSpec
filterASpec filterASpec
slidingSpec slidingSpec
mapAccumSpec mapAccumSpec
@@ -140,23 +134,6 @@ lMergeSpec = describe "lMerge" $ do
it "prefers right Event if left is Tick" $ it "prefers right Event if left is Tick" $
lMerge Tick (Event (2 :: Int)) `shouldBe` Event (2 :: Int) lMerge Tick (Event (2 :: Int)) `shouldBe` Event (2 :: Int)
it "Tick is a left identity" $
hedgehog $ do
e <- forAll eventGen
lMerge Tick e === e
it "Tick is a right identity" $
hedgehog $ do
e <- forAll eventGen
lMerge e Tick === e
it "is associative" $
hedgehog $ do
a <- forAll eventGen
b <- forAll eventGen
c <- forAll eventGen
lMerge a (lMerge b c) === lMerge (lMerge a b) c
changesSpec :: Spec changesSpec :: Spec
changesSpec = describe "changes" $ do changesSpec = describe "changes" $ do
it "first output is always Tick" $ it "first output is always Tick" $
@@ -211,20 +188,6 @@ edgeSpec = describe "edge" $ do
else o' === Tick else o' === Tick
_ -> failure _ -> failure
dropFirstSpec :: Spec
dropFirstSpec = describe "dropFirst" $ do
it "drops the first Event, passes the rest" $
runPure dropFirst [Tick, Event 1, Event 2, Event 3]
`shouldBe` [Tick, Tick, Event 2, Event 3 :: Event Int]
it "passes Tick through untouched before first Event" $
runPure dropFirst [Tick, Tick, Tick :: Event Int]
`shouldBe` [Tick, Tick, Tick :: Event Int]
it "drops only the first Event, Ticks before it are inert" $
runPure dropFirst [Tick, Tick, Event 'a', Tick, Event 'b']
`shouldBe` [Tick, Tick, Tick, Tick, Event 'b' :: Event Char]
filterASpec :: Spec filterASpec :: Spec
filterASpec = describe "filterA" $ do filterASpec = describe "filterA" $ do
it "lets through values matching predicate" $ it "lets through values matching predicate" $
@@ -282,7 +245,7 @@ mapAccumSpec = describe "mapAccum" $ do
for_ (zip3 [0 ..] xs out) $ \(i, _x, cur) -> for_ (zip3 [0 ..] xs out) $ \(i, _x, cur) ->
cur === sum (take (i + 1) xs) cur === sum (take (i + 1) xs)
preMapAccumSpec :: Spec preMapAccumSpec :: Spec
preMapAccumSpec = describe "preMapAccum" $ do preMapAccumSpec = describe "preMapAccum" $ do
it "running sum with pre-state extraction" $ it "running sum with pre-state extraction" $
runPure (preMapAccum (+) (0 :: Int) id) [1, 2, 3] runPure (preMapAccum (+) (0 :: Int) id) [1, 2, 3]
@@ -448,7 +411,7 @@ effSpec :: Spec
effSpec = describe "eff" $ do effSpec = describe "eff" $ do
it "lifts a pure effect function into a stateless Mealy" $ it "lifts a pure effect function into a stateless Mealy" $
runPure (eff (\_ x -> Identity (x + 1))) [1, 2, 3] runPure (eff (\_ x -> Identity (x + 1))) [1, 2, 3]
`shouldBe` [2 :: Int, 3, 4] `shouldBe` [2, 3, 4]
it "output equals f(input) for every step" $ it "output equals f(input) for every step" $
hedgehog $ do hedgehog $ do
@@ -459,7 +422,7 @@ effSpec = describe "eff" $ do
switchSpec :: Spec switchSpec :: Spec
switchSpec = describe "switch" $ do switchSpec = describe "switch" $ do
it "switches to the continuation at the first Event" $ it "switches to the continuation at the first Event" $
runPure (switch (arr (\x -> (x, if x >= (3 :: Int) then Event () else Tick))) runPure (switch (arr (\x -> (x, if x >= 3 then Event () else Tick)))
(const (arr (const 99)))) (const (arr (const 99))))
[1, 2, 3, 4, 5] [1, 2, 3, 4, 5]
`shouldBe` [1, 2, 99, 99, 99] `shouldBe` [1, 2, 99, 99, 99]
@@ -468,14 +431,14 @@ switchSpec = describe "switch" $ do
runPure (switch (arr (\x -> (x, Tick :: Event ()))) runPure (switch (arr (\x -> (x, Tick :: Event ())))
(const (arr (const 99)))) (const (arr (const 99))))
[1, 2, 3] [1, 2, 3]
`shouldBe` [1, 2, 3 :: Int] `shouldBe` [1, 2, 3]
it "prefix outputs come from the first arrow, suffix from the continuation" $ it "prefix outputs come from the first arrow, suffix from the continuation" $
hedgehog $ do hedgehog $ do
threshold <- forAll $ Gen.int (Range.linear (-20) 20) threshold <- forAll $ Gen.int (Range.linear (-20) 20)
xs <- forAll $ Gen.list (Range.linear 0 30) (Gen.int (Range.linear (-20) 20)) xs <- forAll $ Gen.list (Range.linear 0 30) (Gen.int (Range.linear (-20) 20))
let firstArr = arr (\x -> (x, if x >= threshold then Event () else Tick)) let first = arr (\x -> (x, if x >= threshold then Event () else Tick))
out = runPure (switch firstArr (const (arr (const 99)))) xs out = runPure (switch first (const (arr (const 99)))) xs
(pre, _post) = break (>= threshold) xs (pre, _post) = break (>= threshold) xs
take (length pre) out === pre take (length pre) out === pre
drop (length pre) out === replicate (length xs - length pre) 99 drop (length pre) out === replicate (length xs - length pre) 99
@@ -545,7 +508,7 @@ thenASpec = describe "thenA" $ do
xs <- forAll $ Gen.list (Range.linear 0 30) (Gen.int (Range.linear (-20) 20)) xs <- forAll $ Gen.list (Range.linear 0 30) (Gen.int (Range.linear (-20) 20))
let out = runPure (filterA (even @Int) >>| filterA (> 0)) xs let out = runPure (filterA (even @Int) >>| filterA (> 0)) xs
expected = expected =
[ if odd x then Left () [ if not (even x) then Left ()
else if x > 0 then Right x else if x > 0 then Right x
else Left () else Left ()
| x <- xs ] | x <- xs ]
@@ -555,67 +518,14 @@ sampleSpec :: Spec
sampleSpec = describe "sample" $ do sampleSpec = describe "sample" $ do
it "tags the current value onto the Event structure" $ it "tags the current value onto the Event structure" $
runPure sample [(1, Tick), (2, Event 'a'), (3, Tick)] runPure sample [(1, Tick), (2, Event 'a'), (3, Tick)]
`shouldBe` [Tick, Event @Int 2, Tick] `shouldBe` [Tick, Event 2, Tick]
it "output is Event a iff the input event is present" $ it "output is Event a iff the input event is present" $
hedgehog $ do hedgehog $ do
vals <- forAll $ Gen.list (Range.linear 0 30) (Gen.int (Range.linear 0 100)) vals <- forAll $ Gen.list (Range.linear 0 30) (Gen.int (Range.linear 0 100))
evs <- forAll $ Gen.list (Range.linear 0 30) eventGen evs <- forAll $ Gen.list (Range.linear 0 30) eventGen
let n = min (length vals) (length evs) let n = min (length vals) (length evs)
ps = take n $ zip vals evs ps = zip (take n vals) (take n evs)
out = runPure sample ps out = runPure sample ps
for_ (zip ps out) $ \((v, ev), o) -> for_ (zip ps out) $ \((v, ev), o) ->
o === tag v ev o === tag v ev
-- | A stateless arrow carrying a fixed entity set, for testing propagation.
subscribed :: S.Set T.Text -> Mealy Identity Int Int
subscribed ents = Mealy ents $ \_ _ a -> pure (Pair a (subscribed ents))
-- | Same as 'subscribed' but yields a function, for testing '<*>'.
subscribedF :: S.Set T.Text -> Mealy Identity Int (Int -> Int)
subscribedF ents = Mealy ents $ \_ _ a -> pure (Pair (a +) (subscribedF ents))
entitiesSpec :: Spec
entitiesSpec = describe "entities" $ do
it "id carries no entities" $
entities (arr id :: Mealy Identity Int Int) `shouldBe` S.empty
it "arr carries no entities" $
entities (arr (+ 1) :: Mealy Identity Int Int) `shouldBe` S.empty
it "eff carries no entities" $
entities (eff (\_ x -> Identity (x + 1 :: Int))) `shouldBe` S.empty
it "primitive combinators carry no entities" $ do
entities (hold 'a') `shouldBe` S.empty
entities (changes @Int) `shouldBe` S.empty
entities edge `shouldBe` S.empty
entities (sliding (3 :: Int)) `shouldBe` S.empty
it "Category (.) unions entity sets" $
entities (subscribed (S.singleton "a") >>> subscribed (S.singleton "b"))
`shouldBe` S.fromList ["a", "b"]
it "Applicative (<*>) unions entity sets" $
entities (subscribedF (S.singleton "a") <*> subscribed (S.singleton "b"))
`shouldBe` S.fromList ["a", "b"]
it "Arrow (&&&) unions entity sets" $
entities (subscribed (S.singleton "a") &&& subscribed (S.singleton "b"))
`shouldBe` S.fromList ["a", "b"]
it "(>>>) unions entity sets" $
entities (subscribed (S.singleton "a") >>> arr id >>> subscribed (S.singleton "b"))
`shouldBe` S.fromList ["a", "b"]
it "left preserves the entity set" $
entities (left (subscribed (S.singleton "a")) :: Mealy Identity (Either Int Int) (Either Int Int))
`shouldBe` S.singleton "a"
it "first preserves the entity set" $
entities (first (subscribed (S.singleton "a")) :: Mealy Identity (Int, Int) (Int, Int))
`shouldBe` S.singleton "a"
it "fmap preserves the entity set" $
entities (fmap (+ 1) (subscribed (S.singleton "a")))
`shouldBe` S.singleton "a"
+3 -34
View File
@@ -2,8 +2,7 @@
module BedroomSpec (spec) where module BedroomSpec (spec) where
import AFRP (Event (..), entities) import AFRP (Event (..))
import qualified Data.Set as S
import qualified Data.Text as T import qualified Data.Text as T
import Data.Aeson (Value) import Data.Aeson (Value)
import HomeAssistant.Controller import HomeAssistant.Controller
@@ -22,39 +21,9 @@ drawerState = Event . stateEvent "binary_sensor.bedroom_nightstand_drawer_sensor
spec :: Spec spec :: Spec
spec = describe "Bedroom" $ do spec = describe "Bedroom" $ do
entitySpec
drawerSpec drawerSpec
buttonSpec buttonSpec
entitySpec :: Spec
entitySpec = describe "entities" $ do
it "entityChangeEvent' registers its entity id" $
entities (entityChangeEvent' "sensor.foo")
`shouldBe` S.singleton "sensor.foo"
it "entityRead / entityBool inherit the entity id" $ do
entities (entityRead @Double "sensor.bar") `shouldBe` S.singleton "sensor.bar"
entities (entityBool "binary_sensor.baz") `shouldBe` S.singleton "binary_sensor.baz"
it "bedroomDrawerController subscribes to the drawer sensor" $
entities bedroomDrawerController
`shouldBe` S.singleton "binary_sensor.bedroom_nightstand_drawer_sensor_masse_contact"
it "bedroomButtonController subscribes to both remote event entities" $
entities bedroomButtonController
`shouldBe` S.fromList
[ "event.bedroom_quick_remote_masse_action"
, "event.bedroom_quick_jemina_action"
]
it "bedroomPresenceController subscribes to the presence sensor" $
entities bedroomPresenceController
`shouldBe` S.singleton "binary_sensor.presence_sensor_bedroom_occupancy"
it "humidifierController subscribes to the door sensor" $
entities humidifierController
`shouldBe` S.singleton "binary_sensor.makuuhuone_ovi_contact"
drawerSpec :: Spec drawerSpec :: Spec
drawerSpec = describe "bedroomDrawerController" $ do drawerSpec = describe "bedroomDrawerController" $ do
let entity = EntityId "switch.bedroom_drawer_light_masse" let entity = EntityId "switch.bedroom_drawer_light_masse"
@@ -102,7 +71,7 @@ buttonSpec = describe "bedroomButtonController" $ do
it "Masse off click turns the bedroom lights off" $ it "Masse off click turns the bedroom lights off" $
services (runHASS bedroomButtonController [masseButton "2_short_release"]) services (runHASS bedroomButtonController [masseButton "2_short_release"])
`shouldBe` [[light [area] Off]] `shouldBe` [[light [area] False]]
it "Enishen single click turns on her nightstand scene (lowest)" $ it "Enishen single click turns on her nightstand scene (lowest)" $
services (runHASS bedroomButtonController [enishenButton "1_short_release"]) services (runHASS bedroomButtonController [enishenButton "1_short_release"])
@@ -118,7 +87,7 @@ buttonSpec = describe "bedroomButtonController" $ do
it "Enishen off click turns the bedroom lights off" $ it "Enishen off click turns the bedroom lights off" $
services (runHASS bedroomButtonController [enishenButton "2_short_release"]) services (runHASS bedroomButtonController [enishenButton "2_short_release"])
`shouldBe` [[light [area] Off]] `shouldBe` [[light [area] False]]
it "ignores the initial press (scene only fires on release)" $ it "ignores the initial press (scene only fires on release)" $
services (runHASS bedroomButtonController [masseButton "1_initial_press"]) services (runHASS bedroomButtonController [masseButton "1_initial_press"])
+7 -7
View File
@@ -2,7 +2,7 @@
module BusSpec (spec) where module BusSpec (spec) where
import AFRP (Request (..), Event (..)) import AFRP (Request (..))
import Control.Concurrent.STM import Control.Concurrent.STM
( atomically ( atomically
, dupTChan , dupTChan
@@ -10,7 +10,7 @@ import Control.Concurrent.STM
, writeTChan , writeTChan
) )
import Data.Aeson (Value (..)) import Data.Aeson (Value (..))
import Data.Time (UTCTime (..), utc) import Data.Time (UTCTime (..))
import Data.UUID (nil) import Data.UUID (nil)
import HomeAssistant.Controller (HASSEff (..), Service (..), Target(..)) import HomeAssistant.Controller (HASSEff (..), Service (..), Target(..))
import HomeAssistant.Runtime.Bus import HomeAssistant.Runtime.Bus
@@ -22,15 +22,15 @@ spec = describe "Bus" $ do
it "broadcasts inbound messages to every dup'd channel in order" $ withBus InfoS $ \bus -> do it "broadcasts inbound messages to every dup'd channel in order" $ withBus InfoS $ \bus -> do
p1 <- atomically $ dupTChan (busInbound bus) p1 <- atomically $ dupTChan (busInbound bus)
p2 <- atomically $ dupTChan (busInbound bus) p2 <- atomically $ dupTChan (busInbound bus)
atomically $ writeTChan (busInbound bus) (Event (Number 1)) atomically $ writeTChan (busInbound bus) (Number 1)
atomically $ writeTChan (busInbound bus) (Event (Number 2)) atomically $ writeTChan (busInbound bus) (Number 2)
r1 <- atomically $ (,) <$> readTChan p1 <*> readTChan p1 r1 <- atomically $ (,) <$> readTChan p1 <*> readTChan p1
r2 <- atomically $ (,) <$> readTChan p2 <*> readTChan p2 r2 <- atomically $ (,) <$> readTChan p2 <*> readTChan p2
r1 `shouldBe` (Event (Number 1), Event (Number 2)) r1 `shouldBe` (Number 1, Number 2)
r2 `shouldBe` (Event (Number 1), Event (Number 2)) r2 `shouldBe` (Number 1, Number 2)
it "channelHassEval writes CallService to the outbound channel" $ withBus InfoS $ \bus -> do it "channelHassEval writes CallService to the outbound channel" $ withBus InfoS $ \bus -> do
let req = Request (UTCTime (toEnum 0) 0) utc nil let req = Request (UTCTime (toEnum 0) 0) nil
svc = Service "light" "turn_on" Nothing [EntityId "light.bedroom_masse"] svc = Service "light" "turn_on" Nothing [EntityId "light.bedroom_masse"]
runKatipContextT (busLogEnv bus) () (Namespace ["test"]) $ runKatipContextT (busLogEnv bus) () (Namespace ["test"]) $
channelHassEval bus (CallService req svc) channelHassEval bus (CallService req svc)
+2 -49
View File
@@ -2,19 +2,14 @@
module ConnectionSpec (spec) where module ConnectionSpec (spec) where
import AFRP (Request(..))
import Data.Aeson (object, (.=)) import Data.Aeson (object, (.=))
import Data.Maybe (fromJust)
import Data.Text (Text) import Data.Text (Text)
import Data.Time (UTCTime (..), utc)
import Data.UUID (fromString)
import HomeAssistant.Controller (Service (..), Target(..)) import HomeAssistant.Controller (Service (..), Target(..))
import HomeAssistant.Runtime.Connection (encodeService, dedupeBatch) import HomeAssistant.Runtime.Connection (encodeService)
import Test.Hspec import Test.Hspec
spec :: Spec spec :: Spec
spec = do spec = describe "encodeService" $ do
describe "encodeService" $ do
it "encodes a call_service message" $ it "encodes a call_service message" $
encodeService 7 (Service "light" "turn_on" Nothing [EntityId "light.bedroom_masse"]) encodeService 7 (Service "light" "turn_on" Nothing [EntityId "light.bedroom_masse"])
`shouldBe` object `shouldBe` object
@@ -35,45 +30,3 @@ spec = do
, "target" .= object ["entity_id" .= ("light.bedroom_masse" :: Text)] , "target" .= object ["entity_id" .= ("light.bedroom_masse" :: Text)]
, "service_data" .= object ["brightness" .= (200 :: Int)] , "service_data" .= object ["brightness" .= (200 :: Int)]
] ]
describe "dedupeBatch" $ do
it "collapses identical calls to one" $
let batch = [ (req 1, lightOn [AreaId "x"])
, (req 2, lightOn [AreaId "x"])
, (req 3, lightOn [AreaId "x"])
]
in dedupeBatch batch `shouldBe` [(req 3, lightOn [AreaId "x"])]
it "keeps same-target different-service calls separate" $
let batch = [ (req 1, lightOn [AreaId "x"])
, (req 2, lightOff [AreaId "x"])
]
result = dedupeBatch batch
in length result `shouldBe` 2
it "newest call wins for the same key" $
let batch = [ (req 1, lightOn [AreaId "x"])
, (req 2, lightOn [AreaId "x"])
, (req 3, lightOn [AreaId "x"])
]
in map requestTraceId (map fst (dedupeBatch batch)) `shouldBe`
[fromJust (fromString "00000000-0000-0000-0000-000000000003")]
it "treats target lists in different order as the same key" $
let batch = [ (req 1, lightOn [EntityId "a", EntityId "b"])
, (req 2, lightOn [EntityId "b", EntityId "a"])
]
in length (dedupeBatch batch) `shouldBe` 1
req :: Int -> Request
req n = Request (UTCTime (toEnum 0) (fromIntegral (0 :: Int))) utc
(fromJust (fromString uuid))
where
pad i = replicate (12 - length (show i)) '0' <> show i
uuid = "00000000-0000-0000-0000-" <> pad n
lightOn :: [Target] -> Service
lightOn targets = Service "light" "turn_on" Nothing targets
lightOff :: [Target] -> Service
lightOff targets = Service "light" "turn_off" Nothing targets
+5 -7
View File
@@ -16,9 +16,9 @@ spec = pure ()
-- putStrLn "Before the delay" -- putStrLn "Before the delay"
-- threadDelay 100000 -- let the controller dup its inbound channel -- threadDelay 100000 -- let the controller dup its inbound channel
-- putStrLn "After the delay" -- putStrLn "After the delay"
-- atomically $ writeTChan (busInbound bus) (Event (doorEvent "on")) -- initial value: no change event -- atomically $ writeTChan (busInbound bus) (doorEvent "on") -- initial value: no change event
-- atomically $ writeTChan (busInbound bus) (Event (doorEvent "off")) -- door closes: lights on -- atomically $ writeTChan (busInbound bus) (doorEvent "off") -- door closes: lights on
-- atomically $ writeTChan (busInbound bus) (Event (doorEvent "on")) -- door opens: lights off -- atomically $ writeTChan (busInbound bus) (doorEvent "on") -- door opens: lights off
-- putStrLn "After the writes" -- putStrLn "After the writes"
-- Right (_, svc1) <- boundedRead (busOutbound bus) -- Right (_, svc1) <- boundedRead (busOutbound bus)
-- Right (_, svc2) <- boundedRead (busOutbound bus) -- Right (_, svc2) <- boundedRead (busOutbound bus)
@@ -32,11 +32,9 @@ spec = pure ()
-- doorEvent :: Text -> Value -- doorEvent :: Text -> Value
-- doorEvent state = object -- doorEvent state = object
-- [ "event" .= object -- [ "event" .= object
-- [ "variables" .= object -- [ "data" .= object
-- [ "trigger" .= object
-- [ "entity_id" .= ("binary_sensor.makuuhuone_ovi_contact" :: Text) -- [ "entity_id" .= ("binary_sensor.makuuhuone_ovi_contact" :: Text)
-- , "to_state" .= object ["state" .= state] -- , "new_state" .= object ["state" .= state]
-- ]
-- ] -- ]
-- ] -- ]
-- ] -- ]
+10 -16
View File
@@ -14,13 +14,13 @@ module Support
import Control.Monad.Fix (MonadFix (..)) import Control.Monad.Fix (MonadFix (..))
import Data.Aeson (Value, object, (.=)) import Data.Aeson (Value, object, (.=))
import qualified Data.Text as T import qualified Data.Text as T
import Data.Time (UTCTime (..), utc) import Data.Time (UTCTime (..))
import Data.UUID (nil) import Data.UUID (nil)
import AFRP (Mealy (..), Pair (..), Request (..)) import AFRP (Mealy (..), Request (..))
import HomeAssistant.Controller (HASSEff (..), Service) import HomeAssistant.Controller (HASSEff (..), Service)
fakeRequest :: Request fakeRequest :: Request
fakeRequest = Request (sec 0) utc nil fakeRequest = Request (sec 0) nil
sec :: Integer -> UTCTime sec :: Integer -> UTCTime
sec n = UTCTime (toEnum 0) (fromIntegral n) sec n = UTCTime (toEnum 0) (fromIntegral n)
@@ -52,36 +52,30 @@ runHASS :: Mealy HASSEff a b -> [a] -> [(b, [Service])]
runHASS _ [] = [] runHASS _ [] = []
runHASS m (a : as) = runHASS m (a : as) =
case runAcc (runMealy m interp fakeRequest a) [] of case runAcc (runMealy m interp fakeRequest a) [] of
(Pair b m', svcs) -> (b, svcs) : runHASS m' as ((b, m'), svcs) -> (b, svcs) : runHASS m' as
services :: [(b, [Service])] -> [[Service]] services :: [(b, [Service])] -> [[Service]]
services = map snd services = map snd
-- | Build a state-trigger payload matching `entityChangeEvent'` / `entityBool'` -- | Build a state-change event payload matching `entityChangeEvent'` / `entityBool'` lenses.
-- lenses. The subscribe_trigger websocket event wraps the trigger datum under
-- `event.variables.trigger`, with `entity_id` and `to_state.state` fields.
stateEvent :: T.Text -> T.Text -> Value stateEvent :: T.Text -> T.Text -> Value
stateEvent entityId state = object stateEvent entityId state = object
[ "event" .= object [ "event" .= object
[ "variables" .= object [ "data" .= object
[ "trigger" .= object
[ "entity_id" .= entityId [ "entity_id" .= entityId
, "to_state" .= object [ "state" .= state ] , "new_state" .= object [ "state" .= state ]
]
] ]
] ]
] ]
-- | Build an Ikea button trigger payload matching `ikeaQuickButton` lenses. -- | Build an Ikea button event payload matching `ikeaQuickButton` lenses.
buttonEvent :: T.Text -> T.Text -> Value buttonEvent :: T.Text -> T.Text -> Value
buttonEvent entityId eventType = object buttonEvent entityId eventType = object
[ "event" .= object [ "event" .= object
[ "variables" .= object [ "data" .= object
[ "trigger" .= object
[ "entity_id" .= entityId [ "entity_id" .= entityId
, "to_state" .= object , "new_state" .= object
[ "attributes" .= object [ "event_type" .= eventType ] ] [ "attributes" .= object [ "event_type" .= eventType ] ]
] ]
] ]
] ]
]