Skip to content

Commit 69cea6a

Browse files
authored
Merge pull request #42 from danielballan/nonblocking
Implement nonblocking decoder
2 parents 4bd1691 + 8ab59ce commit 69cea6a

File tree

3 files changed

+232
-98
lines changed

3 files changed

+232
-98
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ bundles
1313
.eggs
1414
dist
1515
**/*.egg-info
16+
*.swp

adafruit_irremote.py

Lines changed: 196 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,8 @@
5050
https://github.com/adafruit/circuitpython/releases
5151
5252
"""
53-
54-
# Pretend self matter because we may add object level config later.
55-
# pylint: disable=no-self-use
56-
5753
import array
54+
from collections import namedtuple
5855
import time
5956

6057
__version__ = "0.0.0-auto.0"
@@ -69,118 +66,219 @@ class IRNECRepeatException(Exception):
6966
"""Exception when a NEC repeat is decoded"""
7067

7168

72-
class GenericDecode:
73-
"""Generic decoding of infrared signals"""
69+
def bin_data(pulses):
70+
"""Compute bins of pulse lengths where pulses are +-25% of the average.
7471
75-
def bin_data(self, pulses):
76-
"""Compute bins of pulse lengths where pulses are +-25% of the average.
72+
:param list pulses: Input pulse lengths
73+
"""
74+
bins = [[pulses[0], 0]]
75+
76+
for _, pulse in enumerate(pulses):
77+
matchedbin = False
78+
# print(pulse, end=": ")
79+
for b, pulse_bin in enumerate(bins):
80+
if pulse_bin[0] * 0.75 <= pulse <= pulse_bin[0] * 1.25:
81+
# print("matches bin")
82+
bins[b][0] = (pulse_bin[0] + pulse) // 2 # avg em
83+
bins[b][1] += 1 # track it
84+
matchedbin = True
85+
break
86+
if not matchedbin:
87+
bins.append([pulse, 1])
88+
# print(bins)
89+
return bins
90+
91+
92+
def decode_bits(pulses):
93+
"""Decode the pulses into bits."""
94+
# pylint: disable=too-many-branches,too-many-statements
95+
96+
# TODO The name pulses is redefined several times below, so we'll stash the
97+
# original in a separate variable for now. It might be worth refactoring to
98+
# avoid redefining pulses, for the sake of readability.
99+
input_pulses = tuple(pulses)
100+
pulses = list(pulses) # Copy to avoid mutating input.
101+
102+
# special exception for NEC repeat code!
103+
if (
104+
(len(pulses) == 3)
105+
and (8000 <= pulses[0] <= 10000)
106+
and (2000 <= pulses[1] <= 3000)
107+
and (450 <= pulses[2] <= 700)
108+
):
109+
return NECRepeatIRMessage(input_pulses)
110+
111+
if len(pulses) < 10:
112+
msg = UnparseableIRMessage(input_pulses, reason="Too short")
113+
raise FailedToDecode(msg)
114+
115+
# Ignore any header (evens start at 1), and any trailer.
116+
if len(pulses) % 2 == 0:
117+
pulses_end = -1
118+
else:
119+
pulses_end = None
120+
121+
evens = pulses[1:pulses_end:2]
122+
odds = pulses[2:pulses_end:2]
123+
124+
# bin both halves
125+
even_bins = bin_data(evens)
126+
odd_bins = bin_data(odds)
127+
128+
outliers = [b[0] for b in (even_bins + odd_bins) if b[1] == 1]
129+
even_bins = [b for b in even_bins if b[1] > 1]
130+
odd_bins = [b for b in odd_bins if b[1] > 1]
131+
132+
if not even_bins or not odd_bins:
133+
msg = UnparseableIRMessage(input_pulses, reason="Not enough data")
134+
raise FailedToDecode(msg)
135+
136+
if len(even_bins) == 1:
137+
pulses = odds
138+
pulse_bins = odd_bins
139+
elif len(odd_bins) == 1:
140+
pulses = evens
141+
pulse_bins = even_bins
142+
else:
143+
msg = UnparseableIRMessage(input_pulses, reason="Both even/odd pulses differ")
144+
raise FailedToDecode(msg)
145+
146+
if len(pulse_bins) == 1:
147+
msg = UnparseableIRMessage(input_pulses, reason="Pulses do not differ")
148+
raise FailedToDecode(msg)
149+
if len(pulse_bins) > 2:
150+
msg = UnparseableIRMessage(input_pulses, reason="Only mark & space handled")
151+
raise FailedToDecode(msg)
152+
153+
mark = min(pulse_bins[0][0], pulse_bins[1][0])
154+
space = max(pulse_bins[0][0], pulse_bins[1][0])
155+
156+
if outliers:
157+
# skip outliers
158+
pulses = [
159+
p for p in pulses if not (outliers[0] * 0.75) <= p <= (outliers[0] * 1.25)
160+
]
161+
# convert marks/spaces to 0 and 1
162+
for i, pulse_length in enumerate(pulses):
163+
if (space * 0.75) <= pulse_length <= (space * 1.25):
164+
pulses[i] = False
165+
elif (mark * 0.75) <= pulse_length <= (mark * 1.25):
166+
pulses[i] = True
167+
else:
168+
msg = UnparseableIRMessage(input_pulses, reason="Pulses outside mark/space")
169+
raise FailedToDecode(msg)
77170

78-
:param list pulses: Input pulse lengths
79-
"""
80-
bins = [[pulses[0], 0]]
81-
82-
for _, pulse in enumerate(pulses):
83-
matchedbin = False
84-
# print(pulse, end=": ")
85-
for b, pulse_bin in enumerate(bins):
86-
if pulse_bin[0] * 0.75 <= pulse <= pulse_bin[0] * 1.25:
87-
# print("matches bin")
88-
bins[b][0] = (pulse_bin[0] + pulse) // 2 # avg em
89-
bins[b][1] += 1 # track it
90-
matchedbin = True
91-
break
92-
if not matchedbin:
93-
bins.append([pulse, 1])
94-
# print(bins)
95-
return bins
96-
97-
def decode_bits(self, pulses):
98-
"""Decode the pulses into bits."""
99-
# pylint: disable=too-many-branches,too-many-statements
100-
101-
# special exception for NEC repeat code!
102-
if (
103-
(len(pulses) == 3)
104-
and (8000 <= pulses[0] <= 10000)
105-
and (2000 <= pulses[1] <= 3000)
106-
and (450 <= pulses[2] <= 700)
107-
):
108-
raise IRNECRepeatException()
171+
# convert bits to bytes!
172+
output = [0] * ((len(pulses) + 7) // 8)
173+
for i, pulse_length in enumerate(pulses):
174+
output[i // 8] = output[i // 8] << 1
175+
if pulse_length:
176+
output[i // 8] |= 1
177+
return IRMessage(tuple(input_pulses), code=tuple(output))
109178

110-
if len(pulses) < 10:
111-
raise IRDecodeException("10 pulses minimum")
112179

113-
# Ignore any header (evens start at 1), and any trailer.
114-
if len(pulses) % 2 == 0:
115-
pulses_end = -1
116-
else:
117-
pulses_end = None
180+
IRMessage = namedtuple("IRMessage", ("pulses", "code"))
181+
"Pulses and the code they were parsed into"
118182

119-
evens = pulses[1:pulses_end:2]
120-
odds = pulses[2:pulses_end:2]
183+
UnparseableIRMessage = namedtuple("IRMessage", ("pulses", "reason"))
184+
"Pulses and the reason that they could not be parsed into a code"
121185

122-
# bin both halves
123-
even_bins = self.bin_data(evens)
124-
odd_bins = self.bin_data(odds)
186+
NECRepeatIRMessage = namedtuple("NECRepeatIRMessage", ("pulses",))
187+
"Pulses interpreted as an NEC repeat code"
125188

126-
outliers = [b[0] for b in (even_bins + odd_bins) if b[1] == 1]
127-
even_bins = [b for b in even_bins if b[1] > 1]
128-
odd_bins = [b for b in odd_bins if b[1] > 1]
129189

130-
if not even_bins or not odd_bins:
131-
raise IRDecodeException("Not enough data")
190+
class FailedToDecode(Exception):
191+
"Raised by decode_bits. Error argument is UnparseableIRMessage"
132192

133-
if len(even_bins) == 1:
134-
pulses = odds
135-
pulse_bins = odd_bins
136-
elif len(odd_bins) == 1:
137-
pulses = evens
138-
pulse_bins = even_bins
139-
else:
140-
raise IRDecodeException("Both even/odd pulses differ")
141-
142-
if len(pulse_bins) == 1:
143-
raise IRDecodeException("Pulses do not differ")
144-
if len(pulse_bins) > 2:
145-
raise IRDecodeException("Only mark & space handled")
146-
147-
mark = min(pulse_bins[0][0], pulse_bins[1][0])
148-
space = max(pulse_bins[0][0], pulse_bins[1][0])
149-
150-
if outliers:
151-
# skip outliers
152-
pulses = [
153-
p
154-
for p in pulses
155-
if not (outliers[0] * 0.75) <= p <= (outliers[0] * 1.25)
156-
]
157-
# convert marks/spaces to 0 and 1
158-
for i, pulse_length in enumerate(pulses):
159-
if (space * 0.75) <= pulse_length <= (space * 1.25):
160-
pulses[i] = False
161-
elif (mark * 0.75) <= pulse_length <= (mark * 1.25):
162-
pulses[i] = True
163-
else:
164-
raise IRDecodeException("Pulses outside mark/space")
165-
166-
# convert bits to bytes!
167-
output = [0] * ((len(pulses) + 7) // 8)
168-
for i, pulse_length in enumerate(pulses):
169-
output[i // 8] = output[i // 8] << 1
170-
if pulse_length:
171-
output[i // 8] |= 1
172-
return output
193+
194+
class NonblockingGenericDecode:
195+
"""
196+
Decode pulses into bytes in a non-blocking fashion.
197+
198+
:param ~pulseio.PulseIn input_pulses: Object to read pulses from
199+
:param int max_pulse: Pulse duration to end a burst. Units are microseconds.
200+
201+
>>> pulses = PulseIn(...)
202+
>>> decoder = NonblockingGenericDecoder(pulses)
203+
>>> for message in decoder.read():
204+
... if isinstace(message, IRMessage):
205+
... message.code # TA-DA! Do something with this in your application.
206+
... else:
207+
... # message is either NECRepeatIRMessage or
208+
... # UnparseableIRMessage. You may decide to ignore it, raise
209+
... # an error, or log the issue to a file. If you raise or log,
210+
... # it may be helpful to include message.pulses in the error message.
211+
... ...
212+
"""
213+
214+
def __init__(self, pulses, max_pulse=10_000):
215+
self.pulses = pulses # PulseIn
216+
self.max_pulse = max_pulse
217+
self._unparsed_pulses = [] # internal buffer of partial messages
218+
219+
def read(self):
220+
"""
221+
Consume all pulses from PulseIn. Yield decoded messages, if any.
222+
223+
If a partial message is received, this does not block to wait for the
224+
rest. It stashes the partial message, to be continued the next time it
225+
is called.
226+
"""
227+
# Consume from PulseIn.
228+
while self.pulses:
229+
pulse = self.pulses.popleft()
230+
self._unparsed_pulses.append(pulse)
231+
if pulse > self.max_pulse:
232+
# End of message! Decode it and yield a BaseIRMessage.
233+
try:
234+
yield decode_bits(self._unparsed_pulses)
235+
except FailedToDecode as err:
236+
# If you want to debug failed decodes, this would be a good
237+
# place to print/log or (re-)raise.
238+
(unparseable_message,) = err.args
239+
yield unparseable_message
240+
self._unparsed_pulses.clear()
241+
# TODO Do we need to consume and throw away more pulses here?
242+
# I'm unclear about the role that "pruning" plays in the
243+
# original implementation in GenericDecode._read_pulses_non_blocking.
244+
# When we reach here, we have consumed everything from PulseIn.
245+
# If there are some pulses in self._unparsed_pulses, they represent
246+
# partial messages. We'll finish them next time read() is called.
247+
248+
249+
class GenericDecode:
250+
"""Generic decoding of infrared signals"""
251+
252+
# Note: pylint's complaint about the following three methods (no self-use)
253+
# is absolutely correct, which is why the code was refactored, but we need
254+
# this here for back-compat, hence we disable pylint for that specific
255+
# complaint.
256+
257+
def bin_data(self, pulses): # pylint: disable=no-self-use
258+
"Wraps the top-level function bin_data for backward-compatibility."
259+
return bin_data(pulses)
260+
261+
def decode_bits(self, pulses): # pylint: disable=no-self-use
262+
"Wraps the top-level function decode_bits for backward-compatibility."
263+
result = decode_bits(pulses)
264+
if isinstance(result, NECRepeatIRMessage):
265+
raise IRNECRepeatException()
266+
if isinstance(result, UnparseableIRMessage):
267+
raise IRDecodeException("10 pulses minimum")
173268

174269
def _read_pulses_non_blocking(
175270
self, input_pulses, max_pulse=10000, pulse_window=0.10
176-
):
271+
): # pylint: disable=no-self-use
177272
"""Read out a burst of pulses without blocking until pulses stop for a specified
178273
period (pulse_window), pruning pulses after a pulse longer than ``max_pulse``.
179274
180275
:param ~pulseio.PulseIn input_pulses: Object to read pulses from
181276
:param int max_pulse: Pulse duration to end a burst
182277
:param float pulse_window: pulses are collected for this period of time
183278
"""
279+
# Note: pylint's complaint (no self-use) is absolutely correct, which
280+
# is why the code was refactored, but we need this here for
281+
# back-compat, hence we disable pylint.
184282
received = None
185283
recent_count = 0
186284
pruning = False
@@ -209,7 +307,7 @@ def read_pulses(
209307
max_pulse=10000,
210308
blocking=True,
211309
pulse_window=0.10,
212-
blocking_delay=0.10
310+
blocking_delay=0.10,
213311
):
214312
"""Read out a burst of pulses until pulses stop for a specified
215313
period (pulse_window), pruning pulses after a pulse longer than ``max_pulse``.

examples/irremote_nonblocking.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries
2+
# SPDX-License-Identifier: MIT
3+
4+
# Circuit Playground Express Demo Code
5+
# Adjust the pulseio 'board.PIN' if using something else
6+
import time
7+
8+
import board
9+
import pulseio
10+
11+
import adafruit_irremote
12+
13+
pulsein = pulseio.PulseIn(board.REMOTEIN, maxlen=120, idle_state=True)
14+
decoder = adafruit_irremote.NonblockingGenericDecode(pulsein)
15+
16+
17+
t0 = next_heartbeat = time.monotonic()
18+
19+
while True:
20+
for message in decoder.read():
21+
print(f"t={time.monotonic() - t0:.3} New Message")
22+
print("Heard", len(message.pulses), "Pulses:", message.pulses)
23+
if isinstance(message, adafruit_irremote.IRMessage):
24+
print("Decoded:", message.code)
25+
elif isinstance(message, adafruit_irremote.NECRepeatIRMessage):
26+
print("NEC repeat!")
27+
elif isinstance(message, adafruit_irremote.UnparseableIRMessage):
28+
print("Failed to decode", message.reason)
29+
print("----------------------------")
30+
31+
# This heartbeat confirms that we are not blocked somewhere above.
32+
t = time.monotonic()
33+
if t > next_heartbeat:
34+
print(f"t={time.monotonic() - t0:.3} Heartbeat")
35+
next_heartbeat = t + 0.1

0 commit comments

Comments
 (0)