├── .gitignore ├── .gitmodules ├── .pylintrc ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── bin ├── appenginetest ├── lint └── pre-commit ├── docs ├── Aeonstick2.pdf ├── Fibaro Motion-Sensor_EN_5.3.14.pdf ├── TKB_TZ67-E.pdf ├── aeotec_multisensor_tech.pdf ├── hue_example.json ├── netatmo_api.json ├── screenshot.png └── z_wave_vision_motion_detector_manual.pdf ├── experimental ├── db │ └── remove_property.py ├── names │ ├── name.py │ ├── names │ └── tlds.txt └── speed │ └── speed.sh ├── src ├── app.yaml ├── appengine │ ├── __init__.py │ ├── account.py │ ├── auth_tests.py │ ├── building.py │ ├── device.py │ ├── devices │ │ ├── __init__.py │ │ ├── hue.py │ │ ├── insteon.py │ │ ├── nest.py │ │ ├── netatmo.py │ │ ├── network.py │ │ ├── rfswitch.py │ │ ├── sonos.py │ │ ├── wemo.py │ │ ├── zwave.py │ │ └── zwave_drivers │ │ │ ├── __init__.py │ │ │ ├── aeon.py │ │ │ ├── fibaro.py │ │ │ ├── tkb.py │ │ │ └── vision.py │ ├── driver.py │ ├── history.py │ ├── main.py │ ├── model.py │ ├── pusher_client.py │ ├── pushrpc.py │ ├── rest.py │ ├── room.py │ ├── tasks.py │ └── user.py ├── common │ ├── __init__.py │ ├── creds.example │ ├── detector.py │ ├── detector_tests.py │ ├── public_creds.py │ ├── utils.py │ └── utils_tests.py ├── cron.yaml ├── iphone │ └── Awesomation │ │ ├── Awesomation.xcodeproj │ │ ├── project.pbxproj │ │ └── project.xcworkspace │ │ │ └── contents.xcworkspacedata │ │ ├── Awesomation │ │ ├── AppDelegate.swift │ │ ├── Awesomation-Bridging-Header.h │ │ ├── Awesomation.swift │ │ ├── Base.lproj │ │ │ ├── LaunchScreen.xib │ │ │ └── Main.storyboard │ │ ├── Images.xcassets │ │ │ └── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ ├── Info.plist │ │ └── ViewController.swift │ │ └── AwesomationTests │ │ ├── AwesomationTests.swift │ │ └── Info.plist ├── pi │ ├── __init__.py │ ├── control.py │ ├── daemon.py │ ├── events.py │ ├── hue.py │ ├── network.py │ ├── proxy.py │ ├── pushrpc.py │ ├── rfswitch.py │ ├── scanning_proxy.py │ ├── simple_pusher.py │ ├── sonos.py │ ├── wemo.py │ └── zwave.py └── static │ ├── css │ ├── history.css │ └── screen.css │ ├── imgs │ ├── favicon.png │ └── netatmo.png │ ├── index.html │ └── js │ └── app.js └── third_party ├── py ├── edimax │ └── smartplug.py └── websocket-client │ ├── .gitignore │ ├── LICENSE │ ├── MANIFEST.in │ ├── README.rst │ ├── bin │ └── wsdump.py │ ├── data │ ├── header01.txt │ └── header02.txt │ ├── examples │ ├── echo_client.py │ └── echoapp_client.py │ ├── setup.py │ ├── test_websocket.py │ └── websocket.py └── static ├── handlebars └── handlebars-v2.0.0.js ├── jquery └── jquery-2.1.1.js └── sprintf.js ├── LICENSE ├── README.md ├── package.json ├── src ├── sprintf.js └── sprintf.min.js └── test └── test.html /.gitignore: -------------------------------------------------------------------------------- 1 | .hg 2 | dist/ 3 | *~ 4 | *.pyc 5 | *.o 6 | ._* 7 | \#* 8 | src/common/creds 9 | src/common/creds.py 10 | third_party/static/glyphicons_pro 11 | *.orig 12 | *.rej 13 | *.log 14 | *.cfg 15 | 16 | *.xccheckout 17 | *.pbxuser 18 | *.perspective 19 | *.perspectivev3 20 | *.swp 21 | *~.nib 22 | *.mode1v3 23 | *.mode2v3 24 | xcuserdata 25 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "third_party/py/PyAPNs"] 2 | path = third_party/py/PyAPNs 3 | url = https://github.com/djacobs/PyAPNs.git 4 | [submodule "third_party/py/werkzeug"] 5 | path = third_party/py/werkzeug 6 | url = https://github.com/mitsuhiko/werkzeug.git 7 | [submodule "third_party/py/flask"] 8 | path = third_party/py/flask 9 | url = https://github.com/mitsuhiko/flask.git 10 | [submodule "third_party/py/itsdangerous"] 11 | path = third_party/py/itsdangerous 12 | url = https://github.com/mitsuhiko/itsdangerous.git 13 | [submodule "third_party/py/jinja2"] 14 | path = third_party/py/jinja2 15 | url = https://github.com/mitsuhiko/jinja2.git 16 | [submodule "third_party/py/markupsafe"] 17 | path = third_party/py/markupsafe 18 | url = https://github.com/mitsuhiko/markupsafe.git 19 | [submodule "third_party/py/click"] 20 | path = third_party/py/click 21 | url = https://github.com/mitsuhiko/click.git 22 | [submodule "third_party/rcswitch-pi"] 23 | path = third_party/rcswitch-pi 24 | url = https://github.com/tomwilkie/rcswitch-pi.git 25 | [submodule "third_party/open-zwave"] 26 | path = third_party/open-zwave 27 | url = https://github.com/tomwilkie/open-zwave.git 28 | [submodule "third_party/python-openzwave"] 29 | path = third_party/python-openzwave 30 | url = https://github.com/tomwilkie/python-openzwave.git 31 | [submodule "third_party/py/phue"] 32 | path = third_party/py/phue 33 | url = https://github.com/studioimaginaire/phue.git 34 | [submodule "third_party/wiringPi"] 35 | path = third_party/wiringPi 36 | url = https://github.com/tomwilkie/wiringPi 37 | [submodule "third_party/fswatch"] 38 | path = third_party/fswatch 39 | url = https://github.com/alandipert/fswatch.git 40 | [submodule "third_party/py/PythonPusherClient"] 41 | path = third_party/py/PythonPusherClient 42 | url = https://github.com/tomwilkie/PythonPusherClient.git 43 | [submodule "third_party/py/pusher_client_python"] 44 | path = third_party/py/pusher_client_python 45 | url = https://github.com/pusher/pusher_client_python.git 46 | [submodule "third_party/py/requests"] 47 | path = third_party/py/requests 48 | url = https://github.com/kennethreitz/requests.git 49 | [submodule "third_party/static/bootstrap"] 50 | path = third_party/static/bootstrap 51 | url = https://github.com/twbs/bootstrap.git 52 | [submodule "third_party/py/pywemo"] 53 | path = third_party/py/pywemo 54 | url = https://github.com/tomwilkie/pywemo.git 55 | [submodule "third_party/py/pyping"] 56 | path = third_party/py/pyping 57 | url = https://github.com/tomwilkie/pyping/ 58 | [submodule "third_party/static/jquery-bbq"] 59 | path = third_party/static/jquery-bbq 60 | url = https://github.com/cowboy/jquery-bbq 61 | [submodule "third_party/static/moment"] 62 | path = third_party/static/moment 63 | url = https://github.com/moment/moment.git 64 | [submodule "third_party/py/boto"] 65 | path = third_party/py/boto 66 | url = https://github.com/boto/boto.git 67 | [submodule "third_party/py/ipaddr"] 68 | path = third_party/py/ipaddr 69 | url = https://github.com/tomwilkie/ipaddr-py.git 70 | [submodule "third_party/py/SoCo"] 71 | path = third_party/py/SoCo 72 | url = https://github.com/SoCo/SoCo.git 73 | [submodule "third_party/py/SimpleWebSocketServer"] 74 | path = third_party/py/SimpleWebSocketServer 75 | url = https://github.com/opiate/SimpleWebSocketServer.git 76 | [submodule "third_party/gtm-oauth2"] 77 | path = third_party/gtm-oauth2 78 | url = https://github.com/tomwilkie/gtm-oauth2.git 79 | [submodule "third_party/gtm-http-fetcher"] 80 | path = third_party/gtm-http-fetcher 81 | url = https://github.com/tomwilkie/gtm-http-fetcher 82 | [submodule "third_party/AFNetworking"] 83 | path = third_party/AFNetworking 84 | url = https://github.com/AFNetworking/AFNetworking.git 85 | [submodule "third_party/static/d3"] 86 | path = third_party/static/d3 87 | url = https://github.com/mbostock/d3.git 88 | [submodule "third_party/static/pako"] 89 | path = third_party/static/pako 90 | url = https://github.com/nodeca/pako.git 91 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | 5 | # This can run in containers 6 | sudo: false 7 | 8 | # command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors 9 | install: 10 | - wget https://storage.googleapis.com/appengine-sdks/featured/google_appengine_1.9.18.zip 11 | - unzip -q google_appengine_1.9.18.zip 12 | - make dist 13 | 14 | # command to run tests, e.g. python setup.py test 15 | script: make test APPENGINE=google_appengine 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2014 Tom Wilkie 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | dist/%.py: src/%.py 2 | @mkdir -p $(@D) 3 | ln -f $< $@ 4 | 5 | dist/%.yaml: src/%.yaml 6 | @mkdir -p $(@D) 7 | cp $< $@ 8 | 9 | clean: 10 | rm -rf dist 11 | 12 | # rcswitch build instructions & other native code. 13 | 14 | CXXFLAGS=-O2 -fPIC 15 | 16 | dist/rcswitch_wrap.cxx: third_party/rcswitch-pi/RCSwitch.h third_party/rcswitch-pi/rcswitch.i 17 | @mkdir -p $(@D) 18 | swig -c++ -python -o $@ third_party/rcswitch-pi/rcswitch.i 19 | 20 | dist/rcswitch_wrap.o: dist/rcswitch_wrap.cxx 21 | $(CXX) $(CXXFLAGS) -c $+ -o $@ -I/usr/include/python2.7 -Ithird_party/rcswitch-pi 22 | 23 | dist/RCSwitch.o: third_party/rcswitch-pi/RCSwitch.cpp 24 | @mkdir -p $(@D) 25 | $(CXX) $(CXXFLAGS) -c $+ -o $@ 26 | 27 | dist/_rcswitch.so: dist/rcswitch_wrap.o dist/RCSwitch.o 28 | $(CXX) -shared $(LDFLAGS) $+ -o $@ -lwiringPi 29 | 30 | dist/fswatch/fswatch: third_party/fswatch/fswatch.o 31 | @mkdir -p $(@D) 32 | gcc -framework CoreServices -o $@ $< 33 | 34 | live: dist/fswatch/fswatch 35 | dist/fswatch/fswatch . 'make dist' 36 | 37 | # third party python 38 | define THIRD_PARTY_py_template 39 | dist/third_party/%.py: third_party/py/$(1)/%.py 40 | @mkdir -p $$(@D) 41 | cp $$< $$@ 42 | 43 | dist/third_party/%.json: third_party/py/$(1)/%.json 44 | @mkdir -p $$(@D) 45 | cp $$< $$@ 46 | 47 | dist/third_party/%.pem: third_party/py/$(1)/%.pem 48 | @mkdir -p $$(@D) 49 | cp $$< $$@ 50 | endef 51 | 52 | third_party_py := $(shell find third_party/py/* -maxdepth 0 -type d | sed 's,^[^/]*/[^/]*/,,' | tr "\\n" " ") 53 | $(foreach dir,$(third_party_py),$(eval $(call THIRD_PARTY_py_template,$(dir)))) 54 | 55 | third_party_pyfiles := $(shell find third_party/py -name *.py -o -name *.json -o -name *.pem\ 56 | | egrep -v "example|doc|setup|testsuite" \ 57 | | sed 's,^third_party/[^/]*/[^/]*/,,' \ 58 | | egrep -v "^__init__.py" | tr "\\n" " ") 59 | third_party_pyfiles := $(patsubst %,dist/third_party/%,$(third_party_pyfiles)) 60 | 61 | # Static targets 62 | dist/static/%: src/static/% 63 | @mkdir -p $(@D) 64 | ln -f $< $@ 65 | 66 | dist/static/js/jquery.js: third_party/static/jquery/jquery-2.1.1.js 67 | @mkdir -p $(@D) 68 | cp $< $@ 69 | 70 | dist/static/js/handlebars.js: third_party/static/handlebars/handlebars-v2.0.0.js 71 | @mkdir -p $(@D) 72 | cp $< $@ 73 | 74 | # final actual targets 75 | py_files := $(patsubst src/%,dist/%,$(shell find src -name *.py)) 76 | static_files = $(patsubst src/static/%,dist/static/%,$(shell find src/static -type f)) 77 | static_files := $(static_files) $(patsubst %,dist/static/%,js/jquery.js js/handlebars.js) 78 | 79 | define INCLUDE_STATIC_SUBDIR 80 | static_files := $(static_files) $(patsubst $(1)/%,dist/static/$(2)/%,$(shell find $(1) -type f)) 81 | dist/static/$(2)/%: $(1)/% 82 | @mkdir -p $$(@D) 83 | cp $$< $$@ 84 | endef 85 | 86 | define INCLUDE_STATIC_FILE 87 | static_files := $(static_files) dist/static/$(2)/$(shell basename $(1)) 88 | dist/static/$(2)/$(shell basename $(1)): $(1) 89 | @mkdir -p $$(@D) 90 | cp $$< $$@ 91 | endef 92 | 93 | $(eval $(call INCLUDE_STATIC_SUBDIR,third_party/static/bootstrap/dist/css,css)) 94 | $(eval $(call INCLUDE_STATIC_SUBDIR,third_party/static/bootstrap/dist/js,js)) 95 | $(eval $(call INCLUDE_STATIC_SUBDIR,third_party/static/bootstrap/dist/fonts,fonts)) 96 | $(eval $(call INCLUDE_STATIC_SUBDIR,third_party/static/glyphicons_pro/glyphicons/web/bootstrap_example/css,css)) 97 | $(eval $(call INCLUDE_STATIC_SUBDIR,third_party/static/glyphicons_pro/glyphicons/web/bootstrap_example/fonts,fonts)) 98 | $(eval $(call INCLUDE_STATIC_FILE,third_party/static/moment/moment.js,js)) 99 | $(eval $(call INCLUDE_STATIC_FILE,third_party/static/d3/d3.js,js)) 100 | $(eval $(call INCLUDE_STATIC_FILE,third_party/static/sprintf.js/src/sprintf.js,js)) 101 | $(eval $(call INCLUDE_STATIC_FILE,third_party/static/jquery-bbq/jquery.ba-bbq.js,js)) 102 | $(eval $(call INCLUDE_STATIC_FILE,third_party/static/pako/dist/pako_inflate.js,js)) 103 | 104 | dist/static: $(static_files) 105 | 106 | dist: dist/app.yaml dist/cron.yaml $(py_files) $(third_party_pyfiles) dist/static 107 | 108 | upload-prod: dist 109 | appcfg.py --oauth2 update dist 110 | 111 | upload-dev: dist 112 | appcfg.py --oauth2 --application=awesomation-dev update dist 113 | 114 | devapp: dist 115 | PYTHONPATH=${PYTHONPATH}:./dist:./dist/third_party dev_appserver.py --use_mtime_file_watcher=true --host=0.0.0.0 dist/app.yaml 116 | 117 | pusher: dist 118 | PYTHONPATH=${PYTHONPATH}:./dist:./dist/third_party python dist/pi/simple_pusher.py 119 | 120 | runpi: dist 121 | sudo PYTHONPATH=$${PYTHONPATH}:dist:dist/third_party python dist/pi/control.py --nodaemonize restart 122 | 123 | runonpi: dist 124 | rsync -arvz dist/ pi@domicspi.local:~/dist/ 125 | ssh -t pi@domicspi.local 'sudo PYTHONPATH=$${PYTHONPATH}:~/dist:~/dist/third_party python ~/dist/pi/control.py --nodaemonize restart' 126 | 127 | APPENGINE=/Applications/GoogleAppEngineLauncher.app/Contents/Resources/GoogleAppEngine-default.bundle/Contents/Resources/google_appengine/ 128 | 129 | test: dist 130 | PYTHONPATH=$${PYTHONPATH}:dist:dist/third_party bin/appenginetest $(APPENGINE) dist 131 | 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # "Home Awesomation" 2 | 3 | [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/tomwilkie/awesomation?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Build Status](https://travis-ci.org/tomwilkie/awesomation.svg?branch=master)](https://travis-ci.org/tomwilkie/awesomation) [![Blog](https://img.shields.io/badge/awesomation-blog-green.svg)](http://homeawesomation.blogspot.co.uk/) 4 | 5 | A Python 2 based, multi-user home automation system. 6 | 7 | ![Screenshot](/docs/screenshot.png?raw=true) 8 | 9 | Currently supports the following devices: 10 | * ZWave 11 | * [Aeon Labs Z Stick S2](docs/Aeonstick2.pdf) 12 | * [Aeon Labs Multisensor](docs/aeotec_multisensor_tech.pdf) 13 | * [TKB TZ67-E Wall Plug Dimmer](docs/TKB_TZ67-E.pdf) 14 | * [Fibaro Motion Sensor](docs/Fibaro Motion-Sensor_EN_5.3.14.pdf) 15 | * [Vision Motion Sensor](docs/z_wave_vision_motion_detector_manual.pdf) 16 | * Local Wifi 17 | * Philips Hue lights 18 | * Belkin Wemo switches + motion sensors 19 | * Arbitrary wifi devices (ie your phone, for presence) 20 | * Sonos devices (basic detection right now) 21 | * Edimax mains switch (planned) 22 | * 433Mhz 23 | * RF Switches (testing with [Brennenstuhl remote control mains sockets](http://www.amazon.co.uk/dp/B003BIFLSY)) 24 | * Internet / OAuth 25 | * Nest Thermostats and Protects 26 | * Netatmo weather stations 27 | * Insteon hub & devices ([using their OAuth2 api](http://docs.insteon.apiary.io/)) 28 | 29 | 30 | ### Features 31 | 32 | Awesomation houses can be shared with other Google accounts, increasing WAF. Use the 33 | 'Configuring sharing...' option on the top right menu to invite others to your house. 34 | 35 | Awesomation supports OAuth 2.0 authentication, allowing other app to access and control 36 | you devices (with your permission!). This is used by the prototype iOS app. 37 | 38 | Awesomation currently implements the following behaviours: 39 | * If motion is sensed in a room, the lights are turned on. 40 | * If your phone is detected on the network, Nest is set to home; otherwise, set to away. 41 | * Lights can be automatically dimmed into the evening. 42 | * A learning algorithm is used to compensate for unreliable or insensitive motion sensors. 43 | 44 | Planned features: 45 | * If motion is detected and your phone isn't on the network, an alert is sent. 46 | 47 | ### Getting Started 48 | 49 | You can run the 'local' proxy code on any unix machine (tested on Mac OS X and Debian), and if you don't have any zwave or 433Mhz devices, its as easy as: 50 | 51 | sudo pip install netifaces 52 | 53 | git clone https://github.com/tomwilkie/awesomation.git 54 | cd awesomation 55 | git submodule init; git submodule update 56 | 57 | make runpi 58 | 59 | If you want to use 433Mhz or zwave devices, you'll need is a Raspberry Pi running [Raspbian](http://www.raspberrypi.org/downloads/) - I used the 2014-12-24 build. On the Pi, run: 60 | 61 | sudo apt-get install libudev-dev cython python-dev swig python-setuptools 62 | sudo easy_install pip 63 | sudo pip install netifaces 64 | 65 | git clone https://github.com/tomwilkie/awesomation.git 66 | cd awesomation 67 | git submodule init; git submodule update 68 | cd third_party/open-zwave; make; sudo make install 69 | cd third_party/python-openzwave; python setup-lib.py build; sudo python setup-lib.py install 70 | cd third_party/wiringPi/wiringPi; make; sudo make install 71 | cd third_party/rcswitch-pi; make; make install 72 | 73 | make runpi 74 | 75 | The proxy code will print out something like this: 76 | 77 | 2015-01-09 14:20:19,104 INFO pushrpc.py:40 - I am proxy '614d1ad1b9f446418db40791f5c5ec3f' 78 | 79 | Then, go to http://homeawesomation.appspot.com - you will be asked to login with your Google 80 | credentials, and this will automatically create an account for you. 81 | 82 | In the top left of 'Rooms and Devices' page, select 'Add new device' from the dropdown in the 83 | top right. Select 'Awesomation Proxy' enter the Proxy ID from above. This will associate 84 | your proxy with your account. 85 | 86 | Then, add other devices, rooms etc. Also, you might want to run the proxy in a screen 87 | session, just in case the ssh connection drops. Have fun! 88 | 89 | NB if you're following on, make sure you run `git submodule sync` after a pull - we 90 | regularly change submodule origins, and just running `git submodule update` will miss this. 91 | 92 | ### Run your own server instance 93 | 94 | This step is totally optional - you are more than welcome to use my hosted code as http://homeawesomation.appspot.com. If you wish to develop the server-side code, you'll need to host your own server instance. To do this: 95 | 96 | * Sign up for an App Engine account, Pusher account, Netatmo developer account, Nest deveoper account and AWS account. 97 | * Make a copy of src/common/creds.example as src/common/creds.py and put you AWS, Pusher, Netatmo and Nest credentials in there. 98 | * Edit src/app.yaml and common/public_creds.py for the name of your app engine app and Pusher id. 99 | 100 | 101 | ### Architecture 102 | 103 | The 'architecture' is client-server, with a Raspberry Pi based proxy running in the home and the 'logic' running in the Cloud (on Google App Engine). This allows the cloud app to access local wifi, zwave and 433Mhz devices. 104 | 105 | The client/server model was chosen as I wanted to integrate with internet enabled apis/devices, like the Nest and Netatmo. These APIs require pre-registered OAuth callbacks, and as far as I can tell, cannot be made to work if the callback address is different for different users. 106 | 107 | The server-side logic has the concept of rooms, and when motion is sensed in a room the lights in that room are turned on. 108 | 109 | [Pusher](https://pusher.com/) is used to send commands from the server app to the client app. Credentials for the Pusher account are stored in a private subrepo; you will need to setup your own. 110 | 111 | The server runs on Google App Engine; this seems to work well enough for these purposes. The client (sometimes referred to as the proxy) runs on a Raspberry Pi in the home; an Aeon Labs Z Stick, a simple 433Mhz transmitter and a Wifi stick are connected to a USB hub, which in turn is connected to the Pi. 112 | 113 | -------------------------------------------------------------------------------- /bin/appenginetest: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | """A test running which brings appengine sdk into the path.""" 3 | 4 | import optparse 5 | import sys 6 | import unittest 7 | import logging 8 | 9 | USAGE = """%prog SDK_PATH TEST_PATH 10 | Run unit tests for App Engine apps. 11 | 12 | SDK_PATH Path to the SDK installation 13 | TEST_PATH Path to package containing test modules""" 14 | 15 | LOGFMT = '%(asctime)s %(levelname)s %(filename)s:%(lineno)d - %(message)s' 16 | 17 | 18 | def main(sdk_path, test_path): 19 | sys.path.insert(0, sdk_path) 20 | import dev_appserver 21 | dev_appserver.fix_sys_path() 22 | suite = unittest.loader.TestLoader().discover(test_path, pattern='*_tests.py') 23 | result = unittest.TextTestRunner(verbosity=2).run(suite) 24 | sys.exit(1 if result.errors or result.failures else 0) 25 | 26 | if __name__ == '__main__': 27 | logging.basicConfig(format=LOGFMT, level=logging.INFO) 28 | 29 | parser = optparse.OptionParser(USAGE) 30 | options, args = parser.parse_args() 31 | if len(args) != 2: 32 | print 'Error: Exactly 2 arguments required.' 33 | parser.print_help() 34 | sys.exit(1) 35 | SDK_PATH = args[0] 36 | TEST_PATH = args[1] 37 | main(SDK_PATH, TEST_PATH) 38 | -------------------------------------------------------------------------------- /bin/lint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | PYLINT_DISABLE=import-error,locally-disabled,no-member,maybe-no-member,no-init,star-args,super-on-old-class,bare-except,no-self-use 6 | SPELLINGS=brigth 7 | EXIT_CODE=0 8 | 9 | function quietly() { 10 | output=$($@ 2>&1) || echo $output 11 | } 12 | 13 | function spellcheck() { 14 | filename=$1 15 | ! grep -i --color ${SPELLINGS} ${filename} || EXIT_CODE=$? 16 | } 17 | 18 | function lint { 19 | filename=$1 20 | ext=${filename##*\.} 21 | 22 | # If this is a directory, then lint the files in it 23 | if [[ -d ${filename} ]]; then 24 | find ${filename} -type f | while read filename; do 25 | lint ${filename} 26 | done 27 | return 28 | fi 29 | 30 | # Don't lint this script 31 | if [ "${filename}" == "bin/lint" ]; then 32 | return 33 | fi 34 | 35 | # Don't lint tests 36 | if [[ "${filename}" == *_tests.py ]]; then 37 | return 38 | fi 39 | 40 | spellcheck ${filename} 41 | case "$ext" in 42 | py) 43 | /usr/local/bin/pylint --rcfile=.pylintrc -rno --disable=$PYLINT_DISABLE ${filename} || EXIT_CODE=$? 44 | ;; 45 | js) 46 | /usr/local/bin/jshint ${filename} || EXIT_CODE=$? 47 | ;; 48 | html) 49 | java -jar ~/bin/vnu.jar ${filename} 50 | quietly bootlint -d W002 ${filename} 51 | ;; 52 | css) 53 | quietly csslint --ignore=adjoining-classes,box-model,ids,box-sizing,vendor-prefix,qualified-headings ${filename} 54 | ;; 55 | esac 56 | } 57 | 58 | if [ $# -gt 0 ]; then 59 | for filename in $@; do 60 | lint ${filename} 61 | done 62 | else 63 | #git stash -q --keep-index 64 | git diff --cached --name-only | while read filename; do 65 | if [ ! -e "${filename}" ]; then 66 | continue 67 | fi 68 | 69 | lint ${filename} 70 | done 71 | #git stash pop -q 72 | fi 73 | 74 | exit $EXIT_CODE 75 | -------------------------------------------------------------------------------- /bin/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | bin/lint 6 | make test 7 | -------------------------------------------------------------------------------- /docs/Aeonstick2.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomwilkie/awesomation/708a0ff2ffd431f24ed3f942cafd24882dc89620/docs/Aeonstick2.pdf -------------------------------------------------------------------------------- /docs/Fibaro Motion-Sensor_EN_5.3.14.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomwilkie/awesomation/708a0ff2ffd431f24ed3f942cafd24882dc89620/docs/Fibaro Motion-Sensor_EN_5.3.14.pdf -------------------------------------------------------------------------------- /docs/TKB_TZ67-E.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomwilkie/awesomation/708a0ff2ffd431f24ed3f942cafd24882dc89620/docs/TKB_TZ67-E.pdf -------------------------------------------------------------------------------- /docs/aeotec_multisensor_tech.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomwilkie/awesomation/708a0ff2ffd431f24ed3f942cafd24882dc89620/docs/aeotec_multisensor_tech.pdf -------------------------------------------------------------------------------- /docs/hue_example.json: -------------------------------------------------------------------------------- 1 | { 2 | u 'name': u 'Home Cinema Right', 3 | u 'swversion': u '66010820', 4 | u 'pointsymbol': { 5 | u '1': u 'none', 6 | u '3': u 'none', 7 | u '2': u 'none', 8 | u '5': u 'none', 9 | u '4': u 'none', 10 | u '7': u 'none', 11 | u '6': u 'none', 12 | u '8': u 'none' 13 | }, 14 | u 'state': { 15 | u 'on': False, 16 | u 'hue': 0, 17 | u 'colormode': u 'ct', 18 | u 'effect': u 'none', 19 | u 'alert': u 'none', 20 | u 'xy': [0.0, 0.0], 21 | u 'reachable': False, 22 | u 'bri': 0, 23 | u 'ct': 154, 24 | u 'sat': 0 25 | }, 26 | u 'type': u 'Extended color light', 27 | u 'modelid': u 'LCT001' 28 | } 29 | 30 | { 31 | u 'name': u 'Home Cinema Left', 32 | u 'swversion': u '66010820', 33 | u 'pointsymbol': { 34 | u '1': u 'none', 35 | u '3': u 'none', 36 | u '2': u 'none', 37 | u '5': u 'none', 38 | u '4': u 'none', 39 | u '7': u 'none', 40 | u '6': u 'none', 41 | u '8': u 'none' 42 | }, 43 | u 'state': { 44 | u 'on': False, 45 | u 'hue': 34515, 46 | u 'colormode': u 'ct', 47 | u 'effect': u 'none', 48 | u 'alert': u 'none', 49 | u 'xy': [0.31380000000000002, 0.32390000000000002], 50 | u 'reachable': True, 51 | u 'bri': 1, 52 | u 'ct': 153, 53 | u 'sat': 236 54 | }, 55 | u 'type': u 'Extended color light', 56 | u 'modelid': u 'LCT001' 57 | } 58 | 59 | { 60 | u 'name': u 'Home Cinema LightStrips', 61 | u 'swversion': u '66010400', 62 | u 'pointsymbol': { 63 | u '1': u 'none', 64 | u '3': u 'none', 65 | u '2': u 'none', 66 | u '5': u 'none', 67 | u '4': u 'none', 68 | u '7': u 'none', 69 | u '6': u 'none', 70 | u '8': u 'none' 71 | }, 72 | u 'state': { 73 | u 'on': False, 74 | u 'hue': 49238, 75 | u 'colormode': u 'xy', 76 | u 'effect': u 'none', 77 | u 'alert': u 'none', 78 | u 'xy': [0.18690000000000001, 0.098400000000000001], 79 | u 'reachable': True, 80 | u 'bri': 1, 81 | u 'sat': 253 82 | }, 83 | u 'type': u 'Color light', 84 | u 'modelid': u 'LST001' 85 | } 86 | -------------------------------------------------------------------------------- /docs/netatmo_api.json: -------------------------------------------------------------------------------- 1 | { 2 | u 'status': u 'ok', 3 | u 'body': { 4 | u 'modules': [{ 5 | u 'rf_status': 69, 6 | u 'data_type': [u 'Temperature', u 'Humidity'], 7 | u 'last_message': 1420797218, 8 | u 'firmware': 43, 9 | u 'main_device': u '70:ee:50:01:2d:22', 10 | u 'dashboard_data': { 11 | u 'date_min_temp': 1420792911, 12 | u 'Temperature': 16.199999999999999, 13 | u 'time_utc': 1420797167, 14 | u 'Humidity': 62, 15 | u 'min_temp': 16.199999999999999, 16 | u 'date_max_temp': 1420761691, 17 | u 'max_temp': 16.399999999999999 18 | }, 19 | u 'module_name': u 'Office', 20 | u 'battery_vp': 5956, 21 | u '_id': u '02:00:00:01:6d:2e', 22 | u 'type': u 'NAModule1', 23 | u 'last_seen': 1420797218 24 | }, { 25 | u 'rf_status': 80, 26 | u 'data_type': [u 'Temperature', u 'Co2', u 'Humidity'], 27 | u 'last_message': 1420797218, 28 | u 'firmware': 43, 29 | u 'main_device': u '70:ee:50:01:2d:22', 30 | u 'dashboard_data': { 31 | u 'CO2': 729, 32 | u 'Temperature': 15.9, 33 | u 'time_utc': 1420797173, 34 | u 'Humidity': 54, 35 | u 'date_min_temp': 1420763184, 36 | u 'min_temp': 15.5, 37 | u 'date_max_temp': 1420782306, 38 | u 'max_temp': 16 39 | }, 40 | u 'module_name': u 'Master Bedroom', 41 | u 'battery_vp': 5979, 42 | u '_id': u '03:00:00:00:cb:7c', 43 | u 'type': u 'NAModule4', 44 | u 'last_seen': 1420797173 45 | }], 46 | u 'devices': [{ 47 | u 'meteo_alarms': [{ 48 | u 'status': u 'new', 49 | u 'max level': 1, 50 | u 'begin': 1419692400, 51 | u 'end': 1419764400, 52 | u 'descr': u 'Widespread icy patches are expected to readily form on untreated surfaces this evening and during Saturday night, especially where snow cover exists or where wintry showers occur.', 53 | u 'title': u '__MA_ALARM_SNOW_TITLE', 54 | u 'time_generated': 1419692534, 55 | u 'area': u 'London & South East', 56 | u 'alarm_id': 132425, 57 | u 'text_field': u '__MA_ALARM_SNOW_LEVEL_1', 58 | u '_id': { 59 | u '$id': u '549ec9f61c7759ab9d8b456b' 60 | }, 61 | u 'type': u 'ALARM_SNOW', 62 | u 'origin': u 'meteoalarm', 63 | u 'level': 1 64 | }], 65 | u 'data_type': [u 'Temperature', u 'Co2', u 'Humidity', u 'Noise', u 'Pressure'], 66 | u 'modules': [u '02:00:00:01:6d:2e', u '03:00:00:00:cb:7c'], 67 | u 'firmware': 101, 68 | u 'date_setup': { 69 | u 'usec': 920000, 70 | u 'sec': 1386607747 71 | }, 72 | u 'co2_calibrating': False, 73 | u 'last_status_store': 1420797219, 74 | u 'wifi_status': 41, 75 | u 'place': { 76 | u 'city': u 'London', 77 | u 'improveLocProposed': True, 78 | u 'geoip_city': u 'London', 79 | u 'country': u 'GB', 80 | u 'altitude': 26, 81 | u 'timezone': u 'Europe/London' 82 | }, 83 | u 'dashboard_data': { 84 | u 'Noise': 45, 85 | u 'Temperature': 16.199999999999999, 86 | u 'time_utc': 1420797204, 87 | u 'Humidity': 67, 88 | u 'Pressure': 1021.4, 89 | u 'CO2': 553, 90 | u 'AbsolutePressure': 1018.3, 91 | u 'min_temp': 16.100000000000001, 92 | u 'date_max_temp': 1420761704, 93 | u 'date_min_temp': 1420774749, 94 | u 'max_temp': 16.800000000000001 95 | }, 96 | u 'module_name': u 'Home Cinema', 97 | u 'station_name': u "Tom's Netatmo", 98 | u '_id': u '70:ee:50:01:2d:22', 99 | u 'type': u 'NAMain' 100 | }] 101 | }, 102 | u 'time_exec': 0.0078270435333252005, 103 | u 'time_server': 1420797713 104 | } 105 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomwilkie/awesomation/708a0ff2ffd431f24ed3f942cafd24882dc89620/docs/screenshot.png -------------------------------------------------------------------------------- /docs/z_wave_vision_motion_detector_manual.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomwilkie/awesomation/708a0ff2ffd431f24ed3f942cafd24882dc89620/docs/z_wave_vision_motion_detector_manual.pdf -------------------------------------------------------------------------------- /experimental/db/remove_property.py: -------------------------------------------------------------------------------- 1 | """ Remove a property from the datastore. 2 | How to use: 3 | 4 | $ cd experimental/db/ 5 | $ PYTHONPATH=. remote_api_shell.py -s homeawesomation.appspot.com 6 | > import remove_property 7 | """ 8 | 9 | from google.appengine.api import namespace_manager 10 | from google.appengine.ext import db 11 | 12 | class Base(db.Expando): pass 13 | 14 | def remove(namespace, field): 15 | namespace_manager.set_namespace(namespace) 16 | for base in Base.all().run(): 17 | if hasattr(base, field): 18 | print "%s %s" % (base.key().id_or_name(), getattr(base, 'name', None)) 19 | delattr(base, field) 20 | base.put() 21 | -------------------------------------------------------------------------------- /experimental/names/name.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import json 3 | import urllib2 4 | import itertools 5 | 6 | API_KEY = 'AIzaSyAEXLea9UKrZ1UpJ2JJfVEUCN3IhBWnZsw' 7 | LANG_URL = 'https://www.googleapis.com/language/translate/v2/languages?key=%s' 8 | TRANS_URL = 'https://www.googleapis.com/language/translate/v2?key=%s&source=en&target=%s&q=%s' 9 | 10 | NAMES = ['home', 'house', 'thing', 'smart', 'nest', 'sense', 'sensor', 'magic', 'den'] 11 | 12 | def main(): 13 | result = urllib2.urlopen(LANG_URL % API_KEY).read() 14 | result = json.loads(result) 15 | languages = [l['language'] for l in result['data']['languages']] 16 | print len(languages) 17 | 18 | names = NAMES + list(itertools.permutations(NAMES, 2)) 19 | 20 | for lang in languages: 21 | for name in names: 22 | try: 23 | url = TRANS_URL % (API_KEY, lang, name) 24 | translation = urllib2.urlopen(url).read() 25 | translation = json.loads(translation) 26 | for t in translation['data']['translations']: 27 | print t['translatedText'] 28 | except: 29 | pass 30 | 31 | 32 | def munge(): 33 | names = list(x.strip().lower() for x in open('names')) 34 | tlds = list(x.strip().lower() for x in open('tlds.txt')) 35 | print names 36 | print tlds 37 | 38 | for name in names: 39 | for tld in tlds: 40 | if tld == name: 41 | continue 42 | if name.endswith(tld): 43 | print '%s.%s' % (name[:-len(tld)], tld) 44 | 45 | 46 | if __name__ == '__main__': 47 | main() -------------------------------------------------------------------------------- /experimental/names/names: -------------------------------------------------------------------------------- 1 | huis 2 | huis 3 | ding 4 | المنزل 5 | منزل 6 | شيء 7 | ev 8 | ev 9 | şey 10 | дадому 11 | дом 12 | рэч 13 | Начало 14 | къща 15 | нещо 16 | হোম 17 | ঘর 18 | বিষয় 19 | Početna 20 | kuća 21 | stvar 22 | casa 23 | casa 24 | cosa 25 | home 26 | balay 27 | nga butang 28 | domácí 29 | dům 30 | věc 31 | cartref 32 | tŷ 33 | beth 34 | hjem 35 | hus 36 | ting 37 | Zuhause 38 | Haus 39 | Sache 40 | home 41 | σπίτι 42 | πράγμα 43 | hejmo 44 | domo 45 | afero 46 | casa 47 | casa 48 | cosa 49 | kodu 50 | maja 51 | asi 52 | hasiera 53 | etxea 54 | Gauza 55 | خانه 56 | خانه 57 | چیزی که 58 | koti 59 | talo 60 | asia 61 | maison 62 | maison 63 | chose 64 | Baile 65 | Teach 66 | rud 67 | casa 68 | casa 69 | cousa 70 | ઘર 71 | ઘર 72 | વસ્તુ 73 | gida 74 | house 75 | abu 76 | घर 77 | घर 78 | बात 79 | tsev 80 | tsev 81 | tshaj plaws 82 | Početna 83 | Kuća 84 | stvar 85 | lakay 86 | kay 87 | Bagay 88 | otthon 89 | ház 90 | dolog 91 | Գլխավոր 92 | տուն 93 | բան 94 | rumah 95 | rumah 96 | hal 97 | n'ụlọ 98 | ụlọ 99 | ihe 100 | heim 101 | hús 102 | hlutur 103 | casa 104 | casa 105 | cosa 106 | בית 107 | בית 108 | דבר 109 | ホーム 110 | 家 111 | もの 112 | ngarep 113 | house 114 | bab 115 | მთავარი 116 | სახლი 117 | რამ 118 | ផ្ទះ 119 | ផ្ទះ 120 | រឿង 121 | ಮನೆ 122 | ಮನೆ 123 | ವಿಷಯ 124 | 홈 125 | 집 126 | 일 127 | domum 128 | Domus 129 | aliquid 130 | ເຮືອນ 131 | ເຮືອນ 132 | ການ​ທົດ​ສອບ 133 | namų 134 | namas 135 | dalykas 136 | mājas 137 | māja 138 | lieta 139 | te kāinga 140 | whare 141 | mea 142 | дома 143 | куќа 144 | работа 145 | Нүүр хуудас 146 | байшин 147 | зүйл 148 | मुख्य 149 | घर 150 | गोष्ट 151 | rumah 152 | rumah 153 | perkara 154 | home 155 | house 156 | Ħaġa 157 | घर 158 | घर 159 | कुरा 160 | thuis 161 | huis 162 | ding 163 | hjem 164 | Huset 165 | ting 166 | ਘਰ ਦੇ 167 | ਦੇ ਘਰ 168 | ਗੱਲ ਇਹ ਹੈ ਕਿ 169 | Strona główna 170 | dom 171 | rzecz 172 | casa 173 | casa 174 | coisa 175 | home 176 | casă 177 | lucru 178 | домой 179 | дом 180 | вещь 181 | domáce 182 | dom 183 | vec 184 | domov 185 | hiša 186 | stvar 187 | guriga 188 | guriga 189 | wax 190 | shtëpi 191 | shtëpi 192 | gjë 193 | Хоме 194 | Хоусе 195 | ствар 196 | hem 197 | hus 198 | sak 199 | nyumbani 200 | nyumba 201 | jambo 202 | வீட்டில் 203 | வீட்டில் 204 | விஷயம் 205 | హోమ్ 206 | ఇల్లు 207 | విషయం 208 | บ้าน 209 | บ้าน 210 | สิ่งที่ 211 | tahanan 212 | bahay 213 | bagay 214 | ev 215 | ev 216 | şey 217 | додому 218 | будинок 219 | річ 220 | گھر 221 | گھر 222 | بات 223 | nhà 224 | nhà 225 | điều 226 | היים 227 | הויז 228 | זאַך 229 | ile 230 | ile 231 | ohun 232 | 家 233 | 房子 234 | 事 235 | 家 236 | 房子 237 | 事 238 | Ikhaya 239 | house 240 | Into -------------------------------------------------------------------------------- /experimental/speed/speed.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | function DoSpeedTest { 6 | start=`date +%s` 7 | runtime=`{ time curl http://speedtest.netcologne.de/test_10mb.bin > /dev/null 2>&1; } 2>&1 | grep real | awk -F'[sm. \t]' '{print $3$4}'` 8 | bandwidth=$(bc -l <<< "scale=2; 80000000/$runtime") 9 | echo ${start}, ${bandwidth} 10 | } 11 | 12 | DoSpeedTest >> ~/speed.csv 13 | -------------------------------------------------------------------------------- /src/app.yaml: -------------------------------------------------------------------------------- 1 | application: homeawesomation 2 | version: 1 3 | runtime: python27 4 | api_version: 1 5 | threadsafe: true 6 | 7 | handlers: 8 | - url: .* 9 | script: appengine.main.app 10 | secure: always 11 | 12 | - url: /tasks/update 13 | script: appengine.main.app 14 | login: admin 15 | 16 | libraries: 17 | - name: webapp2 18 | version: latest 19 | - name: ssl 20 | version: latest 21 | 22 | builtins: 23 | - remote_api: on 24 | -------------------------------------------------------------------------------- /src/appengine/__init__.py: -------------------------------------------------------------------------------- 1 | """This module contains the appengine app.""" 2 | -------------------------------------------------------------------------------- /src/appengine/account.py: -------------------------------------------------------------------------------- 1 | """Integration with nest devices.""" 2 | 3 | import json 4 | import logging 5 | import sys 6 | import time 7 | import urllib2 8 | import uuid 9 | 10 | from google.appengine.api import urlfetch 11 | from google.appengine.api import urlfetch_errors 12 | from google.appengine.ext import ndb 13 | 14 | import flask 15 | 16 | from appengine import model, rest 17 | 18 | 19 | REDIRECT_URL = 'https://homeawesomation.appspot.com/api/account/redirect' 20 | ACCOUNT_TYPES = {} 21 | 22 | 23 | def register(account_type): 24 | """Decorator to cause accounts types to be registered.""" 25 | def class_rebuilder(cls): 26 | ACCOUNT_TYPES[account_type] = cls 27 | return cls 28 | return class_rebuilder 29 | 30 | 31 | class Account(model.Base): 32 | """Class represents an account.""" 33 | CLIENT_ID = None 34 | CLIENT_SECRET = None 35 | ACCESS_TOKEN_URL = None 36 | AUTH_URL = None 37 | 38 | auth_code = ndb.StringProperty(required=False) 39 | access_token = ndb.StringProperty(required=False) 40 | expires = ndb.IntegerProperty(required=False) 41 | refresh_token = ndb.StringProperty(required=False) 42 | 43 | human_type = ndb.ComputedProperty(lambda a: a.get_human_type()) 44 | last_update = ndb.DateTimeProperty(required=False, auto_now=True) 45 | 46 | @classmethod 47 | def _event_classname(cls): 48 | return 'account' 49 | 50 | def get_human_type(self): 51 | return '' 52 | 53 | def _get_refresh_data(self): 54 | return '' 55 | 56 | def _get_auth_headers(self): 57 | return {} 58 | 59 | def do_request(self, url, headers=None, **kwargs): 60 | retries = 10 61 | while retries > 0: 62 | retries -= 1 63 | 64 | try: 65 | if headers: 66 | headers.update(self._get_auth_headers()) 67 | else: 68 | headers = self._get_auth_headers() 69 | 70 | result = urlfetch.fetch( 71 | url=url % {'access_token': self.access_token}, 72 | headers=headers, 73 | deadline=15, 74 | **kwargs) 75 | 76 | if result.status_code == 403: 77 | logging.info('Got 403, refreshing credentials') 78 | self.refresh_access_token() 79 | continue 80 | 81 | assert 200 <= result.status_code < 300, ( 82 | "%s, %s" % (result.status_code, result.content)) 83 | return json.loads(result.content) 84 | except urlfetch_errors.DeadlineExceededError: 85 | if retries > 0: 86 | logging.info('Deadline exceeded, retrying.', exc_info=sys.exc_info) 87 | else: 88 | raise 89 | 90 | @rest.command 91 | def refresh_access_token(self): 92 | """For a given account, refresh the access token.""" 93 | parameters = {'client_id': self.CLIENT_ID, 94 | 'auth_code': self.auth_code, 95 | 'client_secret': self.CLIENT_SECRET, 96 | 'grant_type': 'authorization_code'} 97 | url = self.ACCESS_TOKEN_URL % parameters 98 | data = self._get_refresh_data() 99 | logging.info('url: %s, data: %s', url, data) 100 | 101 | try: 102 | # empty data to trigger a post 103 | req = urllib2.Request(url, data) 104 | req.add_header('Content-Type', 'application/x-www-form-urlencoded') 105 | result = urllib2.urlopen(req) 106 | result = json.load(result) 107 | logging.info('result: %s', result) 108 | except urllib2.HTTPError, err: 109 | result = json.load(err) 110 | logging.info(result) 111 | raise err 112 | 113 | self.access_token = result['access_token'] 114 | self.expires = int(time.time() + result['expires_in']) 115 | self.refresh_token = result.get('refresh_token', None) 116 | 117 | @rest.command 118 | def refresh_devices(self): 119 | pass 120 | 121 | 122 | # pylint: disable=invalid-name 123 | blueprint = flask.Blueprint('account', __name__) 124 | rest.register_class(blueprint, Account, None) 125 | 126 | 127 | @blueprint.route('/start_flow') 128 | def oauth_start_flow(): 129 | """Step 1; Beginning of oauth account flow. 130 | 131 | UI will open this page; we create an accounts objects 132 | and redirect to appropriate server. We use account 133 | id as random string for oauth flow. 134 | """ 135 | # Have to do authentication! 136 | rest.default_user_authentication() 137 | 138 | account_type = flask.request.args.get('type') 139 | if account_type is None: 140 | flask.abort(400) 141 | 142 | cls = ACCOUNT_TYPES.get(account_type, None) 143 | if cls is None: 144 | flask.about(400) 145 | 146 | key = str(uuid.uuid4()) 147 | instance = cls(id=key) 148 | instance.put() 149 | 150 | return flask.redirect(instance.AUTH_URL % 151 | {'client_id': instance.CLIENT_ID, 152 | 'state': key}) 153 | 154 | 155 | @blueprint.route('/redirect') 156 | def oauth_redirect_callback(): 157 | """Step 2: Nest (etc) server redirects back to us 158 | with an auth_code - use this refresh access token. 159 | """ 160 | # Have to do authentication! 161 | rest.default_user_authentication() 162 | 163 | auth_code = flask.request.args.get('code', None) 164 | state = flask.request.args.get('state', None) 165 | if auth_code is None or state is None: 166 | logging.info('No auth_code of state found!') 167 | flask.abort(400) 168 | 169 | logging.info('Got OAuth callback state="%s", auth_code="%s"', 170 | state, auth_code) 171 | 172 | account = Account.get_by_id(state) 173 | if account is None: 174 | logging.info('Account \'%s\' not found!', state) 175 | flask.abort(400) 176 | 177 | account.auth_code = auth_code 178 | account.refresh_access_token() 179 | account.refresh_devices() 180 | account.put() 181 | 182 | return "OK" 183 | -------------------------------------------------------------------------------- /src/appengine/auth_tests.py: -------------------------------------------------------------------------------- 1 | """Test authentication.""" 2 | 3 | import unittest 4 | import logging 5 | 6 | import flask 7 | 8 | from google.appengine.ext import testbed 9 | 10 | from appengine import main 11 | 12 | 13 | def has_no_empty_params(rule): 14 | defaults = rule.defaults if rule.defaults is not None else () 15 | arguments = rule.arguments if rule.arguments is not None else () 16 | return len(defaults) >= len(arguments) 17 | 18 | 19 | class AuthTests(unittest.TestCase): 20 | 21 | def setUp(self): 22 | # Flask testing setup 23 | main.app.config['TESTING'] = True 24 | self.client = main.app.test_client() 25 | 26 | # Appengine testing setup 27 | self.testbed = testbed.Testbed() 28 | self.testbed.activate() 29 | self.testbed.init_user_stub() 30 | 31 | def tearDown(self): 32 | self.testbed.deactivate() 33 | 34 | def testEndpointsReturn401(self): 35 | """Test all endpoints needs authentication.""" 36 | 37 | for rule in main.app.url_map.iter_rules(): 38 | logging.debug('Testing endpoint "%s"', rule.endpoint) 39 | 40 | # special case static files, no auth.. 41 | if rule.endpoint == 'static': 42 | continue 43 | 44 | # User a dummy object_id on endpoints 45 | # that need on, just to construct a URL 46 | if rule.arguments == set(): 47 | arguments = {} 48 | if rule.arguments == set(['object_id']): 49 | arguments = {'object_id': '12345'} 50 | elif not has_no_empty_params(rule): 51 | logging.error(rule.arguments) 52 | assert False 53 | 54 | with main.app.test_request_context(): 55 | url = flask.url_for(rule.endpoint, **arguments) 56 | 57 | logging.debug(' url = "%s"', url) 58 | 59 | # Only 2 special cases cause redirects - only human endpoints 60 | if rule.endpoint in {'root', 'user.use_invite'}: 61 | response = self.client.get(url) 62 | self.assertEquals(response.status_code, 302) 63 | continue 64 | 65 | # Filter out rules we can't navigate to in a browser 66 | # and rules that require parameters 67 | if 'GET' in rule.methods: 68 | response = self.client.get(url) 69 | self.assertEquals(response.status_code, 401) 70 | 71 | if 'POST' in rule.methods: 72 | response = self.client.post(url) 73 | self.assertEquals(response.status_code, 401) 74 | 75 | if 'DELETE' in rule.methods: 76 | response = self.client.delete(url) 77 | self.assertEquals(response.status_code, 401) 78 | 79 | 80 | if __name__ == '__main__': 81 | unittest.main() 82 | -------------------------------------------------------------------------------- /src/appengine/building.py: -------------------------------------------------------------------------------- 1 | """Building abstraction.""" 2 | 3 | from google.appengine.api import namespace_manager 4 | 5 | # Buildings don't exist per se - we just store the building ID in the namespace 6 | 7 | def get_id(): 8 | namespace = namespace_manager.get_namespace() 9 | assert namespace != '' 10 | return namespace 11 | -------------------------------------------------------------------------------- /src/appengine/devices/__init__.py: -------------------------------------------------------------------------------- 1 | """Device-specific code.""" 2 | 3 | # This makes the import * in main.py work. 4 | __all__ = ["hue", "insteon", "nest", "netatmo", "network", "rfswitch", "sonos", 5 | "wemo", "zwave"] 6 | -------------------------------------------------------------------------------- /src/appengine/devices/hue.py: -------------------------------------------------------------------------------- 1 | """Philips hue integration.""" 2 | 3 | import logging 4 | import re 5 | 6 | from google.appengine.ext import ndb 7 | 8 | from appengine import device, pushrpc 9 | 10 | 11 | @device.register('hue_bridge') 12 | class HueBridge(device.Device): 13 | """A hue bridge.""" 14 | linked = ndb.BooleanProperty(required=True) 15 | 16 | def get_capabilities(self): 17 | return ['SCAN'] 18 | 19 | def get_categories(self): 20 | return ['LIGHTING'] 21 | 22 | @classmethod 23 | @device.static_command 24 | def scan(cls): 25 | event = {'type': 'hue', 'command': 'scan'} 26 | pushrpc.send_event(event) 27 | 28 | def handle_event(self, event): 29 | """Handle a device update event.""" 30 | # Only events are when we find a bridge, 31 | # and we are told the name and linked status 32 | 33 | self.device_name = event['name'] 34 | self.linked = event['linked'] 35 | 36 | 37 | LIGHT_ID_RE = re.compile(r'hue-([0-9a-f]+)-([0-9]+)') 38 | 39 | 40 | @device.register('hue_light') 41 | class HueLight(device.Switch): 42 | """A hue light.""" 43 | hue_type = ndb.StringProperty() 44 | hue_model_id = ndb.StringProperty() 45 | brightness = ndb.IntegerProperty() # 0 - 255 46 | color_temperature = ndb.IntegerProperty() # 153 - 500 47 | 48 | def get_capabilities(self): 49 | capabilities = ['SWITCH', 'DIMMABLE'] 50 | if self.hue_model_id == 'LCT001': 51 | capabilities.append('COLOR_TEMPERATURE') 52 | return capabilities 53 | 54 | def sync(self): 55 | """Update the state of a light.""" 56 | match = LIGHT_ID_RE.match(self.key.id()) 57 | bridge_id = match.group(1) 58 | device_id = int(match.group(2)) 59 | 60 | event = {'type': 'hue', 61 | 'command': 'set_state', 62 | 'bridge_id': bridge_id, 63 | 'device_id': device_id, 64 | 'mode': self.state, 65 | 'brightness': self.brightness, 66 | 'color_temperature': self.color_temperature} 67 | 68 | pushrpc.send_event(event) 69 | 70 | def handle_event(self, event): 71 | """Handle a device update event.""" 72 | logging.info(event) 73 | 74 | self.device_name = event['name'] 75 | self.hue_type = event['type'] 76 | self.hue_model_id = event['modelid'] 77 | 78 | state = event['state'] 79 | self.state = state['on'] 80 | self.brightness = state['bri'] 81 | self.color_temperature = state.get('ct', None) 82 | -------------------------------------------------------------------------------- /src/appengine/devices/insteon.py: -------------------------------------------------------------------------------- 1 | """Integration with insteon devices.""" 2 | 3 | import json 4 | import logging 5 | import urllib 6 | import time 7 | 8 | from google.appengine.ext import ndb 9 | 10 | from appengine import account, device, rest 11 | 12 | 13 | @device.register('insteon_switch') 14 | class InsteonSwitch(device.Switch): 15 | """Class represents a Insteon switch.""" 16 | insteon_device_id = ndb.IntegerProperty() 17 | 18 | def handle_event(self, event): 19 | self.account = event['account'] 20 | self.device_name = event['DeviceName'] 21 | self.insteon_device_id = event['DeviceID'] 22 | 23 | def sync(self): 24 | """Update the state of a light.""" 25 | my_account = self.find_account() 26 | if not my_account: 27 | logging.info("Couldn't find account.") 28 | return 29 | 30 | command = 'fast_on' if self.state else 'fast_off' 31 | command = my_account.send_command(command, device_id=self.insteon_device_id) 32 | logging.info(command) 33 | 34 | 35 | @account.register('insteon') 36 | class InsteonAccount(account.Account): 37 | """Class represents a Insteon account.""" 38 | BASE_URL = 'https://connect.insteon.com' 39 | AUTH_URL = (BASE_URL + '/api/v2/oauth2/auth?' + 40 | 'client_id=%(client_id)s&state=%(state)s&' 41 | 'response_type=code&redirect_uri=' + account.REDIRECT_URL) 42 | ACCESS_TOKEN_URL = (BASE_URL + '/api/v2/oauth2/token') 43 | COMMAND_RETRIES = 10 44 | 45 | def __init__(self, *args, **kwargs): 46 | super(InsteonAccount, self).__init__(*args, **kwargs) 47 | 48 | # pylint: disable=invalid-name 49 | from common import creds 50 | self.CLIENT_ID = creds.INSTEON_CLIENT_ID 51 | self.CLIENT_SECRET = creds.INSTEON_CLIENT_SECRET 52 | 53 | def _get_auth_headers(self): 54 | return {'Authentication': 'APIKey %s' % self.CLIENT_ID, 55 | 'Authorization': 'Bearer %s' % self.access_token} 56 | 57 | def _get_refresh_data(self): 58 | values = {'client_id': self.CLIENT_ID, 59 | 'client_secret': self.CLIENT_SECRET} 60 | 61 | if self.refresh_token is None: 62 | values['grant_type'] = 'authorization_code' 63 | values['code'] = self.auth_code 64 | # Don't provide a redirect, or you'll get a 401 65 | values['redirect_uri'] = '' 66 | else: 67 | values['grant_type'] = 'refresh_token' 68 | values['refresh_token'] = self.refresh_token 69 | 70 | return urllib.urlencode(values) 71 | 72 | def get_human_type(self): 73 | return 'Insteon' 74 | 75 | @rest.command 76 | def send_command(self, command, **kwargs): 77 | """Utility to send commands to API.""" 78 | if self.access_token is None: 79 | logging.info('No access token, can\'t send command.') 80 | return 81 | 82 | kwargs['command'] = command 83 | payload = json.dumps(kwargs) 84 | result = self.do_request( 85 | self.BASE_URL + '/api/v2/commands', method='POST', payload=payload, 86 | headers={'Content-Type': 'application/json'}) 87 | logging.info(result) 88 | 89 | state = None 90 | for _ in range(self.COMMAND_RETRIES): 91 | state = self.do_request(self.BASE_URL + result['link']) 92 | logging.info(state) 93 | assert state['status'] != 'failed' 94 | if state['status'] == 'suceeded': 95 | break 96 | time.sleep(1) 97 | 98 | return state 99 | 100 | @rest.command 101 | def refresh_devices(self): 102 | if self.access_token is None: 103 | logging.info('No access token, skipping.') 104 | return 105 | 106 | devices = self.do_request(self.BASE_URL + '/api/v2/devices?properties=all') 107 | logging.info(devices) 108 | 109 | events = [] 110 | for entry in devices['DeviceList']: 111 | # This covers some swtich types, will need extending for more 112 | if entry['DevCat'] == 2 and entry['SubCat'] in {53, 54, 55, 56, 57}: 113 | entry['account'] = self.key.string_id() 114 | events.append({ 115 | 'device_type': 'insteon_switch', 116 | 'device_id': 'insteon-%s' % entry['InsteonID'], 117 | 'event': entry, 118 | }) 119 | device.process_events(events) 120 | -------------------------------------------------------------------------------- /src/appengine/devices/nest.py: -------------------------------------------------------------------------------- 1 | """Integration with nest devices.""" 2 | 3 | import json 4 | import logging 5 | import sys 6 | 7 | from google.appengine.api import urlfetch 8 | from google.appengine.ext import ndb 9 | 10 | from appengine import account, device, rest 11 | 12 | 13 | @device.register('nest_thermostat') 14 | class NestThermostat(device.Device): 15 | """Class represents a Nest thermostat.""" 16 | temperature = ndb.FloatProperty() 17 | humidity = ndb.FloatProperty() 18 | target_temperature = ndb.FloatProperty() 19 | 20 | def get_categories(self): 21 | return ['CLIMATE'] 22 | 23 | def handle_event(self, event): 24 | self.account = event['account'] 25 | self.humidity = event['humidity'] 26 | self.temperature = event['ambient_temperature_c'] 27 | self.device_name = event['name_long'] 28 | self.target_temperature = event['target_temperature_c'] 29 | 30 | 31 | @device.register('nest_protect') 32 | class NestProtect(device.Device): 33 | """Class represents a Nest protect (smoke alarm).""" 34 | 35 | def get_categories(self): 36 | return ['CLIMATE'] 37 | 38 | def handle_event(self, event): 39 | self.account = event['account'] 40 | self.device_name = event['name_long'] 41 | 42 | 43 | @account.register('nest') 44 | class NestAccount(account.Account): 45 | """Class represents a Nest account.""" 46 | AUTH_URL = ('https://home.nest.com/login/oauth2?' 47 | 'client_id=%(client_id)s&state=%(state)s') 48 | ACCESS_TOKEN_URL = ('https://api.home.nest.com/oauth2/access_token?' 49 | 'client_id=%(client_id)s&code=%(auth_code)s&' 50 | 'client_secret=%(client_secret)s&' 51 | 'grant_type=authorization_code') 52 | API_URL = 'https://developer-api.nest.com/devices.json?auth=%(access_token)s' 53 | STRUCTURES_URL = ('https://developer-api.nest.com/structures.json' 54 | '?auth=%(access_token)s') 55 | SINGLE_STRUCTURE_URL = ('https://developer-api.nest.com/structures/' 56 | '%(id)s?auth=%(access_token)s') 57 | 58 | def __init__(self, *args, **kwargs): 59 | super(NestAccount, self).__init__(*args, **kwargs) 60 | 61 | # pylint: disable=invalid-name 62 | from common import creds 63 | self.CLIENT_ID = creds.NEST_CLIENT_ID 64 | self.CLIENT_SECRET = creds.NEST_CLIENT_SECRET 65 | 66 | def get_human_type(self): 67 | return 'Nest' 68 | 69 | def set_away(self, value): 70 | """Set away status of all structures.""" 71 | structures = self.do_request(self.STRUCTURES_URL) 72 | 73 | logging.info(structures) 74 | value = 'away' if value else 'home' 75 | 76 | for structure_id in structures.iterkeys(): 77 | url = self.SINGLE_STRUCTURE_URL % {'id': structure_id, 78 | 'access_token': self.access_token} 79 | request_data = json.dumps({'away': value}) 80 | logging.info('Sending request "%s" to %s', request_data, url) 81 | 82 | try: 83 | self.do_request( 84 | url, payload=request_data, 85 | method=urlfetch.PUT) 86 | except: 87 | logging.error('Setting Away on nest failed', exc_info=sys.exc_info()) 88 | 89 | @rest.command 90 | def refresh_devices(self): 91 | if self.access_token is None: 92 | logging.info('No access token, skipping.') 93 | return 94 | 95 | result = self.do_request(self.API_URL) 96 | logging.info(result) 97 | 98 | events = [] 99 | 100 | if 'smoke_co_alarms' in result: 101 | for protect_id, protect_info in result['smoke_co_alarms'].iteritems(): 102 | protect_info['account'] = self.key.string_id() 103 | events.append({ 104 | 'device_type': 'nest_protect', 105 | 'device_id': 'nest-protect-%s' % protect_id, 106 | 'event': protect_info, 107 | }) 108 | 109 | if 'thermostats' in result: 110 | for thermostat_id, thermostat_info in result['thermostats'].iteritems(): 111 | thermostat_info['account'] = self.key.string_id() 112 | events.append({ 113 | 'device_type': 'nest_thermostat', 114 | 'device_id': 'nest-thermostat-%s' % thermostat_id, 115 | 'event': thermostat_info, 116 | }) 117 | 118 | device.process_events(events) 119 | -------------------------------------------------------------------------------- /src/appengine/devices/netatmo.py: -------------------------------------------------------------------------------- 1 | """Integration with nest devices.""" 2 | 3 | import logging 4 | import urllib 5 | 6 | from google.appengine.ext import ndb 7 | 8 | from appengine import account, device, rest 9 | 10 | 11 | @device.register('netatmo_weather_station') 12 | class NetatmoWeatherStation(device.Device): 13 | """A Netatmo Weather Station.""" 14 | temperature = ndb.FloatProperty() 15 | humidity = ndb.FloatProperty() 16 | co2 = ndb.FloatProperty() 17 | pressure = ndb.FloatProperty() 18 | noise = ndb.FloatProperty() 19 | 20 | def get_categories(self): 21 | return ['CLIMATE'] 22 | 23 | def handle_event(self, event): 24 | self.device_name = event['module_name'] 25 | self.account = event['account'] 26 | 27 | self.temperature = event['dashboard_data'].get('Temperature', None) 28 | self.humidity = event['dashboard_data'].get('Humidity', None) 29 | self.co2 = event['dashboard_data'].get('CO2', None) 30 | self.pressure = event['dashboard_data'].get('Pressure', None) 31 | self.noise = event['dashboard_data'].get('Noise', None) 32 | 33 | 34 | @account.register('netatmo') 35 | class NetatmoAccount(account.Account): 36 | """Class represents a Netatmo account.""" 37 | AUTH_URL = ('https://api.netatmo.net/oauth2/authorize?' 38 | 'client_id=%(client_id)s&state=%(state)s') 39 | ACCESS_TOKEN_URL = 'https://api.netatmo.net/oauth2/token' 40 | SCOPES = 'read_station read_thermostat write_thermostat' 41 | API_URL = ('https://api.netatmo.net/api/devicelist?' 42 | 'access_token=%(access_token)s') 43 | 44 | def __init__(self, *args, **kwargs): 45 | super(NetatmoAccount, self).__init__(*args, **kwargs) 46 | 47 | # pylint: disable=invalid-name 48 | from common import creds 49 | self.CLIENT_ID = creds.NETATMO_CLIENT_ID 50 | self.CLIENT_SECRET = creds.NETATMO_CLIENT_SECRET 51 | 52 | def get_human_type(self): 53 | return 'Netatmo' 54 | 55 | def _get_refresh_data(self): 56 | values = {'client_id': self.CLIENT_ID, 57 | 'client_secret': self.CLIENT_SECRET} 58 | 59 | if self.refresh_token is None: 60 | values['grant_type'] = 'authorization_code' 61 | values['code'] = self.auth_code 62 | values['redirect_uri'] = account.REDIRECT_URL 63 | values['scope'] = self.SCOPES 64 | else: 65 | values['grant_type'] = 'refresh_token' 66 | values['refresh_token'] = self.refresh_token 67 | 68 | return urllib.urlencode(values) 69 | 70 | @rest.command 71 | def refresh_devices(self): 72 | if self.access_token is None: 73 | logging.info('No access token, skipping.') 74 | return 75 | 76 | result = self.do_request(self.API_URL) 77 | 78 | events = [] 79 | for details in result['body']['modules']: 80 | if details['type'] not in {'NAModule1', 'NAModule4'}: 81 | continue 82 | 83 | details['account'] = self.key.string_id() 84 | events.append({ 85 | 'device_type': 'netatmo_weather_station', 86 | 'device_id': 'netatmo-%s' % details['_id'], 87 | 'event': details, 88 | }) 89 | 90 | for details in result['body']['devices']: 91 | if details['type'] not in {'NAMain'}: 92 | continue 93 | 94 | details['account'] = self.key.string_id() 95 | events.append({ 96 | 'device_type': 'netatmo_weather_station', 97 | 'device_id': 'netatmo-%s' % details['_id'], 98 | 'event': details, 99 | }) 100 | 101 | device.process_events(events) 102 | -------------------------------------------------------------------------------- /src/appengine/devices/network.py: -------------------------------------------------------------------------------- 1 | """Represents a device on the network - used for presence.""" 2 | import logging 3 | import re 4 | 5 | from google.appengine.ext import ndb 6 | 7 | from appengine import device 8 | from appengine.devices import nest 9 | 10 | 11 | RE = re.compile(r'mac-(.*)') 12 | 13 | 14 | @device.register('network') 15 | class NetworkDevice(device.Device): 16 | """A device on the network.""" 17 | present = ndb.BooleanProperty(required=True, default=False) 18 | 19 | @staticmethod 20 | def update_presence(): 21 | """Set the present status of this device, and 22 | potentially update nest thermostats.""" 23 | # Should we set Nest to be away? 24 | # away = ! (big or all device) 25 | 26 | someone_present = False 27 | for presence_device in device.Device.get_by_capability('PRESENCE').iter(): 28 | someone_present = someone_present or presence_device.present 29 | 30 | logging.info('Is anyone home? %s', someone_present) 31 | for nest_account in nest.NestAccount.query().iter(): 32 | nest_account.set_away(not someone_present) 33 | 34 | def sync(self): 35 | self.update_presence() 36 | 37 | def get_capabilities(self): 38 | return ['PRESENCE'] 39 | 40 | def get_categories(self): 41 | return ['PRESENCE'] 42 | 43 | @classmethod 44 | def handle_static_event(cls, event): 45 | """Handle a device update event.""" 46 | 47 | if 'disappeared' in event: 48 | network_device = NetworkDevice.get_by_id('mac-%s' % event['disappeared']) 49 | if network_device is None: 50 | return 51 | 52 | network_device.present = False 53 | network_device.update_presence() 54 | network_device.put() 55 | 56 | elif 'appeared' in event: 57 | network_device = NetworkDevice.get_by_id('mac-%s' % event['appeared']) 58 | if network_device is None: 59 | return 60 | 61 | network_device.present = True 62 | network_device.update_presence() 63 | network_device.put() 64 | 65 | elif 'devices' in event: 66 | devices = set(event['devices']) 67 | devices_to_put = [] 68 | 69 | for network_device in NetworkDevice.query().iter(): 70 | match = RE.match(network_device.key.string_id()) 71 | assert match is not None 72 | 73 | mac = match.group(1) 74 | present = mac in devices 75 | if network_device.present != present: 76 | network_device.present = present 77 | network_device.update_presence() 78 | devices_to_put.append(network_device) 79 | 80 | ndb.put_multi(devices_to_put) 81 | 82 | -------------------------------------------------------------------------------- /src/appengine/devices/rfswitch.py: -------------------------------------------------------------------------------- 1 | """Generic device driver for 433mhz switches.""" 2 | 3 | from google.appengine.ext import ndb 4 | 5 | from appengine import device, pushrpc 6 | 7 | 8 | @device.register('rfswitch') 9 | class RFSwitch(device.Switch): 10 | """A 433mhz rf switch.""" 11 | system_code = ndb.StringProperty(required=True) 12 | device_code = ndb.IntegerProperty(required=True) 13 | 14 | def sync(self): 15 | event = {'type': 'rfswitch', 'command': 'set_state', 16 | 'system_code': self.system_code, 17 | 'device_code': self.device_code, 'mode': self.state} 18 | pushrpc.send_event(event) 19 | -------------------------------------------------------------------------------- /src/appengine/devices/sonos.py: -------------------------------------------------------------------------------- 1 | """Philips hue integration.""" 2 | 3 | from google.appengine.ext import ndb 4 | 5 | from appengine import device, pushrpc 6 | 7 | 8 | @device.register('sonos') 9 | class SonosDevice(device.Device): 10 | """A hue light.""" 11 | uid = ndb.StringProperty(required=True) 12 | state = ndb.StringProperty() 13 | currently_playing = ndb.JsonProperty() 14 | 15 | def get_categories(self): 16 | return ['MUSIC'] 17 | 18 | @classmethod 19 | @device.static_command 20 | def scan(cls): 21 | event = {'type': 'sonos', 'command': 'scan'} 22 | pushrpc.send_event(event) 23 | 24 | def handle_event(self, event): 25 | """Handle a device update event.""" 26 | self.populate(**event) 27 | 28 | -------------------------------------------------------------------------------- /src/appengine/devices/wemo.py: -------------------------------------------------------------------------------- 1 | """Philips hue integration.""" 2 | 3 | from google.appengine.ext import ndb 4 | 5 | from appengine import device, pushrpc 6 | 7 | 8 | class WemoMixin(object): 9 | """All wemo's have a model and serial number in common.""" 10 | serial_number = ndb.StringProperty(required=True) 11 | model = ndb.StringProperty() 12 | 13 | @classmethod 14 | @device.static_command 15 | def scan(cls): 16 | event = {'type': 'wemo', 'command': 'scan'} 17 | pushrpc.send_event(event) 18 | 19 | def handle_event(self, event): 20 | self.device_name = event['name'] 21 | self.serial_number = event['serial_number'] 22 | self.model = event['model'] 23 | 24 | 25 | @device.register('wemo_motion') 26 | class WemoMotion(device.Device, WemoMixin, device.DetectorMixin): 27 | """A Wemo Motion Sensor""" 28 | 29 | def get_capabilities(self): 30 | return ['OCCUPIED', 'SCAN'] 31 | 32 | def get_categories(self): 33 | return ['CLIMATE'] 34 | 35 | def handle_event(self, event): 36 | """Handle a device update event.""" 37 | WemoMixin.handle_event(self, event) 38 | self.real_occupied_state_change(event['state']) 39 | 40 | 41 | @device.register('wemo_switch') 42 | class WemoSwitch(device.Switch, WemoMixin): 43 | """A Wemo switch.""" 44 | 45 | def get_capabilities(self): 46 | return super(WemoSwitch, self).get_capabilities() + ['SCAN'] 47 | 48 | def sync(self): 49 | """Update the state of a light.""" 50 | event = {'type': 'wemo', 51 | 'command': 'set_state', 52 | 'serial_number': self.serial_number, 53 | 'state': self.state} 54 | pushrpc.send_event(event) 55 | 56 | def handle_event(self, event): 57 | """Handle a device update event.""" 58 | WemoMixin.handle_event(self, event) 59 | self.state = event['state'] 60 | -------------------------------------------------------------------------------- /src/appengine/devices/zwave_drivers/__init__.py: -------------------------------------------------------------------------------- 1 | """Zwave device-specific code.""" 2 | 3 | # This makes the import * in main.py work. 4 | __all__ = ["aeon", "fibaro", "tkb", "vision"] 5 | -------------------------------------------------------------------------------- /src/appengine/devices/zwave_drivers/aeon.py: -------------------------------------------------------------------------------- 1 | """Drivers for Aeon Labs zwave devices.""" 2 | from appengine.devices import zwave 3 | 4 | 5 | @zwave.register(manufacturer_id='0086', product_type='0002', product_id='0005') 6 | class AeonLabsMultiSensor(zwave.Driver): 7 | """Driver for Aeon Labs Multi Sensor.""" 8 | CONFIGURATION = { 9 | ('COMMAND_CLASS_CONFIGURATION', 5): 'Binary Sensor Report', 10 | ('COMMAND_CLASS_CONFIGURATION', 101): 0b11100001, 11 | ('COMMAND_CLASS_CONFIGURATION', 111): 5*60 12 | } 13 | 14 | def get_capabilities(self): 15 | return super(AeonLabsMultiSensor, self).get_capabilities() \ 16 | + ['OCCUPIED'] 17 | 18 | def get_categories(self): 19 | return ['CLIMATE'] 20 | 21 | def value_changed(self, event): 22 | """We've been told a value changed; deal with it.""" 23 | value = event['valueId'] 24 | if value['commandClass'] == 'COMMAND_CLASS_SENSOR_MULTILEVEL': 25 | if value['index'] == 1: 26 | self._device.temperature = value['value'] 27 | elif value['index'] == 3: 28 | self._device.lux = value['value'] 29 | elif value['index'] == 5: 30 | self._device.humidity = value['value'] 31 | 32 | if value['commandClass'] == 'COMMAND_CLASS_SENSOR_BINARY': 33 | self._device.real_occupied_state_change(value['value']) 34 | 35 | -------------------------------------------------------------------------------- /src/appengine/devices/zwave_drivers/fibaro.py: -------------------------------------------------------------------------------- 1 | """Drivers for Fibaro zwave devices.""" 2 | from appengine.devices import zwave 3 | 4 | 5 | @zwave.register(manufacturer_id='010f', product_type='0800', product_id='1001') 6 | class FibaroMotionSensor(zwave.Driver): 7 | """Driver for Fibaro Motion Sensor.""" 8 | CONFIGURATION = { 9 | # Send illumination reports every 5 mins 10 | ('COMMAND_CLASS_CONFIGURATION', 42): 5*60, 11 | 12 | # Send temperature reports every 5 mins 13 | ('COMMAND_CLASS_CONFIGURATION', 64): 5*60, 14 | } 15 | 16 | def get_capabilities(self): 17 | return super(FibaroMotionSensor, self).get_capabilities() + ['OCCUPIED'] 18 | 19 | def get_categories(self): 20 | return ['CLIMATE'] 21 | 22 | def value_changed(self, event): 23 | """We've been told a value changed; deal with it.""" 24 | value = event['valueId'] 25 | if value['commandClass'] == 'COMMAND_CLASS_SENSOR_MULTILEVEL': 26 | if value['index'] == 1: 27 | self._device.temperature = value['value'] 28 | elif value['index'] == 3: 29 | self._device.lux = value['value'] 30 | 31 | if value['commandClass'] == 'COMMAND_CLASS_SENSOR_BINARY': 32 | self._device.real_occupied_state_change(value['value']) 33 | 34 | -------------------------------------------------------------------------------- /src/appengine/devices/zwave_drivers/tkb.py: -------------------------------------------------------------------------------- 1 | """Drivers for TKB zwave devices.""" 2 | 3 | from appengine.devices import zwave 4 | 5 | 6 | @zwave.register(manufacturer_id='0118', product_type='0202', product_id='0611') 7 | class TKBMultilevelPowerSwitch(zwave.Driver): 8 | """Driver for TKB Multilevel power switch.""" 9 | # Device seems to only support 0-100, so scale our 0-255 range 10 | 11 | def get_capabilities(self): 12 | return super(TKBMultilevelPowerSwitch, self).get_capabilities() \ 13 | + ['SWITCH', 'DIMMABLE'] 14 | 15 | def get_categories(self): 16 | return ['LIGHTING'] 17 | 18 | def value_changed(self, event): 19 | """We've been told a value changed; deal with it.""" 20 | value = event['valueId'] 21 | if value['commandClass'] == 'COMMAND_CLASS_SWITCH_MULTILEVEL': 22 | # We treat brightness and state separately, but this 23 | # device does not. Attempt to fake it by ignoring 24 | # brightness going to zero. 25 | if self._device.state and value['value'] > 0: 26 | self._device.brightness = value['value'] * 255 / 100 27 | else: 28 | self._device.state = False 29 | 30 | def sync(self): 31 | ccv = self._device.get_command_class_value( 32 | 'COMMAND_CLASS_SWITCH_MULTILEVEL', 0) 33 | 34 | if self._device.brightness is None: 35 | self._device.brightness = 0 36 | 37 | if self._device.state: 38 | value = self._device.brightness * 100 / 255 39 | else: 40 | value = 0 41 | ccv.set_value(value) 42 | -------------------------------------------------------------------------------- /src/appengine/devices/zwave_drivers/vision.py: -------------------------------------------------------------------------------- 1 | """Drivers for Vision zwave devices.""" 2 | from appengine.devices import zwave 3 | 4 | 5 | @zwave.register(manufacturer_id='0109', product_type='2002', product_id='0203') 6 | class VisionMotionSensor(zwave.Driver): 7 | """Driver for Vision Motion Sensor.""" 8 | #CONFIGURATION = { 9 | # ('COMMAND_CLASS_CONFIGURATION', 5): 'Binary Sensor Report', 10 | # ('COMMAND_CLASS_CONFIGURATION', 101): 0b11100001, 11 | # ('COMMAND_CLASS_CONFIGURATION', 111): 5*60 12 | #} 13 | 14 | def get_capabilities(self): 15 | return super(VisionMotionSensor, self).get_capabilities() + ['OCCUPIED'] 16 | 17 | def get_categories(self): 18 | return ['CLIMATE'] 19 | 20 | def value_changed(self, event): 21 | """We've been told a value changed; deal with it.""" 22 | value = event['valueId'] 23 | if value['commandClass'] == 'COMMAND_CLASS_SENSOR_MULTILEVEL': 24 | if value['index'] == 1: 25 | self._device.temperature = value['value'] 26 | 27 | if value['commandClass'] == 'COMMAND_CLASS_SENSOR_BINARY': 28 | self._device.real_occupied_state_change(value['value']) 29 | 30 | -------------------------------------------------------------------------------- /src/appengine/driver.py: -------------------------------------------------------------------------------- 1 | """List drivers and send them commands.""" 2 | import logging 3 | 4 | import flask 5 | 6 | from appengine import device, rest 7 | 8 | 9 | class Query(object): 10 | def iter(self): 11 | for name, cls in device.DEVICE_TYPES.iteritems(): 12 | yield Driver(name, cls) 13 | 14 | 15 | class Driver(object): 16 | """This is a fake for compatibility with the rest module""" 17 | 18 | def __init__(self, name, cls): 19 | self._name = name 20 | self._cls = cls 21 | 22 | def to_dict(self): 23 | return {'name': self._name} 24 | 25 | # This is a trampoline through to the driver 26 | # mainly for commands 27 | def __getattr__(self, name): 28 | func = getattr(self._cls, name) 29 | if func is None or not getattr(func, 'is_static', False): 30 | logging.error('Command %s does not exist or is not a static command', 31 | name) 32 | flask.abort(400) 33 | return func 34 | 35 | @staticmethod 36 | def put(): 37 | pass 38 | 39 | @staticmethod 40 | def query(): 41 | return Query() 42 | 43 | @staticmethod 44 | def get_by_id(_id): 45 | return Driver(_id, device.DEVICE_TYPES[_id]) 46 | 47 | 48 | # pylint: disable=invalid-name 49 | blueprint = flask.Blueprint('driver', __name__) 50 | rest.register_class(blueprint, Driver, None) 51 | -------------------------------------------------------------------------------- /src/appengine/history.py: -------------------------------------------------------------------------------- 1 | """Save version of objects to dynamodb.""" 2 | import logging 3 | import os 4 | import time 5 | 6 | import boto.dynamodb2 7 | from boto.dynamodb2 import fields 8 | from boto.dynamodb2 import table 9 | from boto.dynamodb2 import types 10 | from boto.dynamodb2 import layer1 11 | 12 | import flask 13 | 14 | from appengine import building 15 | 16 | # Every time we put an object to the appengine datastore, 17 | # we're also going to save a copy to dynamodb. This will 18 | # allow us to graph temperature, occupied state etc over time. 19 | # 20 | # For this, we need to do two things: write versions, 21 | # and read a time ordered list of versions, for a given object 22 | # (and potentially field). 23 | # 24 | # Dynamodb has hash and range keys that point to an 'item' of 25 | # keys and values. Items can't be bigger than 400KB; 26 | # You put 25 items per 'batch', and you get charged per 27 | # batch put AFAICT. Writes are more expensive than reads, 28 | # (by 5x) and more frequent, so we probably want to 29 | # do as much batching (and minimise the distinct 30 | # number of items we use). We will flush all the data 31 | # to dynamodb at the end of each request, so we've got 32 | # quite a bit of flexibility. 33 | # 34 | # We can expect every account to do ~1 write / sec. 35 | # Read will be a couple of orders of magnitude less. 36 | # 37 | # Given all this, we have a couple of design choices: 38 | # 39 | # 0) hashkey = building_id, range key = time uid, 40 | # item = all the stuff flushed in this request 41 | # 42 | # - 1 write per request 43 | # - writes would hotspot with small number of users 44 | # - queries will have to do a lot of work to filter 45 | # out the right data (ie read too much) 46 | # 47 | # 1) Hashkey = object id, Range key = time, 48 | # item = snapshot 49 | # 50 | # - multiple writes per request (but less than 25?) 51 | # - object ids pretty random, so good distribution 52 | # - queries still have to do some filtering, but much 53 | # less 54 | # - single hash keys can grow infinitely large 55 | # 56 | # 2) Hashkey = object id + field name, range key = time, 57 | # item = value 58 | # 59 | # - many writes per request (10 batches?) 60 | # - good write distribution 61 | # - no query filtering 62 | # 63 | # Given above criteria, (0) is probably the best - 64 | # least writes, moving most of the cost query side. 65 | # In this case I'll pick (1) though, as it'll still 66 | # be one batch write, and queries will be easier. 67 | # We'll extend the idea with a time bucket in the hash 68 | # key, to stop hashes grow to large (if this is even 69 | # a problem). 70 | 71 | 72 | TABLE_NAME = 'history' 73 | SCHEMA = [ 74 | fields.HashKey('hash_key'), 75 | fields.RangeKey('range_key', data_type=types.NUMBER), 76 | ] 77 | THROUGHPUT = {'read': 5, 'write': 15} 78 | INDEXES = [ 79 | fields.AllIndex('EverythingIndex', parts=[ 80 | fields.HashKey('hash_key'), 81 | fields.RangeKey('range_key', data_type=types.NUMBER) 82 | ]), 83 | ] 84 | FIELDS_TO_IGNORE = {'class', 'id', 'owner', 'last_update', 'capabilities', 85 | # can't deal with repeated values yet. 86 | 'zwave_command_class_values', 'capabilities', 'detector'} 87 | 88 | def get_history_table(): 89 | """Build the history table, depending on the environment.""" 90 | if False: #os.environ.get('SERVER_SOFTWARE', '').startswith('Development'): 91 | logging.info('Using local dynamodb.') 92 | connection = layer1.DynamoDBConnection( 93 | region='anything', 94 | host='localhost', 95 | port=8100, 96 | aws_access_key_id='anything', 97 | aws_secret_access_key='anything', 98 | is_secure=False 99 | ) 100 | else: 101 | from common import creds 102 | connection = boto.dynamodb2.connect_to_region( 103 | 'us-east-1', 104 | aws_access_key_id=creds.AWS_ACCESS_KEY_ID, 105 | aws_secret_access_key=creds.AWS_SECRET_ACCESS_KEY, 106 | ) 107 | 108 | if TABLE_NAME in connection.list_tables()['TableNames']: 109 | history_table = table.Table( 110 | TABLE_NAME, 111 | schema=SCHEMA, 112 | throughput=THROUGHPUT, 113 | indexes=INDEXES, 114 | connection=connection) 115 | else: 116 | history_table = table.Table.create( 117 | TABLE_NAME, 118 | schema=SCHEMA, 119 | throughput=THROUGHPUT, 120 | indexes=INDEXES, 121 | connection=connection) 122 | 123 | return history_table 124 | 125 | 126 | def store_version(version): 127 | """Post events back to the pi.""" 128 | batch = flask.g.get('history', None) 129 | if batch is None: 130 | batch = [] 131 | setattr(flask.g, 'history', batch) 132 | 133 | building_id = building.get_id() 134 | batch.append((building_id, version)) 135 | 136 | 137 | def store_batch(): 138 | """Push all the events that have been caused by this request.""" 139 | history = flask.g.get('history', None) 140 | setattr(flask.g, 'history', None) 141 | if history is None: 142 | return 143 | 144 | logging.info('Saving %d versions to dynamodb.', len(history)) 145 | 146 | # we might, for some reason, try and store 147 | # two versions of the same objects in a single 148 | # request. We just drop the first in this case. 149 | timestamp = long(time.time() * 1000) 150 | items = {} 151 | 152 | for building_id, version in history: 153 | version['hash_key'] = '%s-%s-%s' % ( 154 | building_id, version['class'], version['id']) 155 | version['range_key'] = timestamp 156 | for key in FIELDS_TO_IGNORE: 157 | version.pop(key, None) 158 | 159 | # Explictly taking copy of keys as we're mutating dict. 160 | # Putting a float doesn't work all the time: 161 | # https://github.com/boto/boto/issues/1531 162 | for key in version.keys(): 163 | value = version[key] 164 | if isinstance(value, (list, dict)): 165 | version[key] = flask.json.dumps(value) 166 | elif isinstance(value, float): 167 | del version[key] 168 | 169 | items[version['hash_key']] = version 170 | 171 | history_table = get_history_table() 172 | 173 | with history_table.batch_write() as batch: 174 | for item in items.itervalues(): 175 | batch.put_item(data=item) 176 | 177 | 178 | def get_range(cls, object_id, start, end): 179 | """Get histroic values for a given object and field.""" 180 | building_id = building.get_id() 181 | history_table = get_history_table() 182 | values = history_table.query_2( 183 | hash_key__eq='%s-%s-%s' % (building_id, cls, object_id), 184 | range_key__gt=start, 185 | range_key__lte=end) 186 | 187 | for value in values: 188 | del value['hash_key'] 189 | yield value 190 | 191 | -------------------------------------------------------------------------------- /src/appengine/main.py: -------------------------------------------------------------------------------- 1 | """Main module for appengine app.""" 2 | import calendar 3 | import datetime 4 | import decimal 5 | import logging 6 | import os 7 | import sys 8 | 9 | from google.appengine.api import users 10 | from google.appengine.ext import ndb 11 | 12 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../third_party')) 13 | 14 | import flask 15 | from boto.dynamodb2 import items 16 | 17 | from appengine import account, device, driver, history 18 | from appengine import pushrpc, room, tasks, user 19 | 20 | # This has the side effect of registering devices 21 | # pylint: disable=unused-wildcard-import,wildcard-import 22 | from appengine.devices import * 23 | from appengine.devices.zwave_drivers import * 24 | 25 | 26 | # Configure logging 27 | logging.getLogger('boto').setLevel(logging.INFO) 28 | 29 | 30 | def static_dir(): 31 | return os.path.normpath(os.path.join(os.path.dirname(__file__), '../static')) 32 | 33 | 34 | class Encoder(flask.json.JSONEncoder): 35 | 36 | def default(self, obj): 37 | if isinstance(obj, datetime.datetime): 38 | if obj.utcoffset() is not None: 39 | obj = obj - obj.utcoffset() 40 | 41 | millis = int( 42 | calendar.timegm(obj.timetuple()) * 1000 + 43 | obj.microsecond / 1000) 44 | 45 | return millis 46 | 47 | elif isinstance(obj, ndb.Key): 48 | return obj.string_id() 49 | 50 | elif isinstance(obj, items.Item): 51 | return {k: v for (k, v) in obj.items()} 52 | 53 | elif isinstance(obj, decimal.Decimal): 54 | return float(obj) 55 | 56 | else: 57 | return flask.json.JSONEncoder.default(self, obj) 58 | 59 | 60 | # pylint: disable=invalid-name 61 | app = flask.Flask('awesomation', static_folder=static_dir()) 62 | app.debug = True 63 | app.json_encoder = Encoder 64 | 65 | # These are not namespaced, and do their own auth 66 | app.register_blueprint(user.blueprint, url_prefix='/api/user') 67 | app.register_blueprint(pushrpc.blueprint, url_prefix='/api/proxy') 68 | app.register_blueprint(tasks.blueprint, url_prefix='/tasks') 69 | 70 | # There are all namespaced, and auth is done in the rest module 71 | app.register_blueprint(account.blueprint, url_prefix='/api/account') 72 | app.register_blueprint(device.blueprint, url_prefix='/api/device') 73 | app.register_blueprint(driver.blueprint, url_prefix='/api/driver') 74 | app.register_blueprint(room.blueprint, url_prefix='/api/room') 75 | 76 | 77 | @app.route('/') 78 | def root(): 79 | user_object = users.get_current_user() 80 | if not user_object: 81 | return flask.redirect(users.create_login_url(flask.request.url)) 82 | return flask.send_from_directory(static_dir(), 'index.html') 83 | 84 | 85 | @app.errorhandler(400) 86 | def custom400(error): 87 | response = flask.jsonify({'message': error.description}) 88 | return response, 400 89 | 90 | 91 | @app.after_request 92 | def after_request(response): 93 | pushrpc.push_batch() 94 | user.push_events() 95 | history.store_batch() 96 | return response 97 | 98 | -------------------------------------------------------------------------------- /src/appengine/model.py: -------------------------------------------------------------------------------- 1 | """Base classes for my data model.""" 2 | import decimal 3 | 4 | from google.appengine.ext import ndb 5 | from google.appengine.ext.ndb import polymodel 6 | 7 | from appengine import history, rest, user 8 | 9 | 10 | # From http://stackoverflow.com/questions/10035133/ndb-decimal-property 11 | class DecimalProperty(ndb.IntegerProperty): 12 | """Decimal property ideal to store currency values, such as $20.34.""" 13 | # See https://developers.google.com/appengine/docs/python/ndb/subclassprop 14 | def _validate(self, value): 15 | if not isinstance(value, (decimal.Decimal, str, unicode, int, long)): 16 | raise TypeError('Expected a Decimal, str, unicode, int ' 17 | 'or long an got instead %s' % repr(value)) 18 | 19 | def _to_base_type(self, value): 20 | return int(decimal.Decimal(value) * 100) 21 | 22 | def _from_base_type(self, value): 23 | return decimal.Decimal(value)/decimal.Decimal(100) 24 | 25 | 26 | class Base(polymodel.PolyModel): 27 | """Base for all objects.""" 28 | 29 | def to_dict(self): 30 | """Convert this object to a python dict.""" 31 | result = super(Base, self).to_dict() 32 | result['id'] = self.key.id() 33 | result['class'] = result['class_'][-1] 34 | del result['class_'] 35 | 36 | # Should move this into detector mixin when I figure out how 37 | if 'detector' in result: 38 | del result['detector'] 39 | return result 40 | 41 | @classmethod 42 | def _event_classname(cls): 43 | return None 44 | 45 | def _put_async(self, **ctx_options): 46 | """Overrides _put_async and sends event to UI.""" 47 | classname = self._event_classname() 48 | if classname is not None: 49 | values = self.to_dict() 50 | user.send_event(cls=classname, id=self.key.string_id(), 51 | event='update', obj=values) 52 | history.store_version(values) 53 | return super(Base, self)._put_async(**ctx_options) 54 | put_async = _put_async 55 | 56 | @rest.command 57 | def get_history(self, start, end): 58 | values = self.to_dict() 59 | return history.get_range(values['class'], values['id'], 60 | start, end) 61 | 62 | def sync(self): 63 | """Called when fields on the object are updated 64 | through the API.""" 65 | pass 66 | -------------------------------------------------------------------------------- /src/appengine/pusher_client.py: -------------------------------------------------------------------------------- 1 | """Abstraction between production pusher and local test.""" 2 | import json 3 | import logging 4 | import os 5 | 6 | from google.appengine.api import urlfetch 7 | 8 | import pusher 9 | 10 | from common import public_creds 11 | from pi import simple_pusher 12 | 13 | 14 | class PusherWrapper(object): 15 | """Wraps the pusher client library, """ 16 | def __init__(self, **kwargs): 17 | # Hack, but some people won't have this (when running locally) 18 | from common import creds 19 | self._pusher_client = pusher.Pusher( 20 | app_id=creds.PUSHER_APP_ID, 21 | key=public_creds.pusher_key, secret=creds.PUSHER_SECRET, 22 | **kwargs) 23 | 24 | def push(self, channel_id, batch): 25 | self._pusher_client[channel_id].trigger('events', batch) 26 | 27 | 28 | class SimplePusherWrapper(object): 29 | def __init__(self, encoder=None): 30 | self._encoder = encoder if encoder is not None else json.JSONEncoder 31 | 32 | def push(self, channel_id, batch): 33 | url = 'http://localhost:%d/%s' % (simple_pusher.HTTP_PORT, channel_id) 34 | payload = self._encoder().encode(batch) 35 | urlfetch.fetch(url=url, payload=payload, method=urlfetch.POST) 36 | 37 | 38 | def should_use_local(): 39 | return os.environ['APPLICATION_ID'].startswith('dev') 40 | 41 | 42 | def get_client(**kwargs): 43 | # If we are running in local mode, 44 | # we want to push events to our local, 45 | # hacked up server. 46 | if should_use_local(): 47 | logging.info('Using local simple pusher.') 48 | return SimplePusherWrapper(**kwargs) 49 | else: 50 | return PusherWrapper(**kwargs) 51 | -------------------------------------------------------------------------------- /src/appengine/pushrpc.py: -------------------------------------------------------------------------------- 1 | """Code to push events to users.""" 2 | import logging 3 | 4 | import flask 5 | import pusher 6 | 7 | from google.appengine.api import namespace_manager 8 | from google.appengine.ext import ndb 9 | 10 | from common import public_creds 11 | from appengine import pusher_client, rest 12 | 13 | 14 | # pylint: disable=invalid-name 15 | blueprint = flask.Blueprint('pushrpc', __name__) 16 | 17 | 18 | @blueprint.before_request 19 | def before_request(): 20 | """Requst here should use authenticated using special device code.""" 21 | 22 | # this endpoing needs normal users 23 | if flask.request.endpoint in {'pushrpc.claim_proxy'}: 24 | rest.default_user_authentication() 25 | return 26 | 27 | # the rest in this module should be proxy-auth 28 | if flask.request.headers.get('awesomation-proxy', None) != 'true': 29 | flask.abort(401) 30 | 31 | proxy = authenticate() 32 | if proxy is None: 33 | flask.abort(401) 34 | 35 | 36 | # Proxies have ids and a secret. 37 | # The proxy authenticates with said ID and secret at 38 | # the HTTP layer, to create a proxy object. It then 39 | # uses this object to create a private pusher channel. 40 | # Events from the proxy are authenticated in the same 41 | # way. 42 | 43 | # Note these proxy object live in the default namespace, 44 | # not the per-user one (so users can't see them). 45 | class Proxy(ndb.Model): 46 | secret = ndb.StringProperty() 47 | building_id = ndb.StringProperty() 48 | 49 | 50 | # Step 1. Create a proxy object with id & secret. 51 | # This happens by default on the first 52 | # request for unknown proxies. 53 | def authenticate(): 54 | """Check this request comes from a valid proxy.""" 55 | assert namespace_manager.get_namespace() == '' 56 | 57 | header = flask.request.headers.get('awesomation-proxy', None) 58 | if header != 'true': 59 | logging.debug('Incorrent header for proxy auth - ' 60 | 'awesomation-proxy = \'%s\'', header) 61 | return None 62 | 63 | if flask.request.endpoint not in {'device.handle_events', 64 | 'pushrpc.pusher_client_auth_callback'}: 65 | logging.debug('Endpoint not allowed for proxy auth - ' 66 | '\'%s\'', flask.request.endpoint) 67 | return None 68 | 69 | auth = flask.request.authorization 70 | if not auth: 71 | logging.error('Proxy auth requires basic auth!') 72 | return None 73 | 74 | proxy = Proxy.get_or_insert( 75 | auth.username, secret=auth.password) 76 | 77 | # if we fetched the proxy, 78 | # need to check the secret. 79 | if proxy.secret != auth.password: 80 | logging.error('Incorrect secret for proxy auth!') 81 | return None 82 | 83 | return proxy 84 | 85 | 86 | # Step 1(b). Users need to claim a proxy from the UI 87 | @blueprint.route('/claim', methods=['POST']) 88 | def claim_proxy(): 89 | """Claim the given proxy id for the current user.""" 90 | body = flask.request.get_json() 91 | if body is None: 92 | flask.abort(400, 'JSON body and mime type required.') 93 | 94 | proxy_id = body.get('proxy_id', None) 95 | if proxy_id is None or proxy_id == '': 96 | flask.abort(400, 'proxy_id required.') 97 | 98 | # this will run as a user, so we don't need to authenticate 99 | # it (already done in main). Running in users namespace. 100 | assert namespace_manager.get_namespace() != '' 101 | building_id = namespace_manager.get_namespace() 102 | 103 | # We need to reset the namespace to access the proxies 104 | namespace_manager.set_namespace(None) 105 | proxy = Proxy.get_by_id(proxy_id) 106 | if proxy == None: 107 | logging.info('Proxy \'%s\' not found', proxy_id) 108 | flask.abort(404) 109 | 110 | if proxy.building_id is not None: 111 | flask.abort(400, 'Proxy already claimed') 112 | 113 | proxy.building_id = building_id 114 | proxy.put() 115 | return ('', 201) 116 | 117 | 118 | # Step 2. Proxy call /api/proxy/channel_auth with its 119 | # (id & secret) auth and channel name == private-id. 120 | # pusher client library makes a callback 121 | # to this end point to check the client 122 | # can use said channel. 123 | @blueprint.route('/channel_auth', methods=['GET']) 124 | def pusher_client_auth_callback(): 125 | """Authenticate a given socket for a given channel.""" 126 | 127 | # Proxies use basic auth 128 | proxy = authenticate() 129 | if proxy is None: 130 | flask.abort(401) 131 | 132 | socket_id = flask.request.args.get('socket_id') 133 | channel_name = flask.request.args.get('channel_name') 134 | if channel_name != 'private-%s' % proxy.key.string_id(): 135 | logging.error('Proxy %s is not allowed channel %s!', proxy, channel_name) 136 | flask.abort(401) 137 | 138 | # Hack, but some people won't have this (when running locally) 139 | from common import creds 140 | client = pusher.Pusher( 141 | app_id=creds.PUSHER_APP_ID, 142 | key=public_creds.pusher_key, secret=creds.PUSHER_SECRET) 143 | auth = client[channel_name].authenticate(socket_id) 144 | 145 | return flask.jsonify(**auth) 146 | 147 | 148 | def send_event(event): 149 | """Post events back to the pi.""" 150 | batch = flask.g.get('events', None) 151 | if batch is None: 152 | batch = [] 153 | setattr(flask.g, 'events', batch) 154 | batch.append(event) 155 | 156 | 157 | def push_batch(): 158 | """Push all the events that have been caused by this request.""" 159 | batch = flask.g.get('events', None) 160 | setattr(flask.g, 'events', None) 161 | if batch is None: 162 | return 163 | 164 | logging.info('Sending %d events to proxy', len(batch)) 165 | pusher_shim = pusher_client.get_client() 166 | 167 | # Now figure out what channel to post these to. 168 | # Building id should always be in the namespace 169 | # for any request which triggers events. 170 | # So we use the namespace. Horrid. 171 | assert namespace_manager.get_namespace() != '' 172 | building_id = namespace_manager.get_namespace() 173 | 174 | try: 175 | namespace_manager.set_namespace(None) 176 | proxies = Proxy.query(Proxy.building_id == building_id).iter() 177 | for proxy in proxies: 178 | channel_id = 'private-%s' % proxy.key.string_id() 179 | logging.info('Pushing %d events to channel %s', len(batch), channel_id) 180 | pusher_shim.push(channel_id, batch) 181 | finally: 182 | namespace_manager.set_namespace(building_id) 183 | 184 | -------------------------------------------------------------------------------- /src/appengine/rest.py: -------------------------------------------------------------------------------- 1 | """A generic rest serving layer for NDB models.""" 2 | import logging 3 | import sys 4 | 5 | from google.appengine.api import namespace_manager 6 | from google.appengine.ext import db 7 | 8 | import flask 9 | import flask.views 10 | 11 | from appengine import user 12 | 13 | 14 | def command(func): 15 | """Command decorator - automatically dispatches methods.""" 16 | setattr(func, 'is_command', True) 17 | return func 18 | 19 | 20 | class ClassView(flask.views.MethodView): 21 | """Implements create, retrieve, update and removed endpoints for models.""" 22 | 23 | def __init__(self, classname, cls, create_callback): 24 | super(ClassView, self).__init__() 25 | self._classname = classname 26 | self._cls = cls 27 | self._create_callback = create_callback 28 | 29 | def get(self, object_id): 30 | """List objects or just return a single object.""" 31 | default_user_authentication() 32 | 33 | if object_id is None: 34 | # Return json list of objects. 35 | object_list = self._cls.query().iter() 36 | if object_list is None: 37 | object_list = [] 38 | 39 | object_list = [obj.to_dict() for obj in object_list] 40 | return flask.jsonify(objects=object_list) 41 | 42 | else: 43 | # Return json repr of given object 44 | obj = self._cls.get_by_id(object_id) 45 | 46 | if not obj: 47 | flask.abort(404) 48 | 49 | return flask.jsonify(**obj.to_dict()) 50 | 51 | def post(self, object_id): 52 | """Using json body to create or update a object.""" 53 | default_user_authentication() 54 | 55 | body = flask.request.get_json() 56 | if body is None: 57 | flask.abort(400, 'JSON body and mime type required.') 58 | logging.info("Creating (or updating) object - %s", body) 59 | 60 | obj = self._cls.get_by_id(object_id) 61 | 62 | if not obj and self._create_callback is None: 63 | flask.abort(403) 64 | elif not obj: 65 | obj = self._create_callback(object_id, body) 66 | 67 | # Update the object; abort with 400 on unknown field 68 | try: 69 | obj.populate(**body) 70 | except AttributeError: 71 | logging.error('Exception populating object', exc_info=sys.exc_info()) 72 | flask.abort(400) 73 | 74 | obj.sync() 75 | 76 | # Put the object - BadValueError if there are uninitalised required fields 77 | try: 78 | obj.put() 79 | except db.BadValueError: 80 | logging.error('Exception saving object', exc_info=sys.exc_info()) 81 | flask.abort(400) 82 | 83 | values = obj.to_dict() 84 | return flask.jsonify(**values) 85 | 86 | def delete(self, object_id): 87 | """Delete an object.""" 88 | default_user_authentication() 89 | 90 | obj = self._cls.get_by_id(object_id) 91 | 92 | if not obj: 93 | flask.abort(404) 94 | 95 | obj.key.delete() 96 | user.send_event(cls=self._classname, id=object_id, event='delete') 97 | return ('', 204) 98 | 99 | 100 | class CommandView(flask.views.MethodView): 101 | """Implements /command endpoints for models.""" 102 | 103 | def __init__(self, classname, cls): 104 | super(CommandView, self).__init__() 105 | self._classname = classname 106 | self._cls = cls 107 | 108 | def post(self, object_id): 109 | """Run a command on a object.""" 110 | default_user_authentication() 111 | 112 | body = flask.request.get_json() 113 | if body is None: 114 | flask.abort(400, 'JSON body and mime type required.') 115 | 116 | logging.info(body) 117 | obj = self._cls.get_by_id(object_id) 118 | 119 | if not obj: 120 | flask.abort(404) 121 | 122 | func_name = body.pop('command', None) 123 | func = getattr(obj, func_name, None) 124 | if func is None or not getattr(func, 'is_command', False): 125 | logging.error('Command %s does not exist or is not a command', 126 | func_name) 127 | flask.abort(400) 128 | 129 | result = func(**body) 130 | obj.put() 131 | return flask.jsonify(result=result) 132 | 133 | 134 | class HistoryView(flask.views.MethodView): 135 | """Implements /history endpoints for models.""" 136 | 137 | def __init__(self, classname, cls): 138 | super(HistoryView, self).__init__() 139 | self._classname = classname 140 | self._cls = cls 141 | 142 | def post(self, object_id): 143 | """Fetch the history for an object.""" 144 | default_user_authentication() 145 | 146 | body = flask.request.get_json() 147 | if body is None: 148 | flask.abort(400, 'JSON body and mime type required.') 149 | 150 | start_time = body.pop('start_time', None) 151 | end_time = body.pop('end_time', None) 152 | if start_time is None or end_time is None: 153 | flask.abort(400, 'start_time and end_time expected.') 154 | 155 | obj = self._cls.get_by_id(object_id) 156 | if not obj: 157 | flask.abort(404) 158 | 159 | result = obj.get_history(start=start_time, end=end_time) 160 | result = list(result) 161 | return flask.jsonify(result=result) 162 | 163 | 164 | def default_user_authentication(): 165 | """Ensure user is authenticated, and switch to 166 | appropriate building namespace.""" 167 | 168 | user_object = user.get_user_object() 169 | if not user_object: 170 | return flask.abort(401) 171 | 172 | # Need to pick a building for this user request 173 | person = user.get_person() 174 | buildings = list(person.buildings) 175 | assert len(buildings) > 0 176 | buildings.sort() 177 | 178 | if 'building-id' in flask.request.headers: 179 | building_id = flask.request.headers['building-id'] 180 | if building_id not in buildings: 181 | flask.abort(401) 182 | else: 183 | building_id = buildings[0] 184 | 185 | namespace_manager.set_namespace(building_id) 186 | 187 | 188 | def register_class(blueprint, cls, create_callback): 189 | """Register a ndb model for rest endpoints.""" 190 | 191 | # register some handlers 192 | class_view_func = ClassView.as_view('%s_crud' % cls.__name__, 193 | blueprint.name, cls, create_callback) 194 | blueprint.add_url_rule('/', defaults={'object_id': None}, 195 | view_func=class_view_func, methods=['GET',]) 196 | blueprint.add_url_rule('/', view_func=class_view_func, 197 | methods=['GET', 'POST', 'DELETE']) 198 | 199 | command_view_func = CommandView.as_view('%s_command' % cls.__name__, 200 | blueprint.name, cls) 201 | blueprint.add_url_rule('//command', methods=['POST'], 202 | view_func=command_view_func) 203 | 204 | history_view_func = HistoryView.as_view('%s_history' % cls.__name__, 205 | blueprint.name, cls) 206 | blueprint.add_url_rule('//history', methods=['POST'], 207 | view_func=history_view_func) 208 | -------------------------------------------------------------------------------- /src/appengine/tasks.py: -------------------------------------------------------------------------------- 1 | """Regular update task.""" 2 | import logging 3 | import sys 4 | 5 | from google.appengine.ext.ndb import metadata 6 | from google.appengine.api import namespace_manager 7 | 8 | import flask 9 | 10 | from appengine import account, pushrpc, room, user 11 | 12 | 13 | # pylint: disable=invalid-name 14 | blueprint = flask.Blueprint('tasks', __name__) 15 | 16 | 17 | @blueprint.before_request 18 | def before_request(): 19 | # Cron jobs are authenticated as a special case 20 | if flask.request.headers.get('X-AppEngine-Cron', None) != 'true': 21 | flask.abort(401) 22 | 23 | 24 | def _update_per_namespace(): 25 | """Do a bunch of periodic stuff for a user.""" 26 | 27 | for acc in account.Account.query().iter(): 28 | try: 29 | acc.refresh_devices() 30 | acc.put() 31 | except: 32 | logging.error('Error refreshing account %s', 33 | acc.key.string_id(), exc_info=sys.exc_info()) 34 | 35 | for _room in room.Room.query().iter(): 36 | try: 37 | _room.update_lights() 38 | except: 39 | logging.error('Error updating room %s', 40 | _room.name, exc_info=sys.exc_info()) 41 | 42 | 43 | def update_per_namespace(): 44 | try: 45 | _update_per_namespace() 46 | except: 47 | logging.error('Error updating for user', exc_info=sys.exc_info()) 48 | finally: 49 | pushrpc.push_batch() 50 | user.push_events() 51 | 52 | 53 | @blueprint.route('/update', methods=['GET']) 54 | def update(): 55 | """Iterate through all the users and do stuff.""" 56 | for namespace in metadata.get_namespaces(): 57 | logging.info('Switching namespace: \'%s\'', namespace) 58 | namespace_manager.set_namespace(namespace) 59 | update_per_namespace() 60 | 61 | namespace_manager.set_namespace('') 62 | return ('', 204) 63 | -------------------------------------------------------------------------------- /src/common/__init__.py: -------------------------------------------------------------------------------- 1 | """Stuff that doesn't depend on appengine.""" 2 | -------------------------------------------------------------------------------- /src/common/creds.example: -------------------------------------------------------------------------------- 1 | """Private credentials.""" 2 | 3 | PUSHER_APP_ID = None 4 | PUSHER_SECRET = None 5 | 6 | NEST_CLIENT_ID = None 7 | NEST_CLIENT_SECRET = None 8 | 9 | NETATMO_CLIENT_ID = None 10 | NETATMO_CLIENT_SECRET = None 11 | 12 | AWS_ACCESS_KEY_ID = None 13 | AWS_SECRET_ACCESS_KEY = None 14 | -------------------------------------------------------------------------------- /src/common/detector.py: -------------------------------------------------------------------------------- 1 | """A Phi accrual failure detector.""" 2 | import decimal 3 | import logging 4 | import math 5 | import sys 6 | import time 7 | 8 | 9 | class AccrualFailureDetector(object): 10 | """ Python implementation of 'The Phi Accrual Failure Detector' 11 | by Hayashibara et al. 12 | 13 | * Taken from https://github.com/rschildmeijer/elastica/blob/ 14 | a41f9427f80b5207891597ec430e76949e4948df/elastica/afd.py 15 | * Licensed under under Apache version 2 according to the README.rst. 16 | * Original version by Brandon Williams (github.com/driftx) 17 | * modified by Roger Schildmeijer (github.com/rchildmeijer)) 18 | 19 | Failure detection is the process of determining which nodes in 20 | a distributed fault-tolerant system have failed. Original Phi 21 | Accrual Failure Detection paper: http://ddg.jaist.ac.jp/pub/HDY+04.pdf 22 | 23 | A low threshold is prone to generate many wrong suspicions but 24 | ensures a quick detection in the event of a real crash. Conversely, 25 | a high threshold generates fewer mistakes but needs more time to 26 | detect actual crashes. 27 | 28 | We use the algorithm to self-tune sensor timeouts for presence. 29 | """ 30 | 31 | max_sample_size = 1000 32 | # 1 = 10% error rate, 2 = 1%, 3 = 0.1%.., (eg threshold=3. no 33 | # heartbeat for >6s => node marked as dead 34 | threshold = 3 35 | 36 | def __init__(self): 37 | self._intervals = [] 38 | self._mean = 60 39 | self._timestamp = None 40 | 41 | @classmethod 42 | def from_dict(cls, values): 43 | detector = cls() 44 | detector._intervals = values['intervals'] 45 | detector._mean = values['mean'] 46 | detector._timestamp = values['timestamp'] 47 | return detector 48 | 49 | def to_dict(self): 50 | return { 51 | 'intervals': self._intervals, 52 | 'mean': self._mean, 53 | 'timestamp': self._timestamp 54 | } 55 | 56 | def heartbeat(self, now=None): 57 | """ Call when host has indicated being alive (aka heartbeat) """ 58 | if now is None: 59 | now = time.time() 60 | 61 | if self._timestamp is None: 62 | self._timestamp = now 63 | return 64 | 65 | interval = now - self._timestamp 66 | self._timestamp = now 67 | self._intervals.append(interval) 68 | 69 | if len(self._intervals) > self.max_sample_size: 70 | self._intervals.pop(0) 71 | 72 | if len(self._intervals) > 0: 73 | self._mean = sum(self._intervals) / float(len(self._intervals)) 74 | logging.debug('mean = %s', self._mean) 75 | 76 | def _probability(self, diff): 77 | if self._mean == 0: 78 | # we've only seen one heartbeat 79 | # so use a different formula 80 | # for probability 81 | return sys.float_info.max 82 | 83 | # cassandra does this, citing: /* Exponential CDF = 1 -e^-lambda*x */ 84 | # but the paper seems to call for a probability density function 85 | # which I can't figure out :/ 86 | exponent = -1.0 * diff / self._mean 87 | return 1 - (1.0 - math.pow(math.e, exponent)) 88 | 89 | def phi(self, timestamp=None): 90 | if self._timestamp is None: 91 | # we've never seen a heartbeat, 92 | # so it must be missing... 93 | return self.threshold 94 | 95 | if timestamp is None: 96 | timestamp = time.time() 97 | 98 | diff = timestamp - self._timestamp 99 | prob = self._probability(diff) 100 | logging.debug('Proability = %s', prob) 101 | if decimal.Decimal(str(prob)).is_zero(): 102 | # a very small number, avoiding ValueError: math domain error 103 | prob = 1E-128 104 | return -1 * math.log10(prob) 105 | 106 | def is_alive(self, timestamp=None): 107 | phi = self.phi(timestamp) 108 | logging.debug('Phi = %s', phi) 109 | return phi < self.threshold 110 | 111 | def is_dead(self, timestamp=None): 112 | return not self.is_alive(timestamp) 113 | -------------------------------------------------------------------------------- /src/common/detector_tests.py: -------------------------------------------------------------------------------- 1 | """Tests for detector.py""" 2 | import logging 3 | import unittest 4 | 5 | from common import detector 6 | 7 | 8 | class TestDetector(unittest.TestCase): 9 | """.""" 10 | 11 | def test_new_detector(self): 12 | """.""" 13 | 14 | dtor = detector.AccrualFailureDetector() 15 | self.assertTrue(dtor.is_dead(timestamp=0)) 16 | 17 | dtor.heartbeat(now=0) 18 | self.assertTrue(dtor.is_alive(timestamp=1)) 19 | 20 | # check the detector is off after 7 mins 21 | # default interval is a min 22 | self.assertTrue(dtor.is_dead(timestamp=7 * 60)) 23 | 24 | def test_intermittent(self): 25 | def test_at_interval(interval): 26 | logging.debug('Testing at %d intervals', interval) 27 | dtor = detector.AccrualFailureDetector() 28 | dtor.heartbeat(now=0) 29 | dtor.heartbeat(now=interval) 30 | 31 | for i in xrange(2, 100): 32 | dt = i*interval 33 | logging.debug('Testing alive at %d', dt) 34 | self.assertTrue(dtor.is_alive(timestamp=dt)) 35 | dtor.heartbeat(now=dt) 36 | 37 | dt = 107 * interval 38 | logging.debug('Testing dead at %d', dt) 39 | self.assertTrue(dtor.is_dead(timestamp=dt)) 40 | 41 | for i in xrange(1, 20): 42 | test_at_interval(i*60) 43 | 44 | def test_ramp(self): 45 | dt = 0 46 | dtor = detector.AccrualFailureDetector() 47 | dtor.heartbeat(now=dt) 48 | 49 | for i in xrange(1, 30): 50 | dt += i * 60 51 | logging.debug('Is alive at %d', dt) 52 | self.assertTrue(dtor.is_alive(timestamp=dt)) 53 | dtor.heartbeat(now=dt) 54 | 55 | # will have learnt an average interval of 15mins 56 | 57 | dt += 7 * 15 * 60 58 | logging.debug('Is dead at %d', dt) 59 | self.assertTrue(dtor.is_dead(timestamp=dt)) 60 | 61 | 62 | if __name__ == '__main__': 63 | unittest.main() 64 | -------------------------------------------------------------------------------- /src/common/public_creds.py: -------------------------------------------------------------------------------- 1 | """Public credentials.""" 2 | 3 | pusher_key = '58c733f69ae8d5b639e0' 4 | appengine_app_id = 'homeawesomation' 5 | -------------------------------------------------------------------------------- /src/common/utils.py: -------------------------------------------------------------------------------- 1 | """Bunch of random util functions.""" 2 | import flask 3 | 4 | 5 | def limit_json_batch(events, max_size=10240): 6 | """Given a list of stuff, yeild lists of stuff 7 | which, when json encoded, would be 8 | of length less than max_size.""" 9 | 10 | # First lets go through and encode all the events 11 | encoded_events = [flask.json.dumps(event) for event in events] 12 | start = 0 13 | end = 0 14 | acc = 2 # start and end braces 15 | 16 | while end < len(encoded_events): 17 | event_length = len(encoded_events[end]) 18 | assert event_length < max_size, encoded_events[end] 19 | 20 | # +1 for comma 21 | if acc + event_length + 1 < max_size: 22 | end += 1 23 | acc += event_length + 1 24 | continue 25 | 26 | # we have to yeild start..end, and they can't be the same 27 | assert start < end 28 | 29 | result = '[%s]' % (','.join(encoded_events[start:end])) 30 | assert len(result) < max_size 31 | yield events[start:end] 32 | start = end 33 | acc = 2 34 | 35 | assert start <= end 36 | if start != end: 37 | result = '[%s]' % (','.join(encoded_events[start:end])) 38 | assert len(result) < max_size 39 | yield events[start:end] 40 | -------------------------------------------------------------------------------- /src/common/utils_tests.py: -------------------------------------------------------------------------------- 1 | """Tests for utils.py""" 2 | import unittest 3 | 4 | from common import utils 5 | 6 | 7 | class TestUtils(unittest.TestCase): 8 | """.""" 9 | 10 | def test_limit_json_batch(self): 11 | """Tests for limit_json_batch function.""" 12 | 13 | self.assertEquals(list(utils.limit_json_batch([])), []) 14 | self.assertEquals(list(utils.limit_json_batch([1])), [[1]]) 15 | 16 | long_string = '0' * (10 * 1000) 17 | self.assertEquals(list(utils.limit_json_batch([long_string])), 18 | [[long_string]]) 19 | self.assertEquals(list(utils.limit_json_batch([long_string, long_string])), 20 | [[long_string], [long_string]]) 21 | 22 | 23 | if __name__ == '__main__': 24 | unittest.main() 25 | -------------------------------------------------------------------------------- /src/cron.yaml: -------------------------------------------------------------------------------- 1 | cron: 2 | - description: 5 minute update cycle 3 | url: /tasks/update 4 | schedule: every 5 minutes 5 | -------------------------------------------------------------------------------- /src/iphone/Awesomation/Awesomation.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/iphone/Awesomation/Awesomation/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Awesomation 4 | // 5 | // Created by Tom Wilkie on 29/01/2015. 6 | // Copyright (c) 2015 Awesomation. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | var window: UIWindow? 15 | 16 | func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool { 17 | return true 18 | } 19 | 20 | func applicationWillResignActive(application: UIApplication) { 21 | NSLog("applicationWillResignActive") 22 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 23 | // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. 24 | } 25 | 26 | func applicationDidEnterBackground(application: UIApplication) { 27 | NSLog("applicationDidEnterBackground") 28 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 29 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. 30 | } 31 | 32 | func applicationWillEnterForeground(application: UIApplication) { 33 | NSLog("applicationWillEnterForeground") 34 | // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background. 35 | } 36 | 37 | func applicationDidBecomeActive(application: UIApplication) { 38 | NSLog("applicationDidBecomeActive") 39 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 40 | } 41 | 42 | func applicationWillTerminate(application: UIApplication) { 43 | NSLog("applicationWillTerminate") 44 | 45 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 46 | } 47 | 48 | 49 | } 50 | 51 | -------------------------------------------------------------------------------- /src/iphone/Awesomation/Awesomation/Awesomation-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to expose to Swift. 3 | // 4 | 5 | #import "GTMOAuth2ViewControllerTouch.h" 6 | #import "AFNetworking.h" 7 | -------------------------------------------------------------------------------- /src/iphone/Awesomation/Awesomation/Awesomation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Awesomation.swift 3 | // Awesomation 4 | // 5 | // Created by Tom Wilkie on 29/01/2015. 6 | // Copyright (c) 2015 Awesomation. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class Awesomation { 12 | 13 | struct Constants { 14 | static let BASE_URL = NSURL(string: "https://homeawesomation.appspot.com") 15 | static let TYPES = ["device"] 16 | } 17 | 18 | typealias AwesomObject = [String: AnyObject] 19 | 20 | var auth: GTMOAuth2Authentication 21 | var config: NSURLSessionConfiguration? 22 | var manager: AFHTTPRequestOperationManager 23 | var cache: [String: [String: AwesomObject]] 24 | 25 | init(auth:GTMOAuth2Authentication) { 26 | self.auth = auth 27 | self.manager = AFHTTPRequestOperationManager(baseURL: Constants.BASE_URL) 28 | self.manager.requestSerializer = AFJSONRequestSerializer(writingOptions: NSJSONWritingOptions()) 29 | self.manager.responseSerializer = AFJSONResponseSerializer() 30 | self.cache = [:] 31 | fetch() 32 | } 33 | 34 | func withToken(block: (Void -> Any)) { 35 | var fakerequest = NSMutableURLRequest() 36 | self.auth.authorizeRequest(fakerequest, { (error) in 37 | self.manager.requestSerializer.setValue("Bearer \(self.auth.accessToken)", forHTTPHeaderField:"Authorization") 38 | 39 | block() 40 | }) 41 | } 42 | 43 | func get(path: String, success: ([String: AnyObject] -> Void)) { 44 | withToken({ 45 | self.manager.GET(path, parameters: nil, success: { (op, result) in 46 | success(result as [String: AnyObject]) 47 | }, failure: { (op, error) in 48 | println(error) 49 | }) 50 | }) 51 | } 52 | 53 | func post(path: String, data: [String: String], success: ([String: AnyObject] -> Void)) { 54 | var data = NSJSONSerialization.dataWithJSONObject(data, options:nil, error:nil) 55 | 56 | withToken({ 57 | self.manager.GET(path, parameters: data, success: { (op, result) in 58 | success(result as [String: AnyObject]) 59 | }, failure: { (op, error) in 60 | println(error) 61 | }) 62 | }) 63 | } 64 | 65 | func fetch() { 66 | for type in Constants.TYPES { 67 | get("/api/\(type)/", { (result) in 68 | var objects = result["objects"] as [AwesomObject] 69 | NSLog("Loaded \(objects.count) \(type)s") 70 | var entry : [String: AwesomObject] = [:] 71 | self.cache[type] = entry 72 | for object in objects { 73 | var id = object["id"] as String 74 | entry[id] = object 75 | } 76 | }) 77 | } 78 | } 79 | } 80 | 81 | -------------------------------------------------------------------------------- /src/iphone/Awesomation/Awesomation/Base.lproj/LaunchScreen.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 20 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/iphone/Awesomation/Awesomation/Images.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "29x29", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "29x29", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "40x40", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "40x40", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "60x60", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "60x60", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "ipad", 35 | "size" : "29x29", 36 | "scale" : "1x" 37 | }, 38 | { 39 | "idiom" : "ipad", 40 | "size" : "29x29", 41 | "scale" : "2x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "40x40", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "40x40", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "76x76", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "76x76", 61 | "scale" : "2x" 62 | } 63 | ], 64 | "info" : { 65 | "version" : 1, 66 | "author" : "xcode" 67 | } 68 | } -------------------------------------------------------------------------------- /src/iphone/Awesomation/Awesomation/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | awesomation.$(PRODUCT_NAME:rfc1034identifier) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIMainStoryboardFile 28 | Main 29 | UIRequiredDeviceCapabilities 30 | 31 | armv7 32 | 33 | UISupportedInterfaceOrientations 34 | 35 | UIInterfaceOrientationPortrait 36 | UIInterfaceOrientationLandscapeLeft 37 | UIInterfaceOrientationLandscapeRight 38 | 39 | NSLocationAlwaysUsageDescription 40 | 41 | UISupportedInterfaceOrientations~ipad 42 | 43 | UIInterfaceOrientationPortrait 44 | UIInterfaceOrientationPortraitUpsideDown 45 | UIInterfaceOrientationLandscapeLeft 46 | UIInterfaceOrientationLandscapeRight 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/iphone/Awesomation/Awesomation/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // Awesomation 4 | // 5 | // Created by Tom Wilkie on 29/01/2015. 6 | // Copyright (c) 2015 Awesomation. All rights reserved. 7 | // 8 | 9 | import AddressBookUI 10 | import CoreLocation 11 | import UIKit 12 | 13 | 14 | class ViewController: UIViewController, CLLocationManagerDelegate { 15 | 16 | struct Constants { 17 | static let KEYCHAIN_ITEM_NAME = "Awesomeation Credentials" 18 | static let SCOPE = "https://www.googleapis.com/auth/userinfo.email" 19 | static let RADIUS: CLLocationDistance = 10 20 | static let HOME = "Home" 21 | static let YOU_ARE_HOME = "You are home." 22 | static let YOU_ARE_NOT_HOME = "You are not home." 23 | static let ESTIMOTE_UUID = "B9407F30-F5F8-466E-AFF9-25556B57FE6D" 24 | static let ESTIMOTE_BEACONS = "Estimote Beacons" 25 | } 26 | 27 | var locationManager: CLLocationManager! 28 | var currentLocation: CLLocation! 29 | var geocoder: CLGeocoder? 30 | var auth: GTMOAuth2Authentication? 31 | 32 | var home: CLCircularRegion? 33 | var homeAddress: String? 34 | var amInHome = false 35 | 36 | @IBOutlet weak var homeTextView: UITextView! 37 | @IBOutlet weak var homeStateView: UILabel! 38 | 39 | override func viewDidLoad() { 40 | super.viewDidLoad() 41 | 42 | locationManager = CLLocationManager() 43 | locationManager.delegate = self 44 | locationManager.desiredAccuracy = kCLLocationAccuracyBest 45 | locationManager.requestAlwaysAuthorization() 46 | locationManager.startUpdatingLocation() 47 | 48 | var estimoteBeacons = CLBeaconRegion(proximityUUID: NSUUID(UUIDString: Constants.ESTIMOTE_UUID), 49 | identifier: Constants.ESTIMOTE_BEACONS) 50 | estimoteBeacons.notifyEntryStateOnDisplay = true 51 | locationManager.startMonitoringForRegion(estimoteBeacons) 52 | locationManager.startRangingBeaconsInRegion(estimoteBeacons) 53 | 54 | geocoder = CLGeocoder() 55 | 56 | updateHome() 57 | } 58 | 59 | override func didReceiveMemoryWarning() { 60 | super.didReceiveMemoryWarning() 61 | // Dispose of any resources that can be recreated. 62 | } 63 | 64 | override func viewDidAppear(animated: Bool) { 65 | self.doAuth(); 66 | } 67 | 68 | func updateUI() { 69 | dispatch_async(dispatch_get_main_queue(), { 70 | self.homeTextView.text = self.homeAddress 71 | self.homeStateView.text = self.amInHome ? Constants.YOU_ARE_HOME : Constants.YOU_ARE_NOT_HOME 72 | }) 73 | } 74 | 75 | func findRegionByName(name: String) -> CLRegion? { 76 | for region in locationManager.monitoredRegions { 77 | var region = region as CLRegion 78 | if region.identifier == name { 79 | return region 80 | } 81 | } 82 | return nil 83 | } 84 | 85 | func updateHome() { 86 | self.home = findRegionByName(Constants.HOME) as? CLCircularRegion; 87 | if home == nil { 88 | self.homeAddress = "None" 89 | self.updateUI() 90 | return 91 | } 92 | 93 | var location = CLLocation(latitude:home!.center.latitude, longitude:home!.center.longitude) 94 | geocoder?.reverseGeocodeLocation(location, completionHandler: { (placemarks, error) -> Void in 95 | if placemarks == nil || placemarks!.count == 0 { 96 | self.homeAddress = "\(location)" 97 | } else { 98 | var placemark = placemarks[0] as CLPlacemark 99 | self.homeAddress = ABCreateStringWithAddressDictionary(placemark.addressDictionary, false) 100 | } 101 | self.updateUI() 102 | }) 103 | } 104 | 105 | @IBAction func setCurrentLocationAsHome(sender: AnyObject) { 106 | var radius = Constants.RADIUS 107 | var homeLocation = currentLocation 108 | 109 | if currentLocation == nil { 110 | NSLog("Current location is nil") 111 | return 112 | } 113 | 114 | if radius > locationManager.maximumRegionMonitoringDistance { 115 | radius = locationManager.maximumRegionMonitoringDistance; 116 | } 117 | 118 | // Create the geographic region to be monitored. 119 | var region = CLCircularRegion( 120 | circularRegionWithCenter: currentLocation.coordinate, 121 | radius: radius, identifier: Constants.HOME) 122 | 123 | NSLog("startMonitoringForRegion \(region)") 124 | locationManager.startMonitoringForRegion(region) 125 | locationManager.requestStateForRegion(region) 126 | 127 | self.updateHome() 128 | } 129 | 130 | func locationManager(manager: CLLocationManager!, didUpdateLocations locations: [AnyObject]!) { 131 | NSLog("didUpdateLocations \(locations)") 132 | 133 | if currentLocation == nil { 134 | currentLocation = locations[locations.count - 1] as CLLocation 135 | locationManager.stopUpdatingLocation() 136 | } 137 | } 138 | 139 | func locationManager(manager: CLLocationManager!, didDetermineState state: CLRegionState, forRegion region: CLRegion!) { 140 | if region.identifier == Constants.HOME { 141 | amInHome = state == CLRegionState.Inside 142 | updateUI() 143 | return 144 | } 145 | 146 | var beaconRegion = region as? CLBeaconRegion 147 | if beaconRegion == nil { 148 | return 149 | } 150 | 151 | NSLog("\(beaconRegion)") 152 | } 153 | 154 | func locationManager(manager: CLLocationManager!, didRangeBeacons beacons: [AnyObject]!, inRegion region: CLBeaconRegion!) { 155 | NSLog("\(beacons)") 156 | } 157 | 158 | func doAuth() { 159 | self.auth = GTMOAuth2ViewControllerTouch.authForGoogleFromKeychainForName( 160 | Constants.KEYCHAIN_ITEM_NAME, 161 | clientID:Credentials.GOOGLE_CLIENT_ID, 162 | clientSecret:Credentials.GOOGLE_CLIENT_SECRET) 163 | 164 | if self.auth != nil && self.auth!.canAuthorize { 165 | NSLog("Loaded auth cookie from keychain") 166 | getDevices() 167 | return 168 | } 169 | 170 | var gtmOauthView = GTMOAuth2ViewControllerTouch( 171 | scope:Constants.SCOPE, 172 | clientID:Credentials.GOOGLE_CLIENT_ID, 173 | clientSecret:Credentials.GOOGLE_CLIENT_SECRET, 174 | keychainItemName:Constants.KEYCHAIN_ITEM_NAME, 175 | completionHandler: {(viewController, auth, error) in 176 | // Get rid of the login view. 177 | // self.parentViewController was saved somewhere else and is the parent 178 | // view controller of the view controller that shows the google login view. 179 | //self.navigationController?.dismissViewControllerAnimated(true, completion: nil) 180 | //viewController.removeFromParentViewController() 181 | 182 | if error != nil { 183 | // Authentication failed 184 | } else { 185 | self.auth = auth 186 | self.getDevices() 187 | } 188 | }) 189 | 190 | self.navigationController!.pushViewController(gtmOauthView, animated: true) 191 | } 192 | 193 | @IBAction func doLogout(sender: AnyObject) { 194 | NSLog("doLogout") 195 | GTMOAuth2ViewControllerTouch.removeAuthFromKeychainForName(Constants.KEYCHAIN_ITEM_NAME) 196 | doAuth() 197 | } 198 | 199 | func getDevices() { 200 | NSLog("getDevices") 201 | var awesomation = Awesomation(auth:self.auth!) 202 | } 203 | } 204 | 205 | -------------------------------------------------------------------------------- /src/iphone/Awesomation/AwesomationTests/AwesomationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AwesomationTests.swift 3 | // AwesomationTests 4 | // 5 | // Created by Tom Wilkie on 29/01/2015. 6 | // Copyright (c) 2015 Awesomation. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import XCTest 11 | 12 | class AwesomationTests: XCTestCase { 13 | 14 | override func setUp() { 15 | super.setUp() 16 | // Put setup code here. This method is called before the invocation of each test method in the class. 17 | } 18 | 19 | override func tearDown() { 20 | // Put teardown code here. This method is called after the invocation of each test method in the class. 21 | super.tearDown() 22 | } 23 | 24 | func testExample() { 25 | // This is an example of a functional test case. 26 | XCTAssert(true, "Pass") 27 | } 28 | 29 | func testPerformanceExample() { 30 | // This is an example of a performance test case. 31 | self.measureBlock() { 32 | // Put the code you want to measure the time of here. 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/iphone/Awesomation/AwesomationTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | awesomation.$(PRODUCT_NAME:rfc1034identifier) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/pi/__init__.py: -------------------------------------------------------------------------------- 1 | """Code to run on the raspberry pi.""" 2 | -------------------------------------------------------------------------------- /src/pi/control.py: -------------------------------------------------------------------------------- 1 | """Main entry point for code running on the raspberry pi.""" 2 | 3 | import argparse 4 | import logging 5 | import sys 6 | 7 | from pi import daemon, events, hue, pushrpc, sonos, wemo 8 | 9 | 10 | LOGFMT = '%(asctime)s %(levelname)s %(filename)s:%(lineno)d - %(message)s' 11 | PIDFILE = '/var/run/control.pid' 12 | 13 | 14 | class Control(daemon.Daemon): 15 | """'Controller class, ties proxies and rpc together.""" 16 | 17 | def __init__(self, args): 18 | super(Control, self).__init__(PIDFILE, daemonize=args.daemonize) 19 | self._args = args 20 | self._push_rpc = None 21 | self._proxies = {} 22 | 23 | def run(self): 24 | self._push_rpc = pushrpc.PushRPC(self._push_event_callback, self._args) 25 | 26 | # These modules should work 100% of the time - 27 | # they don't need special hardware, or root 28 | self._proxies = { 29 | 'hue': hue.Hue(self._args.hue_scan_interval_secs, 30 | self._device_event_callback), 31 | 'wemo': wemo.Wemo(self._args.hue_scan_interval_secs, 32 | self._device_event_callback), 33 | 'sonos': sonos.Sonos(self._args.hue_scan_interval_secs, 34 | self._device_event_callback), 35 | } 36 | 37 | try: 38 | from pi import network 39 | self._proxies['network'] = network.NetworkMonitor( 40 | self._device_event_callback, 41 | self._args.network_scan_interval_secs, 42 | self._args.network_scan_timeout_secs) 43 | except: 44 | logging.debug('Exception was:', exc_info=sys.exc_info()) 45 | logging.error('Failed to initialize network module - did you ' 46 | 'run as root?') 47 | 48 | # This module needs a 433Mhz transmitter, wiringPi etc, so might not work 49 | try: 50 | from pi import rfswitch 51 | self._proxies['rfswitch'] = rfswitch.RFSwitch(self._args.rfswtich_pin) 52 | except: 53 | logging.debug('Exception was:', exc_info=sys.exc_info()) 54 | logging.error('Failed to initialize rfswitch module - have you ' 55 | 'installed rcswitch?') 56 | 57 | # This module needs a zwave usb stick 58 | try: 59 | from pi import zwave 60 | self._proxies['zwave'] = zwave.ZWave( 61 | self._args.zwave_device, self._device_event_callback) 62 | except: 63 | logging.debug('Exception was:', exc_info=sys.exc_info()) 64 | logging.error('Failed to initialize zwave module - have you ' 65 | 'installed libopenzwave?') 66 | 67 | # Just sit in a loop sleeping for now 68 | try: 69 | events.run() 70 | except KeyboardInterrupt: 71 | logging.info('Shutting down') 72 | 73 | # Now try and shut everything down gracefully 74 | self._push_rpc.stop() 75 | 76 | for proxy in self._proxies.itervalues(): 77 | proxy.stop() 78 | 79 | for proxy in self._proxies.itervalues(): 80 | proxy.join() 81 | 82 | def _push_event_callback(self, commands): 83 | """Handle event from the cloud.""" 84 | logging.info('Processing %d commands', len(commands)) 85 | 86 | for command in commands: 87 | command_type = command.pop('type', None) 88 | proxy = self._proxies.get(command_type, None) 89 | if proxy is None: 90 | logging.error('Proxy type \'%s\' unrecognised', command_type) 91 | 92 | try: 93 | proxy.handle_command(command) 94 | except: 95 | logging.error('Error handling command.', exc_info=sys.exc_info()) 96 | 97 | def _device_event_callback(self, device_type, device_id, event_body): 98 | """Handle event from a device.""" 99 | event = {'device_type': device_type, 'device_id': device_id, 100 | 'event': event_body} 101 | self._push_rpc.send_event(event) 102 | 103 | 104 | def main(): 105 | """Main function.""" 106 | # Setup logging 107 | logging.basicConfig(format=LOGFMT, level=logging.INFO) 108 | file_handler = logging.FileHandler('control.log') 109 | file_handler.setFormatter(logging.Formatter(LOGFMT)) 110 | logging.getLogger().addHandler(file_handler) 111 | logging.getLogger('requests.packages.urllib3.connectionpool' 112 | ).setLevel(logging.ERROR) 113 | logging.getLogger('soco.services').setLevel(logging.ERROR) 114 | 115 | # Command line arguments 116 | parser = argparse.ArgumentParser() 117 | parser.add_argument('--daemonize', dest='daemonize', action='store_true') 118 | parser.add_argument('--nodaemonize', dest='daemonize', action='store_false') 119 | parser.set_defaults(daemonize=True) 120 | 121 | parser.add_argument('--local', dest='local', action='store_true') 122 | parser.add_argument('--nolocal', dest='local', action='store_false') 123 | parser.set_defaults(daemonize=False) 124 | 125 | parser.add_argument('--zwave_device', 126 | default='/dev/ttyUSB0') 127 | parser.add_argument('--rfswtich_pin', 128 | default=3) 129 | parser.add_argument('--hue_scan_interval_secs', 130 | default=5*60) 131 | parser.add_argument('--network_scan_interval_secs', 132 | default=10) 133 | parser.add_argument('--network_scan_timeout_secs', 134 | default=5*60) 135 | parser.add_argument('action', nargs=1, choices=['start', 'stop', 'restart'], 136 | metavar='') 137 | args = parser.parse_args() 138 | 139 | control = Control(args) 140 | 141 | if args.action[0] == 'start': 142 | control.start() 143 | elif args.action[0] == 'stop': 144 | control.stop() 145 | elif args.action[0] == 'restart': 146 | control.restart() 147 | else: 148 | assert False, args.action 149 | 150 | 151 | if __name__ == '__main__': 152 | main() 153 | -------------------------------------------------------------------------------- /src/pi/daemon.py: -------------------------------------------------------------------------------- 1 | """Python daemonization code.""" 2 | 3 | # Taken from http://www.jejik.com/files/examples/daemon.py 4 | # Site says it public domain. 5 | 6 | import sys, os, time, atexit 7 | import code, traceback, signal 8 | from signal import SIGTERM 9 | 10 | class Daemon(object): 11 | """ 12 | A generic daemon class. 13 | 14 | Usage: subclass the Daemon class and override the run() method 15 | """ 16 | def __init__(self, pidfile, daemonize=True, stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'): 17 | self.stdin = stdin 18 | self.stdout = stdout 19 | self.stderr = stderr 20 | self.pidfile = pidfile 21 | self._daemonize = daemonize 22 | 23 | # From http://stackoverflow.com/questions/132058/showing-the-stack-trace-from-a-running-python-application 24 | def debug(self, sig, frame): 25 | """Interrupt running process, and provide a python prompt for 26 | interactive debugging.""" 27 | debug = {'_frame':frame} # Allow access to frame object. 28 | debug.update(frame.f_globals) # Unless shadowed by global 29 | debug.update(frame.f_locals) 30 | 31 | interactive = code.InteractiveConsole(debug) 32 | message = "Signal received : entering python shell.\nTraceback:\n" 33 | message += ''.join(traceback.format_stack(frame)) 34 | interactive.interact(message) 35 | 36 | def daemonize(self): 37 | """ 38 | do the UNIX double-fork magic, see Stevens' "Advanced 39 | Programming in the UNIX Environment" for details (ISBN 0201563177) 40 | http://www.erlenstar.demon.co.uk/unix/faq_2.html#SEC16 41 | """ 42 | try: 43 | pid = os.fork() 44 | if pid > 0: 45 | # exit first parent 46 | sys.exit(0) 47 | except OSError, e: 48 | sys.stderr.write("fork #1 failed: %d (%s)\n" % (e.errno, e.strerror)) 49 | sys.exit(1) 50 | 51 | # decouple from parent environment 52 | os.chdir("/") 53 | os.setsid() 54 | os.umask(0) 55 | 56 | # do second fork 57 | try: 58 | pid = os.fork() 59 | if pid > 0: 60 | # exit from second parent 61 | sys.exit(0) 62 | except OSError, e: 63 | sys.stderr.write("fork #2 failed: %d (%s)\n" % (e.errno, e.strerror)) 64 | sys.exit(1) 65 | 66 | # redirect standard file descriptors 67 | sys.stdout.flush() 68 | sys.stderr.flush() 69 | si = file(self.stdin, 'r') 70 | so = file(self.stdout, 'a+') 71 | se = file(self.stderr, 'a+', 0) 72 | os.dup2(si.fileno(), sys.stdin.fileno()) 73 | os.dup2(so.fileno(), sys.stdout.fileno()) 74 | os.dup2(se.fileno(), sys.stderr.fileno()) 75 | 76 | # write pidfile 77 | atexit.register(self.delpid) 78 | pid = str(os.getpid()) 79 | file(self.pidfile,'w+').write("%s\n" % pid) 80 | 81 | def delpid(self): 82 | os.remove(self.pidfile) 83 | 84 | def start(self): 85 | """ 86 | Start the daemon 87 | """ 88 | # Check for a pidfile to see if the daemon already runs 89 | try: 90 | pf = file(self.pidfile,'r') 91 | pid = int(pf.read().strip()) 92 | pf.close() 93 | except IOError: 94 | pid = None 95 | 96 | if pid: 97 | message = "pidfile %s already exist. Daemon already running?\n" 98 | sys.stderr.write(message % self.pidfile) 99 | sys.exit(1) 100 | 101 | # Start the daemon 102 | if self._daemonize: 103 | self.daemonize() 104 | else: 105 | signal.signal(signal.SIGUSR1, self.debug) # Register handler 106 | self.run() 107 | 108 | def stop(self): 109 | """ 110 | Stop the daemon 111 | """ 112 | # Get the pid from the pidfile 113 | try: 114 | pf = file(self.pidfile,'r') 115 | pid = int(pf.read().strip()) 116 | pf.close() 117 | except IOError: 118 | pid = None 119 | 120 | if not pid: 121 | message = "pidfile %s does not exist. Daemon not running?\n" 122 | sys.stderr.write(message % self.pidfile) 123 | return # not an error in a restart 124 | 125 | # Try killing the daemon process 126 | try: 127 | while 1: 128 | os.kill(pid, SIGTERM) 129 | time.sleep(0.1) 130 | except OSError, err: 131 | err = str(err) 132 | if err.find("No such process") > 0: 133 | if os.path.exists(self.pidfile): 134 | os.remove(self.pidfile) 135 | else: 136 | print str(err) 137 | sys.exit(1) 138 | 139 | def restart(self): 140 | """ 141 | Restart the daemon 142 | """ 143 | self.stop() 144 | self.start() 145 | 146 | def run(self): 147 | """ 148 | You should override this method when you subclass Daemon. It will be called after the process has been 149 | daemonized by start() or restart(). 150 | """ 151 | -------------------------------------------------------------------------------- /src/pi/events.py: -------------------------------------------------------------------------------- 1 | """.""" 2 | import sched 3 | import time 4 | 5 | 6 | SCHED = sched.scheduler(time.time, time.sleep) 7 | 8 | 9 | def run(): 10 | while True: 11 | SCHED.run() 12 | if SCHED.empty(): 13 | time.sleep(10) 14 | 15 | 16 | def enter(delay, func, args=None, priority=0): 17 | if args is None: 18 | args = [] 19 | return SCHED.enter(delay, priority, func, args) 20 | 21 | 22 | def cancel(event): 23 | SCHED.cancel(event) 24 | -------------------------------------------------------------------------------- /src/pi/hue.py: -------------------------------------------------------------------------------- 1 | """Philips hue proxy code.""" 2 | 3 | import logging 4 | 5 | import requests 6 | import phue 7 | 8 | from pi import proxy, scanning_proxy 9 | 10 | 11 | class Hue(scanning_proxy.ScanningProxy): 12 | """Hue proxy object.""" 13 | 14 | def __init__(self, refresh_period, callback): 15 | super(Hue, self).__init__(refresh_period) 16 | 17 | self._callback = callback 18 | self._bridges = {} 19 | self._lights = {} 20 | 21 | def _scan_once(self): 22 | """Find hue hubs on the network and tell appengine about them.""" 23 | logging.info('Starting hue bridge scan') 24 | response = requests.get('https://www.meethue.com/api/nupnp') 25 | assert response.status_code == 200, response.status_code 26 | bridges = response.json() 27 | for bridge in bridges: 28 | bridge_id = bridge['id'] 29 | bridge_ip = bridge['internalipaddress'] 30 | bridge_name = None 31 | 32 | # Event explicity doesn't contain ip (it might change) 33 | # or id (its in the device path) 34 | event = None 35 | try: 36 | bridge = phue.Bridge(ip=bridge_ip) 37 | bridge_name = bridge.name 38 | 39 | if bridge_id not in self._bridges: 40 | self._bridges[bridge_id] = bridge 41 | event = {'name': bridge_name, 'linked': True} 42 | except phue.PhueRegistrationException: 43 | if bridge_id in self._bridges: 44 | del self._bridges[bridge_id] 45 | event = {'linked': False} 46 | 47 | if event is not None: 48 | logging.debug('Hue bridge \'%s\' (%s) found at %s - linked=%s', 49 | bridge_name, bridge_id, bridge_ip, event['linked']) 50 | 51 | self._callback('hue_bridge', 'hue-%s' % bridge_id, event) 52 | 53 | # Now find all the lights 54 | for bridge_id, bridge in self._bridges.iteritems(): 55 | lights_by_id = bridge.get_light_objects(mode='id') 56 | for light_id in lights_by_id.iterkeys(): 57 | light_details = bridge.get_light(light_id) 58 | logging.debug('Hue light %d (\'%s\') found on bridge \'%s\', on=%s', 59 | light_id, light_details['name'], bridge_id, 60 | light_details['state']['on']) 61 | 62 | light_id = 'hue-%s-%d' % (bridge_id, light_id) 63 | if self._lights.get(light_id, None) != light_details: 64 | self._callback('hue_light', light_id, light_details) 65 | self._lights[light_id] = light_details 66 | 67 | @proxy.command 68 | def set_state(self, bridge_id, device_id, mode, 69 | brightness=255, color_temperature=500): 70 | """Turn a light on or off.""" 71 | logging.info('bridge_id = %s, device_id = %d, mode = %s, ' 72 | 'brightness = %s, color temp = %s', 73 | bridge_id, device_id, mode, brightness, 74 | color_temperature) 75 | 76 | bridge = self._bridges.get(bridge_id, None) 77 | if not bridge: 78 | logging.error('Bridge %s not found!', bridge_id) 79 | return 80 | 81 | command = {'on' : mode, 82 | 'bri' : brightness} 83 | if color_temperature is not None: 84 | command['ct'] = color_temperature 85 | 86 | bridge.set_light(device_id, command) 87 | -------------------------------------------------------------------------------- /src/pi/network.py: -------------------------------------------------------------------------------- 1 | """This module keeps track of devices appearing on the network.""" 2 | 3 | import collections 4 | import logging 5 | import os 6 | import platform 7 | import re 8 | import subprocess 9 | import time 10 | 11 | import ipaddr 12 | import netifaces 13 | import pyping 14 | 15 | from common import detector 16 | from pi import scanning_proxy 17 | 18 | 19 | # This regex matches the output of `ip -s neighbor list` 20 | LINUX_RE = (r'^(?P\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}) dev (\w+) (lladdr ' 21 | r'(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))? (ref \d+ )?used ' 22 | r'(\d+/\d+/\d+) probes \d+ (?P[A-Z]+)') 23 | LINUX_RE = re.compile(LINUX_RE) 24 | 25 | # This regex matches the output of `arp -a` 26 | MAC_RE = (r'^\? \((?P\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\) at ' 27 | r'(?P([0-9a-f]{1,2}[:-]){5}([0-9a-f]{1,2})|\(incomplete\)) ' 28 | r'on [a-z0-9]+ (ifscope )?(permanent )?\[ethernet\]$') 29 | MAC_RE = re.compile(MAC_RE) 30 | 31 | 32 | class NetworkMonitor(scanning_proxy.ScanningProxy): 33 | """Monitor devices appearing on the network.""" 34 | 35 | def __init__(self, callback, scan_period_sec, timeout_secs): 36 | assert os.geteuid() == 0, \ 37 | 'You need to have root privileges to use this module.' 38 | 39 | self._callback = callback 40 | self._timeout_secs = timeout_secs 41 | self._ping_frequency_secs = 60 42 | 43 | self._hosts = collections.defaultdict(lambda: False) 44 | self._last_ping = collections.defaultdict(float) 45 | self._detectors = collections.defaultdict(detector.AccrualFailureDetector) 46 | 47 | # This module has to poll the network pretty 48 | # frequently to have a good chance of catching 49 | # iphones etc which sleep a lot. 50 | # Hence, we send edge-triggered events to the server 51 | # to rate limit the number of events we're sending. 52 | # (This also helps with latency). 53 | # This works in the other scanners as (a) we assume the 54 | # server is always there and (b) the server creates 55 | # instances on demand for things like wemo and hue. 56 | # We don't want to create instances on demand 57 | # (there is too much junk we can't control on most 58 | # networks) - instead, the user will manually create 59 | # objects to represents phones coming and going. 60 | # Therefore, edge triggering isn't good enough 61 | # - the user might create the phone object after the 62 | # phone has been detected, and the server will miss 63 | # the event. There we will also periodically send 64 | # a 'level-triggered' event - ie, just a list of 65 | # devices which are present. But we'll do this 66 | # less frequently. 67 | self._level_event_frequency_secs = 10*60 68 | self._last_level_event = 0 69 | 70 | super(NetworkMonitor, self).__init__(scan_period_sec) 71 | 72 | def _ping(self, ip_address, now): 73 | """Ping a device, but rate limit. 74 | 75 | Don't ping more often than self._ping_frequency_secs. 76 | """ 77 | if self._last_ping[ip_address] + self._ping_frequency_secs > now: 78 | return 79 | 80 | pyping.ping(ip_address, timeout=1, count=1) 81 | self._last_ping[ip_address] = now 82 | 83 | def ping_subnet(self, now): 84 | """Ping broadcase address for all interfaces.""" 85 | if self._last_ping['SUBNET'] + self._ping_frequency_secs > now: 86 | return 87 | 88 | self._last_ping['SUBNET'] = now 89 | 90 | for interface in netifaces.interfaces(): 91 | if interface.startswith('lo'): 92 | continue 93 | 94 | details = netifaces.ifaddresses(interface) 95 | if netifaces.AF_INET not in details: 96 | continue 97 | 98 | for detail in details[netifaces.AF_INET]: 99 | address = detail.get('addr', None) 100 | netmask = detail.get('netmask', None) 101 | if address is None or netmask is None: 102 | continue 103 | 104 | parsed = ipaddr.IPv4Network('%s/%s' % (address, netmask)) 105 | logging.debug('Ping broadcast address %s', parsed.broadcast) 106 | pyping.ping(str(parsed.broadcast), timeout=10, count=10) 107 | 108 | def _arp(self): 109 | system = platform.system() 110 | if system == 'Darwin': 111 | process = subprocess.Popen(['arp', '-a'], 112 | stdin=None, stdout=subprocess.PIPE, 113 | stderr=None, close_fds=True) 114 | while True: 115 | line = process.stdout.readline() 116 | if not line: 117 | break 118 | 119 | match = MAC_RE.match(line) 120 | if not match: 121 | logging.error('Line not matched by regex: "%s"', line.strip()) 122 | continue 123 | 124 | mac = match.group('mac') 125 | ip_address = match.group('ip') 126 | state = 'INVALID' if mac == '(incomplete)' else 'REACHABLE' 127 | 128 | yield (mac, ip_address, state) 129 | 130 | elif system == 'Linux': 131 | process = subprocess.Popen(['ip', '-s', 'neighbor', 'list'], 132 | stdin=None, stdout=subprocess.PIPE, 133 | stderr=None, close_fds=True) 134 | while True: 135 | line = process.stdout.readline() 136 | if not line: 137 | break 138 | 139 | match = LINUX_RE.match(line) 140 | if not match: 141 | logging.error('Line not matched by regex: "%s"', line.strip()) 142 | continue 143 | 144 | mac = match.group('mac') 145 | ip_address = match.group('ip') 146 | state = match.group('state') 147 | yield (mac, ip_address, state) 148 | 149 | def _scan_once(self): 150 | """Scan the network for devices.""" 151 | now = time.time() 152 | self.ping_subnet(now) 153 | 154 | # Now look at contents of arp table 155 | for mac, ip_address, state in self._arp(): 156 | 157 | # ping everything in the table once a minute. 158 | self._ping(ip_address, now) 159 | 160 | if state != 'REACHABLE': 161 | continue 162 | 163 | self._detectors[mac].heartbeat(now) 164 | 165 | for mac, dtor in self._detectors.iteritems(): 166 | # Has there been a state change? 167 | is_alive = dtor.is_alive(now) 168 | if is_alive == self._hosts[mac]: 169 | continue 170 | 171 | self._hosts[mac] = is_alive 172 | if is_alive: 173 | logging.info('Found new device - %s', mac) 174 | self._callback('network', None, {'appeared': mac}) 175 | else: 176 | logging.info('Device disappeared - %s', mac) 177 | self._callback('network', None, {'disappeared': mac}) 178 | 179 | # Periodically send event with list of 180 | # devices we can see. 181 | if self._last_level_event + self._level_event_frequency_secs < now: 182 | self._last_level_event = now 183 | alive = [mac for mac, dtor in self._detectors.iteritems() 184 | if dtor.is_alive()] 185 | self._callback('network', None, {'devices': alive}) 186 | -------------------------------------------------------------------------------- /src/pi/proxy.py: -------------------------------------------------------------------------------- 1 | """Local proxy objects.""" 2 | 3 | import abc 4 | import logging 5 | 6 | 7 | def command(func): 8 | func.is_command = True 9 | return func 10 | 11 | 12 | class Proxy(object): 13 | """Abstract base class for local proxy objects.""" 14 | __metaclass__ = abc.ABCMeta 15 | 16 | def handle_command(self, cmd): 17 | """Handle a command for this proxy""" 18 | command_name = cmd.pop('command') 19 | 20 | command_method = getattr(self, command_name, None) 21 | if command_method is None or not command_method.is_command: 22 | logging.error('"%s" is not a valid command for a %s', 23 | command_name, self.__class__.__name__) 24 | return 25 | 26 | command_method(**cmd) 27 | 28 | @abc.abstractmethod 29 | def stop(self): 30 | pass 31 | 32 | @abc.abstractmethod 33 | def join(self): 34 | pass 35 | -------------------------------------------------------------------------------- /src/pi/pushrpc.py: -------------------------------------------------------------------------------- 1 | """Pusher intergration for messages from the cloud.""" 2 | 3 | import json 4 | import logging 5 | import Queue 6 | import sys 7 | import threading 8 | import uuid 9 | 10 | from pusherclient import Pusher 11 | import requests 12 | import websocket 13 | 14 | from common import public_creds 15 | from pi import simple_pusher 16 | 17 | 18 | CONFIG_FILE = 'proxy.cfg' 19 | APPENGINE_ADDRESS = 'https://%s.appspot.com' % public_creds.appengine_app_id 20 | LOCAL_ADDRESS = 'http://localhost:8080' 21 | EVENT_PATH = '/api/device/events' 22 | AUTH_PATH = '/api/proxy/channel_auth' 23 | 24 | 25 | def read_or_make_config(): 26 | """Read proxy id and secret from file, or make new one.""" 27 | try: 28 | with open(CONFIG_FILE) as config_file: 29 | return config_file.read().split(',') 30 | except: 31 | proxy_id = str(uuid.uuid4().get_hex()) 32 | proxy_secret = str(uuid.uuid4().get_hex()) 33 | with open(CONFIG_FILE, 'w') as config_file: 34 | config_file.write('%s,%s' % (proxy_id, proxy_secret)) 35 | return (proxy_id, proxy_secret) 36 | 37 | 38 | class PushRPC(object): 39 | """Wrapper for pusher integration.""" 40 | # pylint: disable=too-many-instance-attributes 41 | 42 | def __init__(self, callback, args): 43 | self._proxy_id, self._proxy_secret = read_or_make_config() 44 | logging.info('I am proxy \'%s\'', self._proxy_id) 45 | 46 | self._args = args 47 | self._exiting = False 48 | 49 | self._events = Queue.Queue() 50 | self._events_thread = threading.Thread(target=self._post_events_loop) 51 | self._events_thread.daemon = True 52 | self._events_thread.start() 53 | 54 | self._callback = callback 55 | 56 | if args.local: 57 | self._websocket_connection = None 58 | self._websocket_thread = threading.Thread(target=self._local_websocket) 59 | self._websocket_thread.start() 60 | 61 | else: 62 | self._pusher = Pusher(public_creds.pusher_key, 63 | auth_callback=self._pusher_auth_callback, 64 | log_level=logging.ERROR) 65 | self._pusher.connection.bind( 66 | 'pusher:connection_established', 67 | self._connect_handler) 68 | self._pusher.connect() 69 | 70 | def _local_websocket(self): 71 | """Connect to local websocket server.""" 72 | self._websocket_connection = websocket.create_connection( 73 | "ws://localhost:%d/" % simple_pusher.WEBSOCKET_PORT) 74 | request = json.dumps({'channel': 'private-%s' % self._proxy_id}) 75 | self._websocket_connection.send(request) 76 | 77 | while True: 78 | result = self._websocket_connection.recv() 79 | self._callback_handler(result) 80 | 81 | def _pusher_auth_callback(self, socket_id, channel_name): 82 | params = {'socket_id': socket_id, 'channel_name': channel_name} 83 | response = self._make_request(APPENGINE_ADDRESS, AUTH_PATH, params=params) 84 | response = response.json() 85 | return response['auth'] 86 | 87 | def _make_request(self, server, path, method='GET', **kwargs): 88 | """Make a request to the server with this proxy's auth.""" 89 | response = requests.request( 90 | method, server + path, 91 | auth=(self._proxy_id, self._proxy_secret), 92 | headers={'content-type': 'application/json', 93 | 'awesomation-proxy': 'true'}, 94 | **kwargs) 95 | response.raise_for_status() 96 | return response 97 | 98 | def _connect_handler(self, _): 99 | channel_name = 'private-%s' % self._proxy_id 100 | channel = self._pusher.subscribe(channel_name) 101 | channel.bind('events', self._callback_handler) 102 | 103 | def _callback_handler(self, data): 104 | """Callback for when messages are recieved from pusher.""" 105 | try: 106 | events = json.loads(data) 107 | except ValueError: 108 | logging.error('Error parsing message', exc_info=sys.exc_info()) 109 | return 110 | 111 | # pylint: disable=broad-except 112 | try: 113 | self._callback(events) 114 | except Exception: 115 | logging.error('Error running push callback', exc_info=sys.exc_info()) 116 | 117 | def send_event(self, event): 118 | self._events.put(event) 119 | 120 | def _get_batch_of_events(self, max_size=20): 121 | """Retrieve as many events from queue as possible without blocking.""" 122 | events = [] 123 | while len(events) < max_size: 124 | try: 125 | # First time round we should wait (when list is empty) 126 | block = len(events) == 0 127 | event = self._events.get(block) 128 | 129 | # To break out of this thread, we inject a None event in stop() 130 | if event is None: 131 | return None 132 | 133 | events.append(event) 134 | except Queue.Empty: 135 | break 136 | 137 | assert events 138 | return events 139 | 140 | def _post_events_loop(self): 141 | """Send batched of events to server in a loop.""" 142 | logging.info('Starting events thread.') 143 | while not self._exiting: 144 | events = self._get_batch_of_events() 145 | if events is None: 146 | break 147 | 148 | # pylint: disable=broad-except 149 | try: 150 | self._post_events_once(events) 151 | except Exception: 152 | logging.error('Exception sending events to server', 153 | exc_info=sys.exc_info()) 154 | logging.info('Exiting events thread.') 155 | 156 | def _post_events_once(self, events): 157 | """Send list of events to server.""" 158 | logging.info('Posting %d events to server', len(events)) 159 | 160 | try: 161 | server_address = LOCAL_ADDRESS if self._args.local else APPENGINE_ADDRESS 162 | self._make_request(server_address, EVENT_PATH, 163 | method='POST', data=json.dumps(events)) 164 | except: 165 | logging.error('Posting events failed', exc_info=sys.exc_info()) 166 | 167 | def stop(self): 168 | """Stop various threads and connections.""" 169 | self._exiting = True 170 | self._events.put(None) 171 | self._events_thread.join() 172 | 173 | if self._args.local: 174 | self._websocket_connection.close() 175 | self._websocket_thread.join() 176 | else: 177 | self._pusher.disconnect() 178 | -------------------------------------------------------------------------------- /src/pi/rfswitch.py: -------------------------------------------------------------------------------- 1 | """rf433 device proxy code.""" 2 | 3 | import collections 4 | import logging 5 | import threading 6 | import Queue 7 | 8 | import rcswitch 9 | 10 | from pi import proxy 11 | 12 | 13 | class RFSwitch(proxy.Proxy): 14 | """433mhz RF Switch proxy implementation.""" 15 | 16 | def __init__(self, pin, repeats=5): 17 | self._switch = rcswitch.RCSwitch() 18 | self._switch.enableTransmit(pin) 19 | 20 | # We repeat commands to devices, as 433Mhz switches 21 | # are not super reliable. 22 | self._repeats = repeats 23 | 24 | # We put commands (system code, device code, mode, repeat count) 25 | # on a queue, and then process them on a background thread 26 | # so we don't block the main event loop and can balance 27 | # new commands and repeats more fairy. 28 | self._command_queue = Queue.Queue() 29 | self._exiting = False 30 | self._command_thread = threading.Thread(target=self._command_thread_loop) 31 | self._command_thread.daemon = True 32 | self._command_thread.start() 33 | 34 | # To prevent repeats for a given device competeing 35 | # with new states for the same device, we give commands 36 | # generation numbers, and if we see a command for a device 37 | # from an old generation, we ignore it. 38 | self._device_command_generations = collections.defaultdict(int) 39 | 40 | @proxy.command 41 | def set_state(self, system_code, device_code, mode): 42 | """Handle rf swtich events - turn it on or off.""" 43 | system_code = str(system_code) 44 | device_code = int(device_code) 45 | self._device_command_generations[(system_code, device_code)] += 1 46 | generation = self._device_command_generations[(system_code, device_code)] 47 | self._command_queue.put((system_code, device_code, mode, 48 | self._repeats, generation)) 49 | 50 | def _command_thread_loop(self): 51 | while not self._exiting: 52 | command = self._command_queue.get(True) 53 | if command is None: 54 | return 55 | 56 | system_code, device_code, mode, repeats, generation = command 57 | if generation < self._device_command_generations[ 58 | (system_code, device_code)]: 59 | continue 60 | 61 | logging.info('system_code = %s, device_code = %s, ' 62 | 'mode = %s, repeats = %d, generation = %d', 63 | system_code, device_code, mode, repeats, 64 | generation) 65 | 66 | if mode: 67 | self._switch.switchOn(system_code, device_code) 68 | else: 69 | self._switch.switchOff(system_code, device_code) 70 | 71 | # Put back on queue if needs repeating 72 | repeats -= 1 73 | if repeats > 0: 74 | self._command_queue.put((system_code, device_code, mode, 75 | repeats, generation)) 76 | 77 | def stop(self): 78 | self._exiting = True 79 | self._command_queue.put(None) 80 | 81 | def join(self): 82 | self._command_thread.join() 83 | -------------------------------------------------------------------------------- /src/pi/scanning_proxy.py: -------------------------------------------------------------------------------- 1 | """Philips hue proxy code.""" 2 | 3 | import abc 4 | import logging 5 | import sys 6 | import threading 7 | 8 | 9 | from pi import proxy 10 | 11 | 12 | class ScanningProxy(proxy.Proxy): 13 | """A proxy object with a background scan thread.""" 14 | __metaclass__ = abc.ABCMeta 15 | 16 | def __init__(self, refresh_period): 17 | self._refresh_period = refresh_period 18 | 19 | self._exiting = False 20 | self._scan_thread_condition = threading.Condition() 21 | self._scan_thread = threading.Thread( 22 | target=self._scan, name='%s thread' % self.__class__.__name__) 23 | self._scan_thread.daemon = True 24 | self._scan_thread.start() 25 | 26 | @proxy.command 27 | def scan(self): 28 | with self._scan_thread_condition: 29 | self._scan_thread_condition.notify() 30 | 31 | def _scan(self): 32 | """Loop thread for scanning.""" 33 | while not self._exiting: 34 | # We always do a scan on start up. 35 | try: 36 | self._scan_once() 37 | except: 38 | logging.error('Error during %s scan', self.__class__.__name__, 39 | exc_info=sys.exc_info()) 40 | 41 | with self._scan_thread_condition: 42 | if self._exiting: 43 | break 44 | else: 45 | self._scan_thread_condition.wait(self._refresh_period) 46 | 47 | logging.info('Exited %s scan thread', self.__class__.__name__) 48 | 49 | @abc.abstractmethod 50 | def _scan_once(self): 51 | pass 52 | 53 | def stop(self): 54 | with self._scan_thread_condition: 55 | self._exiting = True 56 | self._scan_thread_condition.notify() 57 | 58 | def join(self): 59 | self._scan_thread.join() 60 | -------------------------------------------------------------------------------- /src/pi/simple_pusher.py: -------------------------------------------------------------------------------- 1 | """A really dump pusher 'clone', for use in testing and running locally.""" 2 | import argparse 3 | import collections 4 | import json 5 | import logging 6 | import SimpleHTTPServer 7 | import SocketServer 8 | import sys 9 | import threading 10 | import time 11 | 12 | import SimpleWebSocketServer 13 | 14 | HTTP_PORT = 8101 15 | WEBSOCKET_PORT = 8102 16 | LOGFMT = '%(asctime)s %(levelname)s %(filename)s:%(lineno)d - %(message)s' 17 | 18 | 19 | class SocketHandler(SimpleWebSocketServer.WebSocket): 20 | """Represents a websocket connection.""" 21 | # pylint: disable=invalid-name 22 | def __init__(self, sockets, server, sock, address): 23 | super(SocketHandler, self).__init__(server, sock, address) 24 | self._sockets = sockets 25 | self._channels = [] 26 | 27 | def handleMessage(self): 28 | """Only message we get is a subscription.""" 29 | if self.data is None: 30 | return 31 | 32 | try: 33 | # message should be a subscription, of form {channel: 'channel_name'} 34 | logging.info('\'%s\' received', self.data) 35 | data = json.loads(self.data.decode('utf-8')) 36 | self._channels.append(data['channel']) 37 | self._sockets[data['channel']].append(self) 38 | except: 39 | logging.error('Error handling message:', exc_info=sys.exc_info()) 40 | 41 | def handleConnected(self): 42 | logging.info('%s connected', self.address) 43 | 44 | def handleClose(self): 45 | logging.info('%s closed', self.address, exc_info=sys.exc_info()) 46 | for channel in self._channels: 47 | self._sockets[channel].remove(self) 48 | 49 | 50 | class ServerHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): 51 | """Represents a http requests.""" 52 | # pylint: disable=invalid-name,too-many-public-methods 53 | def __init__(self, sockets, request, client_address, server): 54 | self._sockets = sockets 55 | SimpleHTTPServer.SimpleHTTPRequestHandler.__init__( 56 | self, request, client_address, server) 57 | 58 | def do_POST(self): 59 | """Send request body to /channel.""" 60 | try: 61 | channel = self.path.split('/')[-1] 62 | content_len = int(self.headers.getheader('content-length', 0)) 63 | post_body = self.rfile.read(content_len) 64 | 65 | #logging.info('Sending \"%s\" to \"%s\"', post_body, channel) 66 | 67 | for socket in self._sockets[channel]: 68 | socket.sendMessage(post_body) 69 | 70 | self.send_response(204, '') 71 | except: 72 | logging.error('Error sending message:', exc_info=sys.exc_info()) 73 | 74 | 75 | class SimplePusher(object): 76 | """A very simple websocket / push service.""" 77 | def __init__(self, args): 78 | self._args = args 79 | self._sockets = collections.defaultdict(list) 80 | self._httpd = None 81 | self._httpd_thread = None 82 | self._websocket_server = None 83 | self._websocket_server_thread = None 84 | 85 | def _http_request_handler(self, request, client_address, server): 86 | return ServerHandler(self._sockets, request, client_address, server) 87 | 88 | def _websocket_request_handler(self, server, sock, addr): 89 | return SocketHandler(self._sockets, server, sock, addr) 90 | 91 | def start(self): 92 | """Start this.""" 93 | logging.info('Starting local websocket server.') 94 | 95 | self._httpd = SocketServer.TCPServer( 96 | ('', self._args.http_port), self._http_request_handler) 97 | self._httpd_thread = threading.Thread(target=self._httpd.serve_forever) 98 | self._httpd_thread.start() 99 | 100 | self._websocket_server = SimpleWebSocketServer.SimpleWebSocketServer( 101 | '', self._args.websocket_port, self._websocket_request_handler) 102 | self._websocket_server_thread = threading.Thread( 103 | target=self._websocket_server.serveforever) 104 | self._websocket_server_thread.start() 105 | 106 | def stop(self): 107 | """Stop this.""" 108 | logging.info('Stopping local websocket server.') 109 | self._httpd.shutdown() 110 | self._httpd_thread.join() 111 | self._websocket_server.close() 112 | self._websocket_server_thread.join() 113 | 114 | 115 | def main(): 116 | logging.basicConfig(format=LOGFMT, level=logging.DEBUG) 117 | 118 | parser = argparse.ArgumentParser() 119 | parser.add_argument('--http_port', 120 | default=HTTP_PORT) 121 | parser.add_argument('--websocket_port', 122 | default=WEBSOCKET_PORT) 123 | args = parser.parse_args() 124 | 125 | pusher = SimplePusher(args) 126 | pusher.start() 127 | 128 | try: 129 | while True: 130 | time.sleep(100) 131 | except: 132 | pass 133 | 134 | pusher.stop() 135 | 136 | 137 | if __name__ == '__main__': 138 | main() 139 | 140 | -------------------------------------------------------------------------------- /src/pi/sonos.py: -------------------------------------------------------------------------------- 1 | """Wemo proxy code.""" 2 | 3 | import logging 4 | 5 | import soco 6 | 7 | from pi import scanning_proxy 8 | 9 | 10 | CURRENTLY_PLAYING_KEYS = ('album', 'artist', 'title') 11 | 12 | class Sonos(scanning_proxy.ScanningProxy): 13 | """Sonos proxy object.""" 14 | 15 | def __init__(self, refresh_period, callback): 16 | super(Sonos, self).__init__(refresh_period) 17 | 18 | self._callback = callback 19 | self._devices = {} 20 | self._previous_details = {} 21 | 22 | def _scan_once(self): 23 | devices = soco.discover() 24 | if devices is None: 25 | return 26 | 27 | devices = list(devices) 28 | 29 | logging.info('Found %d sonos devices.', len(devices)) 30 | 31 | for device in devices: 32 | uid = device.uid 33 | self._devices[uid] = device 34 | 35 | speaker_info = device.get_speaker_info() 36 | currently_playing = device.get_current_track_info() 37 | current_transport_info = device.get_current_transport_info() 38 | 39 | details = { 40 | 'uid': uid, 41 | 'device_name': speaker_info['zone_name'], 42 | 'currently_playing': {k: currently_playing.get(k, None) 43 | for k in CURRENTLY_PLAYING_KEYS}, 44 | 'state': current_transport_info['current_transport_state'], 45 | } 46 | 47 | if self._previous_details.get(uid, None) != details: 48 | self._callback('sonos', 'sonos-%s' % uid, details) 49 | self._previous_details[uid] = details 50 | -------------------------------------------------------------------------------- /src/pi/wemo.py: -------------------------------------------------------------------------------- 1 | """Wemo proxy code.""" 2 | 3 | import logging 4 | 5 | import pywemo 6 | 7 | from pi import proxy, scanning_proxy 8 | 9 | 10 | class Wemo(scanning_proxy.ScanningProxy): 11 | """Hue proxy object.""" 12 | 13 | def __init__(self, refresh_period, callback): 14 | super(Wemo, self).__init__(refresh_period) 15 | 16 | self._callback = callback 17 | self._devices = {} 18 | self._state_cache = {} 19 | self._subscriptions = pywemo.SubscriptionRegistry() 20 | self._subscriptions.start() 21 | 22 | 23 | def _scan_once(self): 24 | devices = pywemo.discover_devices() 25 | 26 | logging.info('Found %d wemo devices.', len(devices)) 27 | 28 | for device in devices: 29 | device_exists = device.serialnumber in self._devices 30 | self._devices[device.serialnumber] = device 31 | 32 | state = device.get_state() 33 | serialnumber = device.serialnumber 34 | state_changed = state != self._state_cache.get(serialnumber, None) 35 | self._state_cache[serialnumber] = state 36 | 37 | details = { 38 | 'serial_number': serialnumber, 39 | 'model': device.model, 40 | 'name': device.name, 41 | 'state': state == 1 42 | } 43 | 44 | if not device_exists: 45 | self._subscriptions.register(device) 46 | self._subscriptions.on(device, 'BinaryState', self._event) 47 | 48 | device_type = self.get_type(device) 49 | if device_type is None: 50 | return 51 | 52 | if not device_exists or state_changed: 53 | self._callback(device_type, 'wemo-%s' % device.serialnumber, details) 54 | 55 | def get_type(self, device): 56 | uuid = device._config.UDN 57 | if uuid.startswith('uuid:Socket'): 58 | return 'wemo_switch' 59 | #elif uuid.startswith('uuid:Lightswitch'): 60 | # return LightSwitch(location) 61 | #elif uuid.startswith('uuid:Insight'): 62 | # return Insight(location) 63 | elif uuid.startswith('uuid:Sensor'): 64 | return 'wemo_motion' 65 | else: 66 | return None 67 | 68 | @proxy.command 69 | def set_state(self, serial_number, state): 70 | device = self._devices.get(serial_number) 71 | if not device: 72 | logging.error('Device "%s" not found', serial_number) 73 | return 74 | 75 | device.set_state(1 if state else 0) 76 | 77 | def _event(self, device, value): 78 | device_type = self.get_type(device) 79 | if device_type is None: 80 | return 81 | 82 | details = { 83 | 'serial_number': device.serialnumber, 84 | 'model': device.model, 85 | 'name': device.name, 86 | 'state': int(value) == 1 87 | } 88 | 89 | self._callback(device_type, 'wemo-%s' % device.serialnumber, details) 90 | 91 | def stop(self): 92 | super(Wemo, self).stop() 93 | self._subscriptions.stop() 94 | 95 | def join(self): 96 | super(Wemo, self).join() 97 | self._subscriptions.join() 98 | -------------------------------------------------------------------------------- /src/pi/zwave.py: -------------------------------------------------------------------------------- 1 | """ZWave device proxy code.""" 2 | 3 | import logging 4 | import sys 5 | 6 | import libopenzwave 7 | 8 | from pi import events, proxy 9 | 10 | 11 | CONFIG = '/usr/local/etc/openzwave' 12 | CONTROLLER_COMMAND_ADD_DEVICE = 1 13 | 14 | 15 | class ZWave(proxy.Proxy): 16 | """ZWave proxy object.""" 17 | 18 | def __init__(self, device, callback): 19 | self._device = device 20 | self._callback = callback 21 | 22 | self._options = libopenzwave.PyOptions() 23 | self._options.create(CONFIG, '/home/pi/openzwave', '') 24 | self._options.addOptionBool('ConsoleOutput', False) 25 | self._options.addOptionInt('SaveLogLevel', 6) # INFO 26 | self._options.addOptionInt('QueueLogLevel', 6) # INFO 27 | self._options.addOptionBool('AppendLogFile', True) 28 | self._options.lock() 29 | 30 | self._manager = libopenzwave.PyManager() 31 | self._manager.create() 32 | self._manager.addWatcher(self._zwave_callback) 33 | self._manager.addDriver(self._device) 34 | 35 | self._home_id = None 36 | self._node_ids = set() 37 | self._add_device_event = None 38 | 39 | def _zwave_callback(self, data): 40 | # pylint: disable=broad-except 41 | try: 42 | self._zwave_callback_internal(data) 43 | except Exception: 44 | logging.error('Exception during zwave event', exc_info=sys.exc_info()) 45 | 46 | def _zwave_callback_internal(self, data): 47 | """Handle zwave events.""" 48 | notification_type = data['notificationType'] 49 | self._home_id = data['homeId'] 50 | node_id = data['nodeId'] 51 | 52 | if notification_type == 'AwakeNodesQueried': 53 | for node_id in self._node_ids: 54 | node_info = {'notificationType': 'NodeInfoUpdate'} 55 | node_info['basic'] = self._manager.getNodeBasic( 56 | self._home_id, node_id) 57 | node_info['generic'] = self._manager.getNodeGeneric( 58 | self._home_id, node_id) 59 | node_info['specific'] = self._manager.getNodeSpecific( 60 | self._home_id, node_id) 61 | node_info['node_type'] = self._manager.getNodeType( 62 | self._home_id, node_id) 63 | node_info['node_name'] = self._manager.getNodeName( 64 | self._home_id, node_id) 65 | node_info['manufacturer_name'] = self._manager.getNodeManufacturerName( 66 | self._home_id, node_id) 67 | node_info['manufacturer_id'] = self._manager.getNodeManufacturerId( 68 | self._home_id, node_id) 69 | node_info['product_name'] = self._manager.getNodeProductName( 70 | self._home_id, node_id) 71 | node_info['product_type'] = self._manager.getNodeProductType( 72 | self._home_id, node_id) 73 | node_info['product_id'] = self._manager.getNodeProductId( 74 | self._home_id, node_id) 75 | self._callback('zwave', 'zwave-%d' % node_id, node_info) 76 | 77 | if node_id in {1, 255}: 78 | logging.info('ZWave callback - %d %s', node_id, notification_type) 79 | return 80 | 81 | if notification_type == 'NodeAdded': 82 | self._node_ids.add(node_id) 83 | 84 | self._manager.addAssociation(self._home_id, node_id, 1, 1) 85 | self._manager.refreshNodeInfo(self._home_id, node_id) 86 | self._manager.requestNodeState(self._home_id, node_id) 87 | self._manager.requestNodeDynamic(self._home_id, node_id) 88 | self._callback('zwave', 'zwave-%d' % node_id, data) 89 | 90 | elif notification_type in {'ValueAdded', 'ValueChanged', 'NodeNaming'}: 91 | self._callback('zwave', 'zwave-%d' % node_id, data) 92 | 93 | else: 94 | logging.info('ZWave callback - %d %s', node_id, notification_type) 95 | 96 | @proxy.command 97 | def heal(self): 98 | self._manager.softResetController(self._home_id) 99 | self._manager.healNetwork(self._home_id, upNodeRoute=True) 100 | 101 | @proxy.command 102 | def hard_reset(self): 103 | self._manager.resetController(self._home_id) 104 | 105 | @proxy.command 106 | def heal_node(self, node_id): 107 | self._manager.healNetworkNode(self._home_id, node_id, 108 | upNodeRoute=True) 109 | 110 | @proxy.command 111 | def set_value(self, value_id, value, node_id=None): 112 | logging.info('Setting value %s on device %s to %s', 113 | value_id, node_id, value) 114 | success = self._manager.setValue(value_id, value) 115 | if not success: 116 | logging.info('Failed') 117 | 118 | def _add_device_callback(self, args): 119 | logging.info('_add_device_callback, args = %s', args) 120 | 121 | def _cancel_add_device(self): 122 | logging.info('Cancelling zwave inclusion mode.') 123 | self._manager.cancelControllerCommand(self._home_id) 124 | 125 | @proxy.command 126 | def add_device(self): 127 | """Put the zwave controller in inclusion mode for 30s.""" 128 | logging.info('Entering zwave inclusion mode.') 129 | self._manager.beginControllerCommand( 130 | self._home_id, CONTROLLER_COMMAND_ADD_DEVICE, 131 | self._add_device_callback, highPower=True) 132 | 133 | self._add_device_event = events.enter(30, self._cancel_add_device) 134 | 135 | def stop(self): 136 | if self._add_device_event is not None: 137 | try: 138 | events.cancel(self._add_device_event) 139 | except ValueError: 140 | pass 141 | 142 | if self._home_id is not None: 143 | self._manager.writeConfig(self._home_id) 144 | self._manager.removeWatcher(self._callback) 145 | self._manager.removeDriver(self._device) 146 | 147 | def join(self): 148 | pass 149 | -------------------------------------------------------------------------------- /src/static/css/history.css: -------------------------------------------------------------------------------- 1 | .bar { 2 | fill: steelblue; 3 | } 4 | 5 | .axis text { 6 | font: 10px sans-serif; 7 | } 8 | 9 | .axis path, 10 | .axis line { 11 | fill: none; 12 | stroke: #000; 13 | shape-rendering: crispEdges; 14 | } 15 | 16 | .x.axis path { 17 | display: none; 18 | } 19 | -------------------------------------------------------------------------------- /src/static/css/screen.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Base structure 3 | */ 4 | 5 | body { 6 | font: RobotoDraft, 'Helvetica Neue', Helvetica, Arial; 7 | padding-top: 0; 8 | background-color: rgb(229, 229, 229); 9 | } 10 | 11 | .header { 12 | position: fixed; 13 | width: 100%; 14 | height: 100px; 15 | top: 0; 16 | z-index: 1; 17 | 18 | padding-top: 12px; 19 | padding-left: 12px; 20 | padding-right: 12px; 21 | 22 | background-color: rgb(3, 169, 244); 23 | border-bottom: 1px solid #eee; 24 | color: #FFF; 25 | 26 | 27 | 28 | -webkit-transition: height 0.3s; 29 | -moz-transition: height 0.3s; 30 | -ms-transition: height 0.3s; 31 | -o-transition: height 0.3s; 32 | transition: height 0.3s; 33 | } 34 | 35 | .no-overflow { 36 | overflow: hidden; 37 | } 38 | 39 | .header.smaller { 40 | height: 50px; 41 | } 42 | 43 | .header.smaller .nav { 44 | display: none; 45 | } 46 | 47 | .header h1 { 48 | margin-top: 0; 49 | font-size: 21px; 50 | font-weight: normal; 51 | float: left; 52 | } 53 | 54 | .header .btn { 55 | color: inherit; 56 | background: inherit; 57 | border: none; 58 | } 59 | 60 | #main { 61 | margin-top: 108px; 62 | } 63 | 64 | #main .help { 65 | font-family: 'Indie Flower', cursive; 66 | font-size: 2em; 67 | text-align: center; 68 | } 69 | 70 | /* Top navigation */ 71 | .nav { 72 | clear: left; 73 | } 74 | 75 | .nav > li > a { 76 | padding-right: 20px; 77 | padding-left: 20px; 78 | color: inherit; 79 | } 80 | 81 | .nav > a:hover, 82 | .nav > a:focus { 83 | color: #000; 84 | } 85 | 86 | .nav > .active > a, 87 | .nav > .active > a:hover, 88 | .nav > .active > a:focus { 89 | background-color: #428bca; 90 | } 91 | 92 | /* 93 | * Placeholder dashboard ideas 94 | */ 95 | 96 | .row { 97 | padding: 8px 0 0 8px; 98 | } 99 | 100 | .room { 101 | padding: 0; 102 | padding-right: 8px; 103 | } 104 | 105 | .room > .room-name { 106 | font-size: 1.5em; 107 | color: #424242; 108 | } 109 | 110 | .room > .room-name > div:first-child { 111 | white-space: nowrap; 112 | overflow: hidden; 113 | text-overflow: ellipsis; 114 | padding-right: 30px; 115 | } 116 | 117 | .room > .room-name > .btn-group { 118 | position: absolute; 119 | top: 0; 120 | right: 0; 121 | } 122 | 123 | .room > .room-name > .btn-group > button { 124 | border: none; 125 | background: inherit; 126 | } 127 | 128 | .devices { 129 | padding-left: 8px; 130 | } 131 | 132 | .card { 133 | padding: 5px; 134 | margin-bottom: 8px; 135 | margin-right: 8px; 136 | 137 | border-radius: 3px; 138 | border: 1px solid #F5F5F5; 139 | background-color: #FFFFFF; 140 | box-shadow: rgba(0,0,0,.098) 0 2px 4px, rgba(0,0,0,.098) 0 0 3px; 141 | color: #424242; 142 | } 143 | 144 | .card .content { 145 | position: absolute; 146 | top: 0; 147 | left: 0; 148 | bottom: 0; 149 | right: 0; 150 | overflow: hidden; 151 | } 152 | 153 | .device { 154 | position: relative; 155 | 156 | box-sizing: border-box; 157 | height: 140px; 158 | width: 100%; 159 | 160 | text-align: center; 161 | } 162 | 163 | .device .watermark { 164 | position: absolute; 165 | left: -15px; 166 | bottom: -5px; 167 | } 168 | 169 | .glyphicons-lightbulb { 170 | color: #FFECB3; 171 | } 172 | 173 | .glyphicons-iphone { 174 | color: #9CCC65; 175 | } 176 | 177 | .glyphicons-cloud { 178 | color: #EEEEEE; 179 | } 180 | 181 | .glyphicons-music { 182 | color: #EF9A9A; 183 | } 184 | 185 | .device .device-name { 186 | position: absolute; 187 | width: 100%; 188 | height: 2em; 189 | top: 0; 190 | left: 0; 191 | right: 0; 192 | 193 | padding-left: 5px; 194 | text-align: left; 195 | white-space: nowrap; 196 | overflow: hidden; 197 | text-overflow: ellipsis; 198 | 199 | font-size: 1.5em; 200 | } 201 | 202 | .device .device-details { 203 | position: absolute; 204 | top: 2em; 205 | bottom: 2em; 206 | left: 0; 207 | right: 0; 208 | } 209 | 210 | .device .btn-group { 211 | position: absolute; 212 | right: 0; 213 | bottom: 0; 214 | } 215 | 216 | .device button { 217 | color: inherit; 218 | background: inherit; 219 | border: none; 220 | border-radius: 3px 0 3px 0; 221 | } 222 | 223 | ul.dropdown-menu a .menucontrol { 224 | position: relative; 225 | padding-left: 20px; 226 | } 227 | 228 | ul.dropdown-menu a .menucontrol .glyphicon { 229 | position: absolute; 230 | top: 0; 231 | left: 0; 232 | } 233 | 234 | /* 235 | * Spinning loading icon 236 | */ 237 | .glyphicon-refresh-animate { 238 | -animation: spin 0.5s infinite linear; 239 | -webkit-animation: spin2 0.5s infinite linear; 240 | -moz-animation: spin 0.5s infinite linear; 241 | } 242 | 243 | @-webkit-keyframes spin2 { 244 | from { -webkit-transform: rotate(0deg);} 245 | to { -webkit-transform: rotate(360deg);} 246 | } 247 | 248 | @keyframes spin { 249 | from { transform: scale(1) rotate(0deg);} 250 | to { transform: scale(1) rotate(360deg);} 251 | } 252 | -------------------------------------------------------------------------------- /src/static/imgs/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomwilkie/awesomation/708a0ff2ffd431f24ed3f942cafd24882dc89620/src/static/imgs/favicon.png -------------------------------------------------------------------------------- /src/static/imgs/netatmo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomwilkie/awesomation/708a0ff2ffd431f24ed3f942cafd24882dc89620/src/static/imgs/netatmo.png -------------------------------------------------------------------------------- /third_party/py/edimax/smartplug.py: -------------------------------------------------------------------------------- 1 | ## 2 | # The MIT License (MIT) 3 | # 4 | # Copyright (c) 2014 Stefan Wendler 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | ## 24 | 25 | __author__ = 'Stefan Wendler, sw@kaltpost.de' 26 | 27 | import requests as req 28 | import optparse as par 29 | import logging as log 30 | 31 | from xml.dom.minidom import getDOMImplementation 32 | from xml.dom.minidom import parseString 33 | 34 | 35 | class SmartPlug(object): 36 | 37 | """ 38 | Simple class to access a "EDIMAX Smart Plug Switch SP-1101W" 39 | 40 | Usage example when used as library: 41 | 42 | p = SmartPlug("172.16.100.75", ('admin', '1234')) 43 | 44 | p.state = "OFF" 45 | p.state = "ON" 46 | print(p.state) 47 | 48 | Usage example when used as command line utility: 49 | 50 | turn plug on: 51 | 52 | python smartplug.py -H 172.16.100.75 -l admin -p 1234 -s ON 53 | 54 | turn plug off: 55 | 56 | python smartplug.py -H 172.16.100.75-l admin -p 1234 -s OFF 57 | 58 | get plug state: 59 | 60 | python smartplug.py -H 172.16.100.75-l admin -p 1234 -g 61 | 62 | """ 63 | 64 | def __init__(self, host, auth): 65 | 66 | """ 67 | Create a new SmartPlug instance identified by the given URL. 68 | 69 | :rtype : object 70 | :param host: The IP/hostname of the SmartPlug. E.g. '172.16.100.75' 71 | :param auth: User and password to authenticate with the plug. E.g. ('admin', '1234') 72 | """ 73 | 74 | self.url = "http://%s:10000/smartplug.cgi" % host 75 | self.auth = auth 76 | self.domi = getDOMImplementation() 77 | 78 | def __xml_cmd(self, cmdId, cmdStr): 79 | 80 | """ 81 | Create XML representation of a command. 82 | 83 | :param cmdId: Use 'get' to request plug state, use 'setup' change plug state. 84 | :param cmdStr: Empty string for 'get', 'ON' or 'OFF' for 'setup' 85 | :return: XML representation of command 86 | """ 87 | 88 | doc = self.domi.createDocument(None, "SMARTPLUG", None) 89 | doc.documentElement.setAttribute("id", "edimax") 90 | 91 | cmd = doc.createElement("CMD") 92 | cmd.setAttribute("id", cmdId) 93 | state = doc.createElement("Device.System.Power.State") 94 | cmd.appendChild(state) 95 | state.appendChild(doc.createTextNode(cmdStr)) 96 | 97 | doc.documentElement.appendChild(cmd) 98 | 99 | return doc.toxml() 100 | 101 | def __post_xml(self, xml): 102 | 103 | """ 104 | Post XML command as multipart file to SmartPlug, parse XML response. 105 | 106 | :param xml: XML representation of command (as generated by __xml_cmd) 107 | :return: 'OK' on success, 'FAILED' otherwise 108 | """ 109 | 110 | files = {'file': xml} 111 | 112 | res = req.post(self.url, auth=self.auth, files=files) 113 | 114 | if res.status_code == req.codes.ok: 115 | dom = parseString(res.text) 116 | 117 | try: 118 | val = dom.getElementsByTagName("CMD")[0].firstChild.nodeValue 119 | 120 | if val is None: 121 | val = dom.getElementsByTagName("CMD")[0].getElementsByTagName("Device.System.Power.State")[0].\ 122 | firstChild.nodeValue 123 | 124 | return val 125 | 126 | except Exception as e: 127 | 128 | print(e.__str__()) 129 | 130 | return None 131 | 132 | @property 133 | def state(self): 134 | 135 | """ 136 | Get the current state of the SmartPlug. 137 | 138 | :return: 'ON' or 'OFF' 139 | """ 140 | 141 | res = self.__post_xml(self.__xml_cmd("get", "")) 142 | 143 | if res != "ON" and res != "OFF": 144 | raise Exception("Failed to communicate with SmartPlug") 145 | 146 | return res 147 | 148 | @state.setter 149 | def state(self, value): 150 | 151 | """ 152 | Set the state of the SmartPlug 153 | 154 | :param value: 'ON', 'on', 'OFF' or 'off' 155 | """ 156 | 157 | if value == "ON" or value == "on": 158 | res = self.__post_xml(self.__xml_cmd("setup", "ON")) 159 | else: 160 | res = self.__post_xml(self.__xml_cmd("setup", "OFF")) 161 | 162 | if res != "OK": 163 | raise Exception("Failed to communicate with SmartPlug") 164 | 165 | if __name__ == "__main__": 166 | 167 | # this turns on debugging from requests library 168 | log.basicConfig(level=log.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') 169 | 170 | usage = "%prog [options]" 171 | 172 | parser = par.OptionParser(usage) 173 | 174 | parser.add_option("-H", "--host", default="172.16.100.75", help="Base URL of the SmartPlug") 175 | parser.add_option("-l", "--login", default="admin", help="Login user to authenticate with SmartPlug") 176 | parser.add_option("-p", "--password", default="1234", help="Password to authenticate with SmartPlug") 177 | 178 | parser.add_option("-g", "--get", action="store_true", help="Get state of plug") 179 | parser.add_option("-s", "--set", help="Set state of plug: ON or OFF") 180 | 181 | (options, args) = parser.parse_args() 182 | 183 | try: 184 | 185 | p = SmartPlug(options.host, (options.login, options.password)) 186 | 187 | if options.get: 188 | print(p.state) 189 | elif options.set: 190 | p.state = options.set 191 | 192 | except Exception as e: 193 | print(e.__str__()) -------------------------------------------------------------------------------- /third_party/py/websocket-client/.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *~ 3 | *\# 4 | .\#* 5 | 6 | build 7 | dist 8 | websocket_client.egg-info 9 | -------------------------------------------------------------------------------- /third_party/py/websocket-client/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | -------------------------------------------------------------------------------- /third_party/py/websocket-client/README.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | websocket-client 3 | ================= 4 | 5 | websocket-client module is WebSocket client for python. This provide the low level APIs for WebSocket. All APIs are the synchronous functions. 6 | 7 | websocket-client supports only hybi-13. 8 | 9 | License 10 | ============ 11 | 12 | - LGPL 13 | 14 | Installation 15 | ============= 16 | 17 | This module is tested on only Python 2.7. 18 | 19 | Type "python setup.py install" or "pip install websocket-client" to install. 20 | 21 | This module does not depend on any other module. 22 | 23 | How about Python 3 24 | =========================== 25 | 26 | py3( https://github.com/liris/websocket-client/tree/py3 ) branch is for python 3.3. Every test case is passed. 27 | If you are using python3, please check it. 28 | 29 | Example 30 | ============ 31 | 32 | Low Level API example:: 33 | 34 | from websocket import create_connection 35 | ws = create_connection("ws://echo.websocket.org/") 36 | print "Sending 'Hello, World'..." 37 | ws.send("Hello, World") 38 | print "Sent" 39 | print "Reeiving..." 40 | result = ws.recv() 41 | print "Received '%s'" % result 42 | ws.close() 43 | 44 | If you want to customize socket options, set sockopt. 45 | 46 | sockopt example: 47 | 48 | from websocket import create_connection 49 | ws = create_connection("ws://echo.websocket.org/". 50 | sockopt=((socket.IPPROTO_TCP, socket.TCP_NODELAY),) ) 51 | 52 | 53 | JavaScript websocket-like API example:: 54 | 55 | import websocket 56 | import thread 57 | import time 58 | 59 | def on_message(ws, message): 60 | print message 61 | 62 | def on_error(ws, error): 63 | print error 64 | 65 | def on_close(ws): 66 | print "### closed ###" 67 | 68 | def on_open(ws): 69 | def run(*args): 70 | for i in range(3): 71 | time.sleep(1) 72 | ws.send("Hello %d" % i) 73 | time.sleep(1) 74 | ws.close() 75 | print "thread terminating..." 76 | thread.start_new_thread(run, ()) 77 | 78 | 79 | if __name__ == "__main__": 80 | websocket.enableTrace(True) 81 | ws = websocket.WebSocketApp("ws://echo.websocket.org/", 82 | on_message = on_message, 83 | on_error = on_error, 84 | on_close = on_close) 85 | ws.on_open = on_open 86 | 87 | ws.run_forever() 88 | 89 | 90 | wsdump.py 91 | ============ 92 | 93 | wsdump.py is simple WebSocket test(debug) tool. 94 | 95 | sample for echo.websocket.org:: 96 | 97 | $ wsdump.py ws://echo.websocket.org/ 98 | Press Ctrl+C to quit 99 | > Hello, WebSocket 100 | < Hello, WebSocket 101 | > How are you? 102 | < How are you? 103 | 104 | Usage 105 | --------- 106 | 107 | usage:: 108 | wsdump.py [-h] [-v [VERBOSE]] ws_url 109 | 110 | WebSocket Simple Dump Tool 111 | 112 | positional arguments: 113 | ws_url websocket url. ex. ws://echo.websocket.org/ 114 | 115 | optional arguments: 116 | -h, --help show this help message and exit 117 | 118 | -v VERBOSE, --verbose VERBOSE set verbose mode. If set to 1, show opcode. If set to 2, enable to trace websocket module 119 | 120 | example:: 121 | 122 | $ wsdump.py ws://echo.websocket.org/ 123 | $ wsdump.py ws://echo.websocket.org/ -v 124 | $ wsdump.py ws://echo.websocket.org/ -vv 125 | 126 | ChangeLog 127 | ============ 128 | 129 | - v0.12.0 130 | 131 | - support keep alive for WebSocketApp(ISSUE#34) 132 | - fix some SSL bugs(ISSUE#35, #36) 133 | - fix "Timing out leaves websocket library in bad state"(ISSUE#37) 134 | - fix "WebSocketApp.run_with_no_err() silently eats all exceptions"(ISSUE#38) 135 | - WebSocketTimeoutException will be raised for ws/wss timeout(ISSUE#40) 136 | - improve wsdump message(ISSUE#42) 137 | - support fragmentation message(ISSUE#43) 138 | - fix some bugs 139 | 140 | - v0.11.0 141 | 142 | - Only log non-normal close status(ISSUE#31) 143 | - Fix default Origin isn't URI(ISSUE#32) 144 | - fileno support(ISSUE#33) 145 | 146 | - v0.10.0 147 | 148 | - allow to set HTTP Header to WebSocketApp(ISSUE#27) 149 | - fix typo in pydoc(ISSUE#28) 150 | - Passing a socketopt flag to the websocket constructor(ISSUE#29) 151 | - websocket.send fails with long data(ISSUE#30) 152 | 153 | 154 | - v0.9.0 155 | 156 | - allow to set opcode in WebSocketApp.send(ISSUE#25) 157 | - allow to modify Origin(ISSUE#26) 158 | 159 | - v0.8.0 160 | 161 | - many bug fix 162 | - some performance improvement 163 | 164 | - v0.7.0 165 | 166 | - fixed problem to read long data.(ISSUE#12) 167 | - fix buffer size boundary violation 168 | 169 | - v0.6.0 170 | 171 | - Patches: UUID4, self.keep_running, mask_key (ISSUE#11) 172 | - add wsdump.py tool 173 | 174 | - v0.5.2 175 | 176 | - fix Echo App Demo Throw Error: 'NoneType' object has no attribute 'opcode (ISSUE#10) 177 | 178 | - v0.5.1 179 | 180 | - delete invalid print statement. 181 | 182 | - v0.5.0 183 | 184 | - support hybi-13 protocol. 185 | 186 | - v0.4.1 187 | 188 | - fix incorrect custom header order(ISSUE#1) 189 | 190 | -------------------------------------------------------------------------------- /third_party/py/websocket-client/bin/wsdump.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import code 5 | import sys 6 | import threading 7 | import websocket 8 | try: 9 | import readline 10 | except: 11 | pass 12 | 13 | 14 | OPCODE_DATA = (websocket.ABNF.OPCODE_TEXT, websocket.ABNF.OPCODE_BINARY) 15 | ENCODING = getattr(sys.stdin, "encoding", "").lower() 16 | 17 | class VAction(argparse.Action): 18 | def __call__(self, parser, args, values, option_string=None): 19 | if values==None: 20 | values = "1" 21 | try: 22 | values = int(values) 23 | except ValueError: 24 | values = values.count("v")+1 25 | setattr(args, self.dest, values) 26 | 27 | def parse_args(): 28 | parser = argparse.ArgumentParser(description="WebSocket Simple Dump Tool") 29 | parser.add_argument("url", metavar="ws_url", 30 | help="websocket url. ex. ws://echo.websocket.org/") 31 | parser.add_argument("-v", "--verbose", default=0, nargs='?', action=VAction, 32 | dest="verbose", 33 | help="set verbose mode. If set to 1, show opcode. " 34 | "If set to 2, enable to trace websocket module") 35 | 36 | return parser.parse_args() 37 | 38 | 39 | class InteractiveConsole(code.InteractiveConsole): 40 | def write(self, data): 41 | sys.stdout.write("\033[2K\033[E") 42 | # sys.stdout.write("\n") 43 | sys.stdout.write("\033[34m" + data + "\033[39m") 44 | sys.stdout.write("\n> ") 45 | sys.stdout.flush() 46 | 47 | def raw_input(self, prompt): 48 | line = raw_input(prompt) 49 | if ENCODING and ENCODING != "utf-8" and not isinstance(line, unicode): 50 | line = line.decode(ENCODING).encode("utf-8") 51 | elif isinstance(line, unicode): 52 | line = encode("utf-8") 53 | 54 | return line 55 | 56 | 57 | def main(): 58 | args = parse_args() 59 | console = InteractiveConsole() 60 | if args.verbose > 1: 61 | websocket.enableTrace(True) 62 | ws = websocket.create_connection(args.url) 63 | print("Press Ctrl+C to quit") 64 | 65 | def recv(): 66 | frame = ws.recv_frame() 67 | if not frame: 68 | raise websocket.WebSocketException("Not a valid frame %s" % frame) 69 | elif frame.opcode in OPCODE_DATA: 70 | return (frame.opcode, frame.data) 71 | elif frame.opcode == websocket.ABNF.OPCODE_CLOSE: 72 | ws.send_close() 73 | return (frame.opcode, None) 74 | elif frame.opcode == websocket.ABNF.OPCODE_PING: 75 | ws.pong("Hi!") 76 | return frame.opcode, frame.data 77 | 78 | return frame.opcode, frame.data 79 | 80 | 81 | def recv_ws(): 82 | while True: 83 | opcode, data = recv() 84 | msg = None 85 | if not args.verbose and opcode in OPCODE_DATA: 86 | msg = "< %s" % data 87 | elif args.verbose: 88 | msg = "< %s: %s" % (websocket.ABNF.OPCODE_MAP.get(opcode), data) 89 | 90 | if msg: 91 | console.write(msg) 92 | 93 | thread = threading.Thread(target=recv_ws) 94 | thread.daemon = True 95 | thread.start() 96 | 97 | while True: 98 | try: 99 | message = console.raw_input("> ") 100 | ws.send(message) 101 | except KeyboardInterrupt: 102 | return 103 | except EOFError: 104 | return 105 | 106 | 107 | if __name__ == "__main__": 108 | try: 109 | main() 110 | except Exception as e: 111 | print(e) 112 | -------------------------------------------------------------------------------- /third_party/py/websocket-client/data/header01.txt: -------------------------------------------------------------------------------- 1 | HTTP/1.1 101 WebSocket Protocol Handshake 2 | Connection: Upgrade 3 | Upgrade: WebSocket 4 | Sec-WebSocket-Accept: Kxep+hNu9n51529fGidYu7a3wO0= 5 | some_header: something 6 | 7 | -------------------------------------------------------------------------------- /third_party/py/websocket-client/data/header02.txt: -------------------------------------------------------------------------------- 1 | HTTP/1.1 101 WebSocket Protocol Handshake 2 | Connection: Upgrade 3 | Upgrade WebSocket 4 | Sec-WebSocket-Accept: Kxep+hNu9n51529fGidYu7a3wO0= 5 | some_header: something 6 | 7 | -------------------------------------------------------------------------------- /third_party/py/websocket-client/examples/echo_client.py: -------------------------------------------------------------------------------- 1 | import websocket 2 | 3 | if __name__ == "__main__": 4 | websocket.enableTrace(True) 5 | ws = websocket.create_connection("ws://echo.websocket.org/") 6 | print("Sending 'Hello, World'...") 7 | ws.send("Hello, World") 8 | print("Sent") 9 | print("Receiving...") 10 | result = ws.recv() 11 | print("Received '%s'" % result) 12 | ws.close() 13 | -------------------------------------------------------------------------------- /third_party/py/websocket-client/examples/echoapp_client.py: -------------------------------------------------------------------------------- 1 | import websocket 2 | import thread 3 | import time 4 | import sys 5 | 6 | 7 | def on_message(ws, message): 8 | print(message) 9 | 10 | 11 | def on_error(ws, error): 12 | print(error) 13 | 14 | 15 | def on_close(ws): 16 | print("### closed ###") 17 | 18 | 19 | def on_open(ws): 20 | def run(*args): 21 | for i in range(3): 22 | # send the message, then wait 23 | # so thread doesnt exit and socket 24 | # isnt closed 25 | ws.send("Hello %d" % i) 26 | time.sleep(1) 27 | 28 | time.sleep(1) 29 | ws.close() 30 | print("Thread terminating...") 31 | 32 | thread.start_new_thread(run, ()) 33 | 34 | if __name__ == "__main__": 35 | websocket.enableTrace(True) 36 | if len(sys.argv) < 2: 37 | host = "ws://echo.websocket.org/" 38 | else: 39 | host = sys.argv[1] 40 | ws = websocket.WebSocketApp(host, 41 | on_message = on_message, 42 | on_error = on_error, 43 | on_close = on_close) 44 | ws.on_open = on_open 45 | ws.run_forever() 46 | -------------------------------------------------------------------------------- /third_party/py/websocket-client/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | VERSION = "0.13.0" 4 | 5 | 6 | setup( 7 | name="websocket-client", 8 | version=VERSION, 9 | description="WebSocket client for python. hybi13 is supported.", 10 | long_description=open("README.rst").read(), 11 | author="liris", 12 | author_email="liris.pp@gmail.com", 13 | license="LGPL", 14 | url="https://github.com/liris/websocket-client", 15 | classifiers=[ 16 | "Development Status :: 3 - Alpha", 17 | "License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)", 18 | "Programming Language :: Python", 19 | "Operating System :: MacOS :: MacOS X", 20 | "Operating System :: POSIX", 21 | "Operating System :: Microsoft :: Windows", 22 | "Topic :: Internet", 23 | "Topic :: Software Development :: Libraries :: Python Modules", 24 | "Intended Audience :: Developers", 25 | ], 26 | keywords='websockets', 27 | py_modules=["websocket"], 28 | scripts=["bin/wsdump.py"] 29 | ) 30 | -------------------------------------------------------------------------------- /third_party/static/sprintf.js/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2007-2013, Alexandru Marasteanu 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of this software nor the names of its contributors may be 12 | used to endorse or promote products derived from this software without 13 | specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 19 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /third_party/static/sprintf.js/README.md: -------------------------------------------------------------------------------- 1 | # sprintf.js 2 | sprintf.js is a complete open source JavaScript sprintf implementation for the *browser* and *node.js*. 3 | 4 | Its prototype is simple: 5 | 6 | string sprintf(string format , [mixed arg1 [, mixed arg2 [ ,...]]]); 7 | 8 | The placeholders in the format string are marked by "%" and are followed by one or more of these elements, in this order: 9 | * An optional "+" sign that forces to preceed the result with a plus or minus sign on numeric values. By default, only the "-" sign is used on negative numbers. 10 | * An optional padding specifier that says what character to use for padding (if specified). Possible values are 0 or any other character precedeed by a '. The default is to pad with spaces. 11 | * An optional "-" sign, that causes sprintf to left-align the result of this placeholder. The default is to right-align the result. 12 | * An optional number, that says how many characters the result should have. If the value to be returned is shorter than this number, the result will be padded. 13 | * An optional precision modifier, consisting of a "." (dot) followed by a number, that says how many digits should be displayed for floating point numbers. When used on a string, it causes the result to be truncated. 14 | * A type specifier that can be any of: 15 | * % — print a literal "%" character 16 | * b — print an integer as a binary number 17 | * c — print an integer as the character with that ASCII value 18 | * d — print an integer as a signed decimal number 19 | * e — print a float as scientific notation 20 | * u — print an integer as an unsigned decimal number 21 | * f — print a float as is 22 | * o — print an integer as an octal number 23 | * s — print a string as is 24 | * x — print an integer as a hexadecimal number (lower-case) 25 | * X — print an integer as a hexadecimal number (upper-case) 26 | 27 | ## JavaScript vsprintf() 28 | vsprintf() is the same as sprintf() except that it accepts an array of arguments, rather than a variable number of arguments: 29 | 30 | vsprintf('The first 4 letters of the english alphabet are: %s, %s, %s and %s', ['a', 'b', 'c', 'd']); 31 | 32 | ## Argument swapping 33 | You can also swap the arguments. That is, the order of the placeholders doesn't have to match the order of the arguments. You can do that by simply indicating in the format string which arguments the placeholders refer to: 34 | 35 | sprintf('%2$s %3$s a %1$s', 'cracker', 'Polly', 'wants'); 36 | And, of course, you can repeat the placeholders without having to increase the number of arguments. 37 | 38 | ## Named arguments 39 | Format strings may contain replacement fields rather than positional placeholders. Instead of referring to a certain argument, you can now refer to a certain key within an object. Replacement fields are surrounded by rounded parentheses () and begin with a keyword that refers to a key: 40 | 41 | var user = { 42 | name: 'Dolly' 43 | }; 44 | sprintf('Hello %(name)s', user); // Hello Dolly 45 | Keywords in replacement fields can be optionally followed by any number of keywords or indexes: 46 | 47 | var users = [ 48 | {name: 'Dolly'}, 49 | {name: 'Molly'}, 50 | {name: 'Polly'} 51 | ]; 52 | sprintf('Hello %(users[0].name)s, %(users[1].name)s and %(users[2].name)s', {users: users}); // Hello Dolly, Molly and Polly 53 | Note: mixing positional and named placeholders is not (yet) supported 54 | 55 | # As a node.js module 56 | ## Install 57 | 58 | npm install sprintf-js 59 | 60 | ## How to 61 | 62 | var sprintf = require("sprintf-js").sprintf, 63 | vsprintf = require("sprintf-js").vsprintf; 64 | 65 | console.log(sprintf("%2$s %3$s a %1$s", "cracker", "Polly", "wants")); 66 | console.log(vsprintf("The first 4 letters of the english alphabet are: %s, %s, %s and %s", ["a", "b", "c", "d"])); 67 | -------------------------------------------------------------------------------- /third_party/static/sprintf.js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sprintf-js", 3 | "version": "0.0.7", 4 | "description": "JavaScript sprintf implementation", 5 | "main": "src/sprintf.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/alexei/sprintf.js.git" 15 | }, 16 | "author": "Alexandru Marasteanu (http://alexei.ro/)", 17 | "license": "BSD", 18 | "readmeFilename": "README.md" 19 | } 20 | -------------------------------------------------------------------------------- /third_party/static/sprintf.js/src/sprintf.js: -------------------------------------------------------------------------------- 1 | /*! sprintf.js | Copyright (c) 2007-2013 Alexandru Marasteanu | 3 clause BSD license */ 2 | 3 | (function(ctx) { 4 | var sprintf = function() { 5 | if (!sprintf.cache.hasOwnProperty(arguments[0])) { 6 | sprintf.cache[arguments[0]] = sprintf.parse(arguments[0]); 7 | } 8 | return sprintf.format.call(null, sprintf.cache[arguments[0]], arguments); 9 | }; 10 | 11 | sprintf.format = function(parse_tree, argv) { 12 | var cursor = 1, tree_length = parse_tree.length, node_type = '', arg, output = [], i, k, match, pad, pad_character, pad_length; 13 | for (i = 0; i < tree_length; i++) { 14 | node_type = get_type(parse_tree[i]); 15 | if (node_type === 'string') { 16 | output.push(parse_tree[i]); 17 | } 18 | else if (node_type === 'array') { 19 | match = parse_tree[i]; // convenience purposes only 20 | if (match[2]) { // keyword argument 21 | arg = argv[cursor]; 22 | for (k = 0; k < match[2].length; k++) { 23 | if (!arg.hasOwnProperty(match[2][k])) { 24 | throw(sprintf('[sprintf] property "%s" does not exist', match[2][k])); 25 | } 26 | arg = arg[match[2][k]]; 27 | } 28 | } 29 | else if (match[1]) { // positional argument (explicit) 30 | arg = argv[match[1]]; 31 | } 32 | else { // positional argument (implicit) 33 | arg = argv[cursor++]; 34 | } 35 | 36 | if (/[^s]/.test(match[8]) && (get_type(arg) != 'number')) { 37 | throw(sprintf('[sprintf] expecting number but found %s', get_type(arg))); 38 | } 39 | switch (match[8]) { 40 | case 'b': arg = arg.toString(2); break; 41 | case 'c': arg = String.fromCharCode(arg); break; 42 | case 'd': arg = parseInt(arg, 10); break; 43 | case 'e': arg = match[7] ? arg.toExponential(match[7]) : arg.toExponential(); break; 44 | case 'f': arg = match[7] ? parseFloat(arg).toFixed(match[7]) : parseFloat(arg); break; 45 | case 'o': arg = arg.toString(8); break; 46 | case 's': arg = ((arg = String(arg)) && match[7] ? arg.substring(0, match[7]) : arg); break; 47 | case 'u': arg = arg >>> 0; break; 48 | case 'x': arg = arg.toString(16); break; 49 | case 'X': arg = arg.toString(16).toUpperCase(); break; 50 | } 51 | arg = (/[def]/.test(match[8]) && match[3] && arg >= 0 ? '+'+ arg : arg); 52 | pad_character = match[4] ? match[4] == '0' ? '0' : match[4].charAt(1) : ' '; 53 | pad_length = match[6] - String(arg).length; 54 | pad = match[6] ? str_repeat(pad_character, pad_length) : ''; 55 | output.push(match[5] ? arg + pad : pad + arg); 56 | } 57 | } 58 | return output.join(''); 59 | }; 60 | 61 | sprintf.cache = {}; 62 | 63 | sprintf.parse = function(fmt) { 64 | var _fmt = fmt, match = [], parse_tree = [], arg_names = 0; 65 | while (_fmt) { 66 | if ((match = /^[^\x25]+/.exec(_fmt)) !== null) { 67 | parse_tree.push(match[0]); 68 | } 69 | else if ((match = /^\x25{2}/.exec(_fmt)) !== null) { 70 | parse_tree.push('%'); 71 | } 72 | else if ((match = /^\x25(?:([1-9]\d*)\$|\(([^\)]+)\))?(\+)?(0|'[^$])?(-)?(\d+)?(?:\.(\d+))?([b-fosuxX])/.exec(_fmt)) !== null) { 73 | if (match[2]) { 74 | arg_names |= 1; 75 | var field_list = [], replacement_field = match[2], field_match = []; 76 | if ((field_match = /^([a-z_][a-z_\d]*)/i.exec(replacement_field)) !== null) { 77 | field_list.push(field_match[1]); 78 | while ((replacement_field = replacement_field.substring(field_match[0].length)) !== '') { 79 | if ((field_match = /^\.([a-z_][a-z_\d]*)/i.exec(replacement_field)) !== null) { 80 | field_list.push(field_match[1]); 81 | } 82 | else if ((field_match = /^\[(\d+)\]/.exec(replacement_field)) !== null) { 83 | field_list.push(field_match[1]); 84 | } 85 | else { 86 | throw('[sprintf] huh?'); 87 | } 88 | } 89 | } 90 | else { 91 | throw('[sprintf] huh?'); 92 | } 93 | match[2] = field_list; 94 | } 95 | else { 96 | arg_names |= 2; 97 | } 98 | if (arg_names === 3) { 99 | throw('[sprintf] mixing positional and named placeholders is not (yet) supported'); 100 | } 101 | parse_tree.push(match); 102 | } 103 | else { 104 | throw('[sprintf] huh?'); 105 | } 106 | _fmt = _fmt.substring(match[0].length); 107 | } 108 | return parse_tree; 109 | }; 110 | 111 | var vsprintf = function(fmt, argv, _argv) { 112 | _argv = argv.slice(0); 113 | _argv.splice(0, 0, fmt); 114 | return sprintf.apply(null, _argv); 115 | }; 116 | 117 | /** 118 | * helpers 119 | */ 120 | function get_type(variable) { 121 | return Object.prototype.toString.call(variable).slice(8, -1).toLowerCase(); 122 | } 123 | 124 | function str_repeat(input, multiplier) { 125 | for (var output = []; multiplier > 0; output[--multiplier] = input) {/* do nothing */} 126 | return output.join(''); 127 | } 128 | 129 | /** 130 | * export to either browser or node.js 131 | */ 132 | ctx.sprintf = sprintf; 133 | ctx.vsprintf = vsprintf; 134 | })(typeof exports != "undefined" ? exports : window); 135 | -------------------------------------------------------------------------------- /third_party/static/sprintf.js/src/sprintf.min.js: -------------------------------------------------------------------------------- 1 | /*! sprintf.js | Copyright (c) 2007-2013 Alexandru Marasteanu | 3 clause BSD license */(function(e){function r(e){return Object.prototype.toString.call(e).slice(8,-1).toLowerCase()}function i(e,t){for(var n=[];t>0;n[--t]=e);return n.join("")}var t=function(){return t.cache.hasOwnProperty(arguments[0])||(t.cache[arguments[0]]=t.parse(arguments[0])),t.format.call(null,t.cache[arguments[0]],arguments)};t.format=function(e,n){var s=1,o=e.length,u="",a,f=[],l,c,h,p,d,v;for(l=0;l>>=0;break;case"x":a=a.toString(16);break;case"X":a=a.toString(16).toUpperCase()}a=/[def]/.test(h[8])&&h[3]&&a>=0?"+"+a:a,d=h[4]?h[4]=="0"?"0":h[4].charAt(1):" ",v=h[6]-String(a).length,p=h[6]?i(d,v):"",f.push(h[5]?a+p:p+a)}}return f.join("")},t.cache={},t.parse=function(e){var t=e,n=[],r=[],i=0;while(t){if((n=/^[^\x25]+/.exec(t))!==null)r.push(n[0]);else if((n=/^\x25{2}/.exec(t))!==null)r.push("%");else{if((n=/^\x25(?:([1-9]\d*)\$|\(([^\)]+)\))?(\+)?(0|'[^$])?(-)?(\d+)?(?:\.(\d+))?([b-fosuxX])/.exec(t))===null)throw"[sprintf] huh?";if(n[2]){i|=1;var s=[],o=n[2],u=[];if((u=/^([a-z_][a-z_\d]*)/i.exec(o))===null)throw"[sprintf] huh?";s.push(u[1]);while((o=o.substring(u[0].length))!=="")if((u=/^\.([a-z_][a-z_\d]*)/i.exec(o))!==null)s.push(u[1]);else{if((u=/^\[(\d+)\]/.exec(o))===null)throw"[sprintf] huh?";s.push(u[1])}n[2]=s}else i|=2;if(i===3)throw"[sprintf] mixing positional and named placeholders is not (yet) supported";r.push(n)}t=t.substring(n[0].length)}return r};var n=function(e,n,r){return r=n.slice(0),r.splice(0,0,e),t.apply(null,r)};e.sprintf=t,e.vsprintf=n})(typeof exports!="undefined"?exports:window); -------------------------------------------------------------------------------- /third_party/static/sprintf.js/test/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | sprintf.js test 6 | 7 | 8 | 9 | 120 | 121 | 122 | --------------------------------------------------------------------------------