Skip to content

Commit

Permalink
JSON serial responses and example python code
Browse files Browse the repository at this point in the history
Make the Arduino only output single-line JSON objects via serial to make it easy for control software to parse.

Also added some example python software which randomly picks words to display from a list of 4-letter animal names.

Fixes scottbez1#31
  • Loading branch information
scottbez1 committed Aug 2, 2018
1 parent 17b9eb3 commit b93f9bf
Show file tree
Hide file tree
Showing 5 changed files with 267 additions and 29 deletions.
48 changes: 45 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ This design is currently at a *prototype* stage. The source files provided here
| --- | --- | --- |
| Enclosure/Mechanics | *Release Candidate* | Need documentation on ordering. |
| Electronics | *Release Candidate* | Need documentation on ordering and assembly. |
| Firmware | *Beta* | Works, but could use a better serial protocol for control/feedback. |
| Control Software | *none* | No work started; currently manual control using Arduino Serial Monitor |
| Firmware | *Release Candidate* | Works. |
| Control Software | *Beta* | Example python code for driving the display is in the [software](https://github.com/scottbez1/splitflap/tree/master/electronics) directory|

I'd love to hear your thoughts and questions about this project, and happy to incorporate any feedback you might have into these designs! Please feel free (and encouraged) to [open GitHub issues](https://github.com/scottbez1/splitflap/issues/new), email me directly, reach out [on Twitter](https://twitter.com/scottbez1), and [get involved](https://github.com/scottbez1/splitflap/pulls) in the open source development and let's keep chatting and building together!

Expand Down Expand Up @@ -161,7 +161,49 @@ The driver firmware is written using Arduino and is available at `arduino/splitf
The firmware currently runs a basic closed-loop controller that accepts letters over USB serial and drives the stepper motors using a precomputed acceleration ramp for smooth control. The firmware automatically calibrates the spool position at startup, using the IR reflectance sensor, and will automatically recalibrate itself if it ever detects that the spool position has gotten out of sync. If a commanded rotation is expected to bring the spool past the "home" position, it will confirm that the sensor is triggered neither too early nor too late; otherwise it will search for the "home" position to get in sync before continuing to the desired letter.

### Computer Control Software ###
There is currently no example computer software demonstrating how to communicate with the driver firmware over USB. This is planned for the future, but the protocol is currently undocumented and likely to change as the firmware continues to be developed. In the meantime, the best "documentation" of the protocol is the [firmware source code](https://github.com/scottbez1/splitflap/blob/master/arduino/splitflap/splitflap.ino) itself.
The display can be controlled by a computer connected to the Arduino over USB serial. A basic python library for interfacing with the Arduino and a demo application that displays random words can be found in the [software](https://github.com/scottbez1/splitflap/tree/master/software) directory.

Commands to the display are sent in a basic plain-text format, and messages _from_ the display are single-line JSON objects, always with a `type` entry describing which type of message it is.

When the Arduino starts up, it sends an initialization message that looks like:
```
{"type":"init", "num_modules":4}
```

The display will automatically calibrate all modules, and when complete it will send a status update message:
```
{
"type":"status",
"modules":[
{"state":"normal", "flap":" ", "count_missed_home":0, "count_unexpected_home":0},
{"state":"sensor_error", "flap":"e", "count_missed_home":0, "count_unexpected_home":0},
{"state":"sensor_error", "flap":"e", "count_missed_home":0, "count_unexpected_home":0},
{"state":"sensor_error", "flap":"e", "count_missed_home":0, "count_unexpected_home":0}
]
}
```
(Note: this is sent as a single line, but has been reformatted for readability above)

In this case the Arduino was programmed to support 4 modules, but only 1 module is connected, so the other 3 end up in `"sensor_error"` state. More on status updates below.

At this point you can command the display to show some letters. To do this, send a message to the Arduino that looks like this:
```
=hiya\n
```
The `=` indicates a movement command, followed by any number of letters, followed by a newline. You don't have to send the exact number of modules - if you send fewer letters than modules, only the first N modules will be updated and the remainder won't move. For instance, you could send `=a\n` as shorthand to only set the first module (even if there are 12 modules connected). Any letters that can't be displayed are considered a no-op for that module.

Whenever ALL modules come to a stop, the Arduino will send a status update message (just like the one following initialization, shown above). Here's what the fields mean in each module's status entry:
- **state** - `normal` indicates it's working as intended, `sensor_error` indicates the module can't find the home position and has given up trying (it will no longer respond to movement commands until told to recalibrate - see below). `panic` indicates the firmware detected a programming bug and has gone into failsafe mode (it will no longer respond to movement commands and requires a full reset of the Arduino to recover - should never happen).
- **flap** - which letter is shown by this module
- **count\_missed\_home** - number of times the module expected to pass the home position but failed to detect it. If this is non-zero, it indicates either a flaky sensor or that the motor may have jammed up. The module automatically attempts to recalibrate whenever it misses the home position, so if this number is non-zero and the module is still in the `normal` state, it means the module successfully recovered from the issue(s). However, if this number keeps going up over continued use, it may indicate a recurrent transient issue that warrants investigation.
- **count\_unexpected\_home** - number of times the module detected the home position when it wasn't supposed to. This is rare, but would indicate a flaky/broken sensor that is tripping at the wrong time. Just like with missed home errors, unexpected home errors will cause the module to attempt to recalibrate itself.

If you want to make all modules recalibrate their home position, send a single @ symbol (no newline follows):
```
@
```
This recalibrates all modules, including any that were in the `sensor_error` state; if recalibration succeeds they will return to the `normal` state and start responding to movement commands again.


## License ##
I'd love to hear your thoughts and questions about this project, and happy to incorporate any feedback you might have into these designs! Please feel free (and encouraged) to [open GitHub issues](https://github.com/scottbez1/splitflap/issues/new), email me directly, reach out [on Twitter](https://twitter.com/scottbez1), and [get involved](https://github.com/scottbez1/splitflap/pulls) in the open source development and let's keep chatting and building together!
Expand Down
82 changes: 56 additions & 26 deletions arduino/splitflap/splitflap.ino
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
limitations under the License.
*/

#include <avr/power.h>
#include <math.h>
#include <SPI.h>
#include "splitflap_module.h"

Expand Down Expand Up @@ -118,7 +116,7 @@ inline void spi_transfer() {
}

void setup() {
Serial.begin(57600);
Serial.begin(38400);

pinMode(DEBUG_LED_0_PIN, OUTPUT);
pinMode(DEBUG_LED_1_PIN, OUTPUT);
Expand All @@ -138,13 +136,9 @@ void setup() {
SPI.beginTransaction(SPISettings(3000000, MSBFIRST, SPI_MODE0));
spi_transfer();

Serial.print(F("Starting.\nNum modules: "));
Serial.print(F("{\"type\":\"init\", \"num_modules\":"));
Serial.print(NUM_MODULES);
Serial.print(F("\nMotor buffer length: "));
Serial.print(MOTOR_BUFFER_LENGTH);
Serial.print(F("\nSensor buffer length: "));
Serial.print(SENSOR_BUFFER_LENGTH);
Serial.print('\n');
Serial.print(F("}\n"));

#if NEOPIXEL_DEBUGGING_ENABLED
strip.begin();
Expand Down Expand Up @@ -192,6 +186,7 @@ inline int8_t FindFlapIndex(uint8_t character) {

bool was_idle = false;
bool was_stopped = false;
bool pending_no_op = false;
uint8_t recv_count = 0;


Expand Down Expand Up @@ -244,28 +239,30 @@ void loop() {

while (Serial.available() > 0) {
int b = Serial.read();
Serial.print(F("Got "));
Serial.print(b);
Serial.write('\n');
switch (b) {
case '@':
for (uint8_t i = 0; i < NUM_MODULES; i++) {
modules[i].ResetErrorCounters();
modules[i].GoHome();
}
break;
case '#':
pending_no_op = true;
break;
case '=':
recv_count = 0;
break;
case '\n':
Serial.print(F("Going to '"));
for (uint8_t i = 0; i < NUM_MODULES; i++) {
Serial.print(F("{\"type\":\"move_echo\", \"dest\":\""));
for (uint8_t i = 0; i < recv_count; i++) {
int8_t index = FindFlapIndex(recv_buffer[i]);
if (index != -1) {
modules[i].GoToFlapIndex(index);
}
Serial.write(recv_buffer[i]);
}
Serial.print("'\n");
Serial.print(F("\"}\n"));
Serial.flush();
break;
default:
if (recv_count > NUM_MODULES - 1) {
Expand All @@ -276,22 +273,55 @@ void loop() {
break;
}
}

if (pending_no_op && all_stopped) {
Serial.print(F("{\"type\":\"no_op\"}\n"));
Serial.flush();
pending_no_op = false;
}

if (!was_stopped && all_stopped) {
for (uint8_t i = 0; i < NUM_MODULES; i++) {
Serial.print(F("---\nStats "));
Serial.print(i);
Serial.print(F(":\nM: "));
Serial.print(modules[i].count_missed_home);
Serial.print(F("\nU: "));
Serial.print(modules[i].count_unexpected_home);
Serial.print('\n');
}
Serial.print(F("##########\n"));
dump_status();
}
Serial.flush();
}
was_idle = all_idle;
was_stopped = all_stopped;
}
}

void dump_status() {
Serial.print(F("{\"type\":\"status\", \"modules\":["));
for (uint8_t i = 0; i < NUM_MODULES; i++) {
Serial.print(F("{\"state\":\""));
switch (modules[i].state) {
case NORMAL:
Serial.print(F("normal"));
break;
#if HOME_CALIBRATION_ENABLED
case LOOK_FOR_HOME:
Serial.print(F("look_for_home"));
break;
case SENSOR_ERROR:
Serial.print(F("sensor_error"));
break;
#endif
case PANIC:
Serial.print(F("panic"));
break;
}
Serial.print(F("\", \"flap\":\""));
Serial.write(flaps[modules[i].GetCurrentFlapIndex()]);
Serial.print(F("\", \"count_missed_home\":"));
Serial.print(modules[i].count_missed_home);
Serial.print(F(", \"count_unexpected_home\":"));
Serial.print(modules[i].count_unexpected_home);
Serial.print(F("}"));
if (i < NUM_MODULES - 1) {
Serial.print(F(", "));
}
}
Serial.print(F("]}\n"));
Serial.flush();
}


12 changes: 12 additions & 0 deletions arduino/splitflap/splitflap_module.h
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@ class SplitflapModule {
uint8_t current_accel_step = 0;

void GoToFlapIndex(uint8_t index);
uint8_t GetCurrentFlapIndex();
void GoHome();
void ResetErrorCounters();
inline bool Update();
void Init();

Expand Down Expand Up @@ -375,6 +377,11 @@ inline void SplitflapModule::GoToFlapIndex(uint8_t index) {
GoToTargetFlapIndex();
}

__attribute__((always_inline))
inline uint8_t SplitflapModule::GetCurrentFlapIndex() {
return (uint8_t)(GetFlapFloor(current_step) % NUM_FLAPS);
}

__attribute__((always_inline))
inline void SplitflapModule::GoHome() {
#if HOME_CALIBRATION_ENABLED
Expand Down Expand Up @@ -530,6 +537,11 @@ inline bool SplitflapModule::Update() {
return false;
}

void SplitflapModule::ResetErrorCounters() {
count_unexpected_home = 0;
count_missed_home = 0;
}

void SplitflapModule::Init() {
CheckSensor();
}
Expand Down
65 changes: 65 additions & 0 deletions software/demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from __future__ import print_function

import random
import time

import serial
import serial.tools.list_ports

from splitflap import splitflap

words = [
'duck', 'goat', 'lion', 'bear', 'mole', 'crab',
'hare', 'toad', 'wolf', 'lynx', 'cats', 'dogs',
'bees', 'mule', 'seal', 'bird', 'frog', 'deer',
'lamb', 'fish', 'hawk', 'kiwi',
]


def run():
port = ask_for_serial_port()

print('Starting...')
with splitflap(port) as s:
while True:
word = random.choice(words)
print('Going to {}'.format(word))
status = s.set_text(word)
print_status(status)
time.sleep(3)


def ask_for_serial_port():
print('Available ports:')
ports = sorted(
filter(
lambda p: p.description != 'n/a',
serial.tools.list_ports.comports(),
),
key=lambda p: p.device,
)
for i, port in enumerate(ports):
print('[{: 2}] {} - {}'.format(i, port.device, port.description))
print()
value = raw_input('Use which port? ')
port_index = int(value)
assert 0 <= port_index < len(ports)
return ports[port_index].device


def print_status(status):
for module in status:
state = ''
if module['state'] == 'panic':
state = '!!!!'
elif module['state'] == 'look_for_home':
state = '...'
elif module['state'] == 'sensor_error':
state = '????'
elif module['state'] == 'normal':
state = module['flap']
print('{:4} {: 4} {: 4}'.format(state, module['count_missed_home'], module['count_unexpected_home']))


if __name__ == '__main__':
run()
89 changes: 89 additions & 0 deletions software/splitflap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import json
from contextlib import contextmanager

import serial

_ALPHABET = {
' ',
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'.',
',',
'\'',
}


class Splitflap(object):

def __init__(self, serial_instance):
self.serial = serial_instance

self.has_inited = False
self.num_modules = 0
self.last_command = None
self.last_status = None
self.exception = None

def _loop_for_status(self):
while True:
line = self.serial.readline().lstrip('\0').rstrip('\n')
data = json.loads(line)
t = data['type']
if t == 'init':
if self.has_inited:
raise RuntimeError('Unexpected re-init!')
self.has_inited = True
self.num_modules = data['num_modules']
elif t == 'move_echo':
if not self.has_inited:
raise RuntimeError('Got move_echo before init!')
if self.last_command is None:
raise RuntimeError('Unexpected move_echo response from controller')
if self.last_command != data['dest']:
raise RuntimeError('Bad response from controller. Expected {!r} but got {!r}'.format(
self.last_command,
data['dest'],
))
elif t == 'status':
if not self.has_inited:
raise RuntimeError('Got status before init!')
if len(data['modules']) != self.num_modules:
raise RuntimeError('Wrong number of modules in status update. Expected {} but got {}'.format(
self.num_modules,
len(data['modules']),
))
self.last_status = data['modules']
return self.last_status
elif t == 'no_op':
pass
else:
raise RuntimeError('Unexpected message: {!r}'.format(data))

def is_in_alphabet(self, letter):
return letter in _ALPHABET

def set_text(self, text):
for letter in text:
assert self.is_in_alphabet(letter), 'Unexpected letter: {!r}. Must be one of {!r}'.format(
letter,
list(_ALPHABET),
)
self.last_command = text
self.serial.write('={}\n'.format(text))
return self._loop_for_status()

def recalibrate_all(self):
self.serial.write('@')
return self._loop_for_status()

def get_status(self):
return self.last_status


@contextmanager
def splitflap(serial_port):
with serial.Serial(serial_port, 38400) as ser:
s = Splitflap(ser)
s._loop_for_status()
yield s

0 comments on commit b93f9bf

Please sign in to comment.