Sensor-Locked Secrets with CircuitPython
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
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
Adafruit ESP32-S3 TFT Feather - 4MB Flash, 2MB PSRAM, STEMMA QT
Adafruit MAX44009 Wide-range Lux Light Sensor - 188,000 Lux Max
Adafruit STCC4 and SHT41 - CO2, Temperature & Humidity Sensor
FeatherWing Doubler - Prototyping Add-on For All Feather Boards
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.
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.
The BOOT drive will disappear and a new disk drive called CIRCUITPY will appear.
That's it!
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.
Click the button bellow access the 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.
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.
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.
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.
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.
Encrypt Secrets
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
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.
# 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
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.
Lux Level
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.
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.
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.
# 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
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.
Temperature Humidity CO2
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.
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.
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.
# 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
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.
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:
from adafruit_max44009 import MAX44009 # ... # ============================================================================= # SENSOR SETUP # ============================================================================= i2c = board.I2C() sensor = MAX44009(i2c)
VCNL4030 Adaptation:
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.
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.
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.
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.

