packages feed

lifx-lan-0.8.3: src/Lifx/Lan/Mock/Terminal.hs

-- TODO remove when `OverloadedRecordUpdate` is fully implemented (and simplify some nested updates) - hopefully 9.4
{-# OPTIONS_GHC -Wno-ambiguous-fields #-}

-- | Rather than interacting with any bulbs, simulate interactions by printing to a terminal.
module Lifx.Lan.Mock.Terminal (Mock, MockError, runMock, runMockFull, MockState (MockState)) where

import Control.Monad
import Control.Monad.Except
import Control.Monad.Reader
import Control.Monad.State
import Data.Colour.RGBSpace
import Data.Colour.SRGB
import Data.Foldable
import Data.Function
import Data.Functor
import Data.Traversable
import Data.Tuple.Extra
import Numeric.Natural

import Data.ByteString qualified as BS
import Data.List (unfoldr)
import Data.Map (Map)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text.IO qualified as T
import Data.Time.Clock.POSIX (getPOSIXTime)
import System.Console.ANSI hiding (SetColor)

import Lifx.Internal.Colour
import Lifx.Lan

newtype Mock a = Mock (StateT (Map Device MockState) (ReaderT [Device] (ExceptT MockError IO)) a)
    deriving newtype
        ( Functor
        , Applicative
        , Monad
        , MonadIO
        )

data MockState = MockState
    { light :: LightState
    , group :: StateGroup
    , service :: Maybe StateService
    , hostFirmware :: Maybe StateHostFirmware
    , version :: Maybe StateVersion
    }

dotLabel :: LightState -> Text
-- dotLabel = (.label) -- TODO this is a GHC bug: https://gitlab.haskell.org/ghc/ghc/-/issues/21226
dotLabel LightState{..} = label

{- | Run a LIFX action by mocking effects in a terminal.

Note that sending some messages (e.g. 'GetVersion') will throw exceptions, since the necessary state isn't specified.
See `runMockFull` for more control.
-}
runMock :: [(Device, (Text, Text))] -> Mock a -> IO (Either MockError a)
runMock ds m = do
    t0 <- getPOSIXTime
    flip runMockFull m . flip evalState 0 $ for ds \d ->
        state ((BS.pack . unfoldNat) &&& succ) <&> \uuid ->
            d & second \(label, group) ->
                MockState
                    { light = LightState (HSBK 0 0 0 0) 1 label
                    , group = StateGroup uuid group t0
                    , service = Nothing
                    , hostFirmware = Nothing
                    , version = Nothing
                    }
  where
    -- represent input as base-(maxBound @a)
    unfoldNat :: forall a. (Integral a, Bounded a) => Natural -> [a]
    unfoldNat = unfoldr \n -> guard (n /= 0) $> swap (second fromIntegral (n `quotRem` fromIntegral (maxBound @a)))

-- | More general version of `runMock`, which allows specifying extra information about devices.
runMockFull :: [(Device, MockState)] -> Mock a -> IO (Either MockError a)
runMockFull ds (Mock x) =
    runExceptT
        . flip
            runReaderT
            (fst <$> ds)
        . flip
            evalStateT
            (Map.fromList ds)
        $ x

data MockError
    = MockNoSuchDevice Device
    | MockProductLookupError ProductLookupError
    | MockDataNotProvided
    deriving (Show)

instance MonadLifx Mock where
    type MonadLifxError Mock = MockError
    lifxThrow = Mock . throwError
    liftProductLookupError = MockProductLookupError

    sendMessage d m = do
        s <- lookupDevice d
        r <- Mock case m of
            GetService -> whenProvided s.service
            GetHostFirmware -> whenProvided s.hostFirmware
            GetPower -> pure $ StatePower s.light.power
            SetPower (convertPower -> power) -> modify $ Map.insert d s{light = s.light{power}}
            SetLabel label -> modify $ Map.insert d s{light = s.light{label}}
            GetVersion -> whenProvided s.version
            GetGroup -> pure s.group
            GetColor -> pure s.light
            SetColor hsbk _t -> modify $ Map.insert d s{light = s.light{hsbk}}
            SetLightPower (convertPower -> power) _t -> modify $ Map.insert d s{light = s.light{power}}
        ds <- Mock ask
        for_ ds \d' -> do
            s' <- lookupDevice d'
            liftIO do
                setSGR $ mkSGR s'.light
                T.putStr $ dotLabel s'.light
                setSGR []
        liftIO $ putStrLn ""
        pure r
      where
        lookupDevice = maybe (lifxThrow $ MockNoSuchDevice d) pure <=< Mock . gets . Map.lookup
        whenProvided = maybe (throwError MockDataNotProvided) pure
        convertPower = fromIntegral . fromEnum
        mkSGR s = [SetRGBColor Background . uncurryRGB sRGB $ hsbkToRgb s.hsbk | s.power /= 0]
    broadcastMessage m = Mock ask >>= traverse \d -> (d,) <$> sendMessage d m
    discoverDevices = Mock . asks . maybe id take