Skip to content

Commit 6ade483

Browse files
committed
Implement NonblockinGenericDecoder and example.
1 parent 9ed86b4 commit 6ade483

File tree

2 files changed

+150
-10
lines changed

2 files changed

+150
-10
lines changed

adafruit_irremote.py

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -93,17 +93,23 @@ def decode_bits(pulses):
9393
"""Decode the pulses into bits."""
9494
# pylint: disable=too-many-branches,too-many-statements
9595

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 = pulses
100+
pulses = list(pulses) # Copy to avoid mutating input.
101+
96102
# special exception for NEC repeat code!
97103
if (
98104
(len(pulses) == 3)
99105
and (8000 <= pulses[0] <= 10000)
100106
and (2000 <= pulses[1] <= 3000)
101107
and (450 <= pulses[2] <= 700)
102108
):
103-
raise IRNECRepeatException()
109+
return NECRepeatIRMessage(input_pulses)
104110

105111
if len(pulses) < 10:
106-
raise IRDecodeException("10 pulses minimum")
112+
return UnparseableIRMessage(input_pulses, reason="Too short")
107113

108114
# Ignore any header (evens start at 1), and any trailer.
109115
if len(pulses) % 2 == 0:
@@ -123,7 +129,7 @@ def decode_bits(pulses):
123129
odd_bins = [b for b in odd_bins if b[1] > 1]
124130

125131
if not even_bins or not odd_bins:
126-
raise IRDecodeException("Not enough data")
132+
return UnparseableIRMessage(input_pulses, reason="Not enough data")
127133

128134
if len(even_bins) == 1:
129135
pulses = odds
@@ -132,12 +138,12 @@ def decode_bits(pulses):
132138
pulses = evens
133139
pulse_bins = even_bins
134140
else:
135-
raise IRDecodeException("Both even/odd pulses differ")
141+
return UnparseableIRMessage(input_pulses, reason="Both even/odd pulses differ")
136142

137143
if len(pulse_bins) == 1:
138-
raise IRDecodeException("Pulses do not differ")
144+
return UnparseableIRMessage(input_pulses, reason="Pulses do not differ")
139145
if len(pulse_bins) > 2:
140-
raise IRDecodeException("Only mark & space handled")
146+
return UnparseableIRMessage(input_pulses, reason="Only mark & space handled")
141147

142148
mark = min(pulse_bins[0][0], pulse_bins[1][0])
143149
space = max(pulse_bins[0][0], pulse_bins[1][0])
@@ -154,15 +160,112 @@ def decode_bits(pulses):
154160
elif (mark * 0.75) <= pulse_length <= (mark * 1.25):
155161
pulses[i] = True
156162
else:
157-
raise IRDecodeException("Pulses outside mark/space")
163+
return UnparseableIRMessage(
164+
input_pulses, reason="Pulses outside mark/space"
165+
)
158166

159167
# convert bits to bytes!
160168
output = [0] * ((len(pulses) + 7) // 8)
161169
for i, pulse_length in enumerate(pulses):
162170
output[i // 8] = output[i // 8] << 1
163171
if pulse_length:
164172
output[i // 8] |= 1
165-
return output
173+
return IRMessage(input_pulses, code=output)
174+
175+
176+
class BaseIRMessage:
177+
"Contains the pulses that were parsed as one message."
178+
179+
def __init__(self, pulses):
180+
# Stash an immutable copy of pulses.
181+
self.pulses = tuple(pulses)
182+
183+
def __repr__(self):
184+
return f"{self.__class__.__name__}({self.pulses})"
185+
186+
187+
class IRMessage(BaseIRMessage):
188+
"""
189+
Message interpreted as bytes.
190+
191+
>>> m.code # the output of interest (the parsed bytes)
192+
>>> m.pulses # the original pulses
193+
"""
194+
195+
def __init__(self, pulses, *, code):
196+
super().__init__(pulses)
197+
self.code = code
198+
199+
def __repr__(self):
200+
return f"{self.__class__.__name__}" f"(pulses={self.pulses}, code={self.code})"
201+
202+
203+
class UnparseableIRMessage(BaseIRMessage):
204+
"Message that could not be interpreted."
205+
206+
def __init__(self, pulses, *, reason):
207+
super().__init__(pulses)
208+
self.reason = reason
209+
210+
def __repr__(self):
211+
return (
212+
f"{self.__class__.__name__}" f"(pulses={self.pulses}, reason={self.reason})"
213+
)
214+
215+
216+
class NECRepeatIRMessage(BaseIRMessage):
217+
"Message interpreted as an NEC repeat code."
218+
pass
219+
220+
221+
class NonblockingGenericDecode:
222+
"""
223+
Decode pulses into bytes in a non-blocking fashion.
224+
225+
:param ~pulseio.PulseIn input_pulses: Object to read pulses from
226+
:param int max_pulse: Pulse duration to end a burst. Units are
227+
microseconds.
228+
229+
>>> pulses = PulseIn(...)
230+
>>> decoder = NonblockingGenericDecoder(pulses)
231+
>>> for message in decoder.read():
232+
... if isinstace(message, IRMessage):
233+
... message.code # TA-DA! Do something with this in your application.
234+
... else:
235+
... # message is either NECRepeatIRMessage or
236+
... # UnparseableIRMessage. You may decide to ignore it, raise
237+
... # an error, or log the issue to a file. If you raise or log,
238+
... # it may be helpful to include message.pulses in the error message.
239+
... ...
240+
"""
241+
242+
def __init__(self, pulses, max_pulse=10_000):
243+
self.pulses = pulses # PulseIn
244+
self.max_pulse = max_pulse
245+
self._unparsed_pulses = [] # internal buffer of partial messages
246+
247+
def read(self):
248+
"""
249+
Consume all pulses from PulseIn. Yield decoded messages, if any.
250+
251+
If a partial message is received, this does not block to wait for the
252+
rest. It stashes the partial message, to be continued the next time it
253+
is called.
254+
"""
255+
# Consume from PulseIn.
256+
while self.pulses:
257+
pulse = self.pulses.popleft()
258+
self._unparsed_pulses.append(pulse)
259+
if pulse > self.max_pulse:
260+
# End of message! Decode it and yield a BaseIRMessage.
261+
yield decode_bits(self._unparsed_pulses)
262+
self._unparsed_pulses.clear()
263+
# TODO Do we need to consume and throw away more pulses here?
264+
# I'm unclear about the role that "pruning" plays in the
265+
# original implementation in GenericDecode._read_pulses_non_blocking.
266+
# When we reach here, we have consumed everything from PulseIn.
267+
# If there are some pulses in self._unparsed_pulses, they represent
268+
# partial messages. We'll finish them next time read() is called.
166269

167270

168271
class GenericDecode:
@@ -174,7 +277,11 @@ def bin_data(self, pulses):
174277

175278
def decode_bits(self, pulses):
176279
"Wraps the top-level function decode_bits for backward-compatibility."
177-
return decode_bits(pulses)
280+
result = decode_bits(pulses)
281+
if isinstance(result, NECRepeatIRMessage):
282+
raise IRNECRepeatException()
283+
elif isinstance(result, UnparseableIRMessage):
284+
raise IRDecodeException("10 pulses minimum")
178285

179286
def _read_pulses_non_blocking(
180287
self, input_pulses, max_pulse=10000, pulse_window=0.10
@@ -214,7 +321,7 @@ def read_pulses(
214321
max_pulse=10000,
215322
blocking=True,
216323
pulse_window=0.10,
217-
blocking_delay=0.10
324+
blocking_delay=0.10,
218325
):
219326
"""Read out a burst of pulses until pulses stop for a specified
220327
period (pulse_window), pruning pulses after a pulse longer than ``max_pulse``.

examples/irremote_nonblocking.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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 pulseio
7+
import board
8+
import adafruit_irremote
9+
import time
10+
11+
pulsein = pulseio.PulseIn(board.REMOTEIN, maxlen=120, idle_state=True)
12+
decoder = adafruit_irremote.NonblockingGenericDecode(pulsein)
13+
14+
15+
t0 = next_heartbeat = time.monotonic()
16+
17+
while True:
18+
for message in decoder.read():
19+
print(f"t={time.monotonic() - t0:.3} New Message")
20+
print("Heard", len(message.pulses), "Pulses:", message.pulses)
21+
if isinstance(message, adafruit_irremote.IRMessage):
22+
print("Decoded:", message.code)
23+
elif isinstance(message, adafruit_irremote.NECRepeatIRMessage):
24+
print("NEC repeat!")
25+
elif isinstance(message, adafruit_irremote.UnparseableIRMessage):
26+
print("Failed to decode", message.reason)
27+
print("----------------------------")
28+
29+
# This heartbeat confirms that we are not blocked somewhere above.
30+
t = time.monotonic()
31+
if t > next_heartbeat:
32+
print(f"t={time.monotonic() - t0:.3} Heartbeat")
33+
next_heartbeat = t + 0.1

0 commit comments

Comments
 (0)