├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── example_file ├── images ├── buffer.png ├── rxtx.png ├── seeed.png ├── smj_433_remote.png └── socket.png ├── rx ├── __init__.py └── get_pin.py └── tx ├── __init__.py ├── get_pin.py └── rp2_rmt.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Peter Hinch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A library for 433MHz remote control 2 | 3 | Remote controlled wall sockets provide a convenient way to control power to 4 | electrical equipment. 5 | 6 | ![Image](images/socket.png) 7 | 8 | They are cheap, reliable and consume negligible power. However they lack 9 | flexibility: they can only be controlled by the matching remote. This library 10 | provides a means of incorporating them into an IOT (internet of things) 11 | solution, or building a remote capable of controlling more devices with a 12 | better antenna and longer range than the stock item. 13 | 14 | For the constructor a key benefit is that no high voltage wiring is required. 15 | 16 | The approach relies on the fact that most units use a common frequency of 17 | 433.92MHz. Transmitter and receiver modules are available for this frequency at 18 | low cost, e.g. [from Seeed](https://www.seeedstudio.com/433Mhz-RF-link-kit-p-127.html). 19 | The Seeed units: 20 | 21 | ![Image](images/seeed.png) 22 | 23 | I also tried one from eBay. This worked, but the receiver had poor sensitivity 24 | requiring the remote to be held very close to it to achieve results. 25 | 26 | #### Receiver 27 | 28 | The signal from the supplied remote is captured by a simple utility and stored 29 | in a file. Multiple signals - optionally from multiple remotes - may be stored 30 | in a file. The utility is used interactively at the REPL. Supported targets are 31 | Pyboard D, Pyboard 1.x, Pyboard Lite, ESP32 and Raspberry Pi Pico. 32 | 33 | #### Transmitter 34 | 35 | This module is intended to be used by applications. The application loads the 36 | file created by the receiver and transmits captured codes on demand. 37 | Transmission is nonblocking. Supported targets are Pyboard D, Pyboard 1.x, 38 | ESP32 and Raspberry Pi Pico. Pyboard Lite works in my testing, but only in 39 | blocking mode. The module does not use `uasyncio` but is compatible with it. 40 | 41 | #### Warning 42 | 43 | It should be noted that this is for experimenters. The capture process cannot 44 | be guaranteed to work for all possible remotes and all radio receivers, not 45 | least because the timing requirements are quite stringent. The receivers I own 46 | introduce significant jitter. The library uses averaging over multiple frames 47 | to improve accuracy. 48 | 49 | See [section 6](./README.md#6-background) for the reasons for this approach. 50 | 51 | #### Raspberry Pi Pico note 52 | 53 | Early firmware has [this issue](https://github.com/micropython/micropython/issues/6866) 54 | affecting USB communication with some PC's. This has been fixed: please use 55 | firmware V1.16 or later. 56 | 57 | #### ESP32 note 58 | 59 | A breaking change was introduced to the firmware in July 2021 affecting the 60 | transmitter. The code has been adapted to accommodate this: in consequence 61 | firmware more recent than this must now be used (a daily build, until the 62 | release of V1.17). 63 | 64 | # 1. Installation 65 | 66 | ## 1.1 Code 67 | 68 | Receiver: copy the `rx` directory and contents to the target's filesystem. 69 | Transmitter: copy the `tx` directory and contents to the target's filesystem. 70 | 71 | In each directory there is a file `get_pin.py`. This provides a convenient way 72 | to instantiate a `Pin` on Pyboard, ESP32 or Raspberry Pi Pico. This may be 73 | modified for your own needs or ignored and replaced with your own code. 74 | 75 | There are no dependencies. 76 | 77 | ## 1.2 Hardware 78 | 79 | It is difficult to generalise as there are multiple sources for 433MHz 80 | transceivers. Check the data for your modules. 81 | 82 | My transmitter and receiver need a 5V supply. The receiver produces a 0-5V 83 | signal: this is compatible with Pyboards but the ESP32 and Raspberry Pi Pico 84 | require a circuit to ensure 0-3.3V levels. The receiver code is polarity 85 | agnostic so an inverting buffer as shown below will suffice. 86 | 87 | Receiver defaults are pin X3 on Pyboard, pin 27 on ESP32 and pin 17 on Pico, 88 | but any pins may be used. 89 | 90 | ![Image](images/buffer.png) 91 | 92 | The transmitter can be directly connected as 5V devices are normally compatible 93 | with 3.3V logic levels. Transmitter defaults are X3 on Pyboard, 23 on ESP32 and 94 | 16 on the Pico. Any pins may be substituted. The 95 | [data for the Seeed transmitter](https://www.seeedstudio.com/433Mhz-RF-link-kit-p-127.html) 96 | states that a supply of up to 12V may be used to increase power. Whether this 97 | applies to other versions is moot: try at your own risk. I haven't. All the 98 | remotes I've seen use a miniature 12V battery. This may (or may not) mean that 99 | a 12V supply is commonplace on transmitter modules. 100 | 101 | ## 1.3 Hardware usage 102 | 103 | Pyboards: Timer 5. 104 | ESP32: RMT channel 0. 105 | Pico: PIO state machine 0, IRQ 0, PIO 0. 106 | 107 | The Pico uses `tx/rp2_rmt.py` which uses the PIO for nonblocking modulation in 108 | a similar way to the ESP32 RMT device. To use this library there is no need to 109 | study the code, but documentation is available 110 | [here](https://github.com/peterhinch/micropython_ir/blob/master/RP2_RMT.md) for 111 | those interested. 112 | 113 | # 2. Acquiring data 114 | 115 | The `RX` class behaves similarly to a dictionary, with individual captures 116 | indexed by arbitrary strings. An `RX` instance is created with 117 | ```python 118 | from rx import RX 119 | from rx.get_pin import pin 120 | recv = RX(pin()) 121 | ``` 122 | To capture a pin on a remote and associate it with the key "on", the remote 123 | should be placed close to the receiver and the button held down. Then issue 124 | ```python 125 | recv('on') 126 | ``` 127 | If the capture is successful, [diagnostic](./README.md#22-diagnostics) 128 | information will be output. If capture fails with an error message, repeat the 129 | process with the same key string. It is important that the button is pressed 130 | before issuing the above line, and only released when the REPL reappears. 131 | 132 | To capture further buttons, repeat the procedure with a unique key for each 133 | button. 134 | 135 | When this is complete, the dictionary can be saved as a JSON file (in this 136 | example called "remotes") with: 137 | ```python 138 | recv.save('remotes') 139 | ``` 140 | 141 | ## 2.1 RX class 142 | 143 | Examples assume an `RX` instance `recv`; `key` is a string used as a dictionary 144 | key. 145 | 146 | Constructor args: 147 | 1. `pin` A `Pin` instance initialised as input. 148 | 2. `nedges=800` The number of transitions acquired in a capture. Larger values 149 | may provide better accuracy at the cost of RAM use. 150 | 151 | Methods: 152 | 1. `load(fname)` Load an existing JSON file. 153 | 2. `save(fname)` Save the current set of captures to a JSON file. 154 | 3. `__call__(key)` Start a capture using the passed (string) key: `recv('TV on')`. 155 | 4. `__delitem__(key)` Delete a key: `del recv['TV on']`. 156 | 5. `__getitem__(key)` Return a list of pulse durations (in μs): `lst = recv['TV on']`. 157 | 6. `show(key)` As above but in more human readable form. 158 | 7. `keys()` List the keys. 159 | 160 | ## 2.2 Diagnostics 161 | 162 | These are intended to provide some feedback on the likely success of a capture. 163 | The acid test is, of course, to transmit it. A successful capture will produce 164 | output similar to this: 165 | ``` 166 | Frame length = 50 No. of frames = 14 167 | Averaging 14 frames 168 | Capture quality 34.3 (perfect = 0) 169 | ``` 170 | This shows that each frame comprises 50 edges (25 "mark" pulses). The capture 171 | process acquired 14 complete frames. In this instance all frames were of the 172 | same length so none were discarded. Averaging was done on all of them to yield 173 | accurate timing information. 174 | 175 | On occasion the diagnostics will report discarding frames of the wrong length: 176 | if only one or two are discarded the capture will probably be successful. 177 | 178 | The "Capture quality" is a measure of the standard deviation of the timing of 179 | the frames; a perfect capture would produce a value of zero. Its purpose is to 180 | help with placement of the remote near to the receiver. There is no particular 181 | "fail" threshold: I have had successful captures with values >60. A value 182 | around 15 is good. 183 | 184 | # 3. Transmitting 185 | 186 | Assuming the JSON file "remotes" is on the target's filesystem, and it contains 187 | a button capture with the key "TV on": 188 | ```python 189 | from tx import TX 190 | from tx.get_pin import pin 191 | transmit = TX(pin(), 'remotes') 192 | transmit('TV on') # Immediate return 193 | ``` 194 | The transmit method is nonblocking, on Pyboard, ESP32 and Raspberry Pi Pico. 195 | There is an alternative blocking method for use on Pyboard only. This offers 196 | more precise timing, and I found it necessary on the Pyboard Lite only. This is 197 | accessed as: 198 | ```python 199 | transmit.send('TV on') # Blocks 200 | ``` 201 | The capture process stores data with a resolution of +-1μs (absolute accuracy 202 | is less, as discussed above). The ESP32 uses the RMT class and the Pico uses a 203 | PIO library: both these solutions produce pulses whose lengths match the data 204 | value +-1μs. To put this in context, the shortest pulse I have measured from a 205 | remote is 110μs. 206 | 207 | ## 3.1 The TX class 208 | 209 | Examples assume a `TX` instance `tx`; `key` is a string used as a dictionary 210 | key. 211 | 212 | Constructor args: 213 | 1. `pin` A `Pin` instance initialised as output, with `value=0`. 214 | 2. `fname` Filename containing the captures. 215 | 3. `reps=5` On transmit, the captured pulse train is repeated `reps` times. 216 | 217 | Methods: 218 | 1. `__call__(key)` Normal nonblocking transmit: `tx('TV on')`. Blocks for 219 | ~2.2ms on Pyboard 1.1, 980μs on ESP32. Transmission continues as a background 220 | process. 221 | 2. `send(key)` Blocking transmit. For more precise timing on Pyboard only. 222 | 3. `__getitem__(key)` Return a list of pulse durations: `lst = tx['TV on']`. 223 | Values are μs. 224 | 4. `show(key)` As above but in more human readable form. 225 | 5. `keys()` List the keys. 226 | 6. `latency()` Returns a time in ms calculated from the capture file. It is 227 | the recommended minimum length of time which should elapse between successive 228 | nonblocking transmissions. The value is conservative. Switching wall sockets 229 | is subject to unknown amounts of latency in the sockets and in the powered 230 | devices themselves: it is not capable of millisecond-level precision. 231 | 232 | Class method: 233 | 1. `active_low()` Match a transmitter which transmits on a logic 0 (if such 234 | things exist). Pyboard only. Call before transmitting data. In this case the 235 | `Pin` passed to the constructor should be initialised with `value=1`. 236 | 237 | On ESP32 and Raspberry Pi Pico if an active low signal is required an external 238 | inverter must be used. 239 | 240 | The value of the `reps` constructor arg may need to be increased for some types 241 | of socket or in cases where radio interference is present. If your captures are 242 | not received, try a value of 10. Larger values increase RAM use (on ESP32, not 243 | on Raspberry Pi Pico). They improve the probability of successful reception. 244 | The 245 | [rc-switch](https://github.com/sui77/rc-switch) library uses a value of 10. 246 | 247 | ## 3.2 Example uasyncio usage 248 | 249 | This assumes that the new `uasyncio` will acquire a `Queue` class. Other tasks 250 | place keys onto the queue, `send_queued()` transmits them ensuring that the 251 | latency limit is met. 252 | 253 | ```python 254 | import uasyncio as asyncio 255 | from tx import TX 256 | from tx.get_pin import pin 257 | txq = asyncio.Queue() # Other tasks put data onto queue. 258 | async def send_queued(): 259 | transmit = TX(pin(), 'remotes') 260 | delay = transmit.latency() # Only need to calculate this once 261 | while True: 262 | to_send = await txq.get() 263 | transmit(to_send) 264 | await asyncio.sleep_ms(delay) 265 | ``` 266 | In the continued absence of an official `Queue` class, an unofficial version is 267 | available, documented 268 | [here](https://github.com/peterhinch/micropython-async/blob/master/v3/docs/TUTORIAL.md#35-queue). 269 | 270 | # 4. File maintenance 271 | 272 | The `RX` class enables maintenance of the JSON file: it is possible to add new 273 | captures and overwrite or delete existing ones. 274 | 275 | ### Adding new captures 276 | 277 | Load an existing file, add a new capture, and update the file. The same 278 | procedure might be used to replace a capture which had failed to work: 279 | ```python 280 | from rx import RX 281 | from rx.get_pin import pin 282 | recv = RX(pin()) 283 | recv.load('remotes') # Load file, start remote transmitting, then issue: 284 | recv('TV on') # With remote continuously transmitting 285 | recv.save('remotes') # Save file to same name 286 | ``` 287 | 288 | ### Deleting a capture 289 | 290 | ```python 291 | from rx import RX 292 | from rx.get_pin import pin 293 | recv = RX(pin()) 294 | recv.load('remotes') # Load file 295 | del recv['TV on'] 296 | recv.save('remotes') # Save file to same name 297 | ``` 298 | 299 | ### Printing timing information 300 | 301 | Data is stored as a series of pulse lengths in μs. These may be printed out. 302 | ```python 303 | from rx import RX 304 | from rx.get_pin import pin 305 | recv = RX(pin()) 306 | recv.load('remotes') # Load file 307 | recv.show('TV on') # Access capture 308 | ``` 309 | The default state of the transmitter is not transmitting, so the first entry 310 | (#0) represents carrier on (mark). Consequently even numbered entries are marks 311 | and odd numbers are spaces. Captured frames have an even number of entries. 312 | This ensures that the carrier is off between sequences. Leaving it on is an 313 | invalid use of the 433MHz band. If creating sequences in code, this should be 314 | honoured. 315 | 316 | # 5. It doesn't work. What should I do? 317 | 318 | If the capture process reports success, the problem is likely to be with the 319 | transmitter. 320 | 321 | Try running the receiver, connected to a scop or logic analyser while operating 322 | the transmitter to see if pulses are being received. Alternatively run the 323 | receiver utility on another MicroPython device. Try first with the remote and 324 | then using the transmitter. If they are detected you can be confident of the 325 | transmitter hardware. 326 | 327 | On any target increase the `reps` constructor arg to 10 and possibly beyond. 328 | 329 | On the Pyboard try the blocking `send` method. I found this necessary on the 330 | Pyboard Lite: it offers better timing. ESP32 and Raspberry Pi Pico timing are 331 | highly accurate for reasons discussed [above](./README.md#3-transmitting). 332 | 333 | # 6. Background 334 | 335 | My house is littered with remote controlled mains sockets. These are usually 336 | located in hard to reach places, behind computers or other kit, and are 337 | controlled by tiny 433MHz remote controls. They have two limitations: 338 | 1. Range. This can be down to two metres. With decent antennas 433MHz band 339 | devices can communicate over 100M or more. The problem results from small 340 | antennas and difficult receiver locations. 341 | 2. Flexibility. Sockets can only be controlled by the bundled remote. 342 | 343 | For some time I've been considering ways to control mains devices with greater 344 | range, ideally from the internet, but all had drawbacks. These 433MHz sockets 345 | have the benefits of being utterly reliable with power consumption too low for 346 | me to measure (<0.5W). A means of transmitting to them from a MicroPython 347 | target would present a wide range of control options. 348 | 349 | This was inspired by 350 | [a forum post](https://forum.micropython.org/viewtopic.php?f=14&t=7854#p45239) 351 | by Kevin Köck. Having posted [my IR library](https://github.com/peterhinch/micropython_ir) 352 | it struck me that there was a lot of commonality. In each case we generate and 353 | receive OOK (on-off keying) messages. In the case of 433MHz the conversion 354 | between modulation and carrier is done by external hardware. A cheap 433MHz 355 | transmitter, driven by a Pyboard D or ESP32, might provide a solution with 356 | network connectivity. 357 | 358 | Depending on range, these could be deployed in two ways: 359 | 1. A single, centrally located, TX with a good antenna and groundplane might 360 | serve all sockets in a house. 361 | 2. Failing that, several transmitters could be located near their respective 362 | sockets. 363 | 364 | ## 6.1 Implementation 365 | 366 | There seems to be one measure of standardisation between these devices: the RF 367 | carrier frequency of 433.92MHz. There are two potential ways of approaching the 368 | problem, both of which work with IR transceivers. 369 | 370 | ## 6.2 Solution 1: Implement specific protocols 371 | 372 | This has been done in [rc-switch](https://github.com/sui77/rc-switch), a C 373 | library for Arduino and the Raspberry Pi. It supports 12 protocols: it seems 374 | evident that there is much less standardisation than in the IR arena where a 375 | few well-documented protocols find wide use. In many IR applications the 376 | programmer can choose the protocol and buy a remote which supports it. This 377 | luxury isn't available to someone with a pre-existing set of switched sockets. 378 | 379 | Advantages: 380 | 1. Efficient applications can be written: each remote key can be represented 381 | by a single integer or string, being the data to transmit. 382 | 2. There is no need for data capture and hence no need for a receiver. 383 | 3. Transmit timing based on protocol knowledge is as accurate as possible. 384 | 385 | Drawbacks: 386 | 1. Some protocols use weird concepts such as tri-bits. The set of N-bit binary 387 | numbers representing transmit data contains invalid bit patterns. 388 | 2. It's hard to do because of the multitude of protocols. The Arduino library 389 | is quite big. 390 | 3. Porting is problematic: an example of all 12 types of socket would be 391 | required and I believe many are for US 115V power. 392 | 393 | [This reference](http://tinkerman.eldiariblau.net/decoding-433mhz-rf-data-from-wireless-switches/) 394 | describes some of the issues. 395 | 396 | ## 6.3 Solution 2: capture and play back 397 | 398 | This involves setting up a receiver, pressing a key on the remote, and storing 399 | the received pulse train for subsequent playback, typically on a different 400 | device. 401 | 402 | With IR protocols this is problematic. Protocols have radically different ways 403 | to deal with the case where a key is held down. Radio protocols repeatedly send 404 | the same code, greatly simplifying capture. 405 | 406 | Advantages: 407 | 1. It is protocol-agnostic so should work on any set of sockets. 408 | 2. The code is simple and easily tested. 409 | 410 | Drawbacks: 411 | 1. It requires a receiver to perform the initial capture. 412 | 2. It needs a fairly fast and capable target because timing is critical. 413 | 3. It is relatively inefficient: every key will be represented by a list of N 414 | integers, being the on or off duration in μs. In practice N is typically 50; 415 | data volume is hardly excessive. 416 | 4. There is some loss of timing precision as the capture process introduces 417 | uncertainty. 418 | 419 | Note that once the capture task is complete the receiver and target can be 420 | re-purposed. Receivers are cheap and are usually bundled with transmitters. 421 | 422 | ## 6.4 Test results 423 | 424 | The modulation is quite fast with pulse durations down to values on the order 425 | of 100μs. Successful capture depends on the quality of the receiver. The 426 | [Seeed](https://www.seeedstudio.com/433Mhz-RF-link-kit-p-127.html) one had much 427 | better sensitivity than this one, bought on eBay: 428 | 429 | ![Image](images/rxtx.png) 430 | 431 | With this receiver, even with the remote very close to the receiver, there was 432 | jitter in the output pulse train. This was sufficient to prevent successful 433 | transmission. Attaching an antenna made no discernible difference. 434 | 435 | Because of these problems the code captures a few frames and performs 436 | averaging. The eBay one worked, but I recommend the Seeed one which produced 437 | better diagnostic information without having to place the remote centimetres 438 | from the receiver. 439 | 440 | # 7. File format 441 | 442 | In response to requests I have provided `example_file` as an instance of the 443 | file format in use. It is a JSON encoded `dict`. The key is a string defining 444 | the button name. The example has keys "on" and "off". The value is a list of 445 | times in μs. The first is a transmitter ON time, followed by an OFF time, 446 | continuing to the end of the list. 447 | 448 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython_remote/ffb12a12e565ee46616d10a84806f11fde6eae06/__init__.py -------------------------------------------------------------------------------- /example_file: -------------------------------------------------------------------------------- 1 | {"on": [111, 541, 111, 542, 113, 541, 109, 543, 111, 541, 110, 543, 112, 541, 438, 217, 110, 542, 438, 216, 110, 544, 436, 216, 112, 542, 437, 216, 111, 542, 436, 216, 111, 543, 110, 542, 438, 215, 439, 215, 112, 541, 112, 544, 436, 215, 437, 217, 110, 5112], "off": [110, 542, 111, 541, 111, 543, 109, 542, 111, 542, 110, 541, 112, 542, 436, 216, 111, 543, 435, 217, 110, 542, 437, 218, 110, 542, 437, 216, 109, 543, 437, 216, 109, 543, 110, 544, 434, 219, 436, 215, 437, 218, 435, 218, 109, 544, 110, 542, 110, 5108]} -------------------------------------------------------------------------------- /images/buffer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython_remote/ffb12a12e565ee46616d10a84806f11fde6eae06/images/buffer.png -------------------------------------------------------------------------------- /images/rxtx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython_remote/ffb12a12e565ee46616d10a84806f11fde6eae06/images/rxtx.png -------------------------------------------------------------------------------- /images/seeed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython_remote/ffb12a12e565ee46616d10a84806f11fde6eae06/images/seeed.png -------------------------------------------------------------------------------- /images/smj_433_remote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython_remote/ffb12a12e565ee46616d10a84806f11fde6eae06/images/smj_433_remote.png -------------------------------------------------------------------------------- /images/socket.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython_remote/ffb12a12e565ee46616d10a84806f11fde6eae06/images/socket.png -------------------------------------------------------------------------------- /rx/__init__.py: -------------------------------------------------------------------------------- 1 | # rx __init__.py Capture utility for 433MHz remote control. 2 | 3 | # Author: Peter Hinch 4 | # Copyright Peter Hinch 2020 Released under the MIT license 5 | 6 | from array import array 7 | from utime import ticks_us, ticks_diff 8 | import ujson 9 | import gc 10 | from math import sqrt 11 | 12 | class RX(): 13 | 14 | def __init__(self, pin, nedges=800): # Typically ~15 frames 15 | self._pin = pin 16 | self._nedges = nedges 17 | self._data = {} 18 | gc.collect() 19 | # Store arrival times of edges in μs 20 | self._times = array('I', (0 for _ in range(nedges))) 21 | 22 | def __getitem__(self, key): # View list of pulse lengths: print(receiver['on']) 23 | if key in self._data: 24 | return self._data[key] 25 | print('Key "{}" does not exist'.format(key)) 26 | 27 | def __delitem__(self, key): # Key deletion: del receiver['on'] 28 | del self._data[key] 29 | 30 | def keys(self): 31 | return self._data.keys() 32 | 33 | def show(self, key): 34 | res = self[key] 35 | if res is not None: 36 | for x, t in enumerate(res): 37 | print('{:3d} {:6d}'.format(x, t)) 38 | 39 | # Attempt to achieve better precision by averaging several frames 40 | def process(self, diffs): 41 | ermsg = 'FAIL: too few valid frames.' 42 | gap = round(max(diffs) * 0.8) # Allow for tolerance 43 | # Discard data prior to and including 1st gap 44 | while diffs[0] < gap: 45 | diffs.pop(0) 46 | diffs.pop(0) 47 | 48 | # diffs starts with 1st pulse 49 | res = [] # list of frames. Each entry ends with gap. 50 | while True: 51 | lst = [] 52 | try: 53 | while diffs[0] < gap: 54 | lst.append(diffs.pop(0)) 55 | lst.append(diffs.pop(0)) # Add the gap 56 | except IndexError: 57 | break # all done 58 | res.append(lst) 59 | 60 | # List of frames. May have some with invalid lengths. 61 | lengths = [len(x) for x in res] 62 | if len(lengths) < 5: 63 | print(ermsg) # Too few frames 64 | return 65 | #print('Lengths', lengths) 66 | d = {x: 0 for x in set(lengths)} 67 | for l in lengths: 68 | d[l] += 1 69 | count = max(d.values()) # Find most common frame length 70 | for length in d.keys(): 71 | if d[length] == count: 72 | break 73 | old = len(res) 74 | print('Frame length = {} No. of frames = {}'.format(length, old)) 75 | res = [r for r in res if len(r) == length] 76 | # All frames have same length 77 | cnt = len(res) 78 | if cnt != old: 79 | print('Deleted {} frames of wrong length'.format(old - cnt)) 80 | if cnt < 5: 81 | print(ermsg) 82 | else: 83 | print('Averaging {} frames'.format(cnt)) 84 | m = [round(sum(x)/cnt) for x in zip(*res)] # Mean values 85 | s = [sqrt(sum([(y - m[i])**2 for y in x])) for i, x in enumerate(zip(*res))] # Standard deviations 86 | print('Capture quality {:5.1f} (perfect = 0)'.format(sum(s)/len(s))) 87 | return [round(x) for x in m] 88 | 89 | def __call__(self, key): 90 | print('Awaiting radio data') 91 | nedges = self._nedges 92 | x = 0 93 | p = self._pin 94 | # ** Time critical ** 95 | while x < nedges: 96 | v = p() 97 | while v == p(): 98 | pass 99 | self._times[x] = ticks_us() 100 | x += 1 101 | # ** End of time critical ** 102 | diffs = [] 103 | for x in range(nedges - 2): 104 | diffs.append(ticks_diff(self._times[x + 1], self._times[x])) 105 | # Perform error checking and averaging. 106 | res = self.process(diffs) 107 | if res is None: 108 | print('Capture failed: please try again.') 109 | else: 110 | self._data[key] = res 111 | print('Key "{}" stored.'.format(key)) 112 | 113 | def load(self, fname): # Import file (will overwrite existing keys) 114 | try: 115 | with open(fname, 'r') as f: 116 | self._data.update(ujson.load(f)) 117 | except OSError: 118 | print("Can't open '{}' for reading.".format(fname)) 119 | 120 | def save(self, fname): 121 | try: 122 | with open(fname, 'w') as f: 123 | ujson.dump(self._data, f) 124 | except OSError: 125 | print("Can't open '{}' for writing.".format(fname)) 126 | else: 127 | print('Data saved in file {}'.format(fname)) 128 | -------------------------------------------------------------------------------- /rx/get_pin.py: -------------------------------------------------------------------------------- 1 | # get_pin.py Return a Pin instance for RX 2 | 3 | # Author: Peter Hinch 4 | # Copyright Peter Hinch 2020 Released under the MIT license 5 | 6 | from machine import Pin, freq 7 | from sys import platform 8 | 9 | def pin(): 10 | # Define pin according to platform 11 | if platform == 'pyboard': 12 | pin = Pin('X3', Pin.IN) 13 | elif platform == 'esp8266': 14 | raise OSError('Receiver does not support ESP8266') 15 | #freq(160000000) 16 | #pin = Pin(13, Pin.IN) 17 | elif platform == 'esp32' or platform == 'esp32_LoBo': 18 | pin = Pin(27, Pin.IN) 19 | elif platform == 'rp2': # Raspberry Pi Pico 20 | pin = Pin(17, Pin.IN) 21 | return pin 22 | -------------------------------------------------------------------------------- /tx/__init__.py: -------------------------------------------------------------------------------- 1 | # __init__.py Nonblocking 433MHz transmitter 2 | # Runs on Pyboard D, Pyboard 1.x, Pyboard Lite, ESP32 and Raspberry Pi Pico 3 | 4 | # Released under the MIT License (MIT). See LICENSE. 5 | # Copyright (c) 2020-2021 Peter Hinch 6 | 7 | from sys import platform 8 | ESP32 = platform == 'esp32' # Loboris not supported owing to RMT 9 | RP2 = platform == 'rp2' 10 | if ESP32: 11 | from esp32 import RMT 12 | elif RP2: 13 | from .rp2_rmt import RP2_RMT 14 | else: 15 | from pyb import Timer 16 | 17 | from machine import Pin 18 | from array import array 19 | from time import ticks_us, ticks_diff, sleep_us 20 | import gc 21 | import ujson 22 | 23 | # import micropython 24 | # micropython.alloc_emergency_exception_buf(100) 25 | STOP = const(0) 26 | 27 | # TX class. Physical transmission occurs in an ISR context controlled by timer 5. 28 | class TX: 29 | _active_high = True 30 | 31 | @classmethod 32 | def active_low(cls): 33 | if ESP32: 34 | raise ValueError('Cannot set active low on ESP32') 35 | cls._active_high = False 36 | 37 | def __init__(self, pin, fname, reps=5): 38 | self._pin = pin 39 | self._reps = reps 40 | with open(fname, 'r') as f: 41 | self._data = ujson.load(f) 42 | # Time to wait between nonblocking transmissions. A conservative value in ms. 43 | self._latency = (reps + 2) * max((sum(x) for x in self._data.values())) // 1000 44 | gc.collect() 45 | if ESP32: 46 | self._rmt = RMT(0, pin=pin, clock_div=80) # 1μs resolution 47 | elif RP2: # PIO-based RMT-like device 48 | self._rmt = RP2_RMT(pin_pulse=pin) # 1μs resolution 49 | # Array size: length of longest entry + 1 for STOP 50 | asize = max([len(x) for x in self._data.values()]) + 1 51 | self._arr = array('H', (0 for _ in range(asize))) # on/off times (μs) 52 | else: # Pyboard 53 | self._tim = Timer(5) # Timer 5 controls carrier on/off times 54 | self._tcb = self._cb # Pre-allocate 55 | asize = reps * max([len(x) for x in self._data.values()]) + 1 # Array size 56 | self._arr = array('H', (0 for _ in range(asize))) # on/off times (μs) 57 | self._aptr = 0 # Index into array 58 | 59 | def _cb(self, t): # T5 callback, generate a carrier mark or space 60 | t.deinit() 61 | p = self._aptr 62 | v = self._arr[p] 63 | if v == STOP: 64 | self._pin(self._active_high ^ 1) 65 | return 66 | self._pin(p & 1 ^ self._active_high) 67 | self._tim.init(prescaler=84, period=v, callback=self._tcb) 68 | self._aptr += 1 69 | 70 | def __getitem__(self, key): 71 | return self._data[key] 72 | 73 | def keys(self): 74 | return self._data.keys() 75 | 76 | def show(self, key): 77 | res = self[key] 78 | if res is not None: 79 | for x, t in enumerate(res): 80 | print('{:3d} {:6d}'.format(x, t)) 81 | 82 | def latency(self): 83 | return self._latency 84 | 85 | # Nonblocking transmit 86 | def __call__(self, key): 87 | gc.collect() 88 | lst = self[key] 89 | if lst is not None: 90 | if ESP32: 91 | # TODO use RMT.loop() cancelled by a soft timer to do reps. 92 | # This would save RAM. RMT.loop() is now fixed. I remain 93 | # unconvinced because of the huge latency of soft timers on 94 | # boards with SPIRAM. It would save ram if a half-word array 95 | # could be passed. But it can't (as of 9th March 2021). 96 | # Prior to July 2021 start = 1 was required. Now this breaks 97 | # and 1 is the default. 98 | self._rmt.write_pulses(lst * self._reps) #, start = 1) 99 | elif RP2: 100 | for x, t in enumerate(lst): 101 | self._arr[x] = t 102 | self._arr[x + 1] = STOP 103 | self._rmt.send(self._arr, self._reps) 104 | else: 105 | x = 0 106 | for _ in range(self._reps): 107 | for t in lst: 108 | self._arr[x] = t 109 | x += 1 110 | self._arr[x] = STOP 111 | self._aptr = 0 # Reset pointer 112 | self._cb(self._tim) # Initiate physical transmission. 113 | 114 | # Blocking transmit: proved necessary on Pyboard Lite 115 | @micropython.native 116 | def send(self, key): 117 | gc.collect() 118 | pin = self._pin 119 | q = self._active_high ^ 1 # Pin inactive state 120 | lst = self[key] 121 | if lst is not None: 122 | for _ in range(self._reps): 123 | pin(q) 124 | for t in lst: 125 | pin(pin() ^ 1) 126 | sleep_us(t) 127 | pin(q) 128 | -------------------------------------------------------------------------------- /tx/get_pin.py: -------------------------------------------------------------------------------- 1 | # get_pin.py Return a Pin instance for TX 2 | 3 | # Author: Peter Hinch 4 | # Copyright Peter Hinch 2020 Released under the MIT license 5 | 6 | from machine import Pin 7 | from sys import platform 8 | 9 | def pin(state=0): 10 | # Define pin according to platform 11 | if platform == 'pyboard': 12 | pin = Pin('X3', Pin.OUT) 13 | elif platform == 'esp32': 14 | pin = Pin(23, Pin.OUT) 15 | elif platform == 'rp2': # Raspberry Pi Pico 16 | pin = Pin(16, Pin.OUT) 17 | elif platform == 'esp8266': 18 | raise OSError('Transmitter does not support ESP8266') 19 | elif platform == 'esp32_LoBo': 20 | raise OSError('Transmitter does not support Loboris port') 21 | else: 22 | raise OSError('Unsupported platform', platform) 23 | pin(state) 24 | return pin 25 | -------------------------------------------------------------------------------- /tx/rp2_rmt.py: -------------------------------------------------------------------------------- 1 | # rp2_rmt.py A RMT-like class for the RP2. 2 | 3 | # Released under the MIT License (MIT). See LICENSE. 4 | 5 | # Copyright (c) 2021 Peter Hinch 6 | 7 | from machine import Pin, PWM 8 | import rp2 9 | 10 | @rp2.asm_pio(set_init=rp2.PIO.OUT_LOW, autopull=True, pull_thresh=32) 11 | def pulsetrain(): 12 | wrap_target() 13 | out(x, 32) # No of 1MHz ticks. Block if FIFO MT at end. 14 | irq(rel(0)) 15 | set(pins, 1) # Set pin high 16 | label('loop') 17 | jmp(x_dec,'loop') 18 | irq(rel(0)) 19 | set(pins, 0) # Set pin low 20 | out(y, 32) # Low time. 21 | label('loop_lo') 22 | jmp(y_dec,'loop_lo') 23 | wrap() 24 | 25 | @rp2.asm_pio(autopull=True, pull_thresh=32) 26 | def irqtrain(): 27 | wrap_target() 28 | out(x, 32) # No of 1MHz ticks. Block if FIFO MT at end. 29 | irq(rel(0)) 30 | label('loop') 31 | jmp(x_dec,'loop') 32 | wrap() 33 | 34 | class DummyPWM: 35 | def duty_u16(self, _): 36 | pass 37 | 38 | class RP2_RMT: 39 | 40 | def __init__(self, pin_pulse=None, carrier=None, sm_no=0, sm_freq=1_000_000): 41 | if carrier is None: 42 | self.pwm = DummyPWM() 43 | self.duty = (0, 0) 44 | else: 45 | pin_car, freq, duty = carrier 46 | self.pwm = PWM(pin_car) # Set up PWM with carrier off. 47 | self.pwm.freq(freq) 48 | self.pwm.duty_u16(0) 49 | self.duty = (int(0xffff * duty // 100), 0) 50 | if pin_pulse is None: 51 | self.sm = rp2.StateMachine(sm_no, irqtrain, freq=sm_freq) 52 | else: 53 | self.sm = rp2.StateMachine(sm_no, pulsetrain, freq=sm_freq, set_base=pin_pulse) 54 | self.apt = 0 # Array index 55 | self.arr = None # Array 56 | self.ict = None # Current IRQ count 57 | self.icm = 0 # End IRQ count 58 | self.reps = 0 # 0 == forever n == no. of reps 59 | rp2.PIO(0).irq(self._cb) 60 | 61 | # IRQ callback. Because of FIFO IRQ's keep arriving after STOP. 62 | def _cb(self, pio): 63 | self.pwm.duty_u16(self.duty[self.ict & 1]) 64 | self.ict += 1 65 | if d := self.arr[self.apt]: # If data available feed FIFO 66 | self.sm.put(d) 67 | self.apt += 1 68 | else: 69 | if r := self.reps != 1: # All done if reps == 1 70 | if r: # 0 == run forever 71 | self.reps -= 1 72 | self.sm.put(self.arr[0]) 73 | self.apt = 1 # Set pointer and count to state 74 | self.ict = 1 # after 1st IRQ 75 | 76 | # Arg is an array of times in μs terminated by 0. 77 | def send(self, ar, reps=1, check=True): 78 | self.sm.active(0) 79 | self.reps = reps 80 | ar[-1] = 0 # Ensure at least one STOP 81 | for x, d in enumerate(ar): # Find 1st STOP 82 | if d == 0: 83 | break 84 | if check: 85 | # Discard any trailing mark which would leave carrier on. 86 | if (x & 1): 87 | x -= 1 88 | ar[x] = 0 89 | self.icm = x # index of 1st STOP 90 | mv = memoryview(ar) 91 | n = min(x, 4) # Fill FIFO if there are enough data points. 92 | self.sm.put(mv[0 : n]) 93 | self.arr = ar # Initial conditions for ISR 94 | self.apt = n # Point to next data value 95 | self.ict = 0 # IRQ count 96 | self.sm.active(1) 97 | 98 | def busy(self): 99 | if self.ict is None: 100 | return False # Just instantiated 101 | return self.ict < self.icm 102 | 103 | def cancel(self): 104 | self.reps = 1 105 | --------------------------------------------------------------------------------