mqtt_spec.rb 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. require 'api_client'
  2. require 'mqtt_client'
  3. RSpec.describe 'State' do
  4. before(:all) do
  5. @client = ApiClient.new(ENV.fetch('ESPMH_HOSTNAME'), ENV.fetch('ESPMH_TEST_DEVICE_ID_BASE'))
  6. @client.upload_json('/settings', 'settings.json')
  7. @topic_prefix = ENV.fetch('ESPMH_MQTT_TOPIC_PREFIX')
  8. @updates_topic = "#{@topic_prefix}updates/:device_id/:device_type/:group_id"
  9. @client.put(
  10. '/settings',
  11. mqtt_server: ENV.fetch('ESPMH_MQTT_SERVER'),
  12. mqtt_username: ENV.fetch('ESPMH_MQTT_USERNAME'),
  13. mqtt_password: ENV.fetch('ESPMH_MQTT_PASSWORD'),
  14. mqtt_topic_pattern: "#{@topic_prefix}commands/:device_id/:device_type/:group_id",
  15. mqtt_state_topic_pattern: "#{@topic_prefix}state/:device_id/:device_type/:group_id",
  16. mqtt_update_topic_pattern: @updates_topic
  17. )
  18. end
  19. before(:each) do
  20. @id_params = {
  21. id: @client.generate_id,
  22. type: 'rgb_cct',
  23. group_id: 1
  24. }
  25. @client.delete_state(@id_params)
  26. @mqtt_client = MqttClient.new(
  27. *%w(SERVER USERNAME PASSWORD).map { |x| ENV.fetch("ESPMH_MQTT_#{x}") } << @topic_prefix
  28. )
  29. end
  30. context 'deleting' do
  31. it 'should remove retained state' do
  32. @client.patch_state(@id_params, status: 'ON')
  33. seen_blank = false
  34. @mqtt_client.on_state(@id_params) do |topic, message|
  35. seen_blank = (message == "")
  36. end
  37. @client.delete_state(@id_params)
  38. @mqtt_client.wait_for_listeners
  39. expect(seen_blank).to eq(true)
  40. end
  41. end
  42. context 'client status topic' do
  43. # Unfortunately, no way to easily simulate an unclean disconnect, so only test birth
  44. it 'should send client status messages when configured' do
  45. status_topic = "#{@topic_prefix}client_status"
  46. @client.put(
  47. '/settings',
  48. mqtt_client_status_topic: status_topic
  49. )
  50. # Clear any retained messages
  51. @mqtt_client.publish(status_topic, nil)
  52. seen_statuses = Set.new
  53. required_statuses = %w(connected disconnected_clean)
  54. @mqtt_client.on_message(status_topic, 20) do |topic, message|
  55. message = JSON.parse(message)
  56. seen_statuses << message['status']
  57. required_statuses.all? { |x| seen_statuses.include?(x) }
  58. end
  59. # Force MQTT reconnect by updating settings
  60. @client.put('/settings', fakekey: 'fakevalue')
  61. @mqtt_client.wait_for_listeners
  62. expect(seen_statuses).to include(*required_statuses)
  63. end
  64. end
  65. context 'commands and state' do
  66. # Check state using HTTP
  67. it 'should affect state' do
  68. @client.patch_state({level: 50, status: 'off'}, @id_params)
  69. @mqtt_client.patch_state(@id_params, status: 'on', level: 70)
  70. state = @client.get_state(@id_params)
  71. expect(state.keys).to include(*%w(level status))
  72. expect(state['status']).to eq('ON')
  73. expect(state['level']).to eq(70)
  74. end
  75. it 'should publish to state topics' do
  76. desired_state = {'status' => 'ON', 'level' => 80}
  77. seen_state = false
  78. @client.patch_state({status: 'off'}, @id_params)
  79. @mqtt_client.on_state(@id_params) do |id, message|
  80. seen_state = (id == @id_params && desired_state.all? { |k,v| v == message[k] })
  81. end
  82. @mqtt_client.patch_state(@id_params, desired_state)
  83. @mqtt_client.wait_for_listeners
  84. expect(seen_state).to be(true)
  85. end
  86. it 'should publish an update message for each new command' do
  87. tweak_params = {'hue' => 49, 'brightness' => 128, 'saturation' => 50}
  88. desired_state = {'state' => 'ON'}.merge(tweak_params)
  89. init_state = desired_state.merge(Hash[
  90. tweak_params.map do |k, v|
  91. [k, v + 10]
  92. end
  93. ])
  94. @client.patch_state(@id_params, init_state)
  95. accumulated_state = {}
  96. @mqtt_client.on_update(@id_params) do |id, message|
  97. desired_state == accumulated_state.merge!(message)
  98. end
  99. @mqtt_client.patch_state(@id_params, desired_state)
  100. @mqtt_client.wait_for_listeners
  101. expect(accumulated_state).to eq(desired_state)
  102. end
  103. it 'should respect the state update interval' do
  104. # Disable updates to prevent the negative effects of spamming commands
  105. @client.put(
  106. '/settings',
  107. mqtt_update_topic_pattern: '',
  108. mqtt_state_rate_limit: 500,
  109. packet_repeats: 1
  110. )
  111. # Set initial state
  112. @client.patch_state({status: 'ON', level: 0}, @id_params)
  113. last_seen = 0
  114. update_timestamp_gaps = []
  115. num_updates = 50
  116. @mqtt_client.on_state(@id_params) do |id, message|
  117. next_time = Time.now
  118. if last_seen != 0
  119. update_timestamp_gaps << next_time - last_seen
  120. end
  121. last_seen = next_time
  122. message['level'] == num_updates
  123. end
  124. (1..num_updates).each do |i|
  125. @mqtt_client.patch_state(@id_params, level: i)
  126. sleep 0.1
  127. end
  128. @mqtt_client.wait_for_listeners
  129. # Discard first, retained messages mess with it
  130. avg = update_timestamp_gaps.sum / update_timestamp_gaps.length
  131. expect(update_timestamp_gaps.length).to be >= 3
  132. expect((avg - 0.5).abs).to be < 0.02
  133. end
  134. end
  135. context ':device_id token for command topic' do
  136. it 'should support hexadecimal device IDs' do
  137. seen = false
  138. @mqtt_client.on_state(@id_params) do |id, message|
  139. seen = (message['status'] == 'ON')
  140. end
  141. # Will use hex by default
  142. @mqtt_client.patch_state(@id_params, status: 'ON')
  143. @mqtt_client.wait_for_listeners
  144. expect(seen).to eq(true), "Should see update for hex param"
  145. end
  146. it 'should support decimal device IDs' do
  147. seen = false
  148. @mqtt_client.on_state(@id_params) do |id, message|
  149. seen = (message['status'] == 'ON')
  150. end
  151. @mqtt_client.publish(
  152. "#{@topic_prefix}commands/#{@id_params[:id]}/rgb_cct/1",
  153. status: 'ON'
  154. )
  155. @mqtt_client.wait_for_listeners
  156. expect(seen).to eq(true), "Should see update for decimal param"
  157. end
  158. end
  159. context ':hex_device_id for command topic' do
  160. before(:all) do
  161. @client.put(
  162. '/settings',
  163. mqtt_topic_pattern: "#{@topic_prefix}commands/:hex_device_id/:device_type/:group_id",
  164. )
  165. end
  166. after(:all) do
  167. @client.put(
  168. '/settings',
  169. mqtt_topic_pattern: "#{@topic_prefix}commands/:device_id/:device_type/:group_id",
  170. )
  171. end
  172. it 'should respond to commands' do
  173. seen = false
  174. @mqtt_client.on_state(@id_params) do |id, message|
  175. seen = (message['status'] == 'ON')
  176. end
  177. # Will use hex by default
  178. @mqtt_client.patch_state(@id_params, status: 'ON')
  179. @mqtt_client.wait_for_listeners
  180. expect(seen).to eq(true), "Should see update for hex param"
  181. end
  182. end
  183. context ':dec_device_id for command topic' do
  184. before(:all) do
  185. @client.put(
  186. '/settings',
  187. mqtt_topic_pattern: "#{@topic_prefix}commands/:dec_device_id/:device_type/:group_id",
  188. )
  189. end
  190. after(:all) do
  191. @client.put(
  192. '/settings',
  193. mqtt_topic_pattern: "#{@topic_prefix}commands/:device_id/:device_type/:group_id",
  194. )
  195. end
  196. it 'should respond to commands' do
  197. seen = false
  198. @mqtt_client.on_state(@id_params) do |id, message|
  199. seen = (message['status'] == 'ON')
  200. end
  201. # Will use hex by default
  202. @mqtt_client.patch_state(@id_params, status: 'ON')
  203. @mqtt_client.wait_for_listeners
  204. expect(seen).to eq(true), "Should see update for hex param"
  205. end
  206. end
  207. context ':hex_device_id for update/state topics' do
  208. before(:all) do
  209. @client.put(
  210. '/settings',
  211. mqtt_state_topic_pattern: "#{@topic_prefix}state/:hex_device_id/:device_type/:group_id",
  212. mqtt_update_topic_pattern: "#{@topic_prefix}updates/:hex_device_id/:device_type/:group_id"
  213. )
  214. end
  215. after(:all) do
  216. @client.put(
  217. '/settings',
  218. mqtt_state_topic_pattern: "#{@topic_prefix}state/:device_id/:device_type/:group_id",
  219. mqtt_update_topic_pattern: "#{@topic_prefix}updates/:device_id/:device_type/:group_id"
  220. )
  221. end
  222. it 'should publish updates with hexadecimal device ID' do
  223. seen_update = false
  224. @mqtt_client.on_update(@id_params) do |id, message|
  225. seen_update = (message['state'] == 'ON')
  226. end
  227. # Will use hex by default
  228. @mqtt_client.patch_state(@id_params, status: 'ON')
  229. @mqtt_client.wait_for_listeners
  230. expect(seen_update).to eq(true)
  231. end
  232. it 'should publish state with hexadecimal device ID' do
  233. seen_state = false
  234. @mqtt_client.on_state(@id_params) do |id, message|
  235. seen_state = (message['status'] == 'ON')
  236. end
  237. # Will use hex by default
  238. @mqtt_client.patch_state(@id_params, status: 'ON')
  239. @mqtt_client.wait_for_listeners
  240. expect(seen_state).to eq(true)
  241. end
  242. end
  243. context ':dec_device_id for update/state topics' do
  244. before(:all) do
  245. @client.put(
  246. '/settings',
  247. mqtt_state_topic_pattern: "#{@topic_prefix}state/:dec_device_id/:device_type/:group_id",
  248. mqtt_update_topic_pattern: "#{@topic_prefix}updates/:dec_device_id/:device_type/:group_id"
  249. )
  250. end
  251. after(:all) do
  252. @client.put(
  253. '/settings',
  254. mqtt_state_topic_pattern: "#{@topic_prefix}state/:device_id/:device_type/:group_id",
  255. mqtt_update_topic_pattern: "#{@topic_prefix}updates/:device_id/:device_type/:group_id"
  256. )
  257. end
  258. it 'should publish updates with hexadecimal device ID' do
  259. seen_update = false
  260. @id_params = @id_params.merge(id_format: 'decimal')
  261. @mqtt_client.on_update(@id_params) do |id, message|
  262. seen_update = (message['state'] == 'ON')
  263. end
  264. # Will use hex by default
  265. @mqtt_client.patch_state(@id_params, status: 'ON')
  266. @mqtt_client.wait_for_listeners
  267. expect(seen_update).to eq(true)
  268. end
  269. it 'should publish state with hexadecimal device ID' do
  270. seen_state = false
  271. @id_params = @id_params.merge(id_format: 'decimal')
  272. @mqtt_client.on_state(@id_params) do |id, message|
  273. seen_state = (message['status'] == 'ON')
  274. end
  275. # Will use hex by default
  276. @mqtt_client.patch_state(@id_params, status: 'ON')
  277. @mqtt_client.wait_for_listeners
  278. expect(seen_state).to eq(true)
  279. end
  280. end
  281. end