diff --git a/.github/workflows/compile-blink.yaml b/.github/workflows/compile-blink.yaml index 57cd396..34e3a48 100644 --- a/.github/workflows/compile-blink.yaml +++ b/.github/workflows/compile-blink.yaml @@ -5,7 +5,7 @@ name: Compile blink on: push: paths: - - src/blink + - 'src/blink/*' - .github/workflows/compile.yaml - .github/workflows/compile-blink.yaml diff --git a/.github/workflows/compile-controller.yaml b/.github/workflows/compile-controller.yaml index 645c6a5..1fcb671 100644 --- a/.github/workflows/compile-controller.yaml +++ b/.github/workflows/compile-controller.yaml @@ -5,7 +5,7 @@ name: Compile controller on: push: paths: - - src/controller + - 'src/controller/*' - LoggerCore/src - .github/workflows/compile.yaml - .github/workflows/compile-controller.yaml diff --git a/.github/workflows/compile-function.yaml b/.github/workflows/compile-function.yaml new file mode 100644 index 0000000..f5c1212 --- /dev/null +++ b/.github/workflows/compile-function.yaml @@ -0,0 +1,39 @@ +# name of the job +name: Compile function + +# specify which paths to watch for changes +on: + push: + paths: + - 'src/function/*' + - 'LoggerCore/src/*' + - .github/workflows/compile.yaml + - .github/workflows/compile-function.yaml + +# run compile via the compile.yaml +jobs: + compile: + strategy: + fail-fast: false + matrix: + # CHANGE program/lib/aux as needed + program: + - src: 'function' + lib: '' + aux: 'LoggerCore/src/LoggerFunction* LoggerCore/src/LoggerModule*' + # CHANGE platforms as needed + platform: + - {name: 'p2', version: '6.3.2'} + + # program name + name: ${{ matrix.program.src }}-${{ matrix.platform.name }}-${{ matrix.platform.version }} + + # workflow call + uses: ./.github/workflows/compile.yaml + secrets: inherit + with: + platform: ${{ matrix.platform.name }} + version: ${{ matrix.platform.version }} + src: ${{ matrix.program.src }} + lib: ${{ matrix.program.lib }} + aux: ${{ matrix.program.aux }} \ No newline at end of file diff --git a/.github/workflows/compile-i2c_scanner.yaml b/.github/workflows/compile-i2c_scanner.yaml index 839e840..2c8e616 100644 --- a/.github/workflows/compile-i2c_scanner.yaml +++ b/.github/workflows/compile-i2c_scanner.yaml @@ -5,7 +5,7 @@ name: Compile i2c scanner on: push: paths: - - src/i2c_scanner + - 'src/i2c_scanner/*' - .github/workflows/compile.yaml - .github/workflows/compile-i2c_scanner.yaml diff --git a/.github/workflows/compile-oled.yaml b/.github/workflows/compile-oled.yaml new file mode 100644 index 0000000..a5ba047 --- /dev/null +++ b/.github/workflows/compile-oled.yaml @@ -0,0 +1,38 @@ +# name of the job +name: Compile oled + +# specify which paths to watch for changes +on: + push: + paths: + - 'src/oled/*' + - .github/workflows/compile.yaml + - .github/workflows/compile-oled.yaml + +# run compile via the compile.yaml +jobs: + compile: + strategy: + fail-fast: false + matrix: + # CHANGE program/lib/aux as needed + program: + - src: 'oled' + lib: 'Adafruit_SSD1306_RK/src Adafruit_GFX_RK/src Adafruit_BusIO_RK/src' + aux: '' + # CHANGE platforms as needed + platform: + - {name: 'p2', version: '6.3.2'} + + # program name + name: ${{ matrix.program.src }}-${{ matrix.platform.name }}-${{ matrix.platform.version }} + + # workflow call + uses: ./.github/workflows/compile.yaml + secrets: inherit + with: + platform: ${{ matrix.platform.name }} + version: ${{ matrix.platform.version }} + src: ${{ matrix.program.src }} + lib: ${{ matrix.program.lib }} + aux: ${{ matrix.program.aux }} \ No newline at end of file diff --git a/.github/workflows/compile-publish.yaml b/.github/workflows/compile-publish.yaml index c39c572..3dfb3f4 100644 --- a/.github/workflows/compile-publish.yaml +++ b/.github/workflows/compile-publish.yaml @@ -5,8 +5,8 @@ name: Compile publish on: push: paths: - - src/publish - - LoggerCore/src + - 'src/publish/*' + - 'LoggerCore/src/*' - .github/workflows/compile.yaml - .github/workflows/compile-publish.yaml diff --git a/.github/workflows/compile.yaml b/.github/workflows/compile.yaml index 6a449cc..bf4a78d 100644 --- a/.github/workflows/compile.yaml +++ b/.github/workflows/compile.yaml @@ -1,4 +1,4 @@ -name: Base workflow_call for compile actions +name: Base workflow for compile on: workflow_call: diff --git a/.gitmodules b/.gitmodules index 6c0b555..c31b2e5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,12 @@ [submodule "lib/SparkFun_Qwiic_OpenLog_Arduino_Library"] path = lib/SparkFun_Qwiic_OpenLog_Arduino_Library url = https://github.com/sparkfun/SparkFun_Qwiic_OpenLog_Arduino_Library.git +[submodule "lib/Adafruit_SSD1306_RK"] + path = lib/Adafruit_SSD1306_RK + url = https://github.com/rickkas7/Adafruit_SSD1306_RK +[submodule "lib/Adafruit_GFX_RK"] + path = lib/Adafruit_GFX_RK + url = https://github.com/rickkas7/Adafruit_GFX_RK +[submodule "lib/Adafruit_BusIO_RK"] + path = lib/Adafruit_BusIO_RK + url = https://github.com/rickkas7/Adafruit_BusIO_RK diff --git a/.vscode/settings.json b/.vscode/settings.json index d9cd3ea..1a309cd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -74,6 +74,7 @@ "charconv": "cpp", "clocale": "cpp", "span": "cpp", - "variant": "cpp" + "variant": "cpp", + "deque": "cpp" } } \ No newline at end of file diff --git a/LoggerCore/src/LoggerFunction.cpp b/LoggerCore/src/LoggerFunction.cpp new file mode 100644 index 0000000..1a89e05 --- /dev/null +++ b/LoggerCore/src/LoggerFunction.cpp @@ -0,0 +1,402 @@ +#include "Particle.h" +#include "LoggerFunction.h" +#include "LoggerFunctionReturns.h" + +Variant LoggerFunction::Command::toVariant() { + Variant var; + var.set("c", cmd); + if (allow_numeric_values) { + // numeric values are allowed (1 = shorter in JSON than true) + var.set("n", 1); + } + if ( expect_value && value_optional ) { + // value attribute is optional (1 = shorter in JSON than true) + var.set("o", 1); + } + if (!text_values.isEmpty()) { + // what are the allowed values? + Variant vals; + for (size_t i = 0; i < text_values.size(); ++i) + vals.append(text_values[i].c_str()); + var.set("v", vals); + } + if (allow_numeric_values && !numeric_units.isEmpty()) { + // what units are allowed? + Variant units; + for (size_t i = 0; i < numeric_units.size(); ++i) + units.append(numeric_units[i].c_str()); + var.set("u",units); + } + return(var); +} + +Variant LoggerFunction::getCommands() { + Variant cmds; + for (auto& cmd : m_commands) { + // group by module to further optimize the JSON + if (!cmds.has(cmd.module)) + cmds.set(cmd.module, Variant()); + cmds[cmd.module].append(cmd.toVariant()); + } + return(cmds); +} + +void LoggerFunction::setup() { + Log.info("registering particle function '%s'", m_function); + Particle.function(m_function, &LoggerFunction::receiveCall, this); + + // available commands variable + if (m_var_available_commands != nullptr) { + Log.info("registering particle variable '%s'", m_var_available_commands); + String cmds_json = getCommands().toJSON(); + if (cmds_json.length() < particle::protocol::MAX_FUNCTION_ARG_LENGTH) { + // commands fit into the particle variable + snprintf(m_value_available_commands, particle::protocol::MAX_FUNCTION_ARG_LENGTH, "%s", cmds_json.c_str()); + } else { + // commands don't fit + Variant trunc; + trunc.set("trunc", true); + trunc.set("error", + String::format("commands are too long (%d chars) and don't fit into the size limit of a particle variable (%d)", + cmds_json.length(), particle::protocol::MAX_FUNCTION_ARG_LENGTH)); + snprintf(m_value_available_commands, particle::protocol::MAX_FUNCTION_ARG_LENGTH, "%s", trunc.toJSON().c_str()); + } + Particle.variable(m_var_available_commands, m_value_available_commands); + } + + // last call variable + if (m_var_last_calls != nullptr) { + // starting value is just an empty json array since there are no commands yet + snprintf(m_value_last_calls, particle::protocol::MAX_FUNCTION_ARG_LENGTH, "%s", "[]"); + Particle.variable(m_var_last_calls, m_value_last_calls); + } +} + +void LoggerFunction::registerCommand(const std::function& cb, const char* module, const char* cmd, + const Vector& text_values, bool allow_numeric_values, + const Vector& numeric_units, bool value_optional) { + Log.info("registering command '%s' for module '%s'", cmd, module); + + // check for issues + size_t i = 0; + bool overwrite = false; + for (; i < m_commands.size(); ++i) { + if (strcmp(module, m_commands[i].module) == 0 && strcmp(cmd, m_commands[i].cmd) == 0) { + Log.warn("cmd (%s) already exists for this module (%s), overwriting existing", cmd, module); + overwrite = true; + break; + } + if (strcmp(cmd, m_commands[i].module) == 0 || strcmp(module, m_commands[i].cmd) == 0 || strcmp(cmd, module) == 0) { + // should this be here or elsewhere? not sure it's visible on logger startup + Log.error("identically named module and command (%s) can cause confusion and is not permitted", cmd); + return; + } + } + + // add/overwrite + if (overwrite) + m_commands[i].use = false; // flag for ignoring (=overwrite) + m_commands.append({cb, module, cmd, text_values, allow_numeric_values, numeric_units, value_optional}); // add +} + +int LoggerFunction::receiveCall (String call) { + + using namespace LoggerFunctionReturns; + + // store call and basic info in the Variant and then parse it + // important: this is NOT a member variable on purpose because variants + // that are modified lead to memory fragmentation + Variant parsed; + parsed.set("call", call.c_str()); + parsed.set("dt", Time.format(Time.now(), "%Y-%m-%d %H:%M:%S %Z")); + parsed.set("lt", "cmd"); // log type + size_t cmd_idx = parseCall(parsed); + + // any issues? + if (cmd_idx == PARSING_ERROR) { + + // parsing error + Log.trace("parsing error: %s", parsed.toJSON().c_str()); + parsed.set("success", false); + + } else { + + // found a command while parsing, execute the callback + Log.trace("execute callback with: %s", parsed.toJSON().c_str()); + bool success = m_commands[cmd_idx].callback(parsed); + parsed.set("success", success); + + // if no ret val set yet + if (success && !hasReturnValue(parsed)) { + setSuccess(parsed); + } else if (!success && !hasReturnValue(parsed)) { + setReturnValue(parsed, CALL_ERR_UNKNOWN); + } + } + + // report command to cloud if logging is on + if (m_log) { + // FIXME: implement + // LoggerPublisher::queueData(parsed); + // this will also print the published data like below instead of here + Log.trace("after callback:"); + Log.print(parsed.toJSON().c_str()); + Log.print("\n"); + } + + // update last call variable? + if (m_var_last_calls != nullptr) { + + // restore from JSON (stored in char to avoid memory fragmentation) + Variant call_log = Variant::fromJSON(m_value_last_calls); + + // store the last call in the call log + call_log.append(parsed); + size_t call_log_size = call_log.toJSON().length(); + while (call_log_size >= particle::protocol::MAX_FUNCTION_ARG_LENGTH && !call_log.isEmpty()) { + // remove the oldest entries until they fit + call_log.removeAt(0); + call_log_size = call_log.toJSON().length(); + } + + // set call_log + if (Log.isTraceEnabled()) { + Log.trace("new value for Particle.variable('%s') from %d commands in call log stack", m_var_last_calls, call_log.size()); + Log.print(call_log.toJSON().c_str()); + Log.print("\n"); + } + + // assign call log + snprintf(m_value_last_calls, particle::protocol::MAX_FUNCTION_ARG_LENGTH, "%s", call_log.toJSON().c_str()); + } + + // return return value + return(getReturnValue(parsed)); +} + +size_t LoggerFunction::parseCall(Variant& parsed) { + + // logger function returns + using namespace LoggerFunctionReturns; + + // get call back out + String call = parsed.get("call").toString(); + if (call.length() == 0) { + setReturnValue(parsed, CALL_ERR_EMPTY); + return(PARSING_ERROR); + } + + // make a mutable local copy for thread safety + // use a smart pointer for memory management + auto copy = std::make_unique(call.length() + 1); + std::strcpy(copy.get(), call.c_str()); + + // get module + char *part = strtok(copy.get(), " "); + + // module or cmd exists? + bool mod_found = false; + size_t cmd_idx = 0; + uint n_cmds_found = 0; + for (size_t i = 0; i < m_commands.size(); ++i) { + if (!m_commands[i].use) continue; + if (strcmp(part, m_commands[i].module) == 0) { + parsed.set("m", part); + mod_found = true; + } + if (strcmp(part, m_commands[i].cmd) == 0) { + Log.trace("cmd match: %s", part); + parsed.set("c", part); + n_cmds_found++; + cmd_idx = i; + } + } + + // what was found? + if (mod_found && n_cmds_found == 0) { + // all good, found a module and now looking for a command + part = strtok(nullptr, " "); + if (part == nullptr) { + // no command provided --> error + setReturnValue(parsed, CALL_ERR_CMD_MISS); + return(PARSING_ERROR); + } + for (size_t i = 0; i < m_commands.size(); ++i) { + if (!m_commands[i].use) continue; + if (strcmp(parsed.get("m").asString().c_str(), m_commands[i].module) == 0 && strcmp(part, m_commands[i].cmd) == 0) { + // found the command + Log.trace("cmd match: %s", part); + parsed.set("c", part); + n_cmds_found++; + cmd_idx = i; + break; + } + } + if (n_cmds_found == 0) { + // no command of those that are registered for the module fits + setReturnValue(parsed, CALL_ERR_CMD_UNREC); + return(PARSING_ERROR); + } + } else if (!mod_found && n_cmds_found == 1) { + // all good, found a single command --> set the module accordingly + parsed.set("m", m_commands[cmd_idx].module); + } else if (!mod_found && n_cmds_found == 0) { + // was neither a comand nor a module -> error + setReturnValue(parsed, CALL_ERR_CMD_MOD_UNREC); + return(PARSING_ERROR); + } else if (!mod_found && n_cmds_found > 1) { + // command is ambiguous (might be in multiple modules) + setReturnValue(parsed, CALL_ERR_AMBIGUOUS); + return(PARSING_ERROR); + } + + // safety check to avoid segfaults // REMOVE? + if (cmd_idx >= m_commands.size()) { + Log.error("this should never happen, cmd_idx (%d) is too large, there are only %d commands", cmd_idx, m_commands.size()); + return(PARSING_ERROR); + } + + // values expected? + if (m_commands[cmd_idx].expect_value) { + part = strtok(nullptr, " "); + if (part == nullptr) { + if (!m_commands[cmd_idx].value_optional) { + // no value provided and value is not optional --> error + setReturnValue(parsed, CALL_ERR_VAL_MISS); + return(PARSING_ERROR); + } + // empty value but that's okay + parsed.set("vtext", Variant()); + } else { + // got a value! + parsed.set("vtext", part); + bool valid_value = false; + + // let's see if it matches any of the allowed text values + if (m_commands[cmd_idx].text_values.size() > 0) { + for (size_t i = 0; i < m_commands[cmd_idx].text_values.size(); ++i) { + if (strcmp(part, m_commands[cmd_idx].text_values[i].c_str()) == 0) { + // found the value (already correctly assigned "vtext") + Log.trace("value match: %s", part); + valid_value = true; + break; + } + } + // nothing found and numeric values not allowed? + if (!valid_value && !m_commands[cmd_idx].allow_numeric_values) { + // --> error + setReturnValue(parsed, CALL_ERR_VAL_UNREC); + return(PARSING_ERROR); + } + } + + // no match yet? let's see if it's a valid numeric value (if they're allowed) + if (!valid_value && m_commands[cmd_idx].allow_numeric_values) { + + char* num_end = nullptr; + + // convert the initial numeric part to double + double number = strtod(part, &num_end); + + if (num_end == part) { + // not a valid number + setReturnValue(parsed, CALL_ERR_VAL_NAN); + return(PARSING_ERROR); + } else { + // yay number + parsed.set("vnum", number); + Log.trace("numeric value: %s", parsed.get("vnum").toString().c_str()); + } + + if (*num_end != '\0' && m_commands[cmd_idx].numeric_units.isEmpty()) { + // found units directly after the number but none were expected! --> error + parsed.set("u", num_end); + setReturnValue(parsed, CALL_ERR_UNIT_UNEXP); + return(PARSING_ERROR); + } + + // check for units + if (!m_commands[cmd_idx].numeric_units.isEmpty()) { + bool valid_units = false; + if (*num_end != '\0') { + // found units directly after the number + parsed.set("u", num_end); + } else { + // fetch next part + part = strtok(nullptr, " "); + if (part == nullptr) { + setReturnValue(parsed, CALL_ERR_UNIT_MISS); + return(PARSING_ERROR); + } + parsed.set("u", part); + } + // check if the units fit any of the expected + for (size_t i = 0; i < m_commands[cmd_idx].numeric_units.size(); ++i) { + if (strcmp(parsed.get("u").asString().c_str(), m_commands[cmd_idx].numeric_units[i].c_str()) == 0) { + // found the unit (already correctly assigned "u") + Log.trace("unit match: %s", parsed.get("u").asString().c_str()); + valid_units = true; + break; + } + } + + // did we find valid units? + if (!valid_units) { + // no --> units not recognized + setReturnValue(parsed, CALL_ERR_UNIT_UNREC); + return(PARSING_ERROR); + } + } + } + } + } + + // check for params if function interprets them + if (!m_params.isEmpty()) { + bool found_param = false; + size_t current_param = 0; + String param_value; + part = strtok(nullptr, " "); + while (part != nullptr) { + bool new_param = false; + + // starts with a 'param='? + for (size_t i = 0; i < m_params.size(); ++i) { + String prefix = m_params[i] + "="; + if (strncmp(part, prefix.c_str(), prefix.length()) == 0) { + // found a param! + if (found_param) { + // store the previous param in the variant + Log.trace("param: %s='%s'", m_params[current_param].c_str(), param_value.c_str()); + parsed.set(m_params[current_param].c_str(), param_value.c_str()); + } + // start the new param + found_param = true; + new_param = true; + current_param = i; + param_value = part + prefix.length(); // start new valuew without the prefix + break; + } + } + + // append value to current param value + if (found_param && !new_param) { + param_value += " "; + param_value += part; + } + + // continue the search + part = strtok(nullptr, " "); + } + + // any param to wrap up? + if (found_param) { + // store the previous param in the variant + Log.trace("param: %s='%s'", m_params[current_param].c_str(), param_value.c_str()); + parsed.set(m_params[current_param].c_str(), param_value.c_str()); + } + } + + // parsing complete + return(cmd_idx); +} diff --git a/LoggerCore/src/LoggerFunction.h b/LoggerCore/src/LoggerFunction.h new file mode 100644 index 0000000..c496120 --- /dev/null +++ b/LoggerCore/src/LoggerFunction.h @@ -0,0 +1,180 @@ +#pragma once +#include "Particle.h" +#include "LoggerFunctionReturns.h" + +/** + * extension of return codes + */ +namespace LoggerFunctionReturns { + inline constexpr Error CALL_ERR_UNKNOWN = { -1, "undefined error"}; + inline constexpr Error CALL_ERR_EMPTY = { -2, "call is empty"}; + inline constexpr Error CALL_ERR_AMBIGUOUS = { -3, "command ambiguous (exists in multiple modules), specify module"}; + inline constexpr Error CALL_ERR_CMD_MOD_UNREC = { -4, "module/command not recognized"}; + inline constexpr Error CALL_ERR_CMD_MISS = { -5, "module found but no command provided"}; + inline constexpr Error CALL_ERR_CMD_UNREC = { -6, "module found but command not recognized"}; + inline constexpr Error CALL_ERR_VAL_MISS = { -7, "value required but none provided"}; + inline constexpr Error CALL_ERR_VAL_NAN = { -8, "value is not a valid number"}; + inline constexpr Error CALL_ERR_VAL_UNREC = { -9, "value not recognized"}; + inline constexpr Error CALL_ERR_UNIT_UNEXP = {-11, "unit after number value but no unit was expected"}; + inline constexpr Error CALL_ERR_UNIT_MISS = {-10, "unit required but none provided"}; + inline constexpr Error CALL_ERR_UNIT_UNREC = {-12, "unit not recognized"}; +} + +/** + * class that manages a function call for the logger + */ +class LoggerFunction { + + protected: + + // name of the function and variables that are registered with Particle.function/variable + const char* m_function; + const char* m_var_available_commands; + char m_value_available_commands[particle::protocol::MAX_FUNCTION_ARG_LENGTH]; + const char* m_var_last_calls; + char m_value_last_calls[particle::protocol::MAX_FUNCTION_ARG_LENGTH]; + + // call parameters (xyz=, abc=) to interpret/capture + const Vector m_params; + + // whether to log received calls with LoggerPublisher + bool m_log; + + // return value indicating a parsing error + const size_t PARSING_ERROR = std::numeric_limits::max(); + + // command object for registering commands + struct Command { + std::function callback; + const char* module; + const char* cmd; + const Vector text_values = {};// if specific text values are allowed (can be fixed number values too) + bool allow_numeric_values = false; + const Vector numeric_units = {}; // if allow_numeric = true and the value should have units + bool value_optional = false; // whether providing a value is required or optional + bool expect_value = true; // if either text_values are provided or numeric_values are allowed + bool use = true; // flag when command is deactivated for some reason + + Command(std::function callback, const char* module, const char* cmd, + const Vector& text_values, bool allow_numeric_values, + const Vector& numeric_units, bool value_optional) : + callback(callback), module(module), cmd(cmd), text_values(text_values), allow_numeric_values(allow_numeric_values), numeric_units(numeric_units), + value_optional(value_optional), expect_value(allow_numeric_values || text_values.size() > 0), use(true) {} + + /** + * generate a variant with the command, this is in an optimized JSON format with + * variables only included when necessary and true/false represented as 1/0 + */ + Variant toVariant(); + }; + + // vector of commands + // this is a non-const Variant but since it is not modified during runtime (only during startup) + // it is not a memory problem + Vector m_commands; + + // register a full cloud command with a std:function call, used by other registerCommmand... calls + void registerCommand(const std::function& cb, const char* module, const char* cmd, + const Vector& text_values, bool allow_numeric_values, + const Vector& numeric_units, bool value_optional); + + // parses the function call + // returns the m_commands index of the command that fits the call (or PARSED_ERROR if parsing error) + size_t parseCall(Variant& parsed); + + public: + + // common text values used a lot + inline static const char* on = "on"; + inline static const char* off = "off"; + + // default constructor that's used for lablogger devices + // copy into your class to make these explicit for your device + // don't change the string constants for compatibility with the LabLogger framwork + LoggerFunction() : + LoggerFunction( + "device", // name of the Particle.function call + {"user", "note"}, // parameters (param=) interpreted by the call + true, // whether to log each command using the LoggerPublisher + "commands", // name of the Particle.variable where all available commands are stored (as JSON) + "last_calls" // name of the Particle.variable where the last received function calls are stored (as JSON) + ) + {} + + // constructor with user defined parameters + LoggerFunction(const char* function, const Vector& params, bool log, const char* var_available_commands, const char* var_last_calls) : m_function(function), m_var_available_commands(var_available_commands), m_var_last_calls(var_last_calls), m_params(params), m_log(log) {} + + /** + * @brief must be called at the end of setup() to register the cloud function+variables and start listening to commands - note that any registerCommand that is called AFTER setup is not included in the available commands + */ + void setup(); + + /** + * @brief register a simple cloud command without any value additions + * usually called during setup + */ + template + // defined here instead of in cpp for full flexibility + void registerCommand(T* instance, bool (T::*method)(Variant&), const char* module, const char* cmd) { + std::function cb = [instance, method](Variant& v) { + return (instance->*method)(v); + }; + const Vector empty = {}; + registerCommand(cb, module, cmd, empty, false, empty, false); + } + + /** + * @brief register a cloud command with a list of specific text values allowed (value required by default) + * usually called during setup + */ + template + // defined here instead of in cpp for full flexibility + void registerCommandWithTextValues(T* instance, bool (T::*method)(Variant&), const char* module, const char* cmd, const Vector& text_values, bool value_optional = false) { + std::function cb = [instance, method](Variant& v) { + return (instance->*method)(v); + }; + const Vector empty = {}; + registerCommand(cb, module, cmd, text_values, false, empty, value_optional); + } + + /** + * @brief register a cloud command with numeric values and optionally defined units and value required by default + * usually called during setup + */ + template + // defined here instead of in cpp for full flexibility + void registerCommandWithNumericValues(T* instance, bool (T::*method)(Variant&), const char* module, const char* cmd, const Vector& numeric_units = {}, bool value_optional = false) { + std::function cb = [instance, method](Variant& v) { + return (instance->*method)(v); + }; + const Vector empty = {}; + registerCommand(cb, module, cmd, empty, true, numeric_units, value_optional); + } + + /** + * @brief register a cloud command with mixed test and numeric values, optionally defined units, and value required by default + * usually called during setup + */ + template + // defined here instead of in cpp for full flexibility + void registerCommandWithMixedValues(T* instance, bool (T::*method)(Variant&), const char* module, const char* cmd, const Vector& text_values, const Vector& numeric_units = {}, bool value_optional = false) { + std::function cb = [instance, method](Variant& v) { + return (instance->*method)(v); + }; + registerCommand(cb, module, cmd, text_values, true, numeric_units, value_optional); + } + + /** + * @brief get back a Variant with all the commands + * this information is stored in a Particle.variable() if var_available_commands is set + * it is optimized for minimal JSON (e.g. true/false = 1/0) to accomodate as many commands as possible + */ + Variant getCommands(); + + /** + * @brief internal function that's registered with the Particle cloud to process user commands + * can be called directly for testing purposes + */ + int receiveCall (String call); + +}; \ No newline at end of file diff --git a/LoggerCore/src/LoggerFunctionReturns.cpp b/LoggerCore/src/LoggerFunctionReturns.cpp new file mode 100644 index 0000000..e94b969 --- /dev/null +++ b/LoggerCore/src/LoggerFunctionReturns.cpp @@ -0,0 +1,45 @@ +#include "Particle.h" +#include "LoggerFunctionReturns.h" + +bool LoggerFunctionReturns::hasReturnValue(Variant &call) { + return(call.has("ret")); +} + +int LoggerFunctionReturns::getReturnValue(Variant &call) { + // return copy of return value + return(call.get("ret").toInt()); +} + +void LoggerFunctionReturns::setReturnValue(Variant& call, int code, const char* message, bool overwrite) { + + if (call.has("ret") && call.get("ret").asInt() != LoggerFunctionReturns::CMD_SUCCESS) { + // call already has a return value that is NOT the marker for success + if (overwrite) { + // something other than success is getting overwritten + Log.warn("overwriting existing return value %d with new value %d = %s", call.get("ret").asInt(), code, message); + } else { + // keeping existing value (overwrite is false) + Log.warn("keeping existing return value (%d) and discarding new value %d = %s", call.get("ret").asInt(), code, message); + return; + } + } + call.set("ret", code); + call.set("msg", message); +} + +void LoggerFunctionReturns::setReturnValue(Variant& call, LoggerFunctionReturns::Warning warn) { + setReturnValue(call, warn.code, warn.message, false); +} + +void LoggerFunctionReturns::setReturnValue(Variant& call, LoggerFunctionReturns::Error err) { + setReturnValue(call, err.code, err.message, true); +} + +void LoggerFunctionReturns::setSuccess(Variant& call) { + if (!hasReturnValue(call)) { + call.set("ret", LoggerFunctionReturns::CMD_SUCCESS); + } else if (call.get("ret").asInt() != LoggerFunctionReturns::CMD_SUCCESS) { + // call already has a state set + Log.warn("could not set call to success (%d), already has a different return code (%d)", LoggerFunctionReturns::CMD_SUCCESS, call.get("ret").asInt()); + } +} diff --git a/LoggerCore/src/LoggerFunctionReturns.h b/LoggerCore/src/LoggerFunctionReturns.h new file mode 100644 index 0000000..4dd4dc2 --- /dev/null +++ b/LoggerCore/src/LoggerFunctionReturns.h @@ -0,0 +1,57 @@ +#pragma once + +#include "Particle.h" + +/** + * @brief namespace that defines return codes for LoggerFunctions + * expand this namespace in other classes to register additional return codes + */ +namespace LoggerFunctionReturns { + + struct Error { + const int code; + const char* message; + constexpr Error(int c, const char* msg) : code(c), message(msg) {} + }; + + struct Warning { + const int code; + const char* message; + constexpr Warning(int c, const char* msg) : code(c), message(msg) {} + }; + + const int CMD_SUCCESS = 0; + + /** + * @brief check if the call has a return value set + */ + bool hasReturnValue(Variant &call); + + /** + * @brief get the return value from the call Variant as an integer + */ + int getReturnValue(Variant &call); + + /** + * @brief set the return value from code and message + * (usually not called directly but via void setReturnValue(Variant& call, Warning warn) andvoid setReturnValue(Variant& call, Error err)) + */ + void setReturnValue(Variant& call, int code, const char* message, bool overwrite); + + /** + * @brief set the return value to a Warning + */ + void setReturnValue(Variant& call, Warning warn); + + /** + * @brief set the return value to an Error + */ + void setReturnValue(Variant& call, Error err); + + /** + * @brief set the return value to success + */ + void setSuccess(Variant& call); + +} + diff --git a/LoggerCore/src/LoggerModule.h b/LoggerCore/src/LoggerModule.h new file mode 100644 index 0000000..06c5dfe --- /dev/null +++ b/LoggerCore/src/LoggerModule.h @@ -0,0 +1,40 @@ +#pragma once +#include "Particle.h" +#include "LoggerFunction.h" +#include "LoggerFunctionReturns.h" + +// module class +class LoggerModule { + + protected: + + // name of the module + const char* m_name; + + public: + + LoggerModule(const char* name) : m_name(name) {} + + const char* getName() { + return(m_name); + } + + void registerCommands(LoggerFunction* func) { + + }; + + /** + * @brief call this while parsing a command to indicate a warning to the user that does not fail the command + */ + void setReturnValue(Variant& call, LoggerFunctionReturns::Warning warn) { + LoggerFunctionReturns::setReturnValue(call, warn); + } + + /** + * @brief call this while parsing a command to indicate an error to the user that fails the command + */ + void setReturnValue(Variant& call, LoggerFunctionReturns::Error err) { + LoggerFunctionReturns::setReturnValue(call, err); + } + +}; diff --git a/README.md b/README.md index ba65e49..6e19ffb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ -![blink](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-blink.yaml/badge.svg?branch=main) -![publish](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-publish.yaml/badge.svg?branch=main) +[![blink](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-blink.yaml/badge.svg?branch=main)](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-blink.yaml) +[![i2c scanner](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-i2c_scanner.yaml/badge.svg?branch=main)](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-i2c_scanner.yaml) +[![publish](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-publish.yaml/badge.svg?branch=main)](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-publish.yaml) +[![function](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-function.yaml/badge.svg?branch=main)](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-function.yaml) # LabLoggerLibs @@ -11,7 +13,7 @@ A BibTeX entry for LaTeX users is ``` @Manual{ - microLogger, + LabLogger, title = {LabLogger: modular data logging for research labs}, author = {Sebastian Kopf}, year = {2025}, @@ -25,9 +27,10 @@ The following firmware is included in the repository to provide frequently used | Program | *main* branch | *dev* branch | | :------- | :--- | :--- | -| blink | ![blink](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-blink.yaml/badge.svg?branch=main) | ![blink-dev](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-blink.yaml/badge.svg?branch=dev) | -| i2c_scanner | ![blink](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-i2c_scanner.yaml/badge.svg?branch=main) | ![blink-dev](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-i2c_scanner.yaml/badge.svg?branch=dev) | -| publish | ![publish](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-publish.yaml/badge.svg?branch=main) | ![publish-dev](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-publish.yaml/badge.svg?branch=dev) | +| blink | [![blink](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-blink.yaml/badge.svg?branch=main)](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-blink.yaml) | [![blink](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-blink.yaml/badge.svg?branch=dev)](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-blink.yaml) | +| i2c_scanner | [![i2c scanner](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-i2c_scanner.yaml/badge.svg?branch=main)](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-i2c_scanner.yaml) | [![i2c scanner](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-i2c_scanner.yaml/badge.svg?branch=dev)](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-i2c_scanner.yaml) | +| publish | [![publish](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-publish.yaml/badge.svg?branch=main)](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-publish.yaml) | [![publish](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-publish.yaml/badge.svg?branch=dev)](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-publish.yaml) | +| function | [![function](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-function.yaml/badge.svg?branch=main)](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-function.yaml) | [![function](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-function.yaml/badge.svg?branch=dev)](https://github.com/KopfLab/LabLoggerLibs/actions/workflows/compile-function.yaml) | ### Compile @@ -80,7 +83,7 @@ The `LoggerCore` library provides the following functionality. ## Dependencies -The following third-party software is used in the ***LabLogger*** libraries: +The following third-party software is used in the ***LabLogger*** libraries. See the linked GitHub repositories for the respective licensing text and license files. | **Library** | **Dependency** | **Website** | **License** | |-------------|----------------------------------------|--------------------------------------------------------------------|-------------| @@ -90,8 +93,8 @@ The following third-party software is used in the ***LabLogger*** libraries: | LoggerCore | SequentialFileRK | https://github.com/rickkas7/SequentialFileRK | MIT | | LoggerCore | PublishQueueExtRK | https://github.com/rickkas7/PublishQueueExtRK | MIT | | LoggerCore | SparkFun_Qwiic_OpenLog_Arduino_Library | https://github.com/sparkfun/SparkFun_Qwiic_OpenLog_Arduino_Library | MIT | -| LoggerOled | Adafruit_SSD1306 | https://github.com/adafruit/Adafruit_SSD1306 | BSD | -| LoggerOled | Adafruit-GFX-Library | https://github.com/adafruit/Adafruit-GFX-Library | BSD | -| LoggerOled | Adafruit_BusIO | https://github.com/adafruit/Adafruit_BusIO | MIT | +| LoggerOled | Adafruit_SSD1306_RK | https://github.com/rickkas7/Adafruit_SSD1306_RK | BSD | +| LoggerOled | Adafruit_GFX_RK | https://github.com/rickkas7/Adafruit_GFX_RK | BSD | +| LoggerOled | Adafruit_BusIO | https://github.com/rickkas7/Adafruit_BusIO_RK | MIT | diff --git a/Rakefile b/Rakefile index 39ed652..9758cae 100644 --- a/Rakefile +++ b/Rakefile @@ -17,6 +17,7 @@ task :blink => :compile task :publish => :compile task :i2c_scanner => :compile task :oled => :compile +task :function => :compile ### SETUP ### diff --git a/lib/Adafruit_BusIO_RK b/lib/Adafruit_BusIO_RK new file mode 160000 index 0000000..1b7c946 --- /dev/null +++ b/lib/Adafruit_BusIO_RK @@ -0,0 +1 @@ +Subproject commit 1b7c946a6107a247b26707d80bc2e2d9e26d4340 diff --git a/lib/Adafruit_GFX_RK b/lib/Adafruit_GFX_RK new file mode 160000 index 0000000..3e32a67 --- /dev/null +++ b/lib/Adafruit_GFX_RK @@ -0,0 +1 @@ +Subproject commit 3e32a67cf17889144e5227ef064f787e0506a794 diff --git a/lib/Adafruit_SSD1306_RK b/lib/Adafruit_SSD1306_RK new file mode 160000 index 0000000..b5ebf2c --- /dev/null +++ b/lib/Adafruit_SSD1306_RK @@ -0,0 +1 @@ +Subproject commit b5ebf2cb1a1a229e3814bfdc9af21fcfcc9942d9 diff --git a/src/function/function_test.cpp b/src/function/function_test.cpp new file mode 100644 index 0000000..65f2a8e --- /dev/null +++ b/src/function/function_test.cpp @@ -0,0 +1,159 @@ +/** + * test for LoggerFunction class + * to use: + * - flash to device of joice + * - either call any commands directly with particle call DEVICE test "test4" + * - or start the set of auto-tests by calling particle call DEVICE test "auto_test" + */ + +#include "Particle.h" +#include "LoggerFunction.h" +#include "LoggerFunctionReturns.h" +#include "LoggerModule.h" + +// enable system treading +#ifndef SYSTEM_VERSION_v620 +SYSTEM_THREAD(ENABLED); +#endif + +// log handler +SerialLogHandler logHandler(LOG_LEVEL_INFO, { // Logging level for non-application messages + { "app", LOG_LEVEL_TRACE } // Logging level for application messages (i.e. debug mode) +}); + +// custom return value examples +namespace LoggerFunctionReturns { + inline constexpr Error MY_WARNING = {100, "my favorite warning"}; + inline constexpr Error MY_ERROR = {-100, "my favorite error"}; +} + +// custom component class examples +class MyModule : public LoggerModule { + + public: + + bool auto_test_running = false; + + MyModule(const char* name) : LoggerModule(name) {} + + // 'autotest' + bool auto_test(Variant& call) { + Log.info("starting auto test suite of commands"); + auto_test_running = true; + return(true); + } + + // 'hello' + void registerHelloCommand(LoggerFunction* func, const char* cmd = "hello") { + func->registerCommand(this, &MyModule::hello, getName(), cmd); + } + + bool hello(Variant& call) { + Log.info("'hello' triggered %s", call.toJSON().c_str()); + setReturnValue(call, LoggerFunctionReturns::MY_WARNING); + return(true); + } + + // 'whatup' + void registerWhatupCommand(LoggerFunction* func, const char* cmd = "whatup") { + func->registerCommand(this, &MyModule::whatup, getName(), cmd); + } + + bool whatup(Variant& call) { + Log.info("'whatup' triggered: %s", call.toJSON().c_str()); + setReturnValue(call, LoggerFunctionReturns::MY_ERROR); + return(false); + } + + // 'test' + bool test(Variant& call) { + Log.info("'test' with: %s", call.toJSON().c_str()); + return(true); + } + +}; + +// example classes +LoggerFunction* func = new LoggerFunction( + "test", // name of the Particle.function + {"user", "note"}, // parameters (param=) interpreted by the call + true, // whether to log each command using the LoggerPublisher + "commands", // name of the Particle.variable where all available commands are stored (as JSON) + "last_calls" // name of the Particle.variable where the last received function calls are stored (as JSON) +); + + +MyModule* mod = new MyModule("mod1"); +String available_cmds; +String last_cmd; + +// setup +void setup() { + + // register a suite of test commands + // start auto-test + func->registerCommand(mod, &MyModule::auto_test, mod->getName(), "auto-test"); + + // register all commands defined in the module class (usually all of them defined there) + mod->registerHelloCommand(func); + mod->registerWhatupCommand(func); + mod->registerWhatupCommand(func, "WHATUP"); + + // simple command + func->registerCommand(mod, &MyModule::test, mod->getName(), "test1"); + + // command that accepts on/off values + func->registerCommandWithTextValues(mod, &MyModule::test, mod->getName(), "test2", {LoggerFunction::on, LoggerFunction::off}); + + // command that accepts a/b/2 values but providing a value is optional (last param) + func->registerCommandWithTextValues(mod, &MyModule::test, mod->getName(), "test3", {"a", "b", "2"}, true); + + // command that accepts numeric values (no units) + func->registerCommandWithNumericValues(mod, &MyModule::test, mod->getName(), "test4"); + + // command that accepts numeric values with specific units, providing the value is optional (last param) + func->registerCommandWithNumericValues(mod, &MyModule::test, mod->getName(), "test5", {"sec", "min"}, true); + + // command that accepts mixed values with a few specific text values OR numeric values with specific units + func->registerCommandWithMixedValues(mod, &MyModule::test, mod->getName(), "test6", {"manual"}, {"ms", "sec"}); + + // start listening to function calls + func->setup(); +} + +// testing commands +const Vector calls { + "non-existent-cmd", + "whatup", "WHATUP", + "mod-dne hello", "mod1 hello note=whatever is up with=that user=test user", + "test1", + "test2", "test2 on", "test2 blib", "test2 off extra user=test", + "test3", "test3 2", "test3 2 kg", "test3 b note=hello #3", + "test4", "test4 x", "test4 1kg", "test4 -2.352", + "test5", "test5 y", "test5 4.2", "test5 -42what", "test5 1.3e3 myunit", "test5 -1sec user=test", "test5 24.1 min note=hello", + "test6", "test6 manual", "test6 dne", "test6 42", "test6 -4.2ms" +}; + +unsigned long last_call = 0; +size_t call_i = 0; +const std::chrono::milliseconds wait = 2s; + +// loop +void loop() { + + // mimic commands via the test calls + if (mod->auto_test_running && millis() - last_call > wait.count()) { + if (call_i >= calls.size()) call_i = 0; + Log.print("\n"); + uint32_t mem_before = System.freeMemory(); + Log.info("CALL #%d (free mem: %.3f KB): '%s'", call_i, (float) System.freeMemory() / 1024., calls[call_i].c_str()); + func->receiveCall(String(calls[call_i])); + uint32_t mem_after = System.freeMemory(); + Log.info("FREE MEM loss: %d B", mem_before - mem_after); + Log.print("\n"); + call_i++; + last_call = millis(); + } + +} + diff --git a/src/function/project.properties b/src/function/project.properties new file mode 100644 index 0000000..3dcc813 --- /dev/null +++ b/src/function/project.properties @@ -0,0 +1 @@ +name=function \ No newline at end of file diff --git a/src/oled/oled_test.cpp b/src/oled/oled_test.cpp new file mode 100644 index 0000000..54be08a --- /dev/null +++ b/src/oled/oled_test.cpp @@ -0,0 +1,41 @@ +#include "Particle.h" +#include "Wire.h" +#include "Adafruit_GFX.h" +#include "Adafruit_SSD1306.h" + +// enable system treading +#ifndef SYSTEM_VERSION_v620 +SYSTEM_THREAD(ENABLED); +#endif + +// log handler +SerialLogHandler logHandler(LOG_LEVEL_INFO); + +// Use I2C with OLED RESET pin +#define OLED_RESET D10 // D10 on photon2, D8 on argon/boron +Adafruit_SSD1306 display(OLED_RESET); + +void setup() { + display.begin(SSD1306_SWITCHCAPVCC, 0x3C); // initialize with the I2C addr 0x3C (for the 128x64) + display.display(); // show splashscreen + display.setTextSize(1); + display.setTextColor(WHITE); +} + +unsigned long last_run = 0; +int counter = 0; +const std::chrono::milliseconds wait = 5s; + +void loop() { + if (millis() - last_run > wait.count()) { + last_run = millis(); + Log.info("hello %d", counter); + + display.setCursor(0, 0); + display.clearDisplay(); + display.printf("hello %d...", counter); + display.display(); + + counter++; + } +} \ No newline at end of file diff --git a/src/oled/project.properties b/src/oled/project.properties new file mode 100644 index 0000000..0b9128a --- /dev/null +++ b/src/oled/project.properties @@ -0,0 +1,11 @@ +name=oled + +## DEPENDENCIES ## +# dependencies added in lib/ as submodules to include full codebase in repo +# these will be included in `rake PROGRA` compile as long as they are listed +# in .github/workflows/compile-PROGRAM.yaml under program -> lib +# if a dependency is not available locally in lib/, comment it in here + +# dependencies.Adafruit_SSD1306_RK=1.3.3 +# dependencies.Adafruit_GFX_RK=1.11.10 +# dependencies.Adafruit_BusIO_RK=1.16.1 \ No newline at end of file