Micro:bit 6 – Putting It Together
Starting from a new hello world paradigm, we have introduced the concept of software design using statecharts and techniques of implementing statecharts on micro:bit with a simple framework. We have shown examples of representing states and events in Javascript and how a graphical design is mapped directly to code.
In this final episode of the series, we are putting everything together and you may try it out with the online Makecode simulator at:
First create a new project. At the top bar select “{} Javascript” to use the text-based editor. On the left-hand side, click on the “right arrow (>)” icon to expand the Explorer list menu. A “+” icon will appear next to the “down arrow” icon. Click on the “+” icon to create a custom.ts file (Note – “ts” stands for Typescript which is a typed variant of Javascript).
Select the “custom.ts” file in the list menu. The editor on the right-hand side should show some placeholder code in the default custom.ts. Delete the placeholder code and replace it with the following code:
namespace state { interface Action { entry: (() => void), exit: (() => void) } // First [] is for orthogonal regions. Second [] is for states within a single region. let actions: Action[][] = [] function getAction(region: number, state: number): Action { if (!actions[region]) { actions[region] = [] } if (!actions[region][state]) { actions[region][state] = { entry: null, exit: null } } return actions[region][state] } export function onEntry(region: number, state: number, action: () => void) { getAction(region, state).entry = action } export function onExit(region: number, state: number, action: () => void) { getAction(region, state).exit = action } // Current state for all regions. let current: number[] = [] export function isIn(region: number, state: number): boolean { return current[region] === state } export function initial(region: number, state: number) { current[region] = state let entryAction = getAction(region, state).entry if (entryAction) { entryAction() } } export function transit(region: number, state: number) { let currState = current[region] if (currState !== null) { let exitAction = getAction(region, currState).exit if (exitAction) { exitAction() } } initial(region, state) } } namespace event { let handlers: ((param: number) => void)[] = [] export function on(evt: number, handler: (param: number) => void) { handlers[evt] = handler } export function raise(evt: number, param: number = 0) { let handler = handlers[evt] if (handler) { handler(param) } } interface EvtObj { evt: number, param: number } let deferQ: EvtObj[] = [] export function defer(evt: number, param: number = 0) { deferQ.push({ evt: evt, param: param }) } export function recall() { let e: EvtObj while (e = deferQ.shift()) { raise(e.evt, e.param) } } } namespace timer { const TICK_MS = 50 interface Timer { duration: number, period: number, ref: number, } let timers: Timer[] = [] function nextRef(t: Timer): number { if (t) { return (t.ref + 1) & 0xFFFF } else return 0 } export function isValid(evt: number, ref: number): boolean { let t = timers[evt] return t && (t.ref == ref) } export function start(evt: number, duration: number, isPeriodic = false): number { let ref = nextRef(timers[evt]) timers[evt] = { duration: duration, period: isPeriodic ? duration : 0, ref: ref } return ref } export function stop(evt: number) { let ref = nextRef(timers[evt]) timers[evt] = { duration: 0, period: 0, ref: ref } } function tickHandler() { interface Timeout { evt: number, ref: number } let timeouts: Timeout[] = [] timers.forEach((t: Timer, index: number) => { if (t && t.duration > 0) { t.duration -= Math.min(t.duration, TICK_MS) if (t.duration == 0) { timeouts.push({ evt: index, ref: t.ref }) t.duration = t.period } } }) timeouts.forEach((timeout: Timeout) => { if (isValid(timeout.evt, timeout.ref)) { event.raise(timeout.evt) } }) } export function run() { let wakeTimeMs = input.runningTime() basic.forever(function () { tickHandler() wakeTimeMs += TICK_MS basic.pause(Math.max(wakeTimeMs - input.runningTime(), 1)) }) } }
The custom.ts file contains a statechart framework reusable across different projects. It includes the following API namespaces: state, event and timer. The abstraction of state, event and timer are indeed the cornerstones of almost every real-time embedded systems.
The main.ts file contains application specific code, which in this case is a timer application. Its entire logic is represented in an accompanying statechart reproduced below. No matter which framework we use (either this simplified one, or full-feature ones like QP or xstate), the key is that once we have grasped a handful of mapping rules the code is mostly a direct mapping from the statechart design. This mapping is reversible making it much easier to jump back and forth between code and design.
Select the “main.ts” in the list menu. Delete the placeholder code in the editor and replace it with the following code:
// Extended state variables. let intervalMs = 0 let remainingTime = 0 let flashOn = false let ledCount = 1 // Event and state enumerations enum Evt { EVT_START, // TIMER events TIMER_FLASH, TIMER_INTERVAL, TIMER_STOP, // INTERNAL events A_PRESSED, B_PRESSED, TIMEOUT, } enum Region { MAIN, } enum MainState { STOPPED, RUNNING, PAUSED, TIMED_OUT, } // Helper functions defining state hierarchy. function inMainStopped() { return state.isIn(Region.MAIN, MainState.STOPPED) } function inMainRunning() { return state.isIn(Region.MAIN, MainState.RUNNING) } function inMainPaused() { return state.isIn(Region.MAIN, MainState.PAUSED) } function inMainTimedOut() { return state.isIn(Region.MAIN, MainState.TIMED_OUT) } function inMainStarted() { return inMainRunning() || inMainPaused() || inMainTimedOut() } // Generates internal events from built-in/external events. input.onButtonPressed(Button.B, function () { event.raise(Evt.B_PRESSED) }) input.onButtonPressed(Button.A, function () { event.raise(Evt.A_PRESSED) }) // Updates built-in LED display on Microbit function display(count: number) { basic.clearScreen() for (let i = 0; i <= count - 1; i++) { led.plot(i % 5, i / 5) } } // Entry and exit actions. state.onEntry(Region.MAIN, MainState.STOPPED, () => { flashOn = true display(ledCount) timer.start(Evt.TIMER_FLASH, 500, true) }) state.onExit(Region.MAIN, MainState.STOPPED, () => { timer.stop(Evt.TIMER_FLASH) }) state.onEntry(Region.MAIN, MainState.PAUSED, () => { led.setBrightness(50) }) state.onExit(Region.MAIN, MainState.PAUSED, () => { led.setBrightness(255) }) state.onEntry(Region.MAIN, MainState.RUNNING, () => { timer.start(Evt.TIMER_INTERVAL, intervalMs, true) }) state.onExit(Region.MAIN, MainState.RUNNING, () => { timer.stop(Evt.TIMER_INTERVAL) }) state.onEntry(Region.MAIN, MainState.TIMED_OUT, () => { timer.start(Evt.TIMER_FLASH, 100, true) timer.start(Evt.TIMER_STOP, 10000) flashOn = true }) state.onExit(Region.MAIN, MainState.TIMED_OUT, () => { timer.stop(Evt.TIMER_INTERVAL) timer.stop(Evt.TIMER_STOP) }) // Transition actions. event.on(Evt.A_PRESSED, () => { if (inMainStopped()) { // For testing, change 1000 to 100 to have the timer running 10x faster. remainingTime = ledCount * 2 * 60 * 1000 intervalMs = remainingTime/ 24 ledCount = 1 display(ledCount) state.transit(Region.MAIN, MainState.RUNNING) } else if (inMainStarted()) { event.raise(Evt.TIMER_STOP) } }) event.on(Evt.B_PRESSED, () => { if (inMainStopped()) { ledCount = ledCount % 25 + 1 state.transit(Region.MAIN, MainState.STOPPED) } else if (inMainRunning()) { state.transit(Region.MAIN, MainState.PAUSED) } else if (inMainPaused()) { state.transit(Region.MAIN, MainState.RUNNING) } }) event.on(Evt.TIMER_FLASH, () => { if (!flashOn) { flashOn = true display(ledCount) } else { flashOn = false display(0) } }) event.on(Evt.TIMER_INTERVAL, () => { if (inMainRunning()) { display(++ledCount) remainingTime -= intervalMs if (remainingTime <= 0) { event.raise(Evt.TIMEOUT) } } }) event.on(Evt.TIMEOUT, () => { if (inMainRunning()) { state.transit(Region.MAIN, MainState.TIMED_OUT) } }) event.on(Evt.TIMER_STOP, () => { if (inMainStarted()) { ledCount = 1 state.transit(Region.MAIN, MainState.STOPPED) } }) // Main code. timer.run() state.initial(Region.MAIN, MainState.STOPPED)
An important observation is that the main flow of the program consists of just two lines at the very end of main.ts, which starts the system timer and initializes the main state machine to the STOPPED state. That is it!
After this, the flow of the program is determined by the order and types of events that arrive, and the responses to those events as defined by the entry, exit and transition actions. To sum up, statecharts enable us to capture asynchronous behaviors precisely using the notion of states, events and transitions.