# SPDX-FileCopyrightText: 2020 Kevin J Walters for Adafruit Industries # # SPDX-License-Identifier: MIT # clue-metal-detector v1.6 # A simple metal detector using a minimum number of external components # Tested with an Adafruit CLUE (Alpha) and CircuitPython 5.2.0 # Tested with an Adafruit Circuit Playground Bluefruit with TFT Gizmo # and CircuitPython 5.2.0 # CLUE: Pad P0 is an output and pad P1 is an input # CPB: Pad/STEMMA A1 is an output and Pad/STEMMA A2 is an input # copy this file to CLUE/CPB board as code.py # 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. # pylint: disable=global-statement import time import math import array import os import gc import board import pwmio import analogio import ulab from displayio import Group import terminalio # These imports works on CLUE, CPB (and CPX on 5.x) from audiocore import RawSample try: from audioio import AudioOut except ImportError: from audiopwmio import PWMAudioOut as AudioOut # displayio graphical objects from adafruit_display_text.label import Label from adafruit_display_shapes.rect import Rect from adafruit_display_shapes.circle import Circle # Assuming CLUE if it's not a Circuit Playround (Bluefruit) clue_less = "Circuit Playground" in os.uname().machine if clue_less: # CPB with TFT Gizmo (240x240) from adafruit_circuitplayground import cp from adafruit_gizmo import tft_gizmo # Outputs display = tft_gizmo.TFT_Gizmo() audio_out = AudioOut(board.SPEAKER) min_audio_frequency = 100 max_audio_frequency = 4000 pixels = cp.pixels board_pin_output = board.A1 # Enable the onboard amplifier for speaker cp._speaker_enable.value = True # pylint: disable=protected-access # Inputs board_pin_input = board.A2 magnetometer = None # This indicates device is not present button_left = lambda: cp.button_b button_right = lambda: cp.button_a else: # CLUE with builtin screen (240x240) from adafruit_clue import clue # Outputs display = board.DISPLAY audio_out = AudioOut(board.SPEAKER) min_audio_frequency = 100 max_audio_frequency = 5000 pixels = clue.pixel board_pin_output = board.P0 # Inputs (buttons reversed as it is used upside-down with Gizmo) board_pin_input = board.P1 magnetometer = lambda: clue.magnetic button_left = lambda: clue.button_a button_right = lambda: clue.button_b # Globals variables used r/w in functions last_frequency = 0 last_negbar_len = None last_posbar_len = None last_mag_radius = None text_overlay_gob = None voltage_barneg_dob = None voltage_sep_dob = None voltage_barpos_dob = None magnet_circ_dob = None # Globals debug = 1 screen_height = display.height screen_width = display.width samples = [] # Other globals quantize_tones = True audio_on = True screen_on = True mu_output = False neopixel_on = True # Used to alternate/flash the NeoPixel neopixel_alternate = True # Some constants used in start_beep() BASE_NOTE = 261.6256 # C4 (middle C) QUANTIZE = 4 # determines the "scale" POSTLOG_FACTOR = QUANTIZE / math.log(2) AUDIO_MIDPOINT = 32768 # There's room for 80 pixels but 60 draws a bit quicker VOLTAGE_BAR_WIDTH = 60 VOLTAGE_BAR_HEIGHT = 118 VOLTAGE_BAR_SEP_HEIGHT = 4 MAG_MAX_RADIUS = 50 VOLTAGE_FMT = "{:6.1f}" MAG_FMT = "{:6.1f}" INFO_FG_COLOR = 0x000080 INFO_BG_COLOR = 0xc0c000 BLACK_TUPLE = (0, 0, 0) RED = 0xff0000 GREEN75 = 0x00c000 BLUE = 0x0000ff WHITE75 = 0xc0c0c0 FONT_WIDTH, FONT_HEIGHT = terminalio.FONT.get_bounding_box() # Thresholds below which audio is silent and NeoPixels are dark threshold_voltage = 0.002 threshold_mag = 2.5 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) # Adapted and borrowed from clue-plotter v1.14 def wait_release(text_func, button_func, menu): """Calls button_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. Text is displayed by calling text_func(text). 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: text_func(menu_text) while time.monotonic_ns() < menu_time_ns: if not button_func(): selected = True break if menu_text: text_func("") if selected: break return (menu_option, (time.monotonic_ns() - start_t_ns) * 1e-9) def popup_text(text_func, text, duration=1.0): """Place some text on the screen using info property of Plotter object for duration seconds.""" text_func(text) time.sleep(duration) text_func(None) def show_text(text): """Place text on the screen. Empty string or None clears it.""" global screen_group, text_overlay_gob if text: font_scale = 3 line_spacing = 1.25 text_lines = text.split("\n") max_word_chars = max([len(word) for word in text_lines]) # If too large reduce the scale to 2 and hope! if (max_word_chars * font_scale * FONT_WIDTH > screen_width or (len(text_lines) * font_scale * FONT_HEIGHT * line_spacing) > screen_height): font_scale -= 1 text_overlay_gob = Label(terminalio.FONT, text=text, scale=font_scale, background_color=INFO_FG_COLOR, color=INFO_BG_COLOR) # Centre the (left justified) text text_overlay_gob.x = (screen_width - font_scale * FONT_WIDTH * max_word_chars) // 2 text_overlay_gob.y = screen_height // 2 screen_group.append(text_overlay_gob) else: if text_overlay_gob is not None: screen_group.remove(text_overlay_gob) text_overlay_gob = None def voltage_bar_set(volt_diff): """Draw a bar based on positive or negative values. Width of 60 is performance compromise as more pixels take longer.""" global voltage_sep_dob, voltage_barpos_dob, voltage_barneg_dob global last_negbar_len, last_posbar_len if voltage_sep_dob is None: voltage_sep_dob = Rect(160, VOLTAGE_BAR_HEIGHT, VOLTAGE_BAR_WIDTH, VOLTAGE_BAR_SEP_HEIGHT, fill=WHITE75) screen_group.append(voltage_sep_dob) if volt_diff < 0: negbar_len = max(min(-round(volt_diff * 5e3), VOLTAGE_BAR_HEIGHT), 1) posbar_len = 1 else: negbar_len = 1 posbar_len = max(min(round(volt_diff * 5e3), VOLTAGE_BAR_HEIGHT), 1) if posbar_len == last_posbar_len and negbar_len == last_negbar_len: return if voltage_barpos_dob is not None: screen_group.remove(voltage_barpos_dob) if posbar_len > 0: voltage_barpos_dob = Rect(160, VOLTAGE_BAR_HEIGHT - posbar_len, VOLTAGE_BAR_WIDTH, posbar_len, fill=GREEN75) screen_group.append(voltage_barpos_dob) last_posbar_len = posbar_len if voltage_barneg_dob is not None: screen_group.remove(voltage_barneg_dob) if negbar_len > 0: voltage_barneg_dob = Rect(160, VOLTAGE_BAR_HEIGHT + VOLTAGE_BAR_SEP_HEIGHT, VOLTAGE_BAR_WIDTH, negbar_len, fill=RED) screen_group.append(voltage_barneg_dob) last_negbar_len = negbar_len def magnet_circ_set(mag_ut): """Display a filled circle to represent the magnetic value mag_ut in microteslas.""" global magnet_circ_dob global last_mag_radius # map microteslas to a radius with minimum of 1 and # maximum of MAG_MAX_RADIUS radius = min(max(round(math.sqrt(mag_ut) * 4), 1), MAG_MAX_RADIUS) if radius == last_mag_radius: return if magnet_circ_dob is not None: screen_group.remove(magnet_circ_dob) magnet_circ_dob = Circle(60, 180, radius, fill=BLUE) screen_group.append(magnet_circ_dob) def manual_screen_refresh(disp): """Refresh the screen as immediately as is currently possibly with refresh method.""" refreshed = False while True: try: # 1000fps is fastest library allows - this high value # minimises any delays this refresh() method introduces refreshed = disp.refresh(minimum_frames_per_second=0, target_frames_per_second=1000) except RuntimeError: pass if refreshed: break def neopixel_set(pix, d_volt, mag_ut): """Set all the NeoPixels to an alternating colour based on voltage difference and magnitude of magnetic flux density difference.""" global neopixel_alternate np_r, np_g, np_b = BLACK_TUPLE if neopixel_alternate: # RGB values are 8bit, hence the cap of 255 using min() if abs(d_volt) > threshold_voltage: if d_volt < 0.0: np_r = min(round(-d_volt * 8e3), 255) else: np_g = min(round(d_volt * 8e3), 255) else: if mag_ut > threshold_mag: np_b = min(round(mag_ut * 6), 255) pix.fill((np_r, np_g, np_b)) # Note: double brackets to pass tuple neopixel_alternate = not neopixel_alternate def start_beep(freq, wave, wave_idx): """Start playing a continous beep based on freq and waveform specified by wave_idx. A frequency of 0 will stop the note playing. This quantizes the notes into a scale to make beeping sound more pleasant. This modifies the sample_rate property of the RawSample objects. """ global last_frequency if freq == 0: if last_frequency != 0: audio_out.stop() last_frequency = 0 return if quantize_tones: note_freq = BASE_NOTE * 2**((round(math.log(freq / BASE_NOTE) * POSTLOG_FACTOR)) / QUANTIZE) d_print(3, "Quantize", freq, note_freq) else: note_freq = freq (waveform, wave_samples_n) = wave[wave_idx] new_freq = round(note_freq * wave_samples_n) # Only set the new frequency if it's not the same as last one if new_freq != last_frequency: waveform.sample_rate = new_freq audio_out.play(waveform, loop=True) last_frequency = new_freq def make_sample_list(levels=10, volume=32767, range_l=24, start_l=8): """Make a list of tuples of (RawSample, sample_length) with a sine wave of varying resolution from high to low. The lower resolutions sound crunchier and louder on the CLUE.""" # Make a range of sample lengths, default is between 32 and 8 sample_lens = [int((x*(range_l + .99)/(levels - 1)) + start_l) for x in range(0, levels)] sample_lens.reverse() wavefs = [] for s_len in sample_lens: raw_samples = array.array("H", [round(volume * math.sin(2 * math.pi * (idx / s_len))) + AUDIO_MIDPOINT for idx in range(s_len)]) sound_samples = RawSample(raw_samples) wavefs.append((sound_samples, s_len)) return wavefs waveforms = make_sample_list() # For testing the waveforms if debug >= 4: for idx in range(len(waveforms)): start_beep(440, waveforms, idx) time.sleep(0.1) start_beep(0, waveforms, 0) # This silences it # See https://forums.adafruit.com/viewtopic.php?f=60&t=164758 for # a comparison and performance analysis of alternate techniques for this def sample_sum(pin, num): """Sample the analogue value from pin num times and return the sum of the values.""" global samples # Not strictly needed - indicative of r/w use samples[:] = [pin.value for _ in range(num)] return sum(samples) # Initialise detector display # The units are created as separate text objects as they are static # and this reduces the amount of redrawing for the dynamic numbers FONT_SCALE = 3 if magnetometer is not None: magnet_value_dob = Label(font=terminalio.FONT, text="----.-", scale=FONT_SCALE, color=0xc0c000) magnet_value_dob.y = 90 magnet_units_dob = Label(font=terminalio.FONT, text="uT", scale=FONT_SCALE, color=0xc0c000) magnet_units_dob.x = len(magnet_value_dob.text) * FONT_WIDTH * FONT_SCALE magnet_units_dob.y = magnet_value_dob.y voltage_value_dob = Label(font=terminalio.FONT, text="----.-", scale=FONT_SCALE, color=0x00c0c0) voltage_value_dob.y = 30 voltage_units_dob = Label(font=terminalio.FONT, text="mV", scale=FONT_SCALE, color=0x00c0c0) voltage_units_dob.y = voltage_value_dob.y voltage_units_dob.x = len(voltage_value_dob.text) * FONT_WIDTH * FONT_SCALE screen_group = Group() if magnetometer is not None: screen_group.append(magnet_value_dob) screen_group.append(magnet_units_dob) screen_group.append(voltage_value_dob) screen_group.append(voltage_units_dob) # Initialise some displayio objects and append them # The following four variables are set by these two functions # voltage_barneg_dob, voltage_sep_dob, voltage_barpos_dob # magnet_circ_dob voltage_bar_set(0) if magnetometer is not None: magnet_circ_set(0) # Start-up splash screen display.show(screen_group) # Start-up splash screen popup_text(show_text, "\n".join(["Button Guide", "Left: audio", " 2secs: NeoPixel", " 4s: screen", " 6s: Mu output", "Right: recalibrate"]), duration=10) # P1 or A2 for analogue input pin_input = analogio.AnalogIn(board_pin_input) CONV_FACTOR = pin_input.reference_voltage / 65535 # Start pwm output on P0 or A1 # 400kHz and 55000 (84%) duty_cycle were chosen empirically to maximise # the voltage and the voltage drop detecting a small pair of metal scissors pwm = pwmio.PWMOut(board_pin_output, frequency=400 * 1000, duty_cycle=0, variable_frequency=True) pwm.duty_cycle = 55000 # Get a baseline value for magnetometer totals = [0.0] * 3 mag_samples_n = 10 if magnetometer is not None: for _ in range(mag_samples_n): mx, my, mz = magnetometer() totals[0] += mx totals[1] += my totals[2] += mz time.sleep(0.05) base_mx = totals[0] / mag_samples_n base_my = totals[1] / mag_samples_n base_mz = totals[2] / mag_samples_n # Wait a bit for P1/A2 input to stabilise _ = sample_sum(pin_input, 3000) / 3000 * CONV_FACTOR base_voltage = sample_sum(pin_input, 1000) / 1000 * CONV_FACTOR voltage_value_dob.text = "{:6.1f}".format(base_voltage * 1000.0) # Auto refresh off display.auto_refresh = False # Store two previous values of voltage to make a simple # filtered value voltage_zm1 = None voltage_zm2 = None filt_voltage = None # Initialise the magnitude of the # magnetic flux density difference from its baseline mag_mag = 0.0 # Keep some historical voltage data to calculate median for re-baselining # aiming for about 10 reads per second so this gives # 20 seconds voltage_hist = ulab.numpy.zeros(20 * 10 + 1, dtype=ulab.numpy.float) voltage_hist_idx = 0 voltage_hist_complete = False voltage_hist_median = None # Reduce the frequency of the more heavyweight graphical changes update_basic_graphics_period = 2 update_complex_graphics_period = 4 update_median_period = 5 counter = 0 while True: # Garbage collect now to reduce likelihood it occurs # during sample reading gc.collect() if debug >=2: d_print(2, "mem_free=" + str(gc.mem_free())) screen_updates = 0 # Used to determine if the screen needs a refresh # Take arithmetic mean of 500 samples but take a few more samples # if the loop isn't doing other work samples_to_read = 500 # About 23ms worth on CLUE update_basic_graphics = (screen_on and counter % update_basic_graphics_period == 0) if not update_basic_graphics: samples_to_read += 150 update_complex_graphics = (screen_on and counter % update_complex_graphics_period == 0) if not update_complex_graphics: samples_to_read += 400 update_median = counter % update_median_period == 0 if not update_median: samples_to_read += 50 # Read the analogue values from P1/A2 sample_start_time_ns = time.monotonic_ns() voltage = (sample_sum(pin_input, samples_to_read) / samples_to_read * CONV_FACTOR) # Store the previous two voltage values voltage_zm2 = voltage_zm1 voltage_zm1 = voltage if voltage_zm1 is None: voltage_zm1 = voltage if voltage_zm2 is None: voltage_zm2 = voltage filt_voltage = (voltage * 0.4 + voltage_zm1 * 0.3 + voltage_zm2 * 0.3) update_basic_graphics = counter % update_basic_graphics_period == 0 update_complex_graphics = counter % update_complex_graphics_period == 0 # Update text if update_basic_graphics: voltage_value_dob.text = VOLTAGE_FMT.format(filt_voltage * 1000.0) screen_updates += 1 # Read magnetometer if magnetometer is not None: mx, my, mz = magnetometer() diff_x = mx - base_mx diff_y = my - base_my diff_z = mz - base_mz # Use the z value as a crude measure as this is # constant if the device is rotated and kept level mag_mag = math.sqrt(diff_z * diff_z) else: mag_mag = 0.0 # Calculate a new audio frequency based on the absolute difference # in voltage being read - turn small voltages into 0 for silence # between 100Hz (won't be audible) # and 5000 (loud on CLUE's miniscule speaker) diff_v = filt_voltage - base_voltage abs_diff_v = abs(diff_v) if audio_on: if abs_diff_v > threshold_voltage or mag_mag > threshold_mag: frequency = min(min_audio_frequency + abs_diff_v * 5e5, max_audio_frequency) else: frequency = 0 # silence start_beep(frequency, waveforms, min(int(mag_mag / 2), len(waveforms) - 1)) # Update the NeoPixel(s) if enabled if neopixel_on: neopixel_set(pixels, diff_v, mag_mag) # Update voltage bargraph if update_complex_graphics: voltage_bar_set(diff_v) screen_updates += 1 # Update the magnetometer text value and the filled circle representation if magnetometer is not None: if update_basic_graphics: magnet_value_dob.text = MAG_FMT.format(mag_mag) screen_updates += 1 if update_complex_graphics: magnet_circ_set(mag_mag) screen_updates += 1 # Update the screen with a refresh if needed if screen_updates: manual_screen_refresh(display) # Send output to Mu in tuple format if mu_output: print((diff_v, mag_mag)) # Check for buttons and just for this section of code turn back on # the screen auto-refresh so the menus actually appear! display.auto_refresh = True if button_left(): opt, _ = wait_release(show_text, button_left, [(2, "Audio " + ("off" if audio_on else "on")), (4, "NeoPixel " + ("off" if neopixel_on else "on")), (6, "Screen " + ("off" if screen_on else "on")), (8, "Mu output " + ("off" if mu_output else "on")) ]) if not screen_on or opt == 2: # Screen toggle screen_on = not screen_on if screen_on: display.show(screen_group) display.brightness = 1.0 else: display.show(None) display.brightness = 0.0 elif opt == 0: # Audio toggle audio_on = not audio_on if not audio_on: start_beep(0, waveforms, 0) # Silence elif opt == 1: # NeoPixel toggle neopixel_on = not neopixel_on if not neopixel_on: neopixel_set(pixels, 0.0, 0.0) else: # Mu toggle mu_output = not mu_output # Set new baseline voltage and magnetometer on right button press if button_right(): wait_release(show_text, button_right, [(2, "Recalibrate")]) d_print(1, "Recalibrate") base_voltage = voltage voltage_hist_idx = 0 voltage_hist_complete = False voltage_hist_median = None if magnetometer is not None: base_mx, base_my, base_mz = mx, my, mz display.auto_refresh = False # Add the current voltage to the historical list voltage_hist[voltage_hist_idx] = voltage if voltage_hist_idx >= len(voltage_hist) - 1: voltage_hist_idx = 0 voltage_hist_complete = True else: voltage_hist_idx += 1 # Adjust the reference base_voltage to the median of historical values if voltage_hist_complete and update_median: voltage_hist_median = ulab.numpy.sort(voltage_hist)[len(voltage_hist) // 2] base_voltage = voltage_hist_median d_print(2, counter, sample_start_time_ns / 1e9, voltage * 1000.0, mag_mag, filt_voltage * 1000.0, base_voltage, voltage_hist_median) counter += 1