Used a clock recovery mechanism instead, and now it works pretty robustly:
Script
import matplotlib.pyplot as plt
import numpy as np
FILENAME = "C220241108_airconoff00000.txt"
FILENAME = "C220241108_aircondown2800000.txt"
data = np.loadtxt(FILENAME, skiprows=5)
xs, ys = data.T
threshold = (np.max(ys) + np.min(ys)) / 2
ys = (ys >= threshold).astype(int) # binarize
# Treat it like a real-time signal processing problem
# Get all transition timings and parity
mask = np.diff(ys) != 0
txs = xs[1:][mask]
assert ys[1:][mask][0] == 1 # make sure the first transition is always positive
# Four clock cycle worth of HIGH will be sent for timing, use this
# to estimate the sampling interval
clock_width = (txs[1] - txs[0]) / 4
sampling_interval = clock_width / 2
# Debouncing
mask = np.diff(txs) < (sampling_interval / 2)
mask = np.hstack(([False], mask)) # no need to additionally roll
txs = txs[~mask]
# Interpolate
fy = 1
fx = sampling_interval/2 # sample from center
fxs = [fx]
fys = [fy]
# Idea: Use single width pulses to center
i = 1 # index of transition following current 'fx'
flipped = True
# while fx < txs[-1]: # more transitions to process
while i < len(txs):
fx += sampling_interval
# Check for debounce
# Check for transition
if txs[i] - fx < sampling_interval:
# Check if single pulse
# TODO: why not use every transition as an estimation?
# this fixes local clock drift. But this might be a bit dangerous
# in the event a malformed signal is obtained during off times.
# Process only in chunks.
if flipped:
fx = (txs[i] + txs[i-1])/2
sampling_interval = txs[i] - txs[i-1] # refine estimation
flipped = True
fxs.append(fx)
fys.append(fy)
# Handle next transition
fy = 1 - fy
i += 1
else: # no flip
flipped = False
fxs.append(fx)
fys.append(fy)
assert fy == 0
fxs.append(fx + sampling_interval)
fys.append(0)
# Save data
def trim(xf, yf):
_yf = np.flatnonzero(yf)
start, end = _yf[0], _yf[-1]
signal = yf[start:end+2]
signal_xs = xf[start:end+2]
return signal_xs, signal
sigxs, sigys = trim(fxs, fys)
# Extract both signals
mid = len(sigxs) // 2
sig1xs, sig1ys = sigxs[:mid], sigys[:mid]
sig2xs, sig2ys = sigxs[mid:], sigys[mid:]
sig1xs, sig1ys = trim(sig1xs, sig1ys)
sig2xs, sig2ys = trim(sig2xs, sig2ys)
if not np.all(sig1ys == sig2ys):
print("Failed!")
plt.plot(xs, ys, alpha=0.2)
tys = np.zeros(txs.size)
tys[::2] = 1
plt.plot(txs, tys, "bx")
plt.plot(fxs, fys, "rx")
plt.show()
raise
sigxs = sig1xs[::2]
sigys = sig1ys[::2]
np.savetxt(FILENAME + ".signal.txt", sigys, "%d", newline=" ")
# Plot
# plt.plot(xs, ys, alpha=0.2)
# plt.plot(sigxs, sigys, "kx")
# plt.ylabel("Value")
# plt.xlabel("Time (s)")
# plt.show()
Data
off 25: 1 1 1 1 0 0 1 0 1 0 1 1 1 1 0 1 1 1 0 1 0 1 1 0 1 1 1 0 1 0 1 1 0 1 0 1 1 1 0 1 1 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 X 1 1 1 1 1 1 0 1 0 1 1 1 1 0 1 1 1 0 1 1 1 1 1 1 0 1 0 1 1 0 1 0 1 1 1 1 0 1 1 0 1 0 1 0 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 0 1 1 1 0 1 0 1 0 1
on 25: 1 1 1 1 0 0 1 0 1 0 1 1 1 1 0 1 1 1 0 1 0 1 1 0 1 1 1 0 1 0 1 1 0 1 0 1 1 1 0 1 1 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 1 1 1 1 0 1 0 1 1 1 1 0 1 1 1 0 1 1 1 1 1 1 0 1 0 1 1 0 1 0 1 1 1 1 0 1 1 0 1 0 1 0 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 0 1 1 1 X 1 X 1 X 1
up 26: 1 1 1 1 0 0 1 0 1 0 1 1 1 1 0 1 1 1 0 1 0 1 1 0 1 1 1 0 1 0 1 1 0 1 0 1 1 1 0 1 1 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 1 1 1 1 0 1 0 1 1 1 1 1 0 1 1 0 1 1 1 1 1 1 0 1 0 1 1 0 1 0 1 1 1 1 0 1 1 0 1 0 1 0 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 0 1 0 1 1 1 1 1 1
up 27: 1 1 1 1 0 0 1 0 1 0 1 1 1 1 0 1 1 1 0 1 0 1 1 0 1 1 1 0 1 0 1 1 0 1 0 1 1 1 0 1 1 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 1 1 1 1 0 1 0 1 1 1 1 0 1 0 1 1 0 1 1 1 1 1 1 0 1 0 1 1 0 1 0 1 1 1 1 0 1 1 0 1 0 1 0 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 1 1 1
up 28: 1 1 1 1 0 0 1 0 1 0 1 1 1 1 0 1 1 1 0 1 0 1 1 0 1 1 1 0 1 0 1 1 0 1 0 1 1 1 0 1 1 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 1 1 1 1 0 1 0 1 1 1 1 1 1 0 1 0 1 1 1 1 1 1 0 1 0 1 1 0 1 0 1 1 1 1 0 1 1 0 1 0 1 0 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 1 0 1 1 1 1 1
up 29: 1 1 1 1 0 0 1 0 1 0 1 1 1 1 0 1 1 1 0 1 0 1 1 0 1 1 1 0 1 0 1 1 0 1 0 1 1 1 0 1 1 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 1 1 1 1 0 1 0 1 1 1 1 0 1 1 0 1 0 1 1 1 1 1 1 0 1 0 1 1 0 1 0 1 1 1 1 0 1 1 0 1 0 1 0 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 0 1 1 1 1 1
down 28: 1 1 1 1 0 0 1 0 1 0 1 1 1 1 0 1 1 1 0 1 0 1 1 0 1 1 1 0 1 0 1 1 0 1 0 1 1 1 0 1 1 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 1 1 1 1 0 1 0 1 1 1 1 1 1 0 1 0 1 1 1 1 1 1 0 1 0 1 1 0 1 0 1 1 1 1 0 1 1 0 1 0 1 0 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 1 0 1 1 1 1 1
down 27: 1 1 1 1 0 0 1 0 1 0 1 1 1 1 0 1 1 1 0 1 0 1 1 0 1 1 1 0 1 0 1 1 0 1 0 1 1 1 0 1 1 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 1 1 1 1 0 1 0 1 1 1 1 0 1 0 1 1 0 1 1 1 1 1 1 0 1 0 1 1 0 1 0 1 1 1 1 0 1 1 0 1 0 1 0 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 1 1 1
^
At some point it dawned on me that IR remote controllers:
are probably pretty simple devices with minimal finite-states
has communication protocols that may or may not align with some industry standard
are pretty easy to measure the signal
A first pass involved measuring it with an IR powermeter directly, with voltage queries, but the call-and-response rate is much too slow to do anything useful (likely on the order of 1ms). The photodiode itself (FDG-50) likely has a large enough bandwidth for me to perform a measurement, so that's what I did.
Attached the two-pin header into the probe points directly from the photodiode, then latched onto crocodile clips before measurement with an oscilloscope, using a 10MR shunt resistor and 1MR measurement impedance:
Setup
Some key facts (for the aircon remote):
On-off keying is used
Baudrate measured around 1.15kHz (clock frequency probably twice that, so 2.3kHz)
Some internal modulation of the IR signal itself that is happening at 38kHz.
25kS/s sampling rate is sufficient to discriminate signals to a high accuracy
The same signal is sent twice in succession.
States are maintained in the remote control, e.g. ON and OFF signals are different in length.
Measurement
OOK baudrate (averaged from higher harmonic), and intrinsic modulation rate:
25kS/s sampling rate:
Repeated signal:
Data is stored in lightstick/projects/_private/irremote
. A simple fixed clock calculation doesn't seem to work though, perhaps may need to do clock estimation on an interval instead...?
Script
import matplotlib.pyplot as plt
import numpy as np
FILENAME = "C220241108_airconoff00000.txt"
data = np.loadtxt(FILENAME, skiprows=5)
xs, ys = data.T
threshold = (np.max(ys) + np.min(ys)) / 2
ys = (ys >= threshold).astype(int) # binarize
# Estimation of mean clock signal by relative spacing
x1s = xs[ys == 1]
dx1s = np.diff(x1s)
yy, xx = np.histogram(dx1s, range=(2e-4, 6e-4), bins=1000000)
sampling_interval = xx[np.argmax(yy)] # 0.00042996
# sampling_interval = 1/2300 # 2.3kHz
# Interpolate
xf = np.arange(xs[0]//sampling_interval, xs[-1]//sampling_interval) * sampling_interval - sampling_interval/2
print(len(xf), xs[-1], sampling_interval)
print(len(xs))
yf = np.interp(xf, xs, ys)
plt.plot(xf, yf, "x")
plt.plot(xs, ys, alpha=0.2)
plt.ylabel("Value")
plt.xlabel("Time (s)")
plt.show()
The remote control one is a little harder to understand, but is clearer once we zoom in a little. Some key facts:
Sharp peaks correspond to visible light LED flashing (likely for user feedback)
The signal itself is a discrete chunk spanning roughly 40ms, with the signal repeating for as long as the button is held down.
The signal manifests as an AC coupling to the IR detector (I should have measured AC instead of the DC).
Minimum sampling rate for high accuracy discrimination is around 5MS/s instead.
Measurement
Signal from fan remote:
Also a quick search led me to this repository that supercharges an ESP32 for IR signal sending.
Update: Extracted the signal by manually applying a small adjustment to the clock frequency. Signal shown below:
Script
import matplotlib.pyplot as plt
import numpy as np
FILENAME = "C220241108_airconoff00000.txt"
data = np.loadtxt(FILENAME, skiprows=5)
xs, ys = data.T
threshold = (np.max(ys) + np.min(ys)) / 2
ys = (ys >= threshold).astype(int) # binarize
# Estimation of mean clock signal by relative spacing
# A reliable reference is the leading edge of the signal (i.e. 0->1)
txs = xs[1:][np.diff(ys) == 1] # transitions
dtxs = np.diff(txs)
yy, xx = np.histogram(dtxs, range=(2e-4, 12e-4), bins=100000)
clock_width = xx[np.argmax(yy)] # 0.00042996
sampling_interval = clock_width / 2 - 1.3e-6
print("Sampling interval:", sampling_interval)
# Interpolate
xf = np.arange(
xs[0] // sampling_interval,
xs[-1] // sampling_interval,
)
xf = xf * sampling_interval + sampling_interval/2 # sample from center
yf = np.interp(xf, xs, ys)
# Save data
def trim(xf, yf):
_yf = np.flatnonzero(yf)
start, end = _yf[0], _yf[-1]
signal = yf[start:end+2]
signal_xs = xf[start:end+2]
return signal_xs, signal
sigxs, sigys = trim(xf, yf)
# Extract both signals
mid = len(sigxs) // 2
sig1xs, sig1ys = sigxs[:mid], sigys[:mid]
sig2xs, sig2ys = sigxs[mid:], sigys[mid:]
sig1xs, sig1ys = trim(sig1xs, sig1ys)
sig2xs, sig2ys = trim(sig2xs, sig2ys)
assert np.all(sig1ys == sig2ys)
sigxs = sig1xs[::2]
sigys = sig1ys[::2]
np.savetxt(FILENAME + ".signal.txt", sigys, "%d")
# Plot
plt.plot(xs, ys, alpha=0.2)
plt.plot(sigxs, sigys, "kx")
plt.ylabel("Value")
plt.xlabel("Time (s)")
plt.show()