RCOutlets
Version: | 0.3.0 |
---|---|
Author: | Alexander Keil |
Contact: | alex@akeil.net |
Date: | 2014-01-04 |
Abstract
Control radio controlled outlets from a computer with Arduino.
Contents
Hardware
Required Parts
- Radio Controlled Outlets
- Arduino
- RF Transmitter
- 2 LED's, 2 Resistors, wires
RC Outlets
The radio controlled outlet comes as a set with three outlet adapters and a handset remote control. The adapters are numbers (1, 2, 3) and the remote has three corresponding pairs of "on"/"off" buttons, one for each outlet (6 buttons total).
There is a rocker switch on the remote and a small dial on the adapter that lets you set one of for "groups" (A-D). This allows to use more than one set at the same time. The adapter "1" will respond to the "1 on" button on the remote only if the remote is set to the same channel as the adapter.
Group | Numbers | ||
---|---|---|---|
1 | 2 | 3 | |
A | A1 | A2 | A3 |
B | B1 | B2 | B3 |
C | C1 | C2 | C3 |
D | D1 | D2 | D3 |
Thus, up to 12 outlets can be controlled with a total of 24 commands (12x "on", 12x "off").
RF Transmitter
Aside from the Arduino, a 433.92 MHz RF Transmitter is required. It does not really matter, which exact RF module is used, as long as it send on 433.92 MHz and runs on 5V. It would even be possible (and much cheaper) to use the RF module from the handheld remote.
In this case an Aurel TX-SAW Mid/5V is used.
The Aurel Transmitter is (for example) available at Conrad (German site), the manufacturer has a user manual [PDF] and a datasheet can also be found.
The Aurel TX-SAW Mid/5V transmitter module has 6 pins which are used as follows:
Pin | Name | Description |
---|---|---|
1 | Tx Data | Data input, connect to TRANSMITTER_PIN on the Arduino. |
2 | GND | Connect to ground. |
7 | GND | Connect to ground. |
8 | RF out | RF output connected to antenna. |
9 | GND | Connect to ground. |
10 | V+ | Connection to 5V supply of Arduino. |
You can use a single GND connection for Pins 2,7 and 9. A piece of wire constitutes a sufficient antenna.
Status LED's
We will also connect two status LED's:
Green: | Lights when a command is received over serial and while the command is transmitted over wireless. |
---|---|
Red: | Lights for some time if there was an error (e.g. a bad command was received). |
Arduino Sketch
Protocol
We use plain text messages to be sent as commands to the Arduino. the messages are fixed length, each terminated by a newline. Each message consists of four characters:
Index | Meaning | Values |
---|---|---|
0 | Group | A,B,C,D |
1 | Device | 1,2,3 |
2 | Command | 0, 1 |
3 | Terminate | \n |
Example:
> A10 # group A, switch 1, "off" > A21 # group A, switch 2, "on" > B11 # group B, switch 1, "on"
Additionally, a simple handshake mechanism is required. When the serial connection to the Arduino is opened, the Arduino will restart and not be ready for a few seconds. All data that is sent before the board is ready will be lost.
The handshake scheme is that the "client" (our Python script) sends an ASCII ENQ character and the "server" replies with a single ASCII ACK to signal that it is ready.
When initializing the connection, we will keep sending ENQ until we receive an ACK (or until a timeout runs out). After the ACK is received, we can start sending commands to control the power outlets.
In pseudo code:
while True: serial.write(ENQ) if serial.read(1) == ACK: break
Code
Luckily, a project called RCSwitch provides us with a library that can handle the interaction with the RF module and also knows how to encode the command which are sent to the power outlets.
Thus, the only thing left is interpreting input received over serial and deciding which commands to send.
Python Client
The Python module uses pySerial to communicate with the Arduino board.
The script can be used as a library module in another Python script or it can be run as a standalone command line app.
Usage
The following interface is supported:
with RCOutlets(options) as outlets: # by group and number outlets.get('A1').on() outlets.get('A0').off() # with aliases/pattern matching for switch in outlets.mget('kitchen B*'): switch.off()
Sample command line usage:
$ outlet A1 on $ outlet A0 off $ outlet desk on $ outlet 'kitchen B*' off
Configuration
If the file ~/.config/rcoutlets.conf exists, values from that file are used in the command-line program.
This is how a sample config could look like:
[rcoutlets] port = /dev/ttyACM0 timeout = 2 groups = A B handshake_timeout = 4 [alias] desk = A1 kitchen = B1 B2 C1
The configuration file can map alias names to switch-identifiers. An alias can refer to several switches and it is possible to define groups of switches. If a command is issued to a group, all switches in that group receive that command.
The Daemon
When the serial connection to the Arduino is initialized, the board is reset and it takes some time for it to come up again. For this reason, the Python script connects to the Arduino via a handshake. It keeps sending a Handshake Request until it receives the proper Handshake Response. Only after the handshake completed successfully, we can start sending commands.
This works well but since the handshake takes about two seconds, a simple run-once script will be unresponsive. It has to initialize the connection each time it is invoked, so every command is delayed by the time it takes to build up the collection.
For this reason, we need a daemon which runs constantly and keeps the connection open. Our command line script will then not connect to the Arduino but instead send its command to the daemon process which relays it over serial.
Requirements:
- Reconnect
- The daemon process might lose its connection to the Arduino, for example if the board is disconnected. The daemon should be able to detect a lost connection and try to rebuild it.
- Start on request
- Start the daemon only when its (first) required. This is a task for the OS. Connect lazily, i.e. when the first command is sent. This is implemented in the daemon.
The same script is used for:
- run the daemon
- run as a client for the daemon
- run standalone with a new connection for each command
The python script will support a command line option to run as a daemon. If called with this option, the script starts the daemon and then exits.
$ rcoutlets --daemon
Here is an example implementation of running a daemon in PYthon.
Pidfile
By default, it is /var/run/rcoutlets/rcoutlets.pid. It is located in a separate directory to allow non-root users to write it without granting write permissions on /var/run/.
Prepare like so (as root):
# mkdir /var/run/rcoutlets # chown username:group /var/run/rcoutlets
Where username and group are the user and group that will start the rcoutlets service.
Com
The controller script used on the console to issue individual commands must of course be able to communicate its commands to the daemon.
For this, a small TCP server is implemented, which receives command-strings and sends them through the serial connection. The daemon process simply starts the server.
The command-script will either use a direct serial connection or a TCP client to send its commands.
Android App
Create an Android app to control the lights.
Functions
Have two buttons for each switch, one for on and one for off.
Also, have named groups that combine several switches. would be nice if user could define these groups.
GUI-Layout
Have several tabs, one for each group of switches. That is four tabs. Each tab has 5x2 buttons: "on" and "off" buttons for all four switches plus additional buttons for controlling all switches of the group.
Add an additional tab with favorites. Would require to disable tabs if a group is not defined.
Note
Question
- How do dynamically add components to the UI?
- How do we add/remove (or hide/show) tabs?
+--------+--------+--------+-------+ | Tab A | Tab B | Tab C | Tab D | +--------+ +--------+-------+ | | | A* <on> <off> | | - - - - - - - - - - - - - - - - -| | | | A1 <on> <off> | | | | A2 <on> <off> | | | | A3 <on> <off> | | | | A4 <on> <off> | | | +----------------------------------+
TCP Client
A TCP Client is required to communicate with the Python server. The TCP client only has a single business method sendCommand which is used to send commands to the server.
The TCP client takes the following configuration options:
host: | Hostname or IP address of the server. |
---|---|
port: | Port number of the server. |
timeout: | Connection timeout for talking to the server. |
Note
Question
Do we have to tear down the TCP connection when the application is moved to the "background" Or when the application quits?
Does it make sense to keep the connection open? Or is it better to open a new connection for each call?
Project Setup
Install the SDK. Apparently the Eclipse Plugin is not strictly required, there is a command line tool.
Manifest File
A manifest file lists all components of the app?
<!-- AndroidManifest.xml --> <manifest> <uses-sdk android:minSdkVersion="8" ... /> </manifest>
Directory Structure:
src/ res/ drawable-hdpi/ layout/ values/ ...
Activity
The main Activity goes into the src/ folder.
GUI
Android GUI's are defined statically in an XML file like this:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"> <Button android:id="id" android:text="text" android:onClick="handleClick" /> </LinearLayout>
GUI elements can be connected to controller code in the layout definition. In the Activity implement a method handleClick(View view), in the layout XML assign onClick="handleClick" on a button.
Code
Arduino-Sketch
The complete Arduino sketch looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 |
/* * RCOutlets * Send commands to radio controlled power outlets. * * Requires The RCSwitch library * https://code.google.com/p/rc-switch/ * * Version 0.2.0 * Alexander Keil <alex@akeil.net> */ #include <RCSwitch.h> const int ERROR_LED_PIN = 7; const int BUSY_LED_PIN = 6; const int TRANSMITTER_PIN = 4; const int HEARTBEAT = 1; const int INDICATOR_DURATION = 1000; const int BAUDRATE = 9600; const char HANDSHAKE_REQUEST = 5; // ascii ENQ const char HANDSHAKE_RESPONSE = 6; // ascii ACK const char COMMAND_TERMINATOR = 10; // ascii newline RCSwitch outlets = RCSwitch(); void setup() { Serial.begin(BAUDRATE); pinMode(ERROR_LED_PIN, OUTPUT); pinMode(BUSY_LED_PIN, OUTPUT); pinMode(TRANSMITTER_PIN, OUTPUT); outlets.enableTransmit(TRANSMITTER_PIN); } void loop() { delay(HEARTBEAT); updateBusyLED(false); updateErrorLED(false); } /* * Handles incoming data over serial. * * Recognizes command-messages constructed from: * - `group` A-D * - `number` 1-3 * - `state` 0 or 1 * * If the COMMAND_TERMINATOR is received and if a valid command * was recognized that command is applied via `sendCommand()`. * * If the HANDSHAKE_REQUEST is received, replies with the HANDSHAKE_RESPONSE. */ void serialEvent() { static int _pos = 0; static char _group = 0; static int _number = 0; static bool _state = false; static bool _error = false; char received = Serial.read(); // handshake if (received == HANDSHAKE_REQUEST ) { Serial.write(HANDSHAKE_RESPONSE); // apply command and reset } else if (received == COMMAND_TERMINATOR) { if ( _pos == 3 and not _error) { sendCommand(_group, _number, _state); } _group = 0; _number = 0; _state = false; _error = false; _pos = 0; // build command } else { switch (_pos) { case 0: if (received >= 65 and received <= 68) { _group = received; } else { _error = true; } break; case 1: if (received >= 49 and received <= 51) { _number = received - 48; } else { _error = true; } break; case 2: if (received == 48) { _state = false; } else if (received == 49) { _state = true; } else{ _error = true; } break; } _pos++; updateErrorLED(_error); } } /* * Send a command over the RF Transmitter. * Parameters: * group Group identifier A, B, C or D * number Deivce label 1, 2 or 3 * state On (true) or Off (false) */ void sendCommand(char group, int number, bool state) { updateBusyLED(true); if (state){ outlets.switchOn(group, number); } else { outlets.switchOff(group, number); } } /* * Update BUSY_LED_PIN. * Remebers the time when it was last turned on and passes it to `updateLED`. */ void updateBusyLED(bool turnOnNow) { static unsigned long lastSet; if (turnOnNow ){ lastSet = millis(); } updateLED(BUSY_LED_PIN, turnOnNow, lastSet); } /* * Update ERROR_LED_PIN. * Remebers the time when it was last turned on and passes it to `updateLED`. */ void updateErrorLED(bool turnOnNow) { static unsigned long lastSet; if (turnOnNow ){ lastSet = millis(); } updateLED(ERROR_LED_PIN, turnOnNow, lastSet); } /* * Update the state of the given `pin`. * * Set it to HIGH always if `turnOnNow` is true. * * Set it to LOW only if `turnOnNow` is false * *and* if the time indicated by `lastSet` is at least INDICATOR_DURATION ago. */ void updateLED(int pin, bool turnOnNow, unsigned long lastSet) { if (turnOnNow) { digitalWrite(pin, HIGH); } else { if( millis() - lastSet > INDICATOR_DURATION) { digitalWrite(pin, LOW); } } } |
Python Script
The complete script looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 |
#!/usr/bin/python # -*- coding: utf-8 -*- ''' Command line app to control RC outlets via an arduino. The command line interface accepts user input which is translated into commands for the Arduino. The translated commands are sent to the Arduino board over a serial connection. Requires an Arduino running the ``RCOutlets`` sketch to actually communicate with the RC Outlets over radio. This script also offers a small TCP server which listens for commands and forwards them to the Arduino. The script can be used in these modes: Without Server For each invocation of the script, a new serial connection is build and closed when the script exits Client for the Server When the server is running, the script can act as a client for the server process. Instead of creating a new serial connection for every command, the serial connection is held by the server and the script sends its commands to the server. As a Server The script can be used to start the server as a daemon process. It can also be used to control the server. ''' from __future__ import print_function from __future__ import with_statement import sys import os import argparse import time import re import logging import atexit from signal import SIGTERM try: import configparser except ImportError: import ConfigParser as configparser import socket import serial from serial import SerialException __version__ = '0.3.1' __author__ = 'akeil' log = logging.getLogger('rcoutlets') logging.basicConfig(level=logging.INFO) # Command line exit codes ---------------------------------------------------- EXIT_OK = 0 EXIT_ERROR = 1 EXIT_COM_ERROR = 2 EXIT_SWITCH_ERROR = 3 # Default Config ------------------------------------------------------------- CFG_DEFAULT_SECTION = 'rcoutlets' DEFAULT_CONFIG = { 'baudrate': '9600', 'port': '/dev/ttyACM0', 'timeout': '1.0', 'groups': 'A B C D', 'handshake_timeout': '5', 'pidfile': '/var/run/rcoutlets/rcoutlets.pid', 'use_tcp': '1', 'tcp_port': '5656', 'tcp_host': '127.0.0.1', } def _cfg_bool(strvalue): if not strvalue: return False elif strvalue.lower() in ('false', 'no', '0'): return False else: return True CONFIG_TYPES = { 'baudrate': int, 'timeout': float, 'handshake_timeout': float, 'tcp_port': int, 'use_tcp': _cfg_bool, } # Communicaton over Serial and TCP ------------------------------------------- HANDSHAKE_REQUEST = '\5'.encode('ascii') # ascii ENQ HANDSHAKE_RESPONSE = '\6'.encode('ascii') # ascii ACK STATE_ON = '1' STATE_OFF = '0' # Exceptions ----------------------------------------------------------------- class ComError(Exception): pass class UnknownSwitchError(Exception): pass # Command line app ----------------------------------------------------------- def main(argv=None): if argv is None: argv = sys.argv[1:] parser = _setup_parser() args = parser.parse_args(argv) config_path = os.path.expanduser('~/.config/rcoutlets.conf') cfg = _read_config(config_path) options = _merge_options(cfg, args) try: options.func(options, cfg) except (ComError, SerialException) as e: print('Communications Error: {}'.format(e)) return EXIT_COM_ERROR except UnknownSwitchError as e: print('Unknown switch: {}'.format(e)) return EXIT_SWITCH_ERROR except Exception as e: print('General error: {}'.format(e)) log.exception(e) return EXIT_ERROR else: return EXIT_OK def _client(options, cfg): state = True if options.command == 'on' else False if options.use_tcp: log.info('Using TCP {o.tcp_host}:{o.tcp_port}'.format(o=options)) com_adapter = TCPCom( options.tcp_host, options.tcp_port, ) else: log.info('Using serial {o.port}@{o.baudrate}'.format(o=options)) com_adapter = SerialCom( options.port, options.baudrate, options.timeout, options.handshake_timeout ) with RCOutlets(com_adapter, options, cfg) as outlets: for switch in outlets.mget(options.name): switch.set(state) def _server(options, unused_cfg): if options.action == 'start': com_adapter = SerialCom( options.port, options.baudrate, options.timeout, options.handshake_timeout ) log.info('Starting TCP server on port {}.'.format(options.tcp_port)) server = TCPServer(options.tcp_port, com_adapter) if options.no_daemon: server.serve_forever() else: start_daemon(server.serve_forever, options.pidfile) elif options.action == 'stop': stop_daemon(options.pidfile) log.info('TCP server stopped.') else: raise ValueError('Unknown server action {!r}'.format(options.action)) def _merge_options(cfg, args): '''Merge Config-settings and command line options. Return a new Namespace object which combines both sources. ''' opts = argparse.Namespace() no_conversion = lambda x: x for name in cfg.options(CFG_DEFAULT_SECTION): value = cfg.get(CFG_DEFAULT_SECTION, name) convert = CONFIG_TYPES.get(name, no_conversion) setattr(opts, name, convert(value)) log.debug('{}: {} ({})'.format(name, getattr(opts, name), value)) for name, value in vars(args).items(): if value is not None or name not in vars(opts): setattr(opts, name, value) return opts def _setup_parser(): parser = argparse.ArgumentParser( prog='rcoutlets', description=('Send commands to an Arduino' ' to control RC power outlets.' ' Requires that the Arduino is connected via serial' ' and runs a compatible programm.' ) ) subs = parser.add_subparsers() common = argparse.ArgumentParser(add_help=False) common.add_argument( '-p', '--port', help=('Identifier of the serial port' ' on which the Arduino is connected.'), ) common.add_argument( '--tcp-port', type=int, help=('Set the TCP port for server or client.') ) client = subs.add_parser( 'client', parents=[common,], help='Control the RCOutlets.' ) client.add_argument( 'name', nargs='?', help=('Name or identifier of the switch to control.' ' Valid names are A1-D3.' ' Can also be the name of an alias that is defined' ' in the configuration file.' ' Wildcards ("*") are allowed.' ), ) client.add_argument( 'command', nargs='?', choices=('on', 'off'), default='on', help=('The command that should be send to the switch(es).' ' Defaults to "on" if none is specified.'), ) client.set_defaults(func=_client) server = subs.add_parser( 'server', parents=[common,], help='Control the TCP server.' ) server.add_argument( 'action', choices=('start', 'stop'), help=('Start or stop the TCP server.'), ) server.add_argument( '--no-daemon', action='store_true', help=('Keep in foreground, do not fork.'), ) server.set_defaults(func=_server) return parser def _read_config(path): cfg = configparser.ConfigParser() s = CFG_DEFAULT_SECTION cfg.add_section(s) for key, value in DEFAULT_CONFIG.items(): cfg.set(s, key, value) cfg.read(path) return cfg # Daemon --------------------------------------------------------------------- def start_daemon(daemon_func, pidfile, stdin=None, stdout=None, stderr=None): '''Run the given callable in a daemon process, detached from the calling process. :param callable daemon_func: A function or any other *callable* that is the implementation for the daemon. The ``daemon_func`` is called once inside the daemon process and the daemon will terminate when the function returns. :param str pidfile: Where to look for this daemons ``pidfile``. The PID of the daemon process is written to that file and the file is used to determine whether an instance of this daemon is already running. :param str stdin: Where to redirect the daemons ``stdin``. Defaults to ``/dev/null``. :param str stdout: Where to redirect the daemons ``stdout``. Defaults to ``/dev/null``. :param str stderr: Where to redirect the daemons ``stderr``. Defaults to ``/dev/null``. :raises: ValueError if a ``pidfile`` already exists and indicates that there is already a running instance of this daemon. :raises: IOError if the ``pidfile`` is not writeable. ''' stdin = stdin or '/dev/null' stdout = stdout or '/dev/null' stderr = stderr or '/dev/null' # check if an instance is already running # if so, exit with error existing_pid = _read_pidfile(pidfile) if existing_pid: raise ValueError('Found existing pidfile with pid {}. Daemon already running?'.format(existing_pid)) # first fork try: pid = os.fork() if pid > 0: sys.exit(0) # exit parent process except OSError as e: print('fork failed: [{}] {!r}'.format(e.errno, e.strerror)) sys.exit(1) # decouple from parent env os.chdir('/') os.setsid() os.umask(0) # second fork try: pid = os.fork() if pid > 0: sys.exit(0) # exit 2d parent except OSError as e: print('fork failed: [{}] {!r}'.format(e.errno, e.strerror)) sys.exit(1) # create the pidfile pid = str(os.getpid()) try: with open(pidfile, 'w+') as f: f.write('{}\n'.format(pid)) except IOError as e: print('Failed to write pidfile at: {!r}'.format(pidfile)) raise print('forked to {}, pidfile is {!r}'.format(pid, pidfile)) # remove pidfile when complete def delpid(): os.remove(pidfile) atexit.register(delpid) # redirect output sys.stdout.flush() sys.stderr.flush() si = open(stdin, 'r') so = open(stdout, 'a+') se = open(stderr, 'a+') os.dup2(si.fileno(), sys.stdin.fileno()) os.dup2(so.fileno(), sys.stdout.fileno()) os.dup2(se.fileno(), sys.stderr.fileno()) try: daemon_func() sys.exit(0) except Exception: sys.exit(1) def stop_daemon(pidfile): '''Stop the daemon process. Attempts to kill the process indicated by ``pidfile``. Does nothing if the pidfile is empty or does not exist (i.e. if we can assume that there is no daemon running). :param str pidfile: The path to the pidfile :raises: ValueError is raised if we fail to kill the daemon process. ''' pid = _read_pidfile(pidfile) if not pid: print('pidfile does not exist or is empty. Daemon not running?') return else: try: while True: os.kill(pid, SIGTERM) except OSError as e: errorstr = str(e) if errorstr.find('No such process') > 0: # clean up if os.path.exists(pidfile): os.remove(pidfile) else: raise ValueError('Failed to kill daemon process: {!r}'.format(errorstr)) def _read_pidfile(pidfile_path): try: with open(pidfile_path) as fp: pid = int(fp.read().strip()) except IOError: pid = None return pid # Models --------------------------------------------------------------------- class RCOutlets(object): '''Communicates with the Arduino via a serial connection or communicates over TCP with the Server. Either use the `send_command` method to control the outlets or get references to a individual :class:`Switch` with :method:`get` or :method:`mget`. RCOutlets can be used as a context manager like this: .. code:: python with RCOutlets() as outlets: outlets.send_command('A10') # is equivalent to: outlets = RCOutlets() try: outlets.begin() outlets.send_command('A10') finally: outlets.end() Whether an immediate serial connection or the TCP-relay is used is determined by passing the respective ``com_adapter``. ''' def __init__(self, com_adapter, options, cfg): self._switches = {} used_groups = options.groups.split() for group in used_groups: for number in range(1, 4): key = '{}{}'.format(group, number) self._switches[key] = Switch(group, number, self) self.alias = {} try: for aliasname in cfg.options('alias'): switchkeys = cfg.get('alias', aliasname).split() self.alias[aliasname] = [self._switches[key] for key in switchkeys] except configparser.NoSectionError: pass # no sections defined self._com = com_adapter # patterns for mget() self._number_wildcard = re.compile('^[A-D]\*$') self._group_wildcard = re.compile('^\*[1-3]$') self._switch_name = re.compile('^[A-D][1-3]$') def __enter__(self): self.begin() return self def __exit__(self, exc_type, exc_val, exc_tb): suppress_exc = False self.end() return suppress_exc def begin(self): '''Open the Serial connection and wait for the Arduino to be ready. After this method exits, commands can be send. Raises an error if no connection can be established within `handshake_timeout` seconds. This method does not have to be called when using RCOutlets as the *context manager* in a ``with`` statement. ''' self._com.open() def end(self): '''Close the connection to the Arduino. No more commands can be send after this method was called, unless `begin` is called. This method does not have to be called when using RCOutlets as the *context manager* in a ``with`` statement. ''' self._com.close() def send_command(self, cmd): self._com.send(cmd) def get(self, name): '''Get a single :class:`Switch` by its name.''' try: return self._switches[name] except KeyError: raise UnknownSwitchError( 'No switch with name {!r}.'.format(name)) def mget(self, expr): '''Get a list of switches, whose names match ``expr``. ``expr`` is a string that contains one or more switch identifiers (A1-D3) and/or alias names separated by whitespace. The ``expr`` argument can contain a "*" wildcard which matches as follows: A* ... D* matches all switches in a group *1 ... *3 matches switch n in all groups any* matches groups that start with "any" *thing matches groups ending in "thing" *text* matches groups starting with, ending in or containing "text" For example:: "A* dining" -> A1, A2, A3 + members of alias "dining" "A1 B2 D2" -> these three switches "B* *2" -> B1, B2, B3 and A2, B2, C2, D2 "*" -> Everything. ''' # helper, finds and yield switches by a single pattern def _find(searchstr): if searchstr == '*': for switch in self._switches.values(): yield switch elif self._number_wildcard.match(searchstr): # e.g. A* group = searchstr[0] for number in range(1,3): yield self.get('{}{}'.format(group, number)) elif self._group_wildcard.match(searchstr): # e.g. *1 number = searchstr[1] for group in ('A', 'B', 'C', 'D'): yield self.get('{}{}'.format(group, number)) else: try: # exact switch name? yield self._switches[searchstr] except KeyError: try: # excat alias? for switch in self.alias[searchstr]: yield switch except KeyError: # alias by pattern? pattern = '^{}$'.format( searchstr.replace('*', '[a-zA-Z0-9]*?')) regex = re.compile(pattern) for alias, switches in self.alias.items(): if regex.match(alias): for switch in switches: yield switch # helper, splits expr to separate patterns and _finds() them all def _generate_list(): for part in expr.split(): for switch in _find(part): yield switch return [switch for switch in _generate_list()] class Switch(object): _COMMAND_TEMPLATE = '{s.group}{s.number}{state}' def __init__(self, group, number, control): self.group = group self.number = number self._control = control def _send_command(self, state): log.info('Switch {}{} {}.'.format( self.group, self.number, 'ON' if state else 'OFF' )) state_command = STATE_ON if state else STATE_OFF cmd = Switch._COMMAND_TEMPLATE.format(s=self, state=state_command) self._control.send_command(cmd) def on(self): self._send_command(True) def off(self): self._send_command(False) def set(self, state): self._send_command(bool(state)) class SerialCom(object): '''Sends commands over the serial port. Can be used by RCOutlets to communicate directly over serial or by the TCPServer to relay commands received via TCP to the serial port. ''' def __init__(self, port, baudrate, timeout, handshake_timeout): self._ser = serial.Serial() self._ser.port = port self._ser.baudrate = baudrate self._ser.timeout = timeout self.handshake_timeout = handshake_timeout def open(self): '''Open the Serial connection and wait for the Arduino to be ready. After this method exits, commands can be send. Raises an error if no connection can be established within `handshake_timeout` seconds. This method does not have to be called when using RCOutlets as the *context manager* in a ``with`` statement. ''' log.info('Open connection over serial.') self._ser.open() log.info('Waiting for handshake...') self._handshake() log.info('Connection established.') def _handshake(self): '''Arduino will reset when a serial connection is opened. Wait for it to come up. ''' handshake_done = False started = time.time() while not handshake_done: self._ser.write(HANDSHAKE_REQUEST) response = self._ser.read(1) handshake_done = response == HANDSHAKE_RESPONSE time.sleep(0.01) time_passed = time.time() - started if time_passed > self.handshake_timeout: raise ComError(( 'Handshake timed out' ' after {} seconds.' ).format(int(time_passed))) def close(self): '''Close the connection to the Arduino. No more commands can be send after this method was called, unless `begin` is called. This method does not have to be called when using RCOutlets as the *context manager* in a ``with`` statement. ''' self._ser.close() def send(self, cmd): '''Send the given ``cmd`` string. the string will be ascii encoded. :raises: ComError if the connection fails. ''' clean_cmd = cmd.strip() ascii_cmd = '{}\n'.format(clean_cmd).encode('ascii') log.info('Send command [serial]: {!r}'.format(ascii_cmd)) try: self._ser.write(ascii_cmd) except ValueError as e: # port not open raise ComError(str(e)) # TCP Server and Client ------------------------------------------------------ class TCPServer(): '''A TCP server which forwards commands to the serial port. The server holds a single serial connection and forwards each command it received 1:1 to that connection. Commands are simple strings, terminated by newlines. :var int port: The port on which the server should listen. :var object com_adapter: The receiver for relayed commands. Should be an instance of :class:`SerialCom`. ''' def __init__(self, port, com_adapter): host = '' self.address = (host, port) self.bufsize = 16 self._com = com_adapter def serve_forever(self): self._com.open() srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((self.address)) max_connections = 1 srv.listen(max_connections) print('Started TCP Server at address {}'.format(self.address)) while True: # wait for incoming connection connection, client_address = srv.accept() log.info('Connected to {}'.format(client_address)) try: self.forward(connection) finally: connection.close() if self._com: self._com.close() srv.close() def forward(self, connection): received = '' while True: parts = connection.recv(self.bufsize).decode('ascii') if parts: log.info('Received [TCP]: {!r}'.format(parts)) received += parts else: # no more incoming data break for cmd in received.splitlines(): self._com.send(cmd) class TCPCom(object): '''Can acts as a ``com_adapter`` for RCOutlets to send commands over TCP instead of the serial port. Intended as a client for the TCPServer.''' def __init__(self, host, port): self.host = host self.port = port self.address = (self.host, self.port) self.timeout = 5.0 # seconds self.cli = None def open(self): self.cli = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.cli.settimeout(self.timeout) self.cli.connect((self.address)) def close(self): self.cli.close() def send(self, cmd): # catch timeouts? clean_cmd = cmd.strip() log.info('Send command [TCP]: {!r}'.format(clean_cmd)) self.cli.sendall('{}\n'.format(clean_cmd).encode('ascii')) if __name__ == '__main__': sys.exit(main()) |