Maker.io main logo

Sensor-Locked Secrets with CircuitPython

76

2026-07-14 | By Adafruit Industries

License: See Original Project Microcontrollers Displays LCD / TFT Ambient Light Temperature Humidity Arduino Adafruit Feather Qwiic STEMMA

Courtesy of Adafruit

Guide by Tim C

Overview

temp_humidity_lux_decrypt_text

This project is about scrambling a secret message or picture in such a way that it can only be decrypted when certain environmental factors are met. The guide covers three different types of sensor inputs: GPS coordinates, lux light values, and temperature/humidity/CO2 readings. The general concept could be adapted for other types of sensors like magnetic, gyroscope, proximity and more.

The inspirations for this project include reverse geo-cache puzzles like the one in this learn guide, and statues that are engineered to use the sun's location at specific day and time to create a special effect with shadows or sun beams. A few historical examples of the latter are ‘the descent of Kukulcán’ serpent effect on the Kukulcán pyramid in Chichén Itzá Mexico, and the inner chamber illumination at Newgrange in County Meath Ireland.

Parts

Installing CircuitPython

CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.

CircuitPython Quickstart

Follow this step-by-step to quickly get CircuitPython running on your board.

Download the latest version of CircuitPython for this board via circuitpython.org

Click the link above to download the latest CircuitPython UF2 file.

Save it wherever is convenient for you.

click_1

board_2

Plug your board into your computer, using a known-good data-sync cable, directly, or via an adapter if needed.

Double-click the reset button (highlighted in red above), and you will see the RGB status LED(s) turn green (highlighted in green above). If you see red, try another port, or if you're using an adapter or hub, try without the hub, or different adapter or hub.

For this board, tap reset and wait for the LED to turn purple, and as soon as it turns purple, tap reset again. The second tap needs to happen while the LED is still purple.

If double-clicking doesn't work the first time, try again. Sometimes it can take a few tries to get the rhythm right!

A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.

You will see a new disk drive appear called FTHRS3BOOT.

Drag the adafruit_circuitpython_etc.uf2 file to FTHRS3BOOT.

drag_3

drag_4

The BOOT drive will disappear and a new disk drive called CIRCUITPY will appear.

That's it!

boot_5

How It Works

The project supports encrypting a text message or an image file. The cipher used differs between them, but the key derivation works similarly. The key is generated by finding the SHA-256 hash of the sensor reading range (ex: 200-300) or GPS coordinates (ex: 40.656,-74.007).

Text Secrets

Text messages use the Vigenère cipher modified to support the full range of printable ASCII characters instead of just letters A-Z. This cipher outputs scrambled, but printable, text when an attempt to decrypt with the incorrect key is made. It's perfect for this project because it shows mysterious text as a clue that a secret message is hidden within. Vigenère cipher can use a key of any length. The key must use the same alphabet of characters that the encrypted message does. In this case, the ASCII printable characters in the ID range 32-126 from space to tilde in the ASCII chart. SHA-256 produces a 256-bit (32 byte) hash containing raw byte values which aren't restricted to printable ASCII. To work around this, the base64 representation of the hash is used as the Vigenère key. For example, a generated key looks something like this:

PdToaSE3R9V9ClRENwNMhYV+ihPv5NXt05kQo8VrbH8=

Once you have the key, encrypting a message with the Vigenère cipher is done by looking up each clear text characters cipher text counterpart in the Vigenère table or tabula recta. Each successive character that is encoded uses a row from the table matching the next character from the key, which gives the cipher its polyalphabetic property. That increases its resilience against basic frequency analysis, and makes repeated clear text characters come out looking scrambled and non-repetitive in the cipher text.

Image File Secrets

Image files contain raw binary data instead of only printable text characters. Modern cryptography ciphers were made for the computer age and support encrypting raw data. AES is one such modern cipher. It's used by this project for encrypting images. Under normal circumstances, an entire file would be encrypted with AES, making it completely unreadable and unable to be successfully rendered as an image in common applications.

The image file contains metadata like encoding type, size, and color palette which are necessary for rendering it. To avoid completely non-functional image files, only the pixel data is encrypted. The rest of the metadata remains intact. As a result, the display will show a scrambled image that resembles TV static whenever the key is wrong, instead of being entirely broken with only an error message about a corrupt file.

One of the variations of AES accepts a 256-bit (32 byte) key, which is conveniently exactly what is output by the SHA-256 hashing algorithm. That means the raw bytes of the hash can be used as the key directly instead of having to convert them to base64 or any other representation. Here is an example of what the AES key could look like:

b'9\x0b6|\xf2\xc3Cg\xca\xb5}I3,m\xcb\x0f\x8e\xb7\xb1\x08J`\xcb\x84\xd1]E\xeb\xe4\xe4\x8a'

The code attempts to decrypt the pixel data using a key derived from the current sensor reading, then uses bitmaptools.arrayblit() to copy the resulting pixels into a displayio.Bitmap so that it can be shown on the display. If the key was incorrect, the Bitmap will look like random pixel noise. If the key was correct, the original image will be revealed.

Encrypting Secrets

The different versions of CircuitPython microcontroller code for this project support different sensor breakouts for decoding the secret messages. The same encryptor page can be used to encode secrets for all of them.

The page can be used to encrypt multiple messages/images with different values. Each encrypted secret gets copied into a SEQUENCE list inside of the code.py file.

Once a secret has been revealed successfully, it remains visibly decrypted on the screen until the user presses the BOOT button on the Feather to advance to the next puzzle in the sequence.

feather_6

Click the button bellow access the encryptor page.

Encryptor Page

The page contains two tabs: one for encoding text, the other for encoding images.

Text Secrets

For text secrets, fill in the following fields and then press the Encrypt button.

  • Reading type - a dropdown. Select Temperature, Humidity, CO2, Lux, or Other based on the type of sensor/data reading you want to unlock the secret.

  • Precision level - an integer number. Larger numbers result in a wider range of successful decryption values, 1 means the value must fall between two consecutive values i.e. 22-23. Whereas 10 would mean a range of 10 values like 42-52. For GPS, the precision level is used for the number of decimal places in the coordinates i.e. 40.656,-74.007 is 3 decimals.

  • Sensor value - a float number. The target sensor reading for unlocking the secret. The actual key will be derived from a range that covers the target value based on the precision level.

  • Plaintext - a text string input. The secret message that you wish to encrypt.

text_7

When you press Encrypt, a sequence entry text box will appear with an object containing the encrypted message along with metadata required to decrypt and validate it.

Press the copy button, along the top right of the box, and then paste the whole thing into the SEQUENCE list inside of code.py in the user configuration section near the top.

top_8

Download File

Copy Code
SEQUENCE = [
    {
        "type":        "text",
        "data":        "?tH,OledH]BF9zxBSX'LUY^C7390.HFaS[GX",
        "sha256":      "583fd7f36f35c16fb4dc60edf493c74679ce6f26f928bf9995dcae85becf6962",
        "reading":     "lux",
        "precision_level": 100,
    },
    # Add more entries here...
]

Image Secrets

Encrypting image secrets is similar to text. The main differences are:

  • Drag and drop an image file on the page instead of entering plaintext into a box. The page supports PNG, JPEG, and BMP formatted images.

  • Nonce / IV - required for AES encryption. Leave the default or change it to any 16-character string.

  • Max palette colors - how many colors to use for the palette of the resulting Bitmap image. Using the default 256 is fine for most cases.

secrets_9

After you fill in the fields and click Encrypt & Download two things will happen. An encrypted copy of the image will be downloaded with a name like some_image.abmp.enc, and the sequence entry box will appear and get populated with an entry for the encoded image.

Press the copy button along the top right of the box and then paste the whole thing into the SEQUENCE list inside of code.py in the user configuration section near the top.

Make sure that the data value in the object has the exact name of the downloaded image file. You can change the name to anything, but the name of the file must match the data value in this object. Change them both together if you do.

Copy the encoded image file to the CIRCUITPY drive.

drive_10

Download File

Copy Code
SEQUENCE = [
    {
        "type":        "image",
        "data":        "secret_image.abmp.enc",
        "iv":          b"InitializationVe",
        "sha256":      "932185ffbba8b245a97a0819428d6038997e968174cadd3bd0a5bab970d0e560",
        "reading":     "temperature",
        "precision_level": 1,
    },
    # Add more entries here...
]

GPS Coordinates

This version of project code is basically a digital version of a reverse geo-cache puzzle like the ones that inspired the project. The key to encrypt secret text or image is derived from GPS coordinates. The secret can only be revealed by taking the device and GPS receiver to the specified location.

Hardware

Connect the Feather S3 TFT to the GPS Featherwing using a FeatherWing doubler.

connect_11

Encrypt Secrets

Encryptor Page

To encrypt secrets for GPS coordinate unlocking use, the Other (custom) reading type value in the dropdown on the encryptor page. Use gps for the custom reading type and enter your coordinates in the key seed string field.

Ensure that your coordinates have the same number of decimal places that the precision level is set to, i.e. 3 decimals equals 3 precision level.

Also make sure the coordinates are separated by a comma and that there are no spaces around the comma or anywhere else in the string. Example of correct syntax for precision level 3: 40.656,-74.007

page_12

Code

To use the application, you need to obtain code.py with the program, and the other project files to place on the Feather CIRCUITPY drive.

Thankfully, this can be done in one go. In the example below, click the Download Project Bundle button below to download the necessary libraries, the code.py file, and other project files in a zip file.

Connect your board to your computer via a known good data+power USB cable. The board should show up in your File Explorer/Finder (depending on your operating system) as a flash drive named CIRCUITPY.

Extract the contents of the zip file, copy the lib directory files to CIRCUITPY/lib. Copy the code.py file to your CIRCUITPY drive. The program should self-start.

Download Project Bundle

Copy Code
# SPDX-FileCopyrightText: 2026 Tim Cocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
import hashlib
import struct
import time

import board
import busio
import bitmaptools
import aesio
from displayio import Group, TileGrid, Palette, Bitmap
import supervisor
import terminalio
from adafruit_display_text.text_box import TextBox
from adafruit_display_text.bitmap_label import Label
import adafruit_binascii
import adafruit_gps
from digitalio import DigitalInOut, Direction, Pull

# =============================================================================
# USER CONFIGURATION
# =============================================================================

# --- Sequence of challenges ---
# Each entry is a dict with the following keys:
#
#   type   : "text"  -> Vigenère-encrypted ciphertext
#            "image" -> AES-CTR-encrypted .abmp image file
#
#   data   : (text)  the ciphertext string
#            (image) filename of the .abmp.enc file
#
#   sha256 : hex-encoded SHA-256 digest used to confirm correct decryption
#            - text  -> SHA-256 of the plaintext string encoded as UTF-8
#            - image -> SHA-256 of the raw decrypted pixel-data bytes
#            Generate these offline with the helper snippet at the bottom
#            of this file.
#
#   reading     : which sensor data type drives this challenge. One of:
#                 "gps" -> gps.latitude,gps.longitude (coordinates, string)
#
#   precision_level : Controls how big the "unlock" target area is.
#                     Approximate sizes at the equator:
#                     2 -> ~1.1 km    (city block scale)
#                     3 -> ~110 m     (large building)
#                     4 -> ~11 m      (room scale)
#                 Must match the precision used at encryption time.
#
#   iv     : (image only) 16-byte AES initialisation vector matching
#            the one used during encryption. Omit or set None for text.
#
# Challenges must be worked through in order: solve #0 to unlock #1, etc.
# The key for each challenge is derived from the GPS coordinates at the
# target location. The user must physically be there to decrypt correctly.

SEQUENCE = [
    # Add more entries here...
]

# --- Display rotation (degrees) ---
DISPLAY_ROTATION = 180

# =============================================================================
# END OF USER CONFIGURATION — do not edit below unless you know what you're doing
# =============================================================================

if not SEQUENCE:
    raise ValueError("SEQUENCE must contain at least one entry.")

for _i, _entry in enumerate(SEQUENCE):
    if _entry.get("type") not in ("text", "image"):
        raise ValueError(f"SEQUENCE[{_i}]: 'type' must be 'text' or 'image'.")
    if not _entry.get("data"):
        raise ValueError(f"SEQUENCE[{_i}]: 'data' must be set.")
    if not _entry.get("sha256"):
        raise ValueError(f"SEQUENCE[{_i}]: 'sha256' must be set.")
    if _entry["type"] == "image" and not _entry.get("iv"):
        raise ValueError(f"SEQUENCE[{_i}]: image entries require an 'iv'.")

# Printable ASCII constants (text Vigenère)
ASCII_MIN = 32
ASCII_MAX = 126
ASCII_RANGE = ASCII_MAX - ASCII_MIN + 1  # 95

# =============================================================================
# BUTTON SETUP
# =============================================================================

btn = DigitalInOut(board.BOOT0)
btn.direction = Direction.INPUT
btn.pull = Pull.UP

# btn.value is True when not pressed (pull-up), False when pressed (active-low)
btn_prev_value = btn.value
btn_last_change_time = time.monotonic()

# =============================================================================
# GPS SETUP
# =============================================================================

uart = busio.UART(board.TX, board.RX, baudrate=9600, timeout=10)
gps = adafruit_gps.GPS(uart, debug=False)
gps.send_command(b"PMTK314,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0")
gps.send_command(b"PMTK220,1000")

# =============================================================================
# DISPLAY SETUP
# =============================================================================

display = supervisor.runtime.display
display.rotation = DISPLAY_ROTATION

main_group = Group()
display.root_group = main_group

# --- Persistent HUD: progress label (always visible right side) ---
hud_group = Group(scale=2, x=2, y=2)

cur_reading_label = Label(terminalio.FONT)
cur_reading_label.anchor_point = (1.0, 1.0)
cur_reading_label.anchored_position = (display.width // 2, display.height // 2)

progress_label = Label(terminalio.FONT)
progress_label.anchor_point = (1.0, 1.0)
progress_label.anchored_position = (display.width // 2, display.height // 2 - 12)

hud_group.append(progress_label)
hud_group.append(cur_reading_label)
main_group.append(hud_group)


# =============================================================================
# KEY DERIVATION
# =============================================================================

def derive_key(coord_str, b64=True):
    """SHA-256 hash of the GPS coordinate string.

    b64=True  -> base64-encoded bytes  (Vigenère key)
    b64=False -> raw 32-byte digest    (AES-256 key)
    """
    h = hashlib.new("sha256")
    h.update(coord_str.encode("utf-8"))
    if b64:
        return adafruit_binascii.b2a_base64(h.digest()).strip()
    else:
        return h.digest()


def get_coord_string():
    """Return "<lat>,<lon>" at COORD_PRECISION decimal places, or None if no fix."""
    if not gps.has_fix or gps.latitude is None or gps.longitude is None:
        return None

    precision_level = entry["precision_level"]
    _coord_str = f"{gps.latitude:.{precision_level}f},{gps.longitude:.{precision_level}f}"
    cur_reading_label.text = _coord_str
    return _coord_str


# =============================================================================
# HASH VERIFICATION
# =============================================================================

def sha256_hex(data):
    """Return lowercase hex SHA-256 digest of *data* (bytes or str -> UTF-8)."""
    h = hashlib.new("sha256")
    if isinstance(data, str):
        h.update(data.encode("utf-8"))
    else:
        h.update(data)
    return "".join("{:02x}".format(b) for b in h.digest())


def verify(data, expected_hex):
    """Return True if SHA-256 of *data* matches *expected_hex*."""
    return sha256_hex(data) == expected_hex.lower()


# =============================================================================
# DECRYPTION
# =============================================================================

def vigenere_decrypt(ciphertext_str, key_bytes):
    """Vigenere decryption over printable ASCII (32-126).

    Characters outside that range pass through unchanged.
    """
    out = []
    key_len = len(key_bytes)
    key_idx = 0
    for ch in ciphertext_str:
        c = ord(ch)
        if ASCII_MIN <= c <= ASCII_MAX:
            k = key_bytes[key_idx % key_len]
            k_shifted = (k - ASCII_MIN) % ASCII_RANGE
            p = ((c - ASCII_MIN) - k_shifted) % ASCII_RANGE
            out.append(chr(p + ASCII_MIN))
            key_idx += 1
        else:
            out.append(ch)
    return "".join(out)


def try_decrypt_text(_entry, coord_str):
    """Decrypt text and verify against the stored hash.

    Returns (success: bool, plaintext: str).
    Plaintext is always returned so the display shows the garbled attempt
    while the user is at the wrong location — part of the puzzle UX.
    """
    key_bytes = derive_key(coord_str, b64=True)
    _plaintext = vigenere_decrypt(_entry["data"], key_bytes)
    _success = verify(_plaintext, _entry["sha256"])
    return _success, _plaintext


def try_decrypt_image(_entry, coord_str, img_width, img_height,
                      encrypted_raw, pixel_start):
    """AES-CTR decrypt pixel data and verify against the stored hash.

    Returns (success: bool, decrypted: bytearray).
    Decrypted is always returned so the bitmap updates on every coordinate
    change, letting the user see the image snap into focus at the right spot.
    """
    key_bytes = derive_key(coord_str, b64=False)
    pixel_data = encrypted_raw[pixel_start: pixel_start + img_width * img_height]
    _decrypted = bytearray(len(pixel_data))
    cipher = aesio.AES(key_bytes, aesio.MODE_CTR, IV=_entry["iv"])
    cipher.decrypt_into(pixel_data, _decrypted)
    _success = verify(_decrypted, _entry["sha256"])
    return _success, _decrypted


# =============================================================================
# CHALLENGE RENDERER
# =============================================================================

_image_cache = {}


def load_image_entry(_entry):
    """Read and parse an encrypted ABMP file. Returns a state dict.

    The file is cached in memory so subsequent challenges that reuse the
    same filename don't hit the filesystem again.
    """
    filename = _entry["data"]
    if filename not in _image_cache:
        with open(filename, "rb") as f:
            raw = bytearray(f.read())
        _image_cache[filename] = raw

    raw = _image_cache[filename]
    if raw[0:4] != b"ABMP":
        raise ValueError(f"Not a valid ABMP file: {filename}")

    img_width, img_height, n_colors = struct.unpack_from("<HHH", raw, 4)
    palette_start = 10
    pixel_start = palette_start + n_colors * 3

    palette = Palette(n_colors)
    for i in range(n_colors):
        off = palette_start + i * 3
        r, g, b = raw[off], raw[off + 1], raw[off + 2]
        palette[i] = (r << 16) | (g << 8) | b

    bitmap = Bitmap(img_width, img_height, n_colors)
    return {
        "bitmap": bitmap,
        "palette": palette,
        "img_width": img_width,
        "img_height": img_height,
        "encrypted_raw": raw,
        "pixel_start": pixel_start,
    }


class ChallengeRenderer(Group):
    """Manages the content_group display for the currently active challenge."""

    def __init__(self):
        super().__init__()
        self._active_index = None
        self.text_widget = None
        self.image_state = None

        self.secret_message_text = TextBox(
                terminalio.FONT,
                display.width // 2 - 2,
                (display.height) // 2,
                align=TextBox.ALIGN_LEFT,
                scale=2
            )
        self.secret_message_text.anchor_point = (0, 0)
        self.secret_message_text.anchored_position = (2, 0)
        self.append(self.secret_message_text)
        self.secret_message_text.text = "Waiting for fix..."

        self.secret_image_tilegrid = None

        self.prompt_text = Label(terminalio.FONT)
        self.prompt_text.anchor_point = (0, 1.0)
        self.prompt_text.anchored_position = (2, display.height)
        self.prompt_text.text = "[ press Boot btn ]"
        self.prompt_text.hidden = True
        self.append(self.prompt_text)

    def setup(self, index):
        """Prepare content_group for challenge *index* (no-op if already set up)."""
        if self._active_index == index:
            return

        if self.secret_image_tilegrid is not None and self.secret_image_tilegrid in self:
            self.remove(self.secret_image_tilegrid)

        self.text_widget = None
        self.image_state = None
        _entry = SEQUENCE[index]

        if _entry["type"] == "text":
            self.secret_message_text.hidden = False
            self.secret_message_text.text = "Reading sensor..."

        elif _entry["type"] == "image":
            state = load_image_entry(_entry)
            self.image_state = state
            self.secret_message_text.hidden = True

            self.secret_image_tilegrid = TileGrid(state["bitmap"], pixel_shader=state["palette"])
            # self.secret_image_tilegrid.transpose_xy = True
            self.append(self.secret_image_tilegrid)

        self._active_index = index

    def update_image(self, decrypted_pixels):
        """Blit already-decrypted pixel bytes into the bitmap."""
        _s = self.image_state
        bitmaptools.arrayblit(
            _s["bitmap"], decrypted_pixels,
            x1=0, y1=0, x2=_s["img_width"], y2=_s["img_height"],
        )


renderer = ChallengeRenderer()
main_group.append(renderer)

# =============================================================================
# HUD + COMPLETION
# =============================================================================

def update_hud(_current_index, _total):
    """Update the top progress bar label."""
    label = f"{_current_index + 1}/{_total}"
    if progress_label.text != label:
        progress_label.text = label


def show_completion_screen():
    """Replace content_group with a 'you win' message and halt."""
    while len(renderer):
        renderer.pop()

    fin = TextBox(
        terminalio.FONT,
        display.width // 2,
        (display.height - 16) // 2,
        align=TextBox.ALIGN_CENTER,
        scale=2
    )
    fin.anchor_point = (0, 0)
    fin.anchored_position = (0, 0)
    fin.text = "All secrets revealed.\nGood job!"
    renderer.append(fin)
    while True:
        pass  # halt / wait forever


# =============================================================================
# MAIN LOOP
# =============================================================================

current_index = 0
old_coord = ""
total = len(SEQUENCE)
challenge_solved = False  # True while waiting for the user to press the button

while True:
    gps.update()

    entry = SEQUENCE[current_index]
    renderer.setup(current_index)
    update_hud(current_index, total)

    now = time.monotonic()
    cur_coord = get_coord_string()  # None until GPS has a fix

    # -----------------------------------------------------------------
    # While a challenge is solved-but-not-yet-confirmed, keep updating
    # the display with the latest decryption (GPS may still drift slightly)
    # but wait for a button press before advancing.
    # -----------------------------------------------------------------
    if challenge_solved:
        btn_cur_val = btn.value
        if not btn_cur_val and btn_prev_value:
            print(f"Challenge {current_index + 1} confirmed by button press — advancing.")
            current_index += 1
            challenge_solved = False
            old_coord = ""  # force re-decrypt immediately on next loop

            if current_index >= total:
                update_hud(total, total)
                show_completion_screen()
        btn_prev_value = btn_cur_val
        continue  # skip normal decrypt logic while waiting for button

    # -----------------------------------------------------------------
    # No GPS fix — show waiting message and loop.
    # -----------------------------------------------------------------
    if cur_coord is None:
        if old_coord != "":
            # Fix was just lost
            if entry["type"] == "text":
                renderer.secret_message_text.text = "Waiting for fix..."
            old_coord = ""
        continue

    # -----------------------------------------------------------------
    # Normal path: decrypt on every coordinate change and check for a solve.
    # -----------------------------------------------------------------
    if cur_coord != old_coord:
        print(f"[{current_index + 1}/{total}] coord: {cur_coord}")
        old_coord = cur_coord

        if entry["type"] == "text":
            success, plaintext = try_decrypt_text(entry, cur_coord)
            renderer.secret_message_text.text = plaintext

            if success:
                print(f"Challenge {current_index + 1} SOLVED (text): {plaintext}")
                challenge_solved = True

        elif entry["type"] == "image":
            s = renderer.image_state
            success, decrypted = try_decrypt_image(
                entry, cur_coord,
                s["img_width"], s["img_height"],
                s["encrypted_raw"], s["pixel_start"],
            )
            renderer.update_image(decrypted)

            if success:
                print(f"Challenge {current_index + 1} SOLVED (image)")
                challenge_solved = True

View on GitHub

Drive Structure

After copying the files, your drive should look like the listing below. It can contain other files as well but must contain these at a minimum.

structure_13

Lux Level

temp_humidity_lux_decrypt_image

This version of the project code uses the MAX44009 light sensor breakout to decrypt secrets with lux value readings.

Hardware

Connect the MAX44009 breakout to the Feather S3 TFT with a STEMMA QT cable.

cable_14

Encrypt Secrets

Encrypt your secret messages using the process documented on this guide page. Choose lux(lux) as the reading type. A precision level of 100 or 50 works well for lux readings on the MAX44009.

Encryptor page

Code

To use the application, you need to obtain code.py with the program, and the other project files to place on the Feather CIRCUITPY drive.

Thankfully, this can be done in one go. In the example below, click the Download Project Bundle button below to download the necessary libraries, the code.py file, and other project files in a zip file.

Connect your board to your computer via a known good data+power USB cable. The board should show up in your File Explorer/Finder (depending on your operating system) as a flash drive named CIRCUITPY.

Extract the contents of the zip file, copy the lib directory files to CIRCUITPY/lib. Copy the code.py file to your CIRCUITPY drive. The program should self-start.

Download Project Bundle

Copy Code
# SPDX-FileCopyrightText: 2026 Tim Cocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
import hashlib
import struct
import time

import board
import bitmaptools
import aesio
from displayio import Group, TileGrid, Palette, Bitmap
import supervisor
import terminalio
from adafruit_display_text.text_box import TextBox
from adafruit_display_text.bitmap_label import Label
import adafruit_binascii
from adafruit_max44009 import MAX44009
from digitalio import DigitalInOut, Direction, Pull

# =============================================================================
# USER CONFIGURATION
# =============================================================================

# --- Sequence of challenges ---
# Each entry is a dict with the following keys:
#
#   type        : "text"  -> Vigenère-encrypted ciphertext
#                 "image" -> AES-CTR-encrypted .abmp image file
#
#   data        : (text)  the ciphertext string
#                 (image) filename of the .abmp.enc file
#
#   sha256      : hex-encoded SHA-256 digest used to confirm correct decryption
#                 - text  -> SHA-256 of the plaintext string encoded as UTF-8
#                 - image -> SHA-256 of the raw decrypted pixel-data bytes
#                 Generate these offline with the encryptor web page.
#
#   reading     : which sensor data type drives this challenge. One of:
#                 "lux" -> sensor.lux (lux, float)
#
#   precision_level : width of each unlock band, in the units of `reading`.
#                 e.g. reading=lux, precision_level=1   -> bands like 22-23
#                      reading=lux,    precision_level=5   -> bands like 40-45
#                      reading=lux,         precision_level=100 -> bands like 400-500
#                 Must match the precision used at encryption time.
#
#   iv          : (image only) 16-byte AES initialisation vector matching
#                 the one used during encryption. "InitializationVe" is used
#                 by default if None. Omit or set None for text.
#
# Challenges must be worked through in order: solve #0 to unlock #1, etc.
#


SEQUENCE = [
    {
        "type":        "text",
        "data":        "?[4&@izK<!=?6s-'5URJf:Jz^:azh$",
        "sha256":      "1dbc88f6f952ec922ebe4343b5fdfe15919215fefabbd48863c2f1e910a6a118",
        "reading":     "lux",
        "precision_level": 100,
    },
    {
        "type":        "text",
        "data":        "C:Z)I*t~6L:}ox|=|4*c=;gB:.@[wt`<g@",
        "sha256":      "bd4a361c0ae2039e04f82281a91cc1f05097562e296d211afce72145af71cbab",
        "reading":     "lux",
        "precision_level": 100,
    },
    # Add more entries here...
]

# Map of reading-type names to (sensor attribute, unit label for prints).
READING_TYPES = {
    "lux": ("lux", "lux"),
}

# --- Display rotation (degrees) ---
DISPLAY_ROTATION = 180

# =============================================================================
# END OF USER CONFIGURATION
# =============================================================================

# =============================================================================
# SENSOR SETUP
# =============================================================================

i2c = board.I2C()
sensor = MAX44009(i2c)
SENSOR_READ_COOLDOWN = 0.25

# =============================================================================
# Validate SEQUENCE entries
# =============================================================================
if not SEQUENCE:
    raise ValueError("SEQUENCE must contain at least one entry.")

for _i, _entry in enumerate(SEQUENCE):
    if _entry.get("type") not in ("text", "image"):
        raise ValueError(f"SEQUENCE[{_i}]: 'type' must be 'text' or 'image'.")
    if not _entry.get("data"):
        raise ValueError(f"SEQUENCE[{_i}]: 'data' must be set.")
    if not _entry.get("sha256"):
        raise ValueError(f"SEQUENCE[{_i}]: 'sha256' must be set.")
    if _entry.get("reading") not in READING_TYPES:
        raise ValueError(
            f"SEQUENCE[{_i}]: 'reading' must be one of {list(READING_TYPES)}."
        )
    _bs = _entry.get("precision_level")
    if not isinstance(_bs, int) or _bs <= 0:
        raise ValueError(f"SEQUENCE[{_i}]: 'precision_level' must be a positive int.")
    if _entry["type"] == "image" and not _entry.get("iv"):
        SEQUENCE[_i]["iv"] = b"InitializationVe"

# Printable ASCII constants (text Vigenere)
ASCII_MIN = 32
ASCII_MAX = 126
ASCII_RANGE = ASCII_MAX - ASCII_MIN + 1  # 95

# =============================================================================
# BUTTON SETUP
# =============================================================================

btn = DigitalInOut(board.BOOT0)
btn.direction = Direction.INPUT
btn.pull = Pull.UP

# btn.value is True when not pressed (pull-up), False when pressed (active-low)
btn_prev_value = btn.value  # tracks last reading for edge detection

# =============================================================================
# DISPLAY SETUP
# =============================================================================

display = supervisor.runtime.display
display.rotation = DISPLAY_ROTATION

main_group = Group()
display.root_group = main_group

# --- Persistent HUD: progress label (always visible right side) ---
hud_group = Group(scale=2, x=2, y=2)

cur_reading_label = Label(terminalio.FONT)
cur_reading_label.anchor_point = (1.0, 1.0)
cur_reading_label.anchored_position = (display.width // 2, display.height // 2)

progress_label = Label(terminalio.FONT)
progress_label.anchor_point = (1.0, 1.0)
progress_label.anchored_position = (display.width // 2, display.height // 2 - 12)

hud_group.append(progress_label)
hud_group.append(cur_reading_label)
main_group.append(hud_group)


# =============================================================================
# KEY DERIVATION
# =============================================================================

def derive_key(range_str, b64=True):
    """SHA-256 of the sensor reading range string.

    b64=True  -> base64-encoded bytes  (Vigenere key)
    b64=False -> raw 32-byte digest    (AES-256 key)
    """
    h = hashlib.new("sha256")
    h.update(range_str.encode("utf-8"))
    if b64:
        return adafruit_binascii.b2a_base64(h.digest()).strip()
    else:
        return h.digest()


def get_range_string_from_sensor(_entry):
    """Return a bucketed range string like '22-23' for the given challenge entry.

    Reads whichever sensor attribute is named by entry['reading'] and buckets
    the (truncated-to-int) value using entry['precision_level'].
    """
    attr_name, _unit = READING_TYPES[_entry["reading"]]

    reading = int(getattr(sensor, attr_name))
    cur_reading_label.text = str(reading)
    precision_level = _entry["precision_level"]
    bucket = (reading // precision_level) * precision_level
    return f"{bucket}-{bucket + precision_level}"


# =============================================================================
# HASH VERIFICATION
# =============================================================================

def sha256_hex(data):
    """Return lowercase hex SHA-256 digest of *data* (bytes or str -> UTF-8)."""
    h = hashlib.new("sha256")
    if isinstance(data, str):
        h.update(data.encode("utf-8"))
    else:
        h.update(data)
    # Convert raw digest bytes to hex without binascii
    return "".join("{:02x}".format(b) for b in h.digest())


def verify(data, expected_hex):
    """Return True if SHA-256 of *data* matches *expected_hex*."""
    return sha256_hex(data) == expected_hex.lower()


# =============================================================================
# DECRYPTION
# =============================================================================

def vigenere_decrypt(ciphertext_str, key_bytes):
    """Vigenere decryption over printable ASCII (32-126).

    Characters outside that range pass through unchanged.
    """
    out = []
    key_len = len(key_bytes)
    key_idx = 0
    for ch in ciphertext_str:
        c = ord(ch)
        if ASCII_MIN <= c <= ASCII_MAX:
            k = key_bytes[key_idx % key_len]
            k_shifted = (k - ASCII_MIN) % ASCII_RANGE
            p = ((c - ASCII_MIN) - k_shifted) % ASCII_RANGE
            out.append(chr(p + ASCII_MIN))
            key_idx += 1
        else:
            out.append(ch)
    return "".join(out)


def try_decrypt_text(_entry, range_str):
    """Decrypt text and verify against the stored hash.

    Returns (success: bool, plaintext: str).
    plaintext is always returned so the display shows the attempt in progress
    (garbled text while the wrong sensor reading is active is part of the puzzle UX).
    """
    key_bytes = derive_key(range_str, b64=True)
    _plaintext = vigenere_decrypt(_entry["data"], key_bytes)
    _success = verify(_plaintext, _entry["sha256"])
    return _success, _plaintext


def try_decrypt_image(_entry, range_str, img_width, img_height,
                      encrypted_raw, pixel_start):
    """AES-CTR decrypt pixel data and verify against the stored hash.

    Returns (success: bool, decrypted: bytearray).
    decrypted is always returned so the bitmap updates on every reading change,
    letting the user see the image snap into focus at the correct sensor level.
    """
    key_bytes = derive_key(range_str, b64=False)
    pixel_data = encrypted_raw[pixel_start: pixel_start + img_width * img_height]
    _decrypted = bytearray(len(pixel_data))
    cipher = aesio.AES(key_bytes, aesio.MODE_CTR, IV=_entry["iv"])
    cipher.decrypt_into(pixel_data, _decrypted)
    _success = verify(_decrypted, _entry["sha256"])
    return _success, _decrypted


# =============================================================================
# CHALLENGE RENDERER
# =============================================================================

_image_cache = {}


def load_image_entry(_entry):
    """Read and parse an encrypted ABMP file. Returns a state dict."""
    filename = _entry["data"]
    if filename not in _image_cache:
        with open(filename, "rb") as f:
            raw = bytearray(f.read())
        _image_cache[filename] = raw

    raw = _image_cache[filename]
    if raw[0:4] != b"ABMP":
        raise ValueError(f"Not a valid ABMP file: {filename}")

    img_width, img_height, n_colors = struct.unpack_from("<HHH", raw, 4)
    palette_start = 10
    pixel_start = palette_start + n_colors * 3

    palette = Palette(n_colors)
    for i in range(n_colors):
        off = palette_start + i * 3
        r, g, b = raw[off], raw[off + 1], raw[off + 2]
        palette[i] = (r << 16) | (g << 8) | b

    bitmap = Bitmap(img_width, img_height, n_colors)
    return {
        "bitmap": bitmap,
        "palette": palette,
        "img_width": img_width,
        "img_height": img_height,
        "encrypted_raw": raw,
        "pixel_start": pixel_start,
    }


class ChallengeRenderer(Group):
    """Manages the content_group display for the currently active challenge."""

    def __init__(self):
        super().__init__()
        self._active_index = None
        self.text_widget = None
        self.image_state = None

        self.secret_message_text = TextBox(
                terminalio.FONT,
                display.width // 2 - 2,
                (display.height) // 2,
                align=TextBox.ALIGN_LEFT,
                scale=2
            )
        self.secret_message_text.anchor_point = (0, 0)
        self.secret_message_text.anchored_position = (2, 0)
        self.append(self.secret_message_text)
        self.secret_message_text.text = "Reading sensor..."

        self.secret_image_tilegrid = None

        self.prompt_text = Label(terminalio.FONT)
        self.prompt_text.anchor_point = (0, 1.0)
        self.prompt_text.anchored_position = (2, display.height)
        self.prompt_text.text = "[ press Boot btn ]"
        self.prompt_text.hidden = True
        self.append(self.prompt_text)

    def setup(self, index):
        """Prepare content_group for challenge *index* (no-op if already set up)."""
        if self._active_index == index:
            return

        if self.secret_image_tilegrid is not None and self.secret_image_tilegrid in self:
            self.remove(self.secret_image_tilegrid)

        self.text_widget = None
        self.image_state = None
        _entry = SEQUENCE[index]

        if _entry["type"] == "text":
            self.secret_message_text.hidden = False
            self.secret_message_text.text = "Reading sensor..."

        elif _entry["type"] == "image":
            state = load_image_entry(_entry)
            self.image_state = state
            self.secret_message_text.hidden = True

            self.secret_image_tilegrid = TileGrid(state["bitmap"], pixel_shader=state["palette"])
            # self.secret_image_tilegrid.transpose_xy = True
            self.append(self.secret_image_tilegrid)

        self._active_index = index

    def update_image(self, decrypted_pixels):
        """Blit already-decrypted pixel bytes into the bitmap."""
        _s = self.image_state
        bitmaptools.arrayblit(
            _s["bitmap"], decrypted_pixels,
            x1=0, y1=0, x2=_s["img_width"], y2=_s["img_height"],
        )


renderer = ChallengeRenderer()
main_group.append(renderer)


# =============================================================================
# HUD + COMPLETION
# =============================================================================

def update_hud(_current_index, _total):
    """Update the top progress bar label."""
    label = f"{_current_index + 1}/{_total}"
    if progress_label.text != label:
        progress_label.text = label


def show_completion_screen():
    """Replace challenge content with a 'you win' message and halt."""
    while len(renderer):
        renderer.pop()

    fin = TextBox(
        terminalio.FONT,
        display.width // 2,
        (display.height - 16) // 2,
        align=TextBox.ALIGN_CENTER,
        scale=2
    )
    fin.anchor_point = (0, 0)
    fin.anchored_position = (0, 0)
    fin.text = "All secrets revealed.\nGood job!"

    renderer.append(fin)
    hud_group.hidden = True
    while True:
        pass  # halt / wait forever


# =============================================================================
# MAIN LOOP
# =============================================================================

current_index = 0
old_range = ""
total = len(SEQUENCE)
challenge_solved = False  # True while waiting for the user to press the button

last_sensor_read_time = 0
cur_cache_key = None
while True:
    entry = SEQUENCE[current_index]
    now = time.monotonic()
    if now - last_sensor_read_time > SENSOR_READ_COOLDOWN and not challenge_solved:
        try:
            cur_range = get_range_string_from_sensor(entry)
        except OSError as e:
            print(e, "retrying after cooldown")
            cur_range = cur_cache_key.split(":")[-1]
        last_sensor_read_time = now
    else:
        cur_range = cur_cache_key.split(":")[-1]
    # Include reading type in cache key so changing challenge boundaries
    # don't accidentally match a previous challenge's bucket value.
    cur_cache_key = f"{entry['reading']}:{cur_range}"

    renderer.setup(current_index)
    update_hud(current_index, total)

    # -----------------------------------------------------------------
    # While a challenge is solved-but-not-yet-confirmed, keep updating
    # the display with the latest decryption (sensor reading may still drift) but
    # wait for a button press before advancing.
    # -----------------------------------------------------------------
    if challenge_solved:
        btn_cur_val = btn.value
        if not btn_cur_val and btn_prev_value:
            print(f"Challenge {current_index + 1} confirmed by button press — advancing.")
            current_index += 1
            challenge_solved = False
            old_range = ""  # force re-decrypt immediately on next loop

            if current_index >= total:
                update_hud(total, total)
                show_completion_screen()
        btn_prev_value = btn_cur_val
        continue  # skip normal decrypt logic while waiting for button

    # -----------------------------------------------------------------
    # Normal path: decrypt on every reading-range change and check for a solve.
    # -----------------------------------------------------------------
    now = time.monotonic()
    if cur_cache_key != old_range:
        last_sensor_read_time = now
        unit = READING_TYPES[entry["reading"]][1]
        print(f"[{current_index + 1}/{total}] {entry['reading']} range: {cur_range} {unit}")
        old_range = cur_cache_key

        if entry["type"] == "text":
            success, plaintext = try_decrypt_text(entry, cur_range)
            renderer.secret_message_text.text = plaintext

            if success:
                print(f"Challenge {current_index + 1} SOLVED (text): {plaintext}")
                cur_reading_label.text = "*" + cur_reading_label.text
                challenge_solved = True

        elif entry["type"] == "image":
            s = renderer.image_state
            success, decrypted = try_decrypt_image(
                entry, cur_range,
                s["img_width"], s["img_height"],
                s["encrypted_raw"], s["pixel_start"],
            )
            renderer.update_image(decrypted)

            if success:
                print(f"Challenge {current_index + 1} SOLVED (image)")
                cur_reading_label.text = "*" + cur_reading_label.text
                challenge_solved = True

View on GitHub

Drive Structure

After copying the files, your drive should look like the listing below. It can contain other files as well but must contain these at a minimum.

drive_14

Temperature Humidity CO2

temphumd_15

This version of the code uses the STCC4+SHT41 breakout to decrypt secrets with temperature, humidity, or CO2 readings. Since all 3 types of data come from the same sensor, it is possible to use a sequence of secrets that are each encrypted with different reading types.

One example usage for this is setting up a scavenger hunt-like experience where participants are encouraged to observe changes to the visible sensor reading and experiment with different ways to influence the sensor or environment to find each secret. The revealed text or image can contain a clue that leads them towards the solution for the next puzzle in the sequence.

Hardware

Connect the STCC4+SHT41 breakout to the Feather S3 TFT with a STEMMA QT cable.

breakout_16

Encrypt Secrets

Encrypt your secret messages using the process documented on this guide page. Choose temperature, humidity, or CO2 as desired for each secret that you encrypt. Temperature and humidity readings use Celsius and percentage units respectively. Both work well with a precision level of 1, but you can use a larger value if you want to make the target range easier to find. CO2 uses PPM which is likely to have a value in the range of 300-1000 for normal environments, so a larger precision level of 100 or 50 works better for it.

It is unhealthy for humans to spend time in environments with CO2 level over 1000ppm. A base line reading in clean outdoor air is typically around 400. CO2 based encryption should use a target level of 1000 or less so that it does not encourage participants to create or venture into unsafe environments.

Encryptor Page

Code

To use the application, you need to obtain code.py with the program, and the other project files to place on the Feather CIRCUITPY drive.

Thankfully, this can be done in one go. In the example below, click the Download Project Bundle button below to download the necessary libraries, the code.py file, and other project files in a zip file.

Connect your board to your computer via a known good data+power USB cable. The board should show up in your File Explorer/Finder (depending on your operating system) as a flash drive named CIRCUITPY.

Extract the contents of the zip file, copy the lib directory files to CIRCUITPY/lib. Copy the code.py file to your CIRCUITPY drive. The program should self-start.

Download Project Bundle

Copy Code
# SPDX-FileCopyrightText: 2026 Tim Cocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
import hashlib
import struct
import time

import board
import bitmaptools
import aesio
from displayio import Group, TileGrid, Palette, Bitmap
import supervisor
import terminalio
from adafruit_display_text.text_box import TextBox
from adafruit_display_text.bitmap_label import Label
import adafruit_binascii
import adafruit_stcc4
from digitalio import DigitalInOut, Direction, Pull

# =============================================================================
# USER CONFIGURATION
# =============================================================================

# --- Sequence of challenges ---
# Each entry is a dict with the following keys:
#
#   type        : "text"  -> Vigenère-encrypted ciphertext
#                 "image" -> AES-CTR-encrypted .abmp image file
#
#   data        : (text)  the ciphertext string
#                 (image) filename of the .abmp.enc file
#
#   sha256      : hex-encoded SHA-256 digest used to confirm correct decryption
#                 - text  -> SHA-256 of the plaintext string encoded as UTF-8
#                 - image -> SHA-256 of the raw decrypted pixel-data bytes
#                 Generate these offline with the encryptor web page.
#
#   reading     : which sensor data type drives this challenge. One of:
#                 "temperature" -> sensor.temperature (°C, float)
#                 "humidity"    -> sensor.relative_humidity (%, float)
#                 "co2"         -> sensor.CO2 (ppm, int)
#
#   precision_level : width of each unlock band, in the units of `reading`.
#                 e.g. reading=temperature, precision_level=1   -> bands like 22-23 (°C)
#                      reading=humidity,    precision_level=5   -> bands like 40-45 (%)
#                      reading=co2,         precision_level=100 -> bands like 400-500 (ppm)
#                 Must match the precision used at encryption time.
#
#   iv          : (image only) 16-byte AES initialisation vector matching
#                 the one used during encryption. "InitializationVe" is used
#                 by default if None. Omit or set None for text.
#
# Challenges must be worked through in order: solve #0 to unlock #1, etc.
#


SEQUENCE = [
    {
        "type":        "text",
        "data":        ">H5mv=h|bL3&iW4Fu]gbBNQ5&.2YC`J",
        "sha256":      "9313a63935c54a01de05e00bca67176639101f6251e411a7422ea1c0451a2588",
        "reading":     "temperature",
        "precision_level": 2,
    },
    {
        "type":        "image",
        "data":        "example_image.abmp.enc",
        "iv":          b"InitializationVe",
        "sha256":      "932185ffbba8b245a97a0819428d6038997e968174cadd3bd0a5bab970d0e560",
        "reading":     "humidity",
        "precision_level": 1,
    },
    # Add more entries here...
]

# Map of reading-type names to (sensor attribute, unit label for prints).
READING_TYPES = {
    "temperature": ("temperature", "°C"),
    "humidity": ("relative_humidity", "%"),
    "co2": ("CO2", "ppm"),
}

# --- Display rotation (degrees) ---
DISPLAY_ROTATION = 180

# =============================================================================
# END OF USER CONFIGURATION
# =============================================================================

# =============================================================================
# SENSOR SETUP
# =============================================================================

i2c = board.I2C()
sensor = adafruit_stcc4.STCC4(i2c)
sensor.continuous_measurement = True
SENSOR_READ_COOLDOWN = 1.0

# =============================================================================
# Validate SEQUENCE entries
# =============================================================================
if not SEQUENCE:
    raise ValueError("SEQUENCE must contain at least one entry.")

for _i, _entry in enumerate(SEQUENCE):
    if _entry.get("type") not in ("text", "image"):
        raise ValueError(f"SEQUENCE[{_i}]: 'type' must be 'text' or 'image'.")
    if not _entry.get("data"):
        raise ValueError(f"SEQUENCE[{_i}]: 'data' must be set.")
    if not _entry.get("sha256"):
        raise ValueError(f"SEQUENCE[{_i}]: 'sha256' must be set.")
    if _entry.get("reading") not in READING_TYPES:
        raise ValueError(
            f"SEQUENCE[{_i}]: 'reading' must be one of {list(READING_TYPES)}."
        )
    _bs = _entry.get("precision_level")
    if not isinstance(_bs, int) or _bs <= 0:
        raise ValueError(f"SEQUENCE[{_i}]: 'precision_level' must be a positive int.")
    if _entry["type"] == "image" and not _entry.get("iv"):
        SEQUENCE[_i]["iv"] = b"InitializationVe"

# Printable ASCII constants (text Vigenere)
ASCII_MIN = 32
ASCII_MAX = 126
ASCII_RANGE = ASCII_MAX - ASCII_MIN + 1  # 95

# =============================================================================
# BUTTON SETUP
# =============================================================================

btn = DigitalInOut(board.BOOT0)
btn.direction = Direction.INPUT
btn.pull = Pull.UP

# btn.value is True when not pressed (pull-up), False when pressed (active-low)
btn_prev_value = btn.value  # tracks last reading for edge detection

# =============================================================================
# DISPLAY SETUP
# =============================================================================

display = supervisor.runtime.display
display.rotation = DISPLAY_ROTATION

main_group = Group()
display.root_group = main_group

# --- Persistent HUD: progress label (always visible right side) ---
hud_group = Group(scale=2, x=2, y=2)

cur_reading_label = Label(terminalio.FONT)
cur_reading_label.anchor_point = (1.0, 1.0)
cur_reading_label.anchored_position = (display.width // 2, display.height // 2)

progress_label = Label(terminalio.FONT)
progress_label.anchor_point = (1.0, 1.0)
progress_label.anchored_position = (display.width // 2, display.height // 2 - 12)

hud_group.append(progress_label)
hud_group.append(cur_reading_label)
main_group.append(hud_group)


# =============================================================================
# KEY DERIVATION
# =============================================================================

def derive_key(range_str, b64=True):
    """SHA-256 of the sensor reading range string.

    b64=True  -> base64-encoded bytes  (Vigenere key)
    b64=False -> raw 32-byte digest    (AES-256 key)
    """
    h = hashlib.new("sha256")
    h.update(range_str.encode("utf-8"))
    if b64:
        return adafruit_binascii.b2a_base64(h.digest()).strip()
    else:
        return h.digest()


def get_range_string_from_sensor(_entry):
    """Return a bucketed range string like '22-23' for the given challenge entry.

    Reads whichever sensor attribute is named by entry['reading'] and buckets
    the (truncated-to-int) value using entry['precision_level'].
    """
    attr_name, _unit = READING_TYPES[_entry["reading"]]

    if attr_name != "CO2":
        # read CO2 to refresh other data types
        _ = sensor.CO2

    reading = int(getattr(sensor, attr_name))
    cur_reading_label.text = str(reading)
    precision_level = _entry["precision_level"]
    bucket = (reading // precision_level) * precision_level
    return f"{bucket}-{bucket + precision_level}"


# =============================================================================
# HASH VERIFICATION
# =============================================================================

def sha256_hex(data):
    """Return lowercase hex SHA-256 digest of *data* (bytes or str -> UTF-8)."""
    h = hashlib.new("sha256")
    if isinstance(data, str):
        h.update(data.encode("utf-8"))
    else:
        h.update(data)
    # Convert raw digest bytes to hex without binascii
    return "".join("{:02x}".format(b) for b in h.digest())


def verify(data, expected_hex):
    """Return True if SHA-256 of *data* matches *expected_hex*."""
    return sha256_hex(data) == expected_hex.lower()


# =============================================================================
# DECRYPTION
# =============================================================================

def vigenere_decrypt(ciphertext_str, key_bytes):
    """Vigenere decryption over printable ASCII (32-126).

    Characters outside that range pass through unchanged.
    """
    out = []
    key_len = len(key_bytes)
    key_idx = 0
    for ch in ciphertext_str:
        c = ord(ch)
        if ASCII_MIN <= c <= ASCII_MAX:
            k = key_bytes[key_idx % key_len]
            k_shifted = (k - ASCII_MIN) % ASCII_RANGE
            p = ((c - ASCII_MIN) - k_shifted) % ASCII_RANGE
            out.append(chr(p + ASCII_MIN))
            key_idx += 1
        else:
            out.append(ch)
    return "".join(out)


def try_decrypt_text(_entry, range_str):
    """Decrypt text and verify against the stored hash.

    Returns (success: bool, plaintext: str).
    plaintext is always returned so the display shows the attempt in progress
    (garbled text while the wrong sensor reading is active is part of the puzzle UX).
    """
    key_bytes = derive_key(range_str, b64=True)
    _plaintext = vigenere_decrypt(_entry["data"], key_bytes)
    _success = verify(_plaintext, _entry["sha256"])
    return _success, _plaintext


def try_decrypt_image(_entry, range_str, img_width, img_height,
                      encrypted_raw, pixel_start):
    """AES-CTR decrypt pixel data and verify against the stored hash.

    Returns (success: bool, decrypted: bytearray).
    decrypted is always returned so the bitmap updates on every reading change,
    letting the user see the image snap into focus at the correct sensor level.
    """
    key_bytes = derive_key(range_str, b64=False)
    pixel_data = encrypted_raw[pixel_start: pixel_start + img_width * img_height]
    _decrypted = bytearray(len(pixel_data))
    cipher = aesio.AES(key_bytes, aesio.MODE_CTR, IV=_entry["iv"])
    cipher.decrypt_into(pixel_data, _decrypted)
    _success = verify(_decrypted, _entry["sha256"])
    return _success, _decrypted


# =============================================================================
# CHALLENGE RENDERER
# =============================================================================

_image_cache = {}


def load_image_entry(_entry):
    """Read and parse an encrypted ABMP file. Returns a state dict."""
    filename = _entry["data"]
    if filename not in _image_cache:
        with open(filename, "rb") as f:
            raw = bytearray(f.read())
        _image_cache[filename] = raw

    raw = _image_cache[filename]
    if raw[0:4] != b"ABMP":
        raise ValueError(f"Not a valid ABMP file: {filename}")

    img_width, img_height, n_colors = struct.unpack_from("<HHH", raw, 4)
    palette_start = 10
    pixel_start = palette_start + n_colors * 3

    palette = Palette(n_colors)
    for i in range(n_colors):
        off = palette_start + i * 3
        r, g, b = raw[off], raw[off + 1], raw[off + 2]
        palette[i] = (r << 16) | (g << 8) | b

    bitmap = Bitmap(img_width, img_height, n_colors)
    return {
        "bitmap": bitmap,
        "palette": palette,
        "img_width": img_width,
        "img_height": img_height,
        "encrypted_raw": raw,
        "pixel_start": pixel_start,
    }


class ChallengeRenderer(Group):
    """Manages the content_group display for the currently active challenge."""

    def __init__(self):
        super().__init__()
        self._active_index = None
        self.text_widget = None
        self.image_state = None

        self.secret_message_text = TextBox(
                terminalio.FONT,
                display.width // 2 - 2,
                (display.height) // 2,
                align=TextBox.ALIGN_LEFT,
                scale=2
            )
        self.secret_message_text.anchor_point = (0, 0)
        self.secret_message_text.anchored_position = (2, 0)
        self.append(self.secret_message_text)
        self.secret_message_text.text = "Reading sensor..."

        self.secret_image_tilegrid = None

        self.prompt_text = Label(terminalio.FONT)
        self.prompt_text.anchor_point = (0, 1.0)
        self.prompt_text.anchored_position = (2, display.height)
        self.prompt_text.text = "[ press Boot btn ]"
        self.prompt_text.hidden = True
        self.append(self.prompt_text)

    def setup(self, index):
        """Prepare content_group for challenge *index* (no-op if already set up)."""
        if self._active_index == index:
            return

        if self.secret_image_tilegrid is not None and self.secret_image_tilegrid in self:
            self.remove(self.secret_image_tilegrid)

        self.text_widget = None
        self.image_state = None
        _entry = SEQUENCE[index]

        if _entry["type"] == "text":
            self.secret_message_text.hidden = False
            self.secret_message_text.text = "Reading sensor..."

        elif _entry["type"] == "image":
            state = load_image_entry(_entry)
            self.image_state = state
            self.secret_message_text.hidden = True

            self.secret_image_tilegrid = TileGrid(state["bitmap"], pixel_shader=state["palette"])
            # self.secret_image_tilegrid.transpose_xy = True
            self.append(self.secret_image_tilegrid)

        self._active_index = index

    def update_image(self, decrypted_pixels):
        """Blit already-decrypted pixel bytes into the bitmap."""
        _s = self.image_state
        bitmaptools.arrayblit(
            _s["bitmap"], decrypted_pixels,
            x1=0, y1=0, x2=_s["img_width"], y2=_s["img_height"],
        )


renderer = ChallengeRenderer()
main_group.append(renderer)


# =============================================================================
# HUD + COMPLETION
# =============================================================================

def update_hud(_current_index, _total):
    """Update the top progress bar label."""
    label = f"{_current_index + 1}/{_total}"
    if progress_label.text != label:
        progress_label.text = label


def show_completion_screen():
    """Replace challenge content with a 'you win' message and halt."""
    while len(renderer):
        renderer.pop()

    fin = TextBox(
        terminalio.FONT,
        display.width // 2,
        (display.height - 16) // 2,
        align=TextBox.ALIGN_CENTER,
        scale=2
    )
    fin.anchor_point = (0, 0)
    fin.anchored_position = (0, 0)
    fin.text = "All secrets revealed.\nGood job!"

    renderer.append(fin)
    hud_group.hidden = True
    while True:
        pass  # halt / wait forever


# =============================================================================
# MAIN LOOP
# =============================================================================

current_index = 0
old_range = ""
total = len(SEQUENCE)
challenge_solved = False  # True while waiting for the user to press the button

last_sensor_read_time = 0
cur_cache_key = None
while True:
    entry = SEQUENCE[current_index]
    now = time.monotonic()
    if now - last_sensor_read_time > SENSOR_READ_COOLDOWN and not challenge_solved:
        try:
            cur_range = get_range_string_from_sensor(entry)
        except OSError as e:
            print(e, "retrying after cooldown")
            cur_range = cur_cache_key.split(":")[-1]
        last_sensor_read_time = now
    else:
        cur_range = cur_cache_key.split(":")[-1]
    # Include reading type in cache key so changing challenge boundaries
    # don't accidentally match a previous challenge's bucket value.
    cur_cache_key = f"{entry['reading']}:{cur_range}"

    renderer.setup(current_index)
    update_hud(current_index, total)

    # -----------------------------------------------------------------
    # While a challenge is solved-but-not-yet-confirmed, keep updating
    # the display with the latest decryption (sensor reading may still drift) but
    # wait for a button press before advancing.
    # -----------------------------------------------------------------
    if challenge_solved:
        # renderer.show_solved_prompt()
        btn_cur_val = btn.value
        if not btn_cur_val and btn_prev_value:
            print(f"Challenge {current_index + 1} confirmed by button press — advancing.")
            current_index += 1
            challenge_solved = False
            old_range = ""  # force re-decrypt immediately on next loop

            if current_index >= total:
                update_hud(total, total)
                show_completion_screen()
        btn_prev_value = btn_cur_val
        continue  # skip normal decrypt logic while waiting for button

    # -----------------------------------------------------------------
    # Normal path: decrypt on every reading-range change and check for a solve.
    # -----------------------------------------------------------------
    now = time.monotonic()
    if cur_cache_key != old_range:
        last_sensor_read_time = now
        unit = READING_TYPES[entry["reading"]][1]
        print(f"[{current_index + 1}/{total}] {entry['reading']} range: {cur_range} {unit}")
        old_range = cur_cache_key

        if entry["type"] == "text":
            success, plaintext = try_decrypt_text(entry, cur_range)
            renderer.secret_message_text.text = plaintext

            if success:
                print(f"Challenge {current_index + 1} SOLVED (text): {plaintext}")
                cur_reading_label.text = "*" + cur_reading_label.text
                challenge_solved = True

        elif entry["type"] == "image":
            s = renderer.image_state
            success, decrypted = try_decrypt_image(
                entry, cur_range,
                s["img_width"], s["img_height"],
                s["encrypted_raw"], s["pixel_start"],
            )
            renderer.update_image(decrypted)

            if success:
                print(f"Challenge {current_index + 1} SOLVED (image)")
                cur_reading_label.text = "*" + cur_reading_label.text
                challenge_solved = True

View on GitHub

Drive Structure

After copying the files, your drive should look like the listing below. It can contain other files as well but must contain these at a minimum.

drive_17

Using Other Sensors

The preceding pages cover versions of the project for the MAX44009, STCC4+SHT41, and GPS FeatherWing. If you have a different light sensor, temperature/humidity sensor, or supported GPS receiver, the code can be easily adapted by changing the driver import and sensor initialization. Since all of the Adafruit libraries for the same type of sensors share common property names for their data readings, they are largely interchangeable in these projects. For different GPS breakouts, be sure to update the pins used for initialization according to the module you have.

For example, the following illustrates changing the lux version of the project to use the VCNL4030.

Existing code:

Download File

Copy Code
from adafruit_max44009 import MAX44009

# ...

# =============================================================================
# SENSOR SETUP
# =============================================================================

i2c = board.I2C()
sensor = MAX44009(i2c)

VCNL4030 Adaptation:

Download File

Copy Code
from adafruit_vcnl4030 import VCNL4030

# ...

# =============================================================================
# SENSOR SETUP
# =============================================================================

i2c = board.I2C()
sensor = VCNL4030(i2c)

Different Types of Sensors

If you want to venture outside the realm of GPS, lux, temperature, humidity, and CO2, the code can be adapted for other sensor types, but it requires a little more work.

The following sections illustrate changes needed to use a TMAG5273 magnetic sensor.

Change the import and initialization of the driver to use the one for the new sensor.

Download File

Copy Code
import adafruit_tmag5273

# ...

sensor = adafruit_tmag5273.TMAG5273(i2c)

Add suitable entries to the READING_TYPES dictionary for the new sensor. The magnetic sensor has the property magnetic that returns x, y, and z values in a tuple. The attribute name entry in this dictionary is changed from string to tuple so that it can hold a name and a sub-index.

If your sensor has a basic property that returns a single value, then just use a string with the property name like the original project code does.

Download File

Copy Code
READING_TYPES = {
    # type key  :  attr info      , unit  string
    "magnetic_x": (("magnetic", 0), "uT"),
    "magnetic_y": (("magnetic", 1), "uT"),
    "magnetic_z": (("magnetic", 2), "uT"),
}

Update the code inside the get_range_string_from_sensor() function to handle the attribute name/sub-indexing scheme.

Copy Code
attr_info, _unit = READING_TYPES[_entry["reading"]]
attr_name, subindex = attr_info

reading = int(getattr(sensor, attr_name)[subindex])

Use one of the reading type key values magnetic_x, magnetic_y, or magnetic_z as the value for a custom reading type on the encryptor page.

Encryptor Page

page_18

Mfr Part # 5483
ESP32-S3 FRONT TFT FEATHER
Adafruit Industries LLC
Mfr Part # 3133
ULTIMATE GPS FEATHERWING MTK3339
Adafruit Industries LLC
Mfr Part # 6498
ADAFRUIT MAX44009 WIDE-RANGE LUX
Adafruit Industries LLC
Mfr Part # 6478
ADAFRUIT STCC4 AND SHT41 - CO2 T
Adafruit Industries LLC
Mfr Part # 2890
FEATHERWING DOUBLER - PROTOTYPIN
Adafruit Industries LLC
Mfr Part # 4473
CABLE A PLUG TO C PLUG 1'
Adafruit Industries LLC
Mfr Part # 4210
JST SH 4-PIN CABLE - QWIIC COMPA
Adafruit Industries LLC
Add all DigiKey Parts to Cart
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.