18 |
19 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 | =====================
3 |
4 | Copyright (c) Nicholas J Humfrey
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 |
--------------------------------------------------------------------------------
/views/index.erb:
--------------------------------------------------------------------------------
1 | <% @title = "HTTP to MQTT Bridge for #{MQTT_OPTS[:remote_host]}" %>
2 |
3 |
This simple web application provides a bridge between HTTP and MQTT using
4 | a RESTish interface. It is possible to GET, POST, PUT and DELETE retained messages on the remote MQTT server.
5 |
6 |
Examples using curl
7 |
8 |
To get a retained value for a topic:
9 |
10 |
curl http://<%= request.host_with_port %>/test
11 |
12 |
To publish to a topic (retained):
13 |
14 |
curl -X PUT --data-binary "Hello World" http://<%= request.host_with_port %>/test
15 |
16 |
To publish to a topic (non-retained):
17 |
18 |
curl -X POST --data-binary "Hello World" http://<%= request.host_with_port %>/test
30 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | daemons (1.3.1)
5 | diff-lcs (1.4.4)
6 | eventmachine (1.2.7)
7 | mqtt (0.5.0)
8 | mustermann (1.1.1)
9 | ruby2_keywords (~> 0.0.1)
10 | rack (2.2.3)
11 | rack-protection (2.1.0)
12 | rack
13 | rack-test (1.1.0)
14 | rack (>= 1.0, < 3)
15 | rake (13.0.3)
16 | rspec (3.10.0)
17 | rspec-core (~> 3.10.0)
18 | rspec-expectations (~> 3.10.0)
19 | rspec-mocks (~> 3.10.0)
20 | rspec-core (3.10.1)
21 | rspec-support (~> 3.10.0)
22 | rspec-expectations (3.10.1)
23 | diff-lcs (>= 1.2.0, < 2.0)
24 | rspec-support (~> 3.10.0)
25 | rspec-mocks (3.10.2)
26 | diff-lcs (>= 1.2.0, < 2.0)
27 | rspec-support (~> 3.10.0)
28 | rspec-support (3.10.2)
29 | ruby2_keywords (0.0.4)
30 | shotgun (0.9.2)
31 | rack (>= 1.0)
32 | sinatra (2.1.0)
33 | mustermann (~> 1.0)
34 | rack (~> 2.2)
35 | rack-protection (= 2.1.0)
36 | tilt (~> 2.0)
37 | thin (1.8.0)
38 | daemons (~> 1.0, >= 1.0.9)
39 | eventmachine (~> 1.0, >= 1.0.4)
40 | rack (>= 1, < 3)
41 | tilt (2.0.10)
42 |
43 | PLATFORMS
44 | x86_64-darwin-19
45 | x86_64-linux
46 |
47 | DEPENDENCIES
48 | mqtt (>= 0.0.7)
49 | rack-test
50 | rake
51 | rspec (>= 3.10.0)
52 | shotgun
53 | sinatra
54 | thin
55 |
56 | RUBY VERSION
57 | ruby 2.7.2p137
58 |
59 | BUNDLED WITH
60 | 2.2.14
61 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | mqtt-http-bridge.rb
2 | ===================
3 |
4 | This simple web application provides a bridge between HTTP and [MQTT] using
5 | a [REST]ish interface. It is possible to GET, POST, PUT and DELETE retained messages
6 | on a remote MQTT server.
7 |
8 |
9 |
10 | Getting started
11 | ---------------
12 |
13 | Install bundler:
14 |
15 | sudo gem install bundler
16 |
17 | Install the other gem dependencies:
18 |
19 | bundle install
20 |
21 | Run the local web server:
22 |
23 | bundle exec rackup -p 1234
24 |
25 | You can then open the bridge in your browser:
26 |
27 | http://localhost:1234/
28 |
29 | To connect to your own MQTT server:
30 |
31 | Edit the first few lines of `mqtt-http-bridge.rb` to match your server.
32 |
33 | If you want to use a different port than the default 1883, add `:remote_port => [PORT NUMBER],` under the line with the IP address. Do not put the port number in quotation marks.
34 |
35 | Examples using curl
36 | -------------------
37 |
38 | To get a retained value for a topic:
39 |
40 | curl http://localhost:1234/test
41 |
42 | To publish to a topic (retained):
43 |
44 | curl -X PUT --data-binary "Hello World" http://localhost:1234/test
45 |
46 | To publish to a topic (non-retained):
47 |
48 | curl -X POST --data-binary "Hello World" http://localhost:1234/test
49 |
50 | To delete the retained value for a topic:
51 |
52 | curl -X DELETE http://localhost:1234/test
53 |
54 |
55 |
56 |
57 | License
58 | -------
59 |
60 | The ruby mqtt-http-bridge is licensed under the terms of the MIT license.
61 | See the file LICENSE for details.
62 |
63 |
64 | Contact
65 | -------
66 |
67 | * Author: Nicholas J Humfrey
68 | * Twitter: [@njh](http://twitter.com/njh)
69 | * Home Page: http://www.aelius.com/njh/
70 |
71 |
72 | [MQTT]: https://en.wikipedia.org/wiki/MQTT
73 | [REST]: http://en.wikipedia.org/wiki/Representational_state_transfer
74 |
--------------------------------------------------------------------------------
/public/css/stylesheet.css:
--------------------------------------------------------------------------------
1 | body
2 | {
3 | font: normal 9pt "lucida grande", helvetica, arial, sans-serif;
4 | text-align: left;
5 | max-width: 740px;
6 | margin: 0 auto;
7 | padding: 10px;
8 | }
9 |
10 | abbr { border: none; }
11 | cite { font-style: normal; }
12 | a img { border: none; padding: 0; margin: 0; }
13 |
14 |
15 | /*--------------------------------------------------------------
16 | Content
17 | --------------------------------------------------------------*/
18 |
19 | #content {}
20 |
21 | #content p {
22 | line-height: 15px;
23 | margin: 0 0 1.2em 0.5em;
24 | }
25 |
26 | #content ul,
27 | #content ol {
28 | margin: 1em;
29 | padding: 0;
30 | }
31 |
32 | #content ul {
33 | list-style-type: square;
34 | }
35 |
36 | #content li {
37 | line-height: 15px;
38 | margin: 0 0 0 2em;
39 | padding: 0;
40 | }
41 |
42 | #content blockquote {
43 | color: #555;
44 | border-left: 5px solid #ccc;
45 | margin: 1.3em 1em;
46 | padding: 0 1em;
47 | }
48 |
49 | #content code {
50 | font: normal 12px monaco "lucida console", "courier new", courier, monospace;
51 | }
52 |
53 | #content pre {
54 | color: #63FF00;
55 | background: #000;
56 | overflow: auto;
57 | font: normal 12px monaco "lucida console", "courier new", courier, monospace;
58 | margin: 1em 1em 1.5em 1em;
59 | padding: 6px;
60 | }
61 |
62 | #content a:link, a:visited { color: #930; }
63 | #content a:hover, a:active { color: #fff; background: #000; }
64 |
65 |
66 | /*--------------------------------------------------------------
67 | Footer
68 | --------------------------------------------------------------*/
69 |
70 | #footer
71 | {
72 | height: 40px;
73 | margin: 10px 0 0;
74 | padding: 10px 0 0;
75 | clear: both;
76 | border-top: 1px solid #ccc;
77 | font-size: 90%;
78 | }
79 |
80 | #footer .links a:link,
81 | #footer .links a:visited {
82 | color: #000;
83 | }
84 |
85 | #footer .links a:hover,
86 | #footer .links a:active {
87 | color: #fff;
88 | background: #000;
89 | }
90 |
--------------------------------------------------------------------------------
/mqtt-http-bridge.rb:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | #
3 | # MQTT to HTTP bridge
4 | #
5 | # Copyright 2012 Nicholas Humfrey
6 | #
7 |
8 | require 'rubygems'
9 | require 'mqtt'
10 | require 'sinatra'
11 | require 'timeout'
12 |
13 | class MqttHttpBridge < Sinatra::Base
14 | MQTT_OPTS = {
15 | :remote_host => 'test.mosquitto.org',
16 | :keep_alive => 4,
17 | :clean_session => true
18 | }
19 |
20 | def mqtt_get(topic)
21 | MQTT::Client.connect(MQTT_OPTS) do |client|
22 | client.subscribe(topic)
23 | begin
24 | Timeout.timeout(2.5) do
25 | topic,message = client.get
26 | client.disconnect
27 | return message
28 | end
29 | rescue Timeout::Error
30 | not_found("No retained data on topic")
31 | end
32 | end
33 | end
34 |
35 | def mqtt_topics
36 | topics = []
37 | MQTT::Client.connect(MQTT_OPTS) do |client|
38 | client.subscribe('$SYS/#')
39 | client.subscribe('#')
40 | begin
41 | Timeout.timeout(1.0) do
42 | client.get { |topic,message| topics << topic }
43 | end
44 | rescue Timeout::Error
45 | end
46 | end
47 | return topics.uniq
48 | end
49 |
50 | def topic
51 | unescape(
52 | request.path_info.slice(1..-1)
53 | )
54 | end
55 |
56 | helpers do
57 | # Escape ampersands, brackets and quotes to their HTML/XML entities.
58 | # (Rack::Utils.escape_html is overly enthusiastic)
59 | def h(string)
60 | mapping = {
61 | "&" => "&",
62 | "<" => "<",
63 | ">" => ">",
64 | "'" => "'",
65 | '"' => """
66 | }
67 | pattern = /#{Regexp.union(*mapping.keys)}/
68 |
69 | # Clean up invalid UTF-8 characters
70 | utf16 = string.to_s.encode('UTF-16', :undef => :replace, :invalid => :replace, :replace => "")
71 | clean = utf16.encode('UTF-8');
72 |
73 | # Now perform escaping
74 | clean.gsub(pattern){|c| mapping[c] }
75 | end
76 |
77 | def link_to(title, url=nil, attr={})
78 | url = title if url.nil?
79 | attr.merge!('href' => url.to_s)
80 | attr_str = attr.keys.map {|k| "#{h k}=\"#{h attr[k]}\""}.join(' ')
81 | "#{h title}"
82 | end
83 | end
84 |
85 |
86 | get '/' do
87 | headers 'Cache-Control' => 'public,max-age=60'
88 | @topics = mqtt_topics.sort
89 | erb :index
90 | end
91 |
92 | get '/*' do
93 | content_type('text/plain')
94 | mqtt_get(topic)
95 | end
96 |
97 | post '/*' do
98 | content_type('text/plain')
99 | MQTT::Client.connect(MQTT_OPTS) do |client|
100 | client.publish(topic, request.body.read, retain=false)
101 | end
102 | "OK"
103 | end
104 |
105 | put '/*' do
106 | content_type('text/plain')
107 | MQTT::Client.connect(MQTT_OPTS) do |client|
108 | client.publish(topic, request.body.read, retain=true)
109 | end
110 | "OK"
111 | end
112 |
113 | delete '/*' do
114 | content_type('text/plain')
115 | MQTT::Client.connect(MQTT_OPTS) do |client|
116 | client.publish(topic, '', retain=true)
117 | end
118 | "OK"
119 | end
120 | end
121 |
--------------------------------------------------------------------------------
/spec/mqtt-http-bridge_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'mqtt-http-bridge'
3 |
4 | # NOTE: there is deliberately no mocking in these tests,
5 | # a live internet connection is required
6 |
7 | set :environment, :test
8 |
9 | describe MqttHttpBridge do
10 | include Rack::Test::Methods
11 |
12 | TEST_MESSAGE_1 = "#{Time.now} - Test Message 1"
13 | TEST_MESSAGE_2 = "#{Time.now} - Test Message 2"
14 |
15 | def app
16 | MqttHttpBridge
17 | end
18 |
19 | before :each do
20 | app.enable :raise_errors
21 | app.disable :show_exceptions
22 | end
23 |
24 | context "PUTing to a simple topic name" do
25 | before :all do
26 | put '/test-mhb', TEST_MESSAGE_1
27 | end
28 |
29 | it "should be successful" do
30 | expect(last_response).to be_ok
31 | end
32 |
33 | it "should have a response of type text/plain" do
34 | expect(last_response.content_type).to eq('text/plain;charset=utf-8')
35 | end
36 |
37 | it "should have a response body of 'OK'" do
38 | expect(last_response.body).to eq('OK')
39 | end
40 | end
41 |
42 | context "PUTing to a topic with a slash at the start" do
43 | before :all do
44 | put '/%2Ftest', TEST_MESSAGE_2
45 | end
46 |
47 | it "should be successful" do
48 | expect(last_response).to be_ok
49 | end
50 |
51 | it "should have a response of type text/plain" do
52 | expect(last_response.content_type).to eq('text/plain;charset=utf-8')
53 | end
54 |
55 | it "should have a response body of 'OK'" do
56 | expect(last_response.body).to eq('OK')
57 | end
58 | end
59 |
60 | context "POSTing to a simple topic name" do
61 | before :all do
62 | @put_response = put('/test-mhb', TEST_MESSAGE_1)
63 | @post_response = post('/test-mhb', TEST_MESSAGE_2)
64 | @get_response = get('/test-mhb')
65 | end
66 |
67 | it "should successfully publish a retained message to topic using PUT" do
68 | expect(@put_response).to be_ok
69 | expect(@put_response.body).to eq('OK')
70 | end
71 |
72 | it "should successfully publish a non-retained message to topic using POST" do
73 | expect(@post_response).to be_ok
74 | expect(@post_response.body).to eq('OK')
75 | end
76 |
77 | it "should successfully GET the retained message afterwards" do
78 | expect(@get_response).to be_ok
79 | expect(@get_response.body).to eq(TEST_MESSAGE_1)
80 | end
81 | end
82 |
83 |
84 | context "GETing a simple topic name" do
85 | before :all do
86 | get '/test-mhb'
87 | end
88 |
89 | it "should be successful" do
90 | expect(last_response).to be_ok
91 | end
92 |
93 | it "should have a response of type text/plain" do
94 | expect(last_response.content_type).to eq('text/plain;charset=utf-8')
95 | end
96 |
97 | it "should have a response body of 'OK'" do
98 | expect(last_response.body).to eq(TEST_MESSAGE_1)
99 | end
100 | end
101 |
102 | context "GETing a topic name with a slash at the start" do
103 | before :all do
104 | get '/%2Ftest'
105 | end
106 |
107 | it "should be successful" do
108 | expect(last_response).to be_ok
109 | end
110 |
111 | it "should have a response of type text/plain" do
112 | expect(last_response.content_type).to eq('text/plain;charset=utf-8')
113 | end
114 |
115 | it "should have a response body of 'OK'" do
116 | expect(last_response.body).to eq(TEST_MESSAGE_2)
117 | end
118 | end
119 |
120 | context "GETing a topic with a space in the name" do
121 | before :all do
122 | @put_response = put('/test%20mhb%20space', TEST_MESSAGE_1)
123 | @get_response = get('/test%20mhb%20space')
124 | end
125 |
126 | it "should successfully publish a retained message to topic using PUT" do
127 | expect(@put_response).to be_ok
128 | expect(@put_response.body).to eq('OK')
129 | end
130 |
131 | it "should successfully GET the retained message afterwards" do
132 | expect(@get_response).to be_ok
133 | expect(@get_response.body).to eq(TEST_MESSAGE_1)
134 | end
135 | end
136 |
137 | context "GETing the homepage" do
138 | before :all do
139 | get '/'
140 | end
141 |
142 | it "should be successful" do
143 | expect(last_response).to be_ok
144 | end
145 |
146 | it "should be of type text/html" do
147 | expect(last_response.content_type).to eq('text/html;charset=utf-8')
148 | end
149 |
150 | it "should be cachable" do
151 | expect(last_response.headers['Cache-Control']).to match(/max-age=([1-9]+)/)
152 | end
153 |
154 | it "should contain a page title" do
155 | expect(last_response.body).to match(%r[
HTTP to MQTT Bridge for (.+)
])
156 | end
157 |
158 | it "should contain the text from the README" do
159 | expect(last_response.body).to match(%r[This simple web application provides a bridge between HTTP])
160 | end
161 |
162 | it "should contain a link to a topic not starting with a slash" do
163 | expect(last_response.body).to match(%r[