My framework for programming Arduinos in Haskell in FRP-style is a week old, and it's grown up a lot.
It can do much more than flash a light now. The =:
operator can now connect
all kinds of FRP Events to all kinds of outputs. There's some type level
progamming going on to only allow connections that make sense. For example,
arduino-copilot knows what pins of an Adruino support DigitalIO and
which support PWM. There are even nice custom type error messages:
demo.hs:7:9: error:
• This Pin does not support digital IO
• In a stmt of a 'do' block: a6 =: blinking
I wanted it to be easy to add support to arduino-copilot for using Arduino C libraries from Haskell, and that's proven to be the case. I added serial support last weekend, which is probably one of the harder libraries. It all fell into place once I realized it should not be about individual printfs, but about a single FRP behavior that describes all output to the serial port. This interface was the result:
n <- input a1 :: Sketch (Behavior ADC)
Serial.device =: [Serial.str "a1:", Serial.show n, Serial.char '\n']
Serial.baud 9600
This weekend I've been adding support for the EEPROMex library, and the Functional Reactive Programming approach really shines in stuff like this example, which gathers data from a sensor, logs it to the serial port, and also stores every 3rd value into the EEPROM for later retrival, using the whole EEPROM as a rolling buffer.
v <- input a1 ([10, 20..] :: [ADC])
range <- EEPROM.allocRange sizeOfEEPROM :: Sketch (EEPROM.Range ADC)
range =: EEPROM.sweepRange 0 v @: frequency 3
led =: frequency 3
Serial.device =: [ Serial.show v, Serial.char '\n']
Serial.baud 9600
delay =: MilliSeconds (constant 10000)
There's a fair bit of abstraction in that... Try doing that in 7 lines of C code with that level of readability. (It compiles into 120 lines of C.)
Copilot's ability to interpret the program and show what it would do, without running it on the Adruino, seems more valuable the more complicated the programs become. Here's the interpretation of the program above.
delay: digitalWrite_13: eeprom_range_write1: output_Serial:
(10000) (13,false) -- (10)
(10000) (13,true) (0,20) (20)
(10000) (13,false) -- (30)
(10000) (13,false) -- (40)
(10000) (13,true) (1,50) (50)
(10000) (13,false) -- (60)
Last night I was writing a program that amoung other things, had an event that
only happened once every 70 minutes (when the Arduino's micros
clock
overflows). I didn't have to wait hours staring at the Arduino to test
and debug my program, instead I interpreted it with a clock input that
overflowed on demand.
(Hmm, I've not actually powered my Arduino on in nearly a week despite writing new Arduino programs every day.)
So arduino-copilot is feeling like it's something that I'll be using soon to write real world Arduino programs. It's certianly is not usable for all Arduino programming, but it will support all the kinds of programs I want to write, and being able to use Functional Reactive Programming will make me want to write them.
Development of arduino-copilot was sponsored by Trenton Cronholm and Jake Vosloo on Patreon.
My framework for programming Arduinos in Haskell has two major improvements this week. It's feeling like I'm laying the keystone on this project. It's all about the combinators now.
Sketch combinators
Consider this arduino-copilot program, that does something unless a pause button is pushed:
paused <- input pin3
pin4 =: foo @: not paused
v <- input a1
pin5 =: bar v @: sometimes && not paused
The pause button has to be checked everywhere, and there's a risk of forgetting to check it, resulting in unexpected behavior. It would be nice to be able to factor that out somehow. Also, notice that it inputs from a1 all the time, but won't use that input when pause is pushed. It would be nice to be able to avoid that unnecessary work.
The new whenB
combinator solves all of that:
paused <- input pin3
whenB (not paused) $ do
pin4 =: foo
v <- input a1
pin5 =: bar v @: sometimes
All whenB
does is takes a Behavior Bool
and uses it to control
whether a Sketch runs. It was not easy to implement, given
the constraints of Copilot DSL, but it's working. And once I had
whenB
, I was able to leverage RebindableSyntax to allow
if then else
expressions to choose between Sketches, as well as between
Streams.
Now it's easy to start by writing a Sketch that describes a simple behavior,
like turnRight
or goForward
, and glue those together in a straightforward
way to make a more complex Sketch, like a line-following robot:
ll <- leftLineSensed
rl <- rightLineSensed
if ll && rl
then stop
else if ll
then turnLeft
else if rl
then turnRight
else goForward
(Full line following robot example here)
TypedBehavior combinators
I've complained before that the Copilot DSL limits Stream
to basic C data
types, and so progamming with it felt like I was not able to leverage
the type checker as much as I'd hope to when writing Haskell, to eg
keep different units of measurement separated.
Well, I found a way around that problem. All it needed was phantom types, and some combinators to lift Copilot DSL expressions.
For example, a Sketch that controls a hot water heater certainly wants to indicate clearly that temperatures are in C not F, and PSI is another important unit. So define some empty types for those units:
data PSI
data Celsius
Using those as the phantom type parameters for TypedBehavior, some important values can be defined:
maxSafePSI :: TypedBehavior PSI Float
maxSafePSI = TypedBehavior (constant 45)
maxWaterTemp :: TypedBehavior Celsius Float
maxWaterTemp = TypedBehavior (constant 35)
And functions like this to convert raw ADC readings into our units:
adcToCelsius :: Behavior Float -> TypedBehavior Celsius Float
adcToCelsius v = TypedBehavior $ v * (constant 200 / constant 1024)
And then we can make functions that take these TypedBehaviors and run Copilot DSL expressions on the Stream contained within them, producing Behaviors suitable for being connected up to pins:
isSafePSI :: TypedBehavior PSI Float -> Behavior Bool
isSafePSI p = liftB2 (<) p maxSafePSI
isSafeTemp :: TypedBehavior Celsius Float -> Behavior Bool
isSafeTemp t = liftB2 (<) t maxSafePSI
(Full water heater example here)
BTW, did you notice the mistake on the last line of code above? No worries; the type checker will, so it will blow up at compile time, and not at runtime.
• Couldn't match type ‘PSI’ with ‘Celsius’
Expected type: TypedBehavior Celsius Float
Actual type: TypedBehavior PSI Float
The liftB2
combinator was all I needed to add to support that.
There's also a liftB
, and there could be liftB3
etc. (Could it
be generalized to a single lift function that supports multiple arities?
I don't know yet.) It would be good to have more types than just phantom
types; I particularly miss Maybe; but this does go a long way.
So you can have a good amount of type safety while using Copilot to program your Arduino, and you can mix both FRP style and imperative style as you like. Enjoy!
This work was sponsored by Trenton Cronholm and Jake Vosloo on Patreon.