hacklet 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,4 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.3
4
+ - 2.0.0
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+
2
+ 0.5.0 / 2013-05-30
3
+ ==================
4
+
5
+ * Can commission new modlets, read data from them and control them
6
+ through a simple utility program. This is the minimal required in
7
+ order to be useful.
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in hacklet.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Matt Colyer
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # Hacklet [![Build Status](https://travis-ci.org/mcolyer/hacklet.png)](https://travis-ci.org/mcolyer/hacklet)
2
+
3
+ A library, written in Ruby, for controlling the [Modlet] (smart) outlet.
4
+
5
+ If you haven't heard of the Modlet before, it's a smart outlet cover
6
+ which allows you to convert any outlet into your house into a smart
7
+ outlet. This means that you can control whether a plug is on or off and
8
+ you can also determine how much energy it's using with a sampling
9
+ frequency of 10 seconds.
10
+
11
+ There are alot of other similar products but this is the first one that
12
+ I've see that [costs $50][amazon] and includes control as well as monitoring of
13
+ the independent sockets.
14
+
15
+ ## Why
16
+
17
+ So why write another client?
18
+
19
+ Unfortunately the client software included with the device isn't
20
+ available on all platforms (Linux) and it's pretty heavyweight for what
21
+ it does.
22
+
23
+ The goal of this project is provide all the same functionality of the
24
+ bundled client but do it in a lightweight manner, to provide total
25
+ control and availability of the data.
26
+
27
+ ## Getting Started
28
+
29
+ Right now things are pretty rough and won't work without modifying
30
+ `bin/hacklet`. Eventually the `hacklet` script will allow for specifying
31
+ which Modlet and socket you'd like to read or control.
32
+
33
+ ```
34
+ bundle install
35
+ sudo modprobe ftdi_sio vendor=0x0403 product=0x8c81
36
+ bin/hacklet
37
+ ```
38
+
39
+ ## Status
40
+
41
+ * [X] Reading Data
42
+ * [X] Controlling Sockets
43
+ * [X] Multiple Modlet Support
44
+ * [X] Commissioning New Devices
45
+ * [X] Useful utility
46
+ * [ ] Set time on New Devices
47
+ * [ ] Documentation
48
+
49
+ [Modlet]: http://themodlet.com
50
+ [amazon]: http://www.amazon.com/ThinkEco-TE1010-Modlet-Starter-White/dp/B00AAT43OA/
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ load "Rakefile.base"
2
+
3
+ desc "Run the tests"
4
+ task :test => :spec
5
+
6
+ # desc "Run the server"
7
+ # task :run do
8
+ # end
9
+
10
+ require 'rspec/core/rake_task'
11
+ RSpec::Core::RakeTask.new
data/Rakefile.base ADDED
@@ -0,0 +1,57 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ module Bundler
5
+ class GemHelper
6
+ # Override Bundler's concept of release.
7
+ def release_gem
8
+ with_fixed_editor {
9
+ guard_on_master_branch
10
+ return if already_tagged?
11
+ build_gem
12
+ edit_changelog
13
+ sh "git commit --allow-empty -a -m 'Release #{version_tag}'"
14
+ tag_version {
15
+ # Bundler's git_push pushes all branches. Let's restrict it
16
+ # to only the master branch since we also ensure that you
17
+ # always release from the master branch.
18
+ perform_git_push "origin master --tags"
19
+ }
20
+ }
21
+ end
22
+ def with_fixed_editor
23
+ editor = ENV['EDITOR'] || ""
24
+ abort "You must set an EDITOR to edit the changelog" if editor.empty?
25
+ swaps = {
26
+ "mate" => "mate -w",
27
+ "subl" => "subl -w"
28
+ }
29
+ begin
30
+ ENV['EDITOR'] = swaps.fetch(editor, editor)
31
+ yield
32
+ ensure
33
+ ENV['EDITOR'] = editor
34
+ end
35
+ end
36
+ def guard_on_master_branch
37
+ unless `git branch` =~ /^\* master$/
38
+ abort "You must be on the master branch to release."
39
+ end
40
+ end
41
+ def edit_changelog
42
+ unless `which git-changelog`.empty?
43
+ #sh "git-changelog"
44
+ else
45
+ abort "git-changelog isn't found. Install it with `brew install git-extras`"
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ desc "Run all tests"
52
+ task :default => :test
53
+
54
+ desc "Update Rakefile.base"
55
+ task :selfupdate do
56
+ sh "curl -sO https://raw.github.com/rcarver/gembase/master/Rakefile.base"
57
+ end
data/bin/hacklet ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ lib = File.expand_path(File.dirname(__FILE__) + '/../lib')
4
+ $LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
5
+
6
+ require 'hacklet'
7
+ require 'logger'
8
+
9
+ logger = Logger.new(STDOUT)
10
+ logger.level = Logger::INFO
11
+ dongle = Hacklet::Dongle.new(logger)
12
+
13
+ Hacklet::Command.run(dongle, ARGV)
data/hacklet.gemspec ADDED
@@ -0,0 +1,26 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'hacklet/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = "hacklet"
8
+ gem.version = Hacklet::VERSION
9
+ gem.authors = ["Matt Colyer"]
10
+ gem.email = ["matt@colyer.name"]
11
+ gem.description = %q{An Open Source client for the Modlet (smart) outlet}
12
+ gem.summary = %q{A daemon, written in ruby, for controlling the Modlet outlet.}
13
+ gem.homepage = "http://github.com/mcolyer/hacklet"
14
+ gem.license = "MIT"
15
+
16
+ gem.files = `git ls-files`.split($/)
17
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
18
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
19
+ gem.require_paths = ["lib"]
20
+
21
+ gem.add_dependency "serialport", "~>1.1.0"
22
+ gem.add_dependency "bindata", "~>1.5.0"
23
+ gem.add_dependency "slop", "~>3.4.0"
24
+ gem.add_development_dependency "rake"
25
+ gem.add_development_dependency "rspec"
26
+ end
data/lib/hacklet.rb ADDED
@@ -0,0 +1,4 @@
1
+ require "hacklet/version"
2
+ require "hacklet/messages"
3
+ require "hacklet/dongle"
4
+ require "hacklet/command"
@@ -0,0 +1,83 @@
1
+ require 'slop'
2
+ require 'logger'
3
+
4
+ module Hacklet
5
+ class Command
6
+ def self.run(dongle, arguments)
7
+ Slop.parse(arguments, :help => true) do
8
+ command 'on', :banner => 'Turn on the specifed socket' do
9
+ on :n, :network=, 'The network id (ex. 0x1234)', :required => true
10
+ on :s, :socket=, 'The socket id (ex. 0)', :required => true
11
+ on :d, :debug, 'Enables debug logging' do
12
+ dongle.logger.level = Logger::DEBUG
13
+ end
14
+
15
+ run do |opts, args|
16
+ network_id = opts[:network][2..-1].to_i(16)
17
+ socket_id = opts[:socket].to_i
18
+
19
+ dongle.open_session do |session|
20
+ session.lock_network
21
+ session.select_network(network_id)
22
+ session.switch(network_id, socket_id, true)
23
+ end
24
+ end
25
+ end
26
+
27
+ command 'off', :banner => 'Turn off the specifed socket' do
28
+ on :n, :network=, 'The network id (ex. 0x1234)', :required => true
29
+ on :s, :socket=, 'The socket id (ex. 0)', :required => true
30
+ on :d, :debug, 'Enables debug logging' do
31
+ dongle.logger.level = Logger::DEBUG
32
+ end
33
+
34
+ run do |opts, args|
35
+ network_id = opts[:network][2..-1].to_i(16)
36
+ socket_id = opts[:socket].to_i
37
+
38
+ dongle.open_session do |session|
39
+ session.lock_network
40
+ session.select_network(network_id)
41
+ session.switch(network_id, socket_id, false)
42
+ end
43
+ end
44
+ end
45
+
46
+ command 'read', :banner => 'Read all available samples from the specified socket' do
47
+ on :n, :network=, 'The network id (ex. 0x1234)', :required => true
48
+ on :s, :socket=, 'The socket id (ex. 0)', :required => true
49
+ on :d, :debug, 'Enables debug logging' do
50
+ dongle.logger.level = Logger::DEBUG
51
+ end
52
+
53
+ run do |opts, args|
54
+ network_id = opts[:network][2..-1].to_i(16)
55
+ socket_id = opts[:socket].to_i
56
+
57
+ dongle.open_session do |session|
58
+ session.lock_network
59
+ session.select_network(network_id)
60
+ session.request_samples(network_id, socket_id)
61
+ end
62
+ end
63
+ end
64
+
65
+ command 'commission', :banner => 'Add a new device to the network' do
66
+ on :d, :debug, 'Enables debug logging' do
67
+ dongle.logger.level = Logger::DEBUG
68
+ end
69
+
70
+ run do |opts, args|
71
+ dongle.open_session do |session|
72
+ session.commission
73
+ end
74
+ end
75
+ end
76
+
77
+ run do
78
+ puts help
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,217 @@
1
+ require 'serialport'
2
+ require 'logger'
3
+ require 'timeout'
4
+
5
+ module Hacklet
6
+ class Dongle
7
+ attr_reader :logger
8
+
9
+ # logger - Optionally takes a Logger instance, the default is to log to
10
+ # STDOUT
11
+ def initialize(logger=Logger.new(STDOUT))
12
+ @logger = logger
13
+ end
14
+
15
+ # Public: Initializes a session so the client can request data.
16
+ #
17
+ # port - Optional string for configuring the serial port device.
18
+ #
19
+ # Returns nothing.
20
+ def open_session(port='/dev/ttyUSB0')
21
+ @serial = open_serial_port(port)
22
+ begin
23
+ @logger.info("Booting")
24
+ boot
25
+ boot_confirm
26
+ @logger.info("Booting complete")
27
+ yield self
28
+ ensure
29
+ @serial.close
30
+ end
31
+ end
32
+
33
+ # Public: Listens for new devices on the network.
34
+ #
35
+ # This must be executed within an open session.
36
+ #
37
+ # Returns nothing.
38
+ def commission
39
+ require_session
40
+
41
+ begin
42
+ unlock_network
43
+ Timeout.timeout(30) do
44
+ loop do
45
+ @logger.info("Listening for devices ...")
46
+ buffer = receive(4)
47
+ buffer += receive(buffer.bytes.to_a[3]+1)
48
+ if buffer.bytes.to_a[1] == 0xa0
49
+ response = BroadcastResponse.read(buffer)
50
+ @logger.info("Found device 0x%x on network 0x%x" % [response.device_id, response.network_id])
51
+ end
52
+ end
53
+ end
54
+ rescue Timeout::Error
55
+ ensure
56
+ lock_network
57
+ end
58
+ end
59
+
60
+ # Public: Selects the network.
61
+ #
62
+ # This must be executed within an open session. I'm guessing it selects the
63
+ # network.
64
+ #
65
+ # network_id - 2 byte identified for the network.
66
+ #
67
+ # Returns nothing.
68
+ def select_network(network_id)
69
+ require_session
70
+
71
+ transmit(HandshakeRequest.new(:network_id => network_id))
72
+ HandshakeResponse.read(receive(6))
73
+ end
74
+
75
+ # Public: Request stored samples.
76
+ #
77
+ # network_id - 2 byte identified for the network.
78
+ # channel_id - 2 byte identified for the channel.
79
+ #
80
+ # TODO: This needs to return a more usable set of data.
81
+ # Returns the SamplesResponse.
82
+ def request_samples(network_id, channel_id)
83
+ require_session
84
+
85
+ @logger.info("Requesting samples")
86
+ transmit(SamplesRequest.new(:network_id => network_id, :channel_id => channel_id))
87
+ AckResponse.read(receive(6))
88
+ buffer = receive(4)
89
+ remaining_bytes = buffer.bytes.to_a[3] + 1
90
+ buffer += receive(remaining_bytes)
91
+ response = SamplesResponse.read(buffer)
92
+
93
+ response.converted_samples.each do |time, wattage|
94
+ @logger.info("#{wattage}w at #{time}")
95
+ end
96
+ @logger.info("#{response.sample_count} returned, #{response.stored_sample_count} remaining")
97
+
98
+ response
99
+ end
100
+
101
+ # Public: Used to controls whether a socket is on or off.
102
+ #
103
+ # network_id - 2 byte identified for the network.
104
+ # channel_id - 1 byte identified for the channel.
105
+ # enabled - true enables the socket and false disables it.
106
+ #
107
+ # Returns the SwitchResponse.
108
+ def switch(network_id, channel_id, state)
109
+ require_session
110
+
111
+ request = ScheduleRequest.new(:network_id => network_id, :channel_id => channel_id)
112
+ if state
113
+ request.always_on!
114
+ @logger.info("Turning on channel #{channel_id} on network 0x#{network_id.to_s(16)}")
115
+ else
116
+ request.always_off!
117
+ @logger.info("Turning off channel #{channel_id} on network 0x#{network_id.to_s(16)}")
118
+ end
119
+ transmit(request)
120
+ ScheduleResponse.read(receive(6))
121
+ end
122
+
123
+ # Public: Unlocks the network, to add a new device.
124
+ #
125
+ # Returns the BootConfirmResponse
126
+ def unlock_network
127
+ @logger.info("Unlocking network")
128
+ transmit(UnlockRequest.new)
129
+ LockResponse.read(receive(6))
130
+ @logger.info("Unlocking complete")
131
+ end
132
+
133
+ # Public: Locks the network, prevents adding new devices.
134
+ #
135
+ # Returns the BootConfirmResponse
136
+ def lock_network
137
+ @logger.info("Locking network")
138
+ transmit(LockRequest.new)
139
+ LockResponse.read(receive(6))
140
+ @logger.info("Locking complete")
141
+ end
142
+
143
+ private
144
+ # Private: Initializes the dongle for communication
145
+ #
146
+ # Returns the BootResponse
147
+ def boot
148
+ transmit(BootRequest.new)
149
+ BootResponse.read(receive(27))
150
+ end
151
+
152
+ # Private: Confirms that booting was successful?
153
+ #
154
+ # Not sure about this.
155
+ #
156
+ # Returns the BootConfirmResponse
157
+ def boot_confirm
158
+ transmit(BootConfirmRequest.new)
159
+ BootConfirmResponse.read(receive(6))
160
+ end
161
+
162
+ # Private: Initializes the serial port
163
+ #
164
+ # port - the String to the device to open as a serial port.
165
+ #
166
+ # Returns a SerialPort object.
167
+ def open_serial_port(port)
168
+ serial_port = SerialPort.new(port, 115200, 8, 1, SerialPort::NONE)
169
+ serial_port.flow_control = SerialPort::NONE
170
+ serial_port
171
+ end
172
+
173
+ # Private: Transmits the packet to the dongle.
174
+ #
175
+ # command - The binary string to send.
176
+ #
177
+ # Returns the number of bytes written.
178
+ def transmit(command)
179
+ @logger.debug("TX: #{unpack(command.to_binary_s).inspect}")
180
+ @serial.write(command.to_binary_s) if @serial
181
+ end
182
+
183
+ # Private: Waits and receives the specified number of packets from the
184
+ # dongle.
185
+ #
186
+ # bytes - The number of bytes to read.
187
+ #
188
+ # Returns a binary string containing the response.
189
+ def receive(bytes)
190
+ if @serial
191
+ response = @serial.read(bytes)
192
+ else
193
+ response = "\x0\x0\x0\x0"
194
+ end
195
+ @logger.debug("RX: #{unpack(response).inspect}")
196
+
197
+ response
198
+ end
199
+
200
+ # Private: Prints a binary string a concise hexidecimal form for debugging
201
+ #
202
+ # message - The message to parse.
203
+ #
204
+ # Returns a string of hexidecimal representing equivalent to the message.
205
+ def unpack(message)
206
+ message.unpack('H2'*message.size)
207
+ end
208
+
209
+ # Private: A helper to ensure that the serial port is active.
210
+ #
211
+ # Returns nothing.
212
+ # Raises RuntimeError if the serial port is not active.
213
+ def require_session
214
+ raise RuntimeError.new("Must be executed within an open session") unless @serial && !@serial.closed?
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,238 @@
1
+ require 'bindata'
2
+
3
+ module Hacklet
4
+ class Message < BinData::Record
5
+ def calculate_checksum
6
+ checksum_fields = @field_names - [:header, :checksum]
7
+ buffer = StringIO.new
8
+ io = BinData::IO.new(buffer)
9
+ checksum_fields.each do |field|
10
+ send(field).do_write(io)
11
+ end
12
+ buffer.rewind
13
+
14
+ buffer.read.bytes.inject(0) { |s,x| s^x }
15
+ end
16
+ end
17
+
18
+ class BootResponse < Message
19
+ endian :big
20
+
21
+ uint8 :header, :check_value => lambda { value == 0x02 }
22
+ uint16 :command, :check_value => lambda { value == 0x4084 }
23
+ uint8 :payload_length, :check_value => lambda { value == 22 }
24
+
25
+ # TODO: Determine what's in here
26
+ string :data, :length => 12
27
+
28
+ uint64 :device_id
29
+
30
+ # TODO: Determine what's in here
31
+ uint16 :data2
32
+
33
+ uint8 :checksum, :check_value => lambda { calculate_checksum == checksum }
34
+ end
35
+
36
+ class BootConfirmResponse < Message
37
+ endian :big
38
+
39
+ uint8 :header, :check_value => lambda { value == 0x02 }
40
+ uint16 :command, :check_value => lambda { value == 0x4080 }
41
+ uint8 :payload_length, :check_value => lambda { value == 1 }
42
+
43
+ uint8 :data, :check_value => lambda { value == 0x10 }
44
+
45
+ uint8 :checksum, :check_value => lambda { calculate_checksum == checksum }
46
+ end
47
+
48
+ class BroadcastResponse < Message
49
+ endian :big
50
+
51
+ uint8 :header, :check_value => lambda { value == 0x02 }
52
+ uint16 :command, :check_value => lambda { value == 0xA013 }
53
+ uint8 :payload_length, :check_value => lambda { value == 11 }
54
+
55
+ uint16 :network_id
56
+ uint64 :device_id
57
+
58
+ # TODO: Not sure why this is here.
59
+ uint8 :data
60
+
61
+ uint8 :checksum, :check_value => lambda { calculate_checksum == checksum }
62
+ end
63
+
64
+ class LockResponse < Message
65
+ endian :big
66
+
67
+ uint8 :header, :check_value => lambda { value == 0x02 }
68
+ uint16 :command, :check_value => lambda { value == 0xA0F9 }
69
+ uint8 :payload_length, :check_value => lambda { value == 1 }
70
+
71
+ uint8be :data, :check_value => lambda { value == 0x00 }
72
+
73
+ uint8 :checksum, :check_value => lambda { calculate_checksum == checksum }
74
+ end
75
+
76
+ class HandshakeResponse < Message
77
+ endian :big
78
+
79
+ uint8 :header, :check_value => lambda { value == 0x02 }
80
+ uint16 :command, :check_value => lambda { value == 0x4003 }
81
+ uint8 :payload_length, :check_value => lambda { value == 1 }
82
+
83
+ uint8be :data, :check_value => lambda { value == 0x00 }
84
+
85
+ uint8 :checksum, :check_value => lambda { calculate_checksum == checksum }
86
+ end
87
+
88
+ class AckResponse < Message
89
+ endian :big
90
+
91
+ uint8 :header, :check_value => lambda { value == 0x02 }
92
+ uint16 :command, :check_value => lambda { value == 0x4024 }
93
+ uint8 :payload_length, :check_value => lambda { value == 1 }
94
+
95
+ uint8be :data, :check_value => lambda { value == 0x00 }
96
+
97
+ uint8 :checksum, :check_value => lambda { calculate_checksum == checksum }
98
+ end
99
+
100
+ class SamplesResponse < Message
101
+ endian :big
102
+
103
+ uint8 :header, :check_value => lambda { value == 0x02 }
104
+ uint16 :command, :check_value => lambda { value == 0x40A4 }
105
+ uint8 :payload_length
106
+
107
+ uint16 :network_id
108
+ uint16 :channel_id
109
+
110
+ # TODO: Confirm this is actually the device id
111
+ uint16 :data
112
+
113
+ uint32le :time
114
+ uint8 :sample_count
115
+ uint24le :stored_sample_count
116
+ array :samples, :type => [:uint16le], :initial_length => :sample_count
117
+
118
+ uint8 :checksum, :check_value => lambda { calculate_checksum == checksum }
119
+
120
+ def converted_samples
121
+ t = time - 10
122
+ samples.map { |s| t += 10; [Time.at(t), (s/13.0).round] }
123
+ end
124
+ end
125
+
126
+ class ScheduleResponse < Message
127
+ endian :big
128
+
129
+ uint8 :header, :check_value => lambda { value == 0x02 }
130
+ uint16 :command, :check_value => lambda { value == 0x4023 }
131
+ uint8 :payload_length, :check_value => lambda { value == 1 }
132
+
133
+ uint8be :data, :check_value => lambda { value == 0x00 }
134
+
135
+ uint8 :checksum, :check_value => lambda { calculate_checksum == checksum }
136
+ end
137
+
138
+ class BootRequest < Message
139
+ endian :big
140
+
141
+ uint8 :header, :initial_value => 0x02
142
+ uint16 :command, :initial_value => 0x4004
143
+ uint8 :payload_length
144
+
145
+ uint8 :checksum, :value => :calculate_checksum
146
+ end
147
+
148
+ class BootConfirmRequest < Message
149
+ endian :big
150
+
151
+ uint8 :header, :initial_value => 0x02
152
+ uint16 :command, :initial_value => 0x4000
153
+ uint8 :payload_length
154
+
155
+ uint8 :checksum, :value => :calculate_checksum
156
+ end
157
+
158
+ class UnlockRequest < Message
159
+ endian :big
160
+
161
+ uint8 :header, :initial_value => 0x02
162
+ uint16 :command, :initial_value => 0xA236
163
+ uint8 :payload_length, :initial_value => 4
164
+
165
+ # TODO: What is this?
166
+ uint32 :data, :initial_value => 0xFCFF9001
167
+
168
+ uint8 :checksum, :value => :calculate_checksum
169
+ end
170
+
171
+ class LockRequest < Message
172
+ endian :big
173
+
174
+ uint8 :header, :initial_value => 0x02
175
+ uint16 :command, :initial_value => 0xA236
176
+ uint8 :payload_length, :initial_value => 4
177
+
178
+ # TODO: What is this?
179
+ uint32 :data, :initial_value => 0xFCFF0001
180
+
181
+ uint8 :checksum, :value => :calculate_checksum
182
+ end
183
+
184
+ class HandshakeRequest < Message
185
+ endian :big
186
+
187
+ uint8 :header, :initial_value => 0x02
188
+ uint16 :command, :initial_value => 0x4003
189
+ uint8 :payload_length, :initial_value => 4
190
+
191
+ uint16 :network_id
192
+ # TODO: What is this?
193
+ uint16 :data, :initial_value => 0x0500
194
+
195
+ uint8 :checksum, :value => :calculate_checksum
196
+ end
197
+
198
+ class SamplesRequest < Message
199
+ endian :big
200
+
201
+ uint8 :header, :initial_value => 0x02
202
+ uint16 :command, :initial_value => 0x4024
203
+ uint8 :payload_length, :initial_value => 6
204
+
205
+ uint16 :network_id
206
+ uint16 :channel_id
207
+ # TODO: What is this?
208
+ uint16 :data, :initial_value => 0x0A00
209
+
210
+ uint8 :checksum, :value => :calculate_checksum
211
+ end
212
+
213
+ class ScheduleRequest < Message
214
+ endian :big
215
+
216
+ uint8 :header, :initial_value => 0x02
217
+ uint16 :command, :initial_value => 0x4023
218
+ uint8 :payload_length, :initial_value => 59
219
+
220
+ uint16 :network_id
221
+ uint8 :channel_id
222
+ array :schedule, :type => [:uint8], :initial_length => 56
223
+
224
+ uint8 :checksum, :value => :calculate_checksum
225
+
226
+ def always_off!
227
+ bitmap = [0x7f]*56
228
+ bitmap[5] = 0x25
229
+ schedule.assign(bitmap)
230
+ end
231
+
232
+ def always_on!
233
+ bitmap = [0xff]*56
234
+ bitmap[5] = 0xa5
235
+ schedule.assign(bitmap)
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,3 @@
1
+ module Hacklet
2
+ VERSION = "0.5.0"
3
+ end
@@ -0,0 +1,65 @@
1
+ require 'spec_helper'
2
+
3
+ describe Hacklet::Command do
4
+ subject do
5
+ Hacklet::Command
6
+ end
7
+
8
+ let(:dongle) { Hacklet::Dongle.new(Logger.new("/dev/null")) }
9
+
10
+ it 'can turn on a socket' do
11
+ serial_port = mock('serial_port')
12
+ serial_port.should_receive(:close)
13
+
14
+ dongle.should_receive(:open_serial_port).and_return(serial_port)
15
+ dongle.should_receive(:boot)
16
+ dongle.should_receive(:boot_confirm)
17
+ dongle.should_receive(:lock_network)
18
+ dongle.should_receive(:select_network).with(16)
19
+ dongle.should_receive(:switch).with(16, 1, true)
20
+
21
+ subject.run(dongle, ['on', '-n', '0x0010', '-s', '1'])
22
+ end
23
+
24
+ it 'can turn off a socket' do
25
+ serial_port = mock('serial_port')
26
+ serial_port.should_receive(:close)
27
+
28
+ dongle.should_receive(:open_serial_port).and_return(serial_port)
29
+ dongle.should_receive(:boot)
30
+ dongle.should_receive(:boot_confirm)
31
+ dongle.should_receive(:lock_network)
32
+ dongle.should_receive(:select_network).with(16)
33
+ dongle.should_receive(:switch).with(16, 0, false)
34
+
35
+ subject.run(dongle, ['off', '-n', '0x0010', '-s', '0'])
36
+ end
37
+
38
+ it 'can read a socket' do
39
+ serial_port = mock('serial_port')
40
+ serial_port.stub(:closed?).and_return(false)
41
+ serial_port.should_receive(:close)
42
+
43
+ dongle.should_receive(:open_serial_port).and_return(serial_port)
44
+ dongle.should_receive(:boot)
45
+ dongle.should_receive(:boot_confirm)
46
+ dongle.should_receive(:lock_network)
47
+ dongle.should_receive(:select_network).with(16)
48
+ dongle.should_receive(:request_samples).with(16, 1)
49
+
50
+ subject.run(dongle, ['read', '-n', '0x0010', '-s', '1'])
51
+ end
52
+
53
+ it 'can commission a device' do
54
+ serial_port = mock('serial_port')
55
+ serial_port.stub(:closed?).and_return(false)
56
+ serial_port.should_receive(:close)
57
+
58
+ dongle.should_receive(:open_serial_port).and_return(serial_port)
59
+ dongle.should_receive(:boot)
60
+ dongle.should_receive(:boot_confirm)
61
+ dongle.should_receive(:commission)
62
+
63
+ subject.run(dongle, ['commission'])
64
+ end
65
+ end
@@ -0,0 +1,125 @@
1
+ require 'spec_helper'
2
+
3
+ describe Hacklet::Dongle do
4
+ subject do
5
+ Hacklet::Dongle.new(Logger.new("/dev/null"))
6
+ end
7
+
8
+ it 'can open a new session' do
9
+ serial_port = mock("SerialPort")
10
+
11
+ # Boot
12
+ serial_port.should_receive(:write).with([0x02, 0x40, 0x04, 0x00, 0x44].pack('c'*5))
13
+ serial_port.should_receive(:read).and_return([0x02, 0x40, 0x84, 0x16, 0x01,
14
+ 0x00, 0x00, 0x87, 0x03, 0x00, 0x30, 0x00, 0x33, 0x83, 0x69, 0x9A, 0x0B,
15
+ 0x2F, 0x00, 0x00, 0x00, 0x58, 0x4F, 0x80, 0x0A, 0x1C, 0x81].pack('c'*27))
16
+
17
+ # Boot Confirmation
18
+ serial_port.should_receive(:write).with([0x02, 0x40, 0x00, 0x00, 0x40].pack('c'*5))
19
+ serial_port.should_receive(:read).and_return([0x02, 0x40, 0x80, 0x01, 0x10, 0xd1].pack('c'*6))
20
+
21
+ serial_port.should_receive(:close)
22
+ subject.should_receive(:open_serial_port).and_return(serial_port)
23
+ subject.open_session do
24
+ # noop
25
+ end
26
+ end
27
+
28
+ it 'can find a new device' do
29
+ serial_port = mock("SerialPort")
30
+
31
+ # Unlock Network
32
+ serial_port.should_receive(:write).with([0x02, 0xA2, 0x36, 0x04, 0xFC, 0xFF, 0x90, 0x01, 0x02].pack('c'*9))
33
+ serial_port.should_receive(:read).and_return([0x02, 0xA0, 0xF9, 0x01, 0x00, 0x58].pack('c'*6))
34
+
35
+ # Ignored packet #1
36
+ serial_port.should_receive(:read).and_return([0x02, 0x99, 0xd1, 0x23].pack('c'*4))
37
+ serial_port.should_receive(:read).and_return([0x01, 0xcc, 0x1f, 0x10, 0x00,
38
+ 0x00, 0x58, 0x4f, 0x80, 0x00, 0x77, 0x2a, 0x8a, 0x33, 0xa7, 0xf4, 0xf6,
39
+ 0x80, 0xd0, 0x9c, 0x5d, 0x3c, 0x84, 0xf5, 0x2b, 0x43, 0x00, 0x00, 0x00,
40
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xcb].pack('c'*36))
41
+
42
+ # Ignored packet #2
43
+ serial_port.should_receive(:read).and_return([0x02, 0x98, 0xc0, 0x09].pack('c'*4))
44
+ serial_port.should_receive(:read).and_return([0xcc, 0x1f, 0x10, 0x00, 0x00,
45
+ 0x58, 0x4f, 0x80, 0x00, 0x05].pack('c'*10))
46
+
47
+ # Useful response
48
+ serial_port.should_receive(:read).and_return([0x02, 0xa0, 0x13, 0x0b].pack('c'*4))
49
+ serial_port.should_receive(:read).and_return([0x66, 0xad, 0xcc, 0x1f, 0x10, 0x00,
50
+ 0x00, 0x58, 0x4f, 0x80, 0x8e, 0xa9].pack('c'*12))
51
+ serial_port.should_receive(:read).and_raise(Timeout::Error)
52
+
53
+ # Lock Network
54
+ serial_port.should_receive(:write).with([0x02, 0xA2, 0x36, 0x04, 0xFC, 0xFF, 0x00, 0x01, 0x92].pack('c'*9))
55
+ serial_port.should_receive(:read).and_return([0x02, 0xA0, 0xF9, 0x01, 0x00, 0x58].pack('c'*6))
56
+
57
+ serial_port.should_receive(:close)
58
+ serial_port.stub!(:closed?).and_return(false)
59
+ subject.should_receive(:open_serial_port).and_return(serial_port)
60
+ subject.should_receive(:boot)
61
+ subject.should_receive(:boot_confirm)
62
+
63
+ subject.open_session do |session|
64
+ session.commission
65
+ end
66
+ end
67
+
68
+ it 'can request a sample' do
69
+ serial_port = mock("SerialPort")
70
+
71
+ # Selecting the network
72
+ serial_port.should_receive(:write).with([0x02, 0x40, 0x03, 0x04, 0xA7, 0xB4, 0x05, 0x00, 0x51].pack('c'*9))
73
+ serial_port.should_receive(:read).and_return([0x02, 0x40, 0x03, 0x01, 0x00, 0x42].pack('c'*6))
74
+
75
+ # Requesting the sample
76
+ serial_port.should_receive(:write).with([0x02, 0x40, 0x24, 0x06, 0xA7, 0xB4,
77
+ 0x00, 0x01, 0x0A, 0x00, 0x7a].pack('c'*11))
78
+ serial_port.should_receive(:read).and_return([0x02, 0x40, 0x24, 0x01, 0x00, 0x65].pack('c'*6))
79
+ serial_port.should_receive(:read).and_return([0x02, 0x40, 0xA4, 0x12].pack('c'*4))
80
+ serial_port.should_receive(:read).and_return([0xA7, 0xB4, 0x00, 0x01, 0x0A,
81
+ 0x00, 0x69, 0x8D, 0x44, 0x51, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
82
+ 0x00, 0x1d].pack('c'*19))
83
+
84
+ serial_port.should_receive(:close)
85
+ serial_port.stub!(:closed?).and_return(false)
86
+ subject.should_receive(:open_serial_port).and_return(serial_port)
87
+ subject.should_receive(:boot)
88
+ subject.should_receive(:boot_confirm)
89
+ subject.should_receive(:lock_network)
90
+
91
+ subject.open_session do |session|
92
+ session.lock_network
93
+ session.select_network(0xA7B4)
94
+ session.request_samples(0xA7B4, 0x0001)
95
+ end
96
+ end
97
+
98
+ it 'can enable a socket' do
99
+ serial_port = mock("SerialPort")
100
+
101
+ # Selecting the network
102
+ serial_port.should_receive(:write).with([0x02, 0x40, 0x03, 0x04, 0xA7, 0xB4, 0x05, 0x00, 0x51].pack('c'*9))
103
+ serial_port.should_receive(:read).and_return([0x02, 0x40, 0x03, 0x01, 0x00, 0x42].pack('c'*6))
104
+
105
+ # Switching
106
+ request = [0xff]*56
107
+ request[5] = 0xA5
108
+ request = [0x02, 0x40, 0x23, 0x3B, 0xA7, 0xB4, 0x00] + request + [0x11]
109
+ serial_port.should_receive(:write).with(request.pack('c'*request.length))
110
+ serial_port.should_receive(:read).and_return([0x02, 0x40, 0x23, 0x01, 0x00, 0x62].pack('c'*6))
111
+
112
+ serial_port.should_receive(:close)
113
+ serial_port.stub!(:closed?).and_return(false)
114
+ subject.should_receive(:open_serial_port).and_return(serial_port)
115
+ subject.should_receive(:boot)
116
+ subject.should_receive(:boot_confirm)
117
+ subject.should_receive(:lock_network)
118
+
119
+ subject.open_session do |session|
120
+ session.lock_network
121
+ session.select_network(0xA7B4)
122
+ session.switch(0xA7B4, 0x0000, true)
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,21 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'Messages' do
4
+ describe 'responses' do
5
+ let(:bad_checksum) { "\x02\x40\x80\x01\x10\x01" }
6
+
7
+ describe Hacklet::BootConfirmResponse do
8
+ it 'detects an invalid checksum' do
9
+ expect { Hacklet::BootConfirmResponse.read(bad_checksum) }.to raise_error(BinData::ValidityError)
10
+ end
11
+ end
12
+ end
13
+
14
+ describe 'requests' do
15
+ describe Hacklet::BootRequest do
16
+ it 'has a proper checksum' do
17
+ subject.checksum.should eq(0x44)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ describe Hacklet do
2
+ it 'should have a version number' do
3
+ Hacklet::VERSION.should_not be_nil
4
+ end
5
+ end
@@ -0,0 +1,2 @@
1
+ $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
2
+ require 'hacklet'
metadata ADDED
@@ -0,0 +1,153 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hacklet
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Matt Colyer
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-05-30 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: serialport
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 1.1.0
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ version: 1.1.0
30
+ - !ruby/object:Gem::Dependency
31
+ name: bindata
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ~>
36
+ - !ruby/object:Gem::Version
37
+ version: 1.5.0
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ~>
44
+ - !ruby/object:Gem::Version
45
+ version: 1.5.0
46
+ - !ruby/object:Gem::Dependency
47
+ name: slop
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ~>
52
+ - !ruby/object:Gem::Version
53
+ version: 3.4.0
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ~>
60
+ - !ruby/object:Gem::Version
61
+ version: 3.4.0
62
+ - !ruby/object:Gem::Dependency
63
+ name: rake
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ type: :development
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ - !ruby/object:Gem::Dependency
79
+ name: rspec
80
+ requirement: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ! '>='
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ type: :development
87
+ prerelease: false
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ description: An Open Source client for the Modlet (smart) outlet
95
+ email:
96
+ - matt@colyer.name
97
+ executables:
98
+ - hacklet
99
+ extensions: []
100
+ extra_rdoc_files: []
101
+ files:
102
+ - .gitignore
103
+ - .rspec
104
+ - .travis.yml
105
+ - CHANGELOG.md
106
+ - Gemfile
107
+ - LICENSE.txt
108
+ - README.md
109
+ - Rakefile
110
+ - Rakefile.base
111
+ - bin/hacklet
112
+ - hacklet.gemspec
113
+ - lib/hacklet.rb
114
+ - lib/hacklet/command.rb
115
+ - lib/hacklet/dongle.rb
116
+ - lib/hacklet/messages.rb
117
+ - lib/hacklet/version.rb
118
+ - spec/hacklet/command_spec.rb
119
+ - spec/hacklet/dongle_spec.rb
120
+ - spec/hacklet/messages_spec.rb
121
+ - spec/hacklet_spec.rb
122
+ - spec/spec_helper.rb
123
+ homepage: http://github.com/mcolyer/hacklet
124
+ licenses:
125
+ - MIT
126
+ post_install_message:
127
+ rdoc_options: []
128
+ require_paths:
129
+ - lib
130
+ required_ruby_version: !ruby/object:Gem::Requirement
131
+ none: false
132
+ requirements:
133
+ - - ! '>='
134
+ - !ruby/object:Gem::Version
135
+ version: '0'
136
+ required_rubygems_version: !ruby/object:Gem::Requirement
137
+ none: false
138
+ requirements:
139
+ - - ! '>='
140
+ - !ruby/object:Gem::Version
141
+ version: '0'
142
+ requirements: []
143
+ rubyforge_project:
144
+ rubygems_version: 1.8.23
145
+ signing_key:
146
+ specification_version: 3
147
+ summary: A daemon, written in ruby, for controlling the Modlet outlet.
148
+ test_files:
149
+ - spec/hacklet/command_spec.rb
150
+ - spec/hacklet/dongle_spec.rb
151
+ - spec/hacklet/messages_spec.rb
152
+ - spec/hacklet_spec.rb
153
+ - spec/spec_helper.rb