{-| Module : Mellon.Controller.Async Description : An asynchronous @mellon-core@ controller Copyright : (c) 2018, Quixoftic, LLC License : BSD3 Maintainer : Drew Hess <dhess-src@quixoftic.com> Stability : experimental Portability : non-portable This module implements a thread-safe, asynchronous controller. Scheduled locks are run as background threads, which sleep until their events fire. == Exception safety All the controller actions provided in this module are exception-safe. If an exception occurs in a controller action (e.g., because the device throws an exception), the controller will be restored to its state as it was immediately prior to the execution of the action, and the exception will be re-thrown. After handling the exception, you can continue to execute actions on the controller, if you wish. However, the controller and the device may be out of sync at that point, or the device may continue to throw exceptions until it can be reset. The safest action to take after an exception occurs in a controller is to reset the device to a known working state; and then to create, from scratch, a new controller for the device. -} module Mellon.Controller.Async ( -- * An asynchronous controller implementation Controller , controller , minUnlockTime , lockController , unlockController , queryController -- * Re-exported types , Device(..) , State(..) ) where import Protolude hiding (State, state) import Control.Concurrent (MVar, modifyMVar, newMVar, readMVar, threadDelay) import Control.Concurrent.Async (async, link) import Control.Monad (void) import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Time (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime, picosecondsToDiffTime) import Mellon.Device (Device(..)) import Mellon.StateMachine (Input(..), Output(..), State(..), transition) -- | A concurrent, thread-safe controller type parameterized on its -- device type. -- -- Note that the type's constructor is not exported. You must use the -- 'controller' constructor to create a new value of this type; it -- ensures that the controller is initialized properly. data Controller d = Controller {_state :: !(MVar State) ,_minUnlockTime :: !NominalDiffTime ,_device :: !(Device d)} -- | Create a new 'Controller' value to control the given 'Device'. -- -- Controllers created by this constructor are thread-safe and may be -- passed around and controlled simultaneously on multiple threads. -- All actions exported by this module which act on a 'Controller' -- value are thread-safe. -- -- The controller locks and unlocks the given device in response to -- user commands and expiring unlocks. The controller assumes that -- this device has already been initialized and is ready for -- operation. It also assumes that it exclusively owns the device; do -- not pass the device to any other controllers or otherwise attempt -- to control the device while the returned 'Controller' value is -- live. -- -- The controller treats the device as a critical section; only one -- thread at a time will issue operations to the device. -- -- In order to synchronize the current device state with the state -- machine, the constructor will lock the device and set the state -- machine's initial state to 'StateLocked' before returning the new -- 'Controller' value. -- -- The optional 'NominalDiffTime' argument can be used to prevent the -- device from too rapidly switching from the locked->unlocked->locked -- states (/glitching/). Effectively, it specifies the minimum amount -- of time that the controller will unlock the device. This is useful -- for handling delayed unlock commands (for example, if the user is -- communicating with the controller via a network connection but the -- unlock command is delayed in transit because connection is down or -- lagged), extremely short unlock durations that might damage the -- physical access device, or hacking attempts. When the controller -- receives an unlock command, it compares the current time to the -- unlock command's expiration date. If the difference between the two -- times is less than the minimum unlock duration, or if the -- expiration date is in the past, then the controller will -- effectively ignore the unlock request. If the value of this -- argument is 'Nothing' or is negative, the controller treats it as a -- 0 value. controller :: (MonadIO m) => Maybe NominalDiffTime -> Device d -> m (Controller d) controller minUnlock device = liftIO $ do lockDevice device mvar <- newMVar StateLocked return $ Controller mvar (maybe 0 (max 0) minUnlock) device -- | Get the controller's minimum unlock time. minUnlockTime :: Controller d -> NominalDiffTime minUnlockTime = _minUnlockTime -- | Immediately lock the device controlled by the controller. -- -- Returns the new state of the controller. lockController :: (MonadIO m) => Controller d -> m State lockController = runMachine InputLockNow -- | Immediately unlock the device controlled by the controller, and -- keep it unlocked until the specified 'UTCTime'. -- -- If the specified time is in the past, then the device will unlock -- briefly, and then lock again after a brief amount of time. -- (__NOTE__: this behavior is considered to be a bug and will be -- fixed in a subsequent release.) -- -- Returns the new state of the controller. unlockController :: (MonadIO m) => UTCTime -> Controller d -> m State unlockController date = runMachine (InputUnlock date) -- | Query the controller's current state. queryController :: (MonadIO m) => Controller d -> m State queryController c = liftIO $ readMVar (_state c) runMachine :: (MonadIO m) => Input -> Controller d -> m State runMachine i c = let state = _state c in liftIO $ modifyMVar state $ \currentState -> do nextState <- go $ transition currentState i return (nextState, nextState) where go :: (MonadIO m) => (Maybe Output, State) -> m State go (Nothing, s) = return s go (Just OutputLock, s) = do liftIO $ lockDevice (_device c) return s go (Just (OutputUnlock date), s) = liftIO $ -- Don't let the lock glitch. If the expiration date is too near -- (or in the past), we ignore it (in which case we must keep -- the state machine in sync by telling it that the unlock has -- already expired). do now <- getCurrentTime if _minUnlockTime c `addUTCTime` now > date then return $ snd $ transition s (InputUnlockExpired date) else do unlockDevice (_device c) scheduleLock date return s go (Just (OutputRescheduleLock date), s) = liftIO $ -- The device is already unlocked, so we don't need to worry -- about a glitch here. do scheduleLock date return s -- For this particular implementation, it's safe simply to ignore -- this command. When the "unscheduled" lock fires, the state -- machine will simply ignore it. go (Just OutputCancelLock, s) = return s scheduleLock :: UTCTime -> IO () scheduleLock date = do a <- async $ do threadSleepUntil date void $ runMachine (InputUnlockExpired date) c -- Ensure exceptions which occur in the child thread are -- reported in the parent. link a -- 'threadDelay' takes an 'Int' argument which is measured in -- microseconds, so on 32-bit platforms, 'threadDelay' might not be -- able to delay long enough to accommodate even a day's sleep. -- Therefore, we need this mess. -- -- Does not account for leap seconds and is only precise to about 1 -- second, but I think that's probably OK. threadSleepUntil :: UTCTime -> IO () threadSleepUntil t = do now <- getCurrentTime let timeRemaining = diffUTCTime t now sleep timeRemaining where sleep :: NominalDiffTime -> IO () sleep r | r <= 0 = return () | r > maxThreadDelayInDiffTime = threadDelay maxThreadDelay >> threadSleepUntil t | otherwise = threadDelay $ nominalDiffTimeToMicroseconds r maxThreadDelay :: Int maxThreadDelay = maxBound maxThreadDelayInDiffTime :: NominalDiffTime maxThreadDelayInDiffTime = diffTimeToNominalDiffTime $ picosecondsToDiffTime $ toInteger maxThreadDelay * 1000000 where diffTimeToNominalDiffTime = realToFrac nominalDiffTimeToMicroseconds :: NominalDiffTime -> Int nominalDiffTimeToMicroseconds d = truncate $ d * 1000000