adam6050 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 69f18923622d9707c07acccd6433cb10863a3d29
4
+ data.tar.gz: 29afcfd92332596de33ce33ebdf56b86b44ded07
5
+ SHA512:
6
+ metadata.gz: 13850fe87f428fe8e5e9b17460a15cb20b7e5979bfce5a581e0cd0311ba87e0d48d10b2d1b04719da16c6186d50d6888d6c1a3440d590c22bc86efdfe8ddda57
7
+ data.tar.gz: 5363aeb733c37736a815ca087da62c63530de69757831d4996877a8e1fab22f9b853aaf13daed31bba18a811fdb0afc28086897bdf79362efbe2002ca3792311
data/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
data/.rubocop.yml ADDED
@@ -0,0 +1,64 @@
1
+ AllCops:
2
+ Exclude:
3
+ - 'vendor/**/*'
4
+ - 'tmp/**/*'
5
+ TargetRubyVersion: 2.4
6
+
7
+ Style/FrozenStringLiteralComment:
8
+ EnforcedStyle: always
9
+
10
+ Layout/EndOfLine:
11
+ EnforcedStyle: lf
12
+
13
+ Layout/ClassStructure:
14
+ Enabled: true
15
+ Categories:
16
+ module_inclusion:
17
+ - include
18
+ - prepend
19
+ - extend
20
+ ExpectedOrder:
21
+ - module_inclusion
22
+ - constants
23
+ - public_class_methods
24
+ - initializer
25
+ - instance_methods
26
+ - protected_methods
27
+ - private_methods
28
+
29
+ Layout/IndentHeredoc:
30
+ EnforcedStyle: squiggly
31
+
32
+ Lint/AmbiguousBlockAssociation:
33
+ Exclude:
34
+ - 'test/**/*.rb'
35
+
36
+ Lint/InterpolationCheck:
37
+ Exclude:
38
+ - 'test/**/*.rb'
39
+
40
+ Metrics/BlockLength:
41
+ Exclude:
42
+ - 'Rakefile'
43
+ - '**/*.rake'
44
+ - 'test/**/*.rb'
45
+
46
+ Metrics/ModuleLength:
47
+ Exclude:
48
+ - 'test/**/*.rb'
49
+
50
+ Metrics/ParameterLists:
51
+ CountKeywordArgs: false
52
+
53
+ Naming/UncommunicativeMethodParamName:
54
+ AllowedNames:
55
+ - x
56
+ - y
57
+ - i
58
+ - p
59
+ - n
60
+ - r
61
+ - g
62
+ - b
63
+ - to
64
+ - '_'
data/.travis.yml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ sudo: false
3
+ language: ruby
4
+ cache: bundler
5
+ rvm:
6
+ - 2.4.1
7
+ before_install: gem install bundler -v 1.16.3
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+ All notable changes to this project will be documented in this file.
3
+
4
+ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
5
+ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
6
+
7
+ # Specify your gem's dependencies in adam6050.gemspec
8
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,52 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ adam6050 (0.1.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ ast (2.4.0)
10
+ docile (1.3.1)
11
+ jaro_winkler (1.5.1)
12
+ json (2.1.0)
13
+ minitest (5.11.3)
14
+ parallel (1.12.1)
15
+ parser (2.5.1.2)
16
+ ast (~> 2.4.0)
17
+ powerpack (0.1.2)
18
+ rainbow (3.0.0)
19
+ rake (10.5.0)
20
+ redcarpet (3.4.0)
21
+ rubocop (0.58.1)
22
+ jaro_winkler (~> 1.5.1)
23
+ parallel (~> 1.10)
24
+ parser (>= 2.5, != 2.5.1.1)
25
+ powerpack (~> 0.1)
26
+ rainbow (>= 2.2.2, < 4.0)
27
+ ruby-progressbar (~> 1.7)
28
+ unicode-display_width (~> 1.0, >= 1.0.1)
29
+ ruby-progressbar (1.9.0)
30
+ simplecov (0.16.1)
31
+ docile (~> 1.1)
32
+ json (>= 1.8, < 3)
33
+ simplecov-html (~> 0.10.0)
34
+ simplecov-html (0.10.2)
35
+ unicode-display_width (1.4.0)
36
+ yard (0.9.14)
37
+
38
+ PLATFORMS
39
+ ruby
40
+
41
+ DEPENDENCIES
42
+ adam6050!
43
+ bundler (~> 1.16)
44
+ minitest (~> 5.0)
45
+ rake (~> 10.0)
46
+ redcarpet (~> 3.4)
47
+ rubocop (~> 0.52)
48
+ simplecov (~> 0.16)
49
+ yard (~> 0.9)
50
+
51
+ BUNDLED WITH
52
+ 1.16.3
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2018 Sebastian Lindberg
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.
data/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # 🎛 ADAM6050
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/adam6050.svg)](https://badge.fury.io/rb/vissen-input)
4
+ [![Build Status](https://travis-ci.org/seblindberg/ruby-adam6050.svg?branch=master)](https://travis-ci.org/seblindberg/ruby-adam6050)
5
+ [![Inline docs](http://inch-ci.org/github/seblindberg/ruby-adam6050.svg?branch=master)](http://inch-ci.org/github/seblindberg/ruby-adam6050)
6
+ [![Documentation](http://img.shields.io/badge/docs-rdoc.info-blue.svg)](http://www.rubydoc.info/gems/adam6050/)
7
+
8
+ This library implements a server that emulates the functionality of the network connected Advantech ADAM-6050 IO module.
9
+
10
+ ## Installation
11
+
12
+ Add this line to your application's Gemfile:
13
+
14
+ ```ruby
15
+ gem 'adam6050'
16
+ ```
17
+
18
+ And then execute:
19
+
20
+ $ bundle
21
+
22
+ Or install it yourself as:
23
+
24
+ $ gem install adam6050
25
+
26
+ ## Usage
27
+
28
+ ```ruby
29
+ require 'adam6050'
30
+
31
+ server = ADAM6050::Server.new
32
+ server.run do |state, prev_state|
33
+ # React to the new state
34
+ end
35
+ ```
36
+
37
+ ## Development
38
+
39
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
40
+
41
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
42
+
43
+ ## Contributing
44
+
45
+ Bug reports and pull requests are welcome on GitHub at https://github.com/seblindberg/ruby-adam6050.
46
+
47
+ ## License
48
+
49
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+ require 'rubocop/rake_task'
6
+ require 'yard'
7
+
8
+ Rake::TestTask.new(:test) do |t|
9
+ t.libs << 'test'
10
+ t.libs << 'lib'
11
+ t.test_files = FileList['test/**/*_test.rb']
12
+ end
13
+
14
+ RuboCop::RakeTask.new(:rubocop)
15
+
16
+ YARD::Rake::YardocTask.new(:yard) do |t|
17
+ t.stats_options = %w[--list-undoc]
18
+ t.files = ['lib/**/*.rb', '-', 'CHANGELOG.md']
19
+ end
20
+
21
+ desc 'Generate Ruby documentation'
22
+ task doc: %w[yard]
23
+
24
+ task default: %w[test rubocop:auto_correct]
data/adam6050.gemspec ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'adam6050/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'adam6050'
9
+ spec.version = ADAM6050::VERSION
10
+ spec.authors = ['Sebastian Lindberg']
11
+ spec.email = ['seb.lindberg@gmail.com']
12
+
13
+ spec.summary = 'Server implementation of the ADAM-6050 IO module.'
14
+ spec.description = 'This library implements a server that emulates the ' \
15
+ 'Advantech ADAM-6050 IO module.'
16
+ spec.homepage = 'https://github.com/seblindberg/ruby-adam6050'
17
+ spec.license = 'MIT'
18
+
19
+ # Specify which files should be added to the gem when it is released.
20
+ # The `git ls-files -z` loads the files in the RubyGem that have been added
21
+ # into git.
22
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
23
+ `git ls-files -z`.split("\x0")
24
+ .reject { |f| f.match(%r{^(test|spec|features)/}) }
25
+ end
26
+ spec.bindir = 'exe'
27
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
28
+ spec.require_paths = ['lib']
29
+
30
+ spec.add_development_dependency 'bundler', '~> 1.16'
31
+ spec.add_development_dependency 'minitest', '~> 5.0'
32
+ spec.add_development_dependency 'rake', '~> 10.0'
33
+ spec.add_development_dependency 'redcarpet', '~> 3.4'
34
+ spec.add_development_dependency 'rubocop', '~> 0.52'
35
+ spec.add_development_dependency 'simplecov', '~> 0.16'
36
+ spec.add_development_dependency 'yard', '~> 0.9'
37
+ end
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'adam6050'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ IRB.start(__FILE__)
data/bin/server ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'adam6050'
6
+
7
+ server = ADAM6050::Server.new
8
+
9
+ begin
10
+ server.run do |state|
11
+ puts ADAM6050::State.inspect(state)
12
+ end
13
+ rescue Interrupt
14
+ puts 'Exiting...'
15
+ end
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ADAM6050
4
+ # This is the base class for all custom errors raised by the library.
5
+ class Error < StandardError
6
+ end
7
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ADAM6050
4
+ module Handler
5
+ # Allows senders to login.
6
+ class Login
7
+ include Handler
8
+
9
+ # @return [String] see Handler::MESSAGE_PREAMBLE.
10
+ MESSAGE_PREAMBLE = '$01PW'
11
+
12
+ # @param password [String] the plain text password to use when validating
13
+ # login requests. If no password is given every request will be granted.
14
+ def initialize(password = nil)
15
+ @password = Password.new password
16
+ freeze
17
+ end
18
+
19
+ # @param msg [String] the incomming message.
20
+ # @param state [Integer] the current state.
21
+ #
22
+ # @return [Array<Integer, String>] the next state and an optional reply.
23
+ def handle(msg, state, session, sender)
24
+ return state, nil unless @password == msg[6..-1]
25
+
26
+ session.register sender
27
+
28
+ [state, '>01']
29
+ end
30
+
31
+ # @return [false] the login handler does not require the sender to be
32
+ # validated.
33
+ def validate?
34
+ false
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ADAM6050
4
+ module Handler
5
+ # Allows registed senders to read the state.
6
+ class Read
7
+ include Handler
8
+
9
+ # @return [String] see Handler::MESSAGE_PREAMBLE.
10
+ MESSAGE_PREAMBLE = '$016'
11
+
12
+ # @param state [Integer] the current state.
13
+ # @return [Array<Integer, String>] the next state and an optional reply.
14
+ def handle(_, state, *)
15
+ # From the manual:
16
+ # The first 2-character portion of the response (exclude the "!"
17
+ # character) indicates the address of the ADAM-6000 module. The second
18
+ # 2-character portion of the response is reserved, and will always be
19
+ # 00 currently.
20
+ [state, '!0100' + State.to_bin(state)]
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ADAM6050
4
+ module Handler
5
+ # Allows registed senders to read the IO status.
6
+ class Status
7
+ include Handler
8
+
9
+ # @return [String] see Handler::MESSAGE_PREAMBLE.
10
+ MESSAGE_PREAMBLE = '$01C'
11
+
12
+ # @param msg [String] the incomming message.
13
+ # @param state [Integer] the current state.
14
+ # @return [Array<Integer, String>] the next state and an optional reply.
15
+ def handle(msg, state, *)
16
+ reply =
17
+ if msg == MESSAGE_PREAMBLE + "\r"
18
+ '!01' + '000000000000' + '000000000000' + '000000000000'
19
+ else
20
+ '>'
21
+ end
22
+
23
+ [state, reply]
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ADAM6050
4
+ module Handler
5
+ # Allows registed senders to change the output bits of the state.
6
+ #
7
+ # From the manual:
8
+ # Name Write Digital Output
9
+ # Description This command sets a single or all digital output channels to
10
+ # the specific ADAM-6000 module.
11
+ # Syntax #01bb(data)\r
12
+ # bb is used to indicate which channel(s) you want to set.
13
+ # Writing to all channels (write a byte): both characters
14
+ # should be equal to zero (BB=00).
15
+ # Writing to a single channel (write a bit): first character
16
+ # is 1, second character indicates channel number which can
17
+ # range from 0h to Fh.
18
+ class Write
19
+ include Handler
20
+
21
+ # @return [String] see Handler::MESSAGE_PREAMBLE.
22
+ MESSAGE_PREAMBLE = '#01'
23
+
24
+ # @param msg [String] the incomming message.
25
+ # @param state [Integer] the current state.
26
+ # @return [Array<Integer, String>] the next state and an optional reply.
27
+ def handle(msg, state, *)
28
+ channel, value = parse msg
29
+ next_state = if msg[3] == '1'
30
+ State.update state, channel, value
31
+ else
32
+ State.update_all state, value
33
+ end
34
+
35
+ [next_state, '>']
36
+ end
37
+
38
+ private
39
+
40
+ def parse(msg)
41
+ [
42
+ msg[4].to_i(16),
43
+ msg[5..6].to_i(16)
44
+ ]
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ADAM6050
4
+ # Handlers are, for the most part, simple transformations that accept a
5
+ # _state_, an incomming _message_, a _session_ and a _sender_ and produce a
6
+ # new state as well as an optional reply. The only
7
+ module Handler
8
+ # @return [String] the first letters of the message that should be used when
9
+ # determining if the handler can handle it.
10
+ MESSAGE_PREAMBLE = '$01'
11
+
12
+ # @param msg [String] the incomming message.
13
+ # @return [true] if the handler can handle the message.
14
+ # @return [false] otherwise.
15
+ def handles?(msg)
16
+ msg.start_with? self.class::MESSAGE_PREAMBLE
17
+ end
18
+
19
+ # @return [true] if the handler requires the sender to be validated.
20
+ def validate?
21
+ true
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ADAM6050
4
+ # == Usage
5
+ # The following example creates a password and uses it to validate an encoded
6
+ # string.
7
+ #
8
+ # password = Password.new 'b6TSkfr6'
9
+ #
10
+ # password == 'b6TSkfr6' # => false
11
+ # password == "]\tklTYM\t" # => true
12
+ #
13
+ # The next example creates a password that will match any string.
14
+ #
15
+ # password = Password.new
16
+ #
17
+ # password == 'anything' # => true
18
+ class Password
19
+ # Format errors should be raised whenever a plain text password longer than
20
+ # 8 characters is passed. Note that only ascii characters are supported.
21
+ class FormatError < Error
22
+ def initialize
23
+ super 'Only ascii passwords of length 8 or less are supported'
24
+ end
25
+ end
26
+
27
+ # @raise [FormatError] if the plain text password is longer than 8
28
+ # characters.
29
+ #
30
+ # @param plain [String] the plain text version of the password.
31
+ def initialize(plain = nil)
32
+ if plain
33
+ password = obfuscate plain
34
+ define_singleton_method(:==) { |text| password == text }
35
+ else
36
+ define_singleton_method(:==) { |_| true }
37
+ end
38
+
39
+ freeze
40
+ end
41
+
42
+ private
43
+
44
+ def obfuscate(plain)
45
+ codepoints = plain.codepoints
46
+
47
+ raise FormatError if codepoints.length > 8
48
+
49
+ password = Array.new(8, 0x0E)
50
+ codepoints.each_with_index do |c, i|
51
+ password[i] = (c & 0x40) | (~c & 0x3F)
52
+ end
53
+ password.pack 'c*'
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ADAM6050
4
+ # The server listens to a speciefied UDP port and delegates incomming messages
5
+ # to the different handlers.
6
+ class Server
7
+ # @return [Integer] the dafault port of the UDP server.
8
+ DEFAULT_PORT = 1025
9
+
10
+ # @return [Logger] the logger used by the server.
11
+ attr_reader :logger
12
+
13
+ # @param password [String] the plain text password to use when validating
14
+ # new clients.
15
+ def initialize(password: nil, logger: Logger.new(STDOUT))
16
+ @session = Session.new
17
+ @handlers = [
18
+ Handler::Login.new(password),
19
+ Handler::Status.new,
20
+ Handler::Read.new,
21
+ Handler::Write.new
22
+ ]
23
+ @state = State.initial
24
+ @state_lock = Mutex.new
25
+ @logger = logger
26
+ end
27
+
28
+ # @param host [String] the host to listen on.
29
+ # @param port [Integer] the UDP port to listen on.
30
+ def run(host: nil, port: DEFAULT_PORT, &block)
31
+ logger.info "Listening on port #{port}"
32
+
33
+ Socket.udp_server_loop host, port do |msg, sender|
34
+ logger.debug { "#{sender.remote_address} -> '#{msg}'" }
35
+ handler = @handlers.find { |h| h.handles? msg } || next
36
+
37
+ @state_lock.synchronize do
38
+ handle(handler, msg, sender, &block)
39
+ end
40
+ end
41
+ end
42
+
43
+ # Updates the state atomicly.
44
+ def update
45
+ @state_lock.synchronize do
46
+ @state = yield @state
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def handle(handler, msg, sender, &block)
53
+ @session.validate! sender if handler.validate?
54
+
55
+ next_state, reply = handler.handle msg, @state, @session, sender
56
+
57
+ return if abort_state_change?(next_state, &block)
58
+
59
+ sender.reply reply + "\r" if reply
60
+ @state = next_state
61
+ rescue Session::InvalidSender => e
62
+ logger.warn e.message
63
+ end
64
+
65
+ def abort_state_change?(next_state)
66
+ return true if next_state == @state
67
+
68
+ commit = !block_given? || yield(next_state, @state)
69
+ commit == false
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ADAM6050
4
+ # The session object is used by the server to keep track of authenticated
5
+ # clients. Once a client is registered with the session it will be reported as
6
+ # valid for a period of time, after which it is again marked as invalid and
7
+ # needs to register again. Any activity before the timeout will reset the
8
+ # countdown.
9
+ #
10
+ # == Usage
11
+ # The following example creates a session with a 10 second timeout and
12
+ # registers a sender. `#validate!` is then called at a later point in time to
13
+ # verify that the sender is still valid.
14
+ #
15
+ # session = Session.new timeout: 10.0
16
+ # session.register sender
17
+ #
18
+ # # The following call will raise an exception if more
19
+ # # than 10 seconds has passed.
20
+ # session.validate! sender
21
+ #
22
+ class Session
23
+ # @return [Numeric] the default number of seconds a sender is valid with no
24
+ # interaction.
25
+ DEFAULT_TIMEOUT = 60.0
26
+
27
+ # @return [Numeric] the default number of seconds to wait after one cleanup
28
+ # before perfoming the next.
29
+ DEFAULT_CLEANUP_INTERVALL = 3600.0
30
+
31
+ # The invalid sender error is used to signify that a sender is not
32
+ # authenticated within the session. This can either be beacuse the sender
33
+ # has not yet logged in, or beacuse an old login has expired.
34
+ class InvalidSender < Error
35
+ def initialize(sender)
36
+ super "Invalid sender: #{sender}"
37
+ end
38
+ end
39
+
40
+ # The unknown sender error is used to signify that a sender has not yet
41
+ # authenticated within the session.
42
+ class UnknownSender < InvalidSender; end
43
+
44
+ # @param timeout [Numeric] the number of seconds a sender is valid with no
45
+ # interaction.
46
+ # @param cleanup_interval [Numeric] the number of seconds to wait after one
47
+ # cleanup before perfoming the next.
48
+ def initialize(timeout: DEFAULT_TIMEOUT,
49
+ cleanup_interval: DEFAULT_CLEANUP_INTERVALL)
50
+ @session = {}
51
+ @timeout = timeout
52
+ @cleanup_interval = cleanup_interval
53
+ @next_cleanup = 0.0
54
+ end
55
+
56
+ # @return [Integer] the number of senders currently known by the session.
57
+ # Note that this may include invalid senders that have not yet been
58
+ # cleaned up.
59
+ def size
60
+ @session.size
61
+ end
62
+
63
+ # Register a new sender as valid in the current session.
64
+ #
65
+ # @param sender [Socket::UDPSource] the udp client.
66
+ # @param time [Numeric] the current time. The current time will be used if
67
+ # not specified.
68
+ # @return [nil]
69
+ def register(sender, time: monotonic_timestamp)
70
+ @session[session_key sender] = time
71
+ nil
72
+ end
73
+
74
+ # A sender is valid as long as it is registered and has not expired within
75
+ # the current session.
76
+ #
77
+ # @param sender [Socket::UDPSource] the udp client.
78
+ # @param time [Numeric] the current time. The current time will be used if
79
+ # not specified.
80
+ # @return [true] if the sender has authenticated and has been active within
81
+ # the configured timeout.
82
+ # @return [false] otherwise.
83
+ def valid?(sender, time: monotonic_timestamp)
84
+ !expired? @session.fetch(session_key(sender), 0.0), time, @timeout
85
+ end
86
+
87
+ # Renews the given sender if it is still valid within the session and raises
88
+ # an exception otherwise.
89
+ #
90
+ # @raise [UnknownSender] if the given sender is not registered.
91
+ # @raise [InvalidSender] if the given sender is not valid.
92
+ #
93
+ # @param sender [Socket::UDPSource] the udp client.
94
+ # @param time [Numeric] the current time. The current time will be used if
95
+ # not specified.
96
+ # @return [nil]
97
+ def validate!(sender, time: monotonic_timestamp)
98
+ key = session_key sender
99
+ last_observed = @session.fetch(key) { raise UnknownSender, sender }
100
+ raise InvalidSender, sender if expired? last_observed, time, @timeout
101
+
102
+ @session[key] = time
103
+ nil
104
+ end
105
+
106
+ # Removes invalid senders from the session.
107
+ #
108
+ # @param time [Numeric] the current time. The current time will be used if
109
+ # not specified.
110
+ # @return [Numeric] the next time before which no cleanup will be performed.
111
+ def cleanup!(time: monotonic_timestamp)
112
+ return if time < @next_cleanup
113
+
114
+ remove_expired! time, @timeout
115
+
116
+ @next_cleanup = time + @cleanup_interval
117
+ end
118
+
119
+ private
120
+
121
+ def session_key(sender)
122
+ sender.remote_address.to_sockaddr
123
+ end
124
+
125
+ def monotonic_timestamp
126
+ Process.clock_gettime Process::CLOCK_MONOTONIC
127
+ end
128
+
129
+ def expired?(sess_time, time, timeout)
130
+ threshold = time - timeout
131
+ sess_time < threshold
132
+ end
133
+
134
+ def remove_expired!(time, timeout)
135
+ @session.delete_if { |_, t| expired? t, time, timeout }
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ADAM6050
4
+ # The application state is stored as an integer an updated in an immutable
5
+ # fashion. This module includes helper functions that simplify reading and
6
+ # creating new states.
7
+ module State
8
+ # @return [Integer] the number of inputs.
9
+ NUM_INPUTS = 12
10
+
11
+ # @return [Integer] the number of outputs.
12
+ NUM_OUTPUTS = 6
13
+
14
+ # @return [Integer] a binary mask selecting the bits of an integer used by
15
+ # the state.
16
+ MASK = (1 << (NUM_INPUTS + NUM_OUTPUTS)) - 1
17
+
18
+ INPUT_MASK = (1 << NUM_INPUTS) - 1
19
+ OUTPUT_MASK = MASK - INPUT_MASK
20
+
21
+ private_constant :MASK, :INPUT_MASK, :OUTPUT_MASK
22
+
23
+ # @return [Integer] the initial state.
24
+ def initial
25
+ 0
26
+ end
27
+
28
+ # @raise [RangeError] if the given channel index exceeds the number of
29
+ # available input channels.
30
+ #
31
+ # @param state [Integer] the current state.
32
+ # @param input_channel [Integer] the input channel number.
33
+ # @return [true, false] the state of the specified input.
34
+ def input_set?(state, input_channel)
35
+ raise RangeError if input_channel >= NUM_INPUTS
36
+
37
+ state & (1 << input_channel) != 0
38
+ end
39
+
40
+ # @raise [RangeError] if the given channel index exceeds the number of
41
+ # available output channels.
42
+ #
43
+ # @param state [Integer] the current state.
44
+ # @param output_channel [Integer] the output channel number.
45
+ # @return [true, false] the state of the specified output.
46
+ def output_set?(state, output_channel)
47
+ raise RangeError if output_channel >= NUM_OUTPUTS
48
+
49
+ state & (1 << output_channel + NUM_INPUTS) != 0
50
+ end
51
+
52
+ # @raise [RangeError] if the given channel index exceeds the number of
53
+ # available output channels.
54
+ #
55
+ # @param state [Integer] the current state.
56
+ # @param output_channel [Integer] the output channel number.
57
+ # @param value [0,Integer] the value to update with.
58
+ # @return [Integer] the next state.
59
+ def update(state, output_channel, value)
60
+ raise RangeError if output_channel >= NUM_OUTPUTS
61
+
62
+ mask = (1 << output_channel + NUM_INPUTS)
63
+ value.zero? ? state & ~mask : state | mask
64
+ end
65
+
66
+ # @param state [Integer] the current state.
67
+ # @param values [Integer] the next output values.
68
+ # @return [Integer] the next state.
69
+ def update_all(state, values)
70
+ state & INPUT_MASK | (values << NUM_INPUTS) & MASK
71
+ end
72
+
73
+ # @param state [Integer] the current state.
74
+ # @return [String] a string representation of the state.
75
+ def inspect(state)
76
+ compact = format '%018b', state
77
+ compact[0...6] + ' ' + compact[6..-1]
78
+ end
79
+
80
+ # @param state [Integer] the current state.
81
+ # @return [String] a binary representation expected by the protocol.
82
+ def to_bin(state)
83
+ format '%05X', (~state & MASK)
84
+ end
85
+
86
+ module_function :initial, :input_set?, :output_set?, :update, :update_all,
87
+ :inspect, :to_bin
88
+ end
89
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ADAM6050
4
+ # @return [String] the ADAM6050 library version number.
5
+ VERSION = '0.1.0'
6
+ end
data/lib/adam6050.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'socket'
5
+
6
+ require 'adam6050/error'
7
+ require 'adam6050/password'
8
+ require 'adam6050/session'
9
+ require 'adam6050/state'
10
+
11
+ require 'adam6050/handler'
12
+ require 'adam6050/handler/login'
13
+ require 'adam6050/handler/read'
14
+ require 'adam6050/handler/status'
15
+ require 'adam6050/handler/write'
16
+
17
+ require 'adam6050/server'
18
+
19
+ # This library implements a server that emulates the Advantech ADAM-6050 IO
20
+ # module.
21
+ module ADAM6050
22
+ end
metadata ADDED
@@ -0,0 +1,168 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: adam6050
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sebastian Lindberg
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2018-07-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.16'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.16'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: redcarpet
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.4'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.4'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.52'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.52'
83
+ - !ruby/object:Gem::Dependency
84
+ name: simplecov
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.16'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.16'
97
+ - !ruby/object:Gem::Dependency
98
+ name: yard
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.9'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.9'
111
+ description: This library implements a server that emulates the Advantech ADAM-6050
112
+ IO module.
113
+ email:
114
+ - seb.lindberg@gmail.com
115
+ executables: []
116
+ extensions: []
117
+ extra_rdoc_files: []
118
+ files:
119
+ - ".gitignore"
120
+ - ".rubocop.yml"
121
+ - ".travis.yml"
122
+ - CHANGELOG.md
123
+ - Gemfile
124
+ - Gemfile.lock
125
+ - LICENSE.txt
126
+ - README.md
127
+ - Rakefile
128
+ - adam6050.gemspec
129
+ - bin/console
130
+ - bin/server
131
+ - bin/setup
132
+ - lib/adam6050.rb
133
+ - lib/adam6050/error.rb
134
+ - lib/adam6050/handler.rb
135
+ - lib/adam6050/handler/login.rb
136
+ - lib/adam6050/handler/read.rb
137
+ - lib/adam6050/handler/status.rb
138
+ - lib/adam6050/handler/write.rb
139
+ - lib/adam6050/password.rb
140
+ - lib/adam6050/server.rb
141
+ - lib/adam6050/session.rb
142
+ - lib/adam6050/state.rb
143
+ - lib/adam6050/version.rb
144
+ homepage: https://github.com/seblindberg/ruby-adam6050
145
+ licenses:
146
+ - MIT
147
+ metadata: {}
148
+ post_install_message:
149
+ rdoc_options: []
150
+ require_paths:
151
+ - lib
152
+ required_ruby_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ">="
155
+ - !ruby/object:Gem::Version
156
+ version: '0'
157
+ required_rubygems_version: !ruby/object:Gem::Requirement
158
+ requirements:
159
+ - - ">="
160
+ - !ruby/object:Gem::Version
161
+ version: '0'
162
+ requirements: []
163
+ rubyforge_project:
164
+ rubygems_version: 2.6.11
165
+ signing_key:
166
+ specification_version: 4
167
+ summary: Server implementation of the ADAM-6050 IO module.
168
+ test_files: []