Holiday Hack 2025: On the Wire
Introduction
On the Wire
Difficulty:❅❅❅❅❅Evan is hanging out next to City Hall with the On the Wire robot:
Evan Booth
Hey, I’m Evan!
I like to build things.
All sorts of things.
If you aren’t failing on some front, consider adjusting your difficulty settings.
So here’s the deal - there are some seriously bizarre signals floating around this area.
Not your typical radio chatter or WiFi noise, but something… different.
I’ve been trying to make sense of the patterns, but it’s like trying to build a robot hand out of a coffee maker - you need the right approach.
Think you can help me decode whatever weirdness is being transmitted out there?
You know what happens to electronics in extreme cold? They fail. All my builds, all my robots, all my weird coffee-maker contraptions—frozen solid. We can’t let Frosty turn this place into a permanent deep freeze.
Chat with Evan Booth
Congratulations! You spoke with Evan Booth!
The terminal opens up a page with the robot’s data on display:
Each of the three tabs display different data:
Solution
Strategy
The goal is to get the reading from the third signal at address 0x3C. The first part of this challenge will be capturing the signals from the webpage. Then I’ll work from the first signal, using its information to read the second, which provides output to decrypt the third signal.
Capture Signals
Enumerate Site
To figure out how the robot is getting those signals, I’ll open the dev tools in the browser. At the end of the HTML source is a large inline <script> block that handles all the signal processing. It defines five wires:
// Signal data storage for each wire
const signalData = {
'dq': [],
'mosi': [],
'sck': [],
'sda': [],
'scl': []
};
Later there’s a function that associates each wire with its tab:
function getProtocolWires(protocol) {
const protocolWires = {
'1wire': ['dq'],
'spi': ['mosi', 'sck'],
'i2c': ['sda', 'scl']
};
return protocolWires[protocol] || [];
}
A lot of the code has to do with displaying the wire data. The key part is how it connects to the signal data:
// Connect to a specific wire
function connectWire(wireName, protocolName) {
if (connections[wireName]) {
addLog(protocolName, `Already connected to ${wireName}`);
return;
}
initCanvas(wireName);
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const host = location.host || location.hostname; // includes port if present in current URL
const ws = new WebSocket(`${proto}://${host}/wire/${wireName}`);
connections[wireName] = ws;
ws.onopen = () => {
updateStatus(protocolName);
addLog(protocolName, `Connected to ${wireName}`);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'welcome') {
addLog(protocolName, `Server: ${data.message}`);
return;
}
if (data.line && typeof data.v !== 'undefined') {
updateSignal(data.line, data.v, data.t);
updateFrameCount(data.line);
}
} catch (e) {
console.error('Error parsing message:', e);
}
};
ws.onerror = (error) => {
console.error(`WebSocket error on ${wireName}:`, error);
addLog(protocolName, `Error on ${wireName}`);
};
ws.onclose = () => {
delete connections[wireName];
updateStatus(protocolName);
addLog(protocolName, `Disconnected from ${wireName}`);
};
}
So each wire has its own websocket endpoint at /wire/{wireName}. Looking at the message handler, there’s a specific handler for a “welcome” message, and then the rest are processed by this code as long as data.v is defined:
if (data.line && typeof data.v !== 'undefined') {
updateSignal(data.line, data.v, data.t);
}
Each message is JSON with three fields:
line- the wire namev- the value (0 or 1)t- the timestamp
In the Network tab, I can confirm this by watching the websocket connections. Each one streams a continuous flow of signal data.
Capture Script
To work with the signals, it will help to collect the data so that I can process it offline without having to connect each time I want to try something. I’ll use Python to create a script to capture the signals and save them as JSON:
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "websockets",
# ]
# ///
import asyncio
import json
import websockets
WIRES = ['dq', 'mosi', 'sck', 'sda', 'scl']
BASE_URL = 'wss://signals.holidayhackchallenge.com/wire'
CAPTURE_SECONDS = 10
async def capture_wire(wire, data):
url = f'{BASE_URL}/{wire}'
async with websockets.connect(url) as ws:
print(f'[+] Connected to {wire}')
while True:
msg = json.loads(await ws.recv())
if msg.get('type') == 'welcome':
print(f'[*] {wire}: {msg["message"]}')
continue
if 'v' in msg:
data[wire].append({'v': msg['v'], 't': msg['t']})
async def main():
data = {wire: [] for wire in WIRES}
tasks = [asyncio.create_task(capture_wire(wire, data)) for wire in WIRES]
print(f'[*] Capturing for {CAPTURE_SECONDS} seconds...')
await asyncio.sleep(CAPTURE_SECONDS)
for task in tasks:
task.cancel()
with open('signals.json', 'w') as f:
json.dump(data, f)
for wire in WIRES:
print(f'[+] {wire}: {len(data[wire])} samples')
print('[+] Saved to signals.json')
asyncio.run(main())
This will use async to connect to each wire, collect for 10 seconds, and then store all the data in signals.json.
oxdf@hacky$ time uv run capture.py
[*] Capturing for 10 seconds...
[+] Connected to mosi
[*] mosi: Connected to mosi wire. Broadcasting continuously every 2000ms...
[+] Connected to scl
[*] scl: Connected to scl wire. Broadcasting continuously every 2000ms...
[+] Connected to sda
[*] sda: Connected to sda wire. Broadcasting continuously every 2000ms...
[+] Connected to sck
[*] sck: Connected to sck wire. Broadcasting continuously every 2000ms...
[+] Connected to dq
[*] dq: Connected to dq wire. Broadcasting continuously every 2000ms...
[+] dq: 4590 samples
[+] mosi: 2742 samples
[+] sck: 4803 samples
[+] sda: 1140 samples
[+] scl: 2004 samples
[+] Saved to signals.json
real 0m10.221s
user 0m0.511s
sys 0m0.173s
Because it’s using async, it only takes 10.2 seconds to get 10 seconds of all five wires. The resulting data has five wires:
oxdf@hacky$ cat signals.json | jq '. | keys'
[
"dq",
"mosi",
"sck",
"scl",
"sda"
]
Each sample has a time and a value:
oxdf@hacky$ cat signals.json | jq '.dq | length'
4590
oxdf@hacky$ cat signals.json | jq '.dq[0] | keys'
[
"t",
"v"
]
oxdf@hacky$ cat signals.json | jq '.dq[0]'
{
"v": 1,
"t": 0
}
oxdf@hacky$ cat signals.json | jq '.dq[100]'
{
"v": 1,
"t": 4291
}
1-Wire
Background
1-Wire is a protocol developed by Dallas Semiconductor that uses pulse-width encoding to transmit data over a single wire. The key to decoding it is measuring how long the signal stays LOW:
- Short LOW pulse (1-15 time units) = 1
- Long LOW pulse (~60 time units) = 0
- Very long LOW pulses (150+) = reset/presence signals (skip these)
Bits are transmitted LSB-first, meaning I need to reverse each group of 8 bits to get the correct byte value.
Analyzing the Data
Looking at the captured dq data, each sample has a value (0 or 1) and a timestamp. To decode the signal, I need to find the transitions from HIGH to LOW and measure how long the signal stays LOW before going HIGH again.
I’ll write a short Python script to look at this:
import json
from collections import Counter
data = json.load(open('signals.json'))['dq']
# Find LOW pulses: look for 1→0 then 0→1 transitions
pulses = []
i = 0
while i < len(data) - 1:
if data[i]['v'] == 1 and data[i+1]['v'] == 0:
start = data[i+1]['t']
# Find end of LOW pulse
j = i + 1
while j < len(data) and data[j]['v'] == 0:
j += 1
if j < len(data):
duration = data[j]['t'] - start
pulses.append(duration)
i = j
else:
i += 1
c = Counter(pulses)
for length, num in c.items():
print(f"Found {num} pulses of length {length}.")
Running this shows there are only four lengths of low pulse:
oxdf@hacky$ uv run 1wire_analysis.py
Found 5 pulses of length 480.
Found 5 pulses of length 150.
Found 1330 pulses of length 60.
Found 950 pulses of length 6.
Short pulses at 6 represent 1s, longer pulses of 60 represent 0s, and very long pulses (150, 480) are the reset/presence signals.
Decoding Script
I’ll write a script to:
- Find all LOW pulses by looking for 1→0→1 transitions
- Calculate the duration of each LOW pulse
- Convert to bits: short = 1, long = 0, skip very long pulses
- Group into bytes (LSB-first) and convert to ASCII
import json
data = json.load(open('signals.json'))['dq']
pulses = []
i = 0
while i < len(data) - 1:
if data[i]['v'] == 1 and data[i+1]['v'] == 0:
start = data[i+1]['t']
j = i + 1
while j < len(data) and data[j]['v'] == 0:
j += 1
if j < len(data):
duration = data[j]['t'] - start
pulses.append(duration)
i = j
else:
i += 1
bits = []
for p in pulses:
if p > 100:
continue
elif p < 30:
bits.append(1)
else:
bits.append(0)
message = ''
for i in range(0, len(bits) - 7, 8):
byte_bits = bits[i:i+8]
byte_bits.reverse()
byte_val = int(''.join(map(str, byte_bits)), 2)
if 32 <= byte_val < 127:
message += chr(byte_val)
else:
message += '\n'
print(message)
Running this outputs a clear message:
oxdf@hacky$ uv run 1wire_decode.py
read and decrypt the SPI bus data using the XOR key: icy
read and decrypt the SPI bus data using the XOR key: icy
read and decrypt the SPI bus data using the XOR key: icy
read and decrypt the SPI bus data using the XOR key: icy
read and decrypt the SPI bus data using the XOR key: icy
The message instructs me to decrypt the SPI bus data using the XOR key icy.
SPI
Background
SPI (Serial Peripheral Interface) uses separate clock and data lines. For this challenge, I have:
sck- the clock signalmosi- the data signal (Master Out, Slave In)
To decode SPI:
- Sample the MOSI data line on each rising edge of SCK (when clock goes 0→1)
- Bits are MSB-first (most significant bit first)
- Then XOR decrypt with the key
icyfrom the 1-Wire message
Decoding Script
I’ll write a script to:
- Find all SCK rising edges (0→1 transitions)
- Sample the MOSI value at each rising edge timestamp
- Group bits into bytes (MSB-first)
- XOR decrypt with key
icy
import json
data = json.load(open('signals.json'))
sck = sorted(data['sck'], key=lambda x: x['t'])
mosi = sorted(data['mosi'], key=lambda x: x['t'])
def get_mosi_at_time(t):
val = 0
for m in mosi:
if m['t'] <= t:
val = m['v']
else:
break
return val
bits = []
for i in range(len(sck) - 1):
if sck[i]['v'] == 0 and sck[i+1]['v'] == 1:
t = sck[i+1]['t']
bits.append(get_mosi_at_time(t))
raw_bytes = []
for i in range(0, len(bits) - 7, 8):
byte_bits = bits[i:i+8]
byte_val = int(''.join(map(str, byte_bits)), 2)
raw_bytes.append(byte_val)
key = b'icy'
decrypted = ''
for i, b in enumerate(raw_bytes):
decrypted += chr(b ^ key[i % len(key)])
print(decrypted)
I need the get_mosi_at_time function because the timestamps for the two signals don’t always line up. So when I get that edge in the clock, I’ll want to find the last value of the signal.
Running this outputs another message:
oxdf@hacky$ uv run spi.py
read and decrypt the I2C bus data using the XOR key: bananza. the temperature sensor address is 0x3C
The message gives me the XOR key bananza and the I2C address 0x3C for the final stage.
I2C
Background
I2C (Inter-Integrated Circuit) is another clock-and-data protocol, using:
scl- the clock signalsda- the data signal
Like SPI, I sample SDA on the rising edge of SCL, and bits are MSB-first. However, I2C has additional complexity:
- The first byte of each transaction is the address byte:
(7-bit address << 1) | R/W bit - Every 9th bit is an ACK bit that must be skipped when extracting data
- The target address is
0x3C, which becomes0x78(write) or0x79(read) when shifted
For address 0x3C:
0x3C = 0011 1100
0x3C << 1 = 0111 1000 = 0x78
So I need to find 01111000 in the bit stream to locate the start of the relevant data.
Decoding Script
I’ll write a script to:
- Find SCL rising edges and sample SDA
- Search for the address byte
0x78in the bit stream - Extract data bits after the address, skipping every 9th bit (ACK)
- XOR decrypt with key
bananza
import json
data = json.load(open('signals.json'))
scl = sorted(data['scl'], key=lambda x: x['t'])
sda = sorted(data['sda'], key=lambda x: x['t'])
def get_sda_at_time(t):
val = 0
for s in sda:
if s['t'] <= t:
val = s['v']
else:
break
return val
bits = []
for i in range(len(scl) - 1):
if scl[i]['v'] == 0 and scl[i+1]['v'] == 1:
t = scl[i+1]['t']
bits.append(get_sda_at_time(t))
addr_pattern = [0, 1, 1, 1, 1, 0, 0, 0]
start_idx = None
for i in range(len(bits) - 8):
if bits[i:i+8] == addr_pattern:
start_idx = i + 9
break
data_bits = []
idx = start_idx
while idx < len(bits):
for _ in range(8):
if idx < len(bits):
data_bits.append(bits[idx])
idx += 1
idx += 1
raw_bytes = []
for i in range(0, len(data_bits) - 7, 8):
byte_bits = data_bits[i:i+8]
byte_val = int(''.join(map(str, byte_bits)), 2)
raw_bytes.append(byte_val)
key = b'bananza'
decrypted = ''
for i, b in enumerate(raw_bytes):
decrypted += chr(b ^ key[i % len(key)])
print(decrypted)
Running this produces the temperature (plus some junk):
oxdf@hacky$ uv run i2c.py
32.84«HJNGFgb`6t{v~úbä
The temperature value is 32.84, which is the answer to the challenge.
Outro
Signals
Congratulations! You have completed the Signals challenge!
Evan is pumped:
Evan Booth
Nice work! You cracked that signal encoding like a pro.
Turns out the weirdness had a method to it after all - just like most of my builds!