From 429b28ef67c60be39263f85c7be7f5e83c249f4c Mon Sep 17 00:00:00 2001 From: Thomas Gohle Date: Sun, 12 Apr 2026 18:21:05 +0200 Subject: [PATCH] full version of beta including resources and binary --- .gitignore | 2 - bin/mw_controller_example | Bin 0 -> 36952 bytes bin/mwcli | Bin 0 -> 72608 bytes examples/battery_discharge_cutoff.json | 25 + examples/cc_step_sweep.json | 28 + examples/cp_step_sweep.json | 21 + examples/cr_step_sweep.json | 21 + examples/load_test_cc.json | 21 + examples/mw_controller_example.c | 103 ++ examples/quick_cc_test.json | 24 + examples/repeat_n_pulse_test.json | 30 + examples/repeat_until_cutoff.json | 29 + examples/repeat_while_burn_in.json | 29 + include/mightywatt.h | 76 ++ include/mightywatt_app.h | 45 + include/mightywatt_controller.h | 113 ++ include/mightywatt_log.h | 30 + include/mightywatt_sequence.h | 46 + src/mightywatt.c | 549 +++++++++ src/mightywatt_app.c | 252 ++++ src/mightywatt_controller.c | 723 ++++++++++++ src/mightywatt_log.c | 122 ++ src/mightywatt_sequence.c | 1459 ++++++++++++++++++++++++ src/mwcli.c | 745 ++++++++++++ 24 files changed, 4491 insertions(+), 2 deletions(-) create mode 100644 bin/mw_controller_example create mode 100644 bin/mwcli create mode 100644 examples/battery_discharge_cutoff.json create mode 100644 examples/cc_step_sweep.json create mode 100644 examples/cp_step_sweep.json create mode 100644 examples/cr_step_sweep.json create mode 100644 examples/load_test_cc.json create mode 100644 examples/mw_controller_example.c create mode 100644 examples/quick_cc_test.json create mode 100644 examples/repeat_n_pulse_test.json create mode 100644 examples/repeat_until_cutoff.json create mode 100644 examples/repeat_while_burn_in.json create mode 100644 include/mightywatt.h create mode 100644 include/mightywatt_app.h create mode 100644 include/mightywatt_controller.h create mode 100644 include/mightywatt_log.h create mode 100644 include/mightywatt_sequence.h create mode 100644 src/mightywatt.c create mode 100644 src/mightywatt_app.c create mode 100644 src/mightywatt_controller.c create mode 100644 src/mightywatt_log.c create mode 100644 src/mightywatt_sequence.c create mode 100644 src/mwcli.c diff --git a/.gitignore b/.gitignore index 6a7344a..0afc933 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,5 @@ # Build outputs *.o -mwcli -mw_controller_example # Logs and captures *.csv diff --git a/bin/mw_controller_example b/bin/mw_controller_example new file mode 100644 index 0000000000000000000000000000000000000000..bb65144a6553d027501b27bf9123c418ce83c702 GIT binary patch literal 36952 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 literal 0 HcmV?d00001 diff --git a/examples/battery_discharge_cutoff.json b/examples/battery_discharge_cutoff.json new file mode 100644 index 0000000..ee42579 --- /dev/null +++ b/examples/battery_discharge_cutoff.json @@ -0,0 +1,25 @@ +{ + "name": "battery_discharge_cutoff", + "sample_period_ms": 1000, + "safety": { + "max_voltage": 5.0, + "max_current": 0.5, + "max_power": 2.5, + "abort_on_disconnect": true + }, + "steps": [ + { "action": "set_mode", "mode": "CC" }, + { "action": "set_current", "value": 0.20 }, + { "action": "output", "enabled": true }, + { + "action": "hold_until", + "timeout_s": 14400, + "condition": { "type": "voltage_below", "value": 3.00 }, + "break_if": { "type": "temperature_above", "value": 50.0 } + }, + { "action": "output", "enabled": false } + ], + "abort_sequence": [ + { "action": "safe" } + ] +} diff --git a/examples/cc_step_sweep.json b/examples/cc_step_sweep.json new file mode 100644 index 0000000..ee8b42b --- /dev/null +++ b/examples/cc_step_sweep.json @@ -0,0 +1,28 @@ +{ + "name": "cc_step_sweep", + "sample_period_ms": 500, + "safety": { + "max_voltage": 6.0, + "max_current": 0.8, + "max_power": 4.0, + "abort_on_disconnect": true + }, + "steps": [ + { "action": "set_mode", "mode": "CC" }, + { "action": "set_current", "value": 0.10 }, + { "action": "output", "enabled": true }, + { "action": "hold", "duration_s": 10 }, + { + "action": "ramp_current", + "start": 0.10, + "stop": 0.50, + "step": 0.10, + "dwell_s": 10, + "break_if": { "type": "temperature_above", "value": 45.0 } + }, + { "action": "output", "enabled": false } + ], + "abort_sequence": [ + { "action": "safe" } + ] +} diff --git a/examples/cp_step_sweep.json b/examples/cp_step_sweep.json new file mode 100644 index 0000000..5777f4e --- /dev/null +++ b/examples/cp_step_sweep.json @@ -0,0 +1,21 @@ +{ + "name": "cp_step_sweep", + "sample_period_ms": 500, + "safety": { + "max_voltage": 6.0, + "max_current": 0.8, + "max_power": 3.0, + "abort_on_disconnect": true + }, + "steps": [ + { "action": "set_mode", "mode": "CP" }, + { "action": "set_power", "value": 0.50 }, + { "action": "output", "enabled": true }, + { "action": "hold", "duration_s": 10 }, + { "action": "ramp_power", "start": 0.50, "stop": 2.50, "step": 0.50, "dwell_s": 10 }, + { "action": "output", "enabled": false } + ], + "abort_sequence": [ + { "action": "safe" } + ] +} diff --git a/examples/cr_step_sweep.json b/examples/cr_step_sweep.json new file mode 100644 index 0000000..b8e4289 --- /dev/null +++ b/examples/cr_step_sweep.json @@ -0,0 +1,21 @@ +{ + "name": "cr_step_sweep", + "sample_period_ms": 500, + "safety": { + "max_voltage": 6.0, + "max_current": 0.8, + "max_power": 4.0, + "abort_on_disconnect": true + }, + "steps": [ + { "action": "set_mode", "mode": "CR" }, + { "action": "set_resistance", "value": 50.0 }, + { "action": "output", "enabled": true }, + { "action": "hold", "duration_s": 10 }, + { "action": "ramp_resistance", "start": 50.0, "stop": 10.0, "step": 10.0, "dwell_s": 10 }, + { "action": "output", "enabled": false } + ], + "abort_sequence": [ + { "action": "safe" } + ] +} diff --git a/examples/load_test_cc.json b/examples/load_test_cc.json new file mode 100644 index 0000000..c74d947 --- /dev/null +++ b/examples/load_test_cc.json @@ -0,0 +1,21 @@ +{ + "name": "load_test_cc", + "sample_period_ms": 500, + "safety": { + "max_voltage": 30.0, + "max_current": 2.0, + "max_power": 20.0, + "abort_on_disconnect": true + }, + "steps": [ + { "action": "set_mode", "mode": "CC" }, + { "action": "set_current", "value": 0.20 }, + { "action": "output", "enabled": true }, + { "action": "hold", "duration_s": 30 }, + { "action": "ramp_current", "start": 0.20, "stop": 1.00, "step": 0.10, "dwell_s": 20 }, + { "action": "hold_until", "timeout_s": 300, "condition": { "type": "voltage_below", "value": 3.00 } } + ], + "abort_sequence": [ + { "action": "safe" } + ] +} diff --git a/examples/mw_controller_example.c b/examples/mw_controller_example.c new file mode 100644 index 0000000..52a3cef --- /dev/null +++ b/examples/mw_controller_example.c @@ -0,0 +1,103 @@ +#define _POSIX_C_SOURCE 200809L + +#include "mightywatt_controller.h" + +#include +#include +#include +#include +#include + +static void sleep_ms(int ms) { + struct timespec ts; + if (ms < 0) { + ms = 0; + } + ts.tv_sec = ms / 1000; + ts.tv_nsec = (long)(ms % 1000) * 1000000L; + nanosleep(&ts, NULL); +} + +int main(int argc, char **argv) { + mw_controller *controller = NULL; + mw_controller_config config; + mw_controller_snapshot snapshot; + uint64_t cmd_id = 0; + int i; + + if (argc < 2) { + fprintf(stderr, "usage: %s /dev/ttyACM0\n", argv[0]); + return 2; + } + + mw_controller_config_init(&config); + config.port_path = argv[1]; + config.poll_interval_ms = 500; + config.reconnect_ms = 1500; + + if (mw_controller_start(&controller, &config) != 0) { + fprintf(stderr, "controller start failed\n"); + return 1; + } + + for (i = 0; i < 20; ++i) { + if (mw_controller_get_snapshot(controller, &snapshot) != 0) { + fprintf(stderr, "snapshot failed\n"); + mw_controller_stop(controller); + return 1; + } + printf("[%02d] state=%s connected=%s pending=%zu error=%s\n", + i, + mw_controller_connection_state_name(snapshot.connection_state), + snapshot.connected ? "yes" : "no", + snapshot.pending_commands, + snapshot.last_error[0] ? snapshot.last_error : "-"); + if (snapshot.connected) { + break; + } + sleep_ms(250); + } + + if (!snapshot.connected) { + fprintf(stderr, "device did not connect in time\n"); + mw_controller_stop(controller); + return 1; + } + + if (mw_controller_queue_load_on(controller, MW_MODE_CURRENT, 250, &cmd_id) != 0) { + fprintf(stderr, "queue load-on failed\n"); + mw_controller_stop(controller); + return 1; + } + printf("queued command id=%" PRIu64 "\n", cmd_id); + + for (;;) { + if (mw_controller_wait_for_update(controller, snapshot.snapshot_seq, 2000, NULL) < 0) { + fprintf(stderr, "wait for update failed\n"); + break; + } + if (mw_controller_get_snapshot(controller, &snapshot) != 0) { + fprintf(stderr, "snapshot failed\n"); + break; + } + if (snapshot.last_completed_command_id >= cmd_id) { + printf("command result=%d completed=%" PRIu64 " state=%s\n", + snapshot.last_completed_result, + snapshot.last_completed_command_id, + mw_controller_connection_state_name(snapshot.connection_state)); + if (snapshot.app_state_valid && snapshot.app_state.report_valid) { + printf("I=%.3f A V=%.3f V remote=%s status=%u\n", + snapshot.app_state.last_report.current_ma / 1000.0, + snapshot.app_state.last_report.voltage_mv / 1000.0, + snapshot.app_state.last_report.remote ? "on" : "off", + (unsigned)snapshot.app_state.last_report.status); + } + break; + } + } + + mw_controller_queue_safe(controller, NULL); + sleep_ms(400); + mw_controller_stop(controller); + return 0; +} diff --git a/examples/quick_cc_test.json b/examples/quick_cc_test.json new file mode 100644 index 0000000..6badccf --- /dev/null +++ b/examples/quick_cc_test.json @@ -0,0 +1,24 @@ +{ + "name": "quick_cc_test", + "sample_period_ms": 500, + "safety": { + "max_voltage": 6.0, + "max_current": 0.6, + "max_power": 3.0, + "abort_on_disconnect": true + }, + "steps": [ + { "action": "set_mode", "mode": "CC" }, + { "action": "set_current", "value": 0.10 }, + { "action": "output", "enabled": true }, + { "action": "hold", "duration_s": 5 }, + { "action": "set_current", "value": 0.25 }, + { "action": "hold", "duration_s": 5 }, + { "action": "set_current", "value": 0.50 }, + { "action": "hold", "duration_s": 5 }, + { "action": "output", "enabled": false } + ], + "abort_sequence": [ + { "action": "safe" } + ] +} diff --git a/examples/repeat_n_pulse_test.json b/examples/repeat_n_pulse_test.json new file mode 100644 index 0000000..8d140c9 --- /dev/null +++ b/examples/repeat_n_pulse_test.json @@ -0,0 +1,30 @@ +{ + "name": "repeat_n_pulse_test", + "sample_period_ms": 500, + "safety": { + "max_voltage": 6.0, + "max_current": 0.8, + "max_power": 4.0, + "abort_on_disconnect": true + }, + "steps": [ + { "action": "set_mode", "mode": "CC" }, + { + "action": "repeat", + "times": 5, + "break_if": { "type": "temperature_above", "value": 45.0 }, + "steps": [ + { "action": "set_current", "value": 0.20 }, + { "action": "output", "enabled": true }, + { "action": "hold", "duration_s": 5 }, + { "action": "set_current", "value": 0.50 }, + { "action": "hold", "duration_s": 5 }, + { "action": "output", "enabled": false }, + { "action": "hold", "duration_s": 2 } + ] + } + ], + "abort_sequence": [ + { "action": "safe" } + ] +} diff --git a/examples/repeat_until_cutoff.json b/examples/repeat_until_cutoff.json new file mode 100644 index 0000000..421d011 --- /dev/null +++ b/examples/repeat_until_cutoff.json @@ -0,0 +1,29 @@ +{ + "name": "repeat_until_cutoff", + "sample_period_ms": 1000, + "safety": { + "max_voltage": 5.0, + "max_current": 0.6, + "max_power": 3.0, + "abort_on_disconnect": true + }, + "steps": [ + { "action": "set_mode", "mode": "CC" }, + { + "action": "repeat_until", + "timeout_s": 14400, + "condition": { "type": "voltage_below", "value": 3.20 }, + "break_if": { "type": "temperature_above", "value": 45.0 }, + "steps": [ + { "action": "set_current", "value": 0.30 }, + { "action": "output", "enabled": true }, + { "action": "hold", "duration_s": 60 }, + { "action": "output", "enabled": false }, + { "action": "hold", "duration_s": 10 } + ] + } + ], + "abort_sequence": [ + { "action": "safe" } + ] +} diff --git a/examples/repeat_while_burn_in.json b/examples/repeat_while_burn_in.json new file mode 100644 index 0000000..1ef5fa4 --- /dev/null +++ b/examples/repeat_while_burn_in.json @@ -0,0 +1,29 @@ +{ + "name": "repeat_while_burn_in", + "sample_period_ms": 1000, + "safety": { + "max_voltage": 6.0, + "max_current": 0.5, + "max_power": 3.0, + "abort_on_disconnect": true + }, + "steps": [ + { "action": "set_mode", "mode": "CC" }, + { + "action": "repeat_while", + "timeout_s": 7200, + "condition": { "type": "temperature_above", "value": 20.0 }, + "break_if": { "type": "temperature_above", "value": 50.0 }, + "steps": [ + { "action": "set_current", "value": 0.15 }, + { "action": "output", "enabled": true }, + { "action": "hold", "duration_s": 30 }, + { "action": "output", "enabled": false }, + { "action": "hold", "duration_s": 15 } + ] + } + ], + "abort_sequence": [ + { "action": "safe" } + ] +} diff --git a/include/mightywatt.h b/include/mightywatt.h new file mode 100644 index 0000000..57deb6b --- /dev/null +++ b/include/mightywatt.h @@ -0,0 +1,76 @@ +#ifndef MIGHTYWATT_H +#define MIGHTYWATT_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define MW_FW_VERSION_MAX 31 +#define MW_BOARD_REV_MAX 31 + +typedef struct mw_device mw_device; + +typedef enum { + MW_MODE_CURRENT = 0, + MW_MODE_VOLTAGE = 1, + MW_MODE_POWER = 2, + MW_MODE_RESISTANCE = 3, + MW_MODE_VOLTAGE_INVERTED = 4, +} mw_mode; + +enum { + MW_STATUS_READY = 0, + MW_STATUS_CURRENT_OVERLOAD = 1 << 0, + MW_STATUS_VOLTAGE_OVERLOAD = 1 << 1, + MW_STATUS_POWER_OVERLOAD = 1 << 2, + MW_STATUS_OVERHEAT = 1 << 3, +}; + +typedef struct { + char firmware_version[MW_FW_VERSION_MAX + 1]; + char board_revision[MW_BOARD_REV_MAX + 1]; + uint32_t max_current_dac_ma; + uint32_t max_current_adc_ma; + uint32_t max_voltage_dac_mv; + uint32_t max_voltage_adc_mv; + uint32_t max_power_mw; + uint32_t dvm_input_resistance_ohm; + uint32_t temperature_threshold_c; +} mw_capabilities; + +typedef struct { + uint16_t current_ma; + uint16_t voltage_mv; + uint8_t temperature_c; + bool remote; + uint8_t status; +} mw_report; + +const char *mw_last_error(const mw_device *dev); +int mw_open(mw_device **out_dev, const char *port_path, int settle_ms); +void mw_close(mw_device *dev); + +int mw_identify(mw_device *dev); +int mw_query_capabilities(mw_device *dev, mw_capabilities *caps); +int mw_get_report(mw_device *dev, mw_report *report); +int mw_set(mw_device *dev, mw_mode mode, uint32_t milli_units, mw_report *report); +int mw_set_remote(mw_device *dev, bool enable, mw_report *report); +int mw_set_series_resistance(mw_device *dev, uint16_t milliohm, mw_report *report); +int mw_get_series_resistance(mw_device *dev, uint16_t *milliohm); + +size_t mw_status_string(uint8_t status, char *buffer, size_t buffer_size); +const char *mw_mode_name(mw_mode mode); +uint32_t mw_report_power_mw(const mw_report *report); +uint32_t mw_capability_limit_for_mode(const mw_capabilities *caps, mw_mode mode); +int mw_validate_target(const mw_capabilities *caps, mw_mode mode, uint32_t milli_units, + char *buffer, size_t buffer_size); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/mightywatt_app.h b/include/mightywatt_app.h new file mode 100644 index 0000000..1470692 --- /dev/null +++ b/include/mightywatt_app.h @@ -0,0 +1,45 @@ +#ifndef MIGHTYWATT_APP_H +#define MIGHTYWATT_APP_H + +#include "mightywatt.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct mw_app mw_app; + +typedef struct { + bool capabilities_valid; + mw_capabilities capabilities; + bool report_valid; + mw_report last_report; + bool target_valid; + mw_mode target_mode; + uint32_t target_milli_units; + bool restore_target_valid; + mw_mode restore_target_mode; + uint32_t restore_target_milli_units; +} mw_app_state; + +int mw_app_open(mw_app **out_app, const char *port_path, int settle_ms); +void mw_app_close(mw_app *app); +const char *mw_app_last_error(const mw_app *app); + +int mw_app_refresh_capabilities(mw_app *app, mw_capabilities *out_caps); +int mw_app_get_report(mw_app *app, mw_report *out_report); +int mw_app_set_target(mw_app *app, mw_mode mode, uint32_t milli_units, mw_report *out_report); +int mw_app_load_off(mw_app *app, mw_report *out_report); +int mw_app_load_on(mw_app *app, mw_mode mode, uint32_t milli_units, mw_report *out_report); +int mw_app_restore_target(mw_app *app, mw_report *out_report); +int mw_app_safe(mw_app *app, mw_report *out_report); +int mw_app_set_remote(mw_app *app, bool enable, mw_report *out_report); +int mw_app_get_series_resistance(mw_app *app, uint16_t *milliohm); +int mw_app_set_series_resistance(mw_app *app, uint16_t milliohm, mw_report *out_report); +int mw_app_get_state(const mw_app *app, mw_app_state *out_state); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/mightywatt_controller.h b/include/mightywatt_controller.h new file mode 100644 index 0000000..78e7b6a --- /dev/null +++ b/include/mightywatt_controller.h @@ -0,0 +1,113 @@ +#ifndef MIGHTYWATT_CONTROLLER_H +#define MIGHTYWATT_CONTROLLER_H + +#include "mightywatt_app.h" + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define MW_CONTROLLER_PORT_PATH_MAX 255 +#define MW_CONTROLLER_ERROR_MAX 255 + +typedef struct mw_controller mw_controller; + +typedef enum { + MW_CONTROLLER_STOPPED = 0, + MW_CONTROLLER_CONNECTING = 1, + MW_CONTROLLER_CONNECTED = 2, + MW_CONTROLLER_RECONNECT_WAIT = 3, +} mw_controller_connection_state; + +typedef enum { + MW_CTRL_CMD_NONE = 0, + MW_CTRL_CMD_SET_TARGET, + MW_CTRL_CMD_LOAD_ON, + MW_CTRL_CMD_LOAD_OFF, + MW_CTRL_CMD_RESTORE_TARGET, + MW_CTRL_CMD_SAFE, + MW_CTRL_CMD_SET_REMOTE, + MW_CTRL_CMD_SET_SERIES_RESISTANCE, + MW_CTRL_CMD_REFRESH_CAPABILITIES, + MW_CTRL_CMD_GET_SERIES_RESISTANCE, +} mw_controller_command_kind; + +typedef struct { + const char *port_path; + int settle_ms; + int poll_interval_ms; + int reconnect_ms; + size_t queue_capacity; + bool safe_on_shutdown; +} mw_controller_config; + +typedef struct { + bool running; + bool connected; + mw_controller_connection_state connection_state; + uint64_t snapshot_seq; + uint64_t connect_attempts; + uint64_t reconnect_count; + uint64_t poll_success_count; + uint64_t poll_error_count; + uint64_t command_success_count; + uint64_t command_error_count; + uint64_t last_queued_command_id; + uint64_t last_completed_command_id; + int last_completed_result; + mw_controller_command_kind last_completed_kind; + size_t pending_commands; + char port_path[MW_CONTROLLER_PORT_PATH_MAX + 1]; + char last_error[MW_CONTROLLER_ERROR_MAX + 1]; + char last_completed_error[MW_CONTROLLER_ERROR_MAX + 1]; + bool app_state_valid; + mw_app_state app_state; + bool series_resistance_valid; + uint16_t series_resistance_milliohm; +} mw_controller_snapshot; + +void mw_controller_config_init(mw_controller_config *config); + +int mw_controller_start(mw_controller **out_controller, const mw_controller_config *config); +void mw_controller_stop(mw_controller *controller); + +int mw_controller_get_snapshot(mw_controller *controller, mw_controller_snapshot *out_snapshot); +int mw_controller_wait_for_update(mw_controller *controller, + uint64_t last_seen_snapshot_seq, + int timeout_ms, + uint64_t *out_snapshot_seq); + +int mw_controller_queue_set_target(mw_controller *controller, + mw_mode mode, + uint32_t milli_units, + uint64_t *out_command_id); +int mw_controller_queue_load_on(mw_controller *controller, + mw_mode mode, + uint32_t milli_units, + uint64_t *out_command_id); +int mw_controller_queue_load_off(mw_controller *controller, uint64_t *out_command_id); +int mw_controller_queue_restore_target(mw_controller *controller, uint64_t *out_command_id); +int mw_controller_queue_safe(mw_controller *controller, uint64_t *out_command_id); +int mw_controller_queue_set_remote(mw_controller *controller, + bool enable, + uint64_t *out_command_id); +int mw_controller_queue_set_series_resistance(mw_controller *controller, + uint16_t milliohm, + uint64_t *out_command_id); +int mw_controller_queue_refresh_capabilities(mw_controller *controller, + uint64_t *out_command_id); +int mw_controller_queue_get_series_resistance(mw_controller *controller, + uint64_t *out_command_id); + +const char *mw_controller_connection_state_name(mw_controller_connection_state state); +const char *mw_controller_command_kind_name(mw_controller_command_kind kind); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/mightywatt_log.h b/include/mightywatt_log.h new file mode 100644 index 0000000..dedd5aa --- /dev/null +++ b/include/mightywatt_log.h @@ -0,0 +1,30 @@ +#ifndef MIGHTYWATT_LOG_H +#define MIGHTYWATT_LOG_H + +#include "mightywatt.h" + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct mw_csv_logger mw_csv_logger; + +typedef enum { + MW_CSV_UNITS_ENGINEERING = 0, + MW_CSV_UNITS_RAW = 1 +} mw_csv_units_mode; + +int mw_csv_logger_open(mw_csv_logger **out_logger, const char *path, mw_csv_units_mode units_mode); +void mw_csv_logger_close(mw_csv_logger *logger); +int mw_csv_logger_write(mw_csv_logger *logger, + const char *context, + long step_index, + const mw_report *report); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/mightywatt_sequence.h b/include/mightywatt_sequence.h new file mode 100644 index 0000000..098c6ec --- /dev/null +++ b/include/mightywatt_sequence.h @@ -0,0 +1,46 @@ +#ifndef MIGHTYWATT_SEQUENCE_H +#define MIGHTYWATT_SEQUENCE_H + +#include "mightywatt_app.h" +#include "mightywatt_log.h" + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + const char *csv_path; + int sample_period_ms_override; + bool safe_on_abort; + mw_csv_units_mode csv_units_mode; +} mw_sequence_run_options; + +typedef struct { + char name[64]; + int sample_period_ms; + size_t steps_total; + size_t steps_completed; + size_t samples_written; + bool aborted; + bool abort_sequence_ran; + size_t abort_steps_completed; + bool last_report_valid; + mw_report last_report; +} mw_sequence_result; + +int mw_sequence_run_file(mw_app *app, + const char *json_path, + const mw_sequence_run_options *options, + mw_sequence_result *out_result, + char *error_text, + size_t error_text_size); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/mightywatt.c b/src/mightywatt.c new file mode 100644 index 0000000..c6685b1 --- /dev/null +++ b/src/mightywatt.c @@ -0,0 +1,549 @@ +#define _DEFAULT_SOURCE +#define _POSIX_C_SOURCE 200809L +#include "mightywatt.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct mw_device { + int fd; + char port_path[256]; + char last_error[256]; +}; + +enum { + MW_CMD_SERIES_RESISTANCE = 28, + MW_CMD_REMOTE = 29, + MW_CMD_QDC = 30, + MW_CMD_IDN = 31, + MW_REPORT_LEN = 7, +}; + +static void mw_set_error(mw_device *dev, const char *fmt, ...) { + va_list ap; + if (!dev) { + return; + } + va_start(ap, fmt); + vsnprintf(dev->last_error, sizeof(dev->last_error), fmt, ap); + va_end(ap); +} + +const char *mw_last_error(const mw_device *dev) { + return dev ? dev->last_error : "invalid device"; +} + +static int mw_sleep_ms(int ms) { + struct timespec ts; + ts.tv_sec = ms / 1000; + ts.tv_nsec = (long)(ms % 1000) * 1000000L; + while (nanosleep(&ts, &ts) == -1) { + if (errno != EINTR) { + return -1; + } + } + return 0; +} + +static int mw_write_all(mw_device *dev, const uint8_t *data, size_t len) { + size_t sent = 0; + while (sent < len) { + ssize_t rc = write(dev->fd, data + sent, len - sent); + if (rc < 0) { + if (errno == EINTR) { + continue; + } + mw_set_error(dev, "write failed: %s", strerror(errno)); + return -1; + } + sent += (size_t)rc; + } + return 0; +} + +static int mw_read_exact(mw_device *dev, uint8_t *data, size_t len) { + size_t got = 0; + while (got < len) { + ssize_t rc = read(dev->fd, data + got, len - got); + if (rc < 0) { + if (errno == EINTR) { + continue; + } + mw_set_error(dev, "read failed: %s", strerror(errno)); + return -1; + } + if (rc == 0) { + mw_set_error(dev, "read timed out"); + return -1; + } + got += (size_t)rc; + } + return 0; +} + +static int mw_read_line(mw_device *dev, char *buffer, size_t buffer_size) { + size_t pos = 0; + if (!buffer || buffer_size == 0) { + mw_set_error(dev, "internal buffer size error"); + return -1; + } + while (pos + 1 < buffer_size) { + unsigned char c; + ssize_t rc = read(dev->fd, &c, 1); + if (rc < 0) { + if (errno == EINTR) { + continue; + } + mw_set_error(dev, "read line failed: %s", strerror(errno)); + return -1; + } + if (rc == 0) { + mw_set_error(dev, "read line timed out"); + return -1; + } + if (c == '\n') { + break; + } + if (c == '\r') { + continue; + } + buffer[pos++] = (char)c; + } + buffer[pos] = '\0'; + if (pos + 1 == buffer_size) { + mw_set_error(dev, "response line too long"); + return -1; + } + return 0; +} + +static int mw_flush_io(mw_device *dev) { + if (tcflush(dev->fd, TCIOFLUSH) != 0) { + mw_set_error(dev, "tcflush failed: %s", strerror(errno)); + return -1; + } + return 0; +} + +static int mw_configure_port(mw_device *dev) { + struct termios tio; + if (tcgetattr(dev->fd, &tio) != 0) { + mw_set_error(dev, "tcgetattr failed: %s", strerror(errno)); + return -1; + } + + cfmakeraw(&tio); + if (cfsetispeed(&tio, B115200) != 0 || cfsetospeed(&tio, B115200) != 0) { + mw_set_error(dev, "failed to set serial speed: %s", strerror(errno)); + return -1; + } + + tio.c_cflag |= (CLOCAL | CREAD); + tio.c_cflag &= ~PARENB; + tio.c_cflag &= ~CSTOPB; + tio.c_cflag &= ~CSIZE; + tio.c_cflag |= CS8; +#ifdef CRTSCTS + tio.c_cflag &= ~CRTSCTS; +#endif +#ifdef HUPCL + tio.c_cflag &= ~HUPCL; +#endif + tio.c_iflag &= ~(IXON | IXOFF | IXANY); + tio.c_cc[VMIN] = 0; + tio.c_cc[VTIME] = 3; + + if (tcsetattr(dev->fd, TCSANOW, &tio) != 0) { + mw_set_error(dev, "tcsetattr failed: %s", strerror(errno)); + return -1; + } + return 0; +} + +int mw_open(mw_device **out_dev, const char *port_path, int settle_ms) { + mw_device *dev; + int fd; + + if (!out_dev || !port_path) { + return -1; + } + + *out_dev = NULL; + dev = calloc(1, sizeof(*dev)); + if (!dev) { + return -1; + } + + fd = open(port_path, O_RDWR | O_NOCTTY | O_CLOEXEC); + if (fd < 0) { + snprintf(dev->last_error, sizeof(dev->last_error), "open failed: %s", strerror(errno)); + free(dev); + return -1; + } + + dev->fd = fd; + snprintf(dev->port_path, sizeof(dev->port_path), "%s", port_path); + + if (mw_configure_port(dev) != 0) { + close(fd); + free(dev); + return -1; + } + + if (settle_ms < 0) { + settle_ms = 0; + } + if (settle_ms > 0 && mw_sleep_ms(settle_ms) != 0) { + mw_set_error(dev, "sleep interrupted unexpectedly"); + close(fd); + free(dev); + return -1; + } + + if (mw_flush_io(dev) != 0) { + close(fd); + free(dev); + return -1; + } + + *out_dev = dev; + return 0; +} + +void mw_close(mw_device *dev) { + if (!dev) { + return; + } + if (dev->fd >= 0) { + close(dev->fd); + } + free(dev); +} + +int mw_identify(mw_device *dev) { + char line[128]; + uint8_t cmd = MW_CMD_IDN; + int tries; + + if (!dev) { + return -1; + } + + for (tries = 0; tries < 3; ++tries) { + if (mw_flush_io(dev) != 0) { + return -1; + } + if (mw_write_all(dev, &cmd, 1) != 0) { + return -1; + } + if (mw_read_line(dev, line, sizeof(line)) == 0 && strcmp(line, "MightyWatt") == 0) { + return 0; + } + mw_sleep_ms(150); + } + + mw_set_error(dev, "device did not identify as MightyWatt"); + return -1; +} + +static int mw_parse_u32(mw_device *dev, const char *line, uint32_t *out) { + char *end = NULL; + unsigned long value; + errno = 0; + value = strtoul(line, &end, 10); + if (errno != 0 || end == line || *end != '\0') { + mw_set_error(dev, "invalid numeric response: '%s'", line); + return -1; + } + *out = (uint32_t)value; + return 0; +} + +int mw_query_capabilities(mw_device *dev, mw_capabilities *caps) { + uint8_t cmd = MW_CMD_QDC; + char line[128]; + uint32_t values[7]; + int i; + + if (!dev || !caps) { + return -1; + } + + memset(caps, 0, sizeof(*caps)); + if (mw_flush_io(dev) != 0) { + return -1; + } + if (mw_write_all(dev, &cmd, 1) != 0) { + return -1; + } + + if (mw_read_line(dev, caps->firmware_version, sizeof(caps->firmware_version)) != 0) { + return -1; + } + if (mw_read_line(dev, caps->board_revision, sizeof(caps->board_revision)) != 0) { + return -1; + } + for (i = 0; i < 7; ++i) { + if (mw_read_line(dev, line, sizeof(line)) != 0) { + return -1; + } + if (mw_parse_u32(dev, line, &values[i]) != 0) { + return -1; + } + } + + caps->max_current_dac_ma = values[0]; + caps->max_current_adc_ma = values[1]; + caps->max_voltage_dac_mv = values[2]; + caps->max_voltage_adc_mv = values[3]; + caps->max_power_mw = values[4]; + caps->dvm_input_resistance_ohm = values[5]; + caps->temperature_threshold_c = values[6]; + return 0; +} + +static void mw_decode_report(const uint8_t raw[MW_REPORT_LEN], mw_report *report) { + report->current_ma = (uint16_t)(((uint16_t)raw[0] << 8) | raw[1]); + report->voltage_mv = (uint16_t)(((uint16_t)raw[2] << 8) | raw[3]); + report->temperature_c = raw[4]; + report->remote = raw[5] ? true : false; + report->status = raw[6]; +} + +int mw_get_report(mw_device *dev, mw_report *report) { + uint8_t cmd = 0; + uint8_t raw[MW_REPORT_LEN]; + + if (!dev || !report) { + return -1; + } + if (mw_write_all(dev, &cmd, 1) != 0) { + return -1; + } + if (mw_read_exact(dev, raw, sizeof(raw)) != 0) { + return -1; + } + mw_decode_report(raw, report); + return 0; +} + +int mw_set(mw_device *dev, mw_mode mode, uint32_t milli_units, mw_report *report) { + uint8_t raw[4]; + size_t len; + + if (!dev || !report) { + return -1; + } + + switch (mode) { + case MW_MODE_CURRENT: + case MW_MODE_VOLTAGE: + case MW_MODE_VOLTAGE_INVERTED: + if (milli_units > 0xFFFFu) { + mw_set_error(dev, "%s target out of 16-bit range", mw_mode_name(mode)); + return -1; + } + len = 3; + raw[0] = (uint8_t)(0x80u | (2u << 5) | (uint8_t)mode); + raw[1] = (uint8_t)((milli_units >> 8) & 0xFFu); + raw[2] = (uint8_t)(milli_units & 0xFFu); + break; + case MW_MODE_POWER: + case MW_MODE_RESISTANCE: + if (milli_units > 0xFFFFFFu) { + mw_set_error(dev, "%s target out of 24-bit range", mw_mode_name(mode)); + return -1; + } + len = 4; + raw[0] = (uint8_t)(0x80u | (3u << 5) | (uint8_t)mode); + raw[1] = (uint8_t)((milli_units >> 16) & 0xFFu); + raw[2] = (uint8_t)((milli_units >> 8) & 0xFFu); + raw[3] = (uint8_t)(milli_units & 0xFFu); + break; + default: + mw_set_error(dev, "unsupported mode"); + return -1; + } + + if (mw_write_all(dev, raw, len) != 0) { + return -1; + } + + { + uint8_t reply[MW_REPORT_LEN]; + if (mw_read_exact(dev, reply, sizeof(reply)) != 0) { + return -1; + } + mw_decode_report(reply, report); + } + return 0; +} + +int mw_set_remote(mw_device *dev, bool enable, mw_report *report) { + uint8_t cmd[2]; + uint8_t reply[MW_REPORT_LEN]; + + if (!dev || !report) { + return -1; + } + cmd[0] = (uint8_t)(0x80u | (1u << 5) | MW_CMD_REMOTE); + cmd[1] = enable ? 1u : 0u; + if (mw_write_all(dev, cmd, sizeof(cmd)) != 0) { + return -1; + } + if (mw_read_exact(dev, reply, sizeof(reply)) != 0) { + return -1; + } + mw_decode_report(reply, report); + return 0; +} + +int mw_set_series_resistance(mw_device *dev, uint16_t milliohm, mw_report *report) { + uint8_t cmd[3]; + uint8_t reply[MW_REPORT_LEN]; + + if (!dev || !report) { + return -1; + } + cmd[0] = (uint8_t)(0x80u | (2u << 5) | MW_CMD_SERIES_RESISTANCE); + cmd[1] = (uint8_t)((milliohm >> 8) & 0xFFu); + cmd[2] = (uint8_t)(milliohm & 0xFFu); + if (mw_write_all(dev, cmd, sizeof(cmd)) != 0) { + return -1; + } + if (mw_read_exact(dev, reply, sizeof(reply)) != 0) { + return -1; + } + mw_decode_report(reply, report); + return 0; +} + +int mw_get_series_resistance(mw_device *dev, uint16_t *milliohm) { + uint8_t cmd = MW_CMD_SERIES_RESISTANCE; + uint32_t value; + char line[64]; + + if (!dev || !milliohm) { + return -1; + } + if (mw_flush_io(dev) != 0) { + return -1; + } + if (mw_write_all(dev, &cmd, 1) != 0) { + return -1; + } + if (mw_read_line(dev, line, sizeof(line)) != 0) { + return -1; + } + if (mw_parse_u32(dev, line, &value) != 0) { + return -1; + } + if (value > UINT16_MAX) { + mw_set_error(dev, "series resistance out of range: %u", value); + return -1; + } + *milliohm = (uint16_t)value; + return 0; +} + +size_t mw_status_string(uint8_t status, char *buffer, size_t buffer_size) { + int n = 0; + int first = 1; + if (!buffer || buffer_size == 0) { + return 0; + } + buffer[0] = '\0'; + if (status == 0) { + snprintf(buffer, buffer_size, "READY"); + return strlen(buffer); + } +#define MW_APPEND(flag, text) \ + do { \ + if (status & (flag)) { \ + n += snprintf(buffer + n, buffer_size > (size_t)n ? buffer_size - (size_t)n : 0, \ + "%s%s", first ? "" : "|", (text)); \ + first = 0; \ + } \ + } while (0) + MW_APPEND(MW_STATUS_CURRENT_OVERLOAD, "CURRENT_OVERLOAD"); + MW_APPEND(MW_STATUS_VOLTAGE_OVERLOAD, "VOLTAGE_OVERLOAD"); + MW_APPEND(MW_STATUS_POWER_OVERLOAD, "POWER_OVERLOAD"); + MW_APPEND(MW_STATUS_OVERHEAT, "OVERHEAT"); +#undef MW_APPEND + return strlen(buffer); +} + +const char *mw_mode_name(mw_mode mode) { + switch (mode) { + case MW_MODE_CURRENT: return "current"; + case MW_MODE_VOLTAGE: return "voltage"; + case MW_MODE_POWER: return "power"; + case MW_MODE_RESISTANCE: return "resistance"; + case MW_MODE_VOLTAGE_INVERTED: return "voltage_inverted"; + default: return "unknown"; + } +} + +uint32_t mw_report_power_mw(const mw_report *report) { + if (!report) { + return 0; + } + return ((uint32_t)report->current_ma * (uint32_t)report->voltage_mv) / 1000u; +} + +uint32_t mw_capability_limit_for_mode(const mw_capabilities *caps, mw_mode mode) { + if (!caps) { + return 0; + } + switch (mode) { + case MW_MODE_CURRENT: + return (caps->max_current_adc_ma > caps->max_current_dac_ma) + ? caps->max_current_adc_ma : caps->max_current_dac_ma; + case MW_MODE_VOLTAGE: + return (caps->max_voltage_adc_mv > caps->max_voltage_dac_mv) + ? caps->max_voltage_adc_mv : caps->max_voltage_dac_mv; + case MW_MODE_VOLTAGE_INVERTED: + return caps->max_voltage_adc_mv; + case MW_MODE_POWER: + return caps->max_power_mw; + case MW_MODE_RESISTANCE: + return caps->dvm_input_resistance_ohm * 1000u; + default: + return 0; + } +} + +int mw_validate_target(const mw_capabilities *caps, mw_mode mode, uint32_t milli_units, + char *buffer, size_t buffer_size) { + uint32_t limit; + + if (buffer && buffer_size > 0) { + buffer[0] = '\0'; + } + if (!caps) { + return 0; + } + + limit = mw_capability_limit_for_mode(caps, mode); + if (limit == 0 && mode != MW_MODE_CURRENT) { + if (buffer && buffer_size > 0) { + snprintf(buffer, buffer_size, "unknown limit for mode %s", mw_mode_name(mode)); + } + return -1; + } + if (milli_units > limit) { + if (buffer && buffer_size > 0) { + snprintf(buffer, buffer_size, "%s target %.3f exceeds device limit %.3f", + mw_mode_name(mode), milli_units / 1000.0, limit / 1000.0); + } + return -1; + } + return 0; +} diff --git a/src/mightywatt_app.c b/src/mightywatt_app.c new file mode 100644 index 0000000..45cc862 --- /dev/null +++ b/src/mightywatt_app.c @@ -0,0 +1,252 @@ +#include "mightywatt_app.h" + +#include +#include +#include +#include + +struct mw_app { + mw_device *dev; + mw_app_state state; + char last_error[256]; +}; + +static void mw_app_set_error(mw_app *app, const char *fmt, ...) { + va_list ap; + if (!app) { + return; + } + va_start(ap, fmt); + vsnprintf(app->last_error, sizeof(app->last_error), fmt, ap); + va_end(ap); +} + +static void mw_app_copy_report(mw_app *app, const mw_report *report) { + if (!app || !report) { + return; + } + app->state.last_report = *report; + app->state.report_valid = true; +} + +static void mw_app_copy_caps(mw_app *app, const mw_capabilities *caps) { + if (!app || !caps) { + return; + } + app->state.capabilities = *caps; + app->state.capabilities_valid = true; +} + +static int mw_app_fail_from_device(mw_app *app, const char *prefix) { + mw_app_set_error(app, "%s: %s", prefix, mw_last_error(app->dev)); + return -1; +} + +static int mw_app_check_target(mw_app *app, mw_mode mode, uint32_t milli_units) { + char reason[160]; + if (mw_validate_target(app->state.capabilities_valid ? &app->state.capabilities : NULL, + mode, milli_units, reason, sizeof(reason)) != 0) { + mw_app_set_error(app, "%s", reason[0] ? reason : "invalid target"); + return -1; + } + return 0; +} + +static void mw_app_store_target(mw_app *app, mw_mode mode, uint32_t milli_units) { + app->state.target_valid = true; + app->state.target_mode = mode; + app->state.target_milli_units = milli_units; + if (milli_units > 0) { + app->state.restore_target_valid = true; + app->state.restore_target_mode = mode; + app->state.restore_target_milli_units = milli_units; + } +} + +int mw_app_open(mw_app **out_app, const char *port_path, int settle_ms) { + mw_app *app; + mw_capabilities caps; + + if (!out_app || !port_path) { + return -1; + } + *out_app = NULL; + + app = calloc(1, sizeof(*app)); + if (!app) { + return -1; + } + + if (mw_open(&app->dev, port_path, settle_ms) != 0) { + mw_app_set_error(app, "%s", app->dev ? mw_last_error(app->dev) : "open failed"); + mw_close(app->dev); + free(app); + return -1; + } + if (mw_identify(app->dev) != 0) { + mw_app_set_error(app, "identify failed: %s", mw_last_error(app->dev)); + mw_close(app->dev); + free(app); + return -1; + } + if (mw_query_capabilities(app->dev, &caps) != 0) { + mw_app_set_error(app, "capabilities failed: %s", mw_last_error(app->dev)); + mw_close(app->dev); + free(app); + return -1; + } + mw_app_copy_caps(app, &caps); + *out_app = app; + return 0; +} + +void mw_app_close(mw_app *app) { + if (!app) { + return; + } + mw_close(app->dev); + free(app); +} + +const char *mw_app_last_error(const mw_app *app) { + return app ? app->last_error : "invalid app"; +} + +int mw_app_refresh_capabilities(mw_app *app, mw_capabilities *out_caps) { + mw_capabilities caps; + if (!app) { + return -1; + } + if (mw_query_capabilities(app->dev, &caps) != 0) { + return mw_app_fail_from_device(app, "capabilities failed"); + } + mw_app_copy_caps(app, &caps); + if (out_caps) { + *out_caps = caps; + } + return 0; +} + +int mw_app_get_report(mw_app *app, mw_report *out_report) { + mw_report report; + if (!app || !out_report) { + return -1; + } + if (mw_get_report(app->dev, &report) != 0) { + return mw_app_fail_from_device(app, "report failed"); + } + mw_app_copy_report(app, &report); + *out_report = report; + return 0; +} + +int mw_app_set_target(mw_app *app, mw_mode mode, uint32_t milli_units, mw_report *out_report) { + mw_report report; + if (!app || !out_report) { + return -1; + } + if (mw_app_check_target(app, mode, milli_units) != 0) { + return -1; + } + if (mw_set(app->dev, mode, milli_units, &report) != 0) { + return mw_app_fail_from_device(app, "set target failed"); + } + mw_app_copy_report(app, &report); + mw_app_store_target(app, mode, milli_units); + *out_report = report; + return 0; +} + +int mw_app_load_off(mw_app *app, mw_report *out_report) { + mw_report report; + if (!app || !out_report) { + return -1; + } + if (mw_set(app->dev, MW_MODE_CURRENT, 0, &report) != 0) { + return mw_app_fail_from_device(app, "load-off failed"); + } + mw_app_copy_report(app, &report); + app->state.target_valid = true; + app->state.target_mode = MW_MODE_CURRENT; + app->state.target_milli_units = 0; + *out_report = report; + return 0; +} + +int mw_app_load_on(mw_app *app, mw_mode mode, uint32_t milli_units, mw_report *out_report) { + return mw_app_set_target(app, mode, milli_units, out_report); +} + +int mw_app_restore_target(mw_app *app, mw_report *out_report) { + if (!app || !out_report) { + return -1; + } + if (!app->state.restore_target_valid) { + mw_app_set_error(app, "no restorable target in current process state"); + return -1; + } + return mw_app_set_target(app, + app->state.restore_target_mode, + app->state.restore_target_milli_units, + out_report); +} + +int mw_app_safe(mw_app *app, mw_report *out_report) { + mw_report report; + if (!app || !out_report) { + return -1; + } + if (mw_app_load_off(app, &report) != 0) { + return -1; + } + if (mw_set_remote(app->dev, false, &report) != 0) { + return mw_app_fail_from_device(app, "safe failed while disabling remote"); + } + mw_app_copy_report(app, &report); + *out_report = report; + return 0; +} + +int mw_app_set_remote(mw_app *app, bool enable, mw_report *out_report) { + mw_report report; + if (!app || !out_report) { + return -1; + } + if (mw_set_remote(app->dev, enable, &report) != 0) { + return mw_app_fail_from_device(app, enable ? "remote on failed" : "remote off failed"); + } + mw_app_copy_report(app, &report); + *out_report = report; + return 0; +} + +int mw_app_get_series_resistance(mw_app *app, uint16_t *milliohm) { + if (!app || !milliohm) { + return -1; + } + if (mw_get_series_resistance(app->dev, milliohm) != 0) { + return mw_app_fail_from_device(app, "get-series failed"); + } + return 0; +} + +int mw_app_set_series_resistance(mw_app *app, uint16_t milliohm, mw_report *out_report) { + mw_report report; + if (!app || !out_report) { + return -1; + } + if (mw_set_series_resistance(app->dev, milliohm, &report) != 0) { + return mw_app_fail_from_device(app, "set-series failed"); + } + mw_app_copy_report(app, &report); + *out_report = report; + return 0; +} + +int mw_app_get_state(const mw_app *app, mw_app_state *out_state) { + if (!app || !out_state) { + return -1; + } + *out_state = app->state; + return 0; +} diff --git a/src/mightywatt_controller.c b/src/mightywatt_controller.c new file mode 100644 index 0000000..9c7b0c5 --- /dev/null +++ b/src/mightywatt_controller.c @@ -0,0 +1,723 @@ +#define _POSIX_C_SOURCE 200809L + +#include "mightywatt_controller.h" + +#include +#include +#include +#include +#include +#include +#include + +typedef struct { + mw_controller_command_kind kind; + mw_mode mode; + uint32_t milli_units; + bool enable; + uint16_t series_milliohm; + uint64_t id; +} mw_controller_command; + +struct mw_controller { + pthread_t thread; + pthread_mutex_t mutex; + pthread_cond_t cond; + bool thread_started; + bool stop_requested; + mw_controller_config config; + mw_controller_snapshot snapshot; + mw_app *app; + mw_controller_command *queue; + size_t queue_capacity; + size_t queue_head; + size_t queue_tail; + size_t queue_count; + uint64_t next_command_id; + uint64_t next_retry_due_ms; + uint64_t next_poll_due_ms; +}; + +static uint64_t mw_now_ms(void) { + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + return (uint64_t)ts.tv_sec * 1000ULL + (uint64_t)(ts.tv_nsec / 1000000L); +} + +static void mw_abs_timespec_from_now(int timeout_ms, struct timespec *out_ts) { + uint64_t now_ms; + if (!out_ts) { + return; + } + if (timeout_ms < 0) { + timeout_ms = 0; + } + now_ms = mw_now_ms() + (uint64_t)timeout_ms; + out_ts->tv_sec = (time_t)(now_ms / 1000ULL); + out_ts->tv_nsec = (long)(now_ms % 1000ULL) * 1000000L; +} + +static void mw_snapshot_touch_locked(mw_controller *controller) { + controller->snapshot.snapshot_seq++; + pthread_cond_broadcast(&controller->cond); +} + +static void mw_snapshot_set_error_locked(mw_controller *controller, const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + vsnprintf(controller->snapshot.last_error, + sizeof(controller->snapshot.last_error), + fmt, + ap); + va_end(ap); +} + +static void mw_snapshot_set_completed_locked(mw_controller *controller, + const mw_controller_command *cmd, + int result, + const char *error_text) { + controller->snapshot.last_completed_command_id = cmd ? cmd->id : 0; + controller->snapshot.last_completed_kind = cmd ? cmd->kind : MW_CTRL_CMD_NONE; + controller->snapshot.last_completed_result = result; + snprintf(controller->snapshot.last_completed_error, + sizeof(controller->snapshot.last_completed_error), + "%s", + error_text ? error_text : ""); +} + +static void mw_snapshot_sync_app_state_locked(mw_controller *controller) { + mw_app_state state; + if (!controller || !controller->app) { + controller->snapshot.app_state_valid = false; + return; + } + if (mw_app_get_state(controller->app, &state) == 0) { + controller->snapshot.app_state = state; + controller->snapshot.app_state_valid = true; + } +} + +static void mw_disconnect_locked(mw_controller *controller, const char *reason) { + mw_app *app_to_close = controller->app; + bool was_connected = controller->snapshot.connected; + + controller->app = NULL; + controller->snapshot.connected = false; + controller->snapshot.connection_state = controller->stop_requested + ? MW_CONTROLLER_STOPPED + : MW_CONTROLLER_RECONNECT_WAIT; + if (reason && reason[0] != '\0') { + mw_snapshot_set_error_locked(controller, "%s", reason); + } + if (was_connected) { + controller->snapshot.reconnect_count++; + } + controller->next_retry_due_ms = mw_now_ms() + (uint64_t)controller->config.reconnect_ms; + mw_snapshot_touch_locked(controller); + pthread_mutex_unlock(&controller->mutex); + mw_app_close(app_to_close); + pthread_mutex_lock(&controller->mutex); +} + +static int mw_queue_push_locked(mw_controller *controller, + const mw_controller_command *command, + uint64_t *out_command_id) { + mw_controller_command stored; + char validation[160]; + + if (!controller || !command) { + return -1; + } + if (controller->stop_requested || !controller->snapshot.running) { + mw_snapshot_set_error_locked(controller, "controller is stopping or not running"); + return -1; + } + if (controller->queue_count >= controller->queue_capacity) { + mw_snapshot_set_error_locked(controller, "command queue is full"); + return -1; + } + + if (command->kind == MW_CTRL_CMD_SET_TARGET || command->kind == MW_CTRL_CMD_LOAD_ON) { + const mw_capabilities *caps = NULL; + if (controller->snapshot.app_state_valid && + controller->snapshot.app_state.capabilities_valid) { + caps = &controller->snapshot.app_state.capabilities; + } + if (mw_validate_target(caps, + command->mode, + command->milli_units, + validation, + sizeof(validation)) != 0) { + mw_snapshot_set_error_locked(controller, + "%s", + validation[0] ? validation : "invalid target"); + return -1; + } + } + + stored = *command; + stored.id = ++controller->next_command_id; + controller->queue[controller->queue_tail] = stored; + controller->queue_tail = (controller->queue_tail + 1U) % controller->queue_capacity; + controller->queue_count++; + controller->snapshot.pending_commands = controller->queue_count; + controller->snapshot.last_queued_command_id = stored.id; + mw_snapshot_touch_locked(controller); + if (out_command_id) { + *out_command_id = stored.id; + } + return 0; +} + +static int mw_queue_pop_locked(mw_controller *controller, mw_controller_command *out_command) { + if (!controller || !out_command || controller->queue_count == 0U) { + return -1; + } + *out_command = controller->queue[controller->queue_head]; + controller->queue_head = (controller->queue_head + 1U) % controller->queue_capacity; + controller->queue_count--; + controller->snapshot.pending_commands = controller->queue_count; + return 0; +} + +static int mw_try_keep_connection(mw_controller *controller) { + mw_report report; + if (!controller || !controller->app) { + return -1; + } + if (mw_app_get_report(controller->app, &report) != 0) { + return -1; + } + return 0; +} + +static void mw_handle_connected_failure_locked(mw_controller *controller, + const mw_controller_command *command, + const char *error_text, + bool may_still_be_connected) { + mw_snapshot_set_completed_locked(controller, command, -1, error_text); + controller->snapshot.command_error_count++; + mw_snapshot_set_error_locked(controller, "%s", error_text ? error_text : "operation failed"); + mw_snapshot_touch_locked(controller); + pthread_mutex_unlock(&controller->mutex); + + if (may_still_be_connected && mw_try_keep_connection(controller) == 0) { + pthread_mutex_lock(&controller->mutex); + mw_snapshot_sync_app_state_locked(controller); + mw_snapshot_touch_locked(controller); + return; + } + + pthread_mutex_lock(&controller->mutex); + mw_disconnect_locked(controller, error_text ? error_text : "device communication failed"); +} + +static void mw_execute_connect(mw_controller *controller) { + mw_app *new_app = NULL; + int rc; + char error_buffer[MW_CONTROLLER_ERROR_MAX + 1] = ""; + + rc = mw_app_open(&new_app, + controller->config.port_path, + controller->config.settle_ms); + if (rc != 0) { + if (new_app) { + snprintf(error_buffer, sizeof(error_buffer), "%s", mw_app_last_error(new_app)); + mw_app_close(new_app); + } else { + snprintf(error_buffer, sizeof(error_buffer), "connect failed"); + } + + pthread_mutex_lock(&controller->mutex); + controller->snapshot.connect_attempts++; + controller->snapshot.connected = false; + controller->snapshot.connection_state = MW_CONTROLLER_RECONNECT_WAIT; + controller->next_retry_due_ms = mw_now_ms() + (uint64_t)controller->config.reconnect_ms; + mw_snapshot_set_error_locked(controller, "%s", error_buffer); + mw_snapshot_touch_locked(controller); + pthread_mutex_unlock(&controller->mutex); + return; + } + + pthread_mutex_lock(&controller->mutex); + controller->snapshot.connect_attempts++; + controller->app = new_app; + controller->snapshot.connected = true; + controller->snapshot.connection_state = MW_CONTROLLER_CONNECTED; + controller->next_poll_due_ms = mw_now_ms() + (uint64_t)controller->config.poll_interval_ms; + controller->snapshot.last_error[0] = '\0'; + mw_snapshot_sync_app_state_locked(controller); + mw_snapshot_touch_locked(controller); + pthread_mutex_unlock(&controller->mutex); +} + +static void mw_execute_poll(mw_controller *controller) { + mw_report report; + (void)report; + if (mw_app_get_report(controller->app, &report) != 0) { + pthread_mutex_lock(&controller->mutex); + controller->snapshot.poll_error_count++; + mw_disconnect_locked(controller, mw_app_last_error(controller->app)); + pthread_mutex_unlock(&controller->mutex); + return; + } + pthread_mutex_lock(&controller->mutex); + controller->snapshot.poll_success_count++; + controller->next_poll_due_ms = mw_now_ms() + (uint64_t)controller->config.poll_interval_ms; + controller->snapshot.last_error[0] = '\0'; + mw_snapshot_sync_app_state_locked(controller); + mw_snapshot_touch_locked(controller); + pthread_mutex_unlock(&controller->mutex); +} + +static void mw_execute_command(mw_controller *controller, const mw_controller_command *command) { + int rc = -1; + mw_report report; + uint16_t series_milliohm = 0; + char error_text[MW_CONTROLLER_ERROR_MAX + 1] = ""; + + switch (command->kind) { + case MW_CTRL_CMD_SET_TARGET: + rc = mw_app_set_target(controller->app, + command->mode, + command->milli_units, + &report); + break; + case MW_CTRL_CMD_LOAD_ON: + rc = mw_app_load_on(controller->app, + command->mode, + command->milli_units, + &report); + break; + case MW_CTRL_CMD_LOAD_OFF: + rc = mw_app_load_off(controller->app, &report); + break; + case MW_CTRL_CMD_RESTORE_TARGET: + rc = mw_app_restore_target(controller->app, &report); + break; + case MW_CTRL_CMD_SAFE: + rc = mw_app_safe(controller->app, &report); + break; + case MW_CTRL_CMD_SET_REMOTE: + rc = mw_app_set_remote(controller->app, command->enable, &report); + break; + case MW_CTRL_CMD_SET_SERIES_RESISTANCE: + rc = mw_app_set_series_resistance(controller->app, + command->series_milliohm, + &report); + break; + case MW_CTRL_CMD_REFRESH_CAPABILITIES: + rc = mw_app_refresh_capabilities(controller->app, NULL); + break; + case MW_CTRL_CMD_GET_SERIES_RESISTANCE: + rc = mw_app_get_series_resistance(controller->app, &series_milliohm); + break; + default: + snprintf(error_text, sizeof(error_text), "unsupported command kind"); + break; + } + + if (rc != 0 && error_text[0] == '\0') { + snprintf(error_text, sizeof(error_text), "%s", mw_app_last_error(controller->app)); + } + + if (rc != 0) { + pthread_mutex_lock(&controller->mutex); + mw_handle_connected_failure_locked(controller, + command, + error_text, + true); + pthread_mutex_unlock(&controller->mutex); + return; + } + + pthread_mutex_lock(&controller->mutex); + controller->snapshot.command_success_count++; + controller->snapshot.last_error[0] = '\0'; + mw_snapshot_set_completed_locked(controller, command, 0, ""); + if (command->kind == MW_CTRL_CMD_GET_SERIES_RESISTANCE) { + controller->snapshot.series_resistance_valid = true; + controller->snapshot.series_resistance_milliohm = series_milliohm; + } + controller->next_poll_due_ms = mw_now_ms() + (uint64_t)controller->config.poll_interval_ms; + mw_snapshot_sync_app_state_locked(controller); + mw_snapshot_touch_locked(controller); + pthread_mutex_unlock(&controller->mutex); +} + +static void *mw_controller_thread_main(void *arg) { + mw_controller *controller = (mw_controller *)arg; + + for (;;) { + bool stop_now = false; + bool do_connect = false; + bool do_poll = false; + mw_controller_command command; + bool have_command = false; + + pthread_mutex_lock(&controller->mutex); + for (;;) { + uint64_t now_ms; + struct timespec wake_ts; + int wait_ms; + + if (controller->stop_requested) { + stop_now = true; + break; + } + + if (!controller->app) { + now_ms = mw_now_ms(); + if (now_ms >= controller->next_retry_due_ms) { + controller->snapshot.connection_state = MW_CONTROLLER_CONNECTING; + controller->snapshot.connected = false; + mw_snapshot_touch_locked(controller); + do_connect = true; + break; + } + wait_ms = (int)(controller->next_retry_due_ms - now_ms); + mw_abs_timespec_from_now(wait_ms, &wake_ts); + pthread_cond_timedwait(&controller->cond, &controller->mutex, &wake_ts); + continue; + } + + if (controller->queue_count > 0U) { + if (mw_queue_pop_locked(controller, &command) == 0) { + have_command = true; + break; + } + } + + now_ms = mw_now_ms(); + if (now_ms >= controller->next_poll_due_ms) { + do_poll = true; + break; + } + + wait_ms = (int)(controller->next_poll_due_ms - now_ms); + mw_abs_timespec_from_now(wait_ms, &wake_ts); + pthread_cond_timedwait(&controller->cond, &controller->mutex, &wake_ts); + } + pthread_mutex_unlock(&controller->mutex); + + if (stop_now) { + mw_app *app_to_close = NULL; + pthread_mutex_lock(&controller->mutex); + app_to_close = controller->app; + controller->app = NULL; + controller->snapshot.running = false; + controller->snapshot.connected = false; + controller->snapshot.connection_state = MW_CONTROLLER_STOPPED; + mw_snapshot_touch_locked(controller); + pthread_mutex_unlock(&controller->mutex); + + if (app_to_close) { + if (controller->config.safe_on_shutdown) { + mw_report dummy_report; + (void)mw_app_safe(app_to_close, &dummy_report); + } + mw_app_close(app_to_close); + } + return NULL; + } + + if (do_connect) { + mw_execute_connect(controller); + } else if (have_command) { + mw_execute_command(controller, &command); + } else if (do_poll) { + mw_execute_poll(controller); + } + } +} + +void mw_controller_config_init(mw_controller_config *config) { + if (!config) { + return; + } + memset(config, 0, sizeof(*config)); + config->settle_ms = 2200; + config->poll_interval_ms = 500; + config->reconnect_ms = 2000; + config->queue_capacity = 32; + config->safe_on_shutdown = true; +} + +int mw_controller_start(mw_controller **out_controller, const mw_controller_config *config) { + mw_controller *controller; + size_t capacity; + + if (!out_controller || !config || !config->port_path || config->port_path[0] == '\0') { + return -1; + } + *out_controller = NULL; + + controller = calloc(1, sizeof(*controller)); + if (!controller) { + return -1; + } + + controller->config = *config; + capacity = config->queue_capacity > 0U ? config->queue_capacity : 32U; + controller->queue = calloc(capacity, sizeof(*controller->queue)); + if (!controller->queue) { + free(controller); + return -1; + } + controller->queue_capacity = capacity; + controller->snapshot.running = true; + controller->snapshot.connection_state = MW_CONTROLLER_CONNECTING; + snprintf(controller->snapshot.port_path, + sizeof(controller->snapshot.port_path), + "%s", + config->port_path); + controller->next_retry_due_ms = mw_now_ms(); + controller->next_poll_due_ms = mw_now_ms() + (uint64_t)controller->config.poll_interval_ms; + + if (pthread_mutex_init(&controller->mutex, NULL) != 0) { + free(controller->queue); + free(controller); + return -1; + } + if (pthread_cond_init(&controller->cond, NULL) != 0) { + pthread_mutex_destroy(&controller->mutex); + free(controller->queue); + free(controller); + return -1; + } + if (pthread_create(&controller->thread, NULL, mw_controller_thread_main, controller) != 0) { + pthread_cond_destroy(&controller->cond); + pthread_mutex_destroy(&controller->mutex); + free(controller->queue); + free(controller); + return -1; + } + + controller->thread_started = true; + *out_controller = controller; + return 0; +} + +void mw_controller_stop(mw_controller *controller) { + if (!controller) { + return; + } + pthread_mutex_lock(&controller->mutex); + controller->stop_requested = true; + pthread_cond_broadcast(&controller->cond); + pthread_mutex_unlock(&controller->mutex); + + if (controller->thread_started) { + pthread_join(controller->thread, NULL); + } + + pthread_cond_destroy(&controller->cond); + pthread_mutex_destroy(&controller->mutex); + free(controller->queue); + free(controller); +} + +int mw_controller_get_snapshot(mw_controller *controller, mw_controller_snapshot *out_snapshot) { + if (!controller || !out_snapshot) { + return -1; + } + pthread_mutex_lock(&controller->mutex); + *out_snapshot = controller->snapshot; + pthread_mutex_unlock(&controller->mutex); + return 0; +} + +int mw_controller_wait_for_update(mw_controller *controller, + uint64_t last_seen_snapshot_seq, + int timeout_ms, + uint64_t *out_snapshot_seq) { + int rc = 0; + + if (!controller) { + return -1; + } + pthread_mutex_lock(&controller->mutex); + if (controller->snapshot.snapshot_seq == last_seen_snapshot_seq) { + if (timeout_ms < 0) { + while (controller->snapshot.snapshot_seq == last_seen_snapshot_seq) { + pthread_cond_wait(&controller->cond, &controller->mutex); + } + } else { + struct timespec wake_ts; + mw_abs_timespec_from_now(timeout_ms, &wake_ts); + while (controller->snapshot.snapshot_seq == last_seen_snapshot_seq) { + rc = pthread_cond_timedwait(&controller->cond, &controller->mutex, &wake_ts); + if (rc == ETIMEDOUT) { + pthread_mutex_unlock(&controller->mutex); + return 1; + } + if (rc != 0) { + pthread_mutex_unlock(&controller->mutex); + return -1; + } + } + } + } + if (out_snapshot_seq) { + *out_snapshot_seq = controller->snapshot.snapshot_seq; + } + pthread_mutex_unlock(&controller->mutex); + return 0; +} + +static int mw_controller_enqueue_simple(mw_controller *controller, + mw_controller_command_kind kind, + mw_mode mode, + uint32_t milli_units, + bool enable, + uint16_t series_milliohm, + uint64_t *out_command_id) { + mw_controller_command cmd; + + if (!controller) { + return -1; + } + memset(&cmd, 0, sizeof(cmd)); + cmd.kind = kind; + cmd.mode = mode; + cmd.milli_units = milli_units; + cmd.enable = enable; + cmd.series_milliohm = series_milliohm; + + pthread_mutex_lock(&controller->mutex); + if (mw_queue_push_locked(controller, &cmd, out_command_id) != 0) { + pthread_mutex_unlock(&controller->mutex); + return -1; + } + pthread_cond_broadcast(&controller->cond); + pthread_mutex_unlock(&controller->mutex); + return 0; +} + +int mw_controller_queue_set_target(mw_controller *controller, + mw_mode mode, + uint32_t milli_units, + uint64_t *out_command_id) { + return mw_controller_enqueue_simple(controller, + MW_CTRL_CMD_SET_TARGET, + mode, + milli_units, + false, + 0, + out_command_id); +} + +int mw_controller_queue_load_on(mw_controller *controller, + mw_mode mode, + uint32_t milli_units, + uint64_t *out_command_id) { + return mw_controller_enqueue_simple(controller, + MW_CTRL_CMD_LOAD_ON, + mode, + milli_units, + false, + 0, + out_command_id); +} + +int mw_controller_queue_load_off(mw_controller *controller, uint64_t *out_command_id) { + return mw_controller_enqueue_simple(controller, + MW_CTRL_CMD_LOAD_OFF, + MW_MODE_CURRENT, + 0, + false, + 0, + out_command_id); +} + +int mw_controller_queue_restore_target(mw_controller *controller, uint64_t *out_command_id) { + return mw_controller_enqueue_simple(controller, + MW_CTRL_CMD_RESTORE_TARGET, + MW_MODE_CURRENT, + 0, + false, + 0, + out_command_id); +} + +int mw_controller_queue_safe(mw_controller *controller, uint64_t *out_command_id) { + return mw_controller_enqueue_simple(controller, + MW_CTRL_CMD_SAFE, + MW_MODE_CURRENT, + 0, + false, + 0, + out_command_id); +} + +int mw_controller_queue_set_remote(mw_controller *controller, + bool enable, + uint64_t *out_command_id) { + return mw_controller_enqueue_simple(controller, + MW_CTRL_CMD_SET_REMOTE, + MW_MODE_CURRENT, + 0, + enable, + 0, + out_command_id); +} + +int mw_controller_queue_set_series_resistance(mw_controller *controller, + uint16_t milliohm, + uint64_t *out_command_id) { + return mw_controller_enqueue_simple(controller, + MW_CTRL_CMD_SET_SERIES_RESISTANCE, + MW_MODE_CURRENT, + 0, + false, + milliohm, + out_command_id); +} + +int mw_controller_queue_refresh_capabilities(mw_controller *controller, + uint64_t *out_command_id) { + return mw_controller_enqueue_simple(controller, + MW_CTRL_CMD_REFRESH_CAPABILITIES, + MW_MODE_CURRENT, + 0, + false, + 0, + out_command_id); +} + +int mw_controller_queue_get_series_resistance(mw_controller *controller, + uint64_t *out_command_id) { + return mw_controller_enqueue_simple(controller, + MW_CTRL_CMD_GET_SERIES_RESISTANCE, + MW_MODE_CURRENT, + 0, + false, + 0, + out_command_id); +} + +const char *mw_controller_connection_state_name(mw_controller_connection_state state) { + switch (state) { + case MW_CONTROLLER_STOPPED: return "stopped"; + case MW_CONTROLLER_CONNECTING: return "connecting"; + case MW_CONTROLLER_CONNECTED: return "connected"; + case MW_CONTROLLER_RECONNECT_WAIT: return "reconnect_wait"; + default: return "unknown"; + } +} + +const char *mw_controller_command_kind_name(mw_controller_command_kind kind) { + switch (kind) { + case MW_CTRL_CMD_NONE: return "none"; + case MW_CTRL_CMD_SET_TARGET: return "set_target"; + case MW_CTRL_CMD_LOAD_ON: return "load_on"; + case MW_CTRL_CMD_LOAD_OFF: return "load_off"; + case MW_CTRL_CMD_RESTORE_TARGET: return "restore_target"; + case MW_CTRL_CMD_SAFE: return "safe"; + case MW_CTRL_CMD_SET_REMOTE: return "set_remote"; + case MW_CTRL_CMD_SET_SERIES_RESISTANCE: return "set_series_resistance"; + case MW_CTRL_CMD_REFRESH_CAPABILITIES: return "refresh_capabilities"; + case MW_CTRL_CMD_GET_SERIES_RESISTANCE: return "get_series_resistance"; + default: return "unknown"; + } +} diff --git a/src/mightywatt_log.c b/src/mightywatt_log.c new file mode 100644 index 0000000..8c3da61 --- /dev/null +++ b/src/mightywatt_log.c @@ -0,0 +1,122 @@ +#define _POSIX_C_SOURCE 200809L +#include "mightywatt_log.h" + +#include +#include +#include +#include + +struct mw_csv_logger { + FILE *fp; + struct timespec t0; + mw_csv_units_mode units_mode; +}; + +static double mw_elapsed_seconds(const struct timespec *t0) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (double)(now.tv_sec - t0->tv_sec) + (double)(now.tv_nsec - t0->tv_nsec) / 1000000000.0; +} + +static void mw_timestamp_iso8601(char *buffer, size_t buffer_size) { + struct timespec now; + struct tm tm_now; + clock_gettime(CLOCK_REALTIME, &now); + gmtime_r(&now.tv_sec, &tm_now); + { + int millis = (int)(now.tv_nsec / 1000000L); + snprintf(buffer, + buffer_size, + "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ", + tm_now.tm_year + 1900, + tm_now.tm_mon + 1, + tm_now.tm_mday, + tm_now.tm_hour, + tm_now.tm_min, + tm_now.tm_sec, + millis); + } +} + +int mw_csv_logger_open(mw_csv_logger **out_logger, const char *path, mw_csv_units_mode units_mode) { + mw_csv_logger *logger; + if (!out_logger || !path || !*path) { + return -1; + } + *out_logger = NULL; + logger = calloc(1, sizeof(*logger)); + if (!logger) { + return -1; + } + logger->fp = fopen(path, "w"); + if (!logger->fp) { + free(logger); + return -1; + } + clock_gettime(CLOCK_MONOTONIC, &logger->t0); + logger->units_mode = units_mode; + if (logger->units_mode == MW_CSV_UNITS_RAW) { + fprintf(logger->fp, + "timestamp_utc,elapsed_ms,context,step_index,current_ma,voltage_mv,power_mw,temperature_c,remote,status_bits,status_text\n"); + } else { + fprintf(logger->fp, + "timestamp_utc,elapsed_s,context,step_index,current_a,voltage_v,power_w,temperature_c,remote,status_bits,status_text\n"); + } + fflush(logger->fp); + *out_logger = logger; + return 0; +} + +void mw_csv_logger_close(mw_csv_logger *logger) { + if (!logger) { + return; + } + if (logger->fp) { + fclose(logger->fp); + } + free(logger); +} + +int mw_csv_logger_write(mw_csv_logger *logger, + const char *context, + long step_index, + const mw_report *report) { + char ts[96]; + char status[128]; + if (!logger || !logger->fp || !report) { + return -1; + } + mw_timestamp_iso8601(ts, sizeof(ts)); + mw_status_string(report->status, status, sizeof(status)); + if (logger->units_mode == MW_CSV_UNITS_RAW) { + fprintf(logger->fp, + "%s,%.0f,%s,%ld,%u,%u,%u,%u,%s,%u,%s\n", + ts, + mw_elapsed_seconds(&logger->t0) * 1000.0, + context ? context : "", + step_index, + (unsigned)report->current_ma, + (unsigned)report->voltage_mv, + (unsigned)mw_report_power_mw(report), + (unsigned)report->temperature_c, + report->remote ? "true" : "false", + (unsigned)report->status, + status); + } else { + fprintf(logger->fp, + "%s,%.3f,%s,%ld,%.3f,%.3f,%.3f,%u,%s,%u,%s\n", + ts, + mw_elapsed_seconds(&logger->t0), + context ? context : "", + step_index, + report->current_ma / 1000.0, + report->voltage_mv / 1000.0, + mw_report_power_mw(report) / 1000.0, + (unsigned)report->temperature_c, + report->remote ? "true" : "false", + (unsigned)report->status, + status); + } + fflush(logger->fp); + return ferror(logger->fp) ? -1 : 0; +} diff --git a/src/mightywatt_sequence.c b/src/mightywatt_sequence.c new file mode 100644 index 0000000..db7fe6b --- /dev/null +++ b/src/mightywatt_sequence.c @@ -0,0 +1,1459 @@ +#define _POSIX_C_SOURCE 200809L +#include "mightywatt_sequence.h" +#include "mightywatt_log.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +typedef enum { + JT_UNDEFINED = 0, + JT_OBJECT, + JT_ARRAY, + JT_STRING, + JT_PRIMITIVE, +} jt_type; + +typedef struct { + jt_type type; + int start; + int end; + int size; + int parent; +} jt_token; + +typedef struct { + const char *js; + size_t len; + size_t pos; + int toknext; + int toksuper; +} jt_parser; + +static void jt_init(jt_parser *p, const char *js, size_t len) { + p->js = js; + p->len = len; + p->pos = 0; + p->toknext = 0; + p->toksuper = -1; +} + +static jt_token *jt_alloc_token(jt_parser *p, jt_token *tokens, size_t num_tokens) { + jt_token *tok; + if ((size_t)p->toknext >= num_tokens) { + return NULL; + } + tok = &tokens[p->toknext++]; + tok->start = tok->end = -1; + tok->size = 0; + tok->parent = -1; + tok->type = JT_UNDEFINED; + return tok; +} + +static void jt_fill_token(jt_token *tok, jt_type type, int start, int end) { + tok->type = type; + tok->start = start; + tok->end = end; + tok->size = 0; +} + +static int jt_parse_primitive(jt_parser *p, jt_token *tokens, size_t num_tokens) { + size_t start = p->pos; + jt_token *tok; + for (; p->pos < p->len; ++p->pos) { + char c = p->js[p->pos]; + if (c == '\t' || c == '\r' || c == '\n' || c == ' ' || c == ',' || c == ']' || c == '}') { + break; + } + if ((unsigned char)c < 32 || c == '"') { + return -1; + } + } + tok = jt_alloc_token(p, tokens, num_tokens); + if (!tok) { + return -1; + } + jt_fill_token(tok, JT_PRIMITIVE, (int)start, (int)p->pos); + tok->parent = p->toksuper; + if (p->toksuper != -1) { + tokens[p->toksuper].size++; + } + p->pos--; + return 0; +} + +static int jt_parse_string(jt_parser *p, jt_token *tokens, size_t num_tokens) { + size_t start = ++p->pos; + jt_token *tok; + for (; p->pos < p->len; ++p->pos) { + char c = p->js[p->pos]; + if (c == '"') { + tok = jt_alloc_token(p, tokens, num_tokens); + if (!tok) { + return -1; + } + jt_fill_token(tok, JT_STRING, (int)start, (int)p->pos); + tok->parent = p->toksuper; + if (p->toksuper != -1) { + tokens[p->toksuper].size++; + } + return 0; + } + if (c == '\\') { + ++p->pos; + if (p->pos >= p->len) { + return -1; + } + } + } + return -1; +} + +static int jt_parse(jt_parser *p, jt_token *tokens, size_t num_tokens) { + for (; p->pos < p->len; ++p->pos) { + char c = p->js[p->pos]; + jt_token *tok; + int i; + switch (c) { + case '{': + case '[': + tok = jt_alloc_token(p, tokens, num_tokens); + if (!tok) { + return -1; + } + tok->type = (c == '{') ? JT_OBJECT : JT_ARRAY; + tok->start = (int)p->pos; + tok->parent = p->toksuper; + if (p->toksuper != -1) { + tokens[p->toksuper].size++; + } + p->toksuper = p->toknext - 1; + break; + case '}': + case ']': + for (i = p->toknext - 1; i >= 0; --i) { + tok = &tokens[i]; + if (tok->start != -1 && tok->end == -1) { + if ((tok->type == JT_OBJECT && c == '}') || (tok->type == JT_ARRAY && c == ']')) { + tok->end = (int)p->pos + 1; + p->toksuper = tok->parent; + break; + } + return -1; + } + } + if (i < 0) { + return -1; + } + break; + case '"': + if (jt_parse_string(p, tokens, num_tokens) != 0) { + return -1; + } + break; + case '\t': + case '\r': + case '\n': + case ' ': + case ':': + case ',': + break; + default: + if (jt_parse_primitive(p, tokens, num_tokens) != 0) { + return -1; + } + break; + } + } + for (int i = p->toknext - 1; i >= 0; --i) { + if (tokens[i].start != -1 && tokens[i].end == -1) { + return -1; + } + } + return p->toknext; +} + +static int js_token_eq(const char *js, const jt_token *tok, const char *text) { + size_t n; + if (!js || !tok || !text || tok->start < 0 || tok->end < tok->start) { + return 0; + } + n = (size_t)(tok->end - tok->start); + return strlen(text) == n && strncmp(js + tok->start, text, n) == 0; +} + +static int js_token_to_string(const char *js, const jt_token *tok, char *buffer, size_t buffer_size) { + size_t n; + if (!js || !tok || !buffer || buffer_size == 0 || tok->start < 0 || tok->end < tok->start) { + return -1; + } + n = (size_t)(tok->end - tok->start); + if (n + 1 > buffer_size) { + return -1; + } + memcpy(buffer, js + tok->start, n); + buffer[n] = '\0'; + return 0; +} + +static int js_token_to_double(const char *js, const jt_token *tok, double *out) { + char tmp[64]; + char *end = NULL; + if (!out || js_token_to_string(js, tok, tmp, sizeof(tmp)) != 0) { + return -1; + } + errno = 0; + *out = strtod(tmp, &end); + if (errno != 0 || end == tmp || *end != '\0') { + return -1; + } + return 0; +} + +static int js_token_to_int(const char *js, const jt_token *tok, int *out) { + double d; + if (!out || js_token_to_double(js, tok, &d) != 0) { + return -1; + } + *out = (int)d; + return 0; +} + +static int js_token_to_bool(const char *js, const jt_token *tok, int *out) { + if (!out || !js || !tok) { + return -1; + } + if (js_token_eq(js, tok, "true")) { + *out = 1; + return 0; + } + if (js_token_eq(js, tok, "false")) { + *out = 0; + return 0; + } + return -1; +} + +static int js_skip(const jt_token *tokens, int count, int index) { + int end; + if (!tokens || index < 0 || index >= count) { + return index + 1; + } + end = index + 1; + if (tokens[index].type == JT_OBJECT || tokens[index].type == JT_ARRAY) { + for (int i = 0; i < tokens[index].size; ++i) { + end = js_skip(tokens, count, end); + } + } + return end; +} + +static int js_object_get(const char *js, const jt_token *tokens, int count, int object_index, const char *key) { + int i; + int end; + if (!js || !tokens || object_index < 0 || object_index >= count || tokens[object_index].type != JT_OBJECT) { + return -1; + } + i = object_index + 1; + end = js_skip(tokens, count, object_index); + while (i < end) { + int key_index = i; + int value_index = i + 1; + if (value_index >= end) { + return -1; + } + if (js_token_eq(js, &tokens[key_index], key)) { + return value_index; + } + i = js_skip(tokens, count, value_index); + } + return -1; +} + +typedef struct { + uint32_t max_voltage_mv; + uint32_t max_current_ma; + uint32_t max_power_mw; + int abort_on_disconnect; +} mw_seq_safety; + +typedef enum { + MW_SEQ_COND_VOLTAGE_BELOW = 0, + MW_SEQ_COND_VOLTAGE_ABOVE, + MW_SEQ_COND_CURRENT_BELOW, + MW_SEQ_COND_CURRENT_ABOVE, + MW_SEQ_COND_POWER_BELOW, + MW_SEQ_COND_POWER_ABOVE, + MW_SEQ_COND_TEMPERATURE_ABOVE, +} mw_seq_condition_kind; + +typedef struct { + mw_seq_condition_kind kind; + uint32_t milli_units; +} mw_seq_condition; + +typedef enum { + MW_SEQ_ACT_SET_MODE = 0, + MW_SEQ_ACT_SET_TARGET, + MW_SEQ_ACT_OUTPUT, + MW_SEQ_ACT_HOLD, + MW_SEQ_ACT_RAMP, + MW_SEQ_ACT_HOLD_UNTIL, + MW_SEQ_ACT_REPEAT, + MW_SEQ_ACT_REPEAT_UNTIL, + MW_SEQ_ACT_REPEAT_WHILE, + MW_SEQ_ACT_SAFE, + MW_SEQ_ACT_REMOTE, +} mw_seq_action_kind; + +typedef struct mw_seq_step { + mw_seq_action_kind kind; + mw_mode mode; + uint32_t milli_units; + int enabled; + double duration_s; + double timeout_s; + uint32_t ramp_start; + uint32_t ramp_stop; + uint32_t ramp_step; + double dwell_s; + mw_seq_condition condition; + int has_condition; + int repeat_times; + struct mw_seq_step *children; + size_t child_count; + int has_break_if; + mw_seq_condition break_if; +} mw_seq_step; + +typedef struct { + char name[64]; + int sample_period_ms; + mw_seq_safety safety; + mw_seq_step *steps; + size_t step_count; + mw_seq_step *abort_steps; + size_t abort_step_count; +} mw_sequence; + +typedef struct { + mw_app *app; + mw_sequence *seq; + mw_csv_logger *logger; + mw_sequence_result *result; + mw_mode staged_mode; + uint32_t staged_target; + int staged_valid; + int output_enabled; + long exec_step_counter; + int in_abort_sequence; + const mw_seq_condition *break_stack[16]; + size_t break_stack_count; +} mw_sequence_exec; + +enum { + SEQ_EXEC_OK = 0, + SEQ_EXEC_FAIL = -1, + SEQ_EXEC_ABORT = 1, +}; + +static void seq_set_error(char *buffer, size_t buffer_size, const char *fmt, ...) { + va_list ap; + if (!buffer || buffer_size == 0) { + return; + } + va_start(ap, fmt); + vsnprintf(buffer, buffer_size, fmt, ap); + va_end(ap); +} + +static int seq_sleep_ms(int ms) { + struct timespec ts; + ts.tv_sec = ms / 1000; + ts.tv_nsec = (long)(ms % 1000) * 1000000L; + while (nanosleep(&ts, &ts) == -1) { + if (errno != EINTR) { + return -1; + } + } + return 0; +} + +#define MW_SEQ_KEEPALIVE_MS 250 + +static double seq_elapsed_s(const struct timespec *t0, const struct timespec *now) { + return (double)(now->tv_sec - t0->tv_sec) + (double)(now->tv_nsec - t0->tv_nsec) / 1000000000.0; +} + +static int seq_effective_keepalive_ms(const mw_sequence_exec *exec) { + int sample_ms; + if (!exec || !exec->seq) { + return MW_SEQ_KEEPALIVE_MS; + } + sample_ms = exec->seq->sample_period_ms; + if (sample_ms <= 0) { + sample_ms = 500; + } + return sample_ms < MW_SEQ_KEEPALIVE_MS ? sample_ms : MW_SEQ_KEEPALIVE_MS; +} + +static int seq_double_to_milli(double input, uint32_t *out) { + if (!out || input < 0.0 || input > 4294967.295) { + return -1; + } + *out = (uint32_t)(input * 1000.0 + 0.5); + return 0; +} + +static int seq_parse_mode_string(const char *text, mw_mode *out_mode) { + if (!text || !out_mode) { + return -1; + } + if (strcasecmp(text, "CC") == 0 || strcasecmp(text, "current") == 0) { + *out_mode = MW_MODE_CURRENT; + } else if (strcasecmp(text, "CV") == 0 || strcasecmp(text, "voltage") == 0) { + *out_mode = MW_MODE_VOLTAGE; + } else if (strcasecmp(text, "CP") == 0 || strcasecmp(text, "power") == 0) { + *out_mode = MW_MODE_POWER; + } else if (strcasecmp(text, "CR") == 0 || strcasecmp(text, "resistance") == 0) { + *out_mode = MW_MODE_RESISTANCE; + } else if (strcasecmp(text, "CVINV") == 0 || strcasecmp(text, "vinv") == 0 || + strcasecmp(text, "voltage_inverted") == 0) { + *out_mode = MW_MODE_VOLTAGE_INVERTED; + } else { + return -1; + } + return 0; +} + +static int seq_parse_action_mode_from_name(const char *action, mw_mode *out_mode) { + if (strncmp(action, "set_", 4) == 0) { + return seq_parse_mode_string(action + 4, out_mode); + } + if (strncmp(action, "ramp_", 5) == 0) { + return seq_parse_mode_string(action + 5, out_mode); + } + return -1; +} + +static int seq_condition_met(const mw_seq_condition *cond, const mw_report *report) { + uint32_t power_mw; + if (!cond || !report) { + return 0; + } + power_mw = mw_report_power_mw(report); + switch (cond->kind) { + case MW_SEQ_COND_VOLTAGE_BELOW: return report->voltage_mv < cond->milli_units; + case MW_SEQ_COND_VOLTAGE_ABOVE: return report->voltage_mv > cond->milli_units; + case MW_SEQ_COND_CURRENT_BELOW: return report->current_ma < cond->milli_units; + case MW_SEQ_COND_CURRENT_ABOVE: return report->current_ma > cond->milli_units; + case MW_SEQ_COND_POWER_BELOW: return power_mw < cond->milli_units; + case MW_SEQ_COND_POWER_ABOVE: return power_mw > cond->milli_units; + case MW_SEQ_COND_TEMPERATURE_ABOVE: return (uint32_t)report->temperature_c * 1000u > cond->milli_units; + default: return 0; + } +} + +static void seq_condition_to_text(const mw_seq_condition *cond, char *buf, size_t buf_sz) { + const char *name = "unknown"; + double value = cond ? (double)cond->milli_units / 1000.0 : 0.0; + if (!buf || buf_sz == 0) { + return; + } + if (cond) { + switch (cond->kind) { + case MW_SEQ_COND_VOLTAGE_BELOW: name = "voltage_below"; break; + case MW_SEQ_COND_VOLTAGE_ABOVE: name = "voltage_above"; break; + case MW_SEQ_COND_CURRENT_BELOW: name = "current_below"; break; + case MW_SEQ_COND_CURRENT_ABOVE: name = "current_above"; break; + case MW_SEQ_COND_POWER_BELOW: name = "power_below"; break; + case MW_SEQ_COND_POWER_ABOVE: name = "power_above"; break; + case MW_SEQ_COND_TEMPERATURE_ABOVE: name = "temperature_above"; break; + default: break; + } + } + snprintf(buf, buf_sz, "%s %.3f", name, value); +} + +static void seq_free_steps(mw_seq_step *steps, size_t count) { + if (!steps) { + return; + } + for (size_t i = 0; i < count; ++i) { + seq_free_steps(steps[i].children, steps[i].child_count); + } + free(steps); +} + +static void seq_free(mw_sequence *seq) { + if (!seq) { + return; + } + seq_free_steps(seq->steps, seq->step_count); + seq_free_steps(seq->abort_steps, seq->abort_step_count); + memset(seq, 0, sizeof(*seq)); +} + +static int seq_parse_condition(const char *js, const jt_token *tokens, int count, int cond_index, + mw_seq_condition *out_cond, char *error_text, size_t error_size) { + int type_idx; + int value_idx; + char type_buf[48]; + double value; + if (!out_cond || cond_index < 0 || tokens[cond_index].type != JT_OBJECT) { + seq_set_error(error_text, error_size, "invalid condition object"); + return -1; + } + type_idx = js_object_get(js, tokens, count, cond_index, "type"); + value_idx = js_object_get(js, tokens, count, cond_index, "value"); + if (type_idx < 0 || value_idx < 0) { + seq_set_error(error_text, error_size, "condition needs type and value"); + return -1; + } + if (js_token_to_string(js, &tokens[type_idx], type_buf, sizeof(type_buf)) != 0 || + js_token_to_double(js, &tokens[value_idx], &value) != 0 || + seq_double_to_milli(value, &out_cond->milli_units) != 0) { + seq_set_error(error_text, error_size, "invalid condition fields"); + return -1; + } + if (strcmp(type_buf, "voltage_below") == 0) { + out_cond->kind = MW_SEQ_COND_VOLTAGE_BELOW; + } else if (strcmp(type_buf, "voltage_above") == 0) { + out_cond->kind = MW_SEQ_COND_VOLTAGE_ABOVE; + } else if (strcmp(type_buf, "current_below") == 0) { + out_cond->kind = MW_SEQ_COND_CURRENT_BELOW; + } else if (strcmp(type_buf, "current_above") == 0) { + out_cond->kind = MW_SEQ_COND_CURRENT_ABOVE; + } else if (strcmp(type_buf, "power_below") == 0) { + out_cond->kind = MW_SEQ_COND_POWER_BELOW; + } else if (strcmp(type_buf, "power_above") == 0) { + out_cond->kind = MW_SEQ_COND_POWER_ABOVE; + } else if (strcmp(type_buf, "temperature_above") == 0) { + out_cond->kind = MW_SEQ_COND_TEMPERATURE_ABOVE; + } else { + seq_set_error(error_text, error_size, "unsupported condition type: %s", type_buf); + return -1; + } + return 0; +} + +static int seq_parse_step_array(const char *js, const jt_token *tokens, int count, int array_index, + mw_seq_step **out_steps, size_t *out_step_count, + char *error_text, size_t error_size); + +static int seq_parse_step(const char *js, const jt_token *tokens, int count, int step_index, + mw_seq_step *out_step, char *error_text, size_t error_size) { + int action_idx; + int break_idx; + char action[48]; + memset(out_step, 0, sizeof(*out_step)); + + action_idx = js_object_get(js, tokens, count, step_index, "action"); + if (action_idx < 0 || js_token_to_string(js, &tokens[action_idx], action, sizeof(action)) != 0) { + seq_set_error(error_text, error_size, "step missing action"); + return -1; + } + + break_idx = js_object_get(js, tokens, count, step_index, "break_if"); + if (break_idx >= 0) { + if (seq_parse_condition(js, tokens, count, break_idx, &out_step->break_if, error_text, error_size) != 0) { + return -1; + } + out_step->has_break_if = 1; + } + + if (strcmp(action, "set_mode") == 0) { + int mode_idx = js_object_get(js, tokens, count, step_index, "mode"); + char mode_buf[32]; + if (mode_idx < 0 || js_token_to_string(js, &tokens[mode_idx], mode_buf, sizeof(mode_buf)) != 0 || + seq_parse_mode_string(mode_buf, &out_step->mode) != 0) { + seq_set_error(error_text, error_size, "set_mode needs valid mode"); + return -1; + } + out_step->kind = MW_SEQ_ACT_SET_MODE; + return 0; + } + + if (strcmp(action, "output") == 0) { + int enabled_idx = js_object_get(js, tokens, count, step_index, "enabled"); + int enabled; + if (enabled_idx < 0 || js_token_to_bool(js, &tokens[enabled_idx], &enabled) != 0) { + seq_set_error(error_text, error_size, "output needs enabled boolean"); + return -1; + } + out_step->kind = MW_SEQ_ACT_OUTPUT; + out_step->enabled = enabled; + return 0; + } + + if (strcmp(action, "safe") == 0) { + out_step->kind = MW_SEQ_ACT_SAFE; + return 0; + } + + if (strcmp(action, "remote") == 0) { + int enabled_idx = js_object_get(js, tokens, count, step_index, "enabled"); + int enabled; + if (enabled_idx < 0 || js_token_to_bool(js, &tokens[enabled_idx], &enabled) != 0) { + seq_set_error(error_text, error_size, "remote needs enabled boolean"); + return -1; + } + out_step->kind = MW_SEQ_ACT_REMOTE; + out_step->enabled = enabled; + return 0; + } + + if (strcmp(action, "hold") == 0) { + int duration_idx = js_object_get(js, tokens, count, step_index, "duration_s"); + if (duration_idx < 0 || js_token_to_double(js, &tokens[duration_idx], &out_step->duration_s) != 0 || + out_step->duration_s < 0.0) { + seq_set_error(error_text, error_size, "hold needs duration_s >= 0"); + return -1; + } + out_step->kind = MW_SEQ_ACT_HOLD; + return 0; + } + + if (strcmp(action, "hold_until") == 0) { + int timeout_idx = js_object_get(js, tokens, count, step_index, "timeout_s"); + int cond_idx = js_object_get(js, tokens, count, step_index, "condition"); + if (timeout_idx < 0 || js_token_to_double(js, &tokens[timeout_idx], &out_step->timeout_s) != 0 || + out_step->timeout_s < 0.0) { + seq_set_error(error_text, error_size, "hold_until needs timeout_s >= 0"); + return -1; + } + if (seq_parse_condition(js, tokens, count, cond_idx, &out_step->condition, error_text, error_size) != 0) { + return -1; + } + out_step->has_condition = 1; + out_step->kind = MW_SEQ_ACT_HOLD_UNTIL; + return 0; + } + + if (strncmp(action, "set_", 4) == 0) { + int value_idx = js_object_get(js, tokens, count, step_index, "value"); + double value; + if (seq_parse_action_mode_from_name(action, &out_step->mode) != 0 || + value_idx < 0 || js_token_to_double(js, &tokens[value_idx], &value) != 0 || + seq_double_to_milli(value, &out_step->milli_units) != 0) { + seq_set_error(error_text, error_size, "%s needs numeric value", action); + return -1; + } + out_step->kind = MW_SEQ_ACT_SET_TARGET; + return 0; + } + + if (strncmp(action, "ramp_", 5) == 0) { + int start_idx = js_object_get(js, tokens, count, step_index, "start"); + int stop_idx = js_object_get(js, tokens, count, step_index, "stop"); + int stepv_idx = js_object_get(js, tokens, count, step_index, "step"); + int dwell_idx = js_object_get(js, tokens, count, step_index, "dwell_s"); + double start, stop, stepv; + if (seq_parse_action_mode_from_name(action, &out_step->mode) != 0 || + start_idx < 0 || stop_idx < 0 || stepv_idx < 0 || dwell_idx < 0 || + js_token_to_double(js, &tokens[start_idx], &start) != 0 || + js_token_to_double(js, &tokens[stop_idx], &stop) != 0 || + js_token_to_double(js, &tokens[stepv_idx], &stepv) != 0 || + js_token_to_double(js, &tokens[dwell_idx], &out_step->dwell_s) != 0 || + seq_double_to_milli(start, &out_step->ramp_start) != 0 || + seq_double_to_milli(stop, &out_step->ramp_stop) != 0 || + seq_double_to_milli(stepv, &out_step->ramp_step) != 0 || + out_step->ramp_step == 0 || out_step->dwell_s < 0.0) { + seq_set_error(error_text, error_size, "%s needs start/stop/step/dwell_s", action); + return -1; + } + out_step->kind = MW_SEQ_ACT_RAMP; + return 0; + } + + if (strcmp(action, "repeat") == 0) { + int times_idx = js_object_get(js, tokens, count, step_index, "times"); + int steps_idx = js_object_get(js, tokens, count, step_index, "steps"); + if (times_idx < 0 || js_token_to_int(js, &tokens[times_idx], &out_step->repeat_times) != 0 || + out_step->repeat_times < 0) { + seq_set_error(error_text, error_size, "repeat needs times >= 0"); + return -1; + } + if (seq_parse_step_array(js, tokens, count, steps_idx, &out_step->children, &out_step->child_count, + error_text, error_size) != 0) { + return -1; + } + out_step->kind = MW_SEQ_ACT_REPEAT; + return 0; + } + + if (strcmp(action, "repeat_until") == 0) { + int cond_idx = js_object_get(js, tokens, count, step_index, "condition"); + int steps_idx = js_object_get(js, tokens, count, step_index, "steps"); + int timeout_idx = js_object_get(js, tokens, count, step_index, "timeout_s"); + if (seq_parse_condition(js, tokens, count, cond_idx, &out_step->condition, error_text, error_size) != 0) { + return -1; + } + out_step->has_condition = 1; + if (timeout_idx >= 0 && + (js_token_to_double(js, &tokens[timeout_idx], &out_step->timeout_s) != 0 || out_step->timeout_s < 0.0)) { + seq_set_error(error_text, error_size, "repeat_until timeout_s must be >= 0"); + return -1; + } + if (seq_parse_step_array(js, tokens, count, steps_idx, &out_step->children, &out_step->child_count, + error_text, error_size) != 0) { + return -1; + } + out_step->kind = MW_SEQ_ACT_REPEAT_UNTIL; + return 0; + } + + if (strcmp(action, "repeat_while") == 0) { + int cond_idx = js_object_get(js, tokens, count, step_index, "condition"); + int steps_idx = js_object_get(js, tokens, count, step_index, "steps"); + int timeout_idx = js_object_get(js, tokens, count, step_index, "timeout_s"); + if (seq_parse_condition(js, tokens, count, cond_idx, &out_step->condition, error_text, error_size) != 0) { + return -1; + } + out_step->has_condition = 1; + if (timeout_idx >= 0 && + (js_token_to_double(js, &tokens[timeout_idx], &out_step->timeout_s) != 0 || out_step->timeout_s < 0.0)) { + seq_set_error(error_text, error_size, "repeat_while timeout_s must be >= 0"); + return -1; + } + if (seq_parse_step_array(js, tokens, count, steps_idx, &out_step->children, &out_step->child_count, + error_text, error_size) != 0) { + return -1; + } + out_step->kind = MW_SEQ_ACT_REPEAT_WHILE; + return 0; + } + + seq_set_error(error_text, error_size, "unsupported action: %s", action); + return -1; +} + +static int seq_parse_step_array(const char *js, const jt_token *tokens, int count, int array_index, + mw_seq_step **out_steps, size_t *out_step_count, + char *error_text, size_t error_size) { + int idx; + mw_seq_step *steps; + size_t n; + if (!out_steps || !out_step_count || array_index < 0 || tokens[array_index].type != JT_ARRAY || + tokens[array_index].size <= 0) { + seq_set_error(error_text, error_size, "sequence block needs non-empty steps array"); + return -1; + } + n = (size_t)tokens[array_index].size; + steps = calloc(n, sizeof(*steps)); + if (!steps) { + seq_set_error(error_text, error_size, "out of memory allocating steps"); + return -1; + } + idx = array_index + 1; + for (size_t i = 0; i < n; ++i) { + if (seq_parse_step(js, tokens, count, idx, &steps[i], error_text, error_size) != 0) { + seq_free_steps(steps, n); + return -1; + } + idx = js_skip(tokens, count, idx); + } + *out_steps = steps; + *out_step_count = n; + return 0; +} + +static int seq_parse_json_text(const char *js, mw_sequence *out_seq, char *error_text, size_t error_size) { + jt_parser parser; + jt_token *tokens = NULL; + int token_count; + int root_idx = 0; + int i; + int steps_idx; + int abort_idx; + + memset(out_seq, 0, sizeof(*out_seq)); + out_seq->sample_period_ms = 500; + out_seq->safety.abort_on_disconnect = 1; + + tokens = calloc(2048, sizeof(*tokens)); + if (!tokens) { + seq_set_error(error_text, error_size, "out of memory"); + return -1; + } + jt_init(&parser, js, strlen(js)); + token_count = jt_parse(&parser, tokens, 2048); + if (token_count < 1 || tokens[root_idx].type != JT_OBJECT) { + free(tokens); + seq_set_error(error_text, error_size, "invalid JSON sequence file"); + return -1; + } + + i = js_object_get(js, tokens, token_count, root_idx, "name"); + if (i >= 0) { + js_token_to_string(js, &tokens[i], out_seq->name, sizeof(out_seq->name)); + } + i = js_object_get(js, tokens, token_count, root_idx, "sample_period_ms"); + if (i >= 0) { + js_token_to_int(js, &tokens[i], &out_seq->sample_period_ms); + if (out_seq->sample_period_ms <= 0) { + out_seq->sample_period_ms = 500; + } + } + i = js_object_get(js, tokens, token_count, root_idx, "safety"); + if (i >= 0 && tokens[i].type == JT_OBJECT) { + int v; + int b; + double d; + v = js_object_get(js, tokens, token_count, i, "max_voltage"); + if (v >= 0 && js_token_to_double(js, &tokens[v], &d) == 0) { + seq_double_to_milli(d, &out_seq->safety.max_voltage_mv); + } + v = js_object_get(js, tokens, token_count, i, "max_current"); + if (v >= 0 && js_token_to_double(js, &tokens[v], &d) == 0) { + seq_double_to_milli(d, &out_seq->safety.max_current_ma); + } + v = js_object_get(js, tokens, token_count, i, "max_power"); + if (v >= 0 && js_token_to_double(js, &tokens[v], &d) == 0) { + seq_double_to_milli(d, &out_seq->safety.max_power_mw); + } + v = js_object_get(js, tokens, token_count, i, "abort_on_disconnect"); + if (v >= 0 && js_token_to_bool(js, &tokens[v], &b) == 0) { + out_seq->safety.abort_on_disconnect = b; + } + } + + steps_idx = js_object_get(js, tokens, token_count, root_idx, "steps"); + if (seq_parse_step_array(js, tokens, token_count, steps_idx, &out_seq->steps, &out_seq->step_count, + error_text, error_size) != 0) { + free(tokens); + return -1; + } + + abort_idx = js_object_get(js, tokens, token_count, root_idx, "abort_sequence"); + if (seq_parse_step_array(js, tokens, token_count, abort_idx, &out_seq->abort_steps, &out_seq->abort_step_count, + error_text, error_size) != 0) { + seq_set_error(error_text, error_size, "sequence needs non-empty abort_sequence array"); + free(tokens); + seq_free(out_seq); + return -1; + } + + free(tokens); + return 0; +} + +static int seq_read_text_file(const char *path, char **out_text, char *error_text, size_t error_size) { + FILE *fp; + long size; + char *buf; + size_t got; + if (!path || !out_text) { + seq_set_error(error_text, error_size, "invalid sequence path"); + return -1; + } + *out_text = NULL; + fp = fopen(path, "rb"); + if (!fp) { + seq_set_error(error_text, error_size, "cannot open sequence file: %s", path); + return -1; + } + if (fseek(fp, 0, SEEK_END) != 0 || (size = ftell(fp)) < 0 || fseek(fp, 0, SEEK_SET) != 0) { + fclose(fp); + seq_set_error(error_text, error_size, "cannot size sequence file: %s", path); + return -1; + } + buf = calloc((size_t)size + 1, 1); + if (!buf) { + fclose(fp); + seq_set_error(error_text, error_size, "out of memory reading sequence file"); + return -1; + } + got = fread(buf, 1, (size_t)size, fp); + fclose(fp); + if (got != (size_t)size) { + free(buf); + seq_set_error(error_text, error_size, "failed to read sequence file: %s", path); + return -1; + } + buf[size] = '\0'; + *out_text = buf; + return 0; +} + +static int seq_check_limits(const mw_seq_safety *safety, const mw_report *report, + char *error_text, size_t error_size) { + uint32_t power_mw; + if (!safety || !report) { + return SEQ_EXEC_OK; + } + power_mw = mw_report_power_mw(report); + if (safety->max_current_ma && report->current_ma > safety->max_current_ma) { + seq_set_error(error_text, error_size, "safety abort: current %.3f A exceeds limit %.3f A", + report->current_ma / 1000.0, safety->max_current_ma / 1000.0); + return SEQ_EXEC_ABORT; + } + if (safety->max_voltage_mv && report->voltage_mv > safety->max_voltage_mv) { + seq_set_error(error_text, error_size, "safety abort: voltage %.3f V exceeds limit %.3f V", + report->voltage_mv / 1000.0, safety->max_voltage_mv / 1000.0); + return SEQ_EXEC_ABORT; + } + if (safety->max_power_mw && power_mw > safety->max_power_mw) { + seq_set_error(error_text, error_size, "safety abort: power %.3f W exceeds limit %.3f W", + power_mw / 1000.0, safety->max_power_mw / 1000.0); + return SEQ_EXEC_ABORT; + } + return SEQ_EXEC_OK; +} + +static int seq_log_report(mw_sequence_exec *exec, const char *context, long step_index, const mw_report *report) { + if (!exec || !report) { + return -1; + } + exec->result->last_report = *report; + exec->result->last_report_valid = true; + if (exec->logger) { + if (mw_csv_logger_write(exec->logger, context, step_index, report) != 0) { + return -1; + } + exec->result->samples_written++; + } + return 0; +} + +static int seq_push_step_break(mw_sequence_exec *exec, const mw_seq_step *step, + char *error_text, size_t error_size) { + if (!exec || !step || !step->has_break_if) { + return SEQ_EXEC_OK; + } + if (exec->break_stack_count >= (sizeof(exec->break_stack) / sizeof(exec->break_stack[0]))) { + seq_set_error(error_text, error_size, "break_if nesting too deep"); + return SEQ_EXEC_FAIL; + } + exec->break_stack[exec->break_stack_count++] = &step->break_if; + return SEQ_EXEC_OK; +} + +static void seq_pop_step_break(mw_sequence_exec *exec, const mw_seq_step *step) { + if (!exec || !step || !step->has_break_if || exec->break_stack_count == 0) { + return; + } + exec->break_stack_count--; +} + +static int seq_check_active_breaks(mw_sequence_exec *exec, const mw_report *report, + char *error_text, size_t error_size) { + char cond_text[80]; + if (!exec || !report || exec->in_abort_sequence) { + return SEQ_EXEC_OK; + } + for (size_t i = 0; i < exec->break_stack_count; ++i) { + const mw_seq_condition *cond = exec->break_stack[i]; + if (cond && seq_condition_met(cond, report)) { + seq_condition_to_text(cond, cond_text, sizeof(cond_text)); + seq_set_error(error_text, error_size, "break_if triggered: %s", cond_text); + return SEQ_EXEC_ABORT; + } + } + return SEQ_EXEC_OK; +} + +static int seq_after_report(mw_sequence_exec *exec, const char *context, long step_index, + const mw_report *report, char *error_text, size_t error_size) { + int rc; + if (seq_log_report(exec, context, step_index, report) != 0) { + seq_set_error(error_text, error_size, "failed to write CSV log"); + return SEQ_EXEC_FAIL; + } + if (exec->in_abort_sequence) { + return SEQ_EXEC_OK; + } + rc = seq_check_limits(&exec->seq->safety, report, error_text, error_size); + if (rc != SEQ_EXEC_OK) { + return rc; + } + return seq_check_active_breaks(exec, report, error_text, error_size); +} + + +static int seq_after_unlogged_report(mw_sequence_exec *exec, + const mw_report *report, + char *error_text, + size_t error_size) { + int rc; + if (!exec || !report) { + seq_set_error(error_text, error_size, "invalid unlogged report context"); + return SEQ_EXEC_FAIL; + } + exec->result->last_report = *report; + exec->result->last_report_valid = true; + if (exec->in_abort_sequence) { + return SEQ_EXEC_OK; + } + rc = seq_check_limits(&exec->seq->safety, report, error_text, error_size); + if (rc != SEQ_EXEC_OK) { + return rc; + } + return seq_check_active_breaks(exec, report, error_text, error_size); +} + +static int seq_get_and_log_report(mw_sequence_exec *exec, const char *context, long step_index, + mw_report *out_report, char *error_text, size_t error_size) { + mw_report report; + if (mw_app_get_report(exec->app, &report) != 0) { + seq_set_error(error_text, error_size, "%s", mw_app_last_error(exec->app)); + return SEQ_EXEC_FAIL; + } + if (out_report) { + *out_report = report; + } + return seq_after_report(exec, context, step_index, &report, error_text, error_size); +} + +static int seq_apply_output_state(mw_sequence_exec *exec, int enabled, long step_index, + char *error_text, size_t error_size) { + mw_report report; + int rc; + if (enabled) { + if (!exec->staged_valid) { + seq_set_error(error_text, error_size, "output enabled without staged target"); + return SEQ_EXEC_FAIL; + } + if (mw_app_load_on(exec->app, exec->staged_mode, exec->staged_target, &report) != 0) { + seq_set_error(error_text, error_size, "%s", mw_app_last_error(exec->app)); + return SEQ_EXEC_FAIL; + } + exec->output_enabled = 1; + rc = seq_after_report(exec, "output_on", step_index, &report, error_text, error_size); + if (rc != SEQ_EXEC_OK) { + return rc; + } + } else { + if (mw_app_load_off(exec->app, &report) != 0) { + seq_set_error(error_text, error_size, "%s", mw_app_last_error(exec->app)); + return SEQ_EXEC_FAIL; + } + exec->output_enabled = 0; + rc = seq_after_report(exec, "output_off", step_index, &report, error_text, error_size); + if (rc != SEQ_EXEC_OK) { + return rc; + } + } + return SEQ_EXEC_OK; +} + +static int seq_set_staged_target(mw_sequence_exec *exec, mw_mode mode, uint32_t milli_units, + long step_index, char *error_text, size_t error_size) { + mw_report report; + int rc; + exec->staged_mode = mode; + exec->staged_target = milli_units; + exec->staged_valid = 1; + if (exec->output_enabled) { + if (mw_app_load_on(exec->app, mode, milli_units, &report) != 0) { + seq_set_error(error_text, error_size, "%s", mw_app_last_error(exec->app)); + return SEQ_EXEC_FAIL; + } + rc = seq_after_report(exec, "set_target", step_index, &report, error_text, error_size); + if (rc != SEQ_EXEC_OK) { + return rc; + } + } + return SEQ_EXEC_OK; +} + +static int seq_poll_for_duration(mw_sequence_exec *exec, double duration_s, const char *context, + long step_index, char *error_text, size_t error_size) { + struct timespec t0; + double next_log_s = 0.0; + int keepalive_ms; + if (!exec) { + seq_set_error(error_text, error_size, "invalid duration polling context"); + return SEQ_EXEC_FAIL; + } + keepalive_ms = seq_effective_keepalive_ms(exec); + clock_gettime(CLOCK_MONOTONIC, &t0); + for (;;) { + struct timespec now; + double elapsed; + int rc; + int should_log; + mw_report report; + + if (mw_app_get_report(exec->app, &report) != 0) { + seq_set_error(error_text, error_size, "%s", mw_app_last_error(exec->app)); + return SEQ_EXEC_FAIL; + } + clock_gettime(CLOCK_MONOTONIC, &now); + elapsed = seq_elapsed_s(&t0, &now); + should_log = (elapsed + 1e-9 >= next_log_s) || (elapsed + 1e-9 >= duration_s); + if (should_log) { + rc = seq_after_report(exec, context, step_index, &report, error_text, error_size); + next_log_s += exec->seq->sample_period_ms / 1000.0; + } else { + rc = seq_after_unlogged_report(exec, &report, error_text, error_size); + } + if (rc != SEQ_EXEC_OK) { + return rc; + } + if (elapsed >= duration_s) { + break; + } + if (seq_sleep_ms(keepalive_ms) != 0) { + seq_set_error(error_text, error_size, "sleep interrupted"); + return SEQ_EXEC_FAIL; + } + } + return SEQ_EXEC_OK; +} + +static int seq_poll_until(mw_sequence_exec *exec, const mw_seq_condition *cond, double timeout_s, + long step_index, char *error_text, size_t error_size) { + struct timespec t0; + double next_log_s = 0.0; + int keepalive_ms; + if (!exec || !cond) { + seq_set_error(error_text, error_size, "invalid hold_until context"); + return SEQ_EXEC_FAIL; + } + keepalive_ms = seq_effective_keepalive_ms(exec); + clock_gettime(CLOCK_MONOTONIC, &t0); + for (;;) { + struct timespec now; + double elapsed; + int rc; + int should_log; + mw_report report; + + if (mw_app_get_report(exec->app, &report) != 0) { + seq_set_error(error_text, error_size, "%s", mw_app_last_error(exec->app)); + return SEQ_EXEC_FAIL; + } + clock_gettime(CLOCK_MONOTONIC, &now); + elapsed = seq_elapsed_s(&t0, &now); + should_log = (elapsed + 1e-9 >= next_log_s); + if (should_log) { + rc = seq_after_report(exec, "hold_until", step_index, &report, error_text, error_size); + next_log_s += exec->seq->sample_period_ms / 1000.0; + } else { + rc = seq_after_unlogged_report(exec, &report, error_text, error_size); + } + if (rc != SEQ_EXEC_OK) { + return rc; + } + if (seq_condition_met(cond, &report)) { + return SEQ_EXEC_OK; + } + if (timeout_s > 0.0 && elapsed >= timeout_s) { + seq_set_error(error_text, error_size, "hold_until timed out after %.3f s", timeout_s); + return SEQ_EXEC_ABORT; + } + if (seq_sleep_ms(keepalive_ms) != 0) { + seq_set_error(error_text, error_size, "sleep interrupted"); + return SEQ_EXEC_FAIL; + } + } +} + +static int seq_log_loop_condition(mw_sequence_exec *exec, const char *context, long step_index, + mw_report *out_report, char *error_text, size_t error_size) { + return seq_get_and_log_report(exec, context, step_index, out_report, error_text, error_size); +} + +static int seq_run_steps(mw_sequence_exec *exec, const mw_seq_step *steps, size_t count, + size_t *completed_out, char *error_text, size_t error_size); + +static int seq_run_step(mw_sequence_exec *exec, const mw_seq_step *step, + char *error_text, size_t error_size) { + long step_index; + int rc; + if (!exec || !step) { + seq_set_error(error_text, error_size, "invalid step execution context"); + return SEQ_EXEC_FAIL; + } + + step_index = exec->exec_step_counter++; + rc = seq_push_step_break(exec, step, error_text, error_size); + if (rc != SEQ_EXEC_OK) { + return rc; + } + + switch (step->kind) { + case MW_SEQ_ACT_SET_MODE: + exec->staged_mode = step->mode; + rc = SEQ_EXEC_OK; + break; + case MW_SEQ_ACT_SET_TARGET: + rc = seq_set_staged_target(exec, step->mode, step->milli_units, step_index, error_text, error_size); + break; + case MW_SEQ_ACT_OUTPUT: + rc = seq_apply_output_state(exec, step->enabled, step_index, error_text, error_size); + break; + case MW_SEQ_ACT_HOLD: + rc = seq_poll_for_duration(exec, step->duration_s, "hold", step_index, error_text, error_size); + break; + case MW_SEQ_ACT_HOLD_UNTIL: + rc = seq_poll_until(exec, &step->condition, step->timeout_s, step_index, error_text, error_size); + break; + case MW_SEQ_ACT_SAFE: { + mw_report report; + if (mw_app_safe(exec->app, &report) != 0) { + seq_set_error(error_text, error_size, "%s", mw_app_last_error(exec->app)); + rc = SEQ_EXEC_FAIL; + } else { + exec->output_enabled = 0; + exec->staged_mode = MW_MODE_CURRENT; + exec->staged_target = 0; + exec->staged_valid = 1; + rc = seq_after_report(exec, exec->in_abort_sequence ? "abort_safe" : "safe", + step_index, &report, error_text, error_size); + } + break; + } + case MW_SEQ_ACT_REMOTE: { + mw_report report; + if (mw_app_set_remote(exec->app, step->enabled ? true : false, &report) != 0) { + seq_set_error(error_text, error_size, "%s", mw_app_last_error(exec->app)); + rc = SEQ_EXEC_FAIL; + } else { + rc = seq_after_report(exec, step->enabled ? "remote_on" : "remote_off", + step_index, &report, error_text, error_size); + } + break; + } + case MW_SEQ_ACT_RAMP: { + uint32_t value = step->ramp_start; + int ascending = step->ramp_stop >= step->ramp_start; + rc = SEQ_EXEC_OK; + for (;;) { + rc = seq_set_staged_target(exec, step->mode, value, step_index, error_text, error_size); + if (rc != SEQ_EXEC_OK) { + break; + } + if (!exec->output_enabled) { + rc = seq_apply_output_state(exec, 1, step_index, error_text, error_size); + if (rc != SEQ_EXEC_OK) { + break; + } + } + rc = seq_poll_for_duration(exec, step->dwell_s, "ramp", step_index, error_text, error_size); + if (rc != SEQ_EXEC_OK) { + break; + } + if (value == step->ramp_stop) { + break; + } + if (ascending) { + if (value + step->ramp_step >= step->ramp_stop) { + value = step->ramp_stop; + } else { + value += step->ramp_step; + } + } else { + if (value <= step->ramp_step || value - step->ramp_step <= step->ramp_stop) { + value = step->ramp_stop; + } else { + value -= step->ramp_step; + } + } + } + break; + } + case MW_SEQ_ACT_REPEAT: { + rc = SEQ_EXEC_OK; + for (int n = 0; n < step->repeat_times; ++n) { + rc = seq_run_steps(exec, step->children, step->child_count, NULL, error_text, error_size); + if (rc != SEQ_EXEC_OK) { + break; + } + } + break; + } + case MW_SEQ_ACT_REPEAT_UNTIL: { + struct timespec t0; + rc = SEQ_EXEC_OK; + clock_gettime(CLOCK_MONOTONIC, &t0); + for (;;) { + struct timespec now; + double elapsed; + mw_report report; + rc = seq_run_steps(exec, step->children, step->child_count, NULL, error_text, error_size); + if (rc != SEQ_EXEC_OK) { + break; + } + rc = seq_log_loop_condition(exec, "repeat_until_check", step_index, &report, error_text, error_size); + if (rc != SEQ_EXEC_OK) { + break; + } + if (seq_condition_met(&step->condition, &report)) { + rc = SEQ_EXEC_OK; + break; + } + if (step->timeout_s > 0.0) { + clock_gettime(CLOCK_MONOTONIC, &now); + elapsed = (double)(now.tv_sec - t0.tv_sec) + (double)(now.tv_nsec - t0.tv_nsec) / 1000000000.0; + if (elapsed >= step->timeout_s) { + seq_set_error(error_text, error_size, "repeat_until timed out after %.3f s", step->timeout_s); + rc = SEQ_EXEC_ABORT; + break; + } + } + } + break; + } + case MW_SEQ_ACT_REPEAT_WHILE: { + struct timespec t0; + rc = SEQ_EXEC_OK; + clock_gettime(CLOCK_MONOTONIC, &t0); + for (;;) { + struct timespec now; + double elapsed; + mw_report report; + rc = seq_log_loop_condition(exec, "repeat_while_check", step_index, &report, error_text, error_size); + if (rc != SEQ_EXEC_OK) { + break; + } + if (!seq_condition_met(&step->condition, &report)) { + rc = SEQ_EXEC_OK; + break; + } + rc = seq_run_steps(exec, step->children, step->child_count, NULL, error_text, error_size); + if (rc != SEQ_EXEC_OK) { + break; + } + if (step->timeout_s > 0.0) { + clock_gettime(CLOCK_MONOTONIC, &now); + elapsed = (double)(now.tv_sec - t0.tv_sec) + (double)(now.tv_nsec - t0.tv_nsec) / 1000000000.0; + if (elapsed >= step->timeout_s) { + seq_set_error(error_text, error_size, "repeat_while timed out after %.3f s", step->timeout_s); + rc = SEQ_EXEC_ABORT; + break; + } + } + } + break; + } + default: + seq_set_error(error_text, error_size, "unsupported step kind"); + rc = SEQ_EXEC_FAIL; + break; + } + + seq_pop_step_break(exec, step); + return rc; +} + +static int seq_run_steps(mw_sequence_exec *exec, const mw_seq_step *steps, size_t count, + size_t *completed_out, char *error_text, size_t error_size) { + size_t completed = 0; + int rc = SEQ_EXEC_OK; + for (size_t i = 0; i < count; ++i) { + rc = seq_run_step(exec, &steps[i], error_text, error_size); + if (rc != SEQ_EXEC_OK) { + break; + } + completed++; + } + if (completed_out) { + *completed_out = completed; + } + return rc; +} + +static int seq_run_abort_sequence(mw_sequence_exec *exec, char *error_text, size_t error_size) { + size_t completed = 0; + int saved_abort = exec->in_abort_sequence; + int rc; + exec->in_abort_sequence = 1; + rc = seq_run_steps(exec, exec->seq->abort_steps, exec->seq->abort_step_count, &completed, error_text, error_size); + exec->in_abort_sequence = saved_abort; + exec->result->abort_sequence_ran = true; + exec->result->abort_steps_completed = completed; + return rc; +} + +int mw_sequence_run_file(mw_app *app, + const char *json_path, + const mw_sequence_run_options *options, + mw_sequence_result *out_result, + char *error_text, + size_t error_text_size) { + char *json_text = NULL; + mw_sequence seq; + mw_csv_logger *logger = NULL; + mw_sequence_exec exec; + mw_sequence_result result; + int main_rc = SEQ_EXEC_FAIL; + int final_rc = -1; + + if (error_text && error_text_size > 0) { + error_text[0] = '\0'; + } + if (!app || !json_path || !out_result) { + seq_set_error(error_text, error_text_size, "invalid arguments to sequence runner"); + return -1; + } + memset(&result, 0, sizeof(result)); + memset(&seq, 0, sizeof(seq)); + + if (seq_read_text_file(json_path, &json_text, error_text, error_text_size) != 0) { + return -1; + } + if (seq_parse_json_text(json_text, &seq, error_text, error_text_size) != 0) { + goto done; + } + if (options && options->sample_period_ms_override > 0) { + seq.sample_period_ms = options->sample_period_ms_override; + } + if (options && options->csv_path && *options->csv_path) { + if (mw_csv_logger_open(&logger, options->csv_path, options->csv_units_mode) != 0) { + seq_set_error(error_text, error_text_size, "failed to open CSV log: %s", options->csv_path); + goto done; + } + } + + memset(&exec, 0, sizeof(exec)); + exec.app = app; + exec.seq = &seq; + exec.logger = logger; + exec.result = &result; + exec.staged_mode = MW_MODE_CURRENT; + + if (seq.name[0]) { + snprintf(result.name, sizeof(result.name), "%s", seq.name); + } + result.sample_period_ms = seq.sample_period_ms; + result.steps_total = seq.step_count; + + main_rc = seq_run_steps(&exec, seq.steps, seq.step_count, &result.steps_completed, error_text, error_text_size); + if (main_rc != SEQ_EXEC_OK) { + result.aborted = true; + } + + if (seq_run_abort_sequence(&exec, error_text, error_text_size) != SEQ_EXEC_OK) { + if (options && options->safe_on_abort) { + mw_report safe_report; + if (mw_app_safe(app, &safe_report) == 0) { + exec.in_abort_sequence = 1; + seq_log_report(&exec, "safe_fallback", -1, &safe_report); + exec.in_abort_sequence = 0; + } + } + goto done; + } + + if (main_rc == SEQ_EXEC_OK) { + final_rc = 0; + } else { + final_rc = -1; + } + +done: + *out_result = result; + mw_csv_logger_close(logger); + seq_free(&seq); + free(json_text); + return final_rc; +} diff --git a/src/mwcli.c b/src/mwcli.c new file mode 100644 index 0000000..24792b2 --- /dev/null +++ b/src/mwcli.c @@ -0,0 +1,745 @@ +#define _DEFAULT_SOURCE +#define _POSIX_C_SOURCE 200809L +#include "mightywatt_app.h" +#include "mightywatt_log.h" +#include "mightywatt_sequence.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +static volatile sig_atomic_t g_stop_requested = 0; + +static void handle_signal(int signo) { + (void)signo; + g_stop_requested = 1; +} + +static void usage(FILE *fp) { + fprintf(fp, + "mwcli - MightyWatt Linux CLI\n" + "\n" + "Usage:\n" + " mwcli -d /dev/ttyACM0 [options] [args]\n" + "\n" + "Options:\n" + " -d, --device PATH Serial device\n" + " -s, --settle-ms N Delay after open (default 2200)\n" + " -i, --interval-ms N Poll interval for monitor/hold (default 500)\n" + " -c, --count N Number of samples for monitor/hold\n" + " --sample-period-ms N Override sequence sample period\n" + " --csv PATH Write measurement samples as CSV\n" + " -j, --json JSON stdout for non-streaming commands\n" + " -h, --help Show this help\n" + "\n" + "Commands:\n" + " idn\n" + " caps\n" + " report\n" + " monitor\n" + " set-current \n" + " set-voltage \n" + " set-power \n" + " set-resistance \n" + " set-vinv \n" + " hold \n" + " load-on \n" + " load-off\n" + " safe\n" + " remote on|off\n" + " get-series\n" + " set-series \n" + " run-sequence \n" + "\n" + "Examples:\n" + " mwcli -d /dev/ttyACM0 caps\n" + " mwcli -d /dev/ttyACM0 --csv out.csv monitor --count 20\n" + " mwcli -d /dev/ttyACM0 hold current 0.500 --interval-ms 500\n" + " mwcli -d /dev/ttyACM0 --csv run.csv run-sequence profile.json\n"); +} + +static int parse_double_arg(const char *text, double *out) { + char *end = NULL; + double value; + errno = 0; + value = strtod(text, &end); + if (errno != 0 || end == text || *end != '\0') { + return -1; + } + *out = value; + return 0; +} + +static int round_to_uint32(double input, uint32_t *out) { + if (input < 0.0 || input > 4294967295.0) { + return -1; + } + *out = (uint32_t)(input + 0.5); + return 0; +} + +static int unit_text_to_milli(const char *text, uint32_t *out) { + double value; + if (parse_double_arg(text, &value) != 0) { + return -1; + } + return round_to_uint32(value * 1000.0, out); +} + +static int parse_mode_text(const char *text, mw_mode *mode) { + if (!text || !mode) { + return -1; + } + if (strcasecmp(text, "current") == 0 || strcasecmp(text, "CC") == 0) { + *mode = MW_MODE_CURRENT; + } else if (strcasecmp(text, "voltage") == 0 || strcasecmp(text, "CV") == 0) { + *mode = MW_MODE_VOLTAGE; + } else if (strcasecmp(text, "power") == 0 || strcasecmp(text, "CP") == 0) { + *mode = MW_MODE_POWER; + } else if (strcasecmp(text, "resistance") == 0 || strcasecmp(text, "CR") == 0) { + *mode = MW_MODE_RESISTANCE; + } else if (strcasecmp(text, "vinv") == 0 || strcasecmp(text, "voltage_inverted") == 0) { + *mode = MW_MODE_VOLTAGE_INVERTED; + } else { + return -1; + } + return 0; +} + +static void json_print_escaped(const char *text) { + const unsigned char *p = (const unsigned char *)text; + putchar('"'); + while (p && *p) { + switch (*p) { + case '"': fputs("\\\"", stdout); break; + case '\\': fputs("\\\\", stdout); break; + case '\b': fputs("\\b", stdout); break; + case '\f': fputs("\\f", stdout); break; + case '\n': fputs("\\n", stdout); break; + case '\r': fputs("\\r", stdout); break; + case '\t': fputs("\\t", stdout); break; + default: + if (*p < 0x20u) { + printf("\\u%04x", (unsigned)*p); + } else { + putchar((int)*p); + } + } + ++p; + } + putchar('"'); +} + +static void print_report_text(const mw_report *r) { + char status[128]; + mw_status_string(r->status, status, sizeof(status)); + printf("current=%.3f A\n", r->current_ma / 1000.0); + printf("voltage=%.3f V\n", r->voltage_mv / 1000.0); + printf("power=%.3f W\n", mw_report_power_mw(r) / 1000.0); + printf("temperature=%u C\n", (unsigned)r->temperature_c); + printf("remote=%s\n", r->remote ? "on" : "off"); + printf("status=%s\n", status); +} + +static void print_report_json(const mw_report *r, const char *command) { + char status[128]; + mw_status_string(r->status, status, sizeof(status)); + printf("{"); + printf("\"ok\":true,"); + printf("\"command\":"); json_print_escaped(command); + printf(",\"report\":{"); + printf("\"current_a\":%.3f,", r->current_ma / 1000.0); + printf("\"voltage_v\":%.3f,", r->voltage_mv / 1000.0); + printf("\"power_w\":%.3f,", mw_report_power_mw(r) / 1000.0); + printf("\"temperature_c\":%u,", (unsigned)r->temperature_c); + printf("\"remote\":%s,", r->remote ? "true" : "false"); + printf("\"status_bits\":%u,", (unsigned)r->status); + printf("\"status_text\":"); json_print_escaped(status); + printf("}}\n"); +} + +static void print_caps_text(const mw_capabilities *c) { + printf("firmware=%s\n", c->firmware_version); + printf("board_revision=%s\n", c->board_revision); + printf("max_current_dac=%.3f A\n", c->max_current_dac_ma / 1000.0); + printf("max_current_adc=%.3f A\n", c->max_current_adc_ma / 1000.0); + printf("max_voltage_dac=%.3f V\n", c->max_voltage_dac_mv / 1000.0); + printf("max_voltage_adc=%.3f V\n", c->max_voltage_adc_mv / 1000.0); + printf("max_power=%.3f W\n", c->max_power_mw / 1000.0); + printf("dvm_input_resistance=%u ohm\n", c->dvm_input_resistance_ohm); + printf("temperature_threshold=%u C\n", c->temperature_threshold_c); +} + +static void print_caps_json(const mw_capabilities *c) { + printf("{"); + printf("\"ok\":true,"); + printf("\"command\":\"caps\","); + printf("\"capabilities\":{"); + printf("\"firmware\":"); json_print_escaped(c->firmware_version); + printf(",\"board_revision\":"); json_print_escaped(c->board_revision); + printf(",\"max_current_dac_a\":%.3f", c->max_current_dac_ma / 1000.0); + printf(",\"max_current_adc_a\":%.3f", c->max_current_adc_ma / 1000.0); + printf(",\"max_voltage_dac_v\":%.3f", c->max_voltage_dac_mv / 1000.0); + printf(",\"max_voltage_adc_v\":%.3f", c->max_voltage_adc_mv / 1000.0); + printf(",\"max_power_w\":%.3f", c->max_power_mw / 1000.0); + printf(",\"dvm_input_resistance_ohm\":%u", c->dvm_input_resistance_ohm); + printf(",\"temperature_threshold_c\":%u", c->temperature_threshold_c); + printf("}}\n"); +} + +static void print_error_json(const char *command, const char *error_text) { + printf("{"); + printf("\"ok\":false,"); + printf("\"command\":"); json_print_escaped(command ? command : "unknown"); + printf(",\"error\":"); json_print_escaped(error_text ? error_text : "unknown error"); + printf("}\n"); +} + +static int log_report_if_requested(mw_csv_logger *logger, const char *context, const mw_report *report) { + if (!logger || !report) { + return 0; + } + return mw_csv_logger_write(logger, context, -1, report); +} + +static int stream_reports(mw_app *app, + int interval_ms, + long count, + int json_output, + const char *command_name, + mw_csv_logger *logger) { + long i = 0; + const int keepalive_ms = 250; + const int maintain_output = (command_name && strcmp(command_name, "hold") == 0); + int effective_interval_ms = interval_ms > 0 ? interval_ms : 500; + int poll_ms = maintain_output && effective_interval_ms > keepalive_ms ? keepalive_ms : effective_interval_ms; + struct timespec t0; + double next_print_s = 0.0; + + clock_gettime(CLOCK_MONOTONIC, &t0); + while (!g_stop_requested && (count < 0 || i < count)) { + mw_report r; + struct timespec now_mono; + double elapsed_s; + int should_print; + time_t now = time(NULL); + struct tm tm_now; + char ts[32]; + + if (mw_app_get_report(app, &r) != 0) { + return -1; + } + clock_gettime(CLOCK_MONOTONIC, &now_mono); + elapsed_s = (double)(now_mono.tv_sec - t0.tv_sec) + (double)(now_mono.tv_nsec - t0.tv_nsec) / 1000000000.0; + should_print = (!maintain_output) || (elapsed_s + 1e-9 >= next_print_s); + if (should_print) { + if (logger && mw_csv_logger_write(logger, command_name ? command_name : "stream", i, &r) != 0) { + return -1; + } + localtime_r(&now, &tm_now); + strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", &tm_now); + if (json_output) { + char status[128]; + mw_status_string(r.status, status, sizeof(status)); + printf("{"); + printf("\"ok\":true,"); + printf("\"command\":"); json_print_escaped(command_name ? command_name : "stream"); + printf(",\"timestamp\":"); json_print_escaped(ts); + printf(",\"report\":{"); + printf("\"current_a\":%.3f,", r.current_ma / 1000.0); + printf("\"voltage_v\":%.3f,", r.voltage_mv / 1000.0); + printf("\"power_w\":%.3f,", mw_report_power_mw(&r) / 1000.0); + printf("\"temperature_c\":%u,", (unsigned)r.temperature_c); + printf("\"remote\":%s,", r.remote ? "true" : "false"); + printf("\"status_bits\":%u,", (unsigned)r.status); + printf("\"status_text\":"); json_print_escaped(status); + printf("}}\n"); + } else { + printf("[%s] I=%.3f A V=%.3f V P=%.3f W T=%u C remote=%s status=0x%02X\n", + ts, + r.current_ma / 1000.0, + r.voltage_mv / 1000.0, + mw_report_power_mw(&r) / 1000.0, + (unsigned)r.temperature_c, + r.remote ? "on" : "off", + (unsigned)r.status); + } + fflush(stdout); + ++i; + next_print_s += effective_interval_ms / 1000.0; + } + if (!g_stop_requested && (count < 0 || i < count)) { + struct timespec ts_sleep; + ts_sleep.tv_sec = poll_ms / 1000; + ts_sleep.tv_nsec = (long)(poll_ms % 1000) * 1000000L; + nanosleep(&ts_sleep, NULL); + } + } + return 0; +} + +int main(int argc, char **argv) { + mw_app *app = NULL; + mw_capabilities caps; + const char *device = NULL; + const char *csv_path = NULL; + mw_csv_units_mode csv_units_mode = MW_CSV_UNITS_ENGINEERING; + int settle_ms = 2200; + int interval_ms = 500; + int sequence_sample_period_ms = 0; + long count = -1; + int json_output = 0; + int opt; + int long_index = 0; + int rc = 1; + const char *command_for_error = NULL; + mw_csv_logger *logger = NULL; + static const struct option options[] = { + {"device", required_argument, 0, 'd'}, + {"settle-ms", required_argument, 0, 's'}, + {"interval-ms", required_argument, 0, 'i'}, + {"count", required_argument, 0, 'c'}, + {"csv", required_argument, 0, 1000}, + {"sample-period-ms", required_argument, 0, 1001}, + {"csv-raw", no_argument, 0, 1002}, + {"json", no_argument, 0, 'j'}, + {"help", no_argument, 0, 'h'}, + {0, 0, 0, 0} + }; + + signal(SIGINT, handle_signal); + signal(SIGTERM, handle_signal); + + while ((opt = getopt_long(argc, argv, "d:s:i:c:jh", options, &long_index)) != -1) { + switch (opt) { + case 'd': device = optarg; break; + case 's': settle_ms = atoi(optarg); break; + case 'i': interval_ms = atoi(optarg); break; + case 'c': count = atol(optarg); break; + case 'j': json_output = 1; break; + case 'h': usage(stdout); return 0; + case 1000: csv_path = optarg; break; + case 1001: sequence_sample_period_ms = atoi(optarg); break; + case 1002: csv_units_mode = MW_CSV_UNITS_RAW; break; + default: usage(stderr); return 2; + } + } + + if (!device || optind >= argc) { + usage(stderr); + return 2; + } + + command_for_error = argv[optind]; + if (mw_app_open(&app, device, settle_ms) != 0) { + if (json_output) { + print_error_json(command_for_error, app ? mw_app_last_error(app) : strerror(errno)); + } else { + fprintf(stderr, "open failed: %s\n", app ? mw_app_last_error(app) : strerror(errno)); + } + mw_app_close(app); + return 1; + } + if (csv_path && *csv_path) { + if (mw_csv_logger_open(&logger, csv_path, csv_units_mode) != 0) { + if (json_output) { + print_error_json(command_for_error, "failed to open CSV output file"); + } else { + fprintf(stderr, "failed to open CSV output file: %s\n", csv_path); + } + goto done; + } + } + + if (mw_app_refresh_capabilities(app, &caps) != 0) { + if (json_output) { + print_error_json(command_for_error, mw_app_last_error(app)); + } else { + fprintf(stderr, "caps refresh failed: %s\n", mw_app_last_error(app)); + } + goto done; + } + + { + const char *cmd = argv[optind++]; + command_for_error = cmd; + + if (strcmp(cmd, "idn") == 0) { + if (json_output) { + printf("{\"ok\":true,\"command\":\"idn\",\"identity\":\"MightyWatt\"}\n"); + } else { + printf("MightyWatt\n"); + } + rc = 0; + } else if (strcmp(cmd, "caps") == 0) { + if (json_output) { + print_caps_json(&caps); + } else { + print_caps_text(&caps); + } + rc = 0; + } else if (strcmp(cmd, "report") == 0) { + mw_report report; + if (mw_app_get_report(app, &report) != 0) { + goto fail; + } + if (log_report_if_requested(logger, "report", &report) != 0) { + if (json_output) { + print_error_json(cmd, "failed to write CSV output"); + } else { + fprintf(stderr, "failed to write CSV output\n"); + } + goto done; + } + if (json_output) { + print_report_json(&report, cmd); + } else { + print_report_text(&report); + } + rc = 0; + } else if (strcmp(cmd, "monitor") == 0) { + if (stream_reports(app, interval_ms, count, json_output, "monitor", logger) != 0) { + goto fail; + } + rc = 0; + } else if (strcmp(cmd, "set-current") == 0 || strcmp(cmd, "set-voltage") == 0 || + strcmp(cmd, "set-power") == 0 || strcmp(cmd, "set-resistance") == 0 || + strcmp(cmd, "set-vinv") == 0) { + mw_mode mode; + uint32_t milli; + mw_report report; + if (optind >= argc) { + if (json_output) { + print_error_json(cmd, "command needs a value"); + } else { + fprintf(stderr, "%s needs a value\n", cmd); + } + goto done; + } + mode = strcmp(cmd, "set-current") == 0 ? MW_MODE_CURRENT : + strcmp(cmd, "set-voltage") == 0 ? MW_MODE_VOLTAGE : + strcmp(cmd, "set-power") == 0 ? MW_MODE_POWER : + strcmp(cmd, "set-resistance") == 0 ? MW_MODE_RESISTANCE : MW_MODE_VOLTAGE_INVERTED; + if (unit_text_to_milli(argv[optind], &milli) != 0) { + if (json_output) { + print_error_json(cmd, "invalid numeric value"); + } else { + fprintf(stderr, "invalid numeric value: %s\n", argv[optind]); + } + goto done; + } + if (mw_app_set_target(app, mode, milli, &report) != 0) { + goto fail; + } + if (log_report_if_requested(logger, cmd, &report) != 0) { + if (json_output) { + print_error_json(cmd, "failed to write CSV output"); + } else { + fprintf(stderr, "failed to write CSV output\n"); + } + goto done; + } + if (json_output) { + print_report_json(&report, cmd); + } else { + print_report_text(&report); + } + rc = 0; + } else if (strcmp(cmd, "hold") == 0) { + mw_mode mode; + uint32_t milli; + mw_report report; + if (optind + 1 >= argc) { + if (json_output) { + print_error_json(cmd, "hold needs "); + } else { + fprintf(stderr, "hold needs \n"); + } + goto done; + } + if (parse_mode_text(argv[optind], &mode) != 0) { + if (json_output) { + print_error_json(cmd, "invalid mode for hold"); + } else { + fprintf(stderr, "invalid mode for hold: %s\n", argv[optind]); + } + goto done; + } + if (unit_text_to_milli(argv[optind + 1], &milli) != 0) { + if (json_output) { + print_error_json(cmd, "invalid numeric value"); + } else { + fprintf(stderr, "invalid numeric value: %s\n", argv[optind + 1]); + } + goto done; + } + if (mw_app_load_on(app, mode, milli, &report) != 0) { + goto fail; + } + if (log_report_if_requested(logger, "hold_start", &report) != 0) { + if (json_output) { + print_error_json(cmd, "failed to write CSV output"); + } else { + fprintf(stderr, "failed to write CSV output\n"); + } + goto done; + } + if (json_output) { + print_report_json(&report, cmd); + } else { + print_report_text(&report); + } + if (stream_reports(app, interval_ms, count, json_output, cmd, logger) != 0) { + goto fail; + } + rc = 0; + } else if (strcmp(cmd, "load-on") == 0) { + mw_mode mode; + uint32_t milli; + mw_report report; + if (optind + 1 >= argc) { + if (json_output) { + print_error_json(cmd, "load-on needs "); + } else { + fprintf(stderr, "load-on needs \n"); + } + goto done; + } + if (parse_mode_text(argv[optind], &mode) != 0) { + if (json_output) { + print_error_json(cmd, "invalid mode for load-on"); + } else { + fprintf(stderr, "invalid mode for load-on: %s\n", argv[optind]); + } + goto done; + } + if (unit_text_to_milli(argv[optind + 1], &milli) != 0) { + if (json_output) { + print_error_json(cmd, "invalid numeric value"); + } else { + fprintf(stderr, "invalid numeric value: %s\n", argv[optind + 1]); + } + goto done; + } + if (mw_app_load_on(app, mode, milli, &report) != 0) { + goto fail; + } + if (log_report_if_requested(logger, cmd, &report) != 0) { + if (json_output) { + print_error_json(cmd, "failed to write CSV output"); + } else { + fprintf(stderr, "failed to write CSV output\n"); + } + goto done; + } + if (json_output) { + print_report_json(&report, cmd); + } else { + print_report_text(&report); + } + rc = 0; + } else if (strcmp(cmd, "load-off") == 0) { + mw_report report; + if (mw_app_load_off(app, &report) != 0) { + goto fail; + } + if (log_report_if_requested(logger, cmd, &report) != 0) { + if (json_output) { + print_error_json(cmd, "failed to write CSV output"); + } else { + fprintf(stderr, "failed to write CSV output\n"); + } + goto done; + } + if (json_output) { + print_report_json(&report, cmd); + } else { + print_report_text(&report); + } + rc = 0; + } else if (strcmp(cmd, "safe") == 0) { + mw_report report; + if (mw_app_safe(app, &report) != 0) { + goto fail; + } + if (log_report_if_requested(logger, cmd, &report) != 0) { + if (json_output) { + print_error_json(cmd, "failed to write CSV output"); + } else { + fprintf(stderr, "failed to write CSV output\n"); + } + goto done; + } + if (json_output) { + print_report_json(&report, cmd); + } else { + print_report_text(&report); + } + rc = 0; + } else if (strcmp(cmd, "remote") == 0) { + mw_report report; + if (optind >= argc) { + if (json_output) { + print_error_json(cmd, "remote needs on or off"); + } else { + fprintf(stderr, "remote needs on or off\n"); + } + goto done; + } + if (strcasecmp(argv[optind], "on") == 0) { + if (mw_app_set_remote(app, 1, &report) != 0) { + goto fail; + } + } else if (strcasecmp(argv[optind], "off") == 0) { + if (mw_app_set_remote(app, 0, &report) != 0) { + goto fail; + } + } else { + if (json_output) { + print_error_json(cmd, "remote expects on or off"); + } else { + fprintf(stderr, "remote expects on or off\n"); + } + goto done; + } + if (log_report_if_requested(logger, cmd, &report) != 0) { + if (json_output) { + print_error_json(cmd, "failed to write CSV output"); + } else { + fprintf(stderr, "failed to write CSV output\n"); + } + goto done; + } + if (json_output) { + print_report_json(&report, cmd); + } else { + print_report_text(&report); + } + rc = 0; + } else if (strcmp(cmd, "get-series") == 0) { + uint16_t mohm; + if (mw_app_get_series_resistance(app, &mohm) != 0) { + goto fail; + } + if (json_output) { + printf("{\"ok\":true,\"command\":\"get-series\",\"series_resistance_ohm\":%.3f}\n", mohm / 1000.0); + } else { + printf("series_resistance=%.3f ohm\n", mohm / 1000.0); + } + rc = 0; + } else if (strcmp(cmd, "set-series") == 0) { + mw_report report; + uint32_t mohm; + if (optind >= argc) { + if (json_output) { + print_error_json(cmd, "set-series needs an ohm value"); + } else { + fprintf(stderr, "set-series needs an ohm value\n"); + } + goto done; + } + if (unit_text_to_milli(argv[optind], &mohm) != 0 || mohm > 65535u) { + if (json_output) { + print_error_json(cmd, "invalid series resistance value"); + } else { + fprintf(stderr, "invalid series resistance value: %s\n", argv[optind]); + } + goto done; + } + if (mw_app_set_series_resistance(app, (uint16_t)mohm, &report) != 0) { + goto fail; + } + if (log_report_if_requested(logger, cmd, &report) != 0) { + if (json_output) { + print_error_json(cmd, "failed to write CSV output"); + } else { + fprintf(stderr, "failed to write CSV output\n"); + } + goto done; + } + if (json_output) { + print_report_json(&report, cmd); + } else { + print_report_text(&report); + } + rc = 0; + } else if (strcmp(cmd, "run-sequence") == 0) { + mw_sequence_result result; + mw_sequence_run_options opts; + char error_text[256]; + if (optind >= argc) { + if (json_output) { + print_error_json(cmd, "run-sequence needs a JSON file path"); + } else { + fprintf(stderr, "run-sequence needs a JSON file path\n"); + } + goto done; + } + memset(&opts, 0, sizeof(opts)); + opts.csv_path = csv_path; + opts.sample_period_ms_override = sequence_sample_period_ms; + opts.safe_on_abort = true; + if (mw_sequence_run_file(app, argv[optind], &opts, &result, error_text, sizeof(error_text)) != 0) { + if (json_output) { + print_error_json(cmd, error_text[0] ? error_text : "sequence failed"); + } else { + fprintf(stderr, "%s\n", error_text[0] ? error_text : "sequence failed"); + } + goto done; + } + if (json_output) { + printf("{"); + printf("\"ok\":true,\"command\":\"run-sequence\","); + printf("\"name\":"); json_print_escaped(result.name[0] ? result.name : "sequence"); + printf(",\"sample_period_ms\":%d", result.sample_period_ms); + printf(",\"steps_total\":%lu", (unsigned long)result.steps_total); + printf(",\"steps_completed\":%lu", (unsigned long)result.steps_completed); + printf(",\"samples_written\":%lu", (unsigned long)result.samples_written); + if (result.last_report_valid) { + printf(",\"last_report\":{"); + printf("\"current_a\":%.3f,", result.last_report.current_ma / 1000.0); + printf("\"voltage_v\":%.3f,", result.last_report.voltage_mv / 1000.0); + printf("\"power_w\":%.3f", mw_report_power_mw(&result.last_report) / 1000.0); + printf("}"); + } + printf("}\n"); + } else { + printf("sequence=%s\n", result.name[0] ? result.name : "sequence"); + printf("sample_period_ms=%d\n", result.sample_period_ms); + printf("steps_completed=%lu/%lu\n", (unsigned long)result.steps_completed, (unsigned long)result.steps_total); + printf("samples_written=%lu\n", (unsigned long)result.samples_written); + if (result.last_report_valid) { + print_report_text(&result.last_report); + } + } + rc = 0; + } else { + if (json_output) { + print_error_json(cmd, "unknown command"); + } else { + fprintf(stderr, "unknown command: %s\n", cmd); + usage(stderr); + } + goto done; + } + } + + goto done; + +fail: + if (json_output) { + print_error_json(command_for_error, mw_app_last_error(app)); + } else { + fprintf(stderr, "%s\n", mw_app_last_error(app)); + } + +done: + mw_csv_logger_close(logger); + mw_app_close(app); + return rc; +}