packages feed

lifx-lan 0.6.2 → 0.7

raw patch · 8 files changed

+450/−285 lines, 8 filesdep +ansi-terminaldep +colourdep ~basePVP ok

version bump matches the API change (PVP)

Dependencies added: ansi-terminal, colour

Dependency ranges changed: base

API changes (from Hackage documentation)

- Lifx.Internal.ProductInfoMap: [$sel:productId:Product] :: Product -> Word32
- Lifx.Lan: ($sel:unLifxT:LifxT) :: LifxT m a -> StateT Word8 (ReaderT (Socket, Word32, Int) (ExceptT LifxError m)) a
- Lifx.Lan: LifxT :: StateT Word8 (ReaderT (Socket, Word32, Int) (ExceptT LifxError m)) a -> LifxT m a
- Lifx.Lan: [$sel:productId:Product] :: Product -> Word32
- Lifx.Lan: getCounter :: MonadLifx m => m Word8
- Lifx.Lan: getSocket :: MonadLifx m => m Socket
- Lifx.Lan: getSource :: MonadLifx m => m Word32
- Lifx.Lan: getTimeout :: MonadLifx m => m Int
- Lifx.Lan: handleOldMessage :: MonadLifx m => Word8 -> Word8 -> Word16 -> ByteString -> m ()
- Lifx.Lan: incrementCounter :: MonadLifx m => m ()
- Lifx.Lan: instance Control.Monad.IO.Class.MonadIO m => Control.Monad.IO.Class.MonadIO (Lifx.Lan.LifxT m)
- Lifx.Lan: instance Control.Monad.IO.Class.MonadIO m => Lifx.Lan.MonadLifx (Lifx.Lan.LifxT m)
- Lifx.Lan: instance GHC.Base.Functor m => GHC.Base.Functor (Lifx.Lan.LifxT m)
- Lifx.Lan: instance GHC.Base.Monad m => GHC.Base.Applicative (Lifx.Lan.LifxT m)
- Lifx.Lan: instance GHC.Base.Monad m => GHC.Base.Monad (Lifx.Lan.LifxT m)
- Lifx.Lan: instance GHC.Classes.Eq Lifx.Lan.Device
- Lifx.Lan: instance GHC.Classes.Eq Lifx.Lan.HSBK
- Lifx.Lan: instance GHC.Classes.Eq Lifx.Lan.LifxError
- Lifx.Lan: instance GHC.Classes.Ord Lifx.Lan.Device
- Lifx.Lan: instance GHC.Classes.Ord Lifx.Lan.HSBK
- Lifx.Lan: instance GHC.Classes.Ord Lifx.Lan.LifxError
- Lifx.Lan: instance GHC.Generics.Generic Lifx.Lan.HSBK
- Lifx.Lan: instance GHC.Generics.Generic Lifx.Lan.LifxError
- Lifx.Lan: instance GHC.Show.Show Lifx.Lan.Device
- Lifx.Lan: instance GHC.Show.Show Lifx.Lan.HSBK
- Lifx.Lan: instance GHC.Show.Show Lifx.Lan.LifxError
- Lifx.Lan: newtype LifxT m a
+ Lifx.Internal.Colour: hsbkToRgb :: HSBK -> RGB Float
+ Lifx.Internal.Colour: interpolateColour :: Num a => a -> RGB a -> RGB a -> RGB a
+ Lifx.Internal.Colour: maxKelvin :: Num a => a
+ Lifx.Internal.Colour: maxWord16 :: Float
+ Lifx.Internal.Colour: minKelvin :: Num a => a
+ Lifx.Internal.ProductInfoMap: [$sel:id:Product] :: Product -> Word32
+ Lifx.Lan: -- | The type of errors associated with <tt>m</tt>.
+ Lifx.Lan: [$sel:id:Product] :: Product -> Word32
+ Lifx.Lan: data LifxT m a
+ Lifx.Lan: instance Control.Monad.IO.Class.MonadIO m => Lifx.Lan.MonadLifx (Lifx.Lan.Internal.LifxT m)
+ Lifx.Lan: liftProductLookupError :: MonadLifx m => ProductLookupError -> MonadLifxError m
+ Lifx.Lan: sendMessageAndWait :: (MonadLifx m, MonadIO m) => Device -> Message () -> m ()
+ Lifx.Lan: type MonadLifxError m;
+ Lifx.Lan: }
+ Lifx.Lan.Internal: BroadcastTimeout :: [HostAddress] -> LifxError
+ Lifx.Lan.Internal: DecodeFailure :: ByteString -> ByteOffset -> String -> LifxError
+ Lifx.Lan.Internal: Device :: HostAddress -> Device
+ Lifx.Lan.Internal: HSBK :: Word16 -> Word16 -> Word16 -> Word16 -> HSBK
+ Lifx.Lan.Internal: LifxT :: StateT Word8 (ReaderT (Socket, Word32, Int) (ExceptT LifxError m)) a -> LifxT m a
+ Lifx.Lan.Internal: ProductLookupError :: ProductLookupError -> LifxError
+ Lifx.Lan.Internal: RecvTimeout :: LifxError
+ Lifx.Lan.Internal: UnexpectedPort :: PortNumber -> LifxError
+ Lifx.Lan.Internal: UnexpectedSockAddrType :: SockAddr -> LifxError
+ Lifx.Lan.Internal: WrongPacketType :: Word16 -> Word16 -> LifxError
+ Lifx.Lan.Internal: WrongSender :: Device -> HostAddress -> LifxError
+ Lifx.Lan.Internal: [$sel:brightness:HSBK] :: HSBK -> Word16
+ Lifx.Lan.Internal: [$sel:hue:HSBK] :: HSBK -> Word16
+ Lifx.Lan.Internal: [$sel:kelvin:HSBK] :: HSBK -> Word16
+ Lifx.Lan.Internal: [$sel:saturation:HSBK] :: HSBK -> Word16
+ Lifx.Lan.Internal: [$sel:unwrap:Device] :: Device -> HostAddress
+ Lifx.Lan.Internal: [$sel:unwrap:LifxT] :: LifxT m a -> StateT Word8 (ReaderT (Socket, Word32, Int) (ExceptT LifxError m)) a
+ Lifx.Lan.Internal: class MonadIO m => MonadLifxIO m
+ Lifx.Lan.Internal: data HSBK
+ Lifx.Lan.Internal: data LifxError
+ Lifx.Lan.Internal: getCounter :: MonadLifxIO m => m Word8
+ Lifx.Lan.Internal: getSocket :: MonadLifxIO m => m Socket
+ Lifx.Lan.Internal: getSource :: MonadLifxIO m => m Word32
+ Lifx.Lan.Internal: getTimeout :: MonadLifxIO m => m Int
+ Lifx.Lan.Internal: handleOldMessage :: MonadLifxIO m => Word8 -> Word8 -> Word16 -> ByteString -> m ()
+ Lifx.Lan.Internal: incrementCounter :: MonadLifxIO m => m ()
+ Lifx.Lan.Internal: instance Control.Monad.IO.Class.MonadIO m => Control.Monad.IO.Class.MonadIO (Lifx.Lan.Internal.LifxT m)
+ Lifx.Lan.Internal: instance Control.Monad.IO.Class.MonadIO m => Lifx.Lan.Internal.MonadLifxIO (Lifx.Lan.Internal.LifxT m)
+ Lifx.Lan.Internal: instance GHC.Base.Functor m => GHC.Base.Functor (Lifx.Lan.Internal.LifxT m)
+ Lifx.Lan.Internal: instance GHC.Base.Monad m => GHC.Base.Applicative (Lifx.Lan.Internal.LifxT m)
+ Lifx.Lan.Internal: instance GHC.Base.Monad m => GHC.Base.Monad (Lifx.Lan.Internal.LifxT m)
+ Lifx.Lan.Internal: instance GHC.Classes.Eq Lifx.Lan.Internal.Device
+ Lifx.Lan.Internal: instance GHC.Classes.Eq Lifx.Lan.Internal.HSBK
+ Lifx.Lan.Internal: instance GHC.Classes.Eq Lifx.Lan.Internal.LifxError
+ Lifx.Lan.Internal: instance GHC.Classes.Ord Lifx.Lan.Internal.Device
+ Lifx.Lan.Internal: instance GHC.Classes.Ord Lifx.Lan.Internal.HSBK
+ Lifx.Lan.Internal: instance GHC.Classes.Ord Lifx.Lan.Internal.LifxError
+ Lifx.Lan.Internal: instance GHC.Generics.Generic Lifx.Lan.Internal.HSBK
+ Lifx.Lan.Internal: instance GHC.Generics.Generic Lifx.Lan.Internal.LifxError
+ Lifx.Lan.Internal: instance GHC.Show.Show Lifx.Lan.Internal.Device
+ Lifx.Lan.Internal: instance GHC.Show.Show Lifx.Lan.Internal.HSBK
+ Lifx.Lan.Internal: instance GHC.Show.Show Lifx.Lan.Internal.LifxError
+ Lifx.Lan.Internal: lifxThrowIO :: MonadLifxIO m => LifxError -> m a
+ Lifx.Lan.Internal: newtype Device
+ Lifx.Lan.Internal: newtype LifxT m a
+ Lifx.Lan.Internal: succ' :: (Eq a, Bounded a, Enum a) => a -> a
+ Lifx.Lan.Mock.Terminal: MockState :: LightState -> Maybe StateService -> Maybe StateHostFirmware -> Maybe StateVersion -> MockState
+ Lifx.Lan.Mock.Terminal: data Mock a
+ Lifx.Lan.Mock.Terminal: data MockError
+ Lifx.Lan.Mock.Terminal: data MockState
+ Lifx.Lan.Mock.Terminal: instance Control.Monad.Error.Class.MonadError Lifx.Lan.Mock.Terminal.MockError Lifx.Lan.Mock.Terminal.Mock
+ Lifx.Lan.Mock.Terminal: instance Control.Monad.IO.Class.MonadIO Lifx.Lan.Mock.Terminal.Mock
+ Lifx.Lan.Mock.Terminal: instance Control.Monad.Reader.Class.MonadReader [Lifx.Lan.Internal.Device] Lifx.Lan.Mock.Terminal.Mock
+ Lifx.Lan.Mock.Terminal: instance Control.Monad.State.Class.MonadState (Data.Map.Internal.Map Lifx.Lan.Internal.Device Lifx.Lan.Mock.Terminal.MockState) Lifx.Lan.Mock.Terminal.Mock
+ Lifx.Lan.Mock.Terminal: instance GHC.Base.Applicative Lifx.Lan.Mock.Terminal.Mock
+ Lifx.Lan.Mock.Terminal: instance GHC.Base.Functor Lifx.Lan.Mock.Terminal.Mock
+ Lifx.Lan.Mock.Terminal: instance GHC.Base.Monad Lifx.Lan.Mock.Terminal.Mock
+ Lifx.Lan.Mock.Terminal: instance GHC.Show.Show Lifx.Lan.Mock.Terminal.MockError
+ Lifx.Lan.Mock.Terminal: instance Lifx.Lan.MonadLifx Lifx.Lan.Mock.Terminal.Mock
+ Lifx.Lan.Mock.Terminal: runMock :: [(Device, Text)] -> Mock a -> IO (Either MockError a)
+ Lifx.Lan.Mock.Terminal: runMockFull :: [(Device, MockState)] -> Mock a -> IO (Either MockError a)
- Lifx.Lan: LightState :: HSBK -> Word16 -> ByteString -> LightState
+ Lifx.Lan: LightState :: HSBK -> Word16 -> Text -> LightState
- Lifx.Lan: [$sel:label:LightState] :: LightState -> ByteString
+ Lifx.Lan: [$sel:label:LightState] :: LightState -> Text
- Lifx.Lan: class MonadIO m => MonadLifx m
+ Lifx.Lan: class Monad m => MonadLifx m where {
- Lifx.Lan: getProductInfo :: MonadLifx m => Device -> m Product
+ Lifx.Lan: getProductInfo :: forall m. MonadLifx m => Device -> m Product
- Lifx.Lan: lifxThrow :: MonadLifx m => LifxError -> m a
+ Lifx.Lan: lifxThrow :: MonadLifx m => MonadLifxError m -> m a

Files

CHANGELOG.md view
@@ -1,5 +1,16 @@ # Revision history for lifx-lan +## 0.7 -- 2022-02-20+- Drop support for GHC < 9.2.+    - If anyone is stuck on an older version of GHC and needs recent features of `lifx-lan` then please let me know. It would be reasonably easy to create a branch for it.+- Don't provide field selector functions for any types. Using `OverloadedRecordDot` in client code is recommended. We still export `unLifxT` as a normal function, for backward compatibility.+- Move much of the implementation detail of `LifxT` has been moved to `Lifx.Lan.Internal`.+- Add `Lifx.Lan.Mock.Terminal` module for testing programs without a physical LIFX device.+- Add `sendMessageAndWait` function.+- Use `Text` rather than `ByteString` for `label` field of `LightState`.+- Rename `productId` field of `Product` to `id`.+- Update to latest products list.+ ## 0.6.2 -- 2022-02-02 - Update to latest products list. 
lifx-lan.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               lifx-lan-version:            0.6.2+version:            0.7 license:            BSD-3-Clause license-file:       LICENSE author:             George Thomas@@ -16,17 +16,22 @@  library     exposed-modules:+        Lifx.Lan+        Lifx.Lan.Mock.Terminal+        Lifx.Lan.Internal+        Lifx.Internal.Colour         Lifx.Internal.Product         Lifx.Internal.ProductInfo         Lifx.Internal.ProductInfoMap-        Lifx.Lan     hs-source-dirs: src     ghc-options:         -Wall     build-depends:-        base ^>= {4.14, 4.15, 4.16},+        base ^>= {4.16},+        ansi-terminal ^>= 0.11.1,         binary ^>= 0.8.8,         bytestring ^>= {0.10.8, 0.11},+        colour ^>= 2.3.6,         composition ^>= 1.0.2.1,         containers ^>= 0.6.2.1,         extra ^>= 1.7.1,@@ -56,11 +61,15 @@         GADTs         GeneralizedNewtypeDeriving         ImportQualifiedPost+        ImpredicativeTypes         LambdaCase         MultiParamTypeClasses         NamedFieldPuns+        NoFieldSelectors+        NoMonomorphismRestriction         NumericUnderscores         OverloadedLabels+        OverloadedRecordDot         OverloadedStrings         PartialTypeSignatures         RankNTypes@@ -69,5 +78,6 @@         StandaloneDeriving         TupleSections         TypeApplications+        TypeFamilies         TypeOperators         ViewPatterns
+ src/Lifx/Internal/Colour.hs view
@@ -0,0 +1,50 @@+module Lifx.Internal.Colour where++import Control.Applicative+import Data.Colour.SRGB+import Data.Ord+import Data.Word++import Data.Colour.RGBSpace.HSV (hsv)++import Lifx.Lan.Internal++{- |+Note that when 'kelvin' has an effect (i.e. when saturation is any less than maximum), output is somewhat arbitrary.++LIFX's team have never shared an exact forumla, and this implementation is inspired by various conflicting sources.+-}+hsbkToRgb :: HSBK -> RGB Float+hsbkToRgb HSBK{..} =+    interpolateColour+        (fromIntegral saturation / maxWord16)+        c+        c'+  where+    c =+        hsv+            (360 * fromIntegral hue / maxWord16)+            (fromIntegral saturation / maxWord16)+            (fromIntegral brightness / maxWord16)+    c' =+        let t =+                (log (fromIntegral kelvin) - log minKelvin)+                    / log (maxKelvin / minKelvin)+         in clamp (0, 1)+                <$> RGB+                    { channelRed = 1+                    , channelGreen = t / 2 + 0.5+                    , channelBlue = t+                    }++interpolateColour :: Num a => a -> RGB a -> RGB a -> RGB a+interpolateColour r = liftA2 (\a b -> a * (r + b * (1 - r)))++maxWord16 :: Float+maxWord16 = fromIntegral $ maxBound @Word16++minKelvin :: Num a => a+minKelvin = 1500++maxKelvin :: Num a => a+maxKelvin = 9000
src/Lifx/Internal/ProductInfo.hs view
@@ -1700,7 +1700,7 @@                 }             , ProductInfo                 { pid = 117-                , name = "LIFX Z"+                , name = "LIFX Z US"                 , features = PartialFeatures                     { hev = Nothing                     , color = Just True@@ -1720,7 +1720,7 @@                 }             , ProductInfo                 { pid = 118-                , name = "LIFX Z"+                , name = "LIFX Z Intl"                 , features = PartialFeatures                     { hev = Nothing                     , color = Just True@@ -1740,7 +1740,7 @@                 }             , ProductInfo                 { pid = 119-                , name = "LIFX Beam"+                , name = "LIFX Beam US"                 , features = PartialFeatures                     { hev = Nothing                     , color = Just True@@ -1760,7 +1760,7 @@                 }             , ProductInfo                 { pid = 120-                , name = "LIFX Beam"+                , name = "LIFX Beam Intl"                 , features = PartialFeatures                     { hev = Nothing                     , color = Just True
src/Lifx/Internal/ProductInfoMap.hs view
@@ -1,9 +1,6 @@ module Lifx.Internal.ProductInfoMap where  import Control.Applicative-import Data.Either.Extra-import Data.Foldable hiding (product)-import Data.Function import Data.Functor import Data.Maybe import Data.Tuple.Extra@@ -17,9 +14,6 @@ import Lifx.Internal.Product import Lifx.Internal.ProductInfo --- TODO RecordDotSyntax can make this and other hiding unnecessary (we could also use "id" instead of "productId"...)-import Prelude hiding (product)- productInfoMap :: Map Word32 (Features, Map Word32 ProductInfo) productInfoMap =     Map.fromList $@@ -27,14 +21,14 @@             ( vid             ,               ( defaults-              , Map.fromList $ (pid &&& id) <$> products+              , Map.fromList $ ((.pid) &&& id) <$> products               )             )  -- | Information about a particular LIFX product. data Product = Product     { name :: Text-    , productId :: Word32+    , id :: Word32     , features :: Features     }     deriving (Eq, Ord, Show, Generic)@@ -45,16 +39,16 @@     deriving (Eq, Ord, Show, Generic)  productLookup :: Word32 -> Word32 -> Word16 -> Word16 -> Either ProductLookupError Product-productLookup vendor product versionMinor versionMajor =+productLookup vendor prod versionMinor versionMajor =     case productInfoMap !? vendor of         Nothing -> Left $ UnknownVendorId vendor-        Just (defaults, products) -> case products !? product of-            Nothing -> Left $ UnknownProductId product+        Just (defaults, products) -> case products !? prod of+            Nothing -> Left $ UnknownProductId prod             Just ProductInfo{features = originalFeatures, ..} ->                 pure                     Product                         { name-                        , productId = product+                        , id = prod                         , features =                             completeFeatures defaults $                                 foldl@@ -67,61 +61,30 @@                                     upgrades                         }   where-    -- TODO RecordDotSyntax-    completeFeatures+    completeFeatures f pf =         Features-            { ..+            { hev = fromMaybe f.hev pf.hev+            , color = fromMaybe f.color pf.color+            , chain = fromMaybe f.chain pf.chain+            , matrix = fromMaybe f.matrix pf.matrix+            , relays = fromMaybe f.relays pf.relays+            , buttons = fromMaybe f.buttons pf.buttons+            , infrared = fromMaybe f.infrared pf.infrared+            , multizone = fromMaybe f.multizone pf.multizone+            , temperatureRange = pf.temperatureRange <|> f.temperatureRange+            , extendedMultizone = fromMaybe f.extendedMultizone pf.extendedMultizone             }-        PartialFeatures-            { hev = maybe_hev-            , color = maybe_color-            , chain = maybe_chain-            , matrix = maybe_matrix-            , relays = maybe_relays-            , buttons = maybe_buttons-            , infrared = maybe_infrared-            , multizone = maybe_multizone-            , temperatureRange = maybe_temperatureRange-            , extendedMultizone = maybe_extendedMultizone-            } =-            Features-                { hev = fromMaybe hev maybe_hev-                , color = fromMaybe color maybe_color-                , chain = fromMaybe chain maybe_chain-                , matrix = fromMaybe matrix maybe_matrix-                , relays = fromMaybe relays maybe_relays-                , buttons = fromMaybe buttons maybe_buttons-                , infrared = fromMaybe infrared maybe_infrared-                , multizone = fromMaybe multizone maybe_multizone-                , temperatureRange = maybe_temperatureRange <|> temperatureRange-                , extendedMultizone = fromMaybe extendedMultizone maybe_extendedMultizone-                }     -- left-biased-    addFeatures+    addFeatures new old =         PartialFeatures-            { ..+            { hev = new.hev <|> old.hev+            , color = new.color <|> old.color+            , chain = new.chain <|> old.chain+            , matrix = new.matrix <|> old.matrix+            , relays = new.relays <|> old.relays+            , buttons = new.buttons <|> old.buttons+            , infrared = new.infrared <|> old.infrared+            , multizone = new.multizone <|> old.multizone+            , temperatureRange = new.temperatureRange <|> old.temperatureRange+            , extendedMultizone = new.extendedMultizone <|> old.extendedMultizone             }-        PartialFeatures-            { hev = old_hev-            , color = old_color-            , chain = old_chain-            , matrix = old_matrix-            , relays = old_relays-            , buttons = old_buttons-            , infrared = old_infrared-            , multizone = old_multizone-            , temperatureRange = old_temperatureRange-            , extendedMultizone = old_extendedMultizone-            } =-            PartialFeatures-                { hev = hev <|> old_hev-                , color = color <|> old_color-                , chain = chain <|> old_chain-                , matrix = matrix <|> old_matrix-                , relays = relays <|> old_relays-                , buttons = buttons <|> old_buttons-                , infrared = infrared <|> old_infrared-                , multizone = multizone <|> old_multizone-                , temperatureRange = temperatureRange <|> old_temperatureRange-                , extendedMultizone = extendedMultizone <|> old_extendedMultizone-                }
src/Lifx/Lan.hs view
@@ -21,18 +21,17 @@ module Lifx.Lan (     Device,     deviceAddress,-    sendMessage,-    broadcastMessage,-    discoverDevices,+    deviceFromAddress,     Message (..),     HSBK (..),     Lifx,     runLifx,-    LifxT (LifxT),+    LifxT,     runLifxT,     LifxError (..),     ProductLookupError (..),     MonadLifx (..),+    sendMessageAndWait,      -- * Responses     StateService (..),@@ -47,35 +46,35 @@     Product (..),     Features (..), -    -- * Low-level-    deviceFromAddress,+    -- * Message encoding++    -- | These are used internally by `LifxT`'s 'sendMessage' and 'broadcastMessage'.+    -- They are exposed in order to support some advanced use cases.     encodeMessage,     Header (..),-    unLifxT, ) where -import Control.Applicative+import Control.Concurrent import Control.Monad import Control.Monad.Except import Control.Monad.Extra import Control.Monad.Reader import Control.Monad.State import Control.Monad.Trans.Maybe+import Data.Composition import Data.Either.Extra import Data.Fixed-import Data.Foldable hiding (product)-import Data.Function+import Data.Foldable import Data.Functor-import Data.List hiding (product) import Data.Maybe-import Data.Tuple.Extra+import Data.Time import Data.Word+import Network.Socket import System.IO.Error  import Data.Binary (Binary) import Data.Binary qualified as Binary import Data.Binary.Get (-    ByteOffset,     Get,     getByteString,     getWord16le,@@ -100,104 +99,36 @@ import Data.List.NonEmpty (NonEmpty) import Data.Map (Map) import Data.Map.Strict qualified as Map-import Data.Time (-    NominalDiffTime,-    diffUTCTime,-    getCurrentTime,-    nominalDiffTimeToSeconds,- )+import Data.Text (Text)+import Data.Text.Encoding (decodeUtf8) import GHC.Generics (Generic)-import Network.Socket (-    Family (AF_INET),-    HostAddress,-    PortNumber,-    SockAddr (SockAddrInet),-    Socket,-    SocketOption (Broadcast),-    SocketType (Datagram),-    bind,-    defaultPort,-    defaultProtocol,-    hostAddressToTuple,-    setSocketOption,-    socket,-    tupleToHostAddress,- ) import Network.Socket.ByteString (recvFrom, sendTo) import System.Random (randomIO) import System.Timeout (timeout)  import Lifx.Internal.Product import Lifx.Internal.ProductInfoMap---- TODO RecordDotSyntax can make this and other hiding unnecessary (we could also use "id" instead of "productId"...)-import Prelude hiding (product)+import Lifx.Lan.Internal  {- Device -} --- | A LIFX device, such as a bulb.-newtype Device = Device {unDevice :: HostAddress}-    deriving newtype (Eq, Ord)--instance Show Device where-    show (Device ha) = let (a, b, c, d) = hostAddressToTuple ha in intercalate "." $ map show [a, b, c, d]- {- | >>> deviceFromAddress (192, 168, 0, 1) 192.168.0.1 -'Device's are really just 'HostAddress's, but you don't need to know that to use this library.-Prefer to get devices from 'discoverDevices' where possible, rather than hardcoding addresses.+If we know the IP address of a `Device`, we can create it directly, rather than calling `discoverDevices`. -} deviceFromAddress :: (Word8, Word8, Word8, Word8) -> Device deviceFromAddress = Device . tupleToHostAddress  deviceAddress :: Device -> HostAddress-deviceAddress = unDevice+deviceAddress = (.unwrap)  {- Core -}  lifxPort :: PortNumber lifxPort = 56700 --- | Send a message and wait for a response.-sendMessage :: MonadLifx m => Device -> Message r -> m r-sendMessage receiver msg = do-    incrementCounter-    sendMessage' True (unDevice receiver) msg-    Dict <- pure $ msgResWitness msg-    getSendResult receiver---- | Broadcast a message and wait for responses.-broadcastMessage :: MonadLifx m => Message r -> m [(Device, r)]-broadcastMessage msg =-    msgResWitness msg & \Dict ->-        concatMap (\(a, xs) -> map (a,) $ toList xs) . Map.toList-            <$> broadcastAndGetResult (const $ pure . pure) Nothing msg--{- |-Search for devices on the local network.-If an integer argument is given, wait until we have found that number of devices --otherwise just keep waiting until timeout.--}-discoverDevices :: MonadLifx m => Maybe Int -> m [Device]-discoverDevices nDevices = Map.keys <$> broadcastAndGetResult f p GetService-  where-    f _addr StateService{..} = do-        checkPort port-        pure . guard $ service == ServiceUDP-    p = nDevices <&> \n -> (>= n) . length---- | A colour. See https://lan.developer.lifx.com/docs/representing-color-with-hsbk.-data HSBK = HSBK-    { hue :: Word16-    , saturation :: Word16-    , brightness :: Word16-    , kelvin :: Word16-    -- ^ takes values in the range 1500 to 9000-    }-    deriving (Eq, Ord, Show, Generic)- -- | A message we can send to a 'Device'. 'r' is the type of the expected response. data Message r where     -- | https://lan.developer.lifx.com/docs/querying-the-device-for-data#getservice---packet-2@@ -268,41 +199,30 @@ data LightState = LightState     { hsbk :: HSBK     , power :: Word16-    , label :: BS.ByteString+    , label :: Text     }     deriving (Eq, Ord, Show, Generic) -data LifxError-    = DecodeFailure BS.ByteString ByteOffset String-    | RecvTimeout-    | BroadcastTimeout [HostAddress] -- contains the addresses which we have received valid responses from-    | WrongPacketType Word16 Word16 -- expected, then actual-    | WrongSender Device HostAddress -- expected, then actual-    | UnexpectedSockAddrType SockAddr-    | UnexpectedPort PortNumber-    | ProductLookupError ProductLookupError-    deriving (Eq, Ord, Show, Generic)- {- Message responses -}  class MessageResult a where-    getSendResult :: MonadLifx m => Device -> m a-    default getSendResult :: (MonadLifx m, Response a) => Device -> m a+    getSendResult :: MonadLifxIO m => Device -> m a+    default getSendResult :: (MonadLifxIO m, Response a) => Device -> m a     getSendResult receiver = untilJustM do         timeoutDuration <- getTimeout         (bs, sender0) <- throwEither $ maybeToEither RecvTimeout <$> receiveMessage timeoutDuration (messageSize @a)         sender <- hostAddressFromSock sender0         res <- decodeMessage @a bs-        when (isJust res && sender /= deviceAddress receiver) $ lifxThrow $ WrongSender receiver sender+        when (isJust res && sender /= deviceAddress receiver) $ lifxThrowIO $ WrongSender receiver sender         pure res       where         throwEither x =             x >>= \case-                Left e -> lifxThrow e+                Left e -> lifxThrowIO e                 Right r -> pure r      broadcastAndGetResult ::-        MonadLifx m =>+        MonadLifxIO m =>         -- | Transform output and discard messages which return 'Nothing'.         (HostAddress -> a -> m (Maybe b)) ->         -- | Return once this predicate over received messages passes. Otherwise just keep waiting until timeout.@@ -310,7 +230,7 @@         Message r ->         m (Map Device (NonEmpty b))     default broadcastAndGetResult ::-        (MonadLifx m, Response a) =>+        (MonadLifxIO m, Response a) =>         (HostAddress -> a -> m (Maybe b)) ->         Maybe (Map HostAddress (NonEmpty b) -> Bool) ->         Message r ->@@ -325,11 +245,11 @@             if timeLeft < 0                 then pure False                 else-                    receiveMessage timeLeft (messageSize @a) >>= \case+                    lift (receiveMessage timeLeft (messageSize @a)) >>= \case                         Just (bs, addr) -> do-                            decodeMessage @a bs >>= \case+                            lift (decodeMessage @a bs) >>= \case                                 Just x -> do-                                    hostAddr <- hostAddressFromSock addr+                                    hostAddr <- lift $ hostAddressFromSock addr                                     lift (filter' hostAddr x) >>= \case                                         Just x' -> modify $ Map.insertWith (<>) hostAddr (pure x')                                         Nothing -> pure ()@@ -337,7 +257,7 @@                             maybe (pure False) gets maybeFinished                         Nothing -> do                             -- if we were waiting for a predicate to pass, then we've timed out-                            when (isJust maybeFinished) $ lifxThrow . BroadcastTimeout =<< gets Map.keys+                            when (isJust maybeFinished) $ lift . lifxThrowIO . BroadcastTimeout =<< gets Map.keys                             pure True  class Response a where@@ -386,9 +306,9 @@     messageSize = 20     getBody = do         vendor <- getWord32le-        product <- getWord32le+        p <- getWord32le         skip 4-        pure StateVersion{..}+        pure StateVersion{product = p, ..} instance MessageResult StateVersion instance Response LightState where     expectedPacketType = 107@@ -397,52 +317,27 @@         hsbk <- HSBK <$> getWord16le <*> getWord16le <*> getWord16le <*> getWord16le         skip 2         power <- getWord16le-        label <- BS.takeWhile (/= 0) <$> getByteString 32+        label <- decodeUtf8 . BS.takeWhile (/= 0) <$> getByteString 32         skip 8         pure LightState{..} instance MessageResult LightState --- all `Message` response types are instances of `MessageResult`--- TODO ImpredicativeTypes:---   msgResWitness :: Message r -> (forall a. MessageResult a => x) -> x-msgResWitness :: Message r -> Dict (MessageResult r)-msgResWitness = \case-    GetService{} -> Dict-    GetHostFirmware{} -> Dict-    GetPower{} -> Dict-    SetPower{} -> Dict-    GetVersion{} -> Dict-    GetColor{} -> Dict-    SetColor{} -> Dict-    SetLightPower{} -> Dict-data Dict c where-    Dict :: c => Dict c+msgResWitness :: (MessageResult r => Message r -> a) -> (Message r -> a)+msgResWitness f m = case m of+    GetService{} -> f m+    GetHostFirmware{} -> f m+    GetPower{} -> f m+    SetPower{} -> f m+    GetVersion{} -> f m+    GetColor{} -> f m+    SetColor{} -> f m+    SetLightPower{} -> f m  {- Monad -}  -- | A simple implementation of 'MonadLifx'. type Lifx = LifxT IO -newtype LifxT m a = LifxT-    { unLifxT ::-        StateT-            Word8-            ( ReaderT-                (Socket, Word32, Int)-                ( ExceptT-                    LifxError-                    m-                )-            )-            a-    }-    deriving newtype-        ( Functor-        , Applicative-        , Monad-        , MonadIO-        )- {- | Note that this throws 'LifxError's as 'IOException's, and sets timeout to 5 seconds. Use 'runLifxT' for more control. -}@@ -465,65 +360,84 @@     source <- randomIO     runExceptT $ runReaderT (evalStateT x 0) (sock, source, timeoutDuration) --- | A monad for sending and receiving LIFX messages.-class MonadIO m => MonadLifx m where-    getSocket :: m Socket-    getSource :: m Word32-    getTimeout :: m Int-    incrementCounter :: m ()-    getCounter :: m Word8-    lifxThrow :: LifxError -> m a-    handleOldMessage ::-        -- | expected counter value-        Word8 ->-        -- | actual counter value-        Word8 ->-        -- | packet type-        Word16 ->-        -- | payload-        BL.ByteString ->-        m ()-    handleOldMessage _ _ _ _ = pure ()+class Monad m => MonadLifx m where+    -- | The type of errors associated with 'm'.+    type MonadLifxError m +    liftProductLookupError :: ProductLookupError -> MonadLifxError m+    lifxThrow :: MonadLifxError m -> m a++    -- | Send a message and wait for a response.+    sendMessage :: Device -> Message r -> m r++    -- | Broadcast a message and wait for responses.+    broadcastMessage :: Message r -> m [(Device, r)]++    -- | Search for devices on the local network.+    -- If an integer argument is given, wait until we have found that number of devices -+    -- otherwise just keep waiting until timeout.+    discoverDevices :: Maybe Int -> m [Device]+ instance MonadIO m => MonadLifx (LifxT m) where-    getSocket = LifxT $ asks fst3-    getSource = LifxT $ asks snd3-    getTimeout = LifxT $ asks thd3-    incrementCounter = LifxT $ modify succ'-    getCounter = LifxT $ gets id-    lifxThrow = LifxT . throwError+    type MonadLifxError (LifxT m) = LifxError+    lifxThrow = lifxThrowIO+    liftProductLookupError = ProductLookupError++    sendMessage receiver = msgResWitness \msg -> do+        incrementCounter+        sendMessage' True receiver.unwrap msg+        getSendResult receiver++    broadcastMessage = msgResWitness \msg ->+        concatMap (\(a, xs) -> map (a,) $ toList xs) . Map.toList+            <$> broadcastAndGetResult (const $ pure . pure) Nothing msg++    discoverDevices nDevices = Map.keys <$> broadcastAndGetResult f p GetService+      where+        f _addr StateService{..} = do+            checkPort port+            pure . guard $ service == ServiceUDP+        p = nDevices <&> \n -> (>= n) . length instance MonadLifx m => MonadLifx (MaybeT m) where-    getSocket = lift getSocket-    getSource = lift getSource-    getTimeout = lift getTimeout-    incrementCounter = lift incrementCounter-    getCounter = lift getCounter+    type MonadLifxError (MaybeT m) = MonadLifxError m+    liftProductLookupError = liftProductLookupError @m+    sendMessage = lift .: sendMessage+    broadcastMessage = lift . broadcastMessage+    discoverDevices = lift . discoverDevices     lifxThrow = lift . lifxThrow instance MonadLifx m => MonadLifx (ExceptT e m) where-    getSocket = lift getSocket-    getSource = lift getSource-    getTimeout = lift getTimeout-    incrementCounter = lift incrementCounter-    getCounter = lift getCounter+    type MonadLifxError (ExceptT e m) = MonadLifxError m+    liftProductLookupError = liftProductLookupError @m+    sendMessage = lift .: sendMessage+    broadcastMessage = lift . broadcastMessage+    discoverDevices = lift . discoverDevices     lifxThrow = lift . lifxThrow instance MonadLifx m => MonadLifx (StateT s m) where-    getSocket = lift getSocket-    getSource = lift getSource-    getTimeout = lift getTimeout-    incrementCounter = lift incrementCounter-    getCounter = lift getCounter+    type MonadLifxError (StateT s m) = MonadLifxError m+    liftProductLookupError = liftProductLookupError @m+    sendMessage = lift .: sendMessage+    broadcastMessage = lift . broadcastMessage+    discoverDevices = lift . discoverDevices     lifxThrow = lift . lifxThrow instance MonadLifx m => MonadLifx (ReaderT e m) where-    getSocket = lift getSocket-    getSource = lift getSource-    getTimeout = lift getTimeout-    incrementCounter = lift incrementCounter-    getCounter = lift getCounter+    type MonadLifxError (ReaderT e m) = MonadLifxError m+    liftProductLookupError = liftProductLookupError @m+    sendMessage = lift .: sendMessage+    broadcastMessage = lift . broadcastMessage+    discoverDevices = lift . discoverDevices     lifxThrow = lift . lifxThrow -{- Low level -}--encodeMessage :: Bool -> Bool -> Word8 -> Word32 -> Message r -> BL.ByteString+encodeMessage ::+    -- | tagged+    Bool ->+    -- | ackRequired+    Bool ->+    -- | sequenceCounter+    Word8 ->+    -- | source+    Word32 ->+    Message r ->+    BL.ByteString encodeMessage tagged ackRequired sequenceCounter source msg =     runPut $ Binary.put (messageHeader tagged ackRequired sequenceCounter source msg) >> putMessagePayload msg @@ -663,20 +577,30 @@         putWord32le $ nominalDiffTimeToInt @Milli d  -- | Ask a device for its vendor and product ID, and look up info on it from the official database.-getProductInfo :: MonadLifx m => Device -> m Product+getProductInfo :: forall m. MonadLifx m => Device -> m Product getProductInfo dev = do     StateHostFirmware{..} <- sendMessage dev GetHostFirmware-    StateVersion{..} <- sendMessage dev GetVersion-    either (lifxThrow . ProductLookupError) pure $ productLookup vendor product versionMinor versionMajor+    v <- sendMessage dev GetVersion+    either (lifxThrow . liftProductLookupError @m) pure $ productLookup v.vendor v.product versionMinor versionMajor -{- Util -}+{- Higher-level helpers -} --- | Safe, wraparound variant of 'succ'.-succ' :: (Eq a, Bounded a, Enum a) => a -> a-succ' e-    | e == maxBound = minBound-    | otherwise = succ e+{- | Like `sendMessage`, but for messages whose effect is not instantaneous (e.g. `SetColor`),+block (using `threadDelay`) until completion.+-}+sendMessageAndWait :: (MonadLifx m, MonadIO m) => Device -> Message () -> m ()+sendMessageAndWait d m = do+    sendMessage d m+    maybe (pure ()) (liftIO . threadDelay . timeMicros) mt+  where+    mt = case m of+        SetPower{} -> Nothing+        SetColor _ t -> Just t+        SetLightPower _ t -> Just t+    timeMicros t = round $ t * 1_000_000 +{- Util -}+ fromIntegralSafe :: forall a b. (Integral a, Integral b, Bounded b) => a -> Maybe b fromIntegralSafe x =     guard@@ -697,11 +621,11 @@ untilM :: Monad m => m Bool -> m () untilM = whileM . fmap not -checkPort :: MonadLifx f => PortNumber -> f ()-checkPort port = when (port /= lifxPort) . lifxThrow $ UnexpectedPort port+checkPort :: MonadLifxIO f => PortNumber -> f ()+checkPort port = when (port /= lifxPort) . lifxThrowIO $ UnexpectedPort port  -- these helpers are all used by 'sendMessage' and 'broadcastMessage'-decodeMessage :: forall b m. (Response b, MonadLifx m) => BS.ByteString -> m (Maybe b) -- Nothing means counter mismatch+decodeMessage :: forall b m. (Response b, MonadLifxIO m) => BS.ByteString -> m (Maybe b) -- Nothing means counter mismatch decodeMessage bs = do     counter <- getCounter     case runGetOrFail Binary.get $ BL.fromStrict bs of@@ -710,14 +634,14 @@             if sequenceCounter /= counter                 then handleOldMessage counter sequenceCounter packetType bs' >> pure Nothing                 else do-                    when (packetType /= expectedPacketType @b) . lifxThrow $+                    when (packetType /= expectedPacketType @b) . lifxThrowIO $                         WrongPacketType (expectedPacketType @b) packetType                     case runGetOrFail getBody bs' of                         Left e -> throwDecodeFailure e                         Right (_, _, res) -> pure $ Just res   where-    throwDecodeFailure (bs', bo, e) = lifxThrow $ DecodeFailure (BL.toStrict bs') bo e-sendMessage' :: MonadLifx m => Bool -> HostAddress -> Message r -> m ()+    throwDecodeFailure (bs', bo, e) = lifxThrowIO $ DecodeFailure (BL.toStrict bs') bo e+sendMessage' :: MonadLifxIO m => Bool -> HostAddress -> Message r -> m () sendMessage' tagged receiver msg = do     sock <- getSocket     counter <- getCounter@@ -727,11 +651,11 @@             sock             (BL.toStrict $ encodeMessage tagged False counter source msg)             (SockAddrInet lifxPort receiver)-hostAddressFromSock :: MonadLifx m => SockAddr -> m HostAddress+hostAddressFromSock :: MonadLifxIO m => SockAddr -> m HostAddress hostAddressFromSock = \case     SockAddrInet port ha -> checkPort port >> pure ha-    addr -> lifxThrow $ UnexpectedSockAddrType addr-receiveMessage :: MonadLifx m => Int -> Int -> m (Maybe (BS.ByteString, SockAddr))+    addr -> lifxThrowIO $ UnexpectedSockAddrType addr+receiveMessage :: MonadLifxIO m => Int -> Int -> m (Maybe (BS.ByteString, SockAddr)) receiveMessage t size = do     sock <- getSocket     liftIO@@ -739,7 +663,7 @@         . recvFrom sock         $ headerSize + size -broadcast :: MonadLifx m => Message r -> m ()+broadcast :: MonadLifxIO m => Message r -> m () broadcast msg = do     incrementCounter     sendMessage' False (tupleToHostAddress (255, 255, 255, 255)) msg
+ src/Lifx/Lan/Internal.hs view
@@ -0,0 +1,100 @@+module Lifx.Lan.Internal where++import Control.Monad.Except+import Control.Monad.Reader+import Control.Monad.State+import Data.Binary.Get+import Data.List+import Data.Tuple.Extra+import Data.Word+import Network.Socket++import Data.ByteString qualified as BS+import Data.ByteString.Lazy qualified as BL+import GHC.Generics (Generic)++import Lifx.Internal.ProductInfoMap (ProductLookupError)++-- | A LIFX device, such as a bulb.+newtype Device = Device {unwrap :: HostAddress}+    deriving newtype (Eq, Ord)++instance Show Device where+    show (Device ha) = let (a, b, c, d) = hostAddressToTuple ha in intercalate "." $ map show [a, b, c, d]++-- | A colour. See https://lan.developer.lifx.com/docs/representing-color-with-hsbk.+data HSBK = HSBK+    { hue :: Word16+    , saturation :: Word16+    , brightness :: Word16+    , kelvin :: Word16+    -- ^ takes values in the range 1500 to 9000+    }+    deriving (Eq, Ord, Show, Generic)++data LifxError+    = DecodeFailure BS.ByteString ByteOffset String+    | RecvTimeout+    | BroadcastTimeout [HostAddress] -- contains the addresses which we have received valid responses from+    | WrongPacketType Word16 Word16 -- expected, then actual+    | WrongSender Device HostAddress -- expected, then actual+    | UnexpectedSockAddrType SockAddr+    | UnexpectedPort PortNumber+    | ProductLookupError ProductLookupError+    deriving (Eq, Ord, Show, Generic)++-- | A monad for sending and receiving LIFX messages.+class MonadIO m => MonadLifxIO m where+    getSocket :: m Socket+    getSource :: m Word32+    getTimeout :: m Int+    incrementCounter :: m ()+    getCounter :: m Word8+    lifxThrowIO :: LifxError -> m a+    handleOldMessage ::+        -- | expected counter value+        Word8 ->+        -- | actual counter value+        Word8 ->+        -- | packet type+        Word16 ->+        -- | payload+        BL.ByteString ->+        m ()+    handleOldMessage _ _ _ _ = pure ()++instance MonadIO m => MonadLifxIO (LifxT m) where+    getSocket = LifxT $ asks fst3+    getSource = LifxT $ asks snd3+    getTimeout = LifxT $ asks thd3+    incrementCounter = LifxT $ modify succ'+    getCounter = LifxT $ gets id+    lifxThrowIO = LifxT . throwError++newtype LifxT m a = LifxT+    { unwrap ::+        StateT+            Word8+            ( ReaderT+                (Socket, Word32, Int)+                ( ExceptT+                    LifxError+                    m+                )+            )+            a+    }+    deriving newtype+        ( Functor+        , Applicative+        , Monad+        , MonadIO+        )++{- Util -}++-- | Safe, wraparound variant of 'succ'.+succ' :: (Eq a, Bounded a, Enum a) => a -> a+succ' e+    | e == maxBound = minBound+    | otherwise = succ e
+ src/Lifx/Lan/Mock/Terminal.hs view
@@ -0,0 +1,107 @@+-- 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.Except+import Control.Monad.Reader+import Control.Monad.State+import Data.Bifunctor+import Data.Colour.RGBSpace+import Data.Colour.SRGB+import Data.Foldable++import Data.Map (Map)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text.IO qualified as T+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+        , MonadError MockError+        , MonadReader [Device]+        , MonadState (Map Device MockState)+        )++data MockState = MockState+    { light :: LightState+    , service :: Maybe StateService+    , hostFirmware :: Maybe StateHostFirmware+    , version :: Maybe StateVersion+    }++-- TODO this seems like a GHC bug+dotLabel :: LightState -> Text+-- dotLabel = (.label)+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)] -> Mock a -> IO (Either MockError a)+runMock = runMockFull . fmap (second \t -> MockState (LightState (HSBK 0 0 0 0) 1 t) Nothing Nothing Nothing)++-- | 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 = throwError+    liftProductLookupError = MockProductLookupError++    sendMessage d m = do+        s <- lookupDevice d+        r <- 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}}+            GetVersion -> whenProvided s.version+            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 <- 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 <=< gets . Map.lookup+        whenProvided = maybe (throwError MockDataNotProvided) pure+        convertPower = fromIntegral . fromEnum+        mkSGR s =+            if s.power /= 0+                then [SetRGBColor Background . uncurryRGB sRGB $ hsbkToRgb s.hsbk]+                else []+    broadcastMessage m = ask >>= traverse \d -> (d,) <$> sendMessage d m+    discoverDevices x = maybe id take x <$> ask