lifx-lan 0.2.0 → 0.3.0
raw patch · 3 files changed
+204/−43 lines, 3 filesdep +transformersPVP ok
version bump matches the API change (PVP)
Dependencies added: transformers
API changes (from Hackage documentation)
- Lifx.Lan: WrongSequenceNumber :: Word8 -> Word8 -> LifxError
+ Lifx.Lan: BroadcastTimeout :: [HostAddress] -> LifxError
+ Lifx.Lan: ServiceReserved1 :: Service
+ Lifx.Lan: ServiceReserved2 :: Service
+ Lifx.Lan: ServiceReserved3 :: Service
+ Lifx.Lan: ServiceReserved4 :: Service
+ Lifx.Lan: ServiceUDP :: Service
+ Lifx.Lan: StateService :: Service -> Word32 -> StateService
+ Lifx.Lan: UnexpectedPort :: PortNumber -> LifxError
+ Lifx.Lan: UnexpectedSockAddrType :: SockAddr -> LifxError
+ Lifx.Lan: [$sel:port:StateService] :: StateService -> Word32
+ Lifx.Lan: [$sel:service:StateService] :: StateService -> Service
+ Lifx.Lan: [GetService] :: Message StateService
+ Lifx.Lan: broadcastMessage :: MonadLifx m => Message a -> m [(HostAddress, a)]
+ Lifx.Lan: data Service
+ Lifx.Lan: data StateService
+ Lifx.Lan: discoverDevices :: MonadLifx m => Maybe Int -> m [HostAddress]
+ Lifx.Lan: handleOldMessage :: MonadLifx m => Word8 -> Word8 -> Word16 -> ByteString -> m ()
+ Lifx.Lan: instance GHC.Classes.Eq Lifx.Lan.Service
+ Lifx.Lan: instance GHC.Classes.Eq Lifx.Lan.StateService
+ Lifx.Lan: instance GHC.Classes.Ord Lifx.Lan.Service
+ Lifx.Lan: instance GHC.Classes.Ord Lifx.Lan.StateService
+ Lifx.Lan: instance GHC.Generics.Generic Lifx.Lan.Service
+ Lifx.Lan: instance GHC.Generics.Generic Lifx.Lan.StateService
+ Lifx.Lan: instance GHC.Show.Show Lifx.Lan.Service
+ Lifx.Lan: instance GHC.Show.Show Lifx.Lan.StateService
+ Lifx.Lan: instance Lifx.Lan.MonadLifx m => Lifx.Lan.MonadLifx (Control.Monad.Trans.Except.ExceptT e m)
+ Lifx.Lan: instance Lifx.Lan.MonadLifx m => Lifx.Lan.MonadLifx (Control.Monad.Trans.Maybe.MaybeT m)
+ Lifx.Lan: instance Lifx.Lan.Response Lifx.Lan.StateService
- Lifx.Lan: WrongSender :: SockAddr -> SockAddr -> LifxError
+ Lifx.Lan: WrongSender :: HostAddress -> HostAddress -> LifxError
- Lifx.Lan: encodeMessage :: Bool -> Word8 -> Word32 -> Message a -> ByteString
+ Lifx.Lan: encodeMessage :: Bool -> Bool -> Word8 -> Word32 -> Message a -> ByteString
Files
- CHANGELOG.md +6/−6
- lifx-lan.cabal +2/−1
- src/Lifx/Lan.hs +196/−36
CHANGELOG.md view
@@ -1,11 +1,11 @@ # Revision history for lifx-lan -## 0.2.0 -- 2021-06-18--* Enable querying state (colour or power level).+## 0.3.0 -- 2021-06-19+- Implement message broadcasting and device discovery. - * Various breaking changes to enable this.+## 0.2.0 -- 2021-06-18+- Enable querying state (colour or power level).+ - Various breaking changes to enable this. ## 0.1.0.2--* Basic. Only supports setting power and colour.+- Basic. Only supports setting power and colour.
lifx-lan.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: lifx-lan-version: 0.2.0+version: 0.3.0 license: BSD-3-Clause license-file: LICENSE author: George Thomas@@ -30,6 +30,7 @@ safe ^>= 0.3.19, text ^>= 1.2.3, time ^>= 1.9.3,+ transformers ^>= 0.5.6, default-language: Haskell2010 default-extensions: AllowAmbiguousTypes
src/Lifx/Lan.hs view
@@ -1,5 +1,7 @@ module Lifx.Lan ( sendMessage,+ broadcastMessage,+ discoverDevices, Message (..), HSBK (..), Duration (..),@@ -12,6 +14,8 @@ -- * Responses LightState (..),+ StateService (..),+ Service (..), StatePower (..), -- * Low-level@@ -21,8 +25,10 @@ import Control.Monad import Control.Monad.Except+import Control.Monad.Extra import Control.Monad.Reader import Control.Monad.State hiding (get, put)+import Control.Monad.Trans.Maybe import Data.Binary import Data.Binary.Get hiding (label) import Data.Binary.Put@@ -30,7 +36,15 @@ import Data.ByteString qualified as BS import Data.ByteString.Lazy qualified as BL import Data.Either.Extra+import Data.Fixed+import Data.Foldable import Data.Function+import Data.Functor+import Data.List.NonEmpty (NonEmpty)+import Data.Map (Map)+import Data.Map.Strict qualified as Map+import Data.Maybe+import Data.Time import Data.Tuple.Extra import GHC.Generics (Generic) import GHC.IO.Exception@@ -45,40 +59,75 @@ lifxPort = 56700 sendMessage :: MonadLifx m => HostAddress -> Message a -> m a-sendMessage lightAddr msg = do- sock <- getSocket- source <- getSource+sendMessage receiver msg = do timeoutDuration <- getTimeout- counter <- getCounter incrementCounter- let receiver = SockAddrInet lifxPort lightAddr- void . liftIO $- sendTo- sock- (BL.toStrict $ encodeMessage False counter source msg)- receiver- getResponse' msg & either pure \(expectedPacketType, messageSize, getBody) -> do- (bs, sender) <-- throwEither . liftIO . fmap (maybeToEither RecvTimeout)- . timeout timeoutDuration- . recvFrom sock- $ headerSize + messageSize- when (sender /= receiver) $ lifxThrow $ WrongSender receiver sender- case runGetOrFail get $ BL.fromStrict bs of- Left e -> throwDecodeFailure e- Right (bs', _, Header{packetType, sequenceCounter}) -> do- when (sequenceCounter /= counter) $ lifxThrow $ WrongSequenceNumber counter sequenceCounter- when (packetType /= expectedPacketType) $ lifxThrow $ WrongPacketType packetType expectedPacketType- case runGetOrFail getBody bs' of- Left e -> throwDecodeFailure e- Right (_, _, res) -> pure res+ sendMessage' True receiver msg+ getResponse' msg & either pure \(expectedPacketType, messageSize, getBody) -> untilJustM do+ (bs, sender0) <- throwEither $ maybeToEither RecvTimeout <$> receiveMessage timeoutDuration messageSize+ sender <- hostAddressFromSock sender0+ res <- decodeMessage getBody expectedPacketType bs+ when (isJust res && sender /= receiver) $ lifxThrow $ WrongSender receiver sender+ pure res where- throwDecodeFailure (bs, bo, e) = lifxThrow $ DecodeFailure (BL.toStrict bs) bo e throwEither x = x >>= \case Left e -> lifxThrow e Right r -> pure r +broadcastMessage :: MonadLifx m => Message a -> m [(HostAddress, a)]+broadcastMessage =+ fmap (concatMap (\(a, xs) -> map (a,) $ toList xs) . Map.toList)+ . broadcastMessage' (const $ pure . pure) Nothing+broadcastMessage' ::+ MonadLifx 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.+ Maybe (Map HostAddress (NonEmpty b) -> Bool) ->+ Message a ->+ m (Map HostAddress (NonEmpty b))+broadcastMessage' filter' maybeFinished msg = do+ timeoutDuration <- getTimeout+ incrementCounter+ sendMessage' False receiver msg+ getResponse' msg & either noResponseNeeded \(expectedPacketType, messageSize, getBody) -> do+ t0 <- liftIO getCurrentTime+ flip execStateT Map.empty $ untilM do+ t <- liftIO getCurrentTime+ let timeLeft = timeoutDuration - nominalDiffTimeToMicroSeconds (diffUTCTime t t0)+ if timeLeft < 0+ then pure False+ else+ receiveMessage timeLeft messageSize >>= \case+ Just (bs, addr) -> do+ decodeMessage getBody expectedPacketType bs >>= \case+ Just x -> do+ hostAddr <- hostAddressFromSock addr+ lift (filter' hostAddr x) >>= \case+ Just x' -> modify $ Map.insertWith (<>) hostAddr (pure x')+ Nothing -> pure ()+ Nothing -> pure ()+ 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+ pure True+ where+ receiver = tupleToHostAddress (255, 255, 255, 255)+ noResponseNeeded = fmap (maybe Map.empty $ Map.singleton receiver . pure) . filter' receiver++{- | If an integer argument is given, wait until we have responses from that number of devices.+Otherwise just keep waiting until timeout.+-}+discoverDevices :: MonadLifx m => Maybe Int -> m [HostAddress]+discoverDevices nDevices = Map.keys <$> broadcastMessage' f p GetService+ where+ f _addr StateService{..} = do+ checkPort $ fromIntegral port+ pure . guard $ service == ServiceUDP+ p = nDevices <&> \n -> (>= n) . length+ data HSBK = HSBK { hue :: Word16 , saturation :: Word16@@ -90,6 +139,7 @@ deriving (Eq, Ord, Show, Generic) data Message a where+ GetService :: Message StateService GetPower :: Message StatePower SetPower :: Bool -> Message () GetColor :: Message LightState@@ -99,6 +149,18 @@ deriving instance (Ord (Message a)) deriving instance (Show (Message a)) +data Service+ = ServiceUDP+ | ServiceReserved1+ | ServiceReserved2+ | ServiceReserved3+ | ServiceReserved4+ deriving (Eq, Ord, Show, Generic)+data StateService = StateService+ { service :: Service+ , port :: Word32+ }+ deriving (Eq, Ord, Show, Generic) newtype StatePower = StatePower { power :: Word16 }@@ -113,9 +175,11 @@ 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 SockAddr SockAddr -- expected, then actual- | WrongSequenceNumber Word8 Word8 -- expected, then actual+ | WrongSender HostAddress HostAddress -- expected, then actual+ | UnexpectedSockAddrType SockAddr+ | UnexpectedPort PortNumber deriving (Eq, Ord, Show, Generic) {- Message responses -}@@ -125,6 +189,18 @@ instance Response () where getResponse = Left ()+instance Response StateService where+ getResponse = Right $ (3,5,) do+ service <-+ getWord8 >>= \case+ 1 -> pure ServiceUDP+ 2 -> pure ServiceReserved1+ 3 -> pure ServiceReserved2+ 4 -> pure ServiceReserved3+ 5 -> pure ServiceReserved4+ n -> fail $ "unknown service: " <> show n+ port <- getWord32le+ pure StateService{..} instance Response LightState where getResponse = Right $ (107,52,) do hsbk <- HSBK <$> getWord16le <*> getWord16le <*> getWord16le <*> getWord16le@@ -141,6 +217,7 @@ -- | Seeing as all `Message` response types are instances of `Response`, we can hide that type class from users. getResponse' :: Message a -> Either a (Word16, Int, Get a) getResponse' = \case+ GetService{} -> getResponse GetPower{} -> getResponse SetPower{} -> getResponse GetColor{} -> getResponse@@ -170,12 +247,12 @@ , MonadIO ) -{- | Note that this throws 'LifxError's as 'IOException's, and sets timeout to 1 second.+{- | Note that this throws 'LifxError's as 'IOException's, and sets timeout to 5 seconds. Use 'runLifxT' for more control. -} runLifx :: Lifx a -> IO a runLifx m =- runLifxT 1_000_000 m >>= \case+ runLifxT 5_000_000 m >>= \case Left e -> ioError IOError@@ -191,6 +268,7 @@ runLifxT :: MonadIO m => Int -> LifxT m a -> m (Either LifxError a) runLifxT timeoutDuration (LifxT x) = do sock <- liftIO $ socket AF_INET Datagram defaultProtocol+ liftIO $ setSocketOption sock Broadcast 1 liftIO . bind sock $ SockAddrInet defaultPort 0 source <- randomIO runExceptT $ runReaderT (evalStateT x 0) (sock, source, timeoutDuration)@@ -202,6 +280,17 @@ 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 () instance MonadIO m => MonadLifx (LifxT m) where getSocket = LifxT $ asks fst3 getSource = LifxT $ asks snd3@@ -209,6 +298,20 @@ incrementCounter = LifxT $ modify succ' getCounter = LifxT $ gets id lifxThrow = LifxT . throwError+instance MonadLifx m => MonadLifx (MaybeT m) where+ getSocket = lift getSocket+ getSource = lift getSource+ getTimeout = lift getTimeout+ incrementCounter = lift incrementCounter+ getCounter = lift getCounter+ 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+ lifxThrow = lift . lifxThrow instance MonadLifx m => MonadLifx (StateT s m) where getSocket = lift getSocket getSource = lift getSource@@ -226,9 +329,9 @@ {- Low level -} -encodeMessage :: Bool -> Word8 -> Word32 -> Message a -> BL.ByteString-encodeMessage ackRequired sequenceCounter source msg =- runPut $ put (messageHeader ackRequired sequenceCounter source msg) >> putMessagePayload msg+encodeMessage :: Bool -> Bool -> Word8 -> Word32 -> Message a -> BL.ByteString+encodeMessage tagged ackRequired sequenceCounter source msg =+ runPut $ put (messageHeader tagged ackRequired sequenceCounter source msg) >> putMessagePayload msg -- | https://lan.developer.lifx.com/docs/encoding-a-packet data Header = Header@@ -288,8 +391,14 @@ where bitIf b n = if b then bit n else zeroBits -messageHeader :: Bool -> Word8 -> Word32 -> Message a -> Header-messageHeader ackRequired sequenceCounter source = \case+messageHeader :: Bool -> Bool -> Word8 -> Word32 -> Message a -> Header+messageHeader tagged ackRequired sequenceCounter source = \case+ GetService{} ->+ Header+ { size = headerSize+ , packetType = 2+ , ..+ } GetPower{} -> Header { size = headerSize@@ -323,13 +432,13 @@ where target = 0 :: Word64 protocol = 1024 :: Word16- tagged = True addressable = True origin = 0 :: Word8 resRequired = False putMessagePayload :: Message a -> Put putMessagePayload = \case+ GetService -> mempty GetPower -> mempty SetPower b -> putWord16le if b then maxBound else minBound@@ -355,3 +464,54 @@ headerSize :: Num a => a headerSize = 36++-- | For use with 'timeout', 'threadDelay' etc.+nominalDiffTimeToMicroSeconds :: NominalDiffTime -> Int+nominalDiffTimeToMicroSeconds t = fromInteger $ p `div` 1_000_000+ where+ MkFixed p = nominalDiffTimeToSeconds t++-- | Inverted 'whileM'.+untilM :: Monad m => m Bool -> m ()+untilM = whileM . fmap not++checkPort :: MonadLifx f => PortNumber -> f ()+checkPort port = when (port /= fromIntegral lifxPort) . lifxThrow $ UnexpectedPort port++-- these helpers are all used by 'sendMessage' and 'broadcastMessage'+decodeMessage :: MonadLifx m => Get b -> Word16 -> BS.ByteString -> m (Maybe b) -- Nothing means counter didnt match+decodeMessage getBody expectedPacketType bs = do+ counter <- getCounter+ case runGetOrFail get $ BL.fromStrict bs of+ Left e -> throwDecodeFailure e+ Right (bs', _, Header{packetType, sequenceCounter}) ->+ if sequenceCounter /= counter+ then handleOldMessage counter sequenceCounter packetType bs' >> pure Nothing+ else do+ when (packetType /= expectedPacketType) . lifxThrow $ WrongPacketType expectedPacketType 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 a -> m ()+sendMessage' tagged receiver msg = do+ sock <- getSocket+ counter <- getCounter+ source <- getSource+ void . liftIO $+ sendTo+ sock+ (BL.toStrict $ encodeMessage tagged False counter source msg)+ (SockAddrInet lifxPort receiver)+hostAddressFromSock :: MonadLifx 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))+receiveMessage t messageSize = do+ sock <- getSocket+ liftIO+ . timeout t+ . recvFrom sock+ $ headerSize + messageSize