diff --git a/.github/workflows/3d.yml b/.github/workflows/3d.yml index dd4e2111..7d1065ee 100644 --- a/.github/workflows/3d.yml +++ b/.github/workflows/3d.yml @@ -100,7 +100,7 @@ jobs: gzip 3d/build/outputs/3d_colored_stl/*.stl - name: Archive artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 if: always() with: name: 3d diff --git a/.github/workflows/electronics.yml b/.github/workflows/electronics.yml index 7ece8145..7d0f025a 100644 --- a/.github/workflows/electronics.yml +++ b/.github/workflows/electronics.yml @@ -65,7 +65,7 @@ jobs: PYTHONUNBUFFERED: 1 - name: Archive artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 if: always() with: name: electronics-classic @@ -150,7 +150,7 @@ jobs: run: cp -r electronics/build/bom electronics/build/outputs - name: Archive artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 if: always() with: name: electronics-chainlink @@ -237,7 +237,7 @@ jobs: run: cp -r electronics/build/bom electronics/build/outputs - name: Archive artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 if: always() with: name: electronics-chainlink-tester @@ -322,7 +322,7 @@ jobs: run: cp -r electronics/build/bom electronics/build/outputs - name: Archive artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 if: always() with: name: electronics-chainlink-base @@ -430,7 +430,7 @@ jobs: run: cp -r electronics/build/bom electronics/build/outputs - name: Archive artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 if: always() with: name: electronics-chainlink-buddy-t-display @@ -538,7 +538,7 @@ jobs: run: cp -r electronics/build/bom electronics/build/outputs - name: Archive artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 if: always() with: name: electronics-chainlink-buddy-breadboard diff --git a/.github/workflows/electronics_v2.yml b/.github/workflows/electronics_v2.yml index d00ecd7d..9ed579ca 100644 --- a/.github/workflows/electronics_v2.yml +++ b/.github/workflows/electronics_v2.yml @@ -87,7 +87,7 @@ jobs: ls -lah electronics/build/outputs - name: Archive artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 if: always() with: name: electronics-v2 diff --git a/3d/scripts/generate_2d.py b/3d/scripts/generate_2d.py index d74d0cdf..c791c4ce 100755 --- a/3d/scripts/generate_2d.py +++ b/3d/scripts/generate_2d.py @@ -162,18 +162,28 @@ def getDimensionSvgContents(text, width): elecrow_zip = os.path.join(laser_parts_directory, 'elecrow.zip') processor.apply_elecrow_style() - processor.add_dimensions(width_mm, height_mm) + processor.add_dimensions(width_mm, height_mm, args.mirror) processor.write(elecrow_svg) logging.info('Resize SVG canvas') - subprocess.check_call([ - app_paths.get('inkscape'), - '--verb=FitCanvasToDrawing', - '--verb=FileSave', - '--verb=FileClose', - '--verb=FileQuit', - elecrow_svg, - ]) + # since version 1.2, inkscape has replaced 'verbs' with 'actions' + # see: https://wiki.inkscape.org/wiki/Using_the_Command_Line + if inkscape._version() < 1.2: + subprocess.check_call([ + app_paths.get('inkscape'), + '--verb=FitCanvasToDrawing', + '--verb=FileSave', + '--verb=FileClose', + '--verb=FileQuit', + elecrow_svg, + ]) + else: + subprocess.check_call([ + app_paths.get('inkscape'), + "--actions=select-all;fit-canvas-to-selection;export-type:svg;export-plain-svg;export-do", + elecrow_svg, + "-o", elecrow_svg, + ]) logging.info('Output PDF') subprocess.check_call([ diff --git a/3d/scripts/generate_combined_front_panel.py b/3d/scripts/generate_combined_front_panel.py index 5e694bb4..3e3ea218 100755 --- a/3d/scripts/generate_combined_front_panel.py +++ b/3d/scripts/generate_combined_front_panel.py @@ -40,7 +40,7 @@ def render(extra_variables, output_directory): renderer = Renderer(os.path.join(source_parts_dir, 'combined_front_panel.scad'), output_directory, extra_variables) renderer.clean() - svg_output = renderer.render_svgs(panelize_quantity = 1) + svg_output, _ = renderer.render_svgs(panelize_quantity = 1) logging.info('\n\n\nDone rendering to SVG: ' + svg_output) if __name__ == '__main__': diff --git a/3d/scripts/generate_fonts.py b/3d/scripts/generate_fonts.py index b2bd4b60..7d0a5078 100755 --- a/3d/scripts/generate_fonts.py +++ b/3d/scripts/generate_fonts.py @@ -44,7 +44,7 @@ def render(extra_variables, skip_optimize, output_directory): renderer = Renderer(os.path.join(source_parts_dir, 'font_generator.scad'), output_directory, extra_variables) renderer.clean() - svg_output = renderer.render_svgs(panelize_quantity = 1) + svg_output, _ = renderer.render_svgs(panelize_quantity = 1) processor = SvgProcessor(svg_output) diff --git a/3d/scripts/requirements.txt b/3d/scripts/requirements.txt index cb4aa3ee..df964d2c 100644 --- a/3d/scripts/requirements.txt +++ b/3d/scripts/requirements.txt @@ -1 +1 @@ -svg.path==4.0.2 +svg.path==6.3 diff --git a/3d/scripts/svg_processor.py b/3d/scripts/svg_processor.py index d14d7163..e83632e1 100644 --- a/3d/scripts/svg_processor.py +++ b/3d/scripts/svg_processor.py @@ -15,6 +15,8 @@ from __future__ import print_function from collections import defaultdict import os + +svg_path_install = f'python3 -m pip install -r {os.path.join(os.path.dirname(__file__), "requirements.txt")}' try: from svg.path import ( Path, @@ -24,7 +26,11 @@ parse_path, ) except: - raise RuntimeError(f'Error loading svg.path library. Run "python3 -m pip install -r {os.path.join(os.path.dirname(__file__), "requirements.txt")}" to install it') + raise RuntimeError(f'Error loading svg.path library. Run "{svg_path_install}" to install it') + +from importlib.metadata import version +assert int(version('svg.path').split('.')[0]) == 6, f'svg.path library is not new enough (found {version("svg.path")}). Run "{svg_path_install}" to install it' + from xml.dom import minidom @@ -159,10 +165,11 @@ def remove_redundant_lines(self): path_text = path.attributes['d'].value path_obj = parse_path(path_text) for line_index, line in enumerate(path_obj): - # Moves don't draw anything by themselves, but they do set the - # target for subsequent closes, so they should not be removed. - slope, intersect = _get_slope_intersect(line.start, line.end) - if not isinstance(line, Move): + if isinstance(line, Close): + # Treat Close as a Line + line = Line(line.start, line.end) + if isinstance(line, Line): + slope, intersect = _get_slope_intersect(line.start, line.end) # TODO: float inaccuracy and rounding may cause collinear lines to end up in separate buckets in rare # cases, so this is not quite correct. Would be better to put lines into *2* nearest buckets in each # dimension to avoid edge cases. @@ -203,12 +210,17 @@ def remove_redundant_lines(self): filtered_path = Path() for line_index, line in enumerate(path_obj): + if isinstance(line, Close): + # Treat Close as a Line + line = Line(line.start, line.end) if i in to_remove: + assert isinstance(line, Line) assert path_index == to_remove[i][0] assert line_index == to_remove[i][1] removed += 1 removed_length += line.length() elif i in to_update: + assert isinstance(line, Line) assert path_index == to_update[i][0] assert line_index == to_update[i][1] replacement_line = to_update[i][2] @@ -216,22 +228,27 @@ def remove_redundant_lines(self): filtered_path.append(replacement_line) kept += 1 kept_length += replacement_line.length() - elif isinstance(line, Close): - # Replace the close with a line, because if we removed all - # or part of the previous line in this path, a close will - # not work as expected. - new_line = Line(line.start, line.end) - filtered_path.append(new_line) - kept += 1 - kept_length += new_line.length() - else: + elif isinstance(line, Line): filtered_path.append(line) kept += 1 kept_length += line.length() + else: + print(f'Omitting non-line in reconstructed path at index {line_index}: {line!r}') i += 1 # Update the path data with the filtered path data - path.attributes['d'] = filtered_path.d() + last_line = None + new_path = Path() + for line in filtered_path: + # Newer versions of svg.path keep Moves but don't ensure consistency with line start/ends. We've stripped non-Lines above, so + # we need to add back Moves where Line segments are not connected + if last_line is None or abs(last_line.end.real - line.start.real) > 0.001 or abs(last_line.end.imag - line.start.imag) > 0.001: + new_path.append(Move(line.start)) + new_path.append(line) + last_line = line + + path.attributes['d'] = new_path.d() + print(f'Optimized line path from\n {path_text!r}\n to\n {path.attributes["d"].value!r}') print('Removed {} lines ({} length) and kept {} lines ({} length)'.format( removed, @@ -353,30 +370,31 @@ def add_highlight_lines(self, lines, color): self.svg_node.appendChild(new_path_node) - def add_dimensions(self, width_mm, height_mm): + def add_dimensions(self, width_mm, height_mm, mirror=False): width_node = self.dom.createElement("path") - width_node.setAttribute('d', f'M 0 10 l 0 5 l {width_mm} 0 l 0 -5') + mirror_dir = -1 if mirror else 1 + width_node.setAttribute('d', f'M 0 10 l 0 5 l {mirror_dir * width_mm} 0 l 0 -5') width_node.setAttribute('fill', 'none') width_node.setAttribute('stroke', '#ff00ff') width_node.setAttribute('stroke-width', '1') self.svg_node.appendChild(width_node) width_label_node = self.dom.createElement('text') - width_label_node.setAttribute('x', f'{width_mm / 2}') + width_label_node.setAttribute('x', f'{mirror_dir * width_mm / 2}') width_label_node.setAttribute('y', '25') width_label_node.setAttribute('style', 'font: 5px sans-serif; fill: #ff00ff; text-anchor: middle;') width_label_node.appendChild(self.dom.createTextNode(f'{width_mm:.2f} mm')) self.svg_node.appendChild(width_label_node) height_node = self.dom.createElement("path") - height_node.setAttribute('d', f'M -10 0 l -5 0 l 0 -{height_mm} l 5 0') + height_node.setAttribute('d', f'M {-width_mm - 10 if mirror else -10} 0 l -5 0 l 0 -{height_mm} l 5 0') height_node.setAttribute('fill', 'none') height_node.setAttribute('stroke', '#ff00ff') height_node.setAttribute('stroke-width', '1') self.svg_node.appendChild(height_node) height_label_node = self.dom.createElement('text') - height_label_node.setAttribute('x', '-20') + height_label_node.setAttribute('x', f'{-width_mm - 20 if mirror else -20}') height_label_node.setAttribute('y', f'{-height_mm / 2}') height_label_node.setAttribute('style', 'font: 5px sans-serif; fill: #ff00ff; text-anchor: end;') height_label_node.appendChild(self.dom.createTextNode(f'{height_mm:.2f} mm')) diff --git a/3d/tools/flap_container.scad b/3d/tools/flap_container.scad index ba3f895d..2613eef0 100644 --- a/3d/tools/flap_container.scad +++ b/3d/tools/flap_container.scad @@ -16,6 +16,7 @@ include <../flap_dimensions.scad> use <../splitflap.scad> +use <../flap.scad> num_flaps = 40; containers_x = 1; diff --git a/README.md b/README.md index 46c3b264..bb9703fa 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ Note: the ordering and assembly guides have not yet been updated for the beta v2 - [Classic controller](#classic-controller-electronics-deprecated) + [Miscellaneous Tools](#miscellaneous-tools) - [Flaps and Fonts](#flaps-and-fonts) + - [Combined Front Panel Generator](#combined-front-panel-generator) - [3D Printed Tools](#3d-printed-tools) - [Chainlink Driver Tester](#chainlink-driver-tester) * [Code](#code) @@ -109,7 +110,7 @@ The mechanical/structural components are made from laser-cut 3mm MDF or acrylic, You can view an interactive 3d model of the design [here](https://scottbez1.github.io/splitflap/embed.html?branch=master). -The beta v2 mechanical design officially supports variants with 52 flaps (perfect for use with the new ["Epilogue" printed flaps](https://www.etsy.com/listing/1685633114/)) and 40 flaps. But you can always modify the design to customize it further. +The beta v2 mechanical design officially supports variants with 52 flaps (perfect for use with the new ["Epilogue" printed flaps](https://bezeklabs.etsy.com/listing/1685633114/)) and 40 flaps. But you can always modify the design to customize it further. ### Stable v0.7 (40-flap modules) ![2d laser cut rendering](https://s3.amazonaws.com/splitflap-artifacts/refs/tags/v0.7/3d/3d_laser_raster.png) @@ -170,19 +171,18 @@ power management/distribution and fault monitoring, UART and RS-485 connections, Each module needs a hall-effect sensor for start-up calibration and fault monitoring. #### Sensors for stable v0.7 hardware -Older sensors for the v0.7 and older laser-cut hardware can be found in the [tagged sensor release](https://github.com/scottbez1/splitflap/releases/tag/releases%2Fsensor%2Fv1.1) +Older sensors for the v0.7 and older laser-cut hardware can be found in the [tagged sensor release](https://github.com/scottbez1/splitflap/releases/tag/releases%2Fsensor%2Fv1.1). + +These older sensors are not compatible with v2 laser-cut hardware. -#### Sensors for v0.7 legacy laser-cut hardware -Older sensors for the v0.7 and older laser-cut hardware can be found in the [tagged sensor release](https://github.com/scottbez1/splitflap/releases/tag/releases%2Fsensor%2Fv1.1) - - - - +#### Beta Sensors v2 + + @@ -190,9 +190,14 @@ Older sensors for the v0.7 and older laser-cut hardware can be found in the [tag - + +New sensors for the v2 laser-cut hardware - these use surface mount components and are optimized for PCB assembly at JLCPCB. These new sensors are not compatible with v0.7 and older laser-cut hardware. + +Packs of 6 sensors are [available mostly-assembled in the Bezek Labs store](https://bezeklabs.etsy.com/listing/1696745674), +and come with the right-angle pin headers and magnets you'll need. Purchases support continued development of this project. + Latest auto-generated (untested!) artifacts:warning:: * Schematic [pdf](https://s3.amazonaws.com/splitflap-artifacts/master/electronics-v2/sensor_smd-schematic.pdf) @@ -207,6 +212,7 @@ Latest auto-generated (untested!) artifacts:warning:: * PCB gerbers [zip](https://s3.amazonaws.com/splitflap-artifacts/master/electronics-v2/sensor_smd-panelized-jlc/gerbers.zip) * PCB BOM (for JLCPCB assembly) [csv](https://s3.amazonaws.com/splitflap-artifacts/master/electronics-v2/sensor_smd-panelized-jlc/bom.csv) * PCB CPL (for JLCPCB assembly) [csv](https://s3.amazonaws.com/splitflap-artifacts/master/electronics-v2/sensor_smd-panelized-jlc/pos.csv) +* Purchase sensor kits in the US: [Bezek Labs](https://bezeklabs.etsy.com/listing/1696745674) :warning:For tested/stable/recommended artifacts, use the [latest release](https://github.com/scottbez1/splitflap/releases) instead @@ -224,7 +230,7 @@ for easy SMD/THT assembly to validate data integrity up and down the whole chain * Module order goes from right-to-left since this is intended to be installed and accessed from *behind* the modules -Chainlink Driver boards are [available mostly-assembled in the Bezek Labs store](https://www.etsy.com/listing/1123280069/splitflap-chainlink-driver-v11), +Chainlink Driver boards are [available mostly-assembled in the Bezek Labs store](https://bezeklabs.etsy.com/listing/1123280069/splitflap-chainlink-driver-v11), and come with the additional connectors and ribbon cables you'll need. Purchases support continued development of this project. More information on building and using Chainlink Drivers is available in the [Chainlink Driver User Guide](https://paper.dropbox.com/doc/Chainlink-Driver-v1.1-Electronics-User-Guide--BW2lxdjVkAxva68kYw2doWQEAg-U0DAXrSxEoOhgSoRU39hq). @@ -272,7 +278,7 @@ and then run a wire from the onboard screw terminals to the Chainlink Driver's m * Optional 5V regulator allows for powering the ESP32 without a USB connection, using the 12V motor power supply -Chainlink Buddy \[T-Display\] boards are [available in the Bezek Labs store](https://www.etsy.com/listing/1109357786/splitflap-chainlink-buddy-t-display), +Chainlink Buddy \[T-Display\] boards are [available in the Bezek Labs store](https://bezeklabs.etsy.com/listing/1109357786/splitflap-chainlink-buddy-t-display), and come with the additional connectors you'll need. Purchases support continued development of this project. @@ -302,7 +308,7 @@ Latest auto-generated (untested!) artifacts:warning:: The Chainlink Buddy \[Breadboard\] makes it easy to connect a Chainlink Driver to a breadboard for prototyping. You could use 5 dupont wires and have a messy rats nest, or you could use a single ribbon cable and this slick breakout board. -Chainlink Buddy \[Breadboard\] boards are [available in the Bezek Labs store](https://www.etsy.com/listing/1123863267/splitflap-chainlink-buddy-breadboard), +Chainlink Buddy \[Breadboard\] boards are [available in the Bezek Labs store](https://bezeklabs.etsy.com/listing/1123863267/splitflap-chainlink-buddy-breadboard), and come with the additional connectors you'll need. Purchases support continued development of this project. @@ -329,7 +335,7 @@ Latest auto-generated (untested!) artifacts:warning:: For larger displays, you should take additional care to make the hardware more robust to potential faults. The Chainlink Base is an experimental (but unsupported) controller design that adds some additional functionality. This has been tested and appears to work, but is not recommended for general use. -The Chainlink Base PCB is an optional alternative to a Chainink Buddy, designed for particularly large displays. +The Chainlink Base PCB is an optional alternative to a Chainlink Buddy, designed for particularly large displays. It hosts the ESP32 and adds additional connectivity options (terminals for UART and RS485 serial) and power distribution (independently-monitored power channels for multiple "zones" of Driver boards). @@ -392,6 +398,20 @@ that is extremely configurable: * Front/back - for batch duplex printing, generate separate front-side and back-side files (e.g. sign shop printing on a flat sheet of PVC) * Side-by-side - for individual flap printing, each flap's front design is laid out side-by-side with its back design +TODO: finish documenting this and render some example images... + +#### Combined Front Panel Generator +If you'd like to share a single front face across multiple modules (rather than each module having its own front face), the repo +includes a script to generate a combined front panel for laser-cutting or CNC milling/routing. + +You can modify: +* Number of rows and columns +* Horizontal and vertical spacing/separation of modules +* Overall outer width and height of the panel + +For CNC cutting, the script supports rendering a vector file optimized for thicker material (e.g. 6mm MDF) where only the bolt-holes will be through-cut. In this mode, the slots for the top/bottom enclosure pieces can be cut as ~4mm pockets so they aren't visible from the front face. The script automatically generates dog-bone shapes for these pocket cuts. + + TODO: finish documenting this and render some example images... #### 3D Printed Tools @@ -400,7 +420,6 @@ The project also includes a number of optional 3D printed designs to make assemb * [a flap scoring jig](3d/tools/scoring_jig.scad) for precisely marking the cut point when splitting CR80 cards * [a flap punch jig](3d/tools/punch_jig.scad) for aligning the punch when making the pin cutouts on either side of a flap * [a flap container](3d/tools/flap_container.scad) for storing and organizing stacks of completed flaps -* [a sensor PCB holder](3d/tools/pcb_case.scad) for storing and protecting soldered sensor boards All of these designs are parametric and customizable within OpenSCAD. To print them, open up the relevant file in OpenSCAD and use `File -> Export -> Export as STL` to render the design as an STL file for your slicer. @@ -454,53 +473,56 @@ The driver firmware is written using PlatformIO with the Arduino framework and i The firmware implements a closed-loop controller that accepts letters as input 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 hall-effect magnetic 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 -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](software) directory. +### Serial protocol +In order for a computer to communicate with the splitflap, it appears as a USB serial device. -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. +However, usage of Arduino’s `Serial` is strictly forbidden, and instead a `logger` abstraction is provided for sending basic text debug logs. Other data is transferred in a structured way, described below. -When the Arduino starts up, it sends an initialization message that looks like: -``` -{"type":"init", "num_modules":4} -``` +This allows flexibility in the format of data transferred over serial, and in fact the splitflap provides 2 different serial modes that serve different purposes. -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. +#### Plaintext mode -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: +By default, it starts in “plaintext” mode, which is developer-friendly and you’re probably familiar with if you’ve opened a serial monitor with the splitflap connected: ``` -=hiya\n +{"type":"init", "num_modules":6} ``` -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. +However, this isn’t great for programmatically configuring or receiving updates from the splitflap, so instead the firmware offers a programmatic interface using a binary protocol based on Google’s Protobuf standard. -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. +#### Protobuf (binary/programmatic) mode + +The protobuf-based binary serial mode is a compact and flexible way to transfer structured data from the host computer to the splitflap and vice-versa. + + +##### Benefits of protobuf +protobuf provides several benefits over other encoding mechanisms like JSON: + +1. Well-defined schema. If you’re curious about the format of data to expect or send, you just need to check the protobuf file +2. Code generation. Instead of hand-writing JSON parsers every time the data changes, protobuf provides code generation of the encoding and decoding logic, and data-structures. Splitflap uses nanopb to generate C structs based off the schema, and all the code for encoding/decoding that data from the binary format. +3. Relatively compact/efficient wire encoding. It’s not a primary goal in this project, but the binary wire encoding is generally fairly compact, due to omitting default/unspecified fields, using variable length encodings, etc. It’s certainly much more compact than JSON which uses strings to describe every field in every message. +4. Backwards/forwards compatibility. Not super relevant to this project, but many common schema changes are backwards and forwards compatible, meaning an older client or a newer client will be able to handle them gracefully. +##### Disadvantages of protobuf + +1. Not human-readable. This makes it much harder to debug, as you need something that can interpret messages. Since they’re binary, viewing anything in a terminal directly will be fruitless. +2. Not self-describing. If you come across a JSON document, you can generally tell what it means because fields have string names/labels describing their contents. Protobuf has no such thing (it uses integer field numbers, which does make renaming fields easier) so you need to have a copy of the schema (.proto file - and you’d better hope it matches the data!) in order to understand an encoded message. + +This is why the splitflap defaults to plaintext mode to make basic validation/debugging easier. + +##### How it works +Protobuf messages are encoded to their binary wire format and a CRC32 checksum appended. Then that entire binary string is COBS encoded into a packet, and delimited/framed by 0 (NULL) bytes when sent over serial. This provides a basic packet-based interface with integrity checks (rather than the raw, stream-based interface of a serial connection). + +The splitflap automatically switches to binary protobuf mode when it receives a 0 byte. + + +### Computer Control Software +The display can be controlled by a computer connected to the ESP32 over USB serial. If you've built a display and want to test it out, check out the web-based demo [here](https://scottbez1.github.io/splitflap) which will connect to your display using USB - no applications/installation necessary! +The firmware supports a plaintext serial mode (enabled by default) for ease of testing, and a protobuf-based binary mode used by the software libraries for enhanced programmatic control and feedback. +You can find example Typescript and Python libraries in the [`software/chainlink`](software/chainlink) folder. # Contributing/Modifying @@ -562,7 +584,7 @@ I'd love to hear your thoughts and questions about this project, and happy to in This project is licensed under Apache v2 (see [LICENSE.txt](LICENSE.txt)). - Copyright 2015-2021 Scott Bezek and the splitflap contributors + Copyright 2015-2024 Scott Bezek and the splitflap contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/arduino/splitflap/Splitflap/config.h b/arduino/splitflap/Splitflap/config.h index 809b76d3..bbf1eb44 100644 --- a/arduino/splitflap/Splitflap/config.h +++ b/arduino/splitflap/Splitflap/config.h @@ -60,7 +60,7 @@ const uint8_t flaps[NUM_FLAPS] = { ' ', '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', 'g', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'r', - '.', '?', '-', '$', '\'', '#', 'y', 'p', ',', '!', '~', '&', 'w' + '.', '?', '-', '$', '\'', '#', 'y', 'p', ',', '!', '@', '&', 'w' }; // Flap option 3: v2 flaps (limited 40-flap set using the first 40 flaps of the set) diff --git a/arduino/splitflap/esp32/core/splitflap_task.cpp b/arduino/splitflap/esp32/core/splitflap_task.cpp index 0497ecd9..e9b39534 100644 --- a/arduino/splitflap/esp32/core/splitflap_task.cpp +++ b/arduino/splitflap/esp32/core/splitflap_task.cpp @@ -376,7 +376,7 @@ void SplitflapTask::log(const char* msg) { void SplitflapTask::showString(const char* str, uint8_t length, bool force_full_rotation) { Command command = {}; command.command_type = CommandType::MODULES; - for (uint8_t i = 0; i < length; i++) { + for (uint8_t i = 0; i < length && i < NUM_MODULES; i++) { int8_t index = findFlapIndex(str[i]); if (index != -1) { if (force_full_rotation || index != modules[i]->GetTargetFlapIndex()) { diff --git a/arduino/splitflap/esp32/splitflap/mqtt_task.cpp b/arduino/splitflap/esp32/splitflap/mqtt_task.cpp index 6e68283c..4045b70b 100644 --- a/arduino/splitflap/esp32/splitflap/mqtt_task.cpp +++ b/arduino/splitflap/esp32/splitflap/mqtt_task.cpp @@ -55,6 +55,10 @@ void MQTTTask::connectMQTT() { if (mqtt_client_.connect(HOSTNAME "-" MQTT_USER, MQTT_USER, MQTT_PASSWORD)) { logger_.log("MQTT connected"); mqtt_client_.subscribe(MQTT_COMMAND_TOPIC); + char buf[256]; + snprintf(buf, sizeof(buf), "{\"name\": \"%s\", \"command_topic\": \"%s\", \"state_topic\": \"%s\", \"unique_id\": \"%s\"}", HOSTNAME, MQTT_COMMAND_TOPIC, MQTT_COMMAND_TOPIC, HOSTNAME); + mqtt_client_.publish("homeassistant/text/splitflap/config", buf); + logger_.log("Published MQTT discovery message"); } else { snprintf(buf, sizeof(buf), "MQTT failed rc=%d will try again in 5 seconds", mqtt_client_.state()); logger_.log(buf); @@ -76,4 +80,5 @@ void MQTTTask::run() { delay(1); } } + #endif diff --git a/software/chainlink/README.md b/software/chainlink/README.md new file mode 100644 index 00000000..98f23ef2 --- /dev/null +++ b/software/chainlink/README.md @@ -0,0 +1,25 @@ +# Chainlink software + +## Python Code + +Run the demo by installing the requirement dependencies outlined in the [requirements.txt](./requirements.txt). + +### Running the demo + +The demo will just send multiple different words to the splitflap board. You may start the demo by executing: + +```bash +python demo.py +``` + +You may get an output looking like this: + +```bash +Available ports: +[ 0] /dev/ttyACM0 - USB Single Serial +[ 1] /dev/ttyAMA0 - ttyAMA0 +``` + +Select which port the demo script should execute. + +Now the demo should send different words to the splitflap board with short breaks in between them. diff --git a/software/chainlink/demo.py b/software/chainlink/demo.py index 85b56db3..160b1661 100644 --- a/software/chainlink/demo.py +++ b/software/chainlink/demo.py @@ -7,10 +7,10 @@ ) words = [ - 'alpaca', 'baboon', 'badger', 'beluga', 'bobcat', 'ferret', - 'gopher', 'impala', 'jackal', 'jaguar', 'kitten', 'marmot', - 'monkey', 'ocelot', 'rabbit', 'racoon', 'turtle', 'walrus', - 'weasel', 'wombat', + 'ALPACA', 'BABOON', 'BADGER', 'BELUGA', 'BOBCAT', 'FERRET', + 'GOPHER', 'IMPALA', 'JACKAL', 'JAGUAR', 'KITTEN', 'MARMOT', + 'MONKEY', 'OCELOT', 'RABBIT', 'RACOON', 'TURTLE', 'WALRUS', + 'WEASEL', 'WOMBAT', ] diff --git a/software/chainlink/js/packages/example-webserial-basic/src/App.tsx b/software/chainlink/js/packages/example-webserial-basic/src/App.tsx index 65293db2..9b1ac394 100644 --- a/software/chainlink/js/packages/example-webserial-basic/src/App.tsx +++ b/software/chainlink/js/packages/example-webserial-basic/src/App.tsx @@ -22,48 +22,6 @@ const FLAP_COLOR_BLOCKS: Record = { 'y': '#ffd639', } -type MessageDelayMs = [string, number] - -const IDLE_MESSAGES_BY_LENGTH: Record = { - 6: [ - ['SPLIT', 6000], - [' FLAP', 6000], - [' BY', 6000], - ['BEZEK', 6000], - [' LABS', 12000], - ['', 2 * 60 * 1000], - ], - 12: [ - ['gprwygprwygp', 6000], - ['wwwwwwwwwwww', 5000], - [' wwwwwwwwwww', 100], - [' wwwwwwwwww', 100], - [' wwwwwwwww', 100], - [' wwwwwwww', 100], - [' wwwwwww', 100], - [' wwwwww', 100], - [' wwwww', 100], - [' wwww', 100], - [' www', 100], - [' ww', 100], - [' w', 100], - [' ', 500], - ['OPEN SOURCE ', 8000], - [' @SCOTTBEZ1', 12000], - [' ', 4000], - [' 999996 SUBS', 5000], - [' 999997 SUBS', 100], - [' 999998 SUBS', 600], - [' 999999 SUBS', 4000], - ['1000000 SUBS', 8000], - [' ', 4000], - ['COFFEE $3.49', 7000], - ['g BUILD PASS', 7000], - ['r TEST FAIL', 7000], - [' ', 4000], - ['COME TRY ME!', 2 * 60 * 1000], - ], -} type Config = NoUndefinedField @@ -235,29 +193,6 @@ export const App: React.FC = () => { }) }, [splitflapState.modules]) - const idleTimeout = useRef>(); - const curMessage = useRef(0); - const nextMessage = () => { - const idleMessages = IDLE_MESSAGES_BY_LENGTH[splitflapState.modules.length] - const m = idleMessages[(curMessage.current++) % idleMessages.length] - updateSplitflap(m[0], true) - idleTimeout.current = setTimeout(nextMessage, m[1]) - } - useEffect(() => { - if (!inputValue.user) { - return - } - const t = idleTimeout.current - if (t) { - clearTimeout(t) - curMessage.current = 0 - } - idleTimeout.current = setTimeout(() => { - setInputValue({val:'', user: false}) - nextMessage() - }, 1 * 60 * 1000) - }, [inputValue, splitflapState.modules.length]) - const numModules = splitflapState.modules.length const charWidth = Math.max(1000 / numModules, 40) diff --git a/software/chainlink/requirements.txt b/software/chainlink/requirements.txt new file mode 100644 index 00000000..95d74a67 --- /dev/null +++ b/software/chainlink/requirements.txt @@ -0,0 +1 @@ +protobuf==3.18.3 \ No newline at end of file diff --git a/software/chainlink/splitflap_proto.py b/software/chainlink/splitflap_proto.py index e2cd881a..fa76c71b 100644 --- a/software/chainlink/splitflap_proto.py +++ b/software/chainlink/splitflap_proto.py @@ -41,15 +41,11 @@ class ForceMovement(Enum): RETRY_TIMEOUT = 0.25 - # TODO: read alphabet from splitflap once this is possible - _DEFAULT_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', - '.', - ',', - '\'', + _LEGACY_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', '.', ',', "'", ] def __init__(self, serial_instance): @@ -66,7 +62,8 @@ def __init__(self, serial_instance): self._current_config = splitflap_pb2.SplitflapConfig() self._num_modules = None - self._alphabet = Splitflap._DEFAULT_ALPHABET + self._alphabet = Splitflap._LEGACY_ALPHABET + self._alphabet_received = False def _read_loop(self): self._logger.debug('Read loop started') @@ -125,6 +122,9 @@ def _process_frame(self, frame): self._current_config.modules.append(splitflap_pb2.SplitflapConfig.ModuleConfig()) else: assert self._num_modules == num_modules_reported, f'Number of reported modules changed (was {self._num_modules}, now {num_modules_reported})' + elif payload_type == 'general_state' and not self._alphabet_received: + self._alphabet_received = True + self._alphabet = list(message.general_state.flap_character_set.decode('utf-8')) with self._lock: for handler in self._message_handlers[payload_type] + self._message_handlers[None]: