Merge branch 'runtime-flags': runtime handler toggling

This commit is contained in:
2026-09-25 13:17:08 +03:00
13 changed files with 284 additions and 59 deletions
+7 -7
View File
@@ -1,8 +1,8 @@
{ mkDerivation, aeson, annotated-exception, async, base, bytestring { mkDerivation, aeson, annotated-exception, async, base, bytestring
, cereal, cereal-conduit, conduit, containers, directory, ekg-core , cereal, cereal-conduit, conduit, containers, directory, ekg-core
, exceptions, filepath, hedgehog, hspec, hspec-hedgehog, http-types , exceptions, filepath, hedgehog, hspec, hspec-hedgehog, http-media
, katip, lens, lens-aeson, lib, network, process, retry, scotty , http-types, katip, lens, lens-aeson, lib, network, process, retry
, servant, servant-server, stm, text, time, unliftio , servant, servant-server, stm, temporary, text, time, unliftio
, unordered-containers, uuid, wai, warp, websockets , unordered-containers, uuid, wai, warp, websockets
}: }:
mkDerivation { mkDerivation {
@@ -14,15 +14,15 @@ mkDerivation {
libraryHaskellDepends = [ libraryHaskellDepends = [
aeson annotated-exception async base bytestring cereal aeson annotated-exception async base bytestring cereal
cereal-conduit conduit containers directory ekg-core exceptions cereal-conduit conduit containers directory ekg-core exceptions
filepath http-types katip lens lens-aeson network process retry filepath http-media http-types katip lens lens-aeson network
scotty servant servant-server stm text time unliftio process retry servant servant-server stm text time unliftio
unordered-containers uuid wai warp websockets unordered-containers uuid wai warp websockets
]; ];
executableHaskellDepends = [ base ]; executableHaskellDepends = [ base ];
testHaskellDepends = [ testHaskellDepends = [
aeson annotated-exception async base bytestring cereal containers aeson annotated-exception async base bytestring cereal containers
directory ekg-core hedgehog hspec hspec-hedgehog katip process stm directory ekg-core hedgehog hspec hspec-hedgehog katip process
text time unordered-containers uuid servant stm temporary text time unordered-containers uuid
]; ];
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
@@ -70,6 +70,7 @@ library
, HomeAssistant.Runtime , HomeAssistant.Runtime
, HomeAssistant.Runtime.Bus , HomeAssistant.Runtime.Bus
, HomeAssistant.Runtime.Connection , HomeAssistant.Runtime.Connection
, HomeAssistant.Runtime.Flags
, HomeAssistant.Runtime.Graphing , HomeAssistant.Runtime.Graphing
, HomeAssistant.Runtime.Metrics , HomeAssistant.Runtime.Metrics
, HomeAssistant.Runtime.Supervisor , HomeAssistant.Runtime.Supervisor
@@ -161,6 +162,7 @@ test-suite home-assistant-controller-test
, BedroomSpec , BedroomSpec
, BusSpec , BusSpec
, ConnectionSpec , ConnectionSpec
, FlagsSpec
, GraphingSpec , GraphingSpec
, MetricsSpec , MetricsSpec
, RuntimeSpec , RuntimeSpec
@@ -200,4 +202,5 @@ test-suite home-assistant-controller-test
ekg-core, ekg-core,
unordered-containers, unordered-containers,
process, process,
directory directory,
temporary
+4 -3
View File
@@ -103,9 +103,10 @@ mergeCodec (Codec agetter aputter) (Codec bgetter bputter) = Codec (mergeGet age
data Pair a b = Pair !a !b data Pair a b = Pair !a !b
data Request = Request data Request = Request
{ requestTime :: !UTCTime { requestTime :: !UTCTime
, requestTimeZone :: !TimeZone , requestTimeZone :: !TimeZone
, requestTraceId :: !UUID , requestTraceId :: !UUID
, requestHandler :: !T.Text
} deriving (Show, Eq) } deriving (Show, Eq)
+25 -23
View File
@@ -21,6 +21,7 @@ import Data.Time (getCurrentTime, getCurrentTimeZone)
import Data.Void (Void) import Data.Void (Void)
import HomeAssistant.Controller (HASS, HASSEff (..)) import HomeAssistant.Controller (HASS, HASSEff (..))
import HomeAssistant.Runtime.Bus import HomeAssistant.Runtime.Bus
import HomeAssistant.Runtime.Flags (loadFlags)
import HomeAssistant.Runtime.Connection (writerAction, readerAction) import HomeAssistant.Runtime.Connection (writerAction, readerAction)
import Network.Socket (withSocketsDo) import Network.Socket (withSocketsDo)
import System.Environment (getEnv, lookupEnv) import System.Environment (getEnv, lookupEnv)
@@ -43,34 +44,34 @@ import HomeAssistant.Runtime.Supervisor (supervised)
import HomeAssistant.Controller.Hallway (hallwayLightsController) import HomeAssistant.Controller.Hallway (hallwayLightsController)
import qualified HttpServer import qualified HttpServer
step :: (MonadIO m) => FilePath -> UUID -> Auto m a b -> a -> m (b, Auto m a b) step :: (MonadIO m) => FilePath -> T.Text -> UUID -> Auto m a b -> a -> m (b, Auto m a b)
step path trace st a = do step path name trace st a = do
now <- liftIO getCurrentTime now <- liftIO getCurrentTime
tz <- liftIO getCurrentTimeZone tz <- liftIO getCurrentTimeZone
let req = Request now tz trace let req = Request now tz trace name
stepAutoSerializing path st req a stepAutoSerializing path st req a
data Controller = forall b. Controller Bool T.Text (HASS (Event Value) b) data Controller = forall b. Controller T.Text (HASS (Event Value) b)
controllers :: [Controller] controllers :: [Controller]
controllers = controllers =
[ Controller True "bedroom-presence" bedroomPresenceController [ Controller "bedroom-presence" bedroomPresenceController
, Controller True "bedroom-button" bedroomButtonController , Controller "bedroom-button" bedroomButtonController
, Controller True "bedroom-drawer" bedroomDrawerController , Controller "bedroom-drawer" bedroomDrawerController
, Controller True "bedroom-humidifier" humidifierController , Controller "bedroom-humidifier" humidifierController
, Controller True "school-light-controller" schoolLightController , Controller "school-light-controller" schoolLightController
, Controller True "kitchen-motion-controller" kitchenMotionController , Controller "kitchen-motion-controller" kitchenMotionController
, Controller True "livingroom-presence" livingroomPresenceController , Controller "livingroom-presence" livingroomPresenceController
, Controller True "hallway-motion-controller" hallwayLightsController , Controller "hallway-motion-controller" hallwayLightsController
, Controller True "children-button" childrenBedroomButtonController , Controller "children-button" childrenBedroomButtonController
, Controller True "livingroom-plants" livingroomPlantLights , Controller "livingroom-plants" livingroomPlantLights
] ]
-- | Steps the machine for every inbound message; service calls go to the -- | 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 -- bus. A restart re-dups the inbound channel and starts from the machine's
-- initial state; messages broadcast during the restart window are lost. -- initial state; messages broadcast during the restart window are lost.
runController :: MonadIO m => FilePath -> Bus -> Controller -> m Void runController :: MonadIO m => FilePath -> Bus -> Controller -> m Void
runController rootDir bus (Controller _enabled name machine ) = liftIO $ do runController rootDir bus (Controller name machine ) = liftIO $ do
inbound <- atomically (dupTChan (busInbound bus)) inbound <- atomically (dupTChan (busInbound bus))
let ns = Namespace [name] let ns = Namespace [name]
let workerDefinition = runMealy machine (runKatipContextT (busLogEnv bus) () ns . channelHassEval bus) let workerDefinition = runMealy machine (runKatipContextT (busLogEnv bus) () ns . channelHassEval bus)
@@ -83,7 +84,7 @@ runController rootDir bus (Controller _enabled name machine ) = liftIO $ do
go path inbound f = do go path inbound f = do
msg <- atomically (readTChan inbound) msg <- atomically (readTChan inbound)
uuid <- UUID.V4.nextRandom uuid <- UUID.V4.nextRandom
(_, next) <- step path uuid f msg (_, next) <- step path name uuid f msg
go path inbound next go path inbound next
lookupPort :: IO Int lookupPort :: IO Int
@@ -102,22 +103,23 @@ defaultMain = withSocketsDo $ do
System.Metrics.registerGcMetrics store System.Metrics.registerGcMetrics store
appMetrics <- HomeAssistant.Runtime.Metrics.registerAppMetrics store appMetrics <- HomeAssistant.Runtime.Metrics.registerAppMetrics store
rateLimitMetrics <- registerRateLimitMetrics store rateLimitMetrics <- registerRateLimitMetrics store
withBus severity appMetrics $ \bus -> do rootPath <- fromMaybe "/tmp/" <$> lookupEnv "HA_LIB_DIR"
let allNames = map (\(Controller n _) -> n) controllers
flags <- loadFlags rootPath allNames
withBus severity appMetrics flags $ \bus -> do
token <- getEnv "HA_TOKEN" token <- getEnv "HA_TOKEN"
host <- getEnv "HA_HOST" host <- getEnv "HA_HOST"
rootPath <- fromMaybe "/tmp/" <$> lookupEnv "HA_LIB_DIR" let rrdPath = rootPath </> "hass-controller.rrd"
rrdPath <- fromMaybe "hass-controller.rrd" <$> lookupEnv "HA_RRD_PATH"
rrdtool <- fromMaybe "rrdtool" <$> lookupEnv "HA_RRDTOOL" rrdtool <- fromMaybe "rrdtool" <$> lookupEnv "HA_RRDTOOL"
metricsPort <- lookupPort metricsPort <- lookupPort
writerLimiter <- slidingWindowLimiter rateLimitMetrics 10 20 writerLimiter <- slidingWindowLimiter rateLimitMetrics 10 20
let active = [c | c@(Controller True _ _) <- controllers] let ents = foldMap (\(Controller _ m) -> entities m) controllers
ents = foldMap (\(Controller _ _ m) -> entities m) active
workers = workers =
[ ("reader", readerAction host 8123 token ents bus) [ ("reader", readerAction host 8123 token ents bus)
, ("writer", writerAction writerLimiter bus) , ("writer", writerAction writerLimiter bus)
, ("metrics", HomeAssistant.Runtime.Metrics.metricsAction store rrdPath rrdtool) , ("metrics", HomeAssistant.Runtime.Metrics.metricsAction store rrdPath rrdtool)
, ("metrics-http", HttpServer.runHttpServer (busLogEnv bus) rrdPath rrdtool metricsPort) , ("metrics-http", HttpServer.runHttpServer (busLogEnv bus) rrdPath rrdtool (busFlags bus) metricsPort)
] ++ [ (name, runController rootPath bus c) | c@(Controller True name _) <- active ] ] ++ [ (name, runController rootPath bus c) | c@(Controller name _) <- controllers ]
runKatipContextT (busLogEnv bus) () mempty $ runKatipContextT (busLogEnv bus) () mempty $
mapConcurrently_ (uncurry supervised) workers mapConcurrently_ (uncurry supervised) workers
+8 -3
View File
@@ -31,7 +31,9 @@ import System.IO (stdout)
import System.Metrics.Counter (inc) import System.Metrics.Counter (inc)
import Data.UUID (toText) import Data.UUID (toText)
import AFRP (Request(..), Event(..)) import AFRP (Request(..), Event(..))
import Control.Monad (when)
import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Monad.IO.Class (MonadIO, liftIO)
import HomeAssistant.Runtime.Flags (Flags, isEnabled)
-- | 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
@@ -43,10 +45,11 @@ data Bus = Bus
, busGen :: CallIdGen , busGen :: CallIdGen
, busLogEnv :: LogEnv , busLogEnv :: LogEnv
, busMetrics :: AppMetrics , busMetrics :: AppMetrics
, busFlags :: Flags
} }
withBus :: Severity -> AppMetrics -> (Bus -> IO a) -> IO a withBus :: Severity -> AppMetrics -> Flags -> (Bus -> IO a) -> IO a
withBus severity metrics callback = do withBus severity metrics flags callback = do
handleScribe <- mkHandleScribe ColorIfTerminal stdout (permitItem severity) V2 handleScribe <- mkHandleScribe ColorIfTerminal stdout (permitItem severity) V2
let makeLogEnv = registerScribe "stdout" handleScribe defaultScribeSettings =<< initLogEnv "hass-controller" "production" let makeLogEnv = registerScribe "stdout" handleScribe defaultScribeSettings =<< initLogEnv "hass-controller" "production"
-- closeScribes will stop accepting new logs, flush existing ones and clean up resources -- closeScribes will stop accepting new logs, flush existing ones and clean up resources
@@ -58,6 +61,7 @@ withBus severity metrics callback = do
<*> mkCallIdGen 0 <*> mkCallIdGen 0
<*> pure le <*> pure le
<*> pure metrics <*> pure metrics
<*> pure flags
callback bus callback bus
recordInbound :: Bus -> IO () recordInbound :: Bus -> IO ()
@@ -70,7 +74,8 @@ channelHassEval :: (MonadIO m, KatipContext m) => Bus -> HASSEff a -> m a
channelHassEval bus = \case channelHassEval bus = \case
CallService req svc -> katipAddContext (sl "traceId" (toText (requestTraceId req))) $ do CallService req svc -> katipAddContext (sl "traceId" (toText (requestTraceId req))) $ do
logFM DebugS (ls $ show svc) logFM DebugS (ls $ show svc)
liftIO $ atomically $ writeTChan (busOutbound bus) (req, svc) enabled <- isEnabled (busFlags bus) (requestHandler req)
when enabled $ liftIO $ atomically $ writeTChan (busOutbound bus) (req, svc)
Debug x -> logFM DebugS (ls $ show x) Debug x -> logFM DebugS (ls $ show x)
Trace req x -> katipAddContext (sl "traceId" (toText (requestTraceId req))) $ Trace req x -> katipAddContext (sl "traceId" (toText (requestTraceId req))) $
logFM InfoS (ls $ show x) logFM InfoS (ls $ show x)
+90
View File
@@ -0,0 +1,90 @@
{-# LANGUAGE OverloadedStrings #-}
module HomeAssistant.Runtime.Flags
( Name
, Flags(..)
, loadFlags
, isEnabled
, setEnabled
, allFlags
, validNames
) where
import Control.Concurrent.MVar (MVar, newMVar, withMVar)
import Control.Concurrent.STM (TVar, atomically, newTVarIO, readTVarIO, stateTVar)
import Control.Exception (IOException, handle)
import Control.Monad.IO.Class (MonadIO, liftIO)
import qualified Conduit as C
import Conduit ((.|))
import Data.Aeson (decodeFileStrict', encode)
import qualified Data.ByteString.Lazy as BL
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Set (Set)
import qualified Data.Set as S
import Data.Text (Text)
import System.FilePath ((</>))
import System.IO (hPutStrLn, stderr)
import System.IO.Error (isDoesNotExistError)
type Name = Text
data Flags = Flags
{ flagsState :: TVar (Map Name Bool)
, flagsLock :: MVar ()
, flagsPath :: FilePath
, flagsValidNames :: Set Text
}
loadFlags :: MonadIO m => FilePath -> [Name] -> m Flags
loadFlags rootPath allNames = liftIO $ do
let path = rootPath </> "handlers.json"
fileMap <- readFlagsFile path
let defaults = M.fromList [(n, True) | n <- allNames]
initial = M.unionWith (\fileVal _default -> fileVal) fileMap defaults
tv <- newTVarIO initial
lock <- newMVar ()
pure Flags
{ flagsState = tv
, flagsLock = lock
, flagsPath = path
, flagsValidNames = S.fromList allNames
}
readFlagsFile :: FilePath -> IO (Map Name Bool)
readFlagsFile path =
handle onMissingOrUnreadable $ do
decoded <- decodeFileStrict' path
case decoded of
Nothing -> do
hPutStrLn stderr ("handlers.json is unparseable; treating all handlers as enabled: " <> path)
pure mempty
Just m -> pure (maybe mempty id m)
where
onMissingOrUnreadable :: IOException -> IO (Map Name Bool)
onMissingOrUnreadable e
| isDoesNotExistError e = pure mempty
| otherwise = do
hPutStrLn stderr ("could not read handlers.json (" <> show e <> "); treating all handlers as enabled")
pure mempty
isEnabled :: MonadIO m => Flags -> Name -> m Bool
isEnabled flags name = liftIO $
M.findWithDefault True name <$> readTVarIO (flagsState flags)
allFlags :: MonadIO m => Flags -> m (Map Name Bool)
allFlags flags = liftIO $ readTVarIO (flagsState flags)
setEnabled :: MonadIO m => Flags -> Name -> Bool -> m ()
setEnabled Flags{flagsState, flagsLock, flagsPath} name val =
liftIO $ withMVar flagsLock $ \_ -> do
m <- atomically $ stateTVar flagsState $ \cur ->
let next = M.insert name val cur in (next, next)
safeWrite flagsPath m
validNames :: Flags -> Set Text
validNames = flagsValidNames
safeWrite :: FilePath -> Map Name Bool -> IO ()
safeWrite path m = C.runResourceT $ C.runConduit $
C.yieldMany [BL.toStrict (encode m)] .| C.sinkFileCautious path
+32 -9
View File
@@ -7,13 +7,17 @@ import Control.Monad.IO.Class (liftIO, MonadIO)
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import Data.Void (Void) import Data.Void (Void)
import Network.Wai.Handler.Warp (run) import Network.Wai.Handler.Warp (run)
import Servant.API ((:>), Capture, Get, QueryParam, (:-), MimeRender (..), Accept (..), NamedRoutes) import Servant.API ((:>), Capture, Get, QueryParam, (:-), MimeRender (..), Accept (..), NamedRoutes, JSON, ReqBody, Put)
import Data.ByteString (ByteString) import Data.ByteString (ByteString)
import GHC.Generics (Generic) import GHC.Generics (Generic)
import Servant (Handler, err404) import Servant (Handler, err404)
import Control.Monad.Catch (throwM, MonadCatch, MonadThrow) import Control.Monad.Catch (throwM, catch, MonadCatch, MonadThrow, SomeException)
import Servant.Server (err500, ServerT) import Servant.Server (err500, ServerT)
import Data.Maybe (fromMaybe) import Data.Maybe (fromMaybe)
import Data.Map.Strict (Map)
import qualified Data.Set as S
import Data.Text (Text)
import HomeAssistant.Runtime.Flags (Flags, allFlags, setEnabled, validNames)
import Control.Exception.Annotated (checkpoint, Annotation (Annotation)) import Control.Exception.Annotated (checkpoint, Annotation (Annotation))
import Servant.Server.Generic (genericServeT) import Servant.Server.Generic (genericServeT)
import Network.Wai (Application) import Network.Wai (Application)
@@ -30,12 +34,27 @@ instance Accept ImagePng where
contentType _ = "image" // "png" contentType _ = "image" // "png"
newtype API mode = API data API mode = API
{ getMetrics :: mode :- "metrics" :> Capture "graph" Graph :> QueryParam "range" Range :> Get '[ImagePng] ByteString { getMetrics :: mode :- "metrics" :> Capture "graph" Graph :> QueryParam "range" Range :> Get '[ImagePng] ByteString
, getFlags :: mode :- "flags" :> Get '[JSON] (Map Text Bool)
, putFlag :: mode :- "flags" :> Capture "name" Text :> ReqBody '[JSON] Bool :> Put '[JSON] (Map Text Bool)
} }
deriving Generic deriving Generic
getFlagsHandler :: Flags -> LoggingHandler (Map Text Bool)
getFlagsHandler flags = allFlags flags
putFlagHandler :: Flags -> Text -> Bool -> LoggingHandler (Map Text Bool)
putFlagHandler flags name val
| name `S.notMember` validNames flags = throwM err404
| otherwise = do
setEnabled flags name val `catch` \e -> do
logFM ErrorS (ls ("failed to persist handler flag: " <> show (e :: SomeException)))
throwM err500
allFlags flags
getMetricsHandler :: FilePath -> FilePath -> Graph -> Maybe Range -> LoggingHandler ByteString getMetricsHandler :: FilePath -> FilePath -> Graph -> Maybe Range -> LoggingHandler ByteString
getMetricsHandler rrdPath rrdtool graphMode mRange = checkpoint (Annotation (graphMode, mRange)) $ do getMetricsHandler rrdPath rrdtool graphMode mRange = checkpoint (Annotation (graphMode, mRange)) $ do
case lookupGraph graphs graphMode of case lookupGraph graphs graphMode of
@@ -46,14 +65,18 @@ getMetricsHandler rrdPath rrdtool graphMode mRange = checkpoint (Annotation (gra
either (\e -> logFM ErrorS (ls e) >> throwM err500) pure g either (\e -> logFM ErrorS (ls e) >> throwM err500) pure g
-- server :: FilePath -> FilePath -> API AsServer -- server :: FilePath -> FilePath -> API AsServer
server :: FilePath -> FilePath -> ServerT (NamedRoutes API) LoggingHandler server :: FilePath -> FilePath -> Flags -> ServerT (NamedRoutes API) LoggingHandler
server rrdPath rrdtool = API { getMetrics = getMetricsHandler rrdPath rrdtool } server rrdPath rrdtool flags = API
{ getMetrics = getMetricsHandler rrdPath rrdtool
, getFlags = getFlagsHandler flags
, putFlag = putFlagHandler flags
}
servantApp :: LogEnv -> FilePath -> FilePath -> Application servantApp :: LogEnv -> FilePath -> FilePath -> Flags -> Application
servantApp le rrdPath rrdtool = genericServeT (toHandler le) (server rrdPath rrdtool) servantApp le rrdPath rrdtool flags = genericServeT (toHandler le) (server rrdPath rrdtool flags)
runHttpServer :: MonadIO m => LogEnv -> FilePath -> FilePath -> Int -> m Void runHttpServer :: MonadIO m => LogEnv -> FilePath -> FilePath -> Flags -> Int -> m Void
runHttpServer le rrdPath rrdtool port = liftIO $ forever $ run port (servantApp le rrdPath rrdtool) runHttpServer le rrdPath rrdtool flags port = liftIO $ forever $ run port (servantApp le rrdPath rrdtool flags)
newtype LoggingHandler a = LoggingHandler (KatipContextT Handler a) newtype LoggingHandler a = LoggingHandler (KatipContextT Handler a)
deriving (Functor, Applicative, Monad, MonadIO, Katip, KatipContext, MonadCatch, MonadThrow) via (KatipContextT Handler) deriving (Functor, Applicative, Monad, MonadIO, Katip, KatipContext, MonadCatch, MonadThrow) via (KatipContextT Handler)
+2 -2
View File
@@ -21,7 +21,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) utc nil "test"
sec :: Integer -> UTCTime sec :: Integer -> UTCTime
sec n = UTCTime (toEnum 0) (fromIntegral n) sec n = UTCTime (toEnum 0) (fromIntegral n)
@@ -42,7 +42,7 @@ runTimed m = go (runMealy m id)
where where
go _ [] = [] go _ [] = []
go w ((s, a) : as) = go w ((s, a) : as) =
case runIdentity (stepAuto w (Request (sec s) utc nil) a) of case runIdentity (stepAuto w (Request (sec s) utc nil "test") a) of
(b, w') -> b : go w' as (b, w') -> b : go w' 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).
+33 -9
View File
@@ -6,6 +6,7 @@ import AFRP (Request (..), Event (..))
import Control.Concurrent.STM import Control.Concurrent.STM
( atomically ( atomically
, dupTChan , dupTChan
, isEmptyTChan
, readTChan , readTChan
, writeTChan , writeTChan
) )
@@ -15,9 +16,11 @@ import Data.Time (UTCTime (..), utc)
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
import HomeAssistant.Runtime.Flags (loadFlags, setEnabled)
import HomeAssistant.Runtime.Metrics (registerAppMetrics) import HomeAssistant.Runtime.Metrics (registerAppMetrics)
import Katip (Namespace (Namespace), runKatipContextT, Severity (..)) import Katip (Namespace (Namespace), runKatipContextT, Severity (..))
import qualified System.Metrics as Metrics import qualified System.Metrics as Metrics
import System.IO.Temp (withTempDirectory)
import Test.Hspec import Test.Hspec
spec :: Spec spec :: Spec
@@ -33,13 +36,22 @@ spec = describe "Bus" $ do
r2 `shouldBe` (Event (Number 1), Event (Number 2)) r2 `shouldBe` (Event (Number 1), Event (Number 2))
it "channelHassEval writes CallService to the outbound channel" $ withTestBus $ \bus -> do it "channelHassEval writes CallService to the outbound channel" $ withTestBus $ \bus -> do
let req = Request (UTCTime (toEnum 0) 0) utc nil let req = Request (UTCTime (toEnum 0) 0) utc nil "test"
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)
(_, svc') <- atomically $ readTChan (busOutbound bus) (_, svc') <- atomically $ readTChan (busOutbound bus)
svc' `shouldBe` svc svc' `shouldBe` svc
it "channelHassEval drops CallService when the handler is disabled" $
withTestFlagsBus $ \bus -> do
setEnabled (busFlags bus) "test-handler" False
let req = Request (UTCTime (toEnum 0) 0) utc nil "test-handler"
svc = Service "light" "turn_on" Nothing [EntityId "light.test"]
runKatipContextT (busLogEnv bus) () (Namespace ["test"]) $
channelHassEval bus (CallService req svc)
atomically (isEmptyTChan (busOutbound bus)) `shouldReturn` True
it "generates unique sequential call ids" $ do it "generates unique sequential call ids" $ do
gen <- mkCallIdGen 0 gen <- mkCallIdGen 0
@@ -51,16 +63,28 @@ spec = describe "Bus" $ do
it "increments the trigger and service counters" $ do it "increments the trigger and service counters" $ do
store <- Metrics.newStore store <- Metrics.newStore
m <- registerAppMetrics store m <- registerAppMetrics store
withBus InfoS m $ \bus -> do withTempDirectory "/tmp" "bus-spec" $ \dir -> do
recordInbound bus flags <- loadFlags dir []
recordInbound bus withBus InfoS m flags $ \bus -> do
recordOutbound bus recordInbound bus
sample <- Metrics.sampleAll store recordInbound bus
HM.lookup "hass.trigger.in" sample `shouldBe` Just (Metrics.Counter 2) recordOutbound bus
HM.lookup "hass.service.out" sample `shouldBe` Just (Metrics.Counter 1) sample <- Metrics.sampleAll store
HM.lookup "hass.trigger.in" sample `shouldBe` Just (Metrics.Counter 2)
HM.lookup "hass.service.out" sample `shouldBe` Just (Metrics.Counter 1)
withTestBus :: (Bus -> IO a) -> IO a withTestBus :: (Bus -> IO a) -> IO a
withTestBus action = do withTestBus action = do
store <- Metrics.newStore store <- Metrics.newStore
m <- registerAppMetrics store m <- registerAppMetrics store
withBus InfoS m action withTempDirectory "/tmp" "bus-spec" $ \dir -> do
flags <- loadFlags dir []
withBus InfoS m flags action
withTestFlagsBus :: (Bus -> IO a) -> IO a
withTestFlagsBus action = do
store <- Metrics.newStore
m <- registerAppMetrics store
withTempDirectory "/tmp" "bus-spec" $ \dir -> do
flags <- loadFlags dir ["test-handler"]
withBus InfoS m flags action
+1 -1
View File
@@ -47,7 +47,7 @@ spec = do
req :: Int -> Request req :: Int -> Request
req n = Request (UTCTime (toEnum 0) (fromIntegral (0 :: Int))) utc req n = Request (UTCTime (toEnum 0) (fromIntegral (0 :: Int))) utc
(fromJust (fromString uuid)) (fromJust (fromString uuid)) "test"
where where
pad i = replicate (12 - length (show i)) '0' <> show i pad i = replicate (12 - length (show i)) '0' <> show i
uuid = "00000000-0000-0000-0000-" <> pad n uuid = "00000000-0000-0000-0000-" <> pad n
+75
View File
@@ -0,0 +1,75 @@
{-# LANGUAGE OverloadedStrings #-}
module FlagsSpec (spec) where
import Control.Concurrent.Async (mapConcurrently_)
import Control.Monad.IO.Class (liftIO)
import Data.Aeson (decodeFileStrict)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Text (Text)
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range
import System.IO.Temp (withTempDirectory)
import Test.Hspec
import Test.Hspec.Hedgehog
import HomeAssistant.Runtime.Flags
spec :: Spec
spec = describe "Flags" $ do
describe "loadFlags" $ do
it "defaults all names to True when the file is missing (opt-out)" $
withTempDirectory "/tmp" "flags-spec" $ \dir -> do
flags <- loadFlags dir ["a", "b"]
m <- allFlags flags
m `shouldBe` M.fromList [("a" :: Text, True), ("b", True)]
it "lets the file override the True default, missing keys stay True" $
withTempDirectory "/tmp" "flags-spec" $ \dir -> do
let path = dir <> "/handlers.json"
writeFile path "{\"b\": false}"
flags <- loadFlags dir ["a", "b"]
m <- allFlags flags
m `shouldBe` M.fromList [("a" :: Text, True), ("b" :: Text, False)]
it "treats a corrupt file as empty (all-enabled) without throwing" $
withTempDirectory "/tmp" "flags-spec" $ \dir -> do
let path = dir <> "/handlers.json"
writeFile path "not json at all"
flags <- loadFlags dir ["a"]
m <- allFlags flags
m `shouldBe` M.fromList [("a" :: Text, True)]
describe "isEnabled" $ do
it "returns the stored value" $
withTempDirectory "/tmp" "flags-spec" $ \dir -> do
let path = dir <> "/handlers.json"
writeFile path "{\"a\": false}"
flags <- loadFlags dir ["a"]
isEnabled flags "a" `shouldReturn` False
it "defaults unknown names to True" $
withTempDirectory "/tmp" "flags-spec" $ \dir -> do
flags <- loadFlags dir []
isEnabled flags "unknown" `shouldReturn` True
describe "setEnabled" $ do
it "updates the in-memory map and persists to disk" $
withTempDirectory "/tmp" "flags-spec" $ \dir -> do
flags <- loadFlags dir ["a"]
setEnabled flags "a" False
isEnabled flags "a" `shouldReturn` False
-- reload from disk to confirm persistence
flags' <- loadFlags dir ["a"]
isEnabled flags' "a" `shouldReturn` False
it "concurrent writes leave the file consistent with the TVar" $
hedgehog $ do
n <- forAll $ Gen.int (Range.linear 1 50)
liftIO $ withTempDirectory "/tmp" "flags-spec" $ \dir -> do
flags <- loadFlags dir ["x"]
mapConcurrently_ (\i -> setEnabled flags "x" (even i)) [1 .. n]
m <- allFlags flags
decoded <- decodeFileStrict (dir <> "/handlers.json")
(decoded :: Maybe (Map Text Bool)) `shouldBe` Just m
+2
View File
@@ -6,6 +6,7 @@ import qualified AFRPSpec
import qualified BedroomSpec import qualified BedroomSpec
import qualified BusSpec import qualified BusSpec
import qualified ConnectionSpec import qualified ConnectionSpec
import qualified FlagsSpec
import qualified GraphingSpec import qualified GraphingSpec
import qualified MetricsSpec import qualified MetricsSpec
import qualified RuntimeSpec import qualified RuntimeSpec
@@ -17,6 +18,7 @@ main = hspec $ do
BedroomSpec.spec BedroomSpec.spec
BusSpec.spec BusSpec.spec
ConnectionSpec.spec ConnectionSpec.spec
FlagsSpec.spec
GraphingSpec.spec GraphingSpec.spec
MetricsSpec.spec MetricsSpec.spec
RuntimeSpec.spec RuntimeSpec.spec
+1 -1
View File
@@ -20,7 +20,7 @@ import AFRP (Mealy (..), Request (..), stepAuto)
import HomeAssistant.Controller (HASSEff (..), Service) import HomeAssistant.Controller (HASSEff (..), Service)
fakeRequest :: Request fakeRequest :: Request
fakeRequest = Request (sec 0) utc nil fakeRequest = Request (sec 0) utc nil "test"
sec :: Integer -> UTCTime sec :: Integer -> UTCTime
sec n = UTCTime (toEnum 0) (fromIntegral n) sec n = UTCTime (toEnum 0) (fromIntegral n)