diff --git a/example/logger/README.md b/example/logger/README.md new file mode 100644 index 0000000..edd95b8 --- /dev/null +++ b/example/logger/README.md @@ -0,0 +1,41 @@ +# Power Consumption Logger Example + +Based on this script, you can monitor power consumption and implement graph plotting. + +## How to Run the Script + +### Install Python Virtual Environment + +```shell +sudo apt install virtualenv +virtualenv venv +source venv/bin/activate +``` + +### Install require package + +```shell +pip install grpcio-tools requests websockets protobuf pandas matplotlib +``` + +### Build `status_pb2.py` + +```shell +python -m grpc_tools.protoc -I ../../proto --python_out=. status.proto +``` + +### Execute script + +#### Power consumption collection +```shell +# python3 logger.py -u -o -p
+python3 logger.py -u admin -p password -o test.csv 192.168.30.5 +``` + +#### Plot data + +```shell +python3 csv_2_plot.py test.csv plot.png +``` + +![plot.png](plot.png) \ No newline at end of file diff --git a/example/logger/csv_2_plot.py b/example/logger/csv_2_plot.py new file mode 100644 index 0000000..5bcb5d0 --- /dev/null +++ b/example/logger/csv_2_plot.py @@ -0,0 +1,95 @@ +import argparse +import matplotlib.dates as mdates +import matplotlib.pyplot as plt +import os +import pandas as pd + + +def plot_power_data(csv_path, output_path): + """ + Reads power data from a CSV file and generates a plot image. + + Args: + csv_path (str): The path to the input CSV file. + output_path (str): The path to save the output plot image. + """ + try: + # Read the CSV file into a pandas DataFrame + # The 'timestamp' column is parsed as dates + df = pd.read_csv(csv_path, parse_dates=['timestamp']) + print(f"Successfully loaded {len(df)} records from '{csv_path}'") + except FileNotFoundError: + print(f"Error: The file '{csv_path}' was not found.") + return + except Exception as e: + print(f"An error occurred while reading the CSV file: {e}") + return + + # Create a figure and a set of subplots (3 rows, 1 column) + # sharex=True makes all subplots share the same x-axis (time) + fig, axes = plt.subplots(3, 1, figsize=(15, 18), sharex=True) + + # --- Plot 1: Power (W) --- + ax1 = axes[0] + ax1.plot(df['timestamp'], df['vin_power'], label='VIN', color='red') + ax1.plot(df['timestamp'], df['main_power'], label='MAIN', color='green') + ax1.plot(df['timestamp'], df['usb_power'], label='USB', color='blue') + ax1.set_title('Power Consumption') + ax1.set_ylabel('Power (W)') + ax1.legend() + ax1.grid(True, which='both', linestyle='--', linewidth=0.5) + + # --- Plot 2: Voltage (V) --- + ax2 = axes[1] + ax2.plot(df['timestamp'], df['vin_voltage'], label='VIN', color='red') + ax2.plot(df['timestamp'], df['main_voltage'], label='MAIN', color='green') + ax2.plot(df['timestamp'], df['usb_voltage'], label='USB', color='blue') + ax2.set_title('Voltage') + ax2.set_ylabel('Voltage (V)') + ax2.legend() + ax2.grid(True, which='both', linestyle='--', linewidth=0.5) + + # --- Plot 3: Current (A) --- + ax3 = axes[2] + ax3.plot(df['timestamp'], df['vin_current'], label='VIN', color='red') + ax3.plot(df['timestamp'], df['main_current'], label='MAIN', color='green') + ax3.plot(df['timestamp'], df['usb_current'], label='USB', color='blue') + ax3.set_title('Current') + ax3.set_ylabel('Current (A)') + ax3.legend() + ax3.grid(True, which='both', linestyle='--', linewidth=0.5) + + # --- Formatting the x-axis (Time) --- + # Improve date formatting on the x-axis + ax3.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S')) + ax3.xaxis.set_major_locator(plt.MaxNLocator(15)) # Limit the number of ticks + plt.xlabel('Time') + plt.xticks(rotation=45) + + # Add a main title to the figure + start_time = df['timestamp'].iloc[0].strftime('%Y-%m-%d %H:%M:%S') + end_time = df['timestamp'].iloc[-1].strftime('%H:%M:%S') + fig.suptitle(f'ODROID Power Log ({start_time} to {end_time})', fontsize=16, y=0.95) + + # Adjust layout to prevent titles/labels from overlapping + plt.tight_layout(rect=[0, 0, 1, 0.94]) + + # --- Save the plot to a file --- + try: + plt.savefig(output_path, dpi=150) + print(f"Plot successfully saved to '{output_path}'") + except Exception as e: + print(f"An error occurred while saving the plot: {e}") + + +def main(): + parser = argparse.ArgumentParser(description="Generate a plot from an Odroid PowerMate CSV log file.") + parser.add_argument("input_csv", help="Path to the input CSV log file.") + parser.add_argument("output_image", help="Path to save the output plot image (e.g., plot.png).") + args = parser.parse_args() + + plot_power_data(args.input_csv, args.output_image) + + +if __name__ == "__main__": + main() diff --git a/example/logger/logger.py b/example/logger/logger.py new file mode 100644 index 0000000..de8a720 --- /dev/null +++ b/example/logger/logger.py @@ -0,0 +1,152 @@ +import argparse +import asyncio +import csv +import requests +import websockets +from datetime import datetime + +# Import the status_pb2.py file generated by `protoc`. +# This file must be in the same directory as logger.py. +import status_pb2 + + +class OdroidPowerLogger: + """ + A class to connect to the Odroid Smart Power monitoring server and log power data. + 1. Logs into the server via an HTTP POST request to obtain an authentication token. + 2. Connects to the WebSocket using the obtained token. + 3. Receives and decodes binary data in Protobuf format, then prints it. + """ + + def __init__(self, host, username, password, output_file=None): + self.host = host + self.username = username + self.password = password + self.base_url = f"http://{self.host}" + self.ws_url = f"ws://{self.host}/ws" + self.output_file = output_file + self.token = None + + def login(self): + """Logs into the server to retrieve an authentication token.""" + login_url = f"{self.base_url}/login" + payload = {"username": self.username, "password": self.password} + try: + print(f"Attempting to log in to '{login_url}'...") + response = requests.post(login_url, json=payload, timeout=5) + response.raise_for_status() + + response_json = response.json() + if "token" in response_json: + self.token = response_json["token"] + print("Login successful! Token received.") + return True + else: + print("Login failed: No token in response.") + return False + except requests.exceptions.RequestException as e: + print(f"Error during login: {e}") + return False + + async def listen_power_data(self): + """Connects to the WebSocket to receive and log power data.""" + if not self.token: + print("Cannot connect to WebSocket without an authentication token.") + return + + # Add the authentication token as a query parameter + uri = f"{self.ws_url}?token={self.token}" + + csv_file = None + csv_writer = None + + try: + # --- CSV File Handling --- + if self.output_file: + try: + # Open the file in write mode, with newline='' to prevent extra blank rows + csv_file = open(self.output_file, 'w', newline='', encoding='utf-8') + csv_writer = csv.writer(csv_file) + + # Write header + header = [ + 'timestamp', 'uptime_sec', + 'vin_voltage', 'vin_current', 'vin_power', + 'main_voltage', 'main_current', 'main_power', + 'usb_voltage', 'usb_current', 'usb_power' + ] + csv_writer.writerow(header) + print(f"Logging data to {self.output_file}") + except IOError as e: + print(f"Error opening CSV file: {e}") + # If file can't be opened, disable CSV writing + csv_file = None + csv_writer = None + # --- End CSV File Handling --- + + async with websockets.connect(uri) as websocket: + print(f"Connected to WebSocket: {uri}") + while True: + # Receive binary message from the server + message_bytes = await websocket.recv() + + # Decode the Protobuf message + status_message = status_pb2.StatusMessage() + status_message.ParseFromString(message_bytes) + + # Process only if the payload type is 'sensor_data' + if status_message.WhichOneof('payload') == 'sensor_data': + sensor_data = status_message.sensor_data + ts_dt = datetime.fromtimestamp(sensor_data.timestamp) + ts_str = ts_dt.strftime('%Y-%m-%d %H:%M:%S') + + print(f"--- {ts_str} (Uptime: {sensor_data.uptime_sec}s) ---") + + # Print data for each channel + for name, channel in [('VIN', sensor_data.vin), ('MAIN', sensor_data.main), + ('USB', sensor_data.usb)]: + print( + f" {name:<4}: {channel.voltage:5.2f} V | {channel.current:5.3f} A | {channel.power:5.2f} W") + + # Write to CSV if enabled + if csv_writer: + row = [ + ts_dt.isoformat(), sensor_data.uptime_sec, + sensor_data.vin.voltage, sensor_data.vin.current, sensor_data.vin.power, + sensor_data.main.voltage, sensor_data.main.current, sensor_data.main.power, + sensor_data.usb.voltage, sensor_data.usb.current, sensor_data.usb.power + ] + csv_writer.writerow(row) + + except websockets.exceptions.ConnectionClosed as e: + print(f"WebSocket connection closed: {e}") + except Exception as e: + print(f"Error during WebSocket processing: {e}") + finally: + if csv_file: + csv_file.close() + print(f"\nCSV file '{self.output_file}' saved.") + + async def run(self): + """Runs the logger.""" + if self.login(): + await self.listen_power_data() + + +async def main(): + parser = argparse.ArgumentParser(description="Odroid Smart Power Data Logger") + parser.add_argument("host", help="Server's host address or IP (e.g., 192.168.1.10)") + parser.add_argument("-u", "--username", required=True, help="Login username") + parser.add_argument("-p", "--password", required=True, help="Login password") + parser.add_argument("-o", "--output", help="Path to the output CSV file.") + args = parser.parse_args() + + logger = OdroidPowerLogger(host=args.host, username=args.username, password=args.password, output_file=args.output) + await logger.run() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nExiting program.") diff --git a/example/logger/plot.png b/example/logger/plot.png new file mode 100644 index 0000000..1c355d5 Binary files /dev/null and b/example/logger/plot.png differ diff --git a/main/app/odroid-power-mate.c b/main/app/odroid-power-mate.c index 09a8acb..27940ea 100644 --- a/main/app/odroid-power-mate.c +++ b/main/app/odroid-power-mate.c @@ -31,8 +31,6 @@ void app_main(void) } ESP_ERROR_CHECK(ret); - storage_init(); - ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); diff --git a/main/idf_component.yml b/main/idf_component.yml index a133602..d8a35ba 100644 --- a/main/idf_component.yml +++ b/main/idf_component.yml @@ -1,6 +1,5 @@ dependencies: espressif/led_indicator: ^1.1.1 - joltwallet/littlefs: ==1.20.1 esp-idf-lib/ina3221: ^1.1.7 esp-idf-lib/pca9557: ^1.0.7 nikas-belogolov/nanopb: ^1.0.0 diff --git a/main/service/storage.c b/main/service/storage.c deleted file mode 100644 index 35999d2..0000000 --- a/main/service/storage.c +++ /dev/null @@ -1,53 +0,0 @@ -#include "storage.h" -#include -#include -#include -#include -#include "esp_littlefs.h" -#include "esp_log.h" - -static const char* TAG = "datalog"; - -#define MAX_LOG_SIZE (700 * 1024) - -void storage_init(void) -{ - ESP_LOGI(TAG, "Initializing DataLog with LittleFS"); - - esp_vfs_littlefs_conf_t conf = { - .base_path = "/littlefs", - .partition_label = "littlefs", - .format_if_mount_failed = true, - .dont_mount = false, - }; - - esp_err_t ret = esp_vfs_littlefs_register(&conf); - - if (ret != ESP_OK) - { - if (ret == ESP_FAIL) - { - ESP_LOGE(TAG, "Failed to mount or format filesystem"); - } - else if (ret == ESP_ERR_NOT_FOUND) - { - ESP_LOGE(TAG, "Failed to find LittleFS partition"); - } - else - { - ESP_LOGE(TAG, "Failed to initialize LittleFS (%s)", esp_err_to_name(ret)); - } - return; - } - - size_t total = 0, used = 0; - ret = esp_littlefs_info(conf.partition_label, &total, &used); - if (ret != ESP_OK) - { - ESP_LOGE(TAG, "Failed to get LittleFS partition information (%s)", esp_err_to_name(ret)); - } - else - { - ESP_LOGI(TAG, "Partition size: total: %d, used: %d", total, used); - } -} diff --git a/schematic.pdf b/schematic.pdf new file mode 100644 index 0000000..2c9030c Binary files /dev/null and b/schematic.pdf differ