├── .gitignore ├── LICENSE ├── README.md ├── current_viewer.py ├── images ├── auto_off.png ├── example1.gif └── screenshot1.png └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Marius Gheorghescu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CurrentViewer 2 | 3 | CurrentViewer interactive data plot for [LowPowerLab CurrentRanger](https://github.com/LowPowerLab/CurrentRanger). It was designed to make it easier to capture, save and share power state profiles for IoT devices that swing between multiple power states (deep sleep, low power, full power). 4 | 5 | It works on: 6 | - Windows 10 7 | - Linux 8 | - Raspberry Pi 9 | 10 | ![Screenshot](./images/example1.gif) 11 | 12 | *Example above is CurrentViewer in action (exported from the app itself via GIF function).* 13 | 14 | Note: CurrentViewer is not a replacement for an osciloscope, as the readings are done via the internal ADC in SAMD21 [Microchip's ARM® Cortex®-M0+](https://www.microchip.com/wwwproducts/en/ATsamd21g18) which has its limitations. But it might be more convenient way to use CurrentRanger. Sometimes the noise (Vpp) can be comparable with entry level oscilloscopes, but the measurements can be off: CurrentRanger has to be properly calibrated in order for the measurements to match what multimeter or oscilloscope displays. 15 | 16 | # 17 | ## Features: 18 | - Runs on Windows and Linux (MacOS coming soon) 19 | - __Live Interactive chart__ with: 20 | - __100k samples in view and can do millions__: the raw readings are __noise-filtered__ (sub-sampling and average) to improve visualization 21 | - __Last Raw__ and __Window Average__ mesurements and __SPS__ (Samples per Second - how fast CurrentRanger sends samples over USB) 22 | - Point __annotations__: hover the mouse over a certain sample so see the exact value 23 | - __Logaritmic plot__: makes it easy to read any swings from 1 nanoamp to 1 amp. Works with CurrentRanger in AUTORANGE as well as manual mode. 24 | - __Data Export__: __CSV__ and __JSON__ but it can also save the **animated chart as .GIF** - for a convenient way to publish measurements on the web. Look for *current0.gif, current1.gif, etc* in the current folder 25 | - Command line options to tune for performance or batch mode (headless logging). Should be able to display as fast as the instrument can measure and send data over USB-Serial: currently this is around __600-800 samples/second__ (depends on firmware and features enabled on CR) 26 | - Automatically __turns on streaming on CurrentRanger__ (and if you use the new firmware feature with SMART AutoOff now the instrument will stay on as long as CurrentViewer is connected to it). 27 | - **[Pause]** streaming if you want to zoom/pan into the data. The data is still being captured behind (live buffer), when you resume you see an instant refresh 28 | 29 | # 30 | ## Installation 31 | 32 | First clone the repo locally 33 | 34 | ``` 35 | git clone https://github.com/MGX3D/CurrentViewer 36 | ``` 37 | 38 | Recommended environment is Python3 (tested with 3.6+) with matplotlib/mplcursors/pyserial installed. To install all the requirements automatically: 39 | 40 | ``` 41 | pip3 install -r requirements.txt 42 | ``` 43 | or 44 | ``` 45 | pip3 install -r requirements.txt 46 | ``` 47 | 48 | # 49 | ## Running 50 | 51 | First you need to identify the COM port CurrentRanger is plugged into (eg COM3 or /dev/ttyACM0). CurrentViewer is typically being tested with direct USB connection only but it should work with BlueTooth as well (however BT is not an area of focus for CurrentViewer due to lower bandwidth) 52 | 53 | On Windows: 54 | ``` 55 | python3 current_viewer.py -p COM9 56 | ``` 57 | 58 | On Linux/Raspberry: 59 | ``` 60 | python3 current_viewer.py -p /dev/ttyACM0 61 | ``` 62 | 63 | If everything is working well you should see an image like this below: 64 | 65 | ![Screenshot](./images/screenshot1.png) 66 | 67 | There is also a console window which displays regular SPS and a debugging log file - **current_viewer.log** that displays more details, down to individual sample errors (eg negative readings, wire corruptions, etc). For the time being the logging is enabled by default to help with diagnostics, but there is an option to disable it (--no-log) 68 | 69 | # 70 | ## Command line options 71 | 72 | A number of options can be passed in command line to control export data to CSV/JSON, to control the charting speed and behavior, etc 73 | 74 | ``` 75 | CurrentViewer v1.0.2 76 | usage: current_viewer.py -p [OPTION] 77 | 78 | CurrentRanger R3 Viewer 79 | 80 | optional arguments: 81 | -h, --help show this help message and exit 82 | --version show program's version number and exit 83 | -p PORT, --port PORT Set the serial port (backed by USB or BlueTooth) to 84 | connect to (example: /dev/ttyACM0 or COM3) 85 | -s , --baud Set the serial baud rate (default: 115200) 86 | -o , --out 87 | Save the output samples to in the format set by 88 | --format 89 | --format Set the output format to one of: CSV, JSON 90 | --gui Display the GUI / Interactive chart (default: ON) 91 | -g, --no-gui Do not display the GUI / Interactive Chart. Useful for 92 | automation 93 | -b , --buffer 94 | Set the chart buffer size (window size) in # of 95 | samples (default: 100000) 96 | -m , --max-chart 97 | Set the chart max # samples displayed (default: 2048) 98 | -r , --refresh 99 | Set the live chart refresh interval in milliseconds 100 | (default: 66) 101 | -v, --verbose Increase logging verbosity (can be specified multiple 102 | times) 103 | -c, --console Show the debug messages on the console 104 | -n, --no-log Disable debug logging (enabled by default) 105 | --log-size Set the log maximum size in megabytes (default: 1Mb) 106 | -l LOG_FILE, --log-file LOG_FILE 107 | Set the debug log file name 108 | (default:current_viewer.log) 109 | ``` 110 | 111 | ## Usage Examples: 112 | 113 | ### Export CSV data only, no GUI: 114 | 115 | ``` 116 | python current_viewer.py -p COM9 -g --out data.csv 117 | ``` 118 | 119 | This is useful for automation scenarios, where data needs to be logged for long periods of time and the charting is not needed. 120 | 121 | 122 | ### Disable file logging and GUI, log verbose to console instead: 123 | ``` 124 | python current_viewer.py -p COM9 -g -n -c -vvv 125 | ``` 126 | 127 | The file log (current_viewer.log) is now capped to 1MB and automatically rotated so it won't use the entire disk space by accident, but even then it can still be noisy (eg protocol errors) and generate lots of IO/writes. In some cases - for example SD/USB cards with limited write cycles - this might be undesirable, so now it's possible to disable disk logging completely with -n / --no-log option. 128 | 129 | 130 | ### Low CPU GUI: draw 100 samples only, 1 refresh/second (default 15) 131 | ``` 132 | python current_viewer.py -p COM9 -m 100 -r 1000 133 | ``` 134 | 135 | The charting library is CPU intensive, so setting a slower refresh (1fps instead of 15fps) or drawing fewer samples 100 (instead of 2048 which is the default with 4K monitors in mind) can reduce CPU consumption and increase rendering speed. The other parameter that can affect performance - in this case memory consumption - is -b/--buffer, this is the in-memory buffer, this represents the maximum view of the chart (-m is the # of data points in that range). For example if your CR is sending 600 samples/second at 100K sample buffer you get a history of roughly 3 minutes. Note that the buffer setting only affects the chart (how much is in view) and it does not affect the logging to CSV/JSON: exported data is saved to file directly from the acquisition loop so in theory should work for hours or days without issue. 136 | 137 | ### Increased log size for debugging 138 | 139 | ``` 140 | python current_viewer.py -p COM9 -vvv --log-size 128 141 | ``` 142 | 143 | This increases the log maximum size to 128 megabytes from the default of 1 (alteratively --log-size 0.1 would cap the log at ~100Kb). It could be useful in capturing hard to reproduce errors. Note there are two logs on disk: the current one (current_viewer.log) and the rotated one (current_viewer.log.1), so the total log on disk is actually double this parameter. 144 | 145 | # 146 | ## Data Export 147 | 148 | There are currently two data formats that CurrentViewer can save: CSV and JSON. See options --out and --format. If the format is not specified CurrentViewer tries to guess it based on extension (ie *--out foo.json* or *--out bar.csv* should be sufficient) 149 | 150 | ### CSV Example: 151 | 152 | ```CSV 153 | Timestamp, Amps 154 | 2020-11-09 11:21:08.510715,-0.00081 155 | 2020-11-09 11:21:08.526342,-0.0004 156 | 2020-11-09 11:21:08.526342,-0.00081 157 | 2020-11-09 11:21:08.526342,-0.0004 158 | 2020-11-09 11:21:08.526342,-0.00121 159 | 2020-11-09 11:21:08.526342,-0.00121 160 | 2020-11-09 11:21:08.526342,-0.00121 161 | 2020-11-09 11:21:08.526342,-0.00121 162 | 2020-11-09 11:21:08.526342,-0.00121 163 | 2020-11-09 11:21:08.526342,-0.00081 164 | 2020-11-09 11:21:08.526342,-0.00121 165 | 2020-11-09 11:21:08.541963,-0.00121 166 | 2020-11-09 11:21:08.541963,-0.00121 167 | 2020-11-09 11:21:08.541963,-0.00081 168 | 2020-11-09 11:21:08.541963,-0.00121 169 | 2020-11-09 11:21:08.541963,-0.00081 170 | 2020-11-09 11:21:08.541963,-0.00081 171 | ``` 172 | 173 | 174 | ### JSON Example 175 | 176 | ```json 177 | { 178 | "data":[ 179 | {"time":"2020-11-09 11:51:08.439275","amps":"4.07e-08"}, 180 | {"time":"2020-11-09 11:51:08.439275","amps":"7.938e-08"}, 181 | {"time":"2020-11-09 11:51:08.439275","amps":"1.652e-08"}, 182 | {"time":"2020-11-09 11:51:08.439275","amps":"2.01e-09"}, 183 | {"time":"2020-11-09 11:51:08.439275","amps":"-2.74e-08"}, 184 | {"time":"2020-11-09 11:51:08.439275","amps":"-2.78e-08"}, 185 | {"time":"2020-11-09 11:51:08.439275","amps":"-2.74e-08"}, 186 | {"time":"2020-11-09 11:51:08.439275","amps":"-1.692e-08"}, 187 | {"time":"2020-11-09 11:51:08.454900","amps":"-4.03e-09"}, 188 | {"time":"2020-11-09 11:51:08.454900","amps":"3.949e-08"}, 189 | {"time":"2020-11-09 11:51:08.454900","amps":"5.883e-08"}, 190 | {"time":"2020-11-09 11:51:08.454900","amps":"6.125e-08"}, 191 | {"time":"2020-11-09 11:51:08.454900","amps":"1.168e-08"}, 192 | {"time":"2020-11-09 11:51:08.454900","amps":"4e-10"}, 193 | {"time":"2020-11-09 11:51:08.454900","amps":"-2.78e-08"}, 194 | {"time":"2020-11-09 11:51:08.466614","amps":"-2.78e-08"}, 195 | {"time":"2020-11-09 11:51:08.467614","amps":"-2.78e-08"} 196 | ] 197 | } 198 | ``` 199 | 200 | # 201 | ## Known limitations 202 | 203 | - Some runtime errors on MacOS / Python 3.8.5. 204 | - Not tested with the BlueTooth add-on yet 205 | - Cannot always put the device in a predictable USB streaming mode (check the troubleshooting steps below) 206 | - Negative data (or noisy data) - this is what's coming from the device. As the firmware improves (Felix has some ideas) this will improve as well. 207 | 208 | # 209 | ## Troubleshooting 210 | 211 | **Note:** Do not direct CurrentViewer support requests to LowPowerLab, this is not an official tool, it's provided AS-IS. This is a side-project for me (and first time dealing with matplotlib in particular, so I will try to address issues as time (and skill) allows :) 212 | 213 | - ### Python dependencies 214 | This was tested with Python 3.6-3.8 (on Windows 10), 3.7.3 (Linux ARM / Rasppberry), 3.8.5 (in Linux x64) . Currently not working on MacOS 10.15 (although it should - but no time to debug). 215 | The dependencies that are critical and might break in the future are: 216 | - matplotlib (tested with 3.1.1) 217 | - mplcursors (tested with 0.3) 218 | - numpy / pandas - these are new dependencies added in 1.0.1. There are already known issues with Python 3.9 and matplotlib/numpy, if you run into those stick to 3.8 or use virtual environments 219 | 220 | If you encounter errors with installing numpy or matplotlib, be sure to check out this full thread (and the temporary workaround) https://developercommunity.visualstudio.com/solutions/1257144/view.html 221 | 222 | - ### Serial port errors 223 | Make sure you can connect to the COM port (using Arduino, Putty, etc) and you see the CurrentRanger menu (type '?'). Then enable USB streaming (command 'u') and check if the data is actually coming in the expected exponent format (see below) 224 | 225 | - ### Data Errors 226 | CurrentViewer expects only measurements in the exponent format ('-0.81e-3') streamed over USB. if you have other things enabled (such as Touch debugging) you might see a lot of errors or inconsistent data. CurrentViewer measures the error rate and if above a certain threshold (50%) will stop. 227 | 228 | I typically test with my branch of the firmware (https://github.com/MGX3D/CurrentRanger) as I only have one CurrentRanger but I try to not rely on features that are not available in the official firmware. 229 | 230 | - ### Device shutting down after 10 minutes 231 | 232 | Make sure your CurrentRanger is set to auto-off=SMART (make sure you have firmware 1.0.0 or newer, connect to the port and send 'a' command until it says "AUTOOF_SMART" then confirm with '?' - the setting will be now persisted in EEPROM) 233 | 234 | ![auto-off](./images/auto_off.png) 235 | 236 | 237 | - ### Other issues? 238 | Check current_viewer.log (and its rotate log current_viewer.log.1) and look for hints. If no luck, open an issue on github and attach the log(s). Do describe your hardware setup as well, and perhaps check how the device behaves with other tools (Arduino, Putty, etc) 239 | 240 | # 241 | ## Contributions 242 | 243 | Contributions are welcome (note the MIT license), best to fork the project and submit a PR when done. Try to keep the changes small so they are easier to merge. 244 | -------------------------------------------------------------------------------- /current_viewer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright (c) Marius Gheorghescu. All rights reserved. 3 | # Licensed under the MIT license. See LICENSE file in the project root for full license information. 4 | import sys 5 | import time 6 | import serial 7 | import logging 8 | from logging.handlers import RotatingFileHandler 9 | import argparse 10 | import platform 11 | import collections 12 | import numpy as np 13 | import pandas as pd 14 | import matplotlib.pyplot as plt 15 | import mplcursors 16 | import matplotlib.animation as animation 17 | from matplotlib.dates import num2date, MinuteLocator, SecondLocator, DateFormatter 18 | from matplotlib.widgets import Button 19 | from datetime import datetime, timedelta 20 | from threading import Thread 21 | from os import path 22 | 23 | version = '1.0.7' 24 | 25 | port = '' 26 | baud = 115200 27 | 28 | logfile = 'current_viewer.log' 29 | 30 | refresh_interval = 66 # 66ms = 15fps 31 | 32 | # controls the window size (and memory usage). 100k samples = 3 minutes 33 | buffer_max_samples = 100000 34 | 35 | # controls how many samples to display in the chart (and CPU usage). Ie 4k display should be ok with 2k samples 36 | chart_max_samples = 2048 37 | 38 | # how many samples to average (median) 39 | max_supersampling = 16; 40 | 41 | # set to true to compute median instead of average (less noise, more CPU) 42 | median_filter = 0; 43 | 44 | # 45 | save_file = None; 46 | save_format = None; 47 | 48 | connected_device = "CurrentRanger" 49 | 50 | class CRPlot: 51 | def __init__(self, sample_buffer = 100): 52 | self.port = '/dev/ttyACM0' 53 | self.baud = 9600 54 | self.thread = None 55 | self.stream_data = True 56 | self.pause_chart = False 57 | self.sample_count = 0 58 | self.animation_index = 0 59 | self.max_samples = sample_buffer 60 | self.data = collections.deque(maxlen=sample_buffer) 61 | self.timestamps = collections.deque(maxlen=sample_buffer) 62 | self.dataStartTS = None 63 | self.serialConnection = None 64 | self.framerate = 30 65 | 66 | def serialStart(self, port, speed = 115200): 67 | self.port = port 68 | self.baud = speed 69 | logging.info("Trying to connect to port='{}' baud='{}'".format(port, speed)) 70 | try: 71 | self.serialConnection = serial.Serial(self.port, self.baud, timeout=5) 72 | logging.info("Connected to {} at baud {}".format(port, speed)) 73 | except serial.SerialException as e: 74 | logging.error("Error connecting to serial port: {}".format(e)) 75 | return False 76 | except: 77 | logging.error("Error connecting to serial port, unexpected exception:{}".format(sys.exc_info())) 78 | return False 79 | 80 | if self.thread == None: 81 | self.thread = Thread(target=self.serialStream) 82 | self.thread.start() 83 | 84 | print('Initializing data capture:', end='') 85 | wait_timeout = 100 86 | while wait_timeout > 0 and self.sample_count == 0: 87 | print('.', end='', flush=True) 88 | time.sleep(0.01) 89 | wait_timeout -= 1 90 | 91 | if (self.sample_count == 0): 92 | logging.error("Error: No data samples received. Aborting") 93 | return False 94 | 95 | print("OK\n") 96 | return True 97 | 98 | 99 | def pauseRefresh(self, state): 100 | logging.debug("pause {}".format(state)) 101 | self.pause_chart = not self.pause_chart 102 | if self.pause_chart: 103 | self.ax.set_title('', color="yellow") 104 | self.bpause.label.set_text('Resume') 105 | else: 106 | self.ax.set_title(f"Streaming: {connected_device}", color="white") 107 | self.bpause.label.set_text('Pause') 108 | 109 | def saveAnimation(self, state): 110 | logging.debug("save {}".format(state)) 111 | 112 | self.bsave.label.set_text('Saving...') 113 | plt.gcf().canvas.draw() 114 | filename = None 115 | while True: 116 | filename = 'current' + str(self.animation_index) + '.gif' 117 | self.animation_index += 1 118 | if not path.exists(filename): 119 | break 120 | logging.info("Animation saved to '{}'".format(filename)) 121 | self.anim.save(filename, writer='imagemagick', fps=self.framerate) 122 | self.bsave.label.set_text('GIF') 123 | 124 | def chartSetup(self, refresh_interval=100): 125 | plt.style.use('dark_background') 126 | fig = plt.figure(num=f"Current Viewer {version}", figsize=(10, 6)) 127 | self.ax = plt.axes() 128 | ax = self.ax 129 | 130 | ax.set_title(f"Streaming: {connected_device}", color="white") 131 | 132 | fig.text (0.2, 0.88, f"CurrentViewer {version}", color="yellow", verticalalignment='bottom', horizontalalignment='center', fontsize=9, alpha=0.7) 133 | fig.text (0.89, 0.0, f"github.com/MGX3D/CurrentViewer", color="white", verticalalignment='bottom', horizontalalignment='center', fontsize=9, alpha=0.5) 134 | 135 | ax.set_ylabel("Current draw (Amps)") 136 | ax.set_yscale("log", nonpositive='clip') 137 | ax.set_ylim(1e-10, 1e1) 138 | plt.yticks([1.0e-9, 1.0e-8, 1.0e-7, 1.0e-6, 1.0e-5, 1.0e-4, 1.0e-3, 1.0e-2, 1.0e-1, 1.0], ['1nA', '10nA', '100nA', '1\u00B5A', '10\u00B5A', '100\u00B5A', '1mA', '10mA', '100mA', '1A'], rotation=0) 139 | ax.grid(axis="y", which="both", color="yellow", alpha=.3, linewidth=.5) 140 | 141 | ax.set_xlabel("Time") 142 | plt.xticks(rotation=20) 143 | ax.set_xlim(datetime.now(), datetime.now() + timedelta(seconds=10)) 144 | ax.grid(axis="x", color="green", alpha=.4, linewidth=2, linestyle=":") 145 | 146 | #ax.xaxis.set_major_locator(SecondLocator()) 147 | ax.xaxis.set_major_formatter(DateFormatter('%H:%M:%S')) 148 | 149 | def on_xlims_change(event_ax): 150 | logging.debug("Interactive zoom: {} .. {}".format(num2date(event_ax.get_xlim()[0]), num2date(event_ax.get_xlim()[1]))) 151 | 152 | chart_len = num2date(event_ax.get_xlim()[1]) - num2date(event_ax.get_xlim()[0]) 153 | 154 | if chart_len.total_seconds() < 5: 155 | self.ax.xaxis.set_major_formatter(DateFormatter('%H:%M:%S.%f')) 156 | else: 157 | self.ax.xaxis.set_major_formatter(DateFormatter('%H:%M:%S')) 158 | self.ax.xaxis.set_minor_formatter(DateFormatter('%H:%M:%S.%f')) 159 | 160 | ax.callbacks.connect('xlim_changed', on_xlims_change) 161 | 162 | lines = ax.plot([], [], label="Current")[0] 163 | 164 | lastText = ax.text(0.50, 0.95, '', transform=ax.transAxes) 165 | statusText = ax.text(0.50, 0.50, '', transform=ax.transAxes) 166 | self.anim = animation.FuncAnimation(fig, self.getSerialData, fargs=(lines, plt.legend(), lastText), interval=refresh_interval) 167 | 168 | plt.legend(loc="upper right", framealpha=0.5) 169 | 170 | apause = plt.axes([0.91, 0.15, 0.08, 0.07]) 171 | self.bpause = Button(apause, label='Pause', color='0.2', hovercolor='0.1') 172 | self.bpause.on_clicked(self.pauseRefresh) 173 | self.bpause.label.set_color('yellow') 174 | 175 | aanimation = plt.axes([0.91, 0.25, 0.08, 0.07]) 176 | self.bsave = Button(aanimation, 'GIF', color='0.2', hovercolor='0.1') 177 | self.bsave.on_clicked(self.saveAnimation) 178 | self.bsave.label.set_color('yellow') 179 | 180 | crs = mplcursors.cursor(ax, hover=True) 181 | @crs.connect("add") 182 | def _(sel): 183 | sel.annotation.arrow_patch.set(arrowstyle="simple", fc="yellow", alpha=.4) 184 | sel.annotation.set_text(self.textAmp(sel.target[1])) 185 | 186 | self.framerate = 1000/refresh_interval 187 | plt.gcf().autofmt_xdate() 188 | plt.show() 189 | 190 | 191 | def serialStream(self): 192 | 193 | # set data streaming mode on CR (assuming it was off) 194 | self.serialConnection.write(b'u') 195 | 196 | self.serialConnection.reset_input_buffer() 197 | self.sample_count = 0 198 | line_count = 0 199 | error_count = 0 200 | self.dataStartTS = datetime.now() 201 | 202 | # data timeout threshold (seconds) - bails out of no samples received 203 | data_timeout_ths = 0.5 204 | 205 | line = None 206 | device_data = bytearray() 207 | 208 | logging.info("Starting USB streaming loop") 209 | 210 | while (self.stream_data): 211 | try: 212 | # get the timestamp before the data string, likely to align better with the actual reading 213 | ts = datetime.now() 214 | 215 | chunk_len = device_data.find(b"\n") 216 | if chunk_len >= 0: 217 | line = device_data[:chunk_len] 218 | device_data = device_data[chunk_len+1:] 219 | else: 220 | line = None 221 | while line == None and self.stream_data: 222 | chunk_len = max(1, min(4096, self.serialConnection.in_waiting)) 223 | chunk = self.serialConnection.read(chunk_len) 224 | chunk_len = chunk.find(b"\n") 225 | if chunk_len >= 0: 226 | line = device_data + chunk[:chunk_len] 227 | device_data[0:] = chunk[chunk_len+1:] 228 | else: 229 | device_data.extend(chunk) 230 | 231 | if line == None: 232 | continue 233 | 234 | line = line.decode(encoding="ascii", errors="strict") 235 | 236 | if (line.startswith("USB_LOGGING")): 237 | if (line.startswith("USB_LOGGING_DISABLED")): 238 | # must have been left open by a different process/instance 239 | logging.info("CR USB Logging was disabled. Re-enabling") 240 | self.serialConnection.write(b'u') 241 | self.serialConnection.flush() 242 | continue 243 | 244 | data = float(line) 245 | self.sample_count += 1 246 | line_count += 1 247 | 248 | if save_file: 249 | if save_format == 'CSV': 250 | save_file.write(f"{ts},{data}\n") 251 | elif save_format == 'JSON': 252 | save_file.write("{}{{\"time\":\"{}\",\"amps\":\"{}\"}}".format(',\n' if self.sample_count>1 else '', ts, data)) 253 | 254 | if data < 0.0: 255 | # this happens too often (negative values) 256 | self.timestamps.append(np.datetime64(ts)) 257 | self.data.append(1.0e-11) 258 | logging.warning("Unexpected value='{}'".format(line.strip())) 259 | else: 260 | self.timestamps.append(np.datetime64(ts)) 261 | self.data.append(data) 262 | logging.debug(f"#{self.sample_count}:{ts}: {data}") 263 | 264 | if (self.sample_count % 1000 == 0): 265 | logging.debug("{}: '{}' -> {}".format(ts.strftime("%H:%M:%S.%f"), line.rstrip(), data)) 266 | dt = datetime.now() - self.dataStartTS 267 | logging.info("Received {} samples in {:.0f}ms ({:.2f} samples/second)".format(self.sample_count, 1000*dt.total_seconds(), self.sample_count/dt.total_seconds())) 268 | print("Received {} samples in {:.0f}ms ({:.2f} samples/second)".format(self.sample_count, 1000*dt.total_seconds(), self.sample_count/dt.total_seconds())) 269 | 270 | except KeyboardInterrupt: 271 | logging.info('Terminated by user') 272 | break 273 | 274 | except ValueError: 275 | logging.error("Invalid data format: '{}': {}".format(line, sys.exc_info())) 276 | error_count += 1 277 | last_sample = (np.datetime64(datetime.now()) - (self.timestamps[-1] if self.sample_count else np.datetime64(datetime.now())))/np.timedelta64(1, 's') 278 | if (error_count > 100) and last_sample > data_timeout_ths: 279 | logging.error("Aborting. Error rate is too high {} errors, last valid sample received {} seconds ago".format(error_count, last_sample)) 280 | self.stream_data = False 281 | break 282 | pass 283 | 284 | except serial.SerialException as e: 285 | logging.error('Serial read error: {}: {}'.format(e.strerror, sys.exc_info())) 286 | self.stream_data = False 287 | break 288 | 289 | self.stream_data = False 290 | 291 | # stop streaming so the device shuts down if in auto mode 292 | logging.info('Telling CR to stop USB streaming') 293 | 294 | try: 295 | # this will throw if the device has failed.disconnected already 296 | self.serialConnection.write(b'u') 297 | except: 298 | logging.warning('Was not able to clean disconnect from the device') 299 | 300 | logging.info('Serial streaming terminated') 301 | 302 | def textAmp(self, amp): 303 | if (abs(amp) > 1.0): 304 | return "{:.3f} A".format(amp) 305 | if (abs(amp) > 0.001): 306 | return "{:.2f} mA".format(amp*1000) 307 | if (abs(amp) > 0.000001): 308 | return "{:.1f} \u00B5A".format(amp*1000*1000) 309 | return "{:.1f} nA".format(amp*1000*1000*1000) 310 | 311 | 312 | def getSerialData(self, frame, lines, legend, lastText): 313 | if (self.pause_chart or len(self.data) < 2): 314 | lastText.set_text('') 315 | return 316 | 317 | if not self.stream_data: 318 | self.ax.set_title('', color="red") 319 | lastText.set_text('') 320 | return 321 | 322 | dt = datetime.now() - self.dataStartTS 323 | 324 | # capped at buffer_max_samples 325 | sample_set_size = len(self.data) 326 | 327 | timestamps = [] 328 | samples = [] #np.arange(chart_max_samples, dtype="float64") 329 | 330 | subsamples = max(1, min(max_supersampling, int(sample_set_size/chart_max_samples))) 331 | 332 | # Sub-sampling for longer window views without the redraw perf impact 333 | for i in range(0, chart_max_samples): 334 | sample_index = int(sample_set_size*i/chart_max_samples) 335 | timestamps.append(self.timestamps[sample_index]) 336 | supersample = np.array([self.data[i] for i in range(sample_index, sample_index+subsamples)]) 337 | samples.append(np.median(supersample) if median_filter else np.average(supersample)) 338 | 339 | self.ax.set_xlim(timestamps[0], timestamps[-1]) 340 | 341 | # some machines max out at 100fps, so this should react in 0.5-5 seconds to actual speed 342 | sps_samples = min(512, sample_set_size); 343 | dt_sps = (np.datetime64(datetime.now()) - self.timestamps[-sps_samples])/np.timedelta64(1, 's'); 344 | 345 | # if more than 1 second since last sample, automatically set SPS to 0 so we don't have until it slowly decays to 0 346 | sps = sps_samples/dt_sps if ((np.datetime64(datetime.now()) - self.timestamps[-1])/np.timedelta64(1, 's')) < 1 else 0.0 347 | lastText.set_text('{:.1f} SPS'.format(sps)) 348 | if sps > 500: 349 | lastText.set_color("white") 350 | elif sps > 100: 351 | lastText.set_color("yellow") 352 | else: 353 | lastText.set_color("red") 354 | 355 | 356 | logging.debug("Drawing chart: range {}@{} .. {}@{}".format(samples[0], timestamps[0], samples[-1], timestamps[-1])) 357 | lines.set_data(timestamps, samples) 358 | self.ax.legend(labels=['Last: {}\nAvg: {}'.format( self.textAmp(samples[-1]), self.textAmp(sum(samples)/len(samples)))]) 359 | 360 | 361 | def isStreaming(self) -> bool: 362 | return self.stream_data 363 | 364 | def close(self): 365 | self.stream_data = False 366 | 367 | if self.thread != None: 368 | self.thread.join() 369 | 370 | if self.serialConnection != None: 371 | self.serialConnection.close() 372 | 373 | logging.info("Connection closed.") 374 | 375 | 376 | def init_argparse() -> argparse.ArgumentParser: 377 | parser = argparse.ArgumentParser( 378 | usage="%(prog)s -p [OPTION]", 379 | description="CurrentRanger R3 Viewer" 380 | ) 381 | 382 | parser.add_argument("--version", action="version", version = f"{parser.prog} version {version}") 383 | parser.add_argument("-p", "--port", nargs=1, required=True, help="Set the serial port (backed by USB or BlueTooth) to connect to (example: /dev/ttyACM0 or COM3)") 384 | parser.add_argument("-s", "--baud", metavar='', type=int, nargs=1, help=f"Set the serial baud rate (default: {baud})") 385 | 386 | parser.add_argument("-o", "--out", metavar='', nargs=1, help=f"Save the output samples to in the format set by --format") 387 | parser.add_argument("--format", metavar='', nargs=1, help=f"Set the output format to one of: CSV, JSON") 388 | 389 | parser.add_argument("--gui", dest="gui", action="store_true", default=True, help="Display the GUI / Interactive chart (default: ON)") 390 | parser.add_argument("-g", "--no-gui", dest="gui", action="store_false", help="Do not display the GUI / Interactive Chart. Useful for automation") 391 | 392 | parser.add_argument("-b", "--buffer", metavar='', type=int, nargs=1, help=f"Set the chart buffer size (window size) in # of samples (default: {buffer_max_samples})") 393 | parser.add_argument("-m", "--max-chart", metavar='', type=int, nargs=1, help=f"Set the chart max # samples displayed (default: {chart_max_samples})") 394 | parser.add_argument("-r", "--refresh", metavar='', type=int, nargs=1, help=f"Set the live chart refresh interval in milliseconds (default: {refresh_interval})") 395 | parser.add_argument("-v", "--verbose", action="count", default=0, help="Increase logging verbosity (can be specified multiple times)") 396 | parser.add_argument("-c", "--console", default=False, action="store_true", help="Show the debug messages on the console") 397 | parser.add_argument("-n", "--no-log", default=False, action="store_true", help=f"Disable debug logging (enabled by default)") 398 | parser.add_argument("--log-size", metavar='', type=float, nargs=1, help=f"Set the log maximum size in megabytes (default: 1Mb)") 399 | parser.add_argument("-l", "--log-file", nargs=1, help=f"Set the debug log file name (default:{logfile})") 400 | 401 | parser.set_defaults(gui=True) 402 | return parser 403 | 404 | def main(): 405 | 406 | print("CurrentViewer v" + version) 407 | 408 | log_size = 1024*1024; 409 | 410 | parser = init_argparse() 411 | args = parser.parse_args() 412 | 413 | if args.log_file: 414 | global logfile 415 | logfile = args.log_file[0] 416 | 417 | if args.log_size: 418 | log_size = 1024*1024*args.log_size[0] 419 | 420 | if args.refresh: 421 | global refresh_interval 422 | refresh_interval = args.refresh[0] 423 | 424 | if args.baud: 425 | global baud 426 | baud = args.baud[0] 427 | 428 | if args.max_chart and args.max_chart[0] > 10: 429 | global chart_max_samples 430 | chart_max_samples = args.max_chart[0] 431 | 432 | if args.buffer: 433 | global buffer_max_samples 434 | buffer_max_samples = args.buffer[0] 435 | if buffer_max_samples < chart_max_samples: 436 | print("Command line error: Buffer size cannot be smaller than the chart sample size", file=sys.stderr) 437 | return -1 438 | 439 | logging_level = logging.DEBUG if args.verbose>2 else (logging.INFO if args.verbose>1 else (logging.WARNING if args.verbose>0 else logging.ERROR)) 440 | 441 | # disable matplotlib logging for fonts, seems to be quite noisy 442 | logging.getLogger('matplotlib.font_manager').disabled = True 443 | 444 | if args.console or not args.no_log: 445 | logging.getLogger().setLevel(logging.DEBUG) 446 | 447 | if not args.no_log: 448 | file_logger = RotatingFileHandler(logfile, maxBytes=log_size, backupCount=1) 449 | file_logger.setLevel(logging.DEBUG) 450 | file_logger.setFormatter(logging.Formatter('%(levelname)s:%(asctime)s:%(threadName)s:%(message)s')) 451 | logging.getLogger().addHandler(file_logger) 452 | 453 | if args.console: 454 | print("Setting console logging") 455 | console_logger = logging.StreamHandler() 456 | console_logger.setLevel(logging_level) 457 | console_logger.setFormatter(logging.Formatter('%(levelname)s:%(message)s')) 458 | logging.getLogger().addHandler(console_logger) 459 | 460 | 461 | global save_file 462 | global save_format 463 | 464 | if args.format: 465 | save_format = args.format[0].upper() 466 | if not save_format in ["CSV", "JSON"]: 467 | print(f"Unknown format {save_format}", file=sys.stderr) 468 | return -2 469 | 470 | if args.out: 471 | output_file_name = args.out[0] 472 | save_file = open(output_file_name, "w+") 473 | 474 | if not save_format: 475 | save_format = 'CSV' if output_file_name.upper().endswith('.CSV') else 'JSON' 476 | logging.info(f"Save format automatically set to {save_format} for {args.out[0]}") 477 | 478 | if save_format == 'CSV': 479 | save_file.write("Timestamp, Amps\n") 480 | elif save_format == 'JSON': 481 | save_file.write("{\n\"data\":[\n") 482 | 483 | logging.info("CurrentViewer v{}. System: {}, Platform: {}, Machine: {}, Python: {}".format(version, platform.system(), platform.platform(), platform.machine(), platform.python_version())) 484 | 485 | csp = CRPlot(sample_buffer=buffer_max_samples) 486 | 487 | if csp.serialStart(port=args.port[0], speed=baud): 488 | if args.gui: 489 | print("Starting live chart...") 490 | csp.chartSetup(refresh_interval=refresh_interval) 491 | else: 492 | print("Running with no GUI (press Ctrl-C to stop)...") 493 | try: 494 | while csp.isStreaming(): 495 | time.sleep(0.01) 496 | except KeyboardInterrupt: 497 | logging.info('Terminated') 498 | csp.close() 499 | 500 | print("Done.") 501 | else: 502 | print("Fatal: Could not connect to USB/BT COM port {}. Check the logs for more information".format(args.port[0]), file=sys.stderr) 503 | 504 | csp.close() 505 | 506 | if save_file: 507 | if save_format == 'JSON': 508 | save_file.write("\n]\n}\n") 509 | save_file.close() 510 | 511 | if __name__ == '__main__': 512 | main() 513 | -------------------------------------------------------------------------------- /images/auto_off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MGX3D/CurrentViewer/7f7699f050a8a0cc9846d852ac09486a2eb4bf09/images/auto_off.png -------------------------------------------------------------------------------- /images/example1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MGX3D/CurrentViewer/7f7699f050a8a0cc9846d852ac09486a2eb4bf09/images/example1.gif -------------------------------------------------------------------------------- /images/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MGX3D/CurrentViewer/7f7699f050a8a0cc9846d852ac09486a2eb4bf09/images/screenshot1.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyserial>=3.4 2 | matplotlib>=3.3.0 3 | mplcursors 4 | argparse 5 | numpy>=1.16.2 6 | pandas>=1.1.4 7 | --------------------------------------------------------------------------------