├── .ruby-version ├── public ├── robots.txt ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png └── css │ └── stylesheet.css ├── Procfile ├── config.ru ├── jenkins.sh ├── spec ├── spec_helper.rb └── mqtt-http-bridge_spec.rb ├── Rakefile ├── manifest.yml ├── Gemfile ├── views ├── layout.erb └── index.erb ├── LICENSE.md ├── Gemfile.lock ├── README.md └── mqtt-http-bridge.rb /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.2 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec rackup config.ru --server thin --port $PORT 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njh/mqtt-http-bridge/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njh/mqtt-http-bridge/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njh/mqtt-http-bridge/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njh/mqtt-http-bridge/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | 4 | Bundler.require(:default) 5 | 6 | require './mqtt-http-bridge' 7 | run MqttHttpBridge 8 | -------------------------------------------------------------------------------- /jenkins.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | eval "$(rbenv init -)" 7 | 8 | bundle install --deployment --path=.bundle/gems 9 | bundle exec rake spec 10 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $:.unshift(File.join(File.dirname(__FILE__),'..')) 2 | 3 | require 'rubygems' 4 | require 'bundler' 5 | 6 | Bundler.require(:default, :test) 7 | 8 | # This is needed by rcov 9 | #require 'rspec/autorun' 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'rubygems' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) do |t| 7 | t.rspec_opts = %w(--no-colour --format progress) 8 | end 9 | 10 | task :default => [:spec] 11 | -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | applications: 3 | .: 4 | name: test-mosquitto 5 | instances: 1 6 | framework: 7 | name: rack 8 | info: 9 | exec: 10 | description: Rack Application 11 | mem: 128M 12 | url: ${name}.${target-base} 13 | mem: 128M 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | ruby File.read('.ruby-version').chomp 3 | 4 | gem 'thin' 5 | 6 | gem 'sinatra' 7 | gem 'mqtt', '>=0.0.7' 8 | 9 | group :development do 10 | gem 'rake' 11 | gem 'shotgun' 12 | end 13 | 14 | group :test do 15 | gem 'rspec', '>=3.10.0' 16 | gem 'rack-test', :require => 'rack/test' 17 | end 18 | -------------------------------------------------------------------------------- /views/layout.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= @title %> 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |

<%= @title %>

16 | <%= yield %> 17 |
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
19 | 20 |

To delete the retained value for a topic:

21 | 22 |
curl -X DELETE http://<%= request.host_with_port %>/test
23 | 24 |

List of Topics

25 | 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[
  • \w+
  • ]) 164 | end 165 | 166 | it "should contain a link to a topic starting with a slash" do 167 | expect(last_response.body).to match(%r[
  • /\w+
  • ]) 168 | end 169 | 170 | it "should contain a link to the '$SYS/broker/version' topic" do 171 | expect(last_response.body).to match(%r[
  • \$SYS/broker/version
  • ]) 172 | end 173 | 174 | it "should not have any duplicate
  • lines" do 175 | lines = last_response.body.split(/\n+/).select {|line| line.match(/
  • /)} 176 | dups = lines.group_by{|e| e}.keep_if{|_, e| e.length > 1} 177 | expect(dups).to be_empty 178 | end 179 | end 180 | 181 | context "DELETEing a topic" do 182 | before :all do 183 | @put_response = put('/deleteme', TEST_MESSAGE_1) 184 | @get1_response = get('/deleteme') 185 | @delete_response = delete('/deleteme') 186 | @get2_response = get('/deleteme') 187 | end 188 | 189 | it "should successfully create the topic to be deleted" do 190 | expect(@put_response).to be_ok 191 | expect(@put_response.body).to eq('OK') 192 | end 193 | 194 | it "should successfully GET back the topic to be deleted" do 195 | expect(@get1_response).to be_ok 196 | expect(@get1_response.body).to eq(TEST_MESSAGE_1) 197 | end 198 | 199 | it "should successfully delete the topic" do 200 | expect(@delete_response).to be_ok 201 | expect(@delete_response.body).to eq('OK') 202 | end 203 | 204 | it "should return 404 after deleting the topic" do 205 | expect(@get2_response).to be_not_found 206 | end 207 | end 208 | 209 | end 210 | --------------------------------------------------------------------------------