From 1c27f37a273f2391889a884beba31677bc3d41dc Mon Sep 17 00:00:00 2001 From: Peder Toftegaard Olsen Date: Mon, 8 Jan 2024 18:44:52 +0100 Subject: [PATCH] Initial commit. --- .clang-format | 3 + .gitignore | 6 + LICENSES/MIT.txt | 21 + README.md | 190 ++++ SCPI_COMMANDS.md | 154 ++++ include/riden_config/riden_config.h | 37 + include/riden_http_server/riden_http_server.h | 49 + include/riden_logging/riden_logging.h | 14 + include/riden_modbus/riden_modbus.h | 317 +++++++ include/riden_modbus/riden_modbus_registers.h | 135 +++ .../riden_modbus_bridge/riden_modbus_bridge.h | 40 + include/riden_scpi/riden_scpi.h | 102 +++ platformio.ini | 47 + src/main.cpp | 197 ++++ src/riden_config/riden_config.cpp | 110 +++ src/riden_config/timezones.h | 480 ++++++++++ src/riden_http_server/http_static.h | 117 +++ src/riden_http_server/riden_http_server.cpp | 411 +++++++++ src/riden_modbus/riden_modbus.cpp | 840 ++++++++++++++++++ .../riden_modbus_bridge.cpp | 121 +++ src/riden_scpi/riden_scpi.cpp | 722 +++++++++++++++ 21 files changed, 4113 insertions(+) create mode 100644 .clang-format create mode 100644 .gitignore create mode 100644 LICENSES/MIT.txt create mode 100644 README.md create mode 100644 SCPI_COMMANDS.md create mode 100644 include/riden_config/riden_config.h create mode 100644 include/riden_http_server/riden_http_server.h create mode 100644 include/riden_logging/riden_logging.h create mode 100644 include/riden_modbus/riden_modbus.h create mode 100644 include/riden_modbus/riden_modbus_registers.h create mode 100644 include/riden_modbus_bridge/riden_modbus_bridge.h create mode 100644 include/riden_scpi/riden_scpi.h create mode 100644 platformio.ini create mode 100644 src/main.cpp create mode 100644 src/riden_config/riden_config.cpp create mode 100644 src/riden_config/timezones.h create mode 100644 src/riden_http_server/http_static.h create mode 100644 src/riden_http_server/riden_http_server.cpp create mode 100644 src/riden_modbus/riden_modbus.cpp create mode 100644 src/riden_modbus_bridge/riden_modbus_bridge.cpp create mode 100644 src/riden_scpi/riden_scpi.cpp diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..9ca6029 --- /dev/null +++ b/.clang-format @@ -0,0 +1,3 @@ +BreakBeforeBraces: Linux +ColumnLimit: 0 +IndentWidth: 4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fda00c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch +.venv/ diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 0000000..860b0b5 --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Peder Toftegaard Olsen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d820cda --- /dev/null +++ b/README.md @@ -0,0 +1,190 @@ +# Riden Multi-Purpose WiFi Dongle Firmware + +This is an alternative firmware for the Riden WiFi module that +provides Modbus TCP and SCPI support as well as a web interface. + +The firmware has been tested with various tools and libraries: + +- Riden Hardware + - RD6006 +- Riden Firmware + - Riden v1.28 + - Riden v1.41 + - Unisoft v1.41.1k +- Modbus TCP + - [Python pyModbusTCP library](https://pypi.org/project/pyModbusTCP/) + - [Python pymodbus library](https://pypi.org/project/pymodbus/) + - A modified version of [ridengui](https://github.com/ShayBox/RidenGUI) + with Modbus TCP support hacked in +- SCPI + - [lxi-tools](https://github.com/lxi-tools/lxi-tools) + - [EEZ Studio](https://www.envox.eu/studio/studio-introduction/) + + +## Features + +- Modbus RTU client communicating with Riden power supply firmware. +- Modbus TCP bridge. +- SCPI control. +- Web interface to configure the dongle. +- Web Portal to set up WiFi as well as do firmware updates. +- Automatically set power supply clock based on NTP. +- mDNS advertising. + + +## Warning + +- When flashing the Riden WiFi module you _will_ erase the existing firmware. +- The firmware provided in this repository comes with no warranty. + + +## Hardware Preparations + +This one had me stuck for some time. To quote from +https://community.home-assistant.io/t/riden-rd6006-dc-power-supply-ha-support-wifi/163849: + +> I had to do small modification to the wifi board - I snipped one pin +> from the pinheader (EN-Enable) so it does not make contact with the +> power supply and soldered 1k resistor between EN and 3.3V. It is done +> because the PS enables wifi module only in “wifi mode” but I need it +> to run in “TTL mode” as well. + +In order to flash an existing Riden WiFi module, solder on +three additional wires: GPIO0, EN, and 3.3V. In order to ease +development you may want to terminate the wires in a Dupont header connector +allowing you to more easily use an ESP01 USB Serial Adapter or similar. + + +## Compiling the Firmware + +You will need [PlatformIO](https://platformio.org/) to compile the +firmware. + +No configuration is necessary; simply execute `pio run` and wait. +The firmware is located at `.pio/build/esp01_1m/firmware.bin`. + + +## Flashing the Firmware + +Provided you have prepared the hardware as described, connect +it to your computer as you would when flashing any other ESP12F module. + +Execute + + pio run -t upload --upload-port + +and wait for the firmware to be flashed. + +Before re-inserting the module into your power supply, +it may be a good idea to make the necessary configuration +changes. You need to select `TTL` as the communications mode, +and 9600 as the speed. + +Re-insert the module and power up the power supply. + +The module will begin to flash, first slowly and then +faster. If it starts flashing really fast (5 flashes +per second), you propably misconfigured the power supply. +Double-check, and if you are still having issues, add +an issue to the Github repository. + +If all is well, the module has created a new access +point, named `RDxxxx-ssssssss` (`xxxx` is the model +and `ssssssss` is the serial number). + +Connect to this access point, and you will be greeted +by a web page for configuring the WiFi network that the +module should connect to. + +Follow the instructions, and save the configuration. + +If all goes well, the blue LED will start to flash slowly +after a short while. You should now be able to connect +to it at http://RDxxxx-ssssssss.local. + + +## Using lxi-tools to Verify Installation + +Execute the command + + lxi discover -m + +to get a list of discovered SCPI devices on the network. +This firmware sneakily advertised `lxi` support in order +for lxi-tools to recognise it. + +Execute the command + + lxi scpi -a RDxxxx-ssssssss.local -r "*IDN?" + +to retrieve the SCPI identification string containing +power supply model, and firmware version. + +Execute the command + + lxi scpi -a RDxxxx-ssssssss.local -r "VOLT?" + +to retrieve the currently set voltage. + +Invoke + + lxi scpi -a RDxxxx-ssssssss.local -r "VOLT 3.3" + +to set the voltage to 3.3V + +A description of the available commands are available +in [SCPI_COMMANDS.md](SCPI_COMMANDS.md). + + +## OTA firmware update + +In order to update the firmware, you may prefer +to use OTA update instead of having to remove +the module. + +Reboot in to config portal using the web interface. +Connect to the module's access point (see above), +hit Update and select your new `firmware.bin`. + + +## Limitations + +The Riden power supply firmware have some quirks, described +below. The firmware provided here err towards caution, and +does not implement functionality that is known to be +unreliable. + +### Currently Active OVP and OCP Values + +There is no way to reliably retrieve these values. If they are set +by selecting a preset, M0 does not reflect the new values. If they +are set via the front panel, M0 does reflect the new values. + +Therefore I have decided NOT to support `*SAV`. `*RCL` is implemented. + +### Preset Register + +The Preset register (19) only reflects the active preset if +changed via the modbus interface. It is not updated if a preset +is selected using the front panel. Therefore it is currently not +possible to retrieve the selected preset. `*RCL` is available for +_recalling_ a preset. + +### Language Selection + +Only 0 and 1 are recognized when setting the Language register. Reading +the register matches the language set from the front panel. + +### Keypad + +It is not possible to control keypad lock. + + +## Credits + +- https://github.com/emelianov/modbus-esp8266 +- https://github.com/sfeister/scpi-parser-arduino +- https://github.com/j123b567/scpi-parser +- https://github.com/ShayBox/Riden +- https://github.com/tzapu/WiFiManager +- https://github.com/nayarsystems/posix_tz_db diff --git a/SCPI_COMMANDS.md b/SCPI_COMMANDS.md new file mode 100644 index 0000000..d66c5b6 --- /dev/null +++ b/SCPI_COMMANDS.md @@ -0,0 +1,154 @@ +# SCPI Commands + +This file lists the SCPI commands implemented in the firmware. + +Mandated and required SCPI commands are recognized as well, +but may not perform as expected. + + +## *RCL {preset} + +Restore a saved preset. **preset** must be between 1 and 9. + + +## SYSTem:ERRor[:NEXT]? + +Returns and at the same time deletes the oldest entry in the error queue. + + +## SYSTem:ERRor:COUNt? + +Returns the number of entries in the error queue. + + +## SYSTem:VERSion? + +Returns the version of SCPI (Standard Commands for Programmable Instruments) +that the instrument complies with. + + +## DISPlay:BRIGhtness {level} + +Set the display's brightness level. **level** must be between 0 and 5. + + +## DISPlay:BRIGhtness? + +Returns the display's brightness level. + + +## DISPlay:LANGuage {language} + +Set the front panel language. **language** is either +a number between 0 and 4 or **ENGLISH**, **CHINESE**, +**GERMAN**, **FRENCH** or **RUSSIAN**. + + +## DISPlay:LANGuage? + +Returns the front panel language. + + +## SYSTem:DATE {year} {month} {day} + +Set the power supply date. + + +## SYSTem:DATE? + +Returns the power supply date. + + +## SYSTem:TIME {hour} {minute} {second} + +Set the power supply time of day. + + +## SYSTem:TIME? + +Returns the power supply time of day. + + +## OUTPut[:STATe] {0 | 1 | on | off} + +The output state. + + +## OUTPut[:STATe]? + +Returns the output state. + + +## OUTPut:MODE? + +The output mode, either CV or CC. + + +## [SOURce]:VOLTage[:LEVel][:IMMediate][:AMPLitude] {voltage} + +Set the output voltage. + + +## [SOURce]:VOLTage[:LEVel][:IMMediate][:AMPLitude]? + +Returns the output voltage. + + +## [SOURce]:VOLTage:PROTection:TRIPped? + +Returns whether the OVP is tripped. + + +## [SOURce]:CURRent[:LEVel][:IMMediate][:AMPLitude] {current} + +Set the output current. + + +## [SOURce]:CURRent[:LEVel][:IMMediate][:AMPLitude]? + +Returns the output current. + + +## [SOURce]:CURRent:PROTection:TRIPped? + +Returns whether the OCP is tripped. + + +## MEASure[:SCALar]:VOLTage[:DC]? + +Returns the measured output voltage. + + +## MEASure[:SCALar]:CURRent[:DC]? + +Returns the measured output current. + + +## MEASure[:SCALar]:POWer[:DC]? + +Returns the measured output power. + + +## MEASure[:SCALar]:TEMPerature[:THERmistor][:DC]? {SYSTEM | PROBE} + +Returns the system or probe temperature. + + +## [SOURce]:VOLTage:LIMit {voltage} + +Set the Over-Voltage Protection value. + + +## [SOURce]:CURRent:LIMit {current} + +Set the Over-Current Protection value. + + +## SYSTem:BEEPer:STATe {0 | 1 | on | off} + +Control the buzzer. + + +## SYSTem:BEEPer:STATe? + +Returns the buzzer state. diff --git a/include/riden_config/riden_config.h b/include/riden_config/riden_config.h new file mode 100644 index 0000000..cfef8ac --- /dev/null +++ b/include/riden_config/riden_config.h @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2024 Peder Toftegaard Olsen +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +namespace RidenDongle +{ + +struct Timezone { + const char *name; + const char *tz; +}; + +class RidenConfig +{ + public: + bool begin(); + bool commit(); + void set_timezone_name(String tz_name); + String get_timezone_name(); + String get_timezone_spec(); + int get_number_of_timezones(); + const Timezone &get_timezone(int index); + void set_config_portal_on_boot(); + bool get_and_reset_config_portal_on_boot(); + + private: + String tz_name; + bool config_portal_on_boot; +}; + +extern RidenConfig riden_config; + +} // namespace RidenDongle diff --git a/include/riden_http_server/riden_http_server.h b/include/riden_http_server/riden_http_server.h new file mode 100644 index 0000000..221d454 --- /dev/null +++ b/include/riden_http_server/riden_http_server.h @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2024 Peder Toftegaard Olsen +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +#include + +#define HTTP_RAW_PORT 80 + +namespace RidenDongle +{ + +class RidenHttpServer +{ + public: + RidenHttpServer(RidenModbus &modbus, RidenScpi &scpi, RidenModbusBridge &bridge) : modbus(modbus), scpi(scpi), bridge(bridge), server(HTTP_RAW_PORT) {} + bool begin(); + void loop(void); + + private: + RidenModbus &modbus; + RidenScpi &scpi; + RidenModbusBridge &bridge; + ESP8266WebServer server; + + void get_root(); + void post_root(); + void handle_get_psu(); + void get_config(); + void post_config(); + void reboot_dongle(); + void handle_not_found(); + void send_redirect_self(); + + void send_connected_clients(); + void send_network_info(); + void send_services(); + void send_power_supply_info(); + + void send_as_chunks(const char *str); + void send_info_row(String key, String value); +}; + +} // namespace RidenDongle diff --git a/include/riden_logging/riden_logging.h b/include/riden_logging/riden_logging.h new file mode 100644 index 0000000..ac9fb5e --- /dev/null +++ b/include/riden_logging/riden_logging.h @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2024 Peder Toftegaard Olsen +// +// SPDX-License-Identifier: MIT + +#ifdef MODBUS_USE_SOFWARE_SERIAL +#include +#define LOG(a) Serial.print(a) +#define LOG_LN(a) Serial.println(a) +#define LOG_F(...) Serial.printf(__VA_ARGS__) +#else +#define LOG(a) +#define LOG_LN(a) +#define LOG_F(...) +#endif diff --git a/include/riden_modbus/riden_modbus.h b/include/riden_modbus/riden_modbus.h new file mode 100644 index 0000000..bbe925b --- /dev/null +++ b/include/riden_modbus/riden_modbus.h @@ -0,0 +1,317 @@ +// SPDX-FileCopyrightText: 2024 Peder Toftegaard Olsen +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "riden_modbus_registers.h" + +#include +#include +#include +#include + +#define MODBUS_ADDRESS 1 +#define NUMBER_OF_PRESETS 9 + +namespace RidenDongle +{ + +enum class Protection { + OVP = 1, + OCP = 2, + None = 0xff, +}; + +enum class OutputMode { + CONSTANT_VOLTAGE = 0, + CONSTANT_CURRENT = 1, + Unknown = 0xff, +}; + +struct Preset { + double voltage; + double current; + double over_voltage_protection; + double over_current_protection; +}; + +struct Calibration { + uint16_t V_OUT_ZERO; + uint16_t V_OUT_SCALE; + uint16_t V_BACK_ZERO; + uint16_t V_BACK_SCALE; + uint16_t I_OUT_ZERO; + uint16_t I_OUT_SCALE; + uint16_t I_BACK_ZERO; + uint16_t I_BACK_SCALE; +}; + +struct AllValues { + double system_temperature_celsius; + double system_temperature_fahrenheit; + double voltage_set; + double current_set; + double voltage_out; + double current_out; + double power_out; + double voltage_in; + bool keypad_locked; + Protection protection; + OutputMode output_mode; + bool output_on; + uint16_t current_range; + bool is_battery_mode; + double voltage_battery; + double probe_temperature_celsius; + double probe_temperature_fahrenheit; + double ah; + double wh; + tm clock; + Calibration calibration; + bool is_take_ok; + bool is_take_out; + bool is_power_on_boot; + bool is_buzzer_enabled; + bool is_logo; + uint16_t language; + uint8_t brightness; + // NOTE: Presets are zero-based, i.e. `presets[0]` refers to `M1`. + Preset presets[NUMBER_OF_PRESETS]; +}; + +/** + * @brief Serial modbus connection to Riden power supply. + */ +class RidenModbus +{ + public: + friend class RidenModbusBridge; + + bool begin(); + bool loop(); + + bool is_connected(); + + String get_type(); + bool get_all_values(AllValues &all_values); + + bool get_id(uint16_t &id); + bool get_serial_number(uint32_t &serial_number); + bool get_firmware_version(uint16_t &firmware_version); + + bool get_system_temperature_celsius(double &temperature); + bool get_system_temperature_fahrenheit(double &temperature); + + bool get_voltage_set(double &voltage); + bool set_voltage_set(double voltage); + + bool get_current_set(double ¤t); + bool set_current_set(double current); + + bool get_voltage_out(double &voltage); + bool get_current_out(double ¤t); + + bool get_power_out(double &power); + + bool get_voltage_in(double &voltage_in); + + bool is_keypad_locked(bool &keypad); + + bool get_protection(Protection &protection); + bool get_output_mode(OutputMode &output_mode); + + bool get_output_on(bool &result); + bool set_output_on(bool on); + + /** + * @brief Set the preset + * + * @param index One-based index, i.e. `1` refers to `M1`. + * @return true On success. + * @return false On failure. + */ + bool set_preset(uint8_t index); + + bool get_current_range(uint16_t ¤t_range); + + bool is_battery_mode(bool &battery_mode); + + bool get_voltage_battery(double &voltage_battery); + + bool get_probe_temperature_celsius(double &temperature); + bool get_probe_temperature_fahrenheit(double &temperature); + + bool get_ah(double &ah); + bool get_wh(double &wh); + + bool get_clock(tm &time); + bool set_clock(tm time); + bool set_date(uint16_t year, uint16_t month, uint16_t day); + bool set_time(uint8_t hour, uint8_t minute, uint8_t second); + + // TODO[pdr] Calibration registers + + // Options + + bool is_take_ok(bool &take_ok); + bool set_take_ok(bool take_ok); + bool is_take_out(bool &take_out); + bool set_take_out(bool take_out); + bool is_power_on_boot(bool &power_on_boot); + bool set_power_on_boot(bool power_on_boot); + bool is_buzzer_enabled(bool &buzzer); + bool set_buzzer_enabled(bool buzzer); + bool is_logo(bool &logo); + bool set_logo(bool logo); + bool get_language(uint16_t &language); + bool set_language(uint16_t language); + bool get_brightness(uint8_t &brightness); + bool set_brightness(uint8_t brightness); + + // Calibration + bool get_calibration(Calibration &calibration); + bool set_calibration(Calibration calibration); + + // Presets + /** + * @brief Store preset at `index`. + * + * @param index One-based index, i.e. `1` refers to `M1`. + * @return true On success. + * @return false On failure. + */ + bool set_preset(uint8_t index, Preset preset); + + /** + * @brief Retrieve preset at `index`. + * + * @param index One-based index, i.e. `1` refers to `M1`. + * @return true On success. + * @return false On failure. + */ + bool get_preset(uint8_t index, Preset &preset); + + /** + * @brief Store preset voltage at `index`. + * + * @param index One-based index, i.e. `1` refers to `M1`. + * @return true On success. + * @return false On failure. + */ + bool set_preset_voltage_out(uint8_t index, double voltage); + + /** + * @brief Retrive preset voltage at `index`. + * + * @param index One-based index, i.e. `1` refers to `M1`. + * @return true On success. + * @return false On failure. + */ + bool get_preset_voltage_out(uint8_t index, double &voltage); + + /** + * @brief Store preset current at `index`. + * + * @param index One-based index, i.e. `1` refers to `M1`. + * @return true On success. + * @return false On failure. + */ + bool set_preset_current_out(uint8_t index, double current); + + /** + * @brief Retrieve preset current at `index`. + * + * @param index One-based index, i.e. `1` refers to `M1`. + * @return true On success. + * @return false On failure. + */ + bool get_preset_current_out(uint8_t index, double ¤t); + + /** + * @brief Store preset OVP at `index`. + * + * @param index One-based index, i.e. `1` refers to `M1`. + * @return true On success. + * @return false On failure. + */ + bool set_preset_over_voltage_protection(uint8_t index, double voltage); + + /** + * @brief Retrieve preset OVP at `index`. + * + * @param index One-based index, i.e. `1` refers to `M1`. + * @return true On success. + * @return false On failure. + */ + bool get_preset_over_voltage_protection(uint8_t index, double &voltage); + + /** + * @brief Store preset OCP at `index`. + * + * @param index One-based index, i.e. `1` refers to `M1`. + * @return true On success. + * @return false On failure. + */ + bool set_preset_over_current_protection(uint8_t index, double current); + + /** + * @brief Retrieve preset OCP at `index`. + * + * @param index One-based index, i.e. `1` refers to `M1`. + * @return true On success. + * @return false On failure. + */ + bool get_preset_over_current_protection(uint8_t index, double ¤t); + + // Bootloader + bool reboot_to_bootloader(); + + // Shortcuts + bool set_over_voltage_protection(double voltage); // = M0_OVP + bool set_over_current_protection(double current); // = M0_OCP + + // Raw Access + bool read_holding_registers(uint16_t offset, uint16_t *value, uint16_t numregs = 1); + bool write_holding_register(uint16_t offset, uint16_t value); + bool write_holding_registers(uint16_t offset, uint16_t *value, uint16_t numregs = 1); + bool read_holding_registers(Register reg, uint16_t *value, uint16_t numregs = 1); + bool write_holding_register(Register reg, uint16_t value); + bool write_holding_registers(Register reg, uint16_t *value, uint16_t numregs = 1); + + private: + ModbusRTU modbus; + bool initialized = false; + String type; + + double v_multi = 100.0; + double i_multi = 100.0; + double p_multi = 100.0; + double v_in_multi = 100.0; + + bool read_voltage(Register reg, double &voltage); + bool write_voltage(Register reg, double voltage); + bool read_current(Register reg, double ¤t); + bool write_current(Register reg, double current); + bool read_power(Register reg, double &power); + bool read_boolean(Register reg, boolean &b); + bool write_boolean(Register reg, boolean b); + + double value_to_voltage(uint16_t value); + double value_to_voltage_in(uint16_t value); + double value_to_current(uint16_t value); + double value_to_power(uint16_t value); + uint16_t voltage_to_value(double voltage); + uint16_t current_to_value(double current); + double values_to_temperature(uint16_t *values); + double values_to_ah(uint16_t *values); + double values_to_wh(uint16_t *values); + Protection value_to_protection(uint16_t value); + OutputMode value_to_output_mode(uint16_t value); + void values_to_tm(tm &time, uint16_t *values); + void tm_to_values(uint16_t *values, tm &time); + void values_to_preset(uint16_t *values, Preset &preset); + void preset_to_values(Preset preset, uint16_t *values); +}; + +} // namespace RidenDongle diff --git a/include/riden_modbus/riden_modbus_registers.h b/include/riden_modbus/riden_modbus_registers.h new file mode 100644 index 0000000..248aff0 --- /dev/null +++ b/include/riden_modbus/riden_modbus_registers.h @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: 2024 Peder Toftegaard Olsen +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +namespace RidenDongle +{ + +// Based on https://github.com/ShayBox/Riden/blob/master/riden/register.py +enum class Register { + // Init + Id = 0, + SerialNumber_High = 1, + SerialNumber_Low = 2, + Firmware = 3, + SystemTemperatureCelsius_Sign = 4, + SystemTemperatureCelsius_Value = 5, + SystemTemperatureFarhenheit_Sign = 6, + SystemTemperatureFarhenheit_Value = 7, + VoltageSet = 8, + CurrentSet = 9, + VoltageOut = 10, + CurrentOut = 11, + AH = 12, // ??? + PowerOut = 13, + VoltageIn = 14, + Keypad = 15, + Protection = 16, // OVP_OCP + OutputMode = 17, // CV_CC + Output = 18, + Preset = 19, + CurrentRange = 20, // Used on RD6012p + // Unused/Unknown 21-31 + BatteryMode = 32, + VoltageBattery = 33, + ProbeTemperatureCelsius_Sign = 34, + ProbeTemperatureCelsius_Value = 35, + ProbeTemperatureFarhenheit_Sign = 36, + ProbeTemperatureFarhenheit_Value = 37, + AH_H = 38, + AH_L = 39, + WH_H = 40, + WH_L = 41, + // Unused/Unknown 42-47 + // Date + Year = 48, + Month = 49, + Day = 50, + // Time + Hour = 51, + Minute = 52, + Second = 53, + // Unused/Unknown 54 + // Calibration + // DO NOT CHANGE Unless you know what you're doing! + V_OUT_ZERO = 55, + V_OUT_SCALE = 56, + V_BACK_ZERO = 57, + V_BACK_SCALE = 58, + I_OUT_ZERO = 59, + I_OUT_SCALE = 60, + I_BACK_ZERO = 61, + I_BACK_SCALE = 62, + // Unused/Unknown 63-65 + // Settings/Options + TakeOk = 66, + TakeOut = 67, + PowerOnBoot = 68, + Buzzer = 69, + Logo = 70, + Language = 71, + Brightness = 72, + // Unused/Unknown 73-79 + // Presets + M0_V = 80, + M0_I = 81, + M0_OVP = 82, + M0_OCP = 83, + M1_V = 84, + M1_I = 85, + M1_OVP = 86, + M1_OCP = 87, + M2_V = 88, + M2_I = 89, + M2_OVP = 90, + M2_OCP = 91, + M3_V = 92, + M3_I = 93, + M3_OVP = 94, + M3_OCP = 95, + M4_V = 96, + M4_I = 97, + M4_OVP = 98, + M4_OCP = 99, + M5_V = 100, + M5_I = 101, + M5_OVP = 102, + M5_OCP = 103, + M6_V = 104, + M6_I = 105, + M6_OVP = 106, + M6_OCP = 107, + M7_V = 108, + M7_I = 109, + M7_OVP = 110, + M7_OCP = 111, + M8_V = 112, + M8_I = 113, + M8_OVP = 114, + M8_OCP = 115, + M9_V = 116, + M9_I = 117, + M9_OVP = 118, + M9_OCP = 119, + // Unused/Unknown 120-255 + SYSTEM = 256, + // NOT REGISTERS - Magic numbers for the registers + BOOTLOADER = 5633, +}; + +/** + * @brief Convert Register to uint16_t. + * + * @param reg The register. + * @return The uint16_t. + */ +constexpr uint16_t operator+(Register reg) noexcept +{ + return static_cast(reg); +} + +} // namespace RidenDongle diff --git a/include/riden_modbus_bridge/riden_modbus_bridge.h b/include/riden_modbus_bridge/riden_modbus_bridge.h new file mode 100644 index 0000000..125b3cc --- /dev/null +++ b/include/riden_modbus_bridge/riden_modbus_bridge.h @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2024 Peder Toftegaard Olsen +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +#include + +namespace RidenDongle +{ + +/** + * @brief Modbus TCP bridge. + */ +class RidenModbusBridge +{ + public: + RidenModbusBridge(RidenModbus &riden_modbus) : riden_modbus(riden_modbus){}; + bool begin(); + bool loop(); + + uint16_t port(); + + Modbus::ResultCode modbus_tcp_raw_callback(uint8_t *data, uint8_t len, void *custom_data); + Modbus::ResultCode modbus_rtu_raw_callback(uint8_t *data, uint8_t len, void *custom); + + private: + RidenModbus &riden_modbus; + ModbusTCP modbus_tcp; + bool initialized = false; + + // State of any currently running modbus command + uint16_t transaction_id = 0; // ModbusTCP transaction + uint8_t slave_id = 0; // Request slave + uint32_t ip = 0; +}; + +} // namespace RidenDongle diff --git a/include/riden_scpi/riden_scpi.h b/include/riden_scpi/riden_scpi.h new file mode 100644 index 0000000..16a98f8 --- /dev/null +++ b/include/riden_scpi/riden_scpi.h @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2024 Peder Toftegaard Olsen +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +#include +#include +#include + +#define READ_BUFFER_LENGTH (100) +#define WRITE_BUFFER_LENGTH (256) +#define SCPI_INPUT_BUFFER_LENGTH 256 +#define SCPI_ERROR_QUEUE_SIZE 17 +#define DEFAULT_SCPI_PORT 5025 +#define SCPI_MAX_CLIENTS 1 + +namespace RidenDongle +{ + +class RidenScpi +{ + public: + RidenScpi(RidenModbus &ridenModbus, uint16_t port = DEFAULT_SCPI_PORT) : ridenModbus(ridenModbus), tcpServer(port) {} + + bool begin(); + bool loop(); + + uint16_t port(); + std::list get_connected_clients(); + void disconnect_client(String ip); + + private: + RidenModbus &ridenModbus; + + bool initialized = false; + char idn2[20] = {0}; // + char idn3[10] = {0}; // + char idn4[10] = {0}; // + + scpi_t scpi_context; + char scpi_input_buffer[SCPI_INPUT_BUFFER_LENGTH]; + scpi_error_t scpi_error_queue_data[SCPI_ERROR_QUEUE_SIZE]; + + char write_buffer[WRITE_BUFFER_LENGTH]; + size_t write_buffer_length = 0; + + static const scpi_command_t scpi_commands[]; + static scpi_interface_t scpi_interface; + + WiFiServer tcpServer; + WiFiClient clients[SCPI_MAX_CLIENTS]; + + // SCPI Functions and Commands + // =========================== + // These are PascalCase in order to match SCPI Parser naming + // conventions. + static size_t SCPI_Write(scpi_t *context, const char *data, size_t len); + static scpi_result_t SCPI_Flush(scpi_t *context); + static int SCPI_Error(scpi_t *context, int_fast16_t err); + static scpi_result_t SCPI_Control(scpi_t *context, scpi_ctrl_name_t ctrl, scpi_reg_val_t val); + static scpi_result_t SCPI_Reset(scpi_t *context); + + static scpi_result_t Rcl(scpi_t *context); + + static scpi_result_t DisplayBrightness(scpi_t *context); + static scpi_result_t DisplayBrightnessQ(scpi_t *context); + static scpi_result_t DisplayLanguage(scpi_t *context); + static scpi_result_t DisplayLanguageQ(scpi_t *context); + + static scpi_result_t SystemDate(scpi_t *context); + static scpi_result_t SystemDateQ(scpi_t *context); + static scpi_result_t SystemTime(scpi_t *context); + static scpi_result_t SystemTimeQ(scpi_t *context); + + static scpi_result_t SourceVoltage(scpi_t *context); + static scpi_result_t SourceVoltageQ(scpi_t *context); + static scpi_result_t SourceVoltageProtectionTrippedQ(scpi_t *context); + static scpi_result_t SourceCurrent(scpi_t *context); + static scpi_result_t SourceCurrentQ(scpi_t *context); + static scpi_result_t SourceCurrentProtectionTrippedQ(scpi_t *context); + static scpi_result_t SourceVoltageLimit(scpi_t *context); + static scpi_result_t SourceVoltageLimitQ(scpi_t *context); + static scpi_result_t SourceCurrentLimit(scpi_t *context); + static scpi_result_t SourceCurrentLimitQ(scpi_t *context); + + static scpi_result_t OutputState(scpi_t *context); + static scpi_result_t OutputStateQ(scpi_t *context); + static scpi_result_t OutputModeQ(scpi_t *context); + + static scpi_result_t MeasureVoltageQ(scpi_t *context); + static scpi_result_t MeasureCurrentQ(scpi_t *context); + static scpi_result_t MeasurePowerQ(scpi_t *context); + static scpi_result_t MeasureTemperatureQ(scpi_t *context); + + static scpi_result_t SystemBeeperState(scpi_t *context); + static scpi_result_t SystemBeeperStateQ(scpi_t *context); +}; + +} // namespace RidenDongle diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..5dd76d6 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,47 @@ +; PlatformIO Project Configuration File +; +; SPDX-FileCopyrightText: 2024 Peder Toftegaard Olsen +; +; SPDX-License-Identifier: MIT + +[platformio] +default_envs = esp01_1m + +[env] +platform = espressif8266 +framework = arduino +lib_deps = + sfeister/SCPI_Parser @ ^2.2.0 + emelianov/modbus-esp8266 @ ^4.1.0 + wnatth3/WiFiManager @ 2.0.16-rc.2 +build_flags = + -D SERIAL_RUIDENG_BAUD=9600 + -D USE_FULL_ERROR_LIST + +[env:esp01_1m] +board = esp01_1m +build_flags = + ${env.build_flags} + -D LED_BUILTIN=2 +upload_port = +upload_resetmethod = nodemcu +upload_speed = 115200 +monitor_port = /dev/tty.usbserial-11430 +monitor_speed = 74880 + +[env:esp_wroom_02] +board = esp_wroom_02 +build_flags = + ${env.build_flags} + -D LED_BUILTIN=2 + -D MODBUS_USE_SOFWARE_SERIAL + -D MODBUS_RX=D5 # GPIO 14 + -D MODBUS_TX=D6 # GPIO 15 +# -D WM_DEBUG_LEVEL=DEBUG_DEV +# -D MODBUSRTU_DEBUG +# -D MODBUSIP_DEBUG +upload_port = /dev/cu.SLAB_USBtoUART +upload_resetmethod = nodemcu +upload_speed = 115200 +monitor_port = /dev/cu.SLAB_USBtoUART +monitor_speed = 74880 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..ee01982 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: 2024 Peder Toftegaard Olsen +// +// SPDX-License-Identifier: MIT + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define NTP_SERVER "pool.ntp.org" + +using namespace RidenDongle; + +static Ticker led_ticker; +static char hostname[100]; + +static bool has_time = false; +static bool did_update_time = false; + +static bool initialized = false; + +static RidenModbus riden_modbus; +static RidenScpi riden_scpi(riden_modbus); +static RidenModbusBridge modbus_bridge(riden_modbus); +static RidenHttpServer http_server(riden_modbus, riden_scpi, modbus_bridge); + +/** + * Invoked by led_ticker to flash the LED. + */ +static void tick(); + +/** + * Gets called when WiFiManager enters configuration mode. + */ +static void wifi_manager_config_mode_callback(WiFiManager *myWiFiManager); + +/** + * Connect to WiFi. + * + * Start WiFiManager access point if no WiFi credentials are found + * or the WiFi module fails to connect. + * + * @param hostname Name to advertise as hostname and WiFiManager access point. + */ +static bool connect_wifi(const char *hostname); + +/** + * Invoked when time has been received from an NTP server. + */ +static void on_time_received(); + +void setup() +{ + pinMode(LED_BUILTIN, OUTPUT); + led_ticker.attach(0.6, tick); + +#if MODBUS_USE_SOFWARE_SERIAL + Serial.begin(74880); + delay(1000); +#endif + + riden_config.begin(); + + // Wait for power supply firmware to boot + delay(5000); + + // We need modbus initialised to read type and serial number + if (riden_modbus.begin()) { + uint32_t serial_number; + riden_modbus.get_serial_number(serial_number); + sprintf(hostname, "%s-%08d", riden_modbus.get_type().c_str(), serial_number); + + if (!connect_wifi(hostname)) { + ESP.reset(); + delay(1000); + } + + riden_scpi.begin(); + http_server.begin(); + modbus_bridge.begin(); + + // turn off led + led_ticker.detach(); + digitalWrite(LED_BUILTIN, HIGH); + + initialized = true; + } else { + led_ticker.attach(0.1, tick); + } +} + +static bool connect_wifi(const char *hostname) +{ + LOG_LN("WiFi initializing"); + + WiFiManager wifiManager; + wifiManager.setHostname(hostname); + wifiManager.setDebugOutput(false); + wifiManager.setAPCallback(wifi_manager_config_mode_callback); + + bool force_wifi_configuration = riden_config.get_and_reset_config_portal_on_boot(); + + bool wifi_connected = false; + if (force_wifi_configuration) { + LOG_LN("WiFi starting configuration portal"); + wifi_connected = wifiManager.startConfigPortal(hostname); + } else { + LOG_LN("WiFi auto-connecting"); + wifi_connected = wifiManager.autoConnect(hostname); + } + if (wifi_connected) { + experimental::ESP8266WiFiGratuitous::stationKeepAliveSetIntervalMs(); + if (!MDNS.begin(hostname)) { + while (true) { + delay(100); + } + } + String tz = riden_config.get_timezone_spec(); + if (tz.length() > 0) { + // Get time via NTP + settimeofday_cb(on_time_received); + configTime(tz.c_str(), NTP_SERVER); + } + LOG_LN("WiFi initialized"); + } else { + LOG_LN("WiFi failed to initialize"); + } + + return wifi_connected; +} + +void loop() +{ + if (!initialized) { + return; + } + + // Not using a ticker in order to + // visually inspect that the loop + // is running. + static uint32_t cnt = 0; + cnt++; + if (cnt % 40000 == 0) { + int state = digitalRead(LED_BUILTIN); + digitalWrite(LED_BUILTIN, !state); + } + + MDNS.update(); + http_server.loop(); + + if (has_time && !did_update_time) { + LOG_LN("Setting PSU clock"); + // Read time and convert to local timezone + time_t now; + tm tm; + time(&now); + localtime_r(&now, &tm); + + riden_modbus.set_clock(tm); + did_update_time = true; + } + + riden_modbus.loop(); + riden_scpi.loop(); + modbus_bridge.loop(); +} + +void tick() +{ + // Toggle led state + int state = digitalRead(LED_BUILTIN); + digitalWrite(LED_BUILTIN, !state); +} + +void wifi_manager_config_mode_callback(WiFiManager *myWiFiManager) +{ + // entered config mode, make led toggle faster + led_ticker.attach(0.2, tick); +} + +void on_time_received() +{ + LOG_LN("Time has been received"); + has_time = true; +} diff --git a/src/riden_config/riden_config.cpp b/src/riden_config/riden_config.cpp new file mode 100644 index 0000000..45bb449 --- /dev/null +++ b/src/riden_config/riden_config.cpp @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: 2024 Peder Toftegaard Olsen +// +// SPDX-License-Identifier: MIT + +#include "timezones.h" +#include +#include + +#include + +#define MAGIC "RD" +#define LATEST_CONFIG_VERSION 1 + +using namespace RidenDongle; + +// NOTE: This struct must never change +struct RidenConfigHeader { + char magic[sizeof(MAGIC)]; + uint8_t config_version; +}; + +// V1 Configuration Struct +struct RidenConfigStructV1 { + RidenConfigHeader header; + char tz_name[100]; + bool config_portal_on_boot; +}; + +bool RidenConfig::begin() +{ + EEPROM.begin(512); + RidenConfigStructV1 config{0}; + EEPROM.get(0, config); + if (memcmp(config.header.magic, "RD", sizeof(MAGIC)) == 0 && config.header.config_version == 1) { + tz_name = config.tz_name; + config_portal_on_boot = config.config_portal_on_boot; + } else { + LOG_LN("RidenConfig: Incorrect magic"); + // Creating a default config + tz_name = ""; + config_portal_on_boot = false; + commit(); + } + return true; +} + +void RidenConfig::set_timezone_name(String tz_name) +{ + this->tz_name = tz_name; +} + +String RidenConfig::get_timezone_name() +{ + return tz_name; +} + +String RidenConfig::get_timezone_spec() +{ + for (int i = 0; i < get_number_of_timezones(); i++) { + Timezone timezone = TIMEZONES[i]; + if (tz_name.compareTo(timezone.name) == 0) { + return timezone.tz; + } + } + return ""; +} + +int RidenConfig::get_number_of_timezones() +{ + return NOF_TIMEZONES; +} + +const Timezone &RidenConfig::get_timezone(int index) +{ + return TIMEZONES[index]; +} + +void RidenConfig::set_config_portal_on_boot() +{ + config_portal_on_boot = true; +} + +bool RidenConfig::get_and_reset_config_portal_on_boot() +{ + if (config_portal_on_boot) { + config_portal_on_boot = false; + commit(); + return true; + } + return false; +} + +bool RidenConfig::commit() +{ + RidenConfigStructV1 config; + memcpy(config.header.magic, MAGIC, sizeof(MAGIC)); + config.header.config_version = LATEST_CONFIG_VERSION; + strcpy(config.tz_name, tz_name.c_str()); + config.config_portal_on_boot = config_portal_on_boot; + EEPROM.put(0, config); + bool success = EEPROM.commit(); + if (success) { + LOG_LN("RidenConfig: Saved configuration"); + } else { + LOG_LN("RidenConfig: Failed to save configuration"); + } + return success; +} + +RidenConfig RidenDongle::riden_config; diff --git a/src/riden_config/timezones.h b/src/riden_config/timezones.h new file mode 100644 index 0000000..7335768 --- /dev/null +++ b/src/riden_config/timezones.h @@ -0,0 +1,480 @@ +// SPDX-FileCopyrightText: 2024 Peder Toftegaard Olsen +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +// Generated from https://github.com/nayarsystems/posix_tz_db + +namespace RidenDongle +{ + +#define NOF_TIMEZONES 462 +static const Timezone TIMEZONES[] = { + Timezone{"Unset", ""}, + Timezone{"Africa/Abidjan", "GMT0"}, + Timezone{"Africa/Accra", "GMT0"}, + Timezone{"Africa/Addis_Ababa", "EAT-3"}, + Timezone{"Africa/Algiers", "CET-1"}, + Timezone{"Africa/Asmara", "EAT-3"}, + Timezone{"Africa/Bamako", "GMT0"}, + Timezone{"Africa/Bangui", "WAT-1"}, + Timezone{"Africa/Banjul", "GMT0"}, + Timezone{"Africa/Bissau", "GMT0"}, + Timezone{"Africa/Blantyre", "CAT-2"}, + Timezone{"Africa/Brazzaville", "WAT-1"}, + Timezone{"Africa/Bujumbura", "CAT-2"}, + Timezone{"Africa/Cairo", "EET-2EEST,M4.5.5/0,M10.5.4/24"}, + Timezone{"Africa/Casablanca", "XXX-2<+01>-1,0/0,J365/23"}, + Timezone{"Africa/Ceuta", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Africa/Conakry", "GMT0"}, + Timezone{"Africa/Dakar", "GMT0"}, + Timezone{"Africa/Dar_es_Salaam", "EAT-3"}, + Timezone{"Africa/Djibouti", "EAT-3"}, + Timezone{"Africa/Douala", "WAT-1"}, + Timezone{"Africa/El_Aaiun", "XXX-2<+01>-1,0/0,J365/23"}, + Timezone{"Africa/Freetown", "GMT0"}, + Timezone{"Africa/Gaborone", "CAT-2"}, + Timezone{"Africa/Harare", "CAT-2"}, + Timezone{"Africa/Johannesburg", "SAST-2"}, + Timezone{"Africa/Juba", "CAT-2"}, + Timezone{"Africa/Kampala", "EAT-3"}, + Timezone{"Africa/Khartoum", "CAT-2"}, + Timezone{"Africa/Kigali", "CAT-2"}, + Timezone{"Africa/Kinshasa", "WAT-1"}, + Timezone{"Africa/Lagos", "WAT-1"}, + Timezone{"Africa/Libreville", "WAT-1"}, + Timezone{"Africa/Lome", "GMT0"}, + Timezone{"Africa/Luanda", "WAT-1"}, + Timezone{"Africa/Lubumbashi", "CAT-2"}, + Timezone{"Africa/Lusaka", "CAT-2"}, + Timezone{"Africa/Malabo", "WAT-1"}, + Timezone{"Africa/Maputo", "CAT-2"}, + Timezone{"Africa/Maseru", "SAST-2"}, + Timezone{"Africa/Mbabane", "SAST-2"}, + Timezone{"Africa/Mogadishu", "EAT-3"}, + Timezone{"Africa/Monrovia", "GMT0"}, + Timezone{"Africa/Nairobi", "EAT-3"}, + Timezone{"Africa/Ndjamena", "WAT-1"}, + Timezone{"Africa/Niamey", "WAT-1"}, + Timezone{"Africa/Nouakchott", "GMT0"}, + Timezone{"Africa/Ouagadougou", "GMT0"}, + Timezone{"Africa/Porto-Novo", "WAT-1"}, + Timezone{"Africa/Sao_Tome", "GMT0"}, + Timezone{"Africa/Tripoli", "EET-2"}, + Timezone{"Africa/Tunis", "CET-1"}, + Timezone{"Africa/Windhoek", "CAT-2"}, + Timezone{"America/Adak", "HST10HDT,M3.2.0,M11.1.0"}, + Timezone{"America/Anchorage", "AKST9AKDT,M3.2.0,M11.1.0"}, + Timezone{"America/Anguilla", "AST4"}, + Timezone{"America/Antigua", "AST4"}, + Timezone{"America/Araguaina", "<-03>3"}, + Timezone{"America/Argentina/Buenos_Aires", "<-03>3"}, + Timezone{"America/Argentina/Catamarca", "<-03>3"}, + Timezone{"America/Argentina/Cordoba", "<-03>3"}, + Timezone{"America/Argentina/Jujuy", "<-03>3"}, + Timezone{"America/Argentina/La_Rioja", "<-03>3"}, + Timezone{"America/Argentina/Mendoza", "<-03>3"}, + Timezone{"America/Argentina/Rio_Gallegos", "<-03>3"}, + Timezone{"America/Argentina/Salta", "<-03>3"}, + Timezone{"America/Argentina/San_Juan", "<-03>3"}, + Timezone{"America/Argentina/San_Luis", "<-03>3"}, + Timezone{"America/Argentina/Tucuman", "<-03>3"}, + Timezone{"America/Argentina/Ushuaia", "<-03>3"}, + Timezone{"America/Aruba", "AST4"}, + Timezone{"America/Asuncion", "<-04>4<-03>,M10.1.0/0,M3.4.0/0"}, + Timezone{"America/Atikokan", "EST5"}, + Timezone{"America/Bahia", "<-03>3"}, + Timezone{"America/Bahia_Banderas", "CST6"}, + Timezone{"America/Barbados", "AST4"}, + Timezone{"America/Belem", "<-03>3"}, + Timezone{"America/Belize", "CST6"}, + Timezone{"America/Blanc-Sablon", "AST4"}, + Timezone{"America/Boa_Vista", "<-04>4"}, + Timezone{"America/Bogota", "<-05>5"}, + Timezone{"America/Boise", "MST7MDT,M3.2.0,M11.1.0"}, + Timezone{"America/Cambridge_Bay", "MST7MDT,M3.2.0,M11.1.0"}, + Timezone{"America/Campo_Grande", "<-04>4"}, + Timezone{"America/Cancun", "EST5"}, + Timezone{"America/Caracas", "<-04>4"}, + Timezone{"America/Cayenne", "<-03>3"}, + Timezone{"America/Cayman", "EST5"}, + Timezone{"America/Chicago", "CST6CDT,M3.2.0,M11.1.0"}, + Timezone{"America/Chihuahua", "CST6"}, + Timezone{"America/Costa_Rica", "CST6"}, + Timezone{"America/Creston", "MST7"}, + Timezone{"America/Cuiaba", "<-04>4"}, + Timezone{"America/Curacao", "AST4"}, + Timezone{"America/Danmarkshavn", "GMT0"}, + Timezone{"America/Dawson", "MST7"}, + Timezone{"America/Dawson_Creek", "MST7"}, + Timezone{"America/Denver", "MST7MDT,M3.2.0,M11.1.0"}, + Timezone{"America/Detroit", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Dominica", "AST4"}, + Timezone{"America/Edmonton", "MST7MDT,M3.2.0,M11.1.0"}, + Timezone{"America/Eirunepe", "<-05>5"}, + Timezone{"America/El_Salvador", "CST6"}, + Timezone{"America/Fortaleza", "<-03>3"}, + Timezone{"America/Fort_Nelson", "MST7"}, + Timezone{"America/Glace_Bay", "AST4ADT,M3.2.0,M11.1.0"}, + Timezone{"America/Godthab", "<-02>2<-01>,M3.5.0/-1,M10.5.0/0"}, + Timezone{"America/Goose_Bay", "AST4ADT,M3.2.0,M11.1.0"}, + Timezone{"America/Grand_Turk", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Grenada", "AST4"}, + Timezone{"America/Guadeloupe", "AST4"}, + Timezone{"America/Guatemala", "CST6"}, + Timezone{"America/Guayaquil", "<-05>5"}, + Timezone{"America/Guyana", "<-04>4"}, + Timezone{"America/Halifax", "AST4ADT,M3.2.0,M11.1.0"}, + Timezone{"America/Havana", "CST5CDT,M3.2.0/0,M11.1.0/1"}, + Timezone{"America/Hermosillo", "MST7"}, + Timezone{"America/Indiana/Indianapolis", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Indiana/Knox", "CST6CDT,M3.2.0,M11.1.0"}, + Timezone{"America/Indiana/Marengo", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Indiana/Petersburg", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Indiana/Tell_City", "CST6CDT,M3.2.0,M11.1.0"}, + Timezone{"America/Indiana/Vevay", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Indiana/Vincennes", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Indiana/Winamac", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Inuvik", "MST7MDT,M3.2.0,M11.1.0"}, + Timezone{"America/Iqaluit", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Jamaica", "EST5"}, + Timezone{"America/Juneau", "AKST9AKDT,M3.2.0,M11.1.0"}, + Timezone{"America/Kentucky/Louisville", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Kentucky/Monticello", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Kralendijk", "AST4"}, + Timezone{"America/La_Paz", "<-04>4"}, + Timezone{"America/Lima", "<-05>5"}, + Timezone{"America/Los_Angeles", "PST8PDT,M3.2.0,M11.1.0"}, + Timezone{"America/Lower_Princes", "AST4"}, + Timezone{"America/Maceio", "<-03>3"}, + Timezone{"America/Managua", "CST6"}, + Timezone{"America/Manaus", "<-04>4"}, + Timezone{"America/Marigot", "AST4"}, + Timezone{"America/Martinique", "AST4"}, + Timezone{"America/Matamoros", "CST6CDT,M3.2.0,M11.1.0"}, + Timezone{"America/Mazatlan", "MST7"}, + Timezone{"America/Menominee", "CST6CDT,M3.2.0,M11.1.0"}, + Timezone{"America/Merida", "CST6"}, + Timezone{"America/Metlakatla", "AKST9AKDT,M3.2.0,M11.1.0"}, + Timezone{"America/Mexico_City", "CST6"}, + Timezone{"America/Miquelon", "<-03>3<-02>,M3.2.0,M11.1.0"}, + Timezone{"America/Moncton", "AST4ADT,M3.2.0,M11.1.0"}, + Timezone{"America/Monterrey", "CST6"}, + Timezone{"America/Montevideo", "<-03>3"}, + Timezone{"America/Montreal", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Montserrat", "AST4"}, + Timezone{"America/Nassau", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/New_York", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Nipigon", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Nome", "AKST9AKDT,M3.2.0,M11.1.0"}, + Timezone{"America/Noronha", "<-02>2"}, + Timezone{"America/North_Dakota/Beulah", "CST6CDT,M3.2.0,M11.1.0"}, + Timezone{"America/North_Dakota/Center", "CST6CDT,M3.2.0,M11.1.0"}, + Timezone{"America/North_Dakota/New_Salem", "CST6CDT,M3.2.0,M11.1.0"}, + Timezone{"America/Nuuk", "<-02>2<-01>,M3.5.0/-1,M10.5.0/0"}, + Timezone{"America/Ojinaga", "CST6CDT,M3.2.0,M11.1.0"}, + Timezone{"America/Panama", "EST5"}, + Timezone{"America/Pangnirtung", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Paramaribo", "<-03>3"}, + Timezone{"America/Phoenix", "MST7"}, + Timezone{"America/Port-au-Prince", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Port_of_Spain", "AST4"}, + Timezone{"America/Porto_Velho", "<-04>4"}, + Timezone{"America/Puerto_Rico", "AST4"}, + Timezone{"America/Punta_Arenas", "<-03>3"}, + Timezone{"America/Rainy_River", "CST6CDT,M3.2.0,M11.1.0"}, + Timezone{"America/Rankin_Inlet", "CST6CDT,M3.2.0,M11.1.0"}, + Timezone{"America/Recife", "<-03>3"}, + Timezone{"America/Regina", "CST6"}, + Timezone{"America/Resolute", "CST6CDT,M3.2.0,M11.1.0"}, + Timezone{"America/Rio_Branco", "<-05>5"}, + Timezone{"America/Santarem", "<-03>3"}, + Timezone{"America/Santiago", "<-04>4<-03>,M9.1.6/24,M4.1.6/24"}, + Timezone{"America/Santo_Domingo", "AST4"}, + Timezone{"America/Sao_Paulo", "<-03>3"}, + Timezone{"America/Scoresbysund", "<-01>1<+00>,M3.5.0/0,M10.5.0/1"}, + Timezone{"America/Sitka", "AKST9AKDT,M3.2.0,M11.1.0"}, + Timezone{"America/St_Barthelemy", "AST4"}, + Timezone{"America/St_Johns", "NST3:30NDT,M3.2.0,M11.1.0"}, + Timezone{"America/St_Kitts", "AST4"}, + Timezone{"America/St_Lucia", "AST4"}, + Timezone{"America/St_Thomas", "AST4"}, + Timezone{"America/St_Vincent", "AST4"}, + Timezone{"America/Swift_Current", "CST6"}, + Timezone{"America/Tegucigalpa", "CST6"}, + Timezone{"America/Thule", "AST4ADT,M3.2.0,M11.1.0"}, + Timezone{"America/Thunder_Bay", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Tijuana", "PST8PDT,M3.2.0,M11.1.0"}, + Timezone{"America/Toronto", "EST5EDT,M3.2.0,M11.1.0"}, + Timezone{"America/Tortola", "AST4"}, + Timezone{"America/Vancouver", "PST8PDT,M3.2.0,M11.1.0"}, + Timezone{"America/Whitehorse", "MST7"}, + Timezone{"America/Winnipeg", "CST6CDT,M3.2.0,M11.1.0"}, + Timezone{"America/Yakutat", "AKST9AKDT,M3.2.0,M11.1.0"}, + Timezone{"America/Yellowknife", "MST7MDT,M3.2.0,M11.1.0"}, + Timezone{"Antarctica/Casey", "<+11>-11"}, + Timezone{"Antarctica/Davis", "<+07>-7"}, + Timezone{"Antarctica/DumontDUrville", "<+10>-10"}, + Timezone{"Antarctica/Macquarie", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + Timezone{"Antarctica/Mawson", "<+05>-5"}, + Timezone{"Antarctica/McMurdo", "NZST-12NZDT,M9.5.0,M4.1.0/3"}, + Timezone{"Antarctica/Palmer", "<-03>3"}, + Timezone{"Antarctica/Rothera", "<-03>3"}, + Timezone{"Antarctica/Syowa", "<+03>-3"}, + Timezone{"Antarctica/Troll", "<+00>0<+02>-2,M3.5.0/1,M10.5.0/3"}, + Timezone{"Antarctica/Vostok", "<+06>-6"}, + Timezone{"Arctic/Longyearbyen", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Asia/Aden", "<+03>-3"}, + Timezone{"Asia/Almaty", "<+06>-6"}, + Timezone{"Asia/Amman", "<+03>-3"}, + Timezone{"Asia/Anadyr", "<+12>-12"}, + Timezone{"Asia/Aqtau", "<+05>-5"}, + Timezone{"Asia/Aqtobe", "<+05>-5"}, + Timezone{"Asia/Ashgabat", "<+05>-5"}, + Timezone{"Asia/Atyrau", "<+05>-5"}, + Timezone{"Asia/Baghdad", "<+03>-3"}, + Timezone{"Asia/Bahrain", "<+03>-3"}, + Timezone{"Asia/Baku", "<+04>-4"}, + Timezone{"Asia/Bangkok", "<+07>-7"}, + Timezone{"Asia/Barnaul", "<+07>-7"}, + Timezone{"Asia/Beirut", "EET-2EEST,M3.5.0/0,M10.5.0/0"}, + Timezone{"Asia/Bishkek", "<+06>-6"}, + Timezone{"Asia/Brunei", "<+08>-8"}, + Timezone{"Asia/Chita", "<+09>-9"}, + Timezone{"Asia/Choibalsan", "<+08>-8"}, + Timezone{"Asia/Colombo", "<+0530>-5:30"}, + Timezone{"Asia/Damascus", "<+03>-3"}, + Timezone{"Asia/Dhaka", "<+06>-6"}, + Timezone{"Asia/Dili", "<+09>-9"}, + Timezone{"Asia/Dubai", "<+04>-4"}, + Timezone{"Asia/Dushanbe", "<+05>-5"}, + Timezone{"Asia/Famagusta", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + Timezone{"Asia/Gaza", "EET-2EEST,M3.4.4/50,M10.4.4/50"}, + Timezone{"Asia/Hebron", "EET-2EEST,M3.4.4/50,M10.4.4/50"}, + Timezone{"Asia/Ho_Chi_Minh", "<+07>-7"}, + Timezone{"Asia/Hong_Kong", "HKT-8"}, + Timezone{"Asia/Hovd", "<+07>-7"}, + Timezone{"Asia/Irkutsk", "<+08>-8"}, + Timezone{"Asia/Jakarta", "WIB-7"}, + Timezone{"Asia/Jayapura", "WIT-9"}, + Timezone{"Asia/Jerusalem", "IST-2IDT,M3.4.4/26,M10.5.0"}, + Timezone{"Asia/Kabul", "<+0430>-4:30"}, + Timezone{"Asia/Kamchatka", "<+12>-12"}, + Timezone{"Asia/Karachi", "PKT-5"}, + Timezone{"Asia/Kathmandu", "<+0545>-5:45"}, + Timezone{"Asia/Khandyga", "<+09>-9"}, + Timezone{"Asia/Kolkata", "IST-5:30"}, + Timezone{"Asia/Krasnoyarsk", "<+07>-7"}, + Timezone{"Asia/Kuala_Lumpur", "<+08>-8"}, + Timezone{"Asia/Kuching", "<+08>-8"}, + Timezone{"Asia/Kuwait", "<+03>-3"}, + Timezone{"Asia/Macau", "CST-8"}, + Timezone{"Asia/Magadan", "<+11>-11"}, + Timezone{"Asia/Makassar", "WITA-8"}, + Timezone{"Asia/Manila", "PST-8"}, + Timezone{"Asia/Muscat", "<+04>-4"}, + Timezone{"Asia/Nicosia", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + Timezone{"Asia/Novokuznetsk", "<+07>-7"}, + Timezone{"Asia/Novosibirsk", "<+07>-7"}, + Timezone{"Asia/Omsk", "<+06>-6"}, + Timezone{"Asia/Oral", "<+05>-5"}, + Timezone{"Asia/Phnom_Penh", "<+07>-7"}, + Timezone{"Asia/Pontianak", "WIB-7"}, + Timezone{"Asia/Pyongyang", "KST-9"}, + Timezone{"Asia/Qatar", "<+03>-3"}, + Timezone{"Asia/Qyzylorda", "<+05>-5"}, + Timezone{"Asia/Riyadh", "<+03>-3"}, + Timezone{"Asia/Sakhalin", "<+11>-11"}, + Timezone{"Asia/Samarkand", "<+05>-5"}, + Timezone{"Asia/Seoul", "KST-9"}, + Timezone{"Asia/Shanghai", "CST-8"}, + Timezone{"Asia/Singapore", "<+08>-8"}, + Timezone{"Asia/Srednekolymsk", "<+11>-11"}, + Timezone{"Asia/Taipei", "CST-8"}, + Timezone{"Asia/Tashkent", "<+05>-5"}, + Timezone{"Asia/Tbilisi", "<+04>-4"}, + Timezone{"Asia/Tehran", "<+0330>-3:30"}, + Timezone{"Asia/Thimphu", "<+06>-6"}, + Timezone{"Asia/Tokyo", "JST-9"}, + Timezone{"Asia/Tomsk", "<+07>-7"}, + Timezone{"Asia/Ulaanbaatar", "<+08>-8"}, + Timezone{"Asia/Urumqi", "<+06>-6"}, + Timezone{"Asia/Ust-Nera", "<+10>-10"}, + Timezone{"Asia/Vientiane", "<+07>-7"}, + Timezone{"Asia/Vladivostok", "<+10>-10"}, + Timezone{"Asia/Yakutsk", "<+09>-9"}, + Timezone{"Asia/Yangon", "<+0630>-6:30"}, + Timezone{"Asia/Yekaterinburg", "<+05>-5"}, + Timezone{"Asia/Yerevan", "<+04>-4"}, + Timezone{"Atlantic/Azores", "<-01>1<+00>,M3.5.0/0,M10.5.0/1"}, + Timezone{"Atlantic/Bermuda", "AST4ADT,M3.2.0,M11.1.0"}, + Timezone{"Atlantic/Canary", "WET0WEST,M3.5.0/1,M10.5.0"}, + Timezone{"Atlantic/Cape_Verde", "<-01>1"}, + Timezone{"Atlantic/Faroe", "WET0WEST,M3.5.0/1,M10.5.0"}, + Timezone{"Atlantic/Madeira", "WET0WEST,M3.5.0/1,M10.5.0"}, + Timezone{"Atlantic/Reykjavik", "GMT0"}, + Timezone{"Atlantic/South_Georgia", "<-02>2"}, + Timezone{"Atlantic/Stanley", "<-03>3"}, + Timezone{"Atlantic/St_Helena", "GMT0"}, + Timezone{"Australia/Adelaide", "ACST-9:30ACDT,M10.1.0,M4.1.0/3"}, + Timezone{"Australia/Brisbane", "AEST-10"}, + Timezone{"Australia/Broken_Hill", "ACST-9:30ACDT,M10.1.0,M4.1.0/3"}, + Timezone{"Australia/Currie", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + Timezone{"Australia/Darwin", "ACST-9:30"}, + Timezone{"Australia/Eucla", "<+0845>-8:45"}, + Timezone{"Australia/Hobart", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + Timezone{"Australia/Lindeman", "AEST-10"}, + Timezone{"Australia/Lord_Howe", "<+1030>-10:30<+11>-11,M10.1.0,M4.1.0"}, + Timezone{"Australia/Melbourne", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + Timezone{"Australia/Perth", "AWST-8"}, + Timezone{"Australia/Sydney", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + Timezone{"Europe/Amsterdam", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Andorra", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Astrakhan", "<+04>-4"}, + Timezone{"Europe/Athens", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + Timezone{"Europe/Belgrade", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Berlin", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Bratislava", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Brussels", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Bucharest", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + Timezone{"Europe/Budapest", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Busingen", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Chisinau", "EET-2EEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Copenhagen", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Dublin", "GMT0IST,M3.5.0/1,M10.5.0"}, + Timezone{"Europe/Gibraltar", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Guernsey", "GMT0BST,M3.5.0/1,M10.5.0"}, + Timezone{"Europe/Helsinki", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + Timezone{"Europe/Isle_of_Man", "GMT0BST,M3.5.0/1,M10.5.0"}, + Timezone{"Europe/Istanbul", "<+03>-3"}, + Timezone{"Europe/Jersey", "GMT0BST,M3.5.0/1,M10.5.0"}, + Timezone{"Europe/Kaliningrad", "EET-2"}, + Timezone{"Europe/Kiev", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + Timezone{"Europe/Kirov", "MSK-3"}, + Timezone{"Europe/Lisbon", "WET0WEST,M3.5.0/1,M10.5.0"}, + Timezone{"Europe/Ljubljana", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/London", "GMT0BST,M3.5.0/1,M10.5.0"}, + Timezone{"Europe/Luxembourg", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Madrid", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Malta", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Mariehamn", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + Timezone{"Europe/Minsk", "<+03>-3"}, + Timezone{"Europe/Monaco", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Moscow", "MSK-3"}, + Timezone{"Europe/Oslo", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Paris", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Podgorica", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Prague", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Riga", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + Timezone{"Europe/Rome", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Samara", "<+04>-4"}, + Timezone{"Europe/San_Marino", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Sarajevo", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Saratov", "<+04>-4"}, + Timezone{"Europe/Simferopol", "MSK-3"}, + Timezone{"Europe/Skopje", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Sofia", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + Timezone{"Europe/Stockholm", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Tallinn", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + Timezone{"Europe/Tirane", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Ulyanovsk", "<+04>-4"}, + Timezone{"Europe/Uzhgorod", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + Timezone{"Europe/Vaduz", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Vatican", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Vienna", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Vilnius", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + Timezone{"Europe/Volgograd", "MSK-3"}, + Timezone{"Europe/Warsaw", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Zagreb", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Europe/Zaporozhye", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + Timezone{"Europe/Zurich", "CET-1CEST,M3.5.0,M10.5.0/3"}, + Timezone{"Indian/Antananarivo", "EAT-3"}, + Timezone{"Indian/Chagos", "<+06>-6"}, + Timezone{"Indian/Christmas", "<+07>-7"}, + Timezone{"Indian/Cocos", "<+0630>-6:30"}, + Timezone{"Indian/Comoro", "EAT-3"}, + Timezone{"Indian/Kerguelen", "<+05>-5"}, + Timezone{"Indian/Mahe", "<+04>-4"}, + Timezone{"Indian/Maldives", "<+05>-5"}, + Timezone{"Indian/Mauritius", "<+04>-4"}, + Timezone{"Indian/Mayotte", "EAT-3"}, + Timezone{"Indian/Reunion", "<+04>-4"}, + Timezone{"Pacific/Apia", "<+13>-13"}, + Timezone{"Pacific/Auckland", "NZST-12NZDT,M9.5.0,M4.1.0/3"}, + Timezone{"Pacific/Bougainville", "<+11>-11"}, + Timezone{"Pacific/Chatham", "<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45"}, + Timezone{"Pacific/Chuuk", "<+10>-10"}, + Timezone{"Pacific/Easter", "<-06>6<-05>,M9.1.6/22,M4.1.6/22"}, + Timezone{"Pacific/Efate", "<+11>-11"}, + Timezone{"Pacific/Enderbury", "<+13>-13"}, + Timezone{"Pacific/Fakaofo", "<+13>-13"}, + Timezone{"Pacific/Fiji", "<+12>-12"}, + Timezone{"Pacific/Funafuti", "<+12>-12"}, + Timezone{"Pacific/Galapagos", "<-06>6"}, + Timezone{"Pacific/Gambier", "<-09>9"}, + Timezone{"Pacific/Guadalcanal", "<+11>-11"}, + Timezone{"Pacific/Guam", "ChST-10"}, + Timezone{"Pacific/Honolulu", "HST10"}, + Timezone{"Pacific/Kiritimati", "<+14>-14"}, + Timezone{"Pacific/Kosrae", "<+11>-11"}, + Timezone{"Pacific/Kwajalein", "<+12>-12"}, + Timezone{"Pacific/Majuro", "<+12>-12"}, + Timezone{"Pacific/Marquesas", "<-0930>9:30"}, + Timezone{"Pacific/Midway", "SST11"}, + Timezone{"Pacific/Nauru", "<+12>-12"}, + Timezone{"Pacific/Niue", "<-11>11"}, + Timezone{"Pacific/Norfolk", "<+11>-11<+12>,M10.1.0,M4.1.0/3"}, + Timezone{"Pacific/Noumea", "<+11>-11"}, + Timezone{"Pacific/Pago_Pago", "SST11"}, + Timezone{"Pacific/Palau", "<+09>-9"}, + Timezone{"Pacific/Pitcairn", "<-08>8"}, + Timezone{"Pacific/Pohnpei", "<+11>-11"}, + Timezone{"Pacific/Port_Moresby", "<+10>-10"}, + Timezone{"Pacific/Rarotonga", "<-10>10"}, + Timezone{"Pacific/Saipan", "ChST-10"}, + Timezone{"Pacific/Tahiti", "<-10>10"}, + Timezone{"Pacific/Tarawa", "<+12>-12"}, + Timezone{"Pacific/Tongatapu", "<+13>-13"}, + Timezone{"Pacific/Wake", "<+12>-12"}, + Timezone{"Pacific/Wallis", "<+12>-12"}, + Timezone{"Etc/GMT", "GMT0"}, + Timezone{"Etc/GMT-0", "GMT0"}, + Timezone{"Etc/GMT-1", "<+01>-1"}, + Timezone{"Etc/GMT-2", "<+02>-2"}, + Timezone{"Etc/GMT-3", "<+03>-3"}, + Timezone{"Etc/GMT-4", "<+04>-4"}, + Timezone{"Etc/GMT-5", "<+05>-5"}, + Timezone{"Etc/GMT-6", "<+06>-6"}, + Timezone{"Etc/GMT-7", "<+07>-7"}, + Timezone{"Etc/GMT-8", "<+08>-8"}, + Timezone{"Etc/GMT-9", "<+09>-9"}, + Timezone{"Etc/GMT-10", "<+10>-10"}, + Timezone{"Etc/GMT-11", "<+11>-11"}, + Timezone{"Etc/GMT-12", "<+12>-12"}, + Timezone{"Etc/GMT-13", "<+13>-13"}, + Timezone{"Etc/GMT-14", "<+14>-14"}, + Timezone{"Etc/GMT0", "GMT0"}, + Timezone{"Etc/GMT+0", "GMT0"}, + Timezone{"Etc/GMT+1", "<-01>1"}, + Timezone{"Etc/GMT+2", "<-02>2"}, + Timezone{"Etc/GMT+3", "<-03>3"}, + Timezone{"Etc/GMT+4", "<-04>4"}, + Timezone{"Etc/GMT+5", "<-05>5"}, + Timezone{"Etc/GMT+6", "<-06>6"}, + Timezone{"Etc/GMT+7", "<-07>7"}, + Timezone{"Etc/GMT+8", "<-08>8"}, + Timezone{"Etc/GMT+9", "<-09>9"}, + Timezone{"Etc/GMT+10", "<-10>10"}, + Timezone{"Etc/GMT+11", "<-11>11"}, + Timezone{"Etc/GMT+12", "<-12>12"}, + Timezone{"Etc/UCT", "UTC0"}, + Timezone{"Etc/UTC", "UTC0"}, + Timezone{"Etc/Greenwich", "GMT0"}, + Timezone{"Etc/Universal", "UTC0"}, + Timezone{"Etc/Zulu", "UTC0"}, +}; + +} // namespace RidenDongle diff --git a/src/riden_http_server/http_static.h b/src/riden_http_server/http_static.h new file mode 100644 index 0000000..d59519b --- /dev/null +++ b/src/riden_http_server/http_static.h @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: 2024 Peder Toftegaard Olsen +// +// SPDX-License-Identifier: MIT + +#pragma once + +static const char *HTML_HEADER = + "" + " " + " Riden Multi-Purpose WiFi Dongle" + " " + " " + " " + " Home" + " Configure"; + +static const char *HTML_FOOTER = + " " + ""; + +static const char *HTML_CONFIG_BODY_1 = + "
" + "
" + "

Dongle Configuration

" + " " + " " + " " + " " + " " + " " + " " + "
Time Zone
" + " " + "
" + "
" + ""; + +static const char *HTML_REBOOTING_DONGLE_BODY = + "
" + "

Dongle is Rebooting

" + "
Redirecting to main page in 10 seconds.
" + " Return to main page" + "
" + ""; + +static const char *HTML_REBOOTING_DONGLE_CONFIG_PORTAL_BODY_1 = + "
" + "

Dongle is Rebooting

" + "
An access point named "; + +static const char *HTML_REBOOTING_DONGLE_CONFIG_PORTAL_BODY_2 = + " should show up shortly.
" + "

Connect to it and the config portal will show up.

"; + +static const char *HTML_NO_CONNECTION_BODY = + "

Unable to communicate with power supply.

" + "

Make sure you have configured the power supply for TTL" + " at 9600 bps.

" + "

You must power-cycle the power supply if you modified" + " its configuration.

"; diff --git a/src/riden_http_server/riden_http_server.cpp b/src/riden_http_server/riden_http_server.cpp new file mode 100644 index 0000000..94c210a --- /dev/null +++ b/src/riden_http_server/riden_http_server.cpp @@ -0,0 +1,411 @@ +// SPDX-FileCopyrightText: 2024 Peder Toftegaard Olsen +// +// SPDX-License-Identifier: MIT + +#include "http_static.h" +#include +#include +#include + +#include +#include + +using namespace RidenDongle; + +static String voltageToString(double voltage) +{ + if (voltage < 1) { + return String(voltage * 1000, 0) + " mV"; + } else { + return String(voltage, 3) + " V"; + } +} + +static String currentToString(double current) +{ + if (current < 1) { + return String(current * 1000, 0) + " mA"; + } else { + return String(current, 3) + " A"; + } +} + +static String powerToString(double power) +{ + if (power < 1) { + return String(power * 1000, 0) + " mW"; + } else { + return String(power, 3) + " W"; + } +} + +static String protectionToString(Protection protection) +{ + switch (protection) { + case Protection::OVP: + return "OVP"; + case Protection::OCP: + return "OCP"; + default: + return "None"; + } +} + +static String outputModeToString(OutputMode output_mode) +{ + switch (output_mode) { + case OutputMode::CONSTANT_VOLTAGE: + return "Constant Voltage"; + case OutputMode::CONSTANT_CURRENT: + return "Constant Current"; + default: + return "Unknown"; + } +} + +bool RidenHttpServer::begin() +{ + MDNS.addService("lxi", "tcp", HTTP_RAW_PORT); // allows discovery by lxi-tools + MDNS.addService("http", "tcp", HTTP_RAW_PORT); + + server.on("/", HTTPMethod::HTTP_GET, std::bind(&RidenHttpServer::get_root, this)); + server.on("/", HTTPMethod::HTTP_POST, std::bind(&RidenHttpServer::post_root, this)); + server.on("/psu/", HTTP_GET, std::bind(&RidenHttpServer::handle_get_psu, this)); + server.on("/config/", HTTPMethod::HTTP_GET, std::bind(&RidenHttpServer::get_config, this)); + server.on("/config/", HTTPMethod::HTTP_POST, std::bind(&RidenHttpServer::post_config, this)); + server.on("/reboot/dongle/", HTTPMethod::HTTP_GET, std::bind(&RidenHttpServer::reboot_dongle, this)); + server.onNotFound(std::bind(&RidenHttpServer::handle_not_found, this)); + server.begin(); + + return true; +} + +void RidenHttpServer::loop(void) +{ + server.handleClient(); +} + +void RidenHttpServer::get_root() +{ + server.setContentLength(CONTENT_LENGTH_UNKNOWN); + server.send(200, "text/html", HTML_HEADER); + if (modbus.is_connected()) { + send_connected_clients(); + send_power_supply_info(); + send_network_info(); + send_services(); + } else { + server.sendContent(HTML_NO_CONNECTION_BODY); + } + server.sendContent(HTML_FOOTER); + server.sendContent(""); +} + +void RidenHttpServer::handle_get_psu() +{ + AllValues all_values; + + server.setContentLength(CONTENT_LENGTH_UNKNOWN); + server.send(200, "text/html", HTML_HEADER); + if (modbus.get_all_values(all_values)) { + server.sendContent("
"); + server.sendContent(" Refresh

Power Supply Details

"); + server.sendContent(" "); + server.sendContent(" "); + send_info_row("Output", all_values.output_on ? "On" : "Off"); + send_info_row("Set", voltageToString(all_values.voltage_set) + " / " + currentToString(all_values.current_set)); + send_info_row("Out", + voltageToString(all_values.voltage_out) + " / " + currentToString(all_values.current_out) + " / " + powerToString(all_values.power_out)); + send_info_row("Protection", protectionToString(all_values.protection)); + send_info_row("Output Mode", outputModeToString(all_values.output_mode)); + send_info_row("Current Range", String(all_values.current_range, 10)); + send_info_row("Battery Mode", all_values.is_battery_mode ? "Yes" : "No"); + send_info_row("Voltage Battery", voltageToString(all_values.voltage_battery)); + send_info_row("Ah", String(all_values.ah, 3) + " Ah"); + send_info_row("Wh", String(all_values.wh, 3) + " Wh"); + server.sendContent(" "); + server.sendContent("
"); + server.sendContent("
"); + server.sendContent(""); + server.sendContent(""); + + server.sendContent("
"); + server.sendContent("

Environment

"); + server.sendContent(" "); + server.sendContent(" "); + send_info_row("Voltage In", voltageToString(all_values.voltage_in)); + send_info_row("System Temperature", String(all_values.system_temperature_celsius, 0) + "°C" + " / " + String(all_values.system_temperature_fahrenheit, 0) + "°F"); + send_info_row("Probe Temperature", String(all_values.probe_temperature_celsius, 0) + "°C" + " / " + String(all_values.probe_temperature_fahrenheit, 0) + "°F"); + server.sendContent(" "); + server.sendContent("
"); + server.sendContent("
"); + server.sendContent(""); + server.sendContent(""); + + server.sendContent("
"); + server.sendContent("

Settings

"); + server.sendContent(" "); + server.sendContent(" "); + send_info_row("Keypad Locked", all_values.keypad_locked ? "Yes" : "No"); + char clock_string[20]; + sprintf(clock_string, "%04u-%02u-%02u %02u:%02u:%02u", + all_values.clock.tm_year + 1900, + all_values.clock.tm_mon + 1, + all_values.clock.tm_mday, + all_values.clock.tm_hour, + all_values.clock.tm_min, + all_values.clock.tm_sec); + send_info_row("Time", clock_string); + send_info_row("Take OK", all_values.is_take_ok ? "Yes" : "No"); + send_info_row("Take Out", all_values.is_take_out ? "Yes" : "No"); + send_info_row("Power on boot", all_values.is_power_on_boot ? "Yes" : "No"); + send_info_row("Buzzer enabled", all_values.is_buzzer_enabled ? "Yes" : "No"); + send_info_row("Logo", all_values.is_logo ? "Yes" : "No"); + send_info_row("Language", String(all_values.language, 10)); + send_info_row("Brightness", String(all_values.brightness, 10)); + server.sendContent(" "); + server.sendContent("
"); + server.sendContent("
"); + server.sendContent(""); + server.sendContent(""); + + server.sendContent("
"); + server.sendContent("

Calibration

"); + server.sendContent(" "); + server.sendContent(" "); + send_info_row("V_OUT_ZERO", String(all_values.calibration.V_OUT_ZERO, 10)); + send_info_row("V_OUT_SCALE", String(all_values.calibration.V_OUT_SCALE, 10)); + send_info_row("V_BACK_ZERO", String(all_values.calibration.V_BACK_ZERO, 10)); + send_info_row("V_BACK_SCALE", String(all_values.calibration.V_BACK_SCALE, 10)); + send_info_row("I_OUT_ZERO", String(all_values.calibration.I_OUT_ZERO, 10)); + send_info_row("I_OUT_SCALE", String(all_values.calibration.I_OUT_SCALE, 10)); + send_info_row("I_BACK_ZERO", String(all_values.calibration.I_BACK_ZERO, 10)); + send_info_row("I_BACK_SCALE", String(all_values.calibration.I_BACK_SCALE, 10)); + server.sendContent(" "); + server.sendContent("
"); + server.sendContent("
"); + server.sendContent(""); + server.sendContent(""); + + server.sendContent("
"); + server.sendContent("

Presets

"); + server.sendContent(" "); + server.sendContent(" "); + for (int preset = 0; preset < NUMBER_OF_PRESETS; preset++) { + server.sendContent(""); + send_info_row("Preset Voltage", voltageToString(all_values.presets[preset].voltage)); + send_info_row("Preset Current", currentToString(all_values.presets[preset].current)); + send_info_row("Preset OVP", voltageToString(all_values.presets[preset].over_voltage_protection)); + send_info_row("Preset OCP", currentToString(all_values.presets[preset].over_current_protection)); + } + server.sendContent(" "); + server.sendContent("
Preset " + String(preset + 1, 10) + " (M" + String(preset + 1, 10) + ")" + "
"); + server.sendContent("
"); + server.sendContent(""); + server.sendContent(""); + } else { + server.sendContent(HTML_NO_CONNECTION_BODY); + } + server.sendContent(HTML_FOOTER); + server.sendContent(""); +} + +void RidenHttpServer::get_config() +{ + server.setContentLength(CONTENT_LENGTH_UNKNOWN); + server.send(200, "text/html", HTML_HEADER); + send_as_chunks(HTML_CONFIG_BODY_1); + String configured_tz = riden_config.get_timezone_name(); + for (int i = 0; i < riden_config.get_number_of_timezones(); i++) { + const Timezone &timezone = riden_config.get_timezone(i); + String name = timezone.name; + if (name == configured_tz) { + server.sendContent(""); + } else { + server.sendContent(""); + } + } + send_as_chunks(HTML_CONFIG_BODY_2); + server.sendContent(HTML_FOOTER); + server.sendContent(""); +} + +void RidenHttpServer::post_config() +{ + String tz = server.arg("timezone"); + LOG_F("Selected timezone: %s\r\n", tz.c_str()); + riden_config.set_timezone_name(tz); + riden_config.commit(); + + send_redirect_self(); +} + +void RidenHttpServer::reboot_dongle() +{ + String config_arg = server.arg("config_portal"); + + server.setContentLength(CONTENT_LENGTH_UNKNOWN); + server.send(200, "text/html", HTML_HEADER); + if (config_arg == "true") { + riden_config.set_config_portal_on_boot(); + riden_config.commit(); + server.sendContent(HTML_REBOOTING_DONGLE_CONFIG_PORTAL_BODY_1); + server.sendContent(WiFi.getHostname()); + server.sendContent(HTML_REBOOTING_DONGLE_CONFIG_PORTAL_BODY_2); + } else { + server.sendContent(HTML_REBOOTING_DONGLE_BODY); + } + server.sendContent(HTML_FOOTER); + server.sendContent(""); + delay(500); + ESP.reset(); + delay(1000); +} + +void RidenHttpServer::send_as_chunks(const char *str) +{ + const size_t chunk_length = 1000; + size_t length = strlen(str); + for (size_t start_pos = 0; start_pos < length; start_pos += chunk_length) { + size_t end_pos = min(start_pos + chunk_length, length); + server.sendContent(&(str[start_pos]), end_pos - start_pos); + } +} + +void RidenHttpServer::send_redirect_self() +{ + server.setContentLength(CONTENT_LENGTH_UNKNOWN); + server.send(200, "text/html", ""); + server.sendContent(""); + server.sendContent(""); + server.sendContent(""); + server.sendContent(""); + server.sendContent(""); +} + +void RidenHttpServer::send_connected_clients() +{ + return; + server.sendContent("

Connected Clients

"); + server.sendContent(""); + server.sendContent(""); + + server.sendContent(""); + server.sendContent(""); + for (auto const &client : scpi.get_connected_clients()) { + server.sendContent(""); + // IP + server.sendContent(""); + // Type + server.sendContent(""); + // Disconnect action + server.sendContent(""); + server.sendContent(""); + } + server.sendContent(""); + server.sendContent(""); + + server.sendContent("
IPInterfaceAction
"); + server.sendContent(client); + server.sendContent("SCPI
"); +} + +void RidenHttpServer::send_power_supply_info() +{ + uint16_t firmware_version; + uint32_t serial_number; + String type = modbus.get_type(); + modbus.get_firmware_version(firmware_version); + modbus.get_serial_number(serial_number); + char tmp_string[10]; + + server.sendContent("
"); + server.sendContent(" Details

Power Supply

"); + server.sendContent(" "); + server.sendContent(" "); + send_info_row("Model", type); + sprintf(tmp_string, "%u.%u", firmware_version / 100, firmware_version % 100); + send_info_row("Firmware", tmp_string); + sprintf(tmp_string, "%08d", serial_number); + send_info_row("Serial Number", String(tmp_string)); + server.sendContent(" "); + server.sendContent("
"); + server.sendContent("
"); + server.sendContent(""); + server.sendContent(""); +} + +void RidenHttpServer::send_network_info() +{ + server.sendContent("
"); + server.sendContent("

Network Configuration

"); + server.sendContent(" "); + server.sendContent(" "); + send_info_row("Hostname", WiFi.getHostname()); + send_info_row("MDNS", String(WiFi.getHostname()) + ".local"); + send_info_row("WiFi network", WiFi.SSID()); + send_info_row("IP", WiFi.localIP().toString()); + send_info_row("Subnet", WiFi.subnetMask().toString()); + send_info_row("Default Gateway", WiFi.gatewayIP().toString()); + for (int i = 0;; i++) { + auto dns = WiFi.dnsIP(i); + if (!dns.isSet()) { + break; + } + send_info_row("DNS", dns.toString()); + } + server.sendContent(" "); + server.sendContent("
"); + server.sendContent("
"); + server.sendContent(""); + server.sendContent(""); +} + +void RidenHttpServer::send_services() +{ + server.sendContent("
"); + server.sendContent("

Network Services

"); + server.sendContent(" "); + server.sendContent(" "); + send_info_row("Web Server Port", String(HTTP_RAW_PORT, 10)); + send_info_row("Modbus TCP Port", String(bridge.port(), 10)); + send_info_row("SCPI Port", String(scpi.port(), 10)); + server.sendContent(" "); + server.sendContent("
"); + server.sendContent("
"); + server.sendContent(""); + server.sendContent(""); +} + +void RidenHttpServer::post_root() +{ + String ip = server.arg("disconnect_ip"); + LOG_F("Disconnect "); + LOG_LN(ip); + scpi.disconnect_client(ip); + + send_redirect_self(); +} + +void RidenHttpServer::send_info_row(String key, String value) +{ + server.sendContent(" "); + server.sendContent(" "); + server.sendContent(key); + server.sendContent(""); + server.sendContent(" "); + server.sendContent(value); + server.sendContent(""); + server.sendContent(" "); +} + +void RidenHttpServer::handle_not_found() +{ + server.send(404, "text/plain", "404: Not found"); +} diff --git a/src/riden_modbus/riden_modbus.cpp b/src/riden_modbus/riden_modbus.cpp new file mode 100644 index 0000000..f3b87ca --- /dev/null +++ b/src/riden_modbus/riden_modbus.cpp @@ -0,0 +1,840 @@ +// SPDX-FileCopyrightText: 2024 Peder Toftegaard Olsen +// +// SPDX-License-Identifier: MIT + +#include +#include + +#include +#ifdef MODBUS_USE_SOFWARE_SERIAL +#include +#endif + +#define BUF_SIZE 100 + +#ifdef MODBUS_USE_SOFWARE_SERIAL +SoftwareSerial SerialRuideng = SoftwareSerial(MODBUS_RX, MODBUS_TX); +#else +#define SerialRuideng Serial +#endif + +using namespace RidenDongle; + +bool RidenModbus::begin() +{ + if (initialized) { + return true; + } + + LOG_LN("RuidengModbus initializing"); + +#ifdef MODBUS_USE_SOFWARE_SERIAL + SerialRuideng.begin(SERIAL_RUIDENG_BAUD, SWSERIAL_8N1); +#else + SerialRuideng.begin(SERIAL_RUIDENG_BAUD, SERIAL_8N1); +#endif + if (!modbus.begin(&SerialRuideng)) { + return false; + } + modbus.client(); + + // We cannot use get_id() yet, since this is method + // is executed from main's setup(). + uint16_t id; + bool res = modbus.readHreg(MODBUS_ADDRESS, uint16_t(Register::Id), &id); + if (!res) { + return false; + } + // Wait until transaction is complete + while (modbus.server()) { + delay(1); + modbus.task(); + } + + if (60241 <= id) { + this->type = "RD6024"; + } else if (60180 <= id && id <= 60189) { + this->type = "RD6018"; + } else if (60120 <= id && id <= 60124) { + this->type = "RD6012"; + } else if (60125 <= id && id <= 60129) { + this->type = "RD6012P"; + this->v_multi = 1000; + this->p_multi = 1000; + // i_multi is not constant! + } else if (60060 <= id && id <= 60064) { + this->type = "RD6006"; + this->i_multi = 1000; + } else if (id == 60065) { + this->type = "RD6006P"; + this->v_multi = 1000; + this->i_multi = 10000; + this->p_multi = 1000; + } else { + return false; + } + + LOG_LN("RuidengModbus initialized"); + initialized = true; + return true; +} + +bool RidenModbus::loop() +{ + if (!initialized) { + return false; + } + + modbus.task(); + return true; +} + +bool RidenModbus::is_connected() +{ + return initialized; +} + +String RidenModbus::get_type() +{ + return type; +} + +bool RidenModbus::get_all_values(AllValues &all_values) +{ + // Reading all registers at once fails silently, so + // we read 20 registers at a time instead. + Register last_reg = Register::M9_OCP; + int total_nof_regs = (+last_reg) + 1; + uint16_t values[total_nof_regs]; + for (int first_reg_to_read = 0; first_reg_to_read < total_nof_regs; first_reg_to_read += 20) { + int regs_to_read = min(20, total_nof_regs - first_reg_to_read); + if (!read_holding_registers(first_reg_to_read, &(values[first_reg_to_read]), regs_to_read)) { + return false; + } + } + + all_values.system_temperature_celsius = values_to_temperature(&(values[+Register::SystemTemperatureCelsius_Sign])); + all_values.system_temperature_fahrenheit = values_to_temperature(&(values[+Register::SystemTemperatureFarhenheit_Sign])); + all_values.voltage_set = value_to_voltage(values[+Register::VoltageSet]); + all_values.current_set = value_to_current(values[+Register::CurrentSet]); + all_values.voltage_out = value_to_voltage(values[+Register::VoltageOut]); + all_values.current_out = value_to_current(values[+Register::CurrentOut]); + all_values.power_out = value_to_power(values[+Register::PowerOut]); + all_values.voltage_in = value_to_voltage_in(values[+Register::VoltageIn]); + all_values.keypad_locked = values[+Register::Keypad] != 0; + all_values.protection = value_to_protection(values[+Register::Protection]); + all_values.output_mode = value_to_output_mode(values[+Register::OutputMode]); + all_values.output_on = values[+Register::Output] != 0; + all_values.current_range = values[+Register::CurrentRange]; + all_values.is_battery_mode = values[+Register::BatteryMode] != 0; + all_values.voltage_battery = value_to_voltage(values[+Register::VoltageBattery]); + all_values.probe_temperature_celsius = values_to_temperature(&(values[+Register::ProbeTemperatureCelsius_Sign])); + all_values.probe_temperature_fahrenheit = values_to_temperature(&(values[+Register::ProbeTemperatureFarhenheit_Sign])); + all_values.ah = values_to_ah(&(values[+Register::AH_H])); + all_values.wh = values_to_wh(&(values[+Register::WH_H])); + values_to_tm(all_values.clock, &(values[+Register::Year])); + all_values.is_take_ok = values[+Register::TakeOk] != 0; + all_values.is_take_out = values[+Register::TakeOut] != 0; + all_values.is_power_on_boot = values[+Register::PowerOnBoot] != 0; + all_values.is_buzzer_enabled = values[+Register::Buzzer] != 0; + all_values.is_logo = values[+Register::Logo] != 0; + all_values.language = values[+Register::Language]; + all_values.brightness = values[+Register::Brightness]; + // Calibration + all_values.calibration.V_OUT_ZERO = values[+Register::V_OUT_ZERO]; + all_values.calibration.V_OUT_SCALE = values[+Register::V_OUT_SCALE]; + all_values.calibration.V_BACK_ZERO = values[+Register::V_BACK_ZERO]; + all_values.calibration.V_BACK_SCALE = values[+Register::V_BACK_SCALE]; + all_values.calibration.I_OUT_ZERO = values[+Register::I_OUT_ZERO]; + all_values.calibration.I_OUT_SCALE = values[+Register::I_OUT_SCALE]; + all_values.calibration.I_BACK_ZERO = values[+Register::I_BACK_ZERO]; + all_values.calibration.I_BACK_SCALE = values[+Register::I_BACK_SCALE]; + // Presets - M0 is ignored + for (int index = 0; index < NUMBER_OF_PRESETS; index++) { + values_to_preset(&(values[+Register::M0_V + 4 * (index + 1)]), all_values.presets[index]); + } + + return true; +} + +bool RidenModbus::reboot_to_bootloader() +{ + return write_holding_register(256, 5633); +} + +bool RidenModbus::get_id(uint16_t &id) +{ + return read_holding_registers(Register::Id, &id); +} + +bool RidenModbus::get_serial_number(uint32_t &serial_number) +{ + uint16_t value[2]; + if (!read_holding_registers(Register::SerialNumber_High, value, 2)) { + return false; + } + serial_number = (uint32_t(value[0]) << 16) + uint32_t(value[1]); + return true; +} + +bool RidenModbus::get_firmware_version(uint16_t &firmware_version) +{ + return read_holding_registers(Register::Firmware, &firmware_version); +} + +bool RidenModbus::get_system_temperature_celsius(double &temperature) +{ + uint16_t values[2]; + if (!read_holding_registers(Register::SystemTemperatureCelsius_Sign, values, 2)) { + return false; + } + temperature = values_to_temperature(values); + return true; +} + +bool RidenModbus::get_system_temperature_fahrenheit(double &temperature) +{ + uint16_t values[2]; + if (!read_holding_registers(Register::SystemTemperatureFarhenheit_Sign, values, 2)) { + return false; + } + temperature = values_to_temperature(values); + return true; +} + +bool RidenModbus::get_voltage_set(double &voltage) +{ + return read_voltage(Register::VoltageSet, voltage); +} + +bool RidenModbus::set_voltage_set(double voltage) +{ + return write_voltage(Register::VoltageSet, voltage); +} + +bool RidenModbus::get_current_set(double ¤t) +{ + return read_current(Register::CurrentSet, current); +} + +bool RidenModbus::set_current_set(double current) +{ + return write_current(Register::CurrentSet, current); +} + +bool RidenModbus::get_voltage_out(double &voltage) +{ + return read_voltage(Register::VoltageOut, voltage); +} + +bool RidenModbus::get_current_out(double ¤t) +{ + return read_current(Register::CurrentOut, current); +} + +bool RidenModbus::get_power_out(double &power) +{ + return read_power(Register::PowerOut, power); +} + +bool RidenModbus::is_keypad_locked(bool &keypad) +{ + return read_boolean(Register::Keypad, keypad); +} + +bool RidenModbus::get_protection(Protection &protection) +{ + uint16_t value; + if (!read_holding_registers(Register::Protection, &value)) { + return false; + } + protection = value_to_protection(value); + return true; +} + +bool RidenModbus::get_output_mode(OutputMode &output_mode) +{ + uint16_t value; + if (!read_holding_registers(Register::OutputMode, &value)) { + return false; + } + output_mode = value_to_output_mode(value); + return true; +} + +bool RidenModbus::get_output_on(bool &result) +{ + return read_boolean(Register::Output, result); +} + +bool RidenModbus::set_output_on(bool on) +{ + return write_boolean(Register::Output, on); +} + +bool RidenModbus::set_preset(uint8_t index) +{ + if (index < 1 || index - 1 >= NUMBER_OF_PRESETS) { + return false; + } + return write_holding_register(Register::Preset, index); +} + +bool RidenModbus::get_current_range(uint16_t ¤t_range) +{ + // TODO[pdr] conversion + return read_holding_registers(Register::CurrentRange, ¤t_range); +} + +bool RidenModbus::is_battery_mode(bool &battery_mode) +{ + return read_boolean(Register::BatteryMode, battery_mode); +} + +bool RidenModbus::get_voltage_battery(double &voltage_battery) +{ + return read_voltage(Register::VoltageBattery, voltage_battery); +} + +bool RidenModbus::get_probe_temperature_celsius(double &temperature) +{ + uint16_t values[2]; + if (!read_holding_registers(Register::ProbeTemperatureCelsius_Sign, values, 2)) { + return false; + } + temperature = values_to_temperature(values); + return true; +} + +bool RidenModbus::get_probe_temperature_fahrenheit(double &temperature) +{ + uint16_t values[2]; + if (!read_holding_registers(Register::ProbeTemperatureFarhenheit_Sign, values, 2)) { + return false; + } + temperature = values_to_temperature(values); + return true; +} + +bool RidenModbus::get_ah(double &ah) +{ + uint16_t values[2]; + if (!read_holding_registers(Register::AH_H, values, 2)) { + return false; + } + ah = values_to_ah(values); + return true; +} + +bool RidenModbus::get_wh(double &wh) +{ + uint16_t values[2]; + if (!read_holding_registers(Register::WH_H, values, 2)) { + return false; + } + wh = values_to_wh(values); + return true; +} + +bool RidenModbus::get_clock(tm &time) +{ + uint16_t values[6]; + bool res = read_holding_registers(Register::Year, values, 6); + if (!res) { + return false; + } + values_to_tm(time, values); + return true; +} + +bool RidenModbus::set_clock(tm time) +{ + uint16_t values[6]; + tm_to_values(values, time); + return write_holding_registers(Register::Year, values, 6); +} + +bool RidenModbus::set_date(uint16_t year, uint16_t month, uint16_t day) +{ + uint16_t values[3] = {year, month, day}; + return write_holding_registers(Register::Year, values, 3); +} + +bool RidenModbus::set_time(uint8_t hour, uint8_t minute, uint8_t second) +{ + uint16_t values[3] = {hour, minute, second}; + return write_holding_registers(Register::Hour, values, 3); +} + +bool RidenModbus::is_take_ok(bool &take_ok) +{ + return read_boolean(Register::TakeOk, take_ok); +} + +bool RidenModbus::set_take_ok(bool take_ok) +{ + return write_boolean(Register::TakeOk, take_ok); +} + +bool RidenModbus::is_take_out(bool &take_out) +{ + return read_boolean(Register::TakeOut, take_out); +} + +bool RidenModbus::set_take_out(bool take_out) +{ + return write_boolean(Register::TakeOut, take_out); +} + +bool RidenModbus::is_power_on_boot(bool &power_on_boot) +{ + return read_boolean(Register::PowerOnBoot, power_on_boot); +} + +bool RidenModbus::set_power_on_boot(bool power_on_boot) +{ + return write_boolean(Register::PowerOnBoot, power_on_boot); +} + +bool RidenModbus::is_buzzer_enabled(bool &buzzer) +{ + return read_boolean(Register::Buzzer, buzzer); +} + +bool RidenModbus::set_buzzer_enabled(bool buzzer) +{ + return write_boolean(Register::Buzzer, buzzer); +} + +bool RidenModbus::is_logo(bool &logo) +{ + return read_boolean(Register::Logo, logo); +} + +bool RidenModbus::set_logo(bool logo) +{ + return write_boolean(Register::Logo, logo); +} + +bool RidenModbus::get_language(uint16_t &language) +{ + return read_holding_registers(Register::Language, &language); +} + +bool RidenModbus::set_language(uint16_t language) +{ + return write_holding_register(Register::Language, language); +} + +bool RidenModbus::get_brightness(uint8_t &brightness) +{ + uint16_t value; + if (!read_holding_registers(Register::Brightness, &value)) { + return false; + } + brightness = value; + return true; +} + +bool RidenModbus::set_brightness(uint8_t brightness) +{ + return write_holding_register(Register::Brightness, brightness); +} + +// Calibration +bool RidenModbus::get_calibration(Calibration &calibration) +{ + uint16_t values[8]; + if (!read_holding_registers(Register::V_OUT_ZERO, values, 8)) { + return false; + } + calibration.V_OUT_ZERO = values[0]; + calibration.V_OUT_SCALE = values[1]; + calibration.V_BACK_ZERO = values[2]; + calibration.V_BACK_SCALE = values[3]; + calibration.I_OUT_ZERO = values[4]; + calibration.I_OUT_SCALE = values[5]; + calibration.I_BACK_ZERO = values[6]; + calibration.I_BACK_SCALE = values[7]; + return true; +} + +bool RidenModbus::set_calibration(Calibration calibration) +{ + uint16_t values[8] = { + calibration.V_OUT_ZERO, + calibration.V_OUT_SCALE, + calibration.V_BACK_ZERO, + calibration.V_BACK_SCALE, + calibration.I_OUT_ZERO, + calibration.I_OUT_SCALE, + calibration.I_BACK_ZERO, + calibration.I_BACK_SCALE, + }; + return write_holding_registers(Register::V_OUT_ZERO, values, 8); +} + +// Presets + +bool RidenModbus::set_preset(uint8_t index, Preset preset) +{ + if (index >= NUMBER_OF_PRESETS) { + return false; + } + uint16_t values[4]; + preset_to_values(preset, values); + Register reg = Register(+Register::M0_V + 4 * index); + return write_holding_registers(reg, values, 4); +} + +bool RidenModbus::get_preset(uint8_t index, Preset &preset) +{ + if (index >= NUMBER_OF_PRESETS) { + return false; + } + Register reg = Register(+Register::M0_V + 4 * index); + uint16_t values[4]; + if (!read_holding_registers(reg, values, 4)) { + return false; + } + values_to_preset(values, preset); + return true; +} + +bool RidenModbus::set_preset_voltage_out(uint8_t index, double voltage) +{ + if (index >= NUMBER_OF_PRESETS) { + return false; + } + Register reg = Register(+Register::M0_V + 4 * index); + return write_voltage(reg, voltage); +} + +bool RidenModbus::get_preset_voltage_out(uint8_t index, double &voltage) +{ + if (index >= NUMBER_OF_PRESETS) { + return false; + } + Register reg = Register(+Register::M0_V + 4 * index); + return read_voltage(reg, voltage); +} + +bool RidenModbus::set_preset_current_out(uint8_t index, double current) +{ + if (index >= NUMBER_OF_PRESETS) { + return false; + } + Register reg = Register(+Register::M0_I + 4 * index); + return write_current(reg, current); +} + +bool RidenModbus::get_preset_current_out(uint8_t index, double ¤t) +{ + if (index >= NUMBER_OF_PRESETS) { + return false; + } + Register reg = Register(+Register::M0_I + 4 * index); + return read_current(reg, current); +} + +bool RidenModbus::set_preset_over_voltage_protection(uint8_t index, double voltage) +{ + if (index >= NUMBER_OF_PRESETS) { + return false; + } + Register reg = Register(+Register::M0_OVP + 4 * index); + return write_voltage(reg, voltage); +} + +bool RidenModbus::get_preset_over_voltage_protection(uint8_t index, double &voltage) +{ + if (index >= NUMBER_OF_PRESETS) { + return false; + } + Register reg = Register(+Register::M0_OVP + 4 * index); + return read_voltage(reg, voltage); +} + +bool RidenModbus::set_preset_over_current_protection(uint8_t index, double current) +{ + if (index >= NUMBER_OF_PRESETS) { + return false; + } + Register reg = Register(+Register::M0_OCP + 4 * index); + return write_current(reg, current); +} + +bool RidenModbus::get_preset_over_current_protection(uint8_t index, double ¤t) +{ + if (index >= NUMBER_OF_PRESETS) { + return false; + } + Register reg = Register(+Register::M0_OCP + 4 * index); + return read_current(reg, current); +} + +// Shortcuts + +bool RidenModbus::set_over_voltage_protection(double voltage) +{ + return set_preset_over_voltage_protection(0, voltage); +} + +bool RidenModbus::set_over_current_protection(double current) +{ + return set_preset_over_current_protection(0, current); +} + +// Helpers + +bool RidenModbus::read_voltage(Register reg, double &voltage) +{ + uint16_t value; + if (!read_holding_registers(reg, &value)) { + return false; + } + voltage = value_to_voltage(value); + return true; +} + +bool RidenModbus::write_voltage(Register reg, double voltage) +{ + uint16_t value = voltage_to_value(voltage); + return write_holding_register(reg, value); +} + +bool RidenModbus::read_current(Register reg, double ¤t) +{ + uint16_t value; + if (!read_holding_registers(reg, &value)) { + return false; + } + current = value_to_current(value); + return true; +} + +bool RidenModbus::write_current(Register reg, double current) +{ + uint16_t value = current_to_value(current); + return write_holding_register(reg, value); +} + +bool RidenModbus::read_power(Register reg, double &power) +{ + uint16_t value; + if (!read_holding_registers(reg, &value)) { + return false; + } + power = value_to_power(value); + return true; +} + +bool RidenModbus::read_boolean(Register reg, boolean &b) +{ + uint16_t value = 0; + if (!read_holding_registers(reg, &value)) { + return false; + } + b = (value != 0); + return true; +} + +bool RidenModbus::write_boolean(Register reg, boolean b) +{ + uint16_t value = b ? 1 : 0; + return write_holding_register(reg, value); +} + +bool RidenModbus::read_holding_registers(uint16_t offset, uint16_t *value, uint16_t numregs) +{ + if (!initialized) { + return false; + } + + // Wait until no transaction is active + while (modbus.server()) { + delay(1); + modbus.task(); + } + bool res = modbus.readHreg(MODBUS_ADDRESS, offset, value, numregs); + if (!res) { + return false; + } + // Check if transaction is active + while (modbus.server()) { + delay(1); + modbus.task(); + } + return true; +} + +bool RidenModbus::write_holding_register(uint16_t offset, uint16_t value) +{ + if (!initialized) { + return false; + } + + // Wait until no transaction is active + while (modbus.server()) { + delay(1); + modbus.task(); + } + bool res = modbus.writeHreg(MODBUS_ADDRESS, offset, value); + if (!res) { + return false; + } + // Check if transaction is active + while (modbus.server()) { + delay(1); + modbus.task(); + } + return true; +} + +bool RidenModbus::write_holding_registers(uint16_t offset, uint16_t *value, uint16_t numregs) +{ + if (!initialized) { + return false; + } + + // Wait until no transaction is active + while (modbus.server()) { + delay(1); + modbus.task(); + } + bool res = modbus.writeHreg(MODBUS_ADDRESS, offset, value, numregs); + if (!res) { + return false; + } + // Check if transaction is active + while (modbus.server()) { + delay(1); + modbus.task(); + } + return true; +} + +bool RidenModbus::read_holding_registers(Register reg, uint16_t *value, uint16_t numregs) +{ + uint16_t offset = +reg; + return read_holding_registers(offset, value, numregs); +} + +bool RidenModbus::write_holding_register(Register reg, uint16_t value) +{ + uint16_t offset = +reg; + return write_holding_register(offset, value); +} + +bool RidenModbus::write_holding_registers(Register reg, uint16_t *value, uint16_t numregs) +{ + uint16_t offset = +reg; + return write_holding_registers(offset, value, numregs); +} + +double RidenModbus::value_to_voltage(uint16_t value) +{ + return double(value) / v_multi; +} + +double RidenModbus::value_to_voltage_in(uint16_t value) +{ + return double(value) / v_in_multi; +} + +double RidenModbus::value_to_current(uint16_t value) +{ + return double(value) / i_multi; +} + +double RidenModbus::value_to_power(uint16_t value) +{ + return double(value) / p_multi; +} + +uint16_t RidenModbus::voltage_to_value(double voltage) +{ + return uint16_t(voltage * v_multi); +} + +uint16_t RidenModbus::current_to_value(double current) +{ + return uint16_t(current * i_multi); +} + +double RidenModbus::values_to_temperature(uint16_t *values) +{ + return (values[0] == 0 ? 1 : -1) * double(values[1]); +} + +double RidenModbus::values_to_ah(uint16_t *values) +{ + uint32_t value = (values[0] << 16) + values[1]; + return double(value) / 1000.0; +} + +double RidenModbus::values_to_wh(uint16_t *values) +{ + uint32_t value = (values[0] << 16) + values[1]; + return double(value) / 1000.0; +} + +Protection RidenModbus::value_to_protection(uint16_t value) +{ + switch (value) { + case 1: + return Protection::OVP; + case 2: + return Protection::OCP; + default: + return Protection::None; + } +} + +OutputMode RidenModbus::value_to_output_mode(uint16_t value) +{ + switch (value) { + case 0: + return OutputMode::CONSTANT_VOLTAGE; + case 1: + return OutputMode::CONSTANT_CURRENT; + default: + return OutputMode::Unknown; + } +} + +void RidenModbus::values_to_tm(tm &time, uint16_t *values) +{ + time.tm_year = values[0] - 1900; + time.tm_mon = values[1] - 1; + time.tm_mday = values[2]; + time.tm_hour = values[3]; + time.tm_min = values[4]; + time.tm_sec = values[5]; +} + +void RidenModbus::tm_to_values(uint16_t *values, tm &time) +{ + values[0] = uint16_t(time.tm_year + 1900); + values[1] = uint16_t(time.tm_mon + 1); + values[2] = uint16_t(time.tm_mday); + values[3] = uint16_t(time.tm_hour); + values[4] = uint16_t(time.tm_min); + values[5] = uint16_t(time.tm_sec); +} + +void RidenModbus::values_to_preset(uint16_t *values, Preset &preset) +{ + preset.voltage = value_to_voltage(values[0]); + preset.current = value_to_current(values[1]); + preset.over_voltage_protection = value_to_voltage(values[2]); + preset.over_current_protection = value_to_current(values[3]); +} + +void RidenModbus::preset_to_values(Preset preset, uint16_t *values) +{ + values[0] = voltage_to_value(preset.voltage); + values[1] = current_to_value(preset.current); + values[2] = voltage_to_value(preset.over_voltage_protection); + values[3] = current_to_value(preset.over_current_protection); +} diff --git a/src/riden_modbus_bridge/riden_modbus_bridge.cpp b/src/riden_modbus_bridge/riden_modbus_bridge.cpp new file mode 100644 index 0000000..a0df03f --- /dev/null +++ b/src/riden_modbus_bridge/riden_modbus_bridge.cpp @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: 2024 Peder Toftegaard Olsen +// +// SPDX-License-Identifier: MIT + +#include +#include + +#include + +using namespace RidenDongle; + +// Callbacks within the esp8266-modbus library do +// not allow for instance methods to be used, so for +// the time being we stick to only allowing a single +// instance of RidenModbusBridge. +static RidenModbusBridge *one_and_only = nullptr; +static Modbus::ResultCode modbus_tcp_raw_callback(uint8_t *data, uint8_t len, void *custom_data); +static Modbus::ResultCode modbus_rtu_raw_callback(uint8_t *data, uint8_t len, void *custom); + +bool RidenModbusBridge::begin() +{ + if (initialized) { + return true; + } + if (one_and_only != nullptr) { + return false; + } + + LOG_LN("RidenModbusBridge initializing"); + + modbus_tcp.onRaw(::modbus_tcp_raw_callback); + modbus_tcp.server(); + + // See https://github.com/espressif/esp-idf/blob/master/examples/protocols/modbus/tcp/mb_tcp_master/main/tcp_master.c#L266 + MDNS.addService("modbus", "tcp", MODBUSTCP_PORT); + + LOG_LN("RidenModbusBridge initialized"); + + one_and_only = this; + initialized = true; + return true; +} + +bool RidenModbusBridge::loop() +{ + modbus_tcp.task(); + return true; +} + +uint16_t RidenModbusBridge::port() +{ + return MODBUSTCP_PORT; +} + +/** + * Data received from the TCP-end is forwarded to ModbusRTU, + * which in turn forwards it to the power supply. + */ +Modbus::ResultCode RidenModbusBridge::modbus_tcp_raw_callback(uint8_t *data, uint8_t len, void *custom_data) +{ + if (!initialized) { + return Modbus::EX_GENERAL_FAILURE; + } + // Wait until no transaction is active + while (riden_modbus.modbus.server()) { + delay(1); + riden_modbus.modbus.task(); + } + + Modbus::frame_arg_t *source = (Modbus::frame_arg_t *)custom_data; + if (!riden_modbus.modbus.rawRequest(source->slaveId, data, len)) { + // Inform TCP-end that processing failed + modbus_tcp.errorResponce(source->slaveId, (Modbus::FunctionCode)data[0], Modbus::EX_DEVICE_FAILED_TO_RESPOND); + return Modbus::EX_DEVICE_FAILED_TO_RESPOND; // Stop ModbusTCP from processing the data + } + + // Set up ourself for forwarding the response to our ModbusTCP instance. + transaction_id = source->transactionId; + slave_id = source->slaveId; + ip = source->ipaddr; + riden_modbus.modbus.onRaw(::modbus_rtu_raw_callback); + return Modbus::EX_SUCCESS; // Stops ModbusTCP from processing the data +} + +/** + * Data received from the RTU-end must be forwarded to the TCP-end. Anything + * else is passed through unaltered to ModbusRTU. + */ +Modbus::ResultCode RidenModbusBridge::modbus_rtu_raw_callback(uint8_t *data, uint8_t len, void *custom) +{ + if (!initialized) { + return Modbus::EX_GENERAL_FAILURE; + } + + // Stop intercepting raw data + riden_modbus.modbus.onRaw(nullptr); + + Modbus::frame_arg_t *source = (Modbus::frame_arg_t *)custom; + if (!source->to_server) { + modbus_tcp.setTransactionId(transaction_id); + modbus_tcp.rawResponce(ip, data, len, slave_id); + } else { + return Modbus::EX_PASSTHROUGH; + } + + // Clear state + transaction_id = 0; + slave_id = 0; + ip = 0; + return Modbus::EX_SUCCESS; // Stops ModbusRTU from processing the data +} + +Modbus::ResultCode modbus_tcp_raw_callback(uint8_t *data, uint8_t len, void *custom_data) +{ + return one_and_only->modbus_tcp_raw_callback(data, len, custom_data); +} + +Modbus::ResultCode modbus_rtu_raw_callback(uint8_t *data, uint8_t len, void *custom) +{ + return one_and_only->modbus_rtu_raw_callback(data, len, custom); +} diff --git a/src/riden_scpi/riden_scpi.cpp b/src/riden_scpi/riden_scpi.cpp new file mode 100644 index 0000000..d4a56ff --- /dev/null +++ b/src/riden_scpi/riden_scpi.cpp @@ -0,0 +1,722 @@ +// SPDX-FileCopyrightText: 2024 Peder Toftegaard Olsen +// +// SPDX-License-Identifier: MIT + +#include +#include +#include + +#include +#include +#include +#include + +using namespace RidenDongle; + +// We only support one client + +const scpi_command_t RidenScpi::scpi_commands[] = { + /* IEEE Mandated Commands (SCPI std V1999.0 4.1.1) */ + {"*CLS", SCPI_CoreCls, 0}, + {"*ESE", SCPI_CoreEse, 0}, + {"*ESE?", SCPI_CoreEseQ, 0}, + {"*ESR?", SCPI_CoreEsrQ, 0}, + {"*IDN?", SCPI_CoreIdnQ, 0}, + {"*OPC", SCPI_CoreOpc, 0}, + {"*OPC?", SCPI_CoreOpcQ, 0}, + {"*RST", SCPI_CoreRst, 0}, + {"*SRE", SCPI_CoreSre, 0}, + {"*SRE?", SCPI_CoreSreQ, 0}, + {"*STB?", SCPI_CoreStbQ, 0}, + {"*TST?", SCPI_CoreTstQ, 0}, + {"*WAI", SCPI_CoreWai, 0}, + + /* Required SCPI commands (SCPI std V1999.0 4.2.1) */ + {"SYSTem:ERRor[:NEXT]?", SCPI_SystemErrorNextQ, 0}, + {"SYSTem:ERRor:COUNt?", SCPI_SystemErrorCountQ, 0}, + {"SYSTem:VERSion?", SCPI_SystemVersionQ, 0}, + + {"STATus:OPERation?", SCPI_StatusOperationEventQ, 0}, + {"STATus:OPERation:EVENt?", SCPI_StatusOperationEventQ, 0}, + {"STATus:OPERation:CONDition?", SCPI_StatusOperationConditionQ, 0}, + {"STATus:OPERation:ENABle", SCPI_StatusOperationEnable, 0}, + {"STATus:OPERation:ENABle?", SCPI_StatusOperationEnableQ, 0}, + + {"STATus:QUEStionable[:EVENt]?", SCPI_StatusQuestionableEventQ, 0}, + {"STATus:QUEStionable:CONDition?", SCPI_StatusQuestionableConditionQ, 0}, + {"STATus:QUEStionable:ENABle", SCPI_StatusQuestionableEnable, 0}, + {"STATus:QUEStionable:ENABle?", SCPI_StatusQuestionableEnableQ, 0}, + + {"STATus:PRESet", SCPI_StatusPreset, 0}, + + {"*RCL", RidenScpi::Rcl, 0}, + {"DISPlay:BRIGhtness", RidenScpi::DisplayBrightness, 0}, + {"DISPlay:BRIGhtness?", RidenScpi::DisplayBrightnessQ, 0}, + {"DISPlay:LANGuage", RidenScpi::DisplayLanguage, 0}, + {"DISPlay:LANGuage?", RidenScpi::DisplayLanguageQ, 0}, + + {"SYSTem:DATE", RidenScpi::SystemDate, 0}, + {"SYSTem:DATE?", RidenScpi::SystemDateQ, 0}, + {"SYSTem:TIME", RidenScpi::SystemTime, 0}, + {"SYSTem:TIME?", RidenScpi::SystemTimeQ, 0}, + + {"OUTPut[:STATe]", RidenScpi::OutputState, 0}, + {"OUTPut[:STATe]?", RidenScpi::OutputStateQ, 0}, + {"OUTPut:MODE?", RidenScpi::OutputModeQ, 0}, + + {"[SOURce]:VOLTage[:LEVel][:IMMediate][:AMPLitude]", RidenScpi::SourceVoltage, 0}, + {"[SOURce]:VOLTage[:LEVel][:IMMediate][:AMPLitude]?", RidenScpi::SourceVoltageQ, 0}, + {"[SOURce]:VOLTage:PROTection:TRIPped?", RidenScpi::SourceVoltageProtectionTrippedQ, 0}, + + {"[SOURce]:CURRent[:LEVel][:IMMediate][:AMPLitude]", RidenScpi::SourceCurrent, 0}, + {"[SOURce]:CURRent[:LEVel][:IMMediate][:AMPLitude]?", RidenScpi::SourceCurrentQ, 0}, + {"[SOURce]:CURRent:PROTection:TRIPped?", RidenScpi::SourceCurrentProtectionTrippedQ}, + + {"MEASure[:SCALar]:VOLTage[:DC]?", RidenScpi::MeasureVoltageQ, 0}, + {"MEASure[:SCALar]:CURRent[:DC]?", RidenScpi::MeasureCurrentQ, 0}, + {"MEASure[:SCALar]:POWer[:DC]?", RidenScpi::MeasurePowerQ, 0}, + {"MEASure[:SCALar]:TEMPerature[:THERmistor][:DC]?", RidenScpi::MeasureTemperatureQ, 0}, + + {"[SOURce]:VOLTage:LIMit", RidenScpi::SourceVoltageLimit, 0}, + + {"[SOURce]:CURRent:LIMit", RidenScpi::SourceCurrentLimit, 0}, + + {"SYSTem:BEEPer:STATe", RidenScpi::SystemBeeperState, 0}, + {"SYSTem:BEEPer:STATe?", RidenScpi::SystemBeeperStateQ, 0}, + + SCPI_CMD_LIST_END}; + +scpi_choice_def_t temperature_options[] = { + {.name = "SYSTEM", .tag = 0}, + {.name = "PROBE", .tag = 1}, + SCPI_CHOICE_LIST_END, +}; + +scpi_choice_def_t language_options[] = { + {.name = "ENGLISH", .tag = 0}, + {.name = "CHINESE", .tag = 1}, + {.name = "GERMAN", .tag = 2}, + {.name = "FRENCH", .tag = 3}, + {.name = "RUSSIAN", .tag = 4}, + SCPI_CHOICE_LIST_END, +}; + +scpi_interface_t RidenScpi::scpi_interface = { + .error = RidenScpi::SCPI_Error, + .write = RidenScpi::SCPI_Write, + .control = RidenScpi::SCPI_Control, + .flush = RidenScpi::SCPI_Flush, + .reset = RidenScpi::SCPI_Reset, +}; + +size_t SCPI_ResultChoice(scpi_t *context, scpi_choice_def_t *options, int32_t value) +{ + for (int i = 0; options[i].name; ++i) { + if (options[i].tag == value) { + return SCPI_ResultMnemonic(context, options[i].name); + } + } + return SCPI_ResultInt32(context, value); +} + +size_t RidenScpi::SCPI_Write(scpi_t *context, const char *data, size_t len) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + + memcpy(&(ridenScpi->write_buffer[ridenScpi->write_buffer_length]), data, len); + ridenScpi->write_buffer_length += len; + + return len; +} + +scpi_result_t RidenScpi::SCPI_Flush(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + + if (ridenScpi->clients[0]) { + ridenScpi->clients[0].write(ridenScpi->write_buffer, ridenScpi->write_buffer_length); + ridenScpi->write_buffer_length = 0; + ridenScpi->clients[0].flush(); + } + + return SCPI_RES_OK; +} + +int RidenScpi::SCPI_Error(scpi_t *context, int_fast16_t err) +{ + (void)context; + LOG_F(" * *ERROR : % d, \"%s\"\r\n", err, SCPI_ErrorTranslate(err)); + return 0; +} + +scpi_result_t RidenScpi::SCPI_Control(scpi_t *context, scpi_ctrl_name_t ctrl, scpi_reg_val_t val) +{ + LOG_LN("SCPI_Control"); + (void)context; +#ifdef MODBUS_USE_SOFWARE_SERIAL + if (SCPI_CTRL_SRQ == ctrl) { + Serial.print("**SRQ: 0x"); + Serial.print(val, HEX); + Serial.print("("); + Serial.print(val, DEC); + Serial.println(")"); + } else { + Serial.print("**CTRL: "); + Serial.print(val, HEX); + Serial.print("("); + Serial.print(val, DEC); + Serial.println(")"); + } +#endif + return SCPI_RES_OK; +}; + +scpi_result_t RidenScpi::SCPI_Reset(scpi_t *context) +{ + (void)context; + LOG_LN("**Reset"); + return SCPI_RES_OK; +} + +scpi_result_t RidenScpi::Rcl(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + uint32_t profile; + if (!SCPI_ParamUnsignedInt(context, &profile, true)) { + return SCPI_RES_ERR; + } + if (profile < 1 || profile - 1 >= NUMBER_OF_PRESETS) { + SCPI_ErrorPush(context, SCPI_ERROR_ILLEGAL_PARAMETER_VALUE); + return SCPI_RES_ERR; + } + if (ridenScpi->ridenModbus.set_preset(profile)) { + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::DisplayBrightness(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + uint32_t brightness; + if (!SCPI_ParamUnsignedInt(context, &brightness, true)) { + return SCPI_RES_ERR; + } + if (brightness > 5) { + SCPI_ErrorPush(context, SCPI_ERROR_ILLEGAL_PARAMETER_VALUE); + return SCPI_RES_ERR; + } + if (ridenScpi->ridenModbus.set_brightness(brightness)) { + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::DisplayBrightnessQ(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + uint8_t brightness; + if (ridenScpi->ridenModbus.get_brightness(brightness)) { + SCPI_ResultUInt8(context, brightness); + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::DisplayLanguage(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + int32_t language = -1; + + scpi_parameter_t param; + + if (!SCPI_Parameter(context, ¶m, true)) { + return SCPI_RES_ERR; + } + + if (!SCPI_ParamToChoice(context, ¶m, language_options, &language)) { + if (!SCPI_ParamToInt(context, ¶m, &language)) { + SCPI_ErrorPush(context, SCPI_ERROR_ILLEGAL_PARAMETER_VALUE); + return SCPI_RES_ERR; + } + } + if (language < 0 || language > 4) { + SCPI_ErrorPush(context, SCPI_ERROR_ILLEGAL_PARAMETER_VALUE); + return SCPI_RES_ERR; + } + if (ridenScpi->ridenModbus.set_language(language)) { + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::DisplayLanguageQ(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + uint16_t language; + if (ridenScpi->ridenModbus.get_language(language)) { + SCPI_ResultChoice(context, language_options, language); + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::SystemDate(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + uint32_t year, month, day; + if (!SCPI_ParamUnsignedInt(context, &year, true) || !SCPI_ParamUnsignedInt(context, &month, true) || !SCPI_ParamUnsignedInt(context, &day, true)) { + return SCPI_RES_ERR; + } + if (ridenScpi->ridenModbus.set_date(year, month, day)) { + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::SystemDateQ(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + tm clock; + if (!ridenScpi->ridenModbus.get_clock(clock)) { + int32_t result[] = {clock.tm_year + 1900, clock.tm_mon + 1, clock.tm_mday}; + SCPI_ResultArrayInt32(context, result, 3, SCPI_FORMAT_NORMAL); + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::SystemTime(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + uint32_t hour, minute, second; + if (!SCPI_ParamUnsignedInt(context, &hour, true) || !SCPI_ParamUnsignedInt(context, &minute, true) || !SCPI_ParamUnsignedInt(context, &second, true)) { + return SCPI_RES_ERR; + } + if (ridenScpi->ridenModbus.set_time(hour, minute, second)) { + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::SystemTimeQ(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + tm clock; + if (!ridenScpi->ridenModbus.get_clock(clock)) { + int32_t result[] = {clock.tm_hour, clock.tm_min, clock.tm_sec}; + SCPI_ResultArrayInt32(context, result, 3, SCPI_FORMAT_NORMAL); + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::OutputState(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + + bool on; + if (!SCPI_ParamBool(context, &on, true)) { + return SCPI_RES_ERR; + } + if (ridenScpi->ridenModbus.set_output_on(on)) { + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::OutputStateQ(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + + bool on; + if (ridenScpi->ridenModbus.get_output_on(on)) { + SCPI_ResultBool(context, on); + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::OutputModeQ(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + OutputMode output_mode; + if (ridenScpi->ridenModbus.get_output_mode(output_mode)) { + switch (output_mode) { + case OutputMode::CONSTANT_VOLTAGE: + SCPI_ResultText(context, "CV"); + return SCPI_RES_OK; + case OutputMode::CONSTANT_CURRENT: + SCPI_ResultText(context, "CC"); + return SCPI_RES_OK; + default: + SCPI_ResultText(context, "XX"); + return SCPI_RES_OK; + } + } + return SCPI_RES_ERR; +} + +scpi_result_t RidenScpi::SourceVoltage(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + scpi_choice_def_t special; + scpi_number_t value; + + if (!SCPI_ParamNumber(context, &special, &value, TRUE)) { + return SCPI_RES_ERR; + } + if (value.unit != SCPI_UNIT_NONE && value.unit != SCPI_UNIT_VOLT) { + SCPI_ErrorPush(context, SCPI_ERROR_DATA_TYPE_ERROR); + return SCPI_RES_ERR; + } + if (ridenScpi->ridenModbus.set_voltage_set(value.content.value)) { + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::SourceVoltageQ(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + double voltage; + + if (ridenScpi->ridenModbus.get_voltage_set(voltage)) { + SCPI_ResultDouble(context, voltage); + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::SourceVoltageProtectionTrippedQ(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + Protection protection; + if (ridenScpi->ridenModbus.get_protection(protection)) { + SCPI_ResultBool(context, protection == Protection::OVP); + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::SourceCurrent(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + scpi_choice_def_t special; + scpi_number_t value; + + if (!SCPI_ParamNumber(context, &special, &value, TRUE)) { + return SCPI_RES_ERR; + } + if (value.unit != SCPI_UNIT_NONE && value.unit != SCPI_UNIT_AMPER) { + SCPI_ErrorPush(context, SCPI_ERROR_DATA_TYPE_ERROR); + return SCPI_RES_ERR; + } + if (ridenScpi->ridenModbus.set_current_set(value.content.value)) { + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::SourceCurrentQ(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + double current; + + if (ridenScpi->ridenModbus.get_current_set(current)) { + SCPI_ResultDouble(context, current); + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::SourceCurrentProtectionTrippedQ(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + Protection protection; + if (ridenScpi->ridenModbus.get_protection(protection)) { + SCPI_ResultBool(context, protection == Protection::OCP); + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::MeasureVoltageQ(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + double voltage; + + if (ridenScpi->ridenModbus.get_voltage_out(voltage)) { + SCPI_ResultDouble(context, voltage); + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::MeasureCurrentQ(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + double current; + + if (ridenScpi->ridenModbus.get_current_out(current)) { + SCPI_ResultDouble(context, current); + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::MeasurePowerQ(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + double power; + + if (ridenScpi->ridenModbus.get_power_out(power)) { + SCPI_ResultDouble(context, power); + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::MeasureTemperatureQ(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + int32_t choice; + if (!SCPI_ParamChoice(context, temperature_options, &choice, TRUE)) { + SCPI_ErrorPush(context, SCPI_ERROR_ILLEGAL_PARAMETER_VALUE); + return SCPI_RES_ERR; + } + double temperature; + bool success; + if (choice == 0) { + success = ridenScpi->ridenModbus.get_system_temperature_celsius(temperature); + } else { + success = ridenScpi->ridenModbus.get_probe_temperature_celsius(temperature); + } + if (success) { + SCPI_ResultDouble(context, temperature); + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::SourceVoltageLimit(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + + scpi_choice_def_t special; + scpi_number_t value; + + if (!SCPI_ParamNumber(context, &special, &value, TRUE)) { + return SCPI_RES_ERR; + } + if (value.unit != SCPI_UNIT_NONE && value.unit != SCPI_UNIT_VOLT) { + SCPI_ErrorPush(context, SCPI_ERROR_DATA_TYPE_ERROR); + return SCPI_RES_ERR; + } + if (ridenScpi->ridenModbus.set_over_voltage_protection(value.content.value)) { + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::SourceCurrentLimit(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + + scpi_choice_def_t special; + scpi_number_t value; + + if (!SCPI_ParamNumber(context, &special, &value, TRUE)) { + return SCPI_RES_ERR; + } + if (value.unit != SCPI_UNIT_NONE && value.unit != SCPI_UNIT_AMPER) { + SCPI_ErrorPush(context, SCPI_ERROR_DATA_TYPE_ERROR); + return SCPI_RES_ERR; + } + if (ridenScpi->ridenModbus.set_over_current_protection(value.content.value)) { + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::SystemBeeperState(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + bool on; + if (!SCPI_ParamBool(context, &on, TRUE)) { + return SCPI_RES_ERR; + } + if (ridenScpi->ridenModbus.set_buzzer_enabled(on)) { + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +scpi_result_t RidenScpi::SystemBeeperStateQ(scpi_t *context) +{ + RidenScpi *ridenScpi = (RidenScpi *)context->user_context; + bool on; + if (ridenScpi->ridenModbus.is_buzzer_enabled(on)) { + SCPI_ResultBool(context, on); + return SCPI_RES_OK; + } else { + SCPI_ErrorPush(context, SCPI_ERROR_COMMAND); + return SCPI_RES_ERR; + } +} + +bool RidenScpi::begin() +{ + if (initialized) { + return true; + } + + LOG_LN("RidenScpi initializing"); + + String type = ridenModbus.get_type(); + uint32_t serial_number; + ridenModbus.get_serial_number(serial_number); + uint16_t firmware_version; + ridenModbus.get_firmware_version(firmware_version); + memcpy(idn2, type.c_str(), type.length()); + sprintf(idn3, "%08u", serial_number); + sprintf(idn4, "%u.%u", firmware_version / 100, firmware_version % 100); + + const char *idn1 = "Riden"; // + SCPI_Init(&scpi_context, + scpi_commands, + &scpi_interface, + scpi_units_def, + idn1, idn2, idn3, idn4, + scpi_input_buffer, SCPI_INPUT_BUFFER_LENGTH, + scpi_error_queue_data, SCPI_ERROR_QUEUE_SIZE); + scpi_context.user_context = this; + + // Start TCP listener + tcpServer.begin(); + tcpServer.setNoDelay(true); + + // Add scpi-raw service to MDNS-SD + MDNS.addService("scpi-raw", "tcp", tcpServer.port()); + + LOG_LN("RidenScpi initialized"); + + initialized = true; + return true; +} + +bool RidenScpi::loop() +{ + // check for any new client connecting, and say hello (before any incoming data) + WiFiClient newClient = tcpServer.accept(); + if (newClient) { + bool reject = true; + for (byte i = 0; i < SCPI_MAX_CLIENTS; i++) { + if (!clients[i]) { + // Once we "accept", the client is no longer tracked by WiFiServer + // so we must store it into our list of clients + newClient.setTimeout(100); + newClient.setNoDelay(true); + clients[i] = newClient; + reject = false; + break; + } + } + if (reject) { + newClient.stop(); + } + } + + static char readBuffer[READ_BUFFER_LENGTH]; + + // check for incoming data from all clients + for (byte i = 0; i < SCPI_MAX_CLIENTS; i++) { + if (clients[i].available() > 0) { + size_t bytesRead = clients[i].readBytes(readBuffer, clients[i].available()); + SCPI_Input(&scpi_context, readBuffer, bytesRead); + } + } + + // stop any clients which disconnect + for (byte i = 0; i < SCPI_MAX_CLIENTS; i++) { + if (clients[i] && !clients[i].connected()) { + clients[i].stop(); + } + } + + return true; +} + +uint16_t RidenScpi::port() +{ + return tcpServer.port(); +} + +std::list RidenScpi::get_connected_clients() +{ + std::list connected_clients; + for (byte i = 0; i < SCPI_MAX_CLIENTS; i++) { + if (clients[i] && clients[i].connected()) { + LOG_LN(clients[i].remoteIP()); + connected_clients.push_back(clients[i].remoteIP().toString()); + } + } + return connected_clients; +} + +void RidenScpi::disconnect_client(String ip) +{ + for (byte i = 0; i < SCPI_MAX_CLIENTS; i++) { + if (clients[i] && clients[i].connected() && clients[i].remoteIP().toString() == ip) { + clients[i].stop(); + } + } +}