diff options
author | Paul Oliver <contact@pauloliver.dev> | 2025-07-02 15:06:35 +0200 |
---|---|---|
committer | Paul Oliver <contact@pauloliver.dev> | 2025-08-13 23:54:10 +0000 |
commit | 8fe62292f18f4577303a868a8557b0486b218bcb (patch) | |
tree | cb9a9108eea479e932f37d03cf399b680e3886b2 | |
parent | 0be7f1274e0cb8406bd4262b86d5e2e9dda77d7a (diff) |
Code now uses `effectful` to manage side-effects
-rw-r--r-- | Makefile | 14 | ||||
-rw-r--r-- | README.md | 4 | ||||
-rw-r--r-- | hsm-bin/Test/Drive.hs | 22 | ||||
-rw-r--r-- | hsm-bin/Test/Status.hs | 18 | ||||
-rw-r--r-- | hsm-bin/hsm-bin.cabal | 34 | ||||
-rw-r--r-- | hsm-core/Hsm/Core/Serial.hs | 53 | ||||
-rw-r--r-- | hsm-core/hsm-core.cabal | 15 | ||||
-rw-r--r-- | hsm-drive/Hsm/Drive.hs | 177 | ||||
-rw-r--r-- | hsm-drive/hsm-drive.cabal | 19 | ||||
-rw-r--r-- | hsm-gpio/Hsm/GPIO.hs | 284 | ||||
-rw-r--r-- | hsm-gpio/Hsm/GPIO/FFI.hsc | 116 | ||||
-rw-r--r-- | hsm-gpio/Hsm/GPIO/Lib.hsc | 116 | ||||
-rw-r--r-- | hsm-gpio/hsm-gpio.cabal | 19 | ||||
-rw-r--r-- | hsm-log/Hsm/Log.hs | 80 | ||||
-rw-r--r-- | hsm-log/hsm-log.cabal | 15 | ||||
-rw-r--r-- | hsm-pwm/Hsm/PWM.hs | 210 | ||||
-rw-r--r-- | hsm-pwm/hsm-pwm.cabal | 15 | ||||
-rw-r--r-- | hsm-readline/Hsm/Readline.hs | 50 | ||||
-rw-r--r-- | hsm-readline/hsm-readline.cabal | 18 | ||||
-rw-r--r-- | hsm-repl/Hsm/Repl.hs | 104 | ||||
-rw-r--r-- | hsm-repl/Test/Repl.hs | 15 | ||||
-rw-r--r-- | hsm-repl/hsm-repl.cabal | 36 | ||||
-rw-r--r-- | hsm-status/Hsm/Status.hs | 41 | ||||
-rw-r--r-- | hsm-status/hsm-status.cabal | 16 | ||||
-rw-r--r-- | stack.yaml | 11 | ||||
-rw-r--r-- | stack.yaml.lock | 24 | ||||
-rw-r--r-- | sysconf/98-gpiod.rules | 1 | ||||
-rw-r--r-- | sysconf/99-pwm.rules | 14 |
28 files changed, 734 insertions, 807 deletions
diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9dbf632 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +resolver = $(shell curl -s https://www.stackage.org/download/snapshots.json | yq .nightly) +packages = $(shell ls -dQm hsm-*) + +build: + stack build + +clean: + stack clean --full + +exec: + stack exec $(exec) + +update: + yq -iSY '.resolver=$(resolver) | .packages=[$(packages)]' stack.yaml @@ -56,7 +56,7 @@ without needing root access. 1. Install [`stack`](https://docs.haskellstack.org/en/stable/). It’s recommended to use [`ghcup`](https://www.haskell.org/ghcup/) for installation. -2. Run `stack build` to compile the libraries and executables +2. Run `make` to compile the libraries and executables > Note: You may need to install system dependencies on your host first (e.g., -> `libgpiod`, etc.) +> `libgpiod`, `libcamera`, etc.) diff --git a/hsm-bin/Test/Drive.hs b/hsm-bin/Test/Drive.hs deleted file mode 100644 index 7a58c11..0000000 --- a/hsm-bin/Test/Drive.hs +++ /dev/null @@ -1,22 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} - -import Control.IO.Region (region) -import Control.Monad.Loops (whileJust_) -import Hsm.Drive (DriveEnv, drive, driveEnvDefault) -import Hsm.GPIO (allocateGPIO) -import Hsm.PWM (allocatePWM) -import Hsm.Readline (allocateReadline, readline) - -newtype Env = Env - { driveEnv :: DriveEnv - } - -main :: IO () -main = - region $ \ioRegion -> do - lineRequest <- allocateGPIO ioRegion "test-status" - pwmHandle <- allocatePWM ioRegion $ const 0 - handle <- allocateReadline ioRegion - whileJust_ (readline handle) - $ drive lineRequest pwmHandle - $ Env driveEnvDefault diff --git a/hsm-bin/Test/Status.hs b/hsm-bin/Test/Status.hs deleted file mode 100644 index 62ba4fa..0000000 --- a/hsm-bin/Test/Status.hs +++ /dev/null @@ -1,18 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} - -import Control.IO.Region (region) -import Control.Monad.Loops (whileJust_) -import Hsm.GPIO (allocateGPIO) -import Hsm.Readline (allocateReadline, readline) -import Hsm.Status (StatusEnv, status, statusEnvDefault) - -newtype Env = Env - { statusEnv :: StatusEnv - } - -main :: IO () -main = - region $ \ioRegion -> do - lineRequest <- allocateGPIO ioRegion "test-status" - handle <- allocateReadline ioRegion - whileJust_ (readline handle) $ status lineRequest $ Env statusEnvDefault diff --git a/hsm-bin/hsm-bin.cabal b/hsm-bin/hsm-bin.cabal deleted file mode 100644 index de6e1a5..0000000 --- a/hsm-bin/hsm-bin.cabal +++ /dev/null @@ -1,34 +0,0 @@ -cabal-version: 3.4 -author: Paul Oliver -build-type: Simple -maintainer: contact@pauloliver.dev -name: hsm-bin -version: 0.1.0.0 - -common test-executable - build-depends: - , base - , hsm-gpio - , hsm-readline - , io-region - , monad-loops - - ghc-options: -Wall -Wunused-packages - - if !arch(x86_64) - ghc-options: -optl=-mno-fix-cortex-a53-835769 - - default-language: GHC2021 - -executable test-status - import: test-executable - build-depends: hsm-status - main-is: Test/Status.hs - -executable test-drive - import: test-executable - build-depends: - , hsm-drive - , hsm-pwm - - main-is: Test/Drive.hs diff --git a/hsm-core/Hsm/Core/Serial.hs b/hsm-core/Hsm/Core/Serial.hs new file mode 100644 index 0000000..0fc89e8 --- /dev/null +++ b/hsm-core/Hsm/Core/Serial.hs @@ -0,0 +1,53 @@ +{-# LANGUAGE TemplateHaskellQuotes #-} + +module Hsm.Core.Serial + ( makeSerial + ) +where + +import GHC.Num (integerFromInt) +import Language.Haskell.TH + ( Body (NormalB) + , Clause (Clause) + , Con (NormalC) + , Dec (DataD, FunD, SigD) + , DerivClause (DerivClause) + , Exp (LitE) + , Lit (IntegerL) + , Name + , Pat (ConP) + , Q + , Type (AppT, ArrowT, ConT) + , mkName + ) + +-- Generates a serial data type with the given name and a set of constructors, +-- each mapped to a corresponding integer value. +-- +-- - The data type derives `Bounded`, `Enum`, and `Show` for convenience. +-- - A companion mapping function is also generated, converting each constructor +-- to its associated integer. +-- +-- For debugging purposes, use `-ddump-splices` to inspect the generated code. +-- +-- Example: +-- +-- $(makeSerial "GPIO" "Pin" "pinLine" ''Int [2, 3, 4]) +-- +-- Generates a data type `GPIOPin` with constructors `GPIO2`, `GPIO3` `GPIO4`, +-- and a function `pinLine :: GPIOPin -> Int`. +makeSerial :: String -> String -> String -> Name -> [Int] -> Q [Dec] +makeSerial name suffix mapFun mapType idxs = + return + [ DataD [] dtName [] Nothing (idxCons <$> idxs) [derivClause] + , SigD mapFunName . AppT (AppT ArrowT $ ConT dtName) $ ConT mapType + , FunD mapFunName $ mapFunClause <$> idxs + ] + where + dtName = mkName $ name <> suffix + idxName idx = mkName $ name <> show idx + idxCons idx = NormalC (idxName idx) [] + derivClause = DerivClause Nothing [ConT ''Bounded, ConT ''Enum, ConT ''Show] + mapFunName = mkName mapFun + mapFunBody = NormalB . LitE . IntegerL . integerFromInt + mapFunClause idx = Clause [ConP (idxName idx) [] []] (mapFunBody idx) [] diff --git a/hsm-core/hsm-core.cabal b/hsm-core/hsm-core.cabal new file mode 100644 index 0000000..3242ff6 --- /dev/null +++ b/hsm-core/hsm-core.cabal @@ -0,0 +1,15 @@ +cabal-version: 3.8 +author: Paul Oliver +build-type: Simple +maintainer: contact@pauloliver.dev +name: hsm-core +version: 0.1.0.0 + +library + build-depends: + , base + , template-haskell + + default-language: GHC2024 + exposed-modules: Hsm.Core.Serial + ghc-options: -O2 -Wall -Werror -Wno-star-is-type -Wunused-packages diff --git a/hsm-drive/Hsm/Drive.hs b/hsm-drive/Hsm/Drive.hs deleted file mode 100644 index 3580b5a..0000000 --- a/hsm-drive/Hsm/Drive.hs +++ /dev/null @@ -1,177 +0,0 @@ -{-# LANGUAGE DataKinds #-} -{-# LANGUAGE OverloadedRecordDot #-} -{-# LANGUAGE OverloadedStrings #-} - -module Hsm.Drive - ( Speed(..) - , Direction(..) - , Duration - , DriveAction(..) - , DriveEnv(..) - , driveEnvDefault - , drive - ) where - -import Control.Concurrent (threadDelay) -import Control.Exception (AsyncException, handle) -import Control.Monad (forM_) -import Data.Text (pack) -import Data.Typeable (Typeable) -import Foreign.Ptr (Ptr) -import GHC.Records (HasField) -import Hsm.GPIO (GPIO(..), LineRequest, active, inactive, setPins) -import Hsm.Log qualified as L -import Hsm.PWM qualified as P -import System.IO.Echo (withoutInputEcho) - -data Direction - = N - | NE - | E - | SE - | S - | SW - | W - | NW - | RL - | RR - deriving (Read, Show, Typeable) - -data Speed - = SlowXXX - | SlowXX - | SlowX - | Slow - | Fast - | Top - deriving (Read, Show, Typeable) - -type Duration = Float - -data DriveAction - = Move Direction Speed Duration - | Stop Duration - deriving (Read, Show, Typeable) - --- This function maps a `Speed` value to a corresponding PWM cycle duration. --- It assumes a stepper motor with 200 steps per revolution, using a 1/16 --- microstep setting (so actually, 3200 steps per revolution). The returned --- values represent the duration of a whole PWM cycle in nanoseconds. -cycleDuration :: Speed -> Int -cycleDuration SlowXXX = 8000000000 `div` 3200 -- 1/8 revs/s -cycleDuration SlowXX = 4000000000 `div` 3200 -- 1/4 revs/s -cycleDuration SlowX = 2000000000 `div` 3200 -- 1/2 revs/s -cycleDuration Slow = 1000000000 `div` 3200 -- 1 revs/s -cycleDuration Fast = 500000000 `div` 3200 -- 2 revs/s -cycleDuration Top = 250000000 `div` 3200 -- 4 revs/s - -data DriveEnv = DriveEnv - { pinEnable :: GPIO - , pinDiag1 :: GPIO - , pinDiag2 :: GPIO - , pinDir1 :: GPIO - , pinDir2 :: GPIO - , pinDir3 :: GPIO - , pinDir4 :: GPIO - , pwmChannel :: P.PWMChannel - } - -driveEnvDefault :: DriveEnv -driveEnvDefault = - DriveEnv - { pinEnable = GPIO21 - , pinDiag1 = GPIO20 - , pinDiag2 = GPIO16 - , pinDir1 = GPIO12 - , pinDir2 = GPIO7 - , pinDir3 = GPIO8 - , pinDir4 = GPIO25 - , pwmChannel = P.PWM3 - } - -drive :: - HasField "driveEnv" env DriveEnv - => Ptr LineRequest - -> P.PWMHandle - -> env - -> [DriveAction] - -> IO Bool -drive lineRequest pwmHandle env actions = - withoutInputEcho $ handle handler runActions - where - logMsg = L.logMsg ["drive"] - -- Sets GPIO pins to a desired state - setCycleDuration = P.setCycleDuration pwmHandle env.driveEnv.pwmChannel - setActive pins = setPins lineRequest (pins <*> [env.driveEnv]) active - setInactive pins = setPins lineRequest (pins <*> [env.driveEnv]) inactive - -- Pin assignments for various movement directions, each direction - -- corresponds to a specific set of GPIO pins. - pinsDiag = [pinDiag1, pinDiag2] - pinsDir = [pinDir1, pinDir2, pinDir3, pinDir4] - pinDiagNE = [pinDiag1] - pinDiagSE = [pinDiag2] - pinDiagSW = [pinDiag1] - pinDiagNW = [pinDiag2] - pinsN = [pinDir2, pinDir4] - pinsE = [pinDir1, pinDir2] - pinsS = [pinDir1, pinDir3] - pinsW = [pinDir3, pinDir4] - pinsRL = [pinDir1, pinDir2, pinDir3, pinDir4] - pinsRR = [] - -- Introduces a delay with the duration converted from seconds to - -- microseconds. - runDelay = threadDelay . round . (* 1000000) - -- Ensures that the system is reset to a safe state by setting the PWM - -- cycle duration to zero and deactivating all used motor control pins. - runRelease = do - setCycleDuration 0 - setInactive $ pinsDir <> pinsDiag - -- Handles each movement command and activates the appropriate pins for - -- the requested direction. It also sets the cycle duration for PWM and - -- holds this state for the specified duration. - runAction (Move direction speed duration) = do - case direction of - N -> setActive $ pinsN <> pinsDiag - NE -> setActive $ pinsN <> pinDiagNE - E -> setActive $ pinsE <> pinsDiag - SE -> setActive $ pinsS <> pinDiagSE - S -> setActive $ pinsS <> pinsDiag - SW -> setActive $ pinsS <> pinDiagSW - W -> setActive $ pinsW <> pinsDiag - NW -> setActive $ pinsN <> pinDiagNW - RL -> setActive $ pinsRL <> pinsDiag - RR -> setActive $ pinsRR <> pinsDiag - setCycleDuration $ cycleDuration speed - runDelay duration - runRelease - -- A Stop command causes the system to wait for the specified duration - -- without performing any movement. During this period, the motor drivers - -- remain enabled, effectively applying a brake to the motor by holding it - -- in its current position. - runAction (Stop duration) = runDelay duration - -- Catches any asynchronous exceptions during the execution of commands. - -- If an exception occurs, the system will log the error and ensure that - -- the motors are safely released by calling `runRelease`. - handler exception = do - logMsg - $ "Async exception caught while command was running: " - <> pack (show @AsyncException exception) - runRelease - return False - -- Executes a series of drive actions - runActions = do - setCycleDuration 0 - -- The A4988 motor driver is placed in sleep mode between commands to - -- save power. To wake it up, a 1-microsecond delay is required before - -- sending step commands. For added safety, we wait 1 millisecond to - -- ensure the driver is fully awake. - logMsg "Enabling drivers" - setActive [pinEnable] - logMsg "Allowing drivers to come out of sleep mode" - threadDelay 1000 - forM_ actions $ \action -> do - logMsg $ "Running action " <> pack (show action) - runAction action - logMsg "Disabling drivers" - setInactive [pinEnable] - return True diff --git a/hsm-drive/hsm-drive.cabal b/hsm-drive/hsm-drive.cabal deleted file mode 100644 index a9dbe69..0000000 --- a/hsm-drive/hsm-drive.cabal +++ /dev/null @@ -1,19 +0,0 @@ -cabal-version: 3.4 -author: Paul Oliver -build-type: Simple -maintainer: contact@pauloliver.dev -name: hsm-drive -version: 0.1.0.0 - -library - build-depends: - , base - , echo - , hsm-gpio - , hsm-log - , hsm-pwm - , text - - exposed-modules: Hsm.Drive - ghc-options: -Wall -Wunused-packages - default-language: GHC2021 diff --git a/hsm-gpio/Hsm/GPIO.hs b/hsm-gpio/Hsm/GPIO.hs index dd69122..2bcf3ed 100644 --- a/hsm-gpio/Hsm/GPIO.hs +++ b/hsm-gpio/Hsm/GPIO.hs @@ -1,163 +1,161 @@ -{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE AllowAmbiguousTypes #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeFamilies #-} module Hsm.GPIO - ( G.active - , G.inactive - , G.LineRequest - , GPIO(..) + ( GPIOPin (..) + , GPIO , setPins , setAllPins - , allocateGPIO - ) where + , runGPIO + ) +where -import Control.IO.Region (Region, alloc, alloc_, defer, free) import Control.Monad (forM_, void) -import Data.ByteString (useAsCString) -import Data.Text (Text, pack) -import Data.Text.Encoding (encodeUtf8) -import Data.Vector.Storable qualified as V -import Foreign.C.Types (CSize(CSize), CUInt(CUInt)) +import Data.Vector.Storable (fromList, replicate, unsafeWith) +import Effectful (Dispatch (Static), DispatchOf, Eff, IOE, liftIO, (:>)) +import Effectful.Dispatch.Static + ( SideEffects (WithSideEffects) + , StaticRep + , evalStaticRep + , getStaticRep + , unsafeEff_ + ) +import Effectful.Resource (Resource, allocateEff, releaseEff) +import Foreign.C.String (withCString) +import Foreign.C.Types (CSize (CSize), CUInt) import Foreign.Ptr (Ptr) -import Hsm.GPIO.Lib qualified as G -import Hsm.Log qualified as L +import Hsm.Core.Serial (makeSerial) +import Hsm.GPIO.FFI + ( LineRequest + , LineValue + , chipClose + , chipOpen + , chipRequestLines + , inactive + , lineConfigAddLineSettings + , lineConfigFree + , lineConfigNew + , lineRequestRelease + , lineRequestSetValue + , lineRequestSetValues + , lineSettingsFree + , lineSettingsNew + , lineSettingsSetDirection + , lineSettingsSetOutputValue + , output + , requestConfigFree + , requestConfigNew + , requestConfigSetConsumer + ) +import Hsm.Log (Log, Severity (Info, Trace), logMsg) +import Prelude hiding (replicate) -logMsg :: Text -> IO () -logMsg = L.logMsg ["gpio"] +$(makeSerial "GPIO" "Pin" "pinLine" ''CUInt $ [2 .. 17] <> [20 .. 27]) -data GPIO - = GPIO2 - | GPIO3 - | GPIO4 - | GPIO5 - | GPIO6 - | GPIO7 - | GPIO8 - | GPIO9 - | GPIO10 - | GPIO11 - | GPIO12 - | GPIO13 - | GPIO14 - | GPIO15 - | GPIO16 - | GPIO17 - -- | GPIO18 -- reserved for PWM - -- | GPIO19 -- reserved for PWM - | GPIO20 - | GPIO21 - | GPIO22 - | GPIO23 - | GPIO24 - | GPIO25 - | GPIO26 - | GPIO27 - deriving (Bounded, Enum, Show) - -pinLine :: GPIO -> CUInt -pinLine = CUInt . read . drop 4 . show - -allPins :: [GPIO] +allPins :: [GPIOPin] allPins = [minBound .. maxBound] allLines :: [CUInt] allLines = pinLine <$> allPins -setPins :: Ptr G.LineRequest -> [GPIO] -> G.LineValue -> IO () -setPins lineRequest pins lineValue = do - logMsg - $ "Setting pin(s) " - <> pack (show pins) - <> " to state " - <> pack (show lineValue) - forM_ pins $ \pin -> G.lineRequestSetValue lineRequest (pinLine pin) lineValue +pinCount :: Int +pinCount = length allPins + +data GPIO (a :: * -> *) (b :: *) + +type instance DispatchOf GPIO = Static WithSideEffects + +newtype instance StaticRep GPIO + = GPIO (Ptr LineRequest) + +setPins :: (GPIO :> es, Log "gpio" :> es) => [GPIOPin] -> LineValue -> Eff es () +setPins pins lineValue = do + GPIO lineRequest <- getStaticRep + logMsg Trace $ "Setting pin(s) " <> show pins <> " to " <> show lineValue + forM_ pins $ \pin -> + unsafeEff_ $ lineRequestSetValue lineRequest (pinLine pin) lineValue -setAllPins :: Ptr G.LineRequest -> G.LineValue -> IO () -setAllPins lineRequest lineValue = do - logMsg - $ "Setting all pins " - <> pack (show allPins) - <> " to state " - <> pack (show lineValue) - void - $ V.unsafeWith (V.replicate (length allPins) lineValue) - $ G.lineRequestSetValues lineRequest +setAllPins :: (GPIO :> es, Log "gpio" :> es) => LineValue -> Eff es () +setAllPins lineValue = do + GPIO lineRequest <- getStaticRep + logMsg Trace $ "Setting all pins " <> show allPins <> " to " <> show lineValue + unsafeEff_ . unsafeWith (replicate pinCount lineValue) $ + void . lineRequestSetValues lineRequest -allocateGPIO :: Region -> Text -> IO (Ptr G.LineRequest) -allocateGPIO region consumer = do - (chip, chipKey) <- allocateChip - (lineSettings, lineSettingsKey) <- allocateLineSettings - (lineConfig, lineConfigKey) <- allocateLineConfig lineSettings - (requestConfig, requestConfigKey) <- allocateRequestConfig - lineRequest <- allocateLineRequest chip requestConfig lineConfig - free requestConfigKey - free lineConfigKey - free lineSettingsKey - free chipKey - defer region $ setAllPins lineRequest G.inactive - return lineRequest +runGPIO + :: (IOE :> es, Log "gpio" :> es, Resource :> es) + => String + -> Eff (GPIO : es) a + -> Eff es a +runGPIO consumer action = do + (chipKey, chip) <- chipBracket + (lineSettingsKey, lineSettings) <- lineSettingsBracket + (lineConfigKey, lineConfig) <- lineConfigBracket lineSettings + (requestConfigKey, requestConfig) <- requestConfigBracket + (_, lineRequest) <- lineRequestBracket chip requestConfig lineConfig + releaseEff requestConfigKey + releaseEff lineConfigKey + releaseEff lineSettingsKey + releaseEff chipKey + evalStaticRep (GPIO lineRequest) action where - chipPath = "/dev/gpiochip0" - -- GPIO chip - chipOpen = do - logMsg $ "Opening GPIO chip " <> chipPath - useAsCString (encodeUtf8 chipPath) G.chipOpen - chipClose chip = do - logMsg $ "Closing GPIO chip " <> chipPath - G.chipClose chip - allocateChip = alloc region chipOpen chipClose - -- Line settings - lineSettingsNew = do - logMsg "Allocating line settings" - lineSettings <- G.lineSettingsNew - logMsg $ "With direction set to " <> pack (show G.output) - void $ G.lineSettingsSetDirection lineSettings G.output - logMsg $ "With output set to " <> pack (show G.inactive) - void $ G.lineSettingsSetOutputValue lineSettings G.inactive - return lineSettings - lineSettingsFree lineSettings = do - logMsg "Freeing line settings" - G.lineSettingsFree lineSettings - allocateLineSettings = alloc region lineSettingsNew lineSettingsFree - -- Line config - lineConfigNew lineSettings = do - logMsg "Allocating line config" - logMsg $ "With GPIO pins " <> pack (show allPins) - lineConfig <- G.lineConfigNew - void - $ V.unsafeWith (V.fromList allLines) - $ \pinsVector -> - G.lineConfigAddLineSettings + chipBracket = allocateEff chipAlloc chipDealloc + where + chipPath = "/dev/gpiochip0" + chipAlloc = do + logMsg Info $ "Opening GPIO chip " <> chipPath + liftIO $ withCString chipPath chipOpen + chipDealloc chip = do + logMsg Info $ "Closing GPIO chip " <> chipPath + liftIO $ chipClose chip + lineSettingsBracket = allocateEff lineSettingsAlloc lineSettingsDealloc + where + lineSettingsAlloc = do + logMsg Info "Allocating line settings" + lineSettings <- liftIO lineSettingsNew + logMsg Info $ "With direction set to " <> show output + liftIO . void $ lineSettingsSetDirection lineSettings output + logMsg Info $ "With output set to " <> show inactive + liftIO . void $ lineSettingsSetOutputValue lineSettings inactive + return lineSettings + lineSettingsDealloc lineSettings = do + logMsg Info "Freeing line settings" + liftIO $ lineSettingsFree lineSettings + lineConfigBracket lineSettings = + allocateEff lineConfigAlloc lineConfigDealloc + where + lineConfigAlloc = do + logMsg Info "Allocating line config" + logMsg Info $ "With GPIO pins " <> show allPins + lineConfig <- liftIO lineConfigNew + liftIO . void . unsafeWith (fromList allLines) $ \pinsVector -> + lineConfigAddLineSettings lineConfig pinsVector - (CSize $ fromIntegral $ length allPins) + (CSize $ fromIntegral pinCount) lineSettings - return lineConfig - lineConfigFree lineConfig = do - logMsg "Freeing line config" - G.lineConfigFree lineConfig - allocateLineConfig lineSettings = - alloc region (lineConfigNew lineSettings) lineConfigFree - -- Request config - requestConfigNew = do - logMsg "Allocating request config" - logMsg $ "With consumer " <> consumer - requestConfig <- G.requestConfigNew - useAsCString (encodeUtf8 consumer) - $ G.requestConfigSetConsumer requestConfig - return requestConfig - requestConfigFree requestConfig = do - logMsg "Freeing request config" - G.requestConfigFree requestConfig - allocateRequestConfig = alloc region requestConfigNew requestConfigFree - -- Line request - requestLines chip requestConfig lineConfig = do - logMsg "Allocating line request" - G.requestLines chip requestConfig lineConfig - lineRequestRelease lineRequest = do - logMsg "Releasing line request" - G.lineRequestRelease lineRequest - allocateLineRequest chip requestConfig lineConfig = - alloc_ - region - (requestLines chip requestConfig lineConfig) - lineRequestRelease + return lineConfig + lineConfigDealloc lineConfig = do + logMsg Info "Freeing line config" + liftIO $ lineConfigFree lineConfig + requestConfigBracket = allocateEff requestConfigAlloc requestConfigDealloc + where + requestConfigAlloc = do + logMsg Info "Allocating request config" + logMsg Info $ "With consumer " <> consumer + requestConfig <- liftIO requestConfigNew + liftIO . withCString consumer $ requestConfigSetConsumer requestConfig + return requestConfig + requestConfigDealloc requestConfig = do + logMsg Info "Freeing request config" + liftIO $ requestConfigFree requestConfig + lineRequestBracket chip requestConfig lineConfig = + allocateEff lineRequestAlloc lineRequestDealloc + where + lineRequestAlloc = do + logMsg Info "Allocating line request" + liftIO $ chipRequestLines chip requestConfig lineConfig + lineRequestDealloc lineRequest = do + logMsg Info "Releasing line request" + liftIO $ lineRequestRelease lineRequest diff --git a/hsm-gpio/Hsm/GPIO/FFI.hsc b/hsm-gpio/Hsm/GPIO/FFI.hsc new file mode 100644 index 0000000..f0f5737 --- /dev/null +++ b/hsm-gpio/Hsm/GPIO/FFI.hsc @@ -0,0 +1,116 @@ +{-# LANGUAGE CApiFFI #-} + +-- FFI bindings to `libgpiod` for direct GPIO hardware access. +-- +-- Exposes only the minimal required subset of `libgpiod` functionality used by +-- this project. The bindings are suitable for low-level hardware control. +-- +-- Future work could expand this into a comprehensive `gpiod` binding package. +module Hsm.GPIO.FFI + ( chipOpen + , chipClose + , input + , output + , LineValue + , active + , inactive + , lineSettingsNew + , lineSettingsFree + , lineSettingsSetDirection + , lineSettingsSetOutputValue + , lineConfigNew + , lineConfigFree + , lineConfigAddLineSettings + , requestConfigNew + , requestConfigFree + , requestConfigSetConsumer + , LineRequest + , chipRequestLines + , lineRequestRelease + , lineRequestSetValue + , lineRequestSetValues + ) +where + +import Foreign.C.String (CString) +import Foreign.C.Types (CInt (CInt), CSize (CSize), CUInt (CUInt)) +import Foreign.Ptr (Ptr) +import Foreign.Storable (Storable) + +data Chip + +foreign import capi unsafe "gpiod.h gpiod_chip_open" + chipOpen :: CString -> IO (Ptr Chip) + +foreign import capi unsafe "gpiod.h gpiod_chip_close" + chipClose :: Ptr Chip -> IO () + +data LineSettings + +newtype LineDirection + = LineDirection CInt + deriving Show + +foreign import capi "gpiod.h value GPIOD_LINE_DIRECTION_INPUT" + input :: LineDirection + +foreign import capi "gpiod.h value GPIOD_LINE_DIRECTION_OUTPUT" + output :: LineDirection + +newtype LineValue + = LineValue CInt + deriving (Show, Storable) + +foreign import capi "gpiod.h value GPIOD_LINE_VALUE_ACTIVE" + active :: LineValue + +foreign import capi "gpiod.h value GPIOD_LINE_VALUE_INACTIVE" + inactive :: LineValue + +foreign import capi unsafe "gpiod.h gpiod_line_settings_new" + lineSettingsNew :: IO (Ptr LineSettings) + +foreign import capi unsafe "gpiod.h gpiod_line_settings_free" + lineSettingsFree :: Ptr LineSettings -> IO () + +foreign import capi unsafe "gpiod.h gpiod_line_settings_set_direction" + lineSettingsSetDirection :: Ptr LineSettings -> LineDirection -> IO CInt + +foreign import capi unsafe "gpiod.h gpiod_line_settings_set_output_value" + lineSettingsSetOutputValue :: Ptr LineSettings -> LineValue -> IO CInt + +data LineConfig + +foreign import capi unsafe "gpiod.h gpiod_line_config_new" + lineConfigNew :: IO (Ptr LineConfig) + +foreign import capi unsafe "gpiod.h gpiod_line_config_free" + lineConfigFree :: Ptr LineConfig -> IO () + +foreign import capi unsafe "gpiod.h gpiod_line_config_add_line_settings" + lineConfigAddLineSettings :: Ptr LineConfig -> Ptr CUInt -> CSize -> Ptr LineSettings -> IO CInt + +data RequestConfig + +foreign import capi unsafe "gpiod.h gpiod_request_config_new" + requestConfigNew :: IO (Ptr RequestConfig) + +foreign import capi unsafe "gpiod.h gpiod_request_config_free" + requestConfigFree :: Ptr RequestConfig -> IO () + +foreign import capi unsafe "gpiod.h gpiod_request_config_set_consumer" + requestConfigSetConsumer :: Ptr RequestConfig -> CString -> IO () + +data LineRequest + +foreign import capi unsafe "gpiod.h gpiod_chip_request_lines" + chipRequestLines :: Ptr Chip -> Ptr RequestConfig -> Ptr LineConfig -> IO (Ptr LineRequest) + +foreign import capi unsafe "gpiod.h gpiod_line_request_release" + lineRequestRelease :: Ptr LineRequest -> IO () + +foreign import capi unsafe "gpiod.h gpiod_line_request_set_value" + lineRequestSetValue :: Ptr LineRequest -> CUInt -> LineValue -> IO CInt + +foreign import capi unsafe "gpiod.h gpiod_line_request_set_values" + lineRequestSetValues :: Ptr LineRequest -> Ptr LineValue -> IO CInt diff --git a/hsm-gpio/Hsm/GPIO/Lib.hsc b/hsm-gpio/Hsm/GPIO/Lib.hsc deleted file mode 100644 index 6716f3a..0000000 --- a/hsm-gpio/Hsm/GPIO/Lib.hsc +++ /dev/null @@ -1,116 +0,0 @@ -{-# LANGUAGE CPP #-} -{-# LANGUAGE ForeignFunctionInterface #-} - --- This module provides C bindings to the `gpiod` library for direct GPIO pin --- control. It includes bindings only for the C functions that are currently --- used. In the future, creating a complete set of bindings for the entire --- `gpiod` library as an external package would be a valuable contribution to --- Hackage. - -module Hsm.GPIO.Lib - ( chipOpen - , chipClose - , input - , output - , LineValue - , active - , inactive - , lineSettingsNew - , lineSettingsFree - , lineSettingsSetDirection - , lineSettingsSetOutputValue - , lineConfigNew - , lineConfigFree - , lineConfigAddLineSettings - , requestConfigNew - , requestConfigFree - , requestConfigSetConsumer - , requestLines - , LineRequest - , lineRequestRelease - , lineRequestSetValue - , lineRequestSetValues - ) where - -#include <gpiod.h> - -import Foreign.C.String (CString) -import Foreign.C.Types (CInt(CInt), CSize(CSize), CUInt(CUInt)) -import Foreign.Ptr (Ptr) -import Foreign.Storable (Storable) - -data Chip - -foreign import ccall unsafe "gpiod.h gpiod_chip_open" - chipOpen :: CString -> IO (Ptr Chip) - -foreign import ccall unsafe "gpiod.h gpiod_chip_close" - chipClose :: Ptr Chip -> IO () - -data LineSettings - -newtype LineDirection = - LineDirection CInt - deriving (Show) - -#{enum LineDirection, LineDirection - , input = GPIOD_LINE_DIRECTION_INPUT - , output = GPIOD_LINE_DIRECTION_OUTPUT -} - -newtype LineValue = - LineValue CInt - deriving (Show, Storable) - -#{enum LineValue, LineValue - , active = GPIOD_LINE_VALUE_ACTIVE - , inactive = GPIOD_LINE_VALUE_INACTIVE -} - -foreign import ccall unsafe "gpiod.h gpiod_line_settings_new" - lineSettingsNew :: IO (Ptr LineSettings) - -foreign import ccall unsafe "gpiod.h gpiod_line_settings_free" - lineSettingsFree :: Ptr LineSettings -> IO () - -foreign import ccall unsafe "gpiod.h gpiod_line_settings_set_direction" - lineSettingsSetDirection :: Ptr LineSettings -> LineDirection -> IO CInt - -foreign import ccall unsafe "gpiod.h gpiod_line_settings_set_output_value" - lineSettingsSetOutputValue :: Ptr LineSettings -> LineValue -> IO CInt - -data LineConfig - -foreign import ccall unsafe "gpiod.h gpiod_line_config_new" - lineConfigNew :: IO (Ptr LineConfig) - -foreign import ccall unsafe "gpiod.h gpiod_line_config_free" - lineConfigFree :: Ptr LineConfig -> IO () - -foreign import ccall unsafe "gpiod.d gpiod_line_config_add_line_settings" - lineConfigAddLineSettings :: Ptr LineConfig -> Ptr CUInt -> CSize -> Ptr LineSettings -> IO CInt - -data RequestConfig - -foreign import ccall unsafe "gpiod.h gpiod_request_config_new" - requestConfigNew :: IO (Ptr RequestConfig) - -foreign import ccall unsafe "gpiod.h gpiod_request_config_free" - requestConfigFree :: Ptr RequestConfig -> IO () - -foreign import ccall unsafe "gpiod.h gpiod_request_config_set_consumer" - requestConfigSetConsumer :: Ptr RequestConfig -> CString -> IO () - -data LineRequest - -foreign import ccall unsafe "gpiod.h gpiod_chip_request_lines" - requestLines :: Ptr Chip -> Ptr RequestConfig -> Ptr LineConfig -> IO (Ptr LineRequest) - -foreign import ccall unsafe "gpiod.h gpiod_line_request_release" - lineRequestRelease :: Ptr LineRequest -> IO () - -foreign import ccall unsafe "gpiod.h gpiod_line_request_set_value" - lineRequestSetValue :: Ptr LineRequest -> CUInt -> LineValue -> IO CInt - -foreign import ccall unsafe "gpiod.h gpiod_line_request_set_values" - lineRequestSetValues :: Ptr LineRequest -> Ptr LineValue -> IO CInt diff --git a/hsm-gpio/hsm-gpio.cabal b/hsm-gpio/hsm-gpio.cabal index 786977a..b38a777 100644 --- a/hsm-gpio/hsm-gpio.cabal +++ b/hsm-gpio/hsm-gpio.cabal @@ -1,4 +1,4 @@ -cabal-version: 3.4 +cabal-version: 3.8 author: Paul Oliver build-type: Simple maintainer: contact@pauloliver.dev @@ -8,14 +8,17 @@ version: 0.1.0.0 library build-depends: , base - , bytestring + , effectful-core + , effectful-plugin + , hsm-core , hsm-log - , io-region - , text + , resourcet-effectful , vector + default-language: GHC2024 exposed-modules: Hsm.GPIO - other-modules: Hsm.GPIO.Lib - ghc-options: -Wall -Wunused-packages - extra-libraries: gpiod - default-language: GHC2021 + ghc-options: + -O2 -Wall -Werror -Wno-star-is-type -Wunused-packages + -ddump-splices -fplugin=Effectful.Plugin + + other-modules: Hsm.GPIO.FFI diff --git a/hsm-log/Hsm/Log.hs b/hsm-log/Hsm/Log.hs index 0f388be..5321910 100644 --- a/hsm-log/Hsm/Log.hs +++ b/hsm-log/Hsm/Log.hs @@ -1,15 +1,77 @@ -{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE AllowAmbiguousTypes #-} +{-# LANGUAGE TypeFamilies #-} +{-# LANGUAGE UndecidableInstances #-} module Hsm.Log - ( logMsg - ) where + ( Severity (Attention, Info, Trace) + , Log + , logMsg + , runLog + ) +where -import Data.Text qualified as T -import Data.Text.IO qualified as T +import Control.Monad (when) +import Data.Function (applyWhen) +import Data.Proxy (Proxy (Proxy)) import Data.Time.Clock (getCurrentTime) import Data.Time.ISO8601 (formatISO8601Millis) +import Effectful (Dispatch (Static), DispatchOf, Eff, Effect, IOE, (:>)) +import Effectful.Dispatch.Static + ( SideEffects (WithSideEffects) + , StaticRep + , evalStaticRep + , getStaticRep + , unsafeEff_ + ) +import GHC.TypeLits (KnownSymbol, Symbol, symbolVal) +import GHC.TypeLits.Printf (printf) +import String.ANSI (red) -logMsg :: [T.Text] -> T.Text -> IO () -logMsg domain msg = do - time <- T.pack . formatISO8601Millis <$> getCurrentTime - T.putStrLn $ T.unwords [time, "[" <> T.intercalate "/" domain <> "]", msg] +data Severity + = Attention + | Info + | Trace + deriving (Eq, Ord, Show) + +data Log (d :: Symbol) (a :: * -> *) (b :: *) + +type instance DispatchOf (Log d) = Static WithSideEffects + +newtype instance StaticRep (Log d) + = Log Severity + +logMsg + :: forall d es + . (KnownSymbol d, Log d :> es) + => Severity + -> String + -> Eff es () +logMsg severity message = do + Log level <- getStaticRep + unsafeEff_ . when (severity <= level) $ do + time <- formatISO8601Millis <$> getCurrentTime + putStrLn . applyWhen (severity == Attention) red $ + printf "%s %s [%s] %s" time (symbolVal $ Proxy @d) (show level) message + +runLog + :: forall d es a + . IOE :> es + => Severity + -> Eff (Log d : es) a + -> Eff es a +runLog = evalStaticRep . Log + +class Logs (ds :: [Symbol]) (es :: [Effect]) where + type Insert ds es :: [Effect] + runLogs :: Severity -> Eff (Insert ds es) a -> Eff es a + +instance Logs ('[] :: [Symbol]) (es :: [Effect]) where + type Insert '[] es = es + runLogs _ = id + +instance + (IOE :> Insert ds es, KnownSymbol d, Logs ds es) + => Logs (d : ds :: [Symbol]) (es :: [Effect]) + where + type Insert (d : ds) es = Log d : Insert ds es + runLogs level = runLogs @ds level . runLog @d level diff --git a/hsm-log/hsm-log.cabal b/hsm-log/hsm-log.cabal index 65279db..24995da 100644 --- a/hsm-log/hsm-log.cabal +++ b/hsm-log/hsm-log.cabal @@ -1,4 +1,4 @@ -cabal-version: 3.4 +cabal-version: 3.8 author: Paul Oliver build-type: Simple maintainer: contact@pauloliver.dev @@ -8,10 +8,15 @@ version: 0.1.0.0 library build-depends: , base + , effectful-core + , effectful-plugin , iso8601-time - , text + , text-ansi , time + , typelits-printf - exposed-modules: Hsm.Log - ghc-options: -Wall -Wunused-packages - default-language: GHC2021 + default-language: GHC2024 + exposed-modules: Hsm.Log + ghc-options: + -O2 -Wall -Werror -Wno-star-is-type -Wunused-packages + -fplugin=Effectful.Plugin diff --git a/hsm-pwm/Hsm/PWM.hs b/hsm-pwm/Hsm/PWM.hs index 6b2a882..aa16d5c 100644 --- a/hsm-pwm/Hsm/PWM.hs +++ b/hsm-pwm/Hsm/PWM.hs @@ -1,143 +1,141 @@ -{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeFamilies #-} +-- Raspberry Pi 5 PWM control (sysfs interface) +-- +-- Requires: +-- - `dtoverlay=pwm-2chan` in `/boot/config.txt` +-- - UDEV rules for non-root access +-- +-- Supports 2 active PWM channels. For details see: +-- - PWM Configuration: https://www.pi4j.com/blog/2024/20240423_pwm_rpi5/#modify-configtxt +-- - SysFS Reference: https://forums.raspberrypi.com/viewtopic.php?t=359251 +-- - UDEV Setup: https://forums.raspberrypi.com/viewtopic.php?t=316514 module Hsm.PWM - ( PWMHandle - , PWMChannel(PWM2, PWM3) + ( PWMChannel (..) + , PWM , setCycleDuration - , allocatePWM - ) where + , runPWM + ) +where import Control.Concurrent (threadDelay) -import Control.IO.Region (Region, alloc_) +import Control.Monad (forM_, void) import Control.Monad.Loops (untilM_) -import Data.Text (Text, pack) -import Hsm.Log qualified as L +import Effectful (Dispatch (Static), DispatchOf, Eff, IOE, liftIO, (:>)) +import Effectful.Dispatch.Static + ( SideEffects (WithSideEffects) + , StaticRep + , evalStaticRep + , unsafeEff_ + ) +import Effectful.Resource (Resource, allocateEff_) +import Hsm.Core.Serial (makeSerial) +import Hsm.Log (Log, Severity (Info, Trace), logMsg) import System.FilePath ((</>)) import System.Posix.Files (fileAccess) --- This data type defines a placeholder `PWMHandle` to ensure that PWM actions --- occur only after the `allocatePWM` function has been called. The empty --- handle acts as a flag to enforce the correct order of operations. -data PWMHandle = - PWMHandle +$(makeSerial "PWM" "Channel" "channelIndex" ''Int [1, 2]) --- This PWM controller assumes `dtoverlay=pwm-2chan` is set in --- `/boot/config.txt`, enabling PWM on GPIO 18 (channel 2) and GPIO 19 --- (channel 3) for the Pi 5. Alternative configurations with additional PWM --- channels are possible. For more information, consult the following links: --- --- - Modifications to `config.txt`: --- https://www.pi4j.com/blog/2024/20240423_pwm_rpi5/#modify-configtxt --- --- - SysFS PWM interface: --- https://forums.raspberrypi.com/viewtopic.php?t=359251 --- --- - UDEV setup for non-root access: --- https://forums.raspberrypi.com/viewtopic.php?t=316514 -data PWMChannel - = PWM2 - | PWM3 - deriving (Bounded, Enum, Show) +data PWM (a :: * -> *) (b :: *) + +type instance DispatchOf PWM = Static WithSideEffects -logMsg :: Text -> IO () -logMsg = L.logMsg ["pwm"] +newtype instance StaticRep PWM + = PWM () chipPath :: FilePath chipPath = "/sys/class/pwm/pwmchip0" -channelIndex :: PWMChannel -> Int -channelIndex = read . drop 3 . show +channelPath :: PWMChannel -> FilePath +channelPath channel = chipPath </> "pwm" <> show (channelIndex channel) -channelPaths :: PWMChannel -> (FilePath, FilePath, FilePath) -channelPaths channel = (enablePath, periodPath, dutyCyclePath) - where - channelPath = chipPath </> ("pwm" <> show (channelIndex channel)) - enablePath = channelPath </> "enable" - periodPath = channelPath </> "period" - dutyCyclePath = channelPath </> "duty_cycle" +enablePath :: PWMChannel -> FilePath +enablePath channel = channelPath channel </> "enable" -setEnable :: PWMChannel -> Bool -> IO () +periodPath :: PWMChannel -> FilePath +periodPath channel = channelPath channel </> "period" + +dutyCyclePath :: PWMChannel -> FilePath +dutyCyclePath channel = channelPath channel </> "duty_cycle" + +setEnable :: (Log "pwm" :> es, PWM :> es) => PWMChannel -> Bool -> Eff es () setEnable channel enable = do - logMsg $ "Setting " <> pack enablePath <> " to " <> pack (show enable) - writeFile enablePath enableString - where - (enablePath, _, _) = channelPaths channel - enableString = show $ fromEnum enable + logMsg Trace $ "Setting " <> enablePath channel <> " to " <> show enable + unsafeEff_ . writeFile (enablePath channel) . show $ fromEnum enable -setPeriod :: PWMChannel -> Int -> IO () +setPeriod :: (Log "pwm" :> es, PWM :> es) => PWMChannel -> Int -> Eff es () setPeriod channel period = do - logMsg $ "Setting " <> pack periodPath <> " to " <> pack (show period) - writeFile periodPath $ show period - where - (_, periodPath, _) = channelPaths channel + logMsg Trace $ "Setting " <> periodPath channel <> " to " <> show period + unsafeEff_ . writeFile (periodPath channel) $ show period -setDutyCycle :: PWMChannel -> Int -> IO () +setDutyCycle :: (Log "pwm" :> es, PWM :> es) => PWMChannel -> Int -> Eff es () setDutyCycle channel dutyCycle = do - logMsg $ "Setting " <> pack dutyCyclePath <> " to " <> pack (show dutyCycle) - writeFile dutyCyclePath $ show dutyCycle - where - (_, _, dutyCyclePath) = channelPaths channel + logMsg Trace $ "Setting " <> dutyCyclePath channel <> " to " <> show dutyCycle + unsafeEff_ . writeFile (dutyCyclePath channel) $ show dutyCycle -setCycleDuration :: PWMHandle -> PWMChannel -> Int -> IO () -setCycleDuration _ channel 0 = do - logMsg $ "Halting PWM signals on channel " <> pack (show channel) +-- Sets the PWM cycle duration (period) for a channel +-- +-- - Special case: A duration of 0 halts PWM output +-- - Normal operation: +-- 1. Zero the duty cycle first to avoid 'Invalid argument' errors +-- (period cannot be smaller than current duty cycle) +-- 2. Update period +-- 3. Set default 50% duty cycle +-- 4. Re-enable output +setCycleDuration + :: (Log "pwm" :> es, PWM :> es) => PWMChannel -> Int -> Eff es () +setCycleDuration channel 0 = do + logMsg Trace $ "Halting PWM signals on channel " <> show channel setEnable channel False -setCycleDuration _ channel cycleDuration = do - logMsg - $ "Setting cycle duration on channel " - <> pack (show channel) - <> " to " - <> pack (show cycleDuration) +setCycleDuration channel cycleDuration = do + logMsg Trace $ + "Setting cycle duration on channel " + <> show channel + <> " to " + <> show cycleDuration setEnable channel False - -- Sets the duty cycle to zero before updating the period. This prevents - -- `Invalid argument` errors, as the period must never be set to a value - -- smaller than the duty cycle. setDutyCycle channel 0 setPeriod channel cycleDuration setDutyCycle channel $ cycleDuration `div` 2 setEnable channel True -allocatePWM :: Region -> (PWMChannel -> Int) -> IO PWMHandle -allocatePWM region mapper = alloc_ region acquire $ const release +runPWM + :: (IOE :> es, Log "pwm" :> es, Resource :> es) + => Eff (PWM : es) a + -> Eff es a +runPWM action = + evalStaticRep (PWM ()) $ do + void $ allocateEff_ pwmAlloc pwmDealloc + action where exportPath = chipPath </> "export" unexportPath = chipPath </> "unexport" - -- This function waits for a file at the given `path` to become writable - -- by the `pwm` user group. A UDEV rule ensures that files in - -- `/sys/class/pwm/pwmchip*` are made writable through a `chown` call. - -- However, because UDEV rules are applied asynchronously, there may be a - -- brief delay before the rule takes effect. This function blocks and - -- repeatedly checks the file's write permissions by calling `fileAccess`. - -- It continues checking until write access is confirmed. + -- Blocks until the PWM sysfs file becomes writable + -- + -- Handles the race condition caused by: + -- 1. Sysfs file creation + -- 2. UDEV rules (async `chown` to `pwm` group) + -- + -- Polls permissions with 1ms delay between checks. waitWritable path = do - logMsg $ "Waiting for " <> pack path <> " to become writable" - untilM_ (threadDelay 1000) $ fileAccess path False True False + logMsg Info $ "Waiting for " <> path <> " to become writable" + liftIO . untilM_ (threadDelay 1000) $ fileAccess path False True False allChannels = [minBound .. maxBound] - -- Acquire PWM channels - acquireChannel channel = do - logMsg - $ "Exporting channel " - <> pack (show channel) - <> " on chip " - <> pack chipPath - writeFile exportPath $ show (channelIndex channel) - let (enablePath, periodPath, dutyCyclePath) = channelPaths channel - waitWritable enablePath - waitWritable periodPath - waitWritable dutyCyclePath - setCycleDuration PWMHandle channel $ mapper channel - acquire = do + pwmAlloc = do waitWritable exportPath waitWritable unexportPath - mapM_ acquireChannel allChannels - return PWMHandle - -- Release PWM channels - releaseChannel channel = do - setEnable channel False - logMsg - $ "Unexporting channel " - <> pack (show channel) - <> " on chip " - <> pack chipPath - writeFile unexportPath $ show (channelIndex channel) - release = mapM_ releaseChannel allChannels + forM_ allChannels $ \channel -> do + logMsg Info $ + "Exporting channel " <> show channel <> " on chip " <> chipPath + liftIO . writeFile exportPath $ show (channelIndex channel) + waitWritable $ enablePath channel + waitWritable $ periodPath channel + waitWritable $ dutyCyclePath channel + setCycleDuration channel 0 + pwmDealloc = + forM_ allChannels $ \channel -> do + setEnable channel False + logMsg Info $ + "Unexporting channel " <> show channel <> " on chip " <> chipPath + liftIO . writeFile unexportPath $ show (channelIndex channel) diff --git a/hsm-pwm/hsm-pwm.cabal b/hsm-pwm/hsm-pwm.cabal index 8a6c44d..a4453ba 100644 --- a/hsm-pwm/hsm-pwm.cabal +++ b/hsm-pwm/hsm-pwm.cabal @@ -1,4 +1,4 @@ -cabal-version: 3.4 +cabal-version: 3.8 author: Paul Oliver build-type: Simple maintainer: contact@pauloliver.dev @@ -8,14 +8,17 @@ version: 0.1.0.0 library build-depends: , base + , effectful-core + , effectful-plugin , filepath + , hsm-core , hsm-log - , io-region , monad-loops - , text + , resourcet-effectful , unix + default-language: GHC2024 exposed-modules: Hsm.PWM - ghc-options: -Wall -Wunused-packages - extra-libraries: gpiod - default-language: GHC2021 + ghc-options: + -O2 -Wall -Werror -Wno-star-is-type -Wunused-packages + -ddump-splices -fplugin=Effectful.Plugin diff --git a/hsm-readline/Hsm/Readline.hs b/hsm-readline/Hsm/Readline.hs deleted file mode 100644 index 8a0c232..0000000 --- a/hsm-readline/Hsm/Readline.hs +++ /dev/null @@ -1,50 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} - -module Hsm.Readline - ( readline - , allocateReadline - ) where - -import Control.IO.Region (Region, alloc_) -import Data.Text (Text, pack) -import Data.Typeable (Proxy(Proxy), Typeable, typeRep) -import Hsm.Log qualified as L -import System.Console.Haskeline qualified as H -import System.Console.Haskeline.IO qualified as H -import Text.Read (readEither) - -logMsg :: Text -> IO () -logMsg = L.logMsg ["readline"] - -readline :: - forall a. (Read a, Show a, Typeable a) - => H.InputState - -> IO (Maybe a) -readline handle = do - logMsg $ "Expecting value of type " <> pack (show $ typeRep $ Proxy @a) - valueMaybe <- queryInput - maybe (return Nothing) parseValueStr valueMaybe - where - queryInput = - H.queryInput handle - $ H.handleInterrupt (return Nothing) - $ H.withInterrupt - $ H.getInputLine "% " - parseValueStr valueStr = - case readEither @a valueStr of - Right commandValue -> do - logMsg $ "Parsed value " <> pack (show commandValue) - return $ Just commandValue - Left err -> do - logMsg $ pack err - readline handle - -allocateReadline :: Region -> IO H.InputState -allocateReadline region = alloc_ region initializeInput cancelInput - where - initializeInput = do - logMsg "Initializing input with default settings" - H.initializeInput H.defaultSettings - cancelInput handle = do - logMsg "Cancelling input" - H.cancelInput handle diff --git a/hsm-readline/hsm-readline.cabal b/hsm-readline/hsm-readline.cabal deleted file mode 100644 index 4532219..0000000 --- a/hsm-readline/hsm-readline.cabal +++ /dev/null @@ -1,18 +0,0 @@ -cabal-version: 3.4 -author: Paul Oliver -build-type: Simple -maintainer: contact@pauloliver.dev -name: hsm-readline -version: 0.1.0.0 - -library - build-depends: - , base - , haskeline - , hsm-log - , io-region - , text - - exposed-modules: Hsm.Readline - ghc-options: -Wall -Wunused-packages - default-language: GHC2021 diff --git a/hsm-repl/Hsm/Repl.hs b/hsm-repl/Hsm/Repl.hs new file mode 100644 index 0000000..5265e59 --- /dev/null +++ b/hsm-repl/Hsm/Repl.hs @@ -0,0 +1,104 @@ +{-# LANGUAGE TypeFamilies #-} + +module Hsm.Repl + ( Repl + , repl + , runRepl + ) +where + +import Control.Monad (forM_) +import Data.Typeable (Proxy (Proxy), Typeable, typeRep) +import Effectful (Dispatch (Static), DispatchOf, Eff, IOE, liftIO, (:>)) +import Effectful.Dispatch.Static + ( SideEffects (WithSideEffects) + , StaticRep + , evalStaticRep + , getStaticRep + , unsafeEff_ + ) +import Effectful.Resource (Resource, allocateEff) +import GHC.TypeLits (KnownSymbol, Symbol, symbolVal) +import Generic.Data.Function.Common.Generic.Meta (KnownSymbols, symbolVals) +import Hsm.Log (Log, Severity (Attention, Info, Trace), logMsg) +import Language.Haskell.Interpreter + ( GhcError (errMsg) + , InterpreterError (WontCompile) + , as + , interpret + , runInterpreter + , setImports + ) +import System.Console.Haskeline + ( defaultSettings + , getInputLine + , handleInterrupt + , withInterrupt + ) +import System.Console.Haskeline.IO + ( InputState + , cancelInput + , initializeInput + , queryInput + ) + +data Repl (p :: Symbol) (ms :: [Symbol]) (t :: *) (a :: * -> *) (b :: *) + +type instance DispatchOf (Repl p ms t) = Static WithSideEffects + +newtype instance StaticRep (Repl p ms t) + = Repl InputState + +repl + :: forall p ms t es + . ( KnownSymbol p + , KnownSymbols ms + , Log "repl" :> es + , Repl p ms t :> es + , Show t + , Typeable t + ) + => Eff es (Maybe t) +repl = query >>= maybe (return Nothing) parse + where + query = do + Repl inputState <- getStaticRep + logMsg Trace $ "Expecting a value of type: " <> show (typeRep $ Proxy @t) + unsafeEff_ + . queryInput inputState + . handleInterrupt (return Nothing) + . withInterrupt + . getInputLine + $ symbolVal (Proxy @p) + parse string = do + logMsg Trace $ "Parsing string: " <> string + eitherValue <- + unsafeEff_ . runInterpreter $ do + setImports $ symbolVals @ms + interpret string as + case eitherValue of + Right value -> do + logMsg Trace $ "Parsed value: " <> show value + return $ Just value + Left (WontCompile errors) -> do + forM_ errors $ logMsg Attention . errMsg + repl + Left err -> do + logMsg Attention $ show err + repl + +runRepl + :: forall p ms t es a + . (IOE :> es, Log "repl" :> es, Resource :> es) + => Eff (Repl p ms t : es) a + -> Eff es a +runRepl action = do + inputState <- snd <$> allocateEff inputStateAlloc inputStateDealloc + evalStaticRep (Repl inputState) action + where + inputStateAlloc = do + logMsg Info "Initializing input" + liftIO $ initializeInput defaultSettings + inputStateDealloc inputState = do + logMsg Info "Cancelling input" + liftIO $ cancelInput inputState diff --git a/hsm-repl/Test/Repl.hs b/hsm-repl/Test/Repl.hs new file mode 100644 index 0000000..9052ef1 --- /dev/null +++ b/hsm-repl/Test/Repl.hs @@ -0,0 +1,15 @@ +import Control.Monad (void) +import Control.Monad.Loops (whileJust_) +import Data.Function ((&)) +import Effectful (runEff) +import Effectful.Resource (runResource) +import Hsm.Log (Severity (Trace), runLog) +import Hsm.Repl (repl, runRepl) + +main :: IO () +main = + void (whileJust_ repl return) + & runRepl @"exec-repl λ " @'["Prelude"] @[Bool] + & runLog @"repl" Trace + & runResource + & runEff diff --git a/hsm-repl/hsm-repl.cabal b/hsm-repl/hsm-repl.cabal new file mode 100644 index 0000000..fd346f9 --- /dev/null +++ b/hsm-repl/hsm-repl.cabal @@ -0,0 +1,36 @@ +cabal-version: 3.8 +author: Paul Oliver +build-type: Simple +maintainer: contact@pauloliver.dev +name: hsm-repl +version: 0.1.0.0 + +common common + build-depends: + , base + , effectful-core + , effectful-plugin + , generic-data-functions + , haskeline + , hint + , hsm-log + , resourcet-effectful + + default-language: GHC2024 + ghc-options: + -O2 -Wall -Werror -Wno-star-is-type -Wunused-packages + -fplugin=Effectful.Plugin + +library + import: common + exposed-modules: Hsm.Repl + +executable test-repl + import: common + build-depends: monad-loops + + if !arch(x86_64) + ghc-options: -optl=-mno-fix-cortex-a53-835769 + + main-is: Test/Repl.hs + other-modules: Hsm.Repl diff --git a/hsm-status/Hsm/Status.hs b/hsm-status/Hsm/Status.hs deleted file mode 100644 index 8154611..0000000 --- a/hsm-status/Hsm/Status.hs +++ /dev/null @@ -1,41 +0,0 @@ -{-# LANGUAGE DataKinds #-} -{-# LANGUAGE OverloadedRecordDot #-} -{-# LANGUAGE OverloadedStrings #-} - -module Hsm.Status - ( StatusEnv(..) - , statusEnvDefault - , status - ) where - -import Foreign.Ptr (Ptr) -import GHC.Records (HasField) -import Hsm.GPIO (GPIO(..), LineRequest, active, inactive, setPins) -import Hsm.Log qualified as L - -data StatusEnv = StatusEnv - { gpioOk :: GPIO - , gpioError :: GPIO - } - -statusEnvDefault :: StatusEnv -statusEnvDefault = StatusEnv {gpioOk = GPIO17, gpioError = GPIO27} - -status :: - HasField "statusEnv" env StatusEnv - => Ptr LineRequest - -> env - -> [Bool] - -> IO () -status lineRequest env signals = do - if and signals - then do - logMsg "All signals OK" - setPins lineRequest [env.statusEnv.gpioError] inactive - setPins lineRequest [env.statusEnv.gpioOk] active - else do - logMsg "Error signal received" - setPins lineRequest [env.statusEnv.gpioError] active - setPins lineRequest [env.statusEnv.gpioOk] inactive - where - logMsg = L.logMsg ["status"] diff --git a/hsm-status/hsm-status.cabal b/hsm-status/hsm-status.cabal deleted file mode 100644 index 66560e8..0000000 --- a/hsm-status/hsm-status.cabal +++ /dev/null @@ -1,16 +0,0 @@ -cabal-version: 3.4 -author: Paul Oliver -build-type: Simple -maintainer: contact@pauloliver.dev -name: hsm-status -version: 0.1.0.0 - -library - build-depends: - , base - , hsm-gpio - , hsm-log - - exposed-modules: Hsm.Status - ghc-options: -Wall -Wunused-packages - default-language: GHC2021 @@ -1,9 +1,10 @@ +extra-deps: + - resourcet-effectful-1.0.1.0 + - typelits-printf-0.3.0.0 packages: - - hsm-bin - - hsm-drive + - hsm-core - hsm-gpio - hsm-log - hsm-pwm - - hsm-readline - - hsm-status -snapshot: lts-23.7 + - hsm-repl +resolver: nightly-2025-07-02 diff --git a/stack.yaml.lock b/stack.yaml.lock index 6ce598a..a6533d3 100644 --- a/stack.yaml.lock +++ b/stack.yaml.lock @@ -3,10 +3,24 @@ # For more information, please see the documentation at: # https://docs.haskellstack.org/en/stable/topics/lock_files -packages: [] +packages: +- completed: + hackage: resourcet-effectful-1.0.1.0@sha256:13f94c9832d0d1573abbabcddc5c3aa3c341973d1d442445795593e355e7803e,2115 + pantry-tree: + sha256: ef0db7bdeca5df1e722958cf5c8f3205ed5bf92b111e0fbc5d1a3c592d1c210e + size: 283 + original: + hackage: resourcet-effectful-1.0.1.0 +- completed: + hackage: typelits-printf-0.3.0.0@sha256:47f3d044056546f5c027db53deb6412dbf455c9dcfb8cbb0637a83692906fc6e,2111 + pantry-tree: + sha256: bdda47a6cdfc18b1ad74f9c16432a411909fa65147fe0f8a91b09a02a442751d + size: 702 + original: + hackage: typelits-printf-0.3.0.0 snapshots: - completed: - sha256: 4ef79c30b9efcf07335cb3de532983a7ac4c5a4180bc17f6212a86b09ce2ff75 - size: 680777 - url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/23/7.yaml - original: lts-23.7 + sha256: 141ac77307711e7dde0c4a5ff5e0738969f77529d6a88b71fdbc317d2310142e + size: 722188 + url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/nightly/2025/7/2.yaml + original: nightly-2025-07-02 diff --git a/sysconf/98-gpiod.rules b/sysconf/98-gpiod.rules index 01a05ed..a7275d9 100644 --- a/sysconf/98-gpiod.rules +++ b/sysconf/98-gpiod.rules @@ -1,2 +1 @@ -# This rule grants the `gpio` group access to GPIO devices. SUBSYSTEM=="gpio", KERNEL=="gpiochip*", GROUP="gpiod", MODE="0660" diff --git a/sysconf/99-pwm.rules b/sysconf/99-pwm.rules index 8407ebe..59ba681 100644 --- a/sysconf/99-pwm.rules +++ b/sysconf/99-pwm.rules @@ -1,9 +1,11 @@ -# This UDEV rule provides the `pwm` user group with access to PWM devices. -# Note that UDEV operates asynchronously, so there may be a slight delay -# between changes to the directory structure (e.g., when a new PWM channel is -# added) and the corresponding permission updates. To ensure the rule has been -# fully applied, you can use the command `udevadm settle` to wait for the UDEV -# process to complete. +# Grants 'pwm' group RW access to Raspberry Pi PWM sysfs interfaces +# +# Handles both: +# - Standard /sys/class/pwm paths +# - RPi5-specific PCIe PWM controllers (/sys/devices/platform/axi/...) +# +# Note: For race-free operation, check file writability (not just existence) +# using `access(2)` before attempting operations. SUBSYSTEM=="pwm*", PROGRAM="/bin/sh -c ' \ chown -R root:pwm /sys/class/pwm ; \ chmod -R 770 /sys/class/pwm ; \ |