Plug your CLUE board into your computer via a known-good USB data cable. A flash drive named CIRCUITPY should appear in your file explorer/finder program.
Example Video
The video below demonstrates all of the sensors and at the end shows the analogue inputs on the three large pads marked #0, #1 and #2. A Feather M4 Express provides colour and two signals for #0 and #1 for the demonstration.
The following sections show the lengthy code in all three files. There is also a Code Discussion section at the bottom of the page.
Installing Project Code
To use with CircuitPython, you need to first install a few libraries, into the lib folder on your CIRCUITPY drive. Then you need to update code.py with the example script.
Thankfully, we can do this in one go. In the example below, click the Download Project Bundle button below to download the necessary libraries and the code.py file in a zip file. Extract the contents of the zip file, open the directory CLUE_Sensor_Plotter/ and then click on the directory that matches the version of CircuitPython you're using and copy the contents of that directory to your CIRCUITPY drive.
Your CIRCUITPY drive should now look similar to the following image:

# SPDX-FileCopyrightText: 2020 Kevin J Walters for Adafruit Industries # # SPDX-License-Identifier: MIT # clue-plotter v1.14 # Sensor and input plotter for Adafruit CLUE in CircuitPython # This plots the sensors and three of the analogue inputs on # the LCD display either with scrolling or wrap mode which # approximates a slow timebase oscilloscope, left button selects # next source or with long press changes palette or longer press # turns on output for Mu plotting, right button changes plot style # Tested with an Adafruit CLUE (Alpha) and CircuitPython and 5.0.0 # copy this file to CLUE board as code.py # needs companion plot_sensor.py and plotter.py files # MIT License # Copyright (c) 2020 Kevin J. Walters # 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. import time import gc import board from adafruit_clue import clue from plotter import Plotter # pylint: disable=unused-import from plot_source import ( PlotSource, TemperaturePlotSource, PressurePlotSource, HumidityPlotSource, ColorPlotSource, ProximityPlotSource, IlluminatedColorPlotSource, VolumePlotSource, AccelerometerPlotSource, GyroPlotSource, MagnetometerPlotSource, PinPlotSource, ) debug = 1 # A list of all the data sources for plotting # NOTE: Due to memory contraints, the total number of data sources # is limited. Can try adding more until a memory limit is hit. At that # point, decide what to keep and what to toss. Can comment/uncomment lines # below as desired. sources = [ TemperaturePlotSource(clue, mode="Celsius"), # TemperaturePlotSource(clue, mode="Fahrenheit"), PressurePlotSource(clue, mode="Metric"), # PressurePlotSource(clue, mode="Imperial"), HumidityPlotSource(clue), ColorPlotSource(clue), ProximityPlotSource(clue), # IlluminatedColorPlotSource(clue, mode="Red"), # IlluminatedColorPlotSource(clue, mode="Green"), # IlluminatedColorPlotSource(clue, mode="Blue"), # IlluminatedColorPlotSource(clue, mode="Clear"), # VolumePlotSource(clue), AccelerometerPlotSource(clue), # GyroPlotSource(clue), # MagnetometerPlotSource(clue), # PinPlotSource([board.P0, board.P1, board.P2]) ] # The first source to select when plotting starts current_source_idx = 0 # The various plotting styles - scroll is currently a jump scroll stylemodes = ( ("lines", "scroll"), # draws lines between points ("lines", "wrap"), ("dots", "scroll"), # just points - slightly quicker ("dots", "wrap"), ) current_sm_idx = 0 def d_print(level, *args, **kwargs): """A simple conditional print for debugging based on global debug level.""" if not isinstance(level, int): print(level, *args, **kwargs) elif debug >= level: print(*args, **kwargs) def select_colors(plttr, src, def_palette): """Choose the colours based on the particular PlotSource or forcing use of default palette.""" # otherwise use defaults channel_colidx = [] palette = plttr.get_colors() colors = PlotSource.DEFAULT_COLORS if def_palette else src.colors() for col in colors: try: channel_colidx.append(palette.index(col)) except ValueError: channel_colidx.append(PlotSource.DEFAULT_COLORS.index(col)) return channel_colidx def ready_plot_source(plttr, srcs, def_palette, index=0): """Select the plot source by index from srcs list and then setup the plot parameters by retrieving meta-data from the PlotSource object.""" src = srcs[index] # Put the description of the source on screen at the top source_name = str(src) d_print(1, "Selecting source:", source_name) plttr.clear_all() plttr.title = source_name plttr.y_axis_lab = src.units() # The range on graph will start at this value plttr.y_range = (src.initial_min(), src.initial_max()) plttr.y_min_range = src.range_min() # Sensor/data source is expected to produce data between these values plttr.y_full_range = (src.min(), src.max()) channels_from_src = src.values() plttr.channels = channels_from_src # Can be between 1 and 3 plttr.channel_colidx = select_colors(plttr, src, def_palette) src.start() return (src, channels_from_src) def wait_release(func, menu): """Calls func repeatedly waiting for it to return a false value and goes through menu list as time passes. The menu is a list of menu entries where each entry is a two element list of time passed in seconds and text to display for that period. The entries must be in ascending time order.""" start_t_ns = time.monotonic_ns() menu_option = None selected = False for menu_option, menu_entry in enumerate(menu): menu_time_ns = start_t_ns + int(menu_entry[0] * 1e9) menu_text = menu_entry[1] if menu_text: plotter.info = menu_text while time.monotonic_ns() < menu_time_ns: if not func(): selected = True break if menu_text: plotter.info = "" if selected: break return (menu_option, (time.monotonic_ns() - start_t_ns) * 1e-9) def popup_text(plttr, text, duration=1.0): """Place some text on the screen using info property of Plotter object for duration seconds.""" plttr.info = text time.sleep(duration) plttr.info = None mu_plotter_output = False range_lock = False initial_title = "CLUE Plotter" # displayio has some static limits on text - pre-calculate the maximum # length of all of the different PlotSource objects max_title_len = max(len(initial_title), max([len(str(so)) for so in sources])) plotter = Plotter( board.DISPLAY, style=stylemodes[current_sm_idx][0], mode=stylemodes[current_sm_idx][1], title=initial_title, max_title_len=max_title_len, mu_output=mu_plotter_output, debug=debug, ) # If set to true this forces use of colour blindness friendly colours use_def_pal = False clue.pixel[0] = clue.BLACK # turn off the NeoPixel on the back of CLUE board plotter.display_on() # Using left and right here in case the CLUE is cased hiding A/B labels popup_text( plotter, "\n".join( [ "Button Guide", "Left: next source", " 2secs: palette", " 4s: Mu plot", " 6s: range lock", "Right: style change", ] ), duration=10, ) count = 0 while True: # Set the source and start items (source, channels) = ready_plot_source( plotter, sources, use_def_pal, current_source_idx ) while True: # Read data from sensor or voltage from pad all_data = source.data() # Check for left (A) and right (B) buttons if clue.button_a: # Wait for button release with time-based menu opt, _ = wait_release( lambda: clue.button_a, [ (2, "Next\nsource"), (4, ("Source" if use_def_pal else "Default") + "\npalette"), (6, "Mu output " + ("off" if mu_plotter_output else "on")), (8, "Range lock\n" + ("off" if range_lock else "on")), ], ) # pylint: disable=no-else-break if opt == 0: # change plot source current_source_idx = (current_source_idx + 1) % len(sources) break # to leave inner while and select the new source elif opt == 1: # toggle palette use_def_pal = not use_def_pal plotter.channel_colidx = select_colors(plotter, source, use_def_pal) elif opt == 2: # toggle Mu output mu_plotter_output = not mu_plotter_output plotter.mu_output = mu_plotter_output else: # toggle range lock range_lock = not range_lock plotter.y_range_lock = range_lock if clue.button_b: # change plot style and mode current_sm_idx = (current_sm_idx + 1) % len(stylemodes) (new_style, new_mode) = stylemodes[current_sm_idx] wait_release(lambda: clue.button_b, [(2, new_style + "\n" + new_mode)]) d_print(1, "Graph change", new_style, new_mode) plotter.change_stylemode(new_style, new_mode) # Display it if channels == 1: plotter.data_add((all_data,)) else: plotter.data_add(all_data) # An occasional print of free heap if debug >= 3 and count % 15 == 0: gc.collect() # must collect() first to measure free memory print("Free memory:", gc.mem_free()) count += 1 source.stop() plotter.display_off()
# SPDX-FileCopyrightText: 2020 Kevin J Walters for Adafruit Industries # # SPDX-License-Identifier: MIT # The MIT License (MIT) # # Copyright (c) 2020 Kevin J. Walters # # 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. import sys import os import unittest from unittest.mock import Mock, MagicMock, PropertyMock verbose = int(os.getenv('TESTVERBOSE', '2')) # Mocking libraries which are about to be import'd by Plotter sys.modules['analogio'] = MagicMock() # Borrowing the dhalbert/tannewt technique from adafruit/Adafruit_CircuitPython_Motor sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) # import what we are testing or will test in future # pylint: disable=unused-import,wrong-import-position from plot_source import PlotSource, TemperaturePlotSource, PressurePlotSource, \ HumidityPlotSource, ColorPlotSource, ProximityPlotSource, \ IlluminatedColorPlotSource, VolumePlotSource, \ AccelerometerPlotSource, GyroPlotSource, \ MagnetometerPlotSource, PinPlotSource # pylint: disable=protected-access class Test_TemperaturePlotSource(unittest.TestCase): SENSOR_DATA = (20, 21.3, 22.0, 0.0, -40, 85) def test_celsius(self): """Create the source in Celsius mode and test with some values.""" # Emulate the clue's temperature sensor by # returning a temperature from a small tuple # of test data mocked_clue = Mock() expected_data = self.SENSOR_DATA type(mocked_clue).temperature = PropertyMock(side_effect=self.SENSOR_DATA) source = TemperaturePlotSource(mocked_clue, mode="Celsius") for expected_value in expected_data: self.assertAlmostEqual(source.data(), expected_value, msg="Checking converted temperature is correct") def test_fahrenheit(self): """Create the source in Fahrenheit mode and test with some values.""" # Emulate the clue's temperature sensor by # returning a temperature from a small tuple # of test data mocked_clue = Mock() expected_data = (68, 70.34, 71.6, 32.0, -40, 185) type(mocked_clue).temperature = PropertyMock(side_effect=self.SENSOR_DATA) source = TemperaturePlotSource(mocked_clue, mode="Fahrenheit") for expected_value in expected_data: self.assertAlmostEqual(source.data(), expected_value, msg="Checking converted temperature is correct") def test_kelvin(self): """Create the source in Kelvin mode and test with some values.""" # Emulate the clue's temperature sensor by # returning a temperature from a small tuple # of test data mocked_clue = Mock() expected_data = (293.15, 294.45, 295.15, 273.15, 233.15, 358.15) type(mocked_clue).temperature = PropertyMock(side_effect=self.SENSOR_DATA) source = TemperaturePlotSource(mocked_clue, mode="Kelvin") for expected_value in expected_data: data = source.data() # self.assertEqual(data, # expected_value, # msg="An inappropriate check for floating-point") self.assertAlmostEqual(data, expected_value, msg="Checking converted temperature is correct") if __name__ == '__main__': unittest.main(verbosity=verbose)
# SPDX-FileCopyrightText: 2020 Kevin J Walters for Adafruit Industries # # SPDX-License-Identifier: MIT # MIT License # Copyright (c) 2020 Kevin J. Walters # 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. """ `plotter` ================================================================================ CircuitPython library for the clue-plotter application's plotting facilties. Internally this holds some values in a circular buffer to enable redrawing and has some basic statistics on data. Not intended to be a truly general purpose plotter but perhaps could be developed into one. * Author(s): Kevin J. Walters Implementation Notes -------------------- **Hardware:** * Adafruit CLUE <https://www.adafruit.com/product/4500> **Software and Dependencies:** * Adafruit's CLUE library: https://github.com/adafruit/Adafruit_CircuitPython_CLUE """ import time import array import displayio import terminalio from adafruit_display_text.bitmap_label import Label def mapf(value, in_min, in_max, out_min, out_max): return (value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min # This creates ('{:.0f}', '{:.1f}', '{:.2f}', etc _FMT_DEC_PLACES = tuple("{:." + str(x) + "f}" for x in range(10)) def format_width(nchars, value): """Simple attempt to generate a value within nchars characters. Return value can be too long, e.g. for nchars=5, bad things happen with values > 99999 or < -9999 or < -99.9.""" neg_format = _FMT_DEC_PLACES[nchars - 3] pos_format = _FMT_DEC_PLACES[nchars - 2] if value <= -10.0: text_value = neg_format.format(value) # may overflow width elif value < 0.0: text_value = neg_format.format(value) elif value >= 10.0: text_value = pos_format.format(value) # may overflow width else: text_value = pos_format.format(value) # 0.0 to 9.99999 return text_value class Plotter: _DEFAULT_SCALE_MODE = {"lines": "onscroll", "dots": "screen"} # Palette for plotting, first one is set transparent TRANSPARENT_IDX = 0 # Removed one colour to get number down to 8 for more efficient # bit-packing in displayio's Bitmap _PLOT_COLORS = ( 0x000000, 0x0000FF, 0x00FF00, 0x00FFFF, 0xFF0000, # 0xff00ff, 0xFFFF00, 0xFFFFFF, 0xFF0080, ) POS_INF = float("inf") NEG_INF = float("-inf") # Approximate number of seconds to review data for zooming in # and how often to do that check ZOOM_IN_TIME = 8 ZOOM_IN_CHECK_TIME_NS = 5 * 1e9 # 20% headroom either side on zoom in/out ZOOM_HEADROOM = 20 / 100 GRID_COLOR = 0x308030 GRID_DOT_SPACING = 8 _GRAPH_TOP = 30 # y position for the graph placement INFO_FG_COLOR = 0x000080 INFO_BG_COLOR = 0xC0C000 LABEL_COLOR = 0xC0C0C0 def _display_manual(self): """Intention was to disable auto_refresh here but this needs a simple displayio refresh to work well.""" self._output.auto_refresh = True def _display_auto(self): self._output.auto_refresh = True def _display_refresh(self): """Intention was to call self._output.refresh() but this does not work well as current implementation is designed with a fixed frame rate in mind.""" if self._output.auto_refresh: return True else: return True def __init__( self, output, style="lines", mode="scroll", scale_mode=None, screen_width=240, screen_height=240, plot_width=192, plot_height=201, x_divs=4, y_divs=4, scroll_px=50, max_channels=3, est_rate=50, title="", max_title_len=20, mu_output=False, debug=0, ): """scroll_px of greater than 1 gives a jump scroll.""" # pylint: disable=too-many-locals,too-many-statements self._output = output self.change_stylemode(style, mode, scale_mode=scale_mode, clear=False) self._screen_width = screen_width self._screen_height = screen_height self._plot_width = plot_width self._plot_height = plot_height self._plot_height_m1 = plot_height - 1 self._x_divs = x_divs self._y_divs = y_divs self._scroll_px = scroll_px self._max_channels = max_channels self._est_rate = est_rate self._title = title self._max_title_len = max_title_len # These arrays are used to provide a circular buffer # with _data_values valid values - this needs to be sized # one larger than screen width to retrieve prior y position # for line undrawing in wrap mode self._data_size = self._plot_width + 1 self._data_y_pos = [] self._data_value = [] for _ in range(self._max_channels): # 'i' is 32 bit signed integer self._data_y_pos.append(array.array("i", [0] * self._data_size)) self._data_value.append(array.array("f", [0.0] * self._data_size)) # begin-keep-pylint-happy self._data_mins = None self._data_maxs = None self._data_stats_maxlen = None self._data_stats = None self._values = None self._data_values = None self._x_pos = None self._data_idx = None self._plot_lastzoom_ns = None # end-keep-pylint-happy self._init_data() self._mu_output = mu_output self._debug = debug self._channels = None self._channel_colidx = [] # The range the data source generates within self._abs_min = None self._abs_max = None # The current plot min/max self._plot_min = None self._plot_max = None self._plot_min_range = None # Used partly to prevent div by zero self._plot_range_lock = False self._plot_dirty = False # flag indicate some data has been plotted self._font = terminalio.FONT self._y_axis_lab = "" self._y_lab_width = 6 # maximum characters for y axis label self._y_lab_color = self.LABEL_COLOR self._displayio_graph = None self._displayio_plot = None self._displayio_title = None self._displayio_info = None self._displayio_y_labs = None self._displayio_y_axis_lab = None self._last_manual_refresh = None def _init_data(self, ranges=True): # Allocate arrays for each possible channel with plot_width elements self._data_mins = [self.POS_INF] self._data_maxs = [self.NEG_INF] self._data_start_ns = [time.monotonic_ns()] self._data_stats_maxlen = 10 # When in use the arrays in here are variable length self._data_stats = [[] * self._max_channels] self._values = 0 # total data processed self._data_values = 0 # valid elements in data_y_pos and data_value self._x_pos = 0 self._data_idx = 0 self._plot_lastzoom_ns = 0 # monotonic_ns() for last zoom in if ranges: self._plot_min = None self._plot_max = None self._plot_min_range = None # Used partly to prevent div by zero self._plot_dirty = False # flag indicate some data has been plotted def _recalc_y_pos(self): """Recalculates _data_y_pos based on _data_value for changes in y scale.""" # Check if nothing to do - important since _plot_min _plot_max not yet set if self._data_values == 0: return for ch_idx in range(self._channels): # intentional use of negative array indexing for data_idx in range( self._data_idx - 1, self._data_idx - 1 - self._data_values, -1 ): self._data_y_pos[ch_idx][data_idx] = round( mapf( self._data_value[ch_idx][data_idx], self._plot_min, self._plot_max, self._plot_height_m1, 0, ) ) def get_colors(self): return self._PLOT_COLORS def clear_all(self, ranges=True): if self._values != 0: self._undraw_bitmap() self._init_data(ranges=ranges) # Simple implementation here is to clear the screen on change... def change_stylemode(self, style, mode, scale_mode=None, clear=True): if style not in ("lines", "dots"): raise ValueError("style not lines or dots") if mode not in ("scroll", "wrap"): raise ValueError("mode not scroll or wrap") if scale_mode is None: scale_mode = self._DEFAULT_SCALE_MODE[style] elif scale_mode not in ("pixel", "onscroll", "screen", "time"): raise ValueError("scale_mode not pixel, onscroll, screen or time") # Clearing everything on screen and everything stored in variables # apart from plot ranges is simplest approach here - clearing # involves undrawing which uses the self._style so must not change # that beforehand if clear: self.clear_all(ranges=False) self._style = style self._mode = mode self._scale_mode = scale_mode if self._mode == "wrap": self._display_auto() elif self._mode == "scroll": self._display_manual() def _make_empty_tg_plot_bitmap(self): plot_bitmap = displayio.Bitmap( self._plot_width, self._plot_height, len(self._PLOT_COLORS) ) # Create a colour palette for plot dots/lines plot_palette = displayio.Palette(len(self._PLOT_COLORS)) for idx in range(len(self._PLOT_COLORS)): plot_palette[idx] = self._PLOT_COLORS[idx] plot_palette.make_transparent(0) tg_plot_data = displayio.TileGrid(plot_bitmap, pixel_shader=plot_palette) tg_plot_data.x = self._screen_width - self._plot_width - 1 tg_plot_data.y = self._GRAPH_TOP return (tg_plot_data, plot_bitmap) def _make_tg_grid(self): # pylint: disable=too-many-locals grid_width = self._plot_width grid_height = self._plot_height_m1 div_width = self._plot_width // self._x_divs div_height = self._plot_height // self._y_divs a_plot_grid = displayio.Bitmap(div_width, div_height, 2) # Grid colours grid_palette = displayio.Palette(2) grid_palette.make_transparent(0) grid_palette[0] = 0x000000 grid_palette[1] = self.GRID_COLOR # Horizontal line on grid rectangle for x in range(0, div_width, self.GRID_DOT_SPACING): a_plot_grid[x, 0] = 1 # Vertical line on grid rectangle for y in range(0, div_height, self.GRID_DOT_SPACING): a_plot_grid[0, y] = 1 right_line = displayio.Bitmap(1, grid_height, 2) tg_right_line = displayio.TileGrid(right_line, pixel_shader=grid_palette) for y in range(0, grid_height, self.GRID_DOT_SPACING): right_line[0, y] = 1 bottom_line = displayio.Bitmap(grid_width + 1, 1, 2) tg_bottom_line = displayio.TileGrid(bottom_line, pixel_shader=grid_palette) for x in range(0, grid_width + 1, self.GRID_DOT_SPACING): bottom_line[x, 0] = 1 # Create a TileGrid using the Bitmap and Palette # and tiling it based on number of divisions required tg_plot_grid = displayio.TileGrid( a_plot_grid, pixel_shader=grid_palette, width=self._x_divs, height=self._y_divs, default_tile=0, ) tg_plot_grid.x = self._screen_width - self._plot_width - 1 tg_plot_grid.y = self._GRAPH_TOP tg_right_line.x = tg_plot_grid.x + grid_width tg_right_line.y = tg_plot_grid.y tg_bottom_line.x = tg_plot_grid.x tg_bottom_line.y = tg_plot_grid.y + grid_height g_plot_grid = displayio.Group() g_plot_grid.append(tg_plot_grid) g_plot_grid.append(tg_right_line) g_plot_grid.append(tg_bottom_line) return g_plot_grid def _make_empty_graph(self, tg_and_plot=None): font_w, font_h = self._font.get_bounding_box() self._displayio_title = Label( self._font, text=self._title, scale=2, line_spacing=1, color=self._y_lab_color, ) self._displayio_title.x = self._screen_width - self._plot_width self._displayio_title.y = font_h // 1 self._displayio_y_axis_lab = Label( self._font, text=self._y_axis_lab, line_spacing=1, color=self._y_lab_color ) self._displayio_y_axis_lab.x = 0 # 0 works here because text is "" self._displayio_y_axis_lab.y = font_h // 1 plot_y_labels = [] # y increases top to bottom of screen for y_div in range(self._y_divs + 1): plot_y_labels.append( Label( self._font, text=" " * self._y_lab_width, line_spacing=1, color=self._y_lab_color, ) ) plot_y_labels[-1].x = ( self._screen_width - self._plot_width - self._y_lab_width * font_w - 5 ) plot_y_labels[-1].y = ( round(y_div * self._plot_height / self._y_divs) + self._GRAPH_TOP - 1 ) self._displayio_y_labs = plot_y_labels # Three items (grid, axis label, title) plus the y tick labels g_background = displayio.Group() g_background.append(self._make_tg_grid()) for label in self._displayio_y_labs: g_background.append(label) g_background.append(self._displayio_y_axis_lab) g_background.append(self._displayio_title) if tg_and_plot is not None: (tg_plot, plot) = tg_and_plot else: (tg_plot, plot) = self._make_empty_tg_plot_bitmap() self._displayio_plot = plot # Create the main Group for display with one spare slot for # popup informational text main_group = displayio.Group() main_group.append(g_background) main_group.append(tg_plot) self._displayio_info = None return main_group def set_y_axis_tick_labels(self, y_min, y_max): px_per_div = (y_max - y_min) / self._y_divs for idx, tick_label in enumerate(self._displayio_y_labs): value = y_max - idx * px_per_div text_value = format_width(self._y_lab_width, value) tick_label.text = text_value[: self._y_lab_width] def display_on(self, tg_and_plot=None): if self._displayio_graph is None: self._displayio_graph = self._make_empty_graph(tg_and_plot=tg_and_plot) self._output.root_group = self._displayio_graph def display_off(self): pass def _draw_vline(self, x1, y1, y2, colidx): """Draw a clipped vertical line at x1 from pixel one along from y1 to y2.""" if y2 == y1: if 0 <= y2 <= self._plot_height_m1: self._displayio_plot[x1, y2] = colidx return # For y2 above y1, on screen this translates to being below step = 1 if y2 > y1 else -1 for line_y_pos in range( max(0, min(y1 + step, self._plot_height_m1)), max(0, min(y2, self._plot_height_m1)) + step, step, ): self._displayio_plot[x1, line_y_pos] = colidx # def _clear_plot_bitmap(self): ### woz here def _redraw_all_col_idx(self, col_idx_list): x_cols = min(self._data_values, self._plot_width) wrapMode = self._mode == "wrap" if wrapMode: x_data_idx = (self._data_idx - self._x_pos) % self._data_size else: x_data_idx = (self._data_idx - x_cols) % self._data_size for ch_idx in range(self._channels): col_idx = col_idx_list[ch_idx] data_idx = x_data_idx for x_pos in range(x_cols): # "jump" the gap in the circular buffer for wrap mode if wrapMode and x_pos == self._x_pos: data_idx = ( data_idx + self._data_size - self._plot_width ) % self._data_size # ideally this should inhibit lines between wrapped data y_pos = self._data_y_pos[ch_idx][data_idx] if self._style == "lines" and x_pos != 0: # Python supports negative array index prev_y_pos = self._data_y_pos[ch_idx][data_idx - 1] self._draw_vline(x_pos, prev_y_pos, y_pos, col_idx) else: if 0 <= y_pos <= self._plot_height_m1: self._displayio_plot[x_pos, y_pos] = col_idx data_idx += 1 if data_idx >= self._data_size: data_idx = 0 # This is almost always going to be quicker # than the slow _clear_plot_bitmap implemented on 5.0.0 displayio def _undraw_bitmap(self): if not self._plot_dirty: return self._redraw_all_col_idx([self.TRANSPARENT_IDX] * self._channels) self._plot_dirty = False def _redraw_all(self): self._redraw_all_col_idx(self._channel_colidx) self._plot_dirty = True def _undraw_column(self, x_pos, data_idx): """Undraw a single column at x_pos based on data from data_idx.""" colidx = self.TRANSPARENT_IDX for ch_idx in range(self._channels): y_pos = self._data_y_pos[ch_idx][data_idx] if self._style == "lines" and x_pos != 0: # Python supports negative array index prev_y_pos = self._data_y_pos[ch_idx][data_idx - 1] self._draw_vline(x_pos, prev_y_pos, y_pos, colidx) else: if 0 <= y_pos <= self._plot_height_m1: self._displayio_plot[x_pos, y_pos] = colidx # very similar code to _undraw_bitmap although that is now # more sophisticated as it supports wrap mode def _redraw_for_scroll(self, x1, x2, x1_data_idx): """Redraw data from x1 to x2 inclusive for scroll mode only.""" for ch_idx in range(self._channels): colidx = self._channel_colidx[ch_idx] data_idx = x1_data_idx for x_pos in range(x1, x2 + 1): y_pos = self._data_y_pos[ch_idx][data_idx] if self._style == "lines" and x_pos != 0: # Python supports negative array index prev_y_pos = self._data_y_pos[ch_idx][data_idx - 1] self._draw_vline(x_pos, prev_y_pos, y_pos, colidx) else: if 0 <= y_pos <= self._plot_height_m1: self._displayio_plot[x_pos, y_pos] = colidx data_idx += 1 if data_idx >= self._data_size: data_idx = 0 self._plot_dirty = True def _update_stats(self, values): """Update the statistics for minimum and maximum.""" for idx, value in enumerate(values): # Occasionally check if we need to add a new bucket to stats if idx == 0 and self._values & 0xF == 0: now_ns = time.monotonic_ns() if now_ns - self._data_start_ns[-1] > 1e9: self._data_start_ns.append(now_ns) self._data_mins.append(value) self._data_maxs.append(value) # Remove the first elements if too long if len(self._data_start_ns) > self._data_stats_maxlen: self._data_start_ns.pop(0) self._data_mins.pop(0) self._data_maxs.pop(0) continue if value < self._data_mins[-1]: self._data_mins[-1] = value if value > self._data_maxs[-1]: self._data_maxs[-1] = value def _data_store(self, values): """Store the data values in the circular buffer.""" for ch_idx, value in enumerate(values): self._data_value[ch_idx][self._data_idx] = value # Increment the data index for circular buffer self._data_idx += 1 if self._data_idx >= self._data_size: self._data_idx = 0 def _data_draw(self, values, x_pos, data_idx): offscale = False for ch_idx, value in enumerate(values): # Last two parameters appear "swapped" - this deals with the # displayio screen y coordinate increasing downwards y_pos = round( mapf(value, self._plot_min, self._plot_max, self._plot_height_m1, 0) ) if y_pos < 0 or y_pos >= self._plot_height: offscale = True self._data_y_pos[ch_idx][data_idx] = y_pos if self._style == "lines" and self._x_pos != 0: # Python supports negative array index prev_y_pos = self._data_y_pos[ch_idx][data_idx - 1] self._draw_vline(x_pos, prev_y_pos, y_pos, self._channel_colidx[ch_idx]) self._plot_dirty = True # bit wrong if whole line is off screen else: if not offscale: self._displayio_plot[x_pos, y_pos] = self._channel_colidx[ch_idx] self._plot_dirty = True def _check_zoom_in(self): """Check if recent data warrants zooming in on y axis scale based on checking minimum and maximum times which are recorded in approximate 1 second buckets. Returns two element tuple with (min, max) or empty tuple for no zoom required. Caution is required with min == max.""" start_idx = len(self._data_start_ns) - self.ZOOM_IN_TIME if start_idx < 0: return () now_ns = time.monotonic_ns() if now_ns < self._plot_lastzoom_ns + self.ZOOM_IN_CHECK_TIME_NS: return () recent_min = min(self._data_mins[start_idx:]) recent_max = max(self._data_maxs[start_idx:]) recent_range = recent_max - recent_min headroom = recent_range * self.ZOOM_HEADROOM # No zoom if the range of data is near the plot range if ( self._plot_min > recent_min - headroom and self._plot_max < recent_max + headroom ): return () new_plot_min = max(recent_min - headroom, self._abs_min) new_plot_max = min(recent_max + headroom, self._abs_max) return (new_plot_min, new_plot_max) def _auto_plot_range(self, redraw_plot=True): """Check if we need to zoom out or in based on checking historical data values unless y_range_lock has been set. """ if self._plot_range_lock: return False zoom_in = False zoom_out = False # Calcuate some new min/max values based on recentish data # and add some headroom y_min = min(self._data_mins) y_max = max(self._data_maxs) y_range = y_max - y_min headroom = y_range * self.ZOOM_HEADROOM new_plot_min = max(y_min - headroom, self._abs_min) new_plot_max = min(y_max + headroom, self._abs_max) # set new range if the data does not fit on the screen # this will also redo y tick labels if necessary if new_plot_min < self._plot_min or new_plot_max > self._plot_max: if self._debug >= 2: print("Zoom out") self._change_y_range(new_plot_min, new_plot_max, redraw_plot=redraw_plot) zoom_out = True else: # otherwise check if zoom in is warranted rescale_zoom_range = self._check_zoom_in() if rescale_zoom_range: if self._debug >= 2: print("Zoom in") self._change_y_range( rescale_zoom_range[0], rescale_zoom_range[1], redraw_plot=redraw_plot, ) zoom_in = True if zoom_in or zoom_out: self._plot_lastzoom_ns = time.monotonic_ns() return True return False def data_add(self, values): # pylint: disable=too-many-branches changed = False data_idx = self._data_idx x_pos = self._x_pos self._update_stats(values) if self._mode == "wrap": if self._x_pos == 0 or self._scale_mode == "pixel": changed = self._auto_plot_range(redraw_plot=False) # Undraw any previous data at current x position if ( not changed and self._data_values >= self._plot_width and self._values >= self._plot_width ): self._undraw_column(self._x_pos, data_idx - self._plot_width) elif self._mode == "scroll": if x_pos >= self._plot_width: # Fallen off x axis range? changed = self._auto_plot_range(redraw_plot=False) if not changed: self._undraw_bitmap() # Need to cls for the scroll sc_data_idx = ( data_idx + self._scroll_px - self._plot_width ) % self._data_size self._data_values -= self._scroll_px self._redraw_for_scroll( 0, self._plot_width - 1 - self._scroll_px, sc_data_idx ) x_pos = self._plot_width - self._scroll_px elif self._scale_mode == "pixel": changed = self._auto_plot_range(redraw_plot=True) # Draw the new data self._data_draw(values, x_pos, data_idx) # Store the new values in circular buffer self._data_store(values) # increment x position dealing with wrap/scroll new_x_pos = x_pos + 1 if new_x_pos >= self._plot_width: # fallen off edge so wrap or leave position # on last column for scroll if self._mode == "wrap": self._x_pos = 0 else: self._x_pos = new_x_pos # this is off screen else: self._x_pos = new_x_pos if self._data_values < self._data_size: self._data_values += 1 self._values += 1 if self._mu_output: print(values) # scrolling mode has automatic refresh in background turned off if self._mode == "scroll": self._display_refresh() def _change_y_range(self, new_plot_min, new_plot_max, redraw_plot=True): y_min = new_plot_min y_max = new_plot_max if self._debug >= 2: print("Change Y range", new_plot_min, new_plot_max, redraw_plot) # if values reduce range below the minimum then widen the range # but keep it within the absolute min/max values if self._plot_min_range is not None: range_extend = self._plot_min_range - (y_max - y_min) if range_extend > 0: y_max += range_extend / 2 y_min -= range_extend / 2 if y_min < self._abs_min: y_min = self._abs_min y_max = y_min + self._plot_min_range elif y_max > self._abs_max: y_max = self._abs_max y_min = y_max - self._plot_min_range self._plot_min = y_min self._plot_max = y_max self.set_y_axis_tick_labels(self._plot_min, self._plot_max) if self._values: self._undraw_bitmap() self._recalc_y_pos() ## calculates new y positions if redraw_plot: self._redraw_all() @property def title(self): return self._title @title.setter def title(self, value): self._title = value[: self._max_title_len] # does not show truncation self._displayio_title.text = self._title @property def info(self): if self._displayio_info is None: return None return self._displayio_info.text @info.setter def info(self, value): """Place some text on the screen. Multiple lines are supported with newline character. Font will be 3x standard terminalio font or 2x if that does not fit.""" if self._displayio_info is not None: self._displayio_graph.pop() if value is not None and value != "": font_scale = 2 line_spacing = 1 font_w, font_h = self._font.get_bounding_box() text_lines = value.split("\n") max_word_chars = max([len(word) for word in text_lines]) # If too large reduce the scale if ( max_word_chars * font_scale * font_w > self._screen_width or len(text_lines) * font_scale * font_h * line_spacing > self._screen_height ): font_scale -= 1 self._displayio_info = Label( self._font, text=value, line_spacing=line_spacing, scale=font_scale, background_color=self.INFO_FG_COLOR, color=self.INFO_BG_COLOR, ) # centre the (left justified) text self._displayio_info.x = ( self._screen_width - font_scale * font_w * max_word_chars ) // 2 self._displayio_info.y = self._screen_height // 3 self._displayio_graph.append(self._displayio_info) else: self._displayio_info = None if self._mode == "scroll": self._display_refresh() @property def channels(self): return self._channels @channels.setter def channels(self, value): if value > self._max_channels: raise ValueError("Exceeds max_channels") self._channels = value @property def y_range(self): return (self._plot_min, self._plot_max) @y_range.setter def y_range(self, minmax): if minmax[0] != self._plot_min or minmax[1] != self._plot_max: self._change_y_range(minmax[0], minmax[1], redraw_plot=True) @property def y_full_range(self): return (self._plot_min, self._plot_max) @y_full_range.setter def y_full_range(self, minmax): self._abs_min = minmax[0] self._abs_max = minmax[1] @property def y_min_range(self): return self._plot_min_range @y_min_range.setter def y_min_range(self, value): self._plot_min_range = value @property def y_axis_lab(self): return self._y_axis_lab @y_axis_lab.setter def y_axis_lab(self, text): self._y_axis_lab = text[: self._y_lab_width] font_w, _ = self._font.get_bounding_box() x_pos = (40 - font_w * len(self._y_axis_lab)) // 2 # max() used to prevent negative (off-screen) values self._displayio_y_axis_lab.x = max(0, x_pos) self._displayio_y_axis_lab.text = self._y_axis_lab @property def channel_colidx(self): return self._channel_colidx @channel_colidx.setter def channel_colidx(self, value): # tuple() ensures object has a local / read-only copy of data self._channel_colidx = tuple(value) @property def mu_output(self): return self._mu_output @mu_output.setter def mu_output(self, value): self._mu_output = value @property def y_range_lock(self): return self._plot_range_lock @y_range_lock.setter def y_range_lock(self, value): self._plot_range_lock = value
Code Discussion
The Design section covers a lot of aspects of the program. A few interesting parts of the implementation are discussed here.
IlluminatedColorPlotSource class
This has the two required methods, the constructor and data()
. It also implements two optional methods:
-
start()
to set the gain and duration parameters on the colour sensor and turn on the white LEDs. -
stop()
to turn off the white LEDs.
class IlluminatedColorPlotSource(PlotSource): def __init__(self, my_clue, mode="Clear"): self._clue = my_clue col_fl_lc = mode[0].lower() if col_fl_lc == "r": plot_colour = self.RGB_COLORS[0] elif col_fl_lc == "g": plot_colour = self.RGB_COLORS[1] elif col_fl_lc == "b": plot_colour = self.RGB_COLORS[2] elif col_fl_lc == "c": plot_colour = self.DEFAULT_COLORS[0] else: raise ValueError("Colour must be Red, Green, Blue or Clear") self._channel = col_fl_lc super().__init__(1, "Illum. color: " + self._channel.upper(), abs_min=0, abs_max=8000, initial_min=0, initial_max=2000, colors=(plot_colour,), rate=50) def data(self): (r, g, b, c) = self._clue.color if self._channel == "r": return r elif self._channel == "g": return g elif self._channel == "b": return b elif self._channel == "c": return c else: return None # This should never happen def start(self): # Set APDS9960 to sample every (256 - 249 ) * 2.78 = 19.46ms # pylint: disable=protected-access self._clue._sensor.integration_time = 249 # 19.46ms, ~ 50Hz self._clue._sensor.color_gain = 0x03 # 64x (library default is 4x) self._clue.white_leds = True def stop(self): self._clue.white_leds = False
The constructor configures the object to select the appropriate value from the colour sensor. It is intended to accept Red
, Green
, Blue
or Clear
but will accept red
and rouge
as it is only checking the lowercase first character. This could be viewed as an inappropriate application of the robustness principle.
The full range of the sensor data is hard-coded in the constructor as 0
and 8000
. This is based on observations of the data reaching 7169
, 8000
gives nicer values on the y axis tick labels. It's better to retrieve this value programmatically, if it's available, and verify that value against the manufacturer's data sheet.
The clue object does not have a property, public method or public variable to access the sensor to set integration_time
and color_gain
. The single underscore prefix on the _sensor
instance variable indicates that it is intended only for the class itself to access but Python does not enforce this. This allows static code analyzers like pylint to detect misuse. In this case, the minor misuse is acknowledged with a pylint disable pragma which inhibits the warning. For critical applications this would be inappropriate as it is breaking the object's encapsulation and makes the application vulnerable in possibly disastrous ways to future changes in the class.
Units and Interfaces
The setting of integration_time
in ColorPlotSource
and IlluminatedColorPlotSource
uses an obscure, manufacturer-specific formula for the actual time not described in the underlying library documentation. This is confusing for a programmer who has previously used the Arduino library which uses milliseconds as units. Changing the CircuitPython library integration_time
would require a coordinated and synchronised change with every application which uses the library. This is generally impractical.
The gyro
value retrieved in GyroPlotSource
is documented as being measured in degrees per second and tests confirm this. The design guide for CircuitPython's libraries lists gyro with a type of (float, float, float)
and units of "x, y, z radians
per second". This presents the same problem as the previous case as a change will have impact to many existing users.
These two examples highlight the importance of thoughtful design and review of interfaces.
Setting Properties and Pass by ...
Python has a feature called properties which is often used in classes to create things which appear to be instance variables but actually execute code when they are read (get) or assigned to (set). The classic example below features a pair of getter and setter methods with decorators.
@property def channel_colidx(self): return self._channel_colidx @channel_colidx.setter def channel_colidx(self, value): # tuple() ensures object has a local / read-only copy of data self._channel_colidx = tuple(value)
The comment is already explaining a subtlety here of using the passed value
. The value is intended to be a sequence. If a list is passed the the caller part of the program can modify the list stored in the class as it is passed by reference. For comparison, if the value had been an int
this would not be possible as it is passed by value. This is shown on REPL below with a similar example.
>>> class class_storing_sequence(): ... def __init__(self, a_sequence): ... self._a_sequence = a_sequence ... self._a_sequence_copy = tuple(a_sequence) ... >>> words = ["pass", "by"] >>> obj = class_storing_sequence(words) >>> words.extend(["object", "reference"]) >>> obj._a_sequence ['pass', 'by', 'object', 'reference'] >>> obj._a_sequence_copy ('pass', 'by')
An even more confusing permutation would be another part of the program retrieving the channel_colidx
list and intentionally or unintentionally modifying it. This could cause tricky-to-find bugs. The cautious use of tuple()
in channel_colidx(self, value)
creates an independent copy of the sequence. The use of tuple()
rather than list()
is very intentional as this makes it read-only due to Python tuples being immutable.
The general issue is described and discussed in detail in Robert Heaton's blog post: Is Python pass-by-reference or pass-by-value?
Page last edited February 24, 2025
Text editor powered by tinymce.