Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
bd24083
Add Arduino API coverage plan
schappim May 9, 2026
1c81252
Add bits & bytes helpers (bit, bit_read/write/set/clear, high_byte, l…
schappim May 9, 2026
a463652
Add math helpers (map_value, constrain, sq)
schappim May 9, 2026
bccb421
Add character classification helpers (12 is_* predicates)
schappim May 9, 2026
a7de5aa
Add random_seed, random_range, random_max + ?-suffixed predicate aliases
schappim May 9, 2026
dfc1488
Add tone()/no_tone() with Timer2 CTC + COMPA ISR
schappim May 9, 2026
2a019b1
Add pulse_in_long with default and explicit timeouts
schappim May 9, 2026
8ac42eb
Add external interrupts (attach/detach + flag-based polling)
schappim May 9, 2026
76963d0
Add analog_reference (REFS bits configurable per channel)
schappim May 9, 2026
470c3b8
Add serial extensions (end, flush, peek, available_for_write, set/get…
schappim May 9, 2026
a823e24
Add Serial input parsers (parse_int, parse_float, find, find_until, r…
schappim May 9, 2026
a2efe80
Add Serial print formatting (HEX/BIN/OCT, floats)
schappim May 9, 2026
2259c79
Add EEPROM helpers (read/write/update + 32-bit int read/write)
schappim May 9, 2026
2d53112
Add SPI library (begin/end, transfer/transfer16, clock/mode/order)
schappim May 9, 2026
eeedecd
Add Wire (I2C master) library
schappim May 9, 2026
acf7259
Add Servo library (single-servo) on Timer1
schappim May 9, 2026
202a857
Add arduino_yield (no-op cooperative-multitasking hook)
schappim May 9, 2026
6dd8833
Update Arduino API coverage plan to reflect implemented helpers
schappim May 9, 2026
c37517a
Add 01.Basics example translations
schappim May 9, 2026
5fca4be
Add 02.Digital example translations
schappim May 9, 2026
6fc341d
Add 03.Analog example translations
schappim May 9, 2026
d8fcff1
Add 04.Communication example translations
schappim May 9, 2026
e28bf41
Add 05.Control example translations
schappim May 9, 2026
195348d
Add 06.Sensors example translations
schappim May 9, 2026
37f77a3
Add 07.Display example translations
schappim May 9, 2026
f1e23e1
Add is_graph helper + 08.Strings/character_analysis port
schappim May 9, 2026
1f25514
Add bulk-compile test covering every example sketch
schappim May 9, 2026
0ba1654
Add Ruby-style aliases for predicates, math, sleep, srand, stop_tone
schappim May 9, 2026
a59769f
Add Ruby-style module facades: Pin, Serial, Eeprom, Spi, Wire, Servo
schappim May 9, 2026
686c820
Move CLI into Rubyduino::CLI; encapsulate codegen loader
schappim May 9, 2026
945648d
Add NeoPixel/DHT/1-Wire/DS18B20/LCD/Stepper/SoftwareSerial/IR drivers…
schappim May 9, 2026
037a9ef
Add 18 ThinkerShield Crack the Code samples (converted to Ruby)
schappim May 10, 2026
a277165
test_builtin_examples: widen glob to crack_the_code + root sketches
schappim May 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 101 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,20 @@ Under the hood it uses [Spinel](https://github.com/matz/spinel), a Ruby AOT comp

</div>

## Example

<img width="500" height="281" alt="IMG_0197" src="https://github.com/user-attachments/assets/d2b1cc69-647a-4f63-b090-31b13a21e5a8" />
## Hello, Blink

```ruby
pin_mode(ArduinoUNO::LED_BUILTIN, ArduinoUNO::OUTPUT)
pin_mode(ArduinoUno::LED_BUILTIN, ArduinoUno::OUTPUT)

loop do
digital_write(ArduinoUNO::LED_BUILTIN, ArduinoUNO::HIGH)
delay_ms(100)
digital_write(ArduinoUNO::LED_BUILTIN, ArduinoUNO::LOW)
delay_ms(100)
digital_write(ArduinoUno::LED_BUILTIN, ArduinoUno::HIGH)
sleep_ms(1000)
digital_write(ArduinoUno::LED_BUILTIN, ArduinoUno::LOW)
sleep_ms(1000)
end
```

Not bad, huh?
<img width="500" height="281" alt="IMG_0197" src="https://github.com/user-attachments/assets/d2b1cc69-647a-4f63-b090-31b13a21e5a8" />

## Installation

Expand Down Expand Up @@ -57,15 +55,107 @@ sudo dnf install avr-gcc avr-libc avrdude
## Usage

```bash
rubyduino examples/hello.rb
rubyduino examples/builtin/01_basics/blink.rb
```

Pass a serial port explicitly when auto-detection is not enough:

```bash
rubyduino -p /dev/cu.usbmodem11401 examples/hello.rb
rubyduino -p /dev/cu.usbmodem11401 examples/builtin/01_basics/blink.rb
```

## Two API styles

Rubyduino exposes the same functionality two ways. Pick whichever reads better — both compile to identical AVR code.

**Arduino-compatible (porting `.ino` sketches)**

```ruby
pin_mode(13, ArduinoUno::OUTPUT)
digital_write(13, ArduinoUno::HIGH)
delay_ms(500)

serial_begin(9600)
serial_println("hello")
serial_println(value, ArduinoUno::HEX)
```

**Ruby-style (modules, predicates, idiomatic naming)**

```ruby
Pin.mode(13, ArduinoUno::OUTPUT)
Pin.high(13)
sleep_ms(500)

Serial.begin(9600)
Serial.println_str("hello")
Serial.println_hex(value)

Eeprom.write(0, 0xAB); v = Eeprom.read(0)
Spi.begin; reply = Spi.transfer(0x9F)
Wire.begin; Wire.transmit(0x68); Wire.write(0x6B); Wire.end_transmission
Servo.attach(9); Servo.angle = 90
```

Other Ruby-idiomatic helpers: `clamp`, `square`, `sleep_ms`, `sleep_us`, `srand`, `stop_tone`, predicate aliases like `alpha?`, `digit?`, `hex_digit?`, `interrupt_fired?`, `servo_attached?`, plus `without_interrupts { ... }` for critical sections.

## What's covered

The full Arduino UNO core API plus most of the bundled libraries — see [`plans/arduino_api_coverage.md`](plans/arduino_api_coverage.md) for the precise checklist.

**Core API:** `pin_mode`, `digital_read/write`, `analog_read/write`, `analog_reference`, `delay_ms`, `delay_us`, `millis`, `micros`, `pulse_in`, `pulse_in_long`, `shift_in/out`, `tone`, `no_tone`, `attach_interrupt`, `detach_interrupt`, all bit/byte macros, `map_value`, `constrain`, `sq`, all 13 character classifiers (with `?` aliases), full Serial including `parse_int`, `parse_float`, `find?`, formatted print (HEX/BIN/OCT/float), `random_seed` + `rand(low..high)` codegen.

**Bundled libraries:** EEPROM, SPI, Wire (I²C master), Servo (single-servo on Timer1).

**Common third-party hardware**, all bit-banged in `lib/rubyduino/sp_runtime.h`:

| Driver | Helpers | Wiring |
|----------------|-----------------------------------------------------------------|-----------------------|
| WS2812 / NeoPixel | `neopixel_begin`, `neopixel_set_pixel`, `neopixel_show`, `neopixel_clear` | data pin only |
| DHT11 / DHT22 | `dht_read?`, `dht_temperature_x10`, `dht_humidity_x10` | 1 wire + pull-up |
| 1-Wire / DS18B20 | `onewire_*`, `ds18b20_request_temperature`, `ds18b20_read_temperature_x10` | 1 wire + 4.7k pull-up |
| HD44780 LCD (4-bit) | `lcd_begin`, `lcd_print_str`, `lcd_print_int`, `lcd_set_cursor`, `lcd_clear` | RS, EN, D4–D7 |
| Stepper | `stepper_begin`, `stepper_set_speed`, `stepper_step` | 4 GPIO |
| SoftwareSerial | `soft_serial_begin`, `soft_serial_write`, `soft_serial_print_str`, `soft_serial_read` | RX + TX pins |
| IR remote (NEC) | `ir_receive?`, `ir_command` | TSOP38xx demodulator |

## Examples

Every Arduino built-in example for an UNO ships as a Ruby port:

- [`examples/builtin/01_basics`](examples/builtin/01_basics) — Blink, Fade, AnalogReadSerial, etc.
- [`examples/builtin/02_digital`](examples/builtin/02_digital) — BlinkWithoutDelay, Debounce, tone melodies, …
- [`examples/builtin/03_analog`](examples/builtin/03_analog) — AnalogInOutSerial, Calibration, Smoothing, …
- [`examples/builtin/04_communication`](examples/builtin/04_communication) — ASCIITable, Dimmer, Midi, …
- [`examples/builtin/05_control`](examples/builtin/05_control) — Arrays, switchCase, while, …
- [`examples/builtin/06_sensors`](examples/builtin/06_sensors) — ADXL3xx, Knock, Memsic2125, Ping
- [`examples/builtin/07_display`](examples/builtin/07_display) — barGraph, RowColumnScanning
- [`examples/builtin/08_strings`](examples/builtin/08_strings) — character_analysis (the rest depend on the Arduino `String` class)

Hardware-driver examples live in [`examples/hardware`](examples/hardware):

- `neopixel_rainbow.rb` — 8-pixel WS2812 rainbow chase
- `dht22_serial.rb` — temperature/humidity over Serial
- `ds18b20_serial.rb` — DS18B20 temperature read every second
- `lcd_hello.rb` — 16x2 HD44780 hello-world
- `stepper_loop.rb` — 4-wire stepper full forward/back rotation
- `software_serial_passthrough.rb` — bridge between hardware and software UART
- `ir_remote_decode.rb` — print decoded NEC IR frames

## Tests

```bash
bundle exec rake test
```

The test suite has three layers:

1. **Codegen tests** — exercise the Ruby → C compiler hooks.
2. **Native logic tests** — extract pure-logic helpers and compile/run them with `clang` on the host.
3. **AVR compile tests** — run the full pipeline through `avr-gcc` and inspect ELF output for ISR vectors and symbols.

Bulk-compile coverage walks every sketch in `examples/builtin/**` and `examples/hardware/**` and verifies it links cleanly for `atmega328p`.

## License

MIT
189 changes: 2 additions & 187 deletions bin/rubyduino
Original file line number Diff line number Diff line change
@@ -1,191 +1,6 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require "fileutils"
require "English"
require "open3"
require "rbconfig"
require "tmpdir"
require_relative "../lib/rubyduino/cli"

def usage
warn "Usage: rubyduino [options] input.rb"
warn ""
warn "Options:"
warn " -p PORT Serial port, e.g. /dev/cu.usbmodem31401"
warn " -o NAME Output base path without extension"
warn " --mcu MCU AVR MCU, default: atmega328p"
warn " --baud BAUD Upload baud, default: 115200"
exit 1
end

def run!(*cmd, chdir: nil, env: {})
options = {}
options[:chdir] = chdir if chdir
ok = system(env, *cmd, **options)
return if ok

status = $CHILD_STATUS&.exitstatus
abort "rubyduino: command failed#{status ? " with exit #{status}" : ""}: #{cmd.join(" ")}"
end

def capture!(*cmd)
out, status = Open3.capture2e(*cmd)
return out.strip if status.success?

abort out.empty? ? "rubyduino: command failed: #{cmd.join(" ")}" : out
end

def command?(name)
!executable(name).nil?
end

def executable(name)
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).filter_map do |dir|
path = File.join(dir, name)
path if File.file?(path) && File.executable?(path)
end.first
end

def runnable_executable?(path)
_output, status = Open3.capture2e(path, "-?")
status.success?
rescue SystemCallError
false
end

def find_port
patterns = %w[
/dev/cu.usbmodem*
/dev/cu.usbserial*
/dev/ttyACM*
/dev/ttyUSB*
]
patterns.lazy.flat_map { |pattern| Dir.glob(pattern) }.first
end

def find_avrdude
candidates = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).filter_map do |dir|
path = File.join(dir, "avrdude")
path if File.file?(path) && File.executable?(path)
end

candidates.concat(
Dir.glob(
[
"/Applications/Arduino.app/Contents/**/bin/avrdude",
"/opt/homebrew/**/bin/avrdude",
"/usr/local/**/bin/avrdude"
]
)
)

candidates.uniq.find { |path| runnable_executable?(path) }
end

def avrdude_conf_for(avrdude)
conf = File.expand_path("../etc/avrdude.conf", File.dirname(avrdude))
File.file?(conf) ? conf : nil
end

root_dir = File.expand_path("..", __dir__)
unless Dir.exist?(File.join(root_dir, "vendor/spinel"))
require "rubyduino/spinel"
root_dir = File.expand_path("../..", Rubyduino::Spinel::ROOT)
end

spinel_dir = File.join(root_dir, "vendor/spinel")
rubyduino_dir = File.join(root_dir, "lib/rubyduino")
parse_bin = File.join(spinel_dir, "spinel_parse")
codegen_rb = File.join(rubyduino_dir, "spinel_arduino_codegen.rb")
entry_c = File.join(rubyduino_dir, "arduino_entry.c")
arduino_uno_rb = File.join(rubyduino_dir, "arduino_uno.rb")

mcu = "atmega328p"
f_cpu = "16000000UL"
baud = "115200"
port = nil
out = nil
source = nil

argv = ARGV.dup
until argv.empty?
arg = argv.shift
case arg
when "-p"
usage if argv.empty?
port = argv.shift
when "-o"
usage if argv.empty?
out = argv.shift
when "--mcu"
usage if argv.empty?
mcu = argv.shift
when "--baud"
usage if argv.empty?
baud = argv.shift
when "-h", "--help"
usage
when /\.rb\z/
usage if source
source = arg
else
usage
end
end

usage unless source
abort "rubyduino: #{source}: No such file" unless File.file?(source)
abort "rubyduino: missing Spinel checkout at #{spinel_dir}" unless Dir.exist?(spinel_dir)
abort "rubyduino: missing #{codegen_rb}" unless File.file?(codegen_rb)
abort "rubyduino: missing #{entry_c}" unless File.file?(entry_c)
abort "rubyduino: missing #{arduino_uno_rb}" unless File.file?(arduino_uno_rb)
abort "rubyduino: avr-gcc not found" unless command?("avr-gcc")
abort "rubyduino: avr-objcopy not found" unless command?("avr-objcopy")

unless File.executable?(parse_bin)
if command?("make")
warn "Spinel: building parser in #{spinel_dir}"
prism_header = File.join(spinel_dir, "vendor/prism/include/prism/diagnostic.h")
run!("make", "-C", spinel_dir, "deps") unless File.file?(prism_header)
run!("make", "-C", spinel_dir, "PRISM_DIR=vendor/prism", "parse")
end
end
abort "rubyduino: missing #{parse_bin}; run 'make -C #{spinel_dir} parse'" unless File.executable?(parse_bin)

out ||= source.end_with?(".rb") ? source.delete_suffix(".rb") : source
c_file = "#{out}.c"
elf_file = "#{out}.elf"
hex_file = "#{out}.hex"
app_obj = "#{out}.o"
entry_obj = "#{out}.entry.o"

port ||= find_port
abort "rubyduino: no Arduino serial port found; pass -p /dev/..." unless port

avrdude = find_avrdude
abort "rubyduino: avrdude not found" unless avrdude

ast_tmp = File.join(Dir.tmpdir, "spinel_arduino_ast.#{$PROCESS_ID}.#{rand(1_000_000)}")
source_tmp = File.join(Dir.tmpdir, "rubyduino_source.#{$PROCESS_ID}.#{rand(1_000_000)}.rb")
begin
warn "Spinel: #{source} -> #{c_file}"
File.write(source_tmp, "#{File.read(arduino_uno_rb)}\n#{File.read(source)}")
run!(parse_bin, source_tmp, ast_tmp)
run!(RbConfig.ruby, codegen_rb, ast_tmp, c_file)

warn "AVR: #{c_file} -> #{hex_file}"
compile_flags = ["-Os", "-DF_CPU=#{f_cpu}", "-mmcu=#{mcu}", "-I#{rubyduino_dir}", "-I#{File.join(spinel_dir, "lib")}"]
run!("avr-gcc", *compile_flags, "-Dmain=sp_arduino_user_main", "-c", c_file, "-o", app_obj)
run!("avr-gcc", *compile_flags, "-c", entry_c, "-o", entry_obj)
run!("avr-gcc", "-Os", "-DF_CPU=#{f_cpu}", "-mmcu=#{mcu}", app_obj, entry_obj, "-o", elf_file)
run!("avr-objcopy", "-O", "ihex", "-R", ".eeprom", elf_file, hex_file)
run!("avr-size", "--mcu=#{mcu}", "--format=avr", elf_file) if command?("avr-size")

warn "Flash: #{hex_file} -> #{port}"
conf = avrdude_conf_for(avrdude)
conf_args = conf ? ["-C#{conf}"] : []
run!(avrdude, *conf_args, "-p#{mcu}", "-carduino", "-P#{port}", "-b#{baud}", "-D", "-Uflash:w:#{hex_file}:i")
ensure
FileUtils.rm_f(ast_tmp)
FileUtils.rm_f(source_tmp)
end
Rubyduino::CLI.run(ARGV)
13 changes: 13 additions & 0 deletions examples/builtin/01_basics/analog_read_serial.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# AnalogReadSerial
#
# Reads an analog input on pin A0, prints the result to the Serial Monitor.
#
# https://docs.arduino.cc/built-in-examples/basics/AnalogReadSerial/

serial_begin(9600)

loop do
sensor_value = analog_read(ArduinoUNO::A0)
serial_println(sensor_value)
delay_ms(1)
end
9 changes: 9 additions & 0 deletions examples/builtin/01_basics/bare_minimum.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# BareMinimum
#
# https://docs.arduino.cc/built-in-examples/basics/BareMinimum/

# put your setup code here, to run once

loop do
# put your main code here, to run repeatedly
end
14 changes: 14 additions & 0 deletions examples/builtin/01_basics/blink.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Blink
#
# Turns an LED on for one second, then off for one second, repeatedly.
#
# https://docs.arduino.cc/built-in-examples/basics/Blink/

pin_mode(ArduinoUNO::LED_BUILTIN, ArduinoUNO::OUTPUT)

loop do
digital_write(ArduinoUNO::LED_BUILTIN, ArduinoUNO::HIGH)
delay_ms(1000)
digital_write(ArduinoUNO::LED_BUILTIN, ArduinoUNO::LOW)
delay_ms(1000)
end
16 changes: 16 additions & 0 deletions examples/builtin/01_basics/digital_read_serial.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# DigitalReadSerial
#
# Reads a digital input on pin 2, prints the result to the Serial Monitor.
#
# https://docs.arduino.cc/built-in-examples/basics/DigitalReadSerial/

push_button = 2

serial_begin(9600)
pin_mode(push_button, ArduinoUNO::INPUT)

loop do
button_state = digital_read(push_button)
serial_println(button_state)
delay_ms(1)
end
Loading
Loading