Run controllers on their own threads over the bus

This commit is contained in:
2026-08-20 19:40:01 +03:00
parent f449ad60ad
commit 9c355adf47
5 changed files with 84 additions and 94 deletions
+4 -4
View File
@@ -1,5 +1,5 @@
{ mkDerivation, aeson, base, bytestring, hspec, lens, lens-aeson { mkDerivation, aeson, async, base, bytestring, hspec, lens
, lib, network, stm, text, time, websockets , lens-aeson, lib, network, stm, text, time, websockets
}: }:
mkDerivation { mkDerivation {
pname = "home-assistant-controller"; pname = "home-assistant-controller";
@@ -8,11 +8,11 @@ mkDerivation {
isLibrary = true; isLibrary = true;
isExecutable = true; isExecutable = true;
libraryHaskellDepends = [ libraryHaskellDepends = [
aeson base bytestring lens lens-aeson network stm text time aeson async base bytestring lens lens-aeson network stm text time
websockets websockets
]; ];
executableHaskellDepends = [ base ]; executableHaskellDepends = [ base ];
testHaskellDepends = [ aeson base hspec stm text ]; testHaskellDepends = [ aeson async base hspec stm text ];
license = lib.meta.getLicenseFromSpdxId "BSD-3-Clause"; license = lib.meta.getLicenseFromSpdxId "BSD-3-Clause";
mainProgram = "home-assistant-controller"; mainProgram = "home-assistant-controller";
} }
+4 -1
View File
@@ -82,6 +82,7 @@ library
, bytestring , bytestring
, time , time
, stm , stm
, async
-- Directories containing source files. -- Directories containing source files.
hs-source-dirs: src hs-source-dirs: src
@@ -123,6 +124,7 @@ test-suite home-assistant-controller-test
-- Modules included in this executable, other than Main. -- Modules included in this executable, other than Main.
other-modules: BusSpec other-modules: BusSpec
, ConnectionSpec , ConnectionSpec
, RuntimeSpec
-- LANGUAGE extensions used by modules in this package. -- LANGUAGE extensions used by modules in this package.
-- other-extensions: -- other-extensions:
@@ -143,4 +145,5 @@ test-suite home-assistant-controller-test
hspec, hspec,
stm, stm,
aeson, aeson,
text text,
async
+38 -89
View File
@@ -1,113 +1,62 @@
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE GADTs #-} {-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
module HomeAssistant.Runtime module HomeAssistant.Runtime
( defaultMain ( defaultMain
, app
, step , step
, CallIdGen , CallIdGen
, mkCallIdGen , mkCallIdGen
, hassEval
, dryRunHassEval , dryRunHassEval
, receiveJSON , Controller(..)
, wsCallService , runController
) where ) where
import AFRP (Mealy(..), Event(..)) import AFRP (Event (..), Mealy (..))
import HomeAssistant.Controller (HASSEff(..), lightController, Service(..)) import Control.Concurrent.Async (mapConcurrently_)
import HomeAssistant.Runtime.Bus (CallIdGen(..), mkCallIdGen) import Control.Concurrent.STM (atomically, dupTChan, readTChan)
import Data.Aeson ((.=), Value (Null), encode, eitherDecode, object) import Data.Aeson (Value)
import qualified Data.ByteString.Lazy as BL
import qualified Data.Text as T import qualified Data.Text as T
import qualified Network.WebSockets as WS import Data.Time (getCurrentTime)
import Data.Void (Void)
import HomeAssistant.Controller (HASS, HASSEff (..), lightController)
import HomeAssistant.Runtime.Bus
import HomeAssistant.Runtime.Connection (readerAction, writerAction)
import Network.Socket (withSocketsDo) import Network.Socket (withSocketsDo)
import System.Environment (getEnv) import System.Environment (getEnv)
import Data.Time (getCurrentTime)
step :: (forall x. eff x -> IO x) -> Mealy eff a b -> a -> IO (b, Mealy eff a b) step :: (forall x. eff x -> IO x) -> Mealy eff a b -> a -> IO (b, Mealy eff a b)
step nt (Mealy f) a = do step nt (Mealy f) a = do
now <- getCurrentTime now <- getCurrentTime
f nt now a f nt now a
data Controller = forall b. Controller T.Text (HASS (Event Value) b)
controllers :: [Controller]
controllers = [Controller "light" lightController]
-- | Steps the machine for every inbound message; service calls go to the
-- bus. A restart re-dups the inbound channel and starts from the machine's
-- initial state; messages broadcast during the restart window are lost.
runController :: Bus -> Controller -> IO Void
runController bus (Controller _name machine) = do
inbound <- atomically (dupTChan (busInbound bus))
go inbound machine
where
go inbound f = do
msg <- atomically (readTChan inbound)
(_, f') <- step (channelHassEval bus) f (Event msg)
go inbound f'
defaultMain :: IO () defaultMain :: IO ()
defaultMain = withSocketsDo $ do defaultMain = withSocketsDo $ do
token <- getEnv "HA_TOKEN" token <- getEnv "HA_TOKEN"
gen <- mkCallIdGen 0 bus <- newBus 0
WS.runClient "last-resort-redux" 8123 "/api/websocket" (app gen token) mapConcurrently_ id $
[ readerAction "last-resort-redux" 8123 token bus
app :: CallIdGen -> String -> WS.ClientApp () , writerAction bus
app gen token conn = do ] ++ map (runController bus) controllers
-- HA speaks first: {"type":"auth_required", ...}
authRequired <- receiveJSON conn
print authRequired
WS.sendTextData conn $ encode $ object
[ "type" .= ("auth" :: T.Text)
, "access_token" .= token
]
-- Expect {"type":"auth_ok", ...}
authResult <- receiveJSON conn
print authResult
getStateId <- generateCallId gen
WS.sendTextData conn $ encode $ object
[ "id" .= getStateId
, "type" .= ("get_states" :: T.Text)
]
msg <- WS.receiveData conn :: IO BL.ByteString
BL.writeFile "/tmp/states.json" msg
subscribeId <- generateCallId gen
-- Subscription 1: all entity state changes
WS.sendTextData conn $ encode $ object
[ "id" .= subscribeId
, "type" .= ("subscribe_events" :: T.Text)
, "event_type" .= ("state_changed" :: T.Text)
]
go lightController
where
go f = do
msg <- WS.receiveData conn :: IO BL.ByteString
let decoded = Event $ either (const Null) id $ eitherDecode @Value msg
(x, f') <- step (dryRunHassEval gen) f decoded
mapM_ print x
go f'
receiveJSON :: WS.Connection -> IO Value
receiveJSON conn = do
msg <- WS.receiveData conn
case eitherDecode msg of
Left err -> fail $ "Invalid JSON from Home Assistant: " ++ err
Right x -> pure x
wsCallService
:: WS.Connection
-> Int -- ^ request id
-> T.Text -- ^ domain
-> T.Text -- ^ service
-> T.Text -- ^ entity id
-> IO ()
wsCallService conn requestId domain service entityId =
WS.sendTextData conn $ encode $ object
[ "id" .= requestId
, "type" .= ("call_service" :: T.Text)
, "domain" .= domain
, "service" .= service
, "target" .= object
[ "entity_id" .= entityId
]
]
hassEval :: CallIdGen -> WS.Connection -> HASSEff a -> IO a
hassEval gen conn = \case
CallService x -> do
callId <- generateCallId gen
wsCallService conn callId (serviceDomain x) (serviceName x) (serviceTarget x)
Pure a -> pure a
dryRunHassEval :: CallIdGen -> HASSEff a -> IO a dryRunHassEval :: CallIdGen -> HASSEff a -> IO a
dryRunHassEval gen = \case dryRunHassEval gen = \case
+2
View File
@@ -3,8 +3,10 @@ module Main (main) where
import Test.Hspec (hspec) import Test.Hspec (hspec)
import qualified BusSpec import qualified BusSpec
import qualified ConnectionSpec import qualified ConnectionSpec
import qualified RuntimeSpec
main :: IO () main :: IO ()
main = hspec $ do main = hspec $ do
BusSpec.spec BusSpec.spec
ConnectionSpec.spec ConnectionSpec.spec
RuntimeSpec.spec
+36
View File
@@ -0,0 +1,36 @@
{-# LANGUAGE OverloadedStrings #-}
module RuntimeSpec (spec) where
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (async)
import Control.Concurrent.STM (atomically, readTChan, writeTChan)
import Data.Aeson (Value, object, (.=))
import Data.Text (Text)
import HomeAssistant.Controller (light, lightController)
import HomeAssistant.Runtime (Controller (..), runController)
import HomeAssistant.Runtime.Bus
import Test.Hspec
spec :: Spec
spec = describe "runController" $ do
it "feeds inbound events through the machine and forwards service calls" $ do
bus <- newBus 0
_ <- async (runController bus (Controller "test" lightController))
threadDelay 100000 -- let the controller dup its inbound channel
atomically $ writeTChan (busInbound bus) (doorEvent "on") -- initial value: no change event
atomically $ writeTChan (busInbound bus) (doorEvent "off") -- door closes: lights on
atomically $ writeTChan (busInbound bus) (doorEvent "on") -- door opens: lights off
svc1 <- atomically (readTChan (busOutbound bus))
svc2 <- atomically (readTChan (busOutbound bus))
svc1 `shouldBe` light True
svc2 `shouldBe` light False
doorEvent :: Text -> Value
doorEvent state = object
[ "event" .= object
[ "data" .= object
[ "entity_id" .= ("binary_sensor.makuuhuone_ovi_contact" :: Text)
, "new_state" .= object ["state" .= state]
]
]
]