From 4f2498ab4d72d55412e80bd32d7f03c4df9c9b2e Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Wed, 25 Sep 2024 13:48:55 -0700 Subject: [PATCH] [WIP] Allow looping uart streamed gcode --- FluidNC/src/FixedCircularBuffer.h | 61 +++++++++++++++++++ FluidNC/src/Flowcontrol.cpp | 8 ++- FluidNC/src/Job.cpp | 32 +++++++--- FluidNC/src/Job.h | 1 + FluidNC/src/Protocol.h | 2 + FluidNC/src/UartChannel.cpp | 21 ++++++- FluidNC/src/UartChannel.h | 10 ++- FluidNC/tests/FixedCircularBufferTest.cpp | 42 +++++++++++++ fixture_tests/fixtures/flow_control_repeat.nc | 45 ++++++++++++++ fixture_tests/run_fixture | 9 ++- fixture_tests/tool/controller.py | 26 ++++++-- fixture_tests/tool/op_entries.py | 36 ++++++++--- 12 files changed, 261 insertions(+), 32 deletions(-) create mode 100644 FluidNC/src/FixedCircularBuffer.h create mode 100644 FluidNC/tests/FixedCircularBufferTest.cpp create mode 100644 fixture_tests/fixtures/flow_control_repeat.nc diff --git a/FluidNC/src/FixedCircularBuffer.h b/FluidNC/src/FixedCircularBuffer.h new file mode 100644 index 000000000..f2936b15a --- /dev/null +++ b/FluidNC/src/FixedCircularBuffer.h @@ -0,0 +1,61 @@ +// Copyright (c) 2024 - Dylan Knutson +// Use of this source code is governed by a GPLv3 license that can be found in the LICENSE file. + +#pragma once + +#include +#include + +/** + * A fixed-size circular buffer that stores elements of type T. + * Keeps track of how many elements have been pushed onto it, and allows + * for indexing as if it was an infinite sized array. If indexing into + * the buffer would result in an out-of-bounds access, returns std::nullopt. + * + * This is useful for implementing "scrollback" of a buffer of e.g. user + * provided commands, without using an unbounded amount of memory. + */ +template +class FixedCircularBuffer { +public: + std::vector storage; + std::size_t head_idx, tail_idx; + +public: + FixedCircularBuffer() : FixedCircularBuffer(0) {} + FixedCircularBuffer(size_t size) : storage(size), head_idx(0), tail_idx(0) {} + + /** + * Push an element onto the end of the buffer. + */ + void push(T&& elem) { + storage[tail_idx % storage.size()] = std::move(elem); + tail_idx += 1; + if (tail_idx - head_idx > storage.size()) { + head_idx += 1; + } + } + + /** + * Get the element at the given index, or std::nullopt if the index is out of bounds. + */ + std::optional at(std::size_t idx) const { + if (idx >= tail_idx) { + return std::nullopt; + } + if (idx < head_idx) { + return std::nullopt; + } + return storage[idx % storage.size()]; + } + + /** + * Is the buffer empty? + */ + bool is_empty() const { return head_idx == tail_idx; } + + /** + * Get the index of the last element pushed onto the buffer. + */ + std::size_t position() const { return tail_idx; } +}; diff --git a/FluidNC/src/Flowcontrol.cpp b/FluidNC/src/Flowcontrol.cpp index 4be12aecd..0fe38e3e6 100644 --- a/FluidNC/src/Flowcontrol.cpp +++ b/FluidNC/src/Flowcontrol.cpp @@ -233,8 +233,10 @@ Error flowcontrol(uint32_t o_label, char* line, size_t& pos, bool& skip) { case Op_Repeat: if (Job::active()) { if (!skipping && (status = expression(line, pos, value)) == Error::Ok) { - stack_push(o_label, operation, !value); - if (value) { + // TODO - return an error if value < 0 + // For now, just guard against negative values + stack_push(o_label, operation, !(value > 0.0)); + if (value > 0.0) { context.top().file = Job::source(); context.top().file_pos = context.top().file->position(); context.top().repeats = (uint32_t)value; @@ -249,7 +251,7 @@ Error flowcontrol(uint32_t o_label, char* line, size_t& pos, bool& skip) { if (Job::active()) { if (last_op == Op_Repeat) { if (o_label == context.top().o_label) { - if (context.top().repeats && --context.top().repeats) { + if (context.top().repeats && --context.top().repeats > 0.0) { context.top().file->set_position(context.top().file_pos); } else { stack_pull(); diff --git a/FluidNC/src/Job.cpp b/FluidNC/src/Job.cpp index 5de670121..a97801d08 100644 --- a/FluidNC/src/Job.cpp +++ b/FluidNC/src/Job.cpp @@ -4,17 +4,29 @@ #include "Job.h" #include #include +#include "Protocol.h" std::stack job; Channel* Job::leader = nullptr; bool Job::active() { - return !job.empty(); + return !job.empty() || activeChannel; } +JobSource activeChannelJobSource(nullptr); + JobSource* Job::source() { - return job.empty() ? nullptr : job.top(); + if (job.empty()) { + if (activeChannel) { + activeChannelJobSource.set_channel(activeChannel); + return &activeChannelJobSource; + } else { + return nullptr; + } + } else { + return job.top(); + } } // save() and restore() are use to close/reopen an SD file atop the job stack @@ -38,9 +50,11 @@ void Job::nest(Channel* in_channel, Channel* out_channel) { job.push(source); } void Job::pop() { - auto source = job.top(); - job.pop(); - delete source; + if (!job.empty()) { + auto source = job.top(); + job.pop(); + delete source; + } if (!active()) { leader = nullptr; } @@ -60,14 +74,14 @@ void Job::abort() { } bool Job::get_param(const std::string& name, float& value) { - return job.top()->get_param(name, value); + return source()->get_param(name, value); } bool Job::set_param(const std::string& name, float value) { - return job.top()->set_param(name, value); + return source()->set_param(name, value); } bool Job::param_exists(const std::string& name) { - return job.top()->param_exists(name); + return source()->param_exists(name); } Channel* Job::channel() { - return job.top()->channel(); + return source()->channel(); } diff --git a/FluidNC/src/Job.h b/FluidNC/src/Job.h index c44684b77..378b659b5 100644 --- a/FluidNC/src/Job.h +++ b/FluidNC/src/Job.h @@ -31,6 +31,7 @@ class JobSource { void set_position(size_t pos) { _channel->set_position(pos); } Channel* channel() { return _channel; } + void set_channel(Channel* channel) { _channel = channel; } ~JobSource() { delete _channel; } }; diff --git a/FluidNC/src/Protocol.h b/FluidNC/src/Protocol.h index 37d08c4d7..0ede1bc23 100644 --- a/FluidNC/src/Protocol.h +++ b/FluidNC/src/Protocol.h @@ -50,6 +50,8 @@ extern volatile bool rtCycleStop; extern volatile bool runLimitLoop; +extern Channel* activeChannel; + // Alarm codes. enum class ExecAlarm : uint8_t { None = 0, diff --git a/FluidNC/src/UartChannel.cpp b/FluidNC/src/UartChannel.cpp index 9859f78ae..1c907d327 100644 --- a/FluidNC/src/UartChannel.cpp +++ b/FluidNC/src/UartChannel.cpp @@ -6,8 +6,10 @@ #include "Serial.h" // allChannels UartChannel::UartChannel(int num, bool addCR) : Channel("uart_channel", num, addCR) { - _lineedit = new Lineedit(this, _line, Channel::maxLine - 1); - _active = false; + _lineedit = new Lineedit(this, _line, Channel::maxLine - 1); + _active = false; + _history_buffer = FixedCircularBuffer(512); + _history_buffer_pos = 0; } void UartChannel::init() { @@ -63,10 +65,13 @@ size_t UartChannel::write(const uint8_t* buffer, size_t length) { } int UartChannel::available() { - return _uart->available(); + return (_history_buffer_pos < _history_buffer.position()) || _uart->available(); } int UartChannel::peek() { + if (_history_buffer_pos < _history_buffer.position()) { + return _history_buffer.at(_history_buffer_pos).value(); + } return _uart->peek(); } @@ -90,12 +95,22 @@ bool UartChannel::lineComplete(char* line, char c) { } int UartChannel::read() { + if (_history_buffer_pos < _history_buffer.position()) { + int c = _history_buffer.at(_history_buffer_pos).value(); + _history_buffer_pos += 1; + return c; + } + int c = _uart->read(); if (c == 0x11) { // 0x11 is XON. If we receive that, it is a request to use software flow control _uart->setSwFlowControl(true, -1, -1); return -1; } + if (c != -1) { + _history_buffer.push((char)c); + _history_buffer_pos += 1; + } return c; } diff --git a/FluidNC/src/UartChannel.h b/FluidNC/src/UartChannel.h index eb8cbb955..1c2ffbfff 100644 --- a/FluidNC/src/UartChannel.h +++ b/FluidNC/src/UartChannel.h @@ -6,11 +6,14 @@ #include "Uart.h" #include "Channel.h" #include "lineedit.h" +#include "FixedCircularBuffer.h" class UartChannel : public Channel, public Configuration::Configurable { private: - Lineedit* _lineedit; - Uart* _uart; + Lineedit* _lineedit; + Uart* _uart; + FixedCircularBuffer _history_buffer; + std::size_t _history_buffer_pos; int _uart_num = 0; int _report_interval_ms = 0; @@ -49,6 +52,9 @@ class UartChannel : public Channel, public Configuration::Configurable { handler.item("uart_num", _uart_num); handler.item("message_level", _message_level, messageLevels2); } + + size_t position() override { return _history_buffer_pos; } + void set_position(size_t pos) override { _history_buffer_pos = pos; } }; extern UartChannel Uart0; diff --git a/FluidNC/tests/FixedCircularBufferTest.cpp b/FluidNC/tests/FixedCircularBufferTest.cpp new file mode 100644 index 000000000..729f1ce13 --- /dev/null +++ b/FluidNC/tests/FixedCircularBufferTest.cpp @@ -0,0 +1,42 @@ +// Copyright (c) 2024 - Dylan Knutson +// Use of this source code is governed by a GPLv3 license that can be found in the LICENSE file. + +#include "gtest/gtest.h" +#include "src/FixedCircularBuffer.h" + +TEST(FixedCircularBuffer, Empty) { + FixedCircularBuffer buffer(0); + + ASSERT_TRUE(buffer.is_empty()); + ASSERT_EQ(buffer.position(), 0); + ASSERT_EQ(buffer.at(0), std::nullopt); + ASSERT_EQ(buffer.at(1), std::nullopt); + ASSERT_EQ(buffer.at(2), std::nullopt); +} + +TEST(FixedCircularBuffer, OneElement) { + FixedCircularBuffer buffer(1); + + buffer.push(42); + + ASSERT_FALSE(buffer.is_empty()); + ASSERT_EQ(buffer.position(), 1); + ASSERT_EQ(buffer.at(0), 42); + ASSERT_EQ(buffer.at(1), std::nullopt); + ASSERT_EQ(buffer.at(2), std::nullopt); +} + +TEST(FixedCircularBuffer, FrontElementsPopped) { + FixedCircularBuffer buffer(2); + + buffer.push(1); + buffer.push(2); + buffer.push(3); + + ASSERT_FALSE(buffer.is_empty()); + ASSERT_EQ(buffer.position(), 3); + ASSERT_EQ(buffer.at(0), std::nullopt); + ASSERT_EQ(buffer.at(1), 2); + ASSERT_EQ(buffer.at(2), 3); + ASSERT_EQ(buffer.at(3), std::nullopt); +} diff --git a/fixture_tests/fixtures/flow_control_repeat.nc b/fixture_tests/fixtures/flow_control_repeat.nc new file mode 100644 index 000000000..efea4c1a0 --- /dev/null +++ b/fixture_tests/fixtures/flow_control_repeat.nc @@ -0,0 +1,45 @@ +ignore ok + +# test repeat zero times (should not print anything) +-> o100 repeat [0] +-> (print, fail lit 0) +-> o100 endrepeat +-> (print, pass lit 0) +<- [MSG:INFO: PRINT, pass lit 0] + +# test when using a variable +-> # = 0 +-> o100 repeat [#] +-> (print, fail var 0) +-> o100 endrepeat +-> (print, pass var 0) +<- [MSG:INFO: PRINT, pass var 0] + +# test negative repeat (should not print anything) +# todo - negative repeat should probably set an error +-> o100 repeat [-1] +-> (print, fail lit -1) +-> o100 endrepeat +-> (print, pass lit -1) +<- [MSG:INFO: PRINT, pass lit -1] + +# test repeat a fixed number of times +-> # = 0 +-> o100 repeat [3] +-> # = [# + 1] +-> (print, count=%d#) +<- [MSG:INFO: PRINT, count=1] +-> o100 endrepeat +<- [MSG:INFO: PRINT, count=2] +<- [MSG:INFO: PRINT, count=3] + +# test repeating a variable number of times +-> # = 0 +-> # = 3 +-> o100 repeat [#] +-> # = [# + 1] +-> (print, count=%d#) +<- [MSG:INFO: PRINT, count=1] +-> o100 endrepeat +<- [MSG:INFO: PRINT, count=2] +<- [MSG:INFO: PRINT, count=3] diff --git a/fixture_tests/run_fixture b/fixture_tests/run_fixture index 0398d748a..a0f6c0e65 100755 --- a/fixture_tests/run_fixture +++ b/fixture_tests/run_fixture @@ -28,6 +28,12 @@ else: def run_fixture(fixture_path, controller): op_entries_parsed = op_entries.parse_file(fixture_path) + print( + colored(f"--- Run fixture ", "blue") + + colored(fixture_path, "blue", attrs=["bold"]) + + colored(" ---", "blue") + ) + try: for op_entry in op_entries_parsed: if not op_entry.execute(controller): @@ -39,7 +45,8 @@ def run_fixture(fixture_path, controller): exit(1) except KeyboardInterrupt: - print("Interrupt") + print("Interrupted by user") + exit(1) except TimeoutError as e: print("Timeout waiting for response, line: " + e.args[0]) diff --git a/fixture_tests/tool/controller.py b/fixture_tests/tool/controller.py index d443617a3..10c8ca3a3 100644 --- a/fixture_tests/tool/controller.py +++ b/fixture_tests/tool/controller.py @@ -1,5 +1,8 @@ import serial from termcolor import colored +from tool.utils import color + +DEBUG_SERIAL = False class Controller: @@ -7,8 +10,10 @@ def __init__(self, device, baudrate, timeout): self._debug = False self._serial = serial.Serial(device, baudrate, timeout=timeout) self._current_line = None + self._ignored_lines = set() def send_soft_reset(self): + self._ignored_lines = set() self._serial.write(b"\x18") self._serial.flush() self.clear_line() @@ -17,10 +22,21 @@ def send_soft_reset(self): self.clear_line() self.clear_line() + def ignore_line(self, line): + self._ignored_lines.add(line) + def current_line(self): - if self._current_line is None: - self._current_line = self._serial.readline().decode("utf-8").strip() - # print(colored("[c] <- " + self._current_line, "light_blue")) + while self._current_line is None: + line = self._serial.readline().decode("utf-8").strip() + if DEBUG_SERIAL: + print(colored("[c] <- " + line, "light_blue")) + + if line in self._ignored_lines: + print(color.dark_grey("<- " + line + " (ignored)", dark=True)) + continue + + self._current_line = line + break return self._current_line def clear_line(self): @@ -31,7 +47,9 @@ def next_line(self): return self.current_line() def send_line(self, line): - # print(colored("[c] -> " + line, "light_blue")) + if DEBUG_SERIAL: + print(colored("[c] -> " + line, "light_blue")) + self._serial.write(line.encode("utf-8") + b"\n") def getc(self, size): diff --git a/fixture_tests/tool/op_entries.py b/fixture_tests/tool/op_entries.py index c453612dd..057908e31 100644 --- a/fixture_tests/tool/op_entries.py +++ b/fixture_tests/tool/op_entries.py @@ -9,6 +9,10 @@ def parse_file(fixture_path): with open(fixture_path, "r") as f: op_entries = [] for lineno, line in enumerate(f.read().splitlines()): + if line == "": + # skip empty lines + continue + if line.startswith("#"): # skip comment lines continue @@ -59,6 +63,13 @@ def _op_str(self): return color.dark_grey(self.op) + " " +class IgnoreLineOpEntry(OpEntry): + def execute(self, controller): + print(self._op_str() + color.dark_grey(self.data, dark=True, bold=True)) + controller.ignore_line(self.data) + return True + + class SendLineOpEntry(OpEntry): def execute(self, controller): print(self._op_str() + color.sent_line(self.data)) @@ -109,26 +120,29 @@ def execute(self, controller): class UntilStringMatchOpEntry(OpEntry): def __init__(self, op, data, lineno, fixture_path): - self.glob_match = data.startswith("* ") + self._glob_match = data.startswith("* ") super().__init__(op, data.removeprefix("* "), lineno, fixture_path) def execute(self, controller): while True: - matches = self._line_matches(controller) - print( - self._op_str() - + color.green(controller.current_line(), dark=True, bold=matches) - ) + line = controller.current_line() + matches = self._line_matches(line) + + opstr = self._op_str() + if self._glob_match: + opstr += color.dark_grey("* ", bold=True) + + print(opstr + color.green(line, dark=True, bold=matches)) controller.clear_line() if matches: break return True - def _line_matches(self, controller): - if self.glob_match: - return fnmatch.fnmatch(controller.current_line(), self.data) + def _line_matches(self, line): + if self._glob_match: + return fnmatch.fnmatch(line, self.data) else: - return self.data == controller.current_line() + return self.data == line class SendFileOpEntry(OpEntry): @@ -217,6 +231,8 @@ def execute(self, controller): OPS_MAP = { + # ignores messages consisting of only the following line (e.g. 'ok') + "ignore": IgnoreLineOpEntry, # send command to controller "->": SendLineOpEntry, # send file to controller