14 Commits

Author SHA1 Message Date
9cb4734093 csv_2_plot: fix assignment of 'elapsed_seconds' column to avoid SettingWithCopyWarning
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 16:12:51 +09:00
b734915040 monitor: reduce averaging and adjust conversion times for improved responsiveness
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 15:28:32 +09:00
a84b504733 monitor: adjust sensor period range to support lower limit of 100ms
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 14:17:41 +09:00
222de64932 csv_2_plot: refine Y-axis major tick interval calculation for better alignment with data ranges
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 14:16:24 +09:00
1f35c52261 csv_2_plot: fix timestamp column assignment during timezone conversion
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 13:45:30 +09:00
14440094ac csv_2_plot: set matplotlib backend to 'Agg' for non-GUI environments
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 13:43:09 +09:00
11f9c72543 csv_2_plot: add support for customizing x-axis grid and label intervals
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 12:17:04 +09:00
c98e735410 csv_2_plot: add support for plotting with relative time on the x-axis
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 12:10:25 +09:00
5a505a5205 csv_2_plot: add Y-axis max value customization for voltage, current, and power
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 11:59:55 +09:00
6a5ec86505 update logger README to include detailed usage instructions and examples
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-10 10:01:26 +09:00
e20f4b9f74 csv_2_plot: update title margin
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-10 10:01:26 +09:00
56e1f619e1 csv_2_plot: update y-axis gridline colors
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-10 10:01:26 +09:00
8930a36eaf csv_2_plot: improve plot configuration and add average voltage/interval calculations
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-10 10:01:26 +09:00
194474fdff logger: save time on UTC
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-10 10:00:27 +09:00
7 changed files with 305 additions and 87 deletions

View File

@@ -1,46 +1,140 @@
# Power Consumption Logger Example
# Odroid PowerMate Logger and Plotter
Based on this script, you can monitor power consumption and implement graph plotting.
This directory contains two Python scripts to log power data from an Odroid PowerMate device and visualize it.
## How to Run the Script
1. `logger.py`: Connects to the device's web server, authenticates, and logs real-time power data from its WebSocket to a CSV file.
2. `csv_2_plot.py`: Reads the generated CSV file and creates a plot image of the power, voltage, and current data over time.
### Install Python Virtual Environment
## Prerequisites
```shell
### 1. Clone this example
```bash
git clone https://github.com/hardkernel/odroid-powermate.git
cd odroid-powermate/example/logger
```
```shell
sudo apt install virtualenv
virtualenv venv
source venv/bin/activate
### 2. Python and Virtual Environment
It is highly recommended to use a Python virtual environment to manage project dependencies and avoid conflicts with other projects.
Ensure you have Python 3 installed.
1. **Create a virtual environment:**
Open your terminal in this directory and run:
```bash
python3 -m venv venv
```
This will create a `venv` directory containing the Python interpreter and libraries.
2. **Activate the virtual environment:**
* **On Windows:**
```powershell
.\venv\Scripts\activate
```
* **On macOS and Linux:**
```bash
source venv/bin/activate
```
Your terminal prompt should now show `(venv)` at the beginning, indicating that the virtual environment is active.
### 3. Install Required Libraries
With the virtual environment activated, install the necessary Python packages:
```bash
pip3 install requests websockets protobuf pandas matplotlib python-dateutil
```
### Install require package
### 4. Protobuf Generated File
```shell
pip install grpcio-tools requests websockets protobuf pandas matplotlib
The `logger.py` script uses Google Protocol Buffers (Protobuf) to decode real-time data from the WebSocket. This requires a Python file, `status_pb2.py`, which is generated from a Protobuf definition file (`status.proto`).
**How to Generate `status_pb2.py`:**
1. **Install Protobuf Compiler Tools:**
You need the `grpcio-tools` package, which includes the `protoc` compiler and Python plugins. You can install it via pip:
```bash
pip3 install grpcio-tools
```
2. **Locate the `.proto` file:**
Ensure you have the `status.proto` file in the current directory. This file defines the structure of the data messages.
3. **Run the Compiler:**
Execute the following command in your terminal. This command tells `protoc` to look for `status.proto` in the directory (`-I../../proto`) and generate the Python output file (`--python_out=.`) in the same place.
```bash
python3 -m grpc_tools.protoc -I../../proto --python_out=. status.proto
```
After running this command, the `status_pb2.py` file will be created, and `logger.py` will be able to use it.
## Usage
The process is a two-step workflow: first log the data, then plot it.
### Step 1: Log Power Data with `logger.py`
Run `logger.py` to connect to your Odroid Smart Power device and save the data to a CSV file.
**Syntax:**
```bash
python3 logger.py <host> -u <username> -p <password> -o <output_file.csv>
```
### Build `status_pb2.py`
**Arguments:**
* `host`: The IP address or hostname of the Odroid Smart Power device (e.g., `192.168.1.50`).
* `-u`, `--username`: The username for logging in.
* `-p`, `--password`: The password for logging in.
* `-o`, `--output`: The path to save the output CSV file. This is required if you want to generate a plot.
```shell
python -m grpc_tools.protoc -I ../../proto --python_out=. status.proto
**Example:**
This command will log in and save the power data to `power_log.csv`.
```bash
python3 logger.py 192.168.1.50 -u admin -p mypassword -o power_log.csv
```
### Execute script
The script will continue to log data until you stop it with `Ctrl+C`.
#### Power consumption collection
```shell
# python3 logger.py -u <username> -o <name.csv> -p <password> <address>
python3 logger.py -u admin -p password -o test.csv 192.168.30.5
### Step 2: Generate a Plot with `csv_2_plot.py`
Once you have a CSV log file, you can use `csv_2_plot.py` to create a visual graph.
You can also use the csv file recorded from PowerMate Web.
**Syntax:**
```bash
python3 csv_2_plot.py <input.csv> <output.png> [options]
```
#### Plot data
**Arguments:**
* `input_csv`: The path to the CSV file generated by `logger.py`.
* `output_image`: The path to save the output plot image (e.g., `plot.png`).
```shell
python3 csv_2_plot.py test.csv plot.png [--type power voltage current] [--source vin main usb]
**Optional Arguments:**
* `-t`, `--type`: Specify which plots to generate. Choices are `power`, `voltage`, `current`. Default is all three.
* `-s`, `--source`: Specify which power sources to include. Choices are `vin`, `main`, `usb`. Default is all three.
**Example 1: Default Plot**
This command reads `power_log.csv` and generates a plot containing power, voltage, and current for all sources, saving it as `power_graph.png`.
```bash
python3 csv_2_plot.py power_log.csv power_graph.png
```
![plot.png](plot.png)
**Example 2: Custom Plot**
This command generates a plot showing only the **power** and **current** for the **MAIN** and **USB** sources.
```bash
# main, usb power consumption
python csv_2_plot.py power_log.csv custom_plot.png --type power --source main usb
```
## Example Output
Running the plot script will generate an image file similar to this:
![plot.png](img/plot.png)
The 5-unit scale is highlighted with a blue dotted line, and the 10-unit scale is highlighted with a red dotted line.

View File

@@ -1,36 +1,53 @@
import argparse
import matplotlib
matplotlib.use('Agg')
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import pandas as pd
from dateutil.tz import gettz
from matplotlib.ticker import MultipleLocator, FuncFormatter
import math
def plot_power_data(csv_path, output_path, plot_types, sources):
def plot_power_data(csv_path, output_path, plot_types, sources,
voltage_y_max=None, current_y_max=None, power_y_max=None,
relative_time=False, time_x_line=None, time_x_label=None):
"""
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.
plot_types (list): A list of strings indicating which plots to generate
(e.g., ['power', 'voltage', 'current']).
sources (list): A list of strings indicating which power sources to plot
(e.g., ['vin', 'main', 'usb']).
plot_types (list): A list of strings indicating which plots to generate.
sources (list): A list of strings indicating which power sources to plot.
voltage_y_max (float, optional): Maximum value for the voltage plot's Y-axis.
current_y_max (float, optional): Maximum value for the current plot's Y-axis.
power_y_max (float, optional): Maximum value for the power plot's Y-axis.
relative_time (bool): If True, the x-axis will show elapsed time from the start.
time_x_line (float, optional): Interval in seconds for x-axis grid lines.
time_x_label (float, optional): Interval in seconds for x-axis labels.
"""
try:
# Read the CSV file into a pandas DataFrame
# The 'timestamp' column is parsed as dates. Pandas automatically recognizes
# the ISO format (with 'Z') as UTC.
df = pd.read_csv(csv_path, parse_dates=['timestamp'])
print(f"Successfully loaded {len(df)} records from '{csv_path}'")
# --- Timezone Conversion ---
# Get the system's local timezone
local_tz = gettz()
# The timestamp from CSV is already UTC-aware.
# Convert it to the system's local timezone for plotting.
df['timestamp'] = df['timestamp'].dt.tz_convert(local_tz)
print(f"Timestamp converted to local timezone: {local_tz}")
if df.empty:
print("CSV file is empty. Exiting.")
return
# --- Time Handling ---
x_axis_data = df['timestamp']
if relative_time:
start_time = df['timestamp'].iloc[0]
df.loc[:, 'elapsed_seconds'] = (df['timestamp'] - start_time).dt.total_seconds()
x_axis_data = df['elapsed_seconds']
print("X-axis set to relative time (elapsed seconds).")
else:
# --- Timezone Conversion for absolute time ---
local_tz = gettz()
df.loc[:, 'timestamp'] = df['timestamp'].dt.tz_convert(local_tz)
print(f"Timestamp converted to local timezone: {local_tz}")
except FileNotFoundError:
print(f"Error: The file '{csv_path}' was not found.")
@@ -39,24 +56,37 @@ def plot_power_data(csv_path, output_path, plot_types, sources):
print(f"An error occurred while reading the CSV file: {e}")
return
# --- Calculate Average Interval ---
avg_interval_ms = 0
if len(df) > 1:
avg_interval = df['timestamp'].diff().mean()
avg_interval_ms = avg_interval.total_seconds() * 1000
# --- Calculate Average Voltages ---
avg_voltages = {}
for source in sources:
voltage_col = f'{source}_voltage'
if voltage_col in df.columns:
avg_voltages[source] = df[voltage_col].mean()
# --- Plotting Configuration ---
# Y-axis scale settings from chart.js
scale_config = {
'power': {'steps': [5, 20, 50, 160]},
'voltage': {'steps': [5, 10, 15, 25]},
'current': {'steps': [1, 2.5, 5, 10]}
}
plot_configs = {
'power': {'title': 'Power Consumption', 'ylabel': 'Power (W)',
'cols': [f'{s}_power' for s in sources]},
'voltage': {'title': 'Voltage', 'ylabel': 'Voltage (V)',
'cols': [f'{s}_voltage' for s in sources]},
'current': {'title': 'Current', 'ylabel': 'Current (A)',
'cols': [f'{s}_current' for s in sources]}
'power': {'title': 'Power Consumption', 'ylabel': 'Power (W)', 'cols': [f'{s}_power' for s in sources]},
'voltage': {'title': 'Voltage', 'ylabel': 'Voltage (V)', 'cols': [f'{s}_voltage' for s in sources]},
'current': {'title': 'Current', 'ylabel': 'Current (A)', 'cols': [f'{s}_current' for s in sources]}
}
y_max_options = {
'power': power_y_max,
'voltage': voltage_y_max,
'current': current_y_max
}
channel_labels = [s.upper() for s in sources]
# Define a color map for all possible sources
color_map = {'vin': 'red', 'main': 'green', 'usb': 'blue'}
channel_colors = [color_map[s] for s in sources]
@@ -65,20 +95,17 @@ def plot_power_data(csv_path, output_path, plot_types, sources):
print("No plot types selected. Exiting.")
return
# Create a figure and a set of subplots based on the number of selected plot types.
fig, axes = plt.subplots(num_plots, 1, figsize=(15, 6 * num_plots), sharex=True, squeeze=False)
axes = axes.flatten() # Flatten the 2D array to 1D for easier iteration
fig, axes = plt.subplots(num_plots, 1, figsize=(15, 9 * num_plots), sharex=True, squeeze=False)
axes = axes.flatten()
# --- Loop through selected plot types and generate plots ---
for i, plot_type in enumerate(plot_types):
ax = axes[i]
config = plot_configs[plot_type]
max_data_value = 0
for j, col_name in enumerate(config['cols']):
if col_name in df.columns:
ax.plot(df['timestamp'], df[col_name], label=channel_labels[j], color=channel_colors[j])
# Find the maximum value in the current column to set the y-axis limit
ax.plot(x_axis_data, df[col_name], label=channel_labels[j], color=channel_colors[j], zorder=2)
max_col_value = df[col_name].max()
if max_col_value > max_data_value:
max_data_value = max_col_value
@@ -86,34 +113,110 @@ def plot_power_data(csv_path, output_path, plot_types, sources):
print(f"Warning: Column '{col_name}' not found in CSV. Skipping.")
# --- Dynamic Y-axis Scaling ---
ax.set_ylim(bottom=0) # Set y-axis minimum to 0
if plot_type in scale_config:
ax.set_ylim(bottom=0)
y_max_option = y_max_options.get(plot_type)
if y_max_option is not None:
ax.set_ylim(top=y_max_option)
elif plot_type in scale_config:
steps = scale_config[plot_type]['steps']
# Find the smallest step that is >= max_data_value
new_max = next((step for step in steps if step >= max_data_value), steps[-1])
ax.set_ylim(top=new_max)
ax.set_title(config['title'])
ax.set_ylabel(config['ylabel'])
ax.legend()
ax.grid(True, which='both', linestyle='--', linewidth=0.5)
# --- Formatting the x-axis (Time) ---
local_tz = gettz()
# --- Y-Grid and Tick Configuration ---
y_min, y_max = ax.get_ylim()
if y_max <= 0:
major_interval = 1.0 # Default for very small or zero range
elif plot_type == 'current' and y_max <= 2.5:
major_interval = 0.5 # Maintain current behavior for very small current values
elif y_max <= 10:
major_interval = 2.0 # Maintain current behavior for small ranges where 5-unit is too coarse
elif y_max <= 25:
major_interval = 5.0 # Already a multiple of 5
else: # y_max > 25
# Aim for major ticks that are multiples of 5.
# Calculate a rough interval to get around 5 major ticks.
rough_interval = y_max / 5.0
# Find the smallest multiple of 5 that is greater than or equal to rough_interval.
# This ensures labels are multiples of 5.
major_interval = math.ceil(rough_interval / 5.0) * 5.0
# Ensure major_interval is not 0 if y_max is small but positive.
if major_interval == 0 and y_max > 0:
major_interval = 5.0
ax.yaxis.set_major_locator(MultipleLocator(major_interval))
ax.yaxis.set_minor_locator(MultipleLocator(1))
ax.yaxis.grid(False, which='major')
ax.yaxis.grid(True, which='minor', linestyle='--', linewidth=0.6, zorder=0)
for y_val in range(int(y_min), int(y_max) + 1):
if y_val == 0: continue
if y_val % 10 == 0:
ax.axhline(y=y_val, color='maroon', linestyle='--', linewidth=1.2, zorder=1)
elif y_val % 5 == 0:
ax.axhline(y=y_val, color='midnightblue', linestyle='--', linewidth=1.2, zorder=1)
# --- X-Grid Configuration ---
ax.xaxis.grid(True, which='major', linestyle='--', linewidth=0.8)
if time_x_line is not None:
ax.xaxis.grid(True, which='minor', linestyle=':', linewidth=0.6)
# --- Formatting the x-axis ---
last_ax = axes[-1]
# Pass the timezone to the formatter
last_ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S', tz=local_tz))
last_ax.xaxis.set_major_locator(plt.MaxNLocator(15)) # Limit the number of ticks
plt.xlabel(f'Time ({local_tz.tzname(df["timestamp"].iloc[-1])})') # Display timezone name
if not df.empty:
last_ax.set_xlim(x_axis_data.iloc[0], x_axis_data.iloc[-1])
if relative_time:
plt.xlabel('Elapsed Time (seconds)')
if time_x_label is not None:
last_ax.xaxis.set_major_locator(MultipleLocator(time_x_label))
else:
last_ax.xaxis.set_major_locator(plt.MaxNLocator(15))
if time_x_line is not None:
last_ax.xaxis.set_minor_locator(MultipleLocator(time_x_line))
else:
local_tz = gettz()
plt.xlabel(f'Time ({local_tz.tzname(df["timestamp"].iloc[-1])})')
last_ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S', tz=local_tz))
if time_x_label is not None:
last_ax.xaxis.set_major_locator(mdates.SecondLocator(interval=int(time_x_label)))
else:
last_ax.xaxis.set_major_locator(plt.MaxNLocator(15))
if time_x_line is not None:
last_ax.xaxis.set_minor_locator(mdates.SecondLocator(interval=int(time_x_line)))
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'PowerMate Log ({start_time} to {end_time})', fontsize=16, y=0.95)
# --- Add a main title and subtitle ---
if relative_time:
main_title = 'PowerMate Log'
else:
start_time_str = df['timestamp'].iloc[0].strftime('%Y-%m-%d %H:%M:%S')
end_time_str = df['timestamp'].iloc[-1].strftime('%H:%M:%S')
main_title = f'PowerMate Log ({start_time_str} to {end_time_str})'
# Adjust layout to prevent titles/labels from overlapping
plt.tight_layout(rect=[0, 0, 1, 0.94])
subtitle_parts = []
if avg_interval_ms > 0:
subtitle_parts.append(f'Avg. Interval: {avg_interval_ms:.2f} ms')
voltage_strings = [f'{source.upper()} Avg: {avg_v:.2f} V' for source, avg_v in avg_voltages.items()]
if voltage_strings:
subtitle_parts.extend(voltage_strings)
subtitle = ' | '.join(subtitle_parts)
full_title = main_title
if subtitle:
full_title += f'\n{subtitle}'
fig.suptitle(full_title, fontsize=14)
plt.tight_layout(rect=[0, 0, 1, 0.98])
# --- Save the plot to a file ---
try:
@@ -143,9 +246,32 @@ def main():
help="Power sources to plot. Choose from 'vin', 'main', 'usb'. "
"Default is to plot all three."
)
parser.add_argument("--voltage_y_max", type=float, help="Maximum value for the voltage plot's Y-axis.")
parser.add_argument("--current_y_max", type=float, help="Maximum value for the current plot's Y-axis.")
parser.add_argument("--power_y_max", type=float, help="Maximum value for the power plot's Y-axis.")
parser.add_argument(
"-r", "--relative-time",
action='store_true',
help="Display the x-axis as elapsed time from the start (in seconds) instead of absolute time."
)
parser.add_argument("--time_x_line", type=float, help="Interval in seconds for x-axis grid lines.")
parser.add_argument("--time_x_label", type=float, help="Interval in seconds for x-axis labels.")
args = parser.parse_args()
plot_power_data(args.input_csv, args.output_image, args.type, args.source)
plot_power_data(
args.input_csv,
args.output_image,
args.type,
args.source,
voltage_y_max=args.voltage_y_max,
current_y_max=args.current_y_max,
power_y_max=args.power_y_max,
relative_time=args.relative_time,
time_x_line=args.time_x_line,
time_x_label=args.time_x_label
)
if __name__ == "__main__":

BIN
example/logger/img/plot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

View File

@@ -3,7 +3,7 @@ import asyncio
import csv
import requests
import websockets
from datetime import datetime
from datetime import datetime, timezone
# Import the status_pb2.py file generated by `protoc`.
# This file must be in the same directory as logger.py.
@@ -68,7 +68,7 @@ class OdroidPowerLogger:
csv_file = open(self.output_file, 'w', newline='', encoding='utf-8')
csv_writer = csv.writer(csv_file)
# Write header - matches main.js and csv_2_plot.py expectations
# Write header
header = [
'timestamp', 'uptime_ms',
'vin_voltage', 'vin_current', 'vin_power',
@@ -97,24 +97,22 @@ class OdroidPowerLogger:
# Process only if the payload type is 'sensor_data'
if status_message.WhichOneof('payload') == 'sensor_data':
sensor_data = status_message.sensor_data
# Format timestamp to ISO format with 'Z' for UTC, matching main.js
ts_dt = datetime.fromtimestamp(sensor_data.timestamp_ms / 1000)
ts_iso = ts_dt.isoformat(timespec='milliseconds') + 'Z'
ts_dt = datetime.fromtimestamp(sensor_data.timestamp_ms / 1000, tz=timezone.utc)
ts_str_print = ts_dt.strftime('%Y-%m-%d %H:%M:%S UTC')
# Print data for console output (can be adjusted if needed)
print(f"--- {ts_iso} (Uptime: {sensor_data.uptime_ms / 1000:.3f}s) ---")
print(f"--- {ts_str_print} (Uptime: {sensor_data.uptime_ms / 1000}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:.3f} V | {channel.current:.3f} A | {channel.power:.3f} W")
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:
# Format numerical values to 3 decimal places, matching main.js
ts_iso_csv = ts_dt.isoformat(timespec='milliseconds').replace('+00:00', 'Z')
row = [
ts_iso,
sensor_data.uptime_ms,
ts_iso_csv, sensor_data.uptime_ms,
f"{sensor_data.vin.voltage:.3f}", f"{sensor_data.vin.current:.3f}", f"{sensor_data.vin.power:.3f}",
f"{sensor_data.main.voltage:.3f}", f"{sensor_data.main.current:.3f}", f"{sensor_data.main.power:.3f}",
f"{sensor_data.usb.voltage:.3f}", f"{sensor_data.usb.current:.3f}", f"{sensor_data.usb.power:.3f}"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

View File

@@ -54,9 +54,9 @@ ina3221_t ina3221 = {
.ch1 = true, // channel 1 enable
.ch2 = true, // channel 2 enable
.ch3 = true, // channel 3 enable
.avg = INA3221_AVG_64, // 64 samples average
.vbus = INA3221_CT_2116, // 2ms by channel (bus)
.vsht = INA3221_CT_2116, // 2ms by channel (shunt)
.avg = INA3221_AVG_16, // 16 samples average
.vbus = INA3221_CT_140, // 140us by channel (bus)
.vsht = INA3221_CT_1100, // 1.1ms by channel (shunt)
},
};
@@ -298,7 +298,7 @@ void init_status_monitor()
esp_err_t update_sensor_period(int period)
{
if (period < 500 || period > 10000) // 0.5 sec ~ 10 sec
if (period < 100 || period > 10000) // 0.1 sec ~ 10 sec
{
return ESP_ERR_INVALID_ARG;
}

View File

@@ -379,7 +379,7 @@
</div>
<div class="mb-3 p-3 border rounded">
<label for="period-slider" class="form-label">Sensor Period: <span class="fw-bold text-primary" id="period-value">...</span> ms</label>
<input type="range" class="form-range" id="period-slider" min="500" max="5000" step="100">
<input type="range" class="form-range" id="period-slider" min="100" max="5000" step="100">
<div class="d-flex justify-content-end mt-2">
<button type="button" class="btn btn-primary btn-sm" id="period-apply-button">Apply</button>
</div>