mqtt_spec.rb 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. require 'api_client'
  2. RSpec.describe 'MQTT' do
  3. before(:all) do
  4. @client = ApiClient.new(ENV.fetch('ESPMH_HOSTNAME'), ENV.fetch('ESPMH_TEST_DEVICE_ID_BASE'))
  5. @client.upload_json('/settings', 'settings.json')
  6. end
  7. before(:each) do
  8. mqtt_params = mqtt_parameters()
  9. @updates_topic = mqtt_params[:updates_topic]
  10. @topic_prefix = mqtt_topic_prefix()
  11. @client.put(
  12. '/settings',
  13. mqtt_params
  14. )
  15. @id_params = {
  16. id: @client.generate_id,
  17. type: 'rgb_cct',
  18. group_id: 1
  19. }
  20. @client.delete_state(@id_params)
  21. @mqtt_client = create_mqtt_client()
  22. end
  23. context 'deleting' do
  24. it 'should remove retained state' do
  25. @client.patch_state(@id_params, status: 'ON')
  26. seen_blank = false
  27. @mqtt_client.on_state(@id_params) do |topic, message|
  28. seen_blank = (message == "")
  29. end
  30. @client.delete_state(@id_params)
  31. @mqtt_client.wait_for_listeners
  32. expect(seen_blank).to eq(true)
  33. end
  34. end
  35. context 'client status topic' do
  36. before(:all) do
  37. @status_topic = "#{@topic_prefix}client_status"
  38. @client.patch_settings(mqtt_client_status_topic: @status_topic)
  39. end
  40. it 'should send client status messages when configured' do
  41. # Clear any retained messages
  42. @mqtt_client.publish(@status_topic, nil)
  43. # Unfortunately, no way to easily simulate an unclean disconnect, so only test birth
  44. # and forced disconnect
  45. seen_statuses = Set.new
  46. required_statuses = %w(connected disconnected_clean)
  47. @mqtt_client.on_message(@status_topic, 20) do |topic, message|
  48. message = JSON.parse(message)
  49. seen_statuses << message['status']
  50. required_statuses.all? { |x| seen_statuses.include?(x) }
  51. end
  52. # Force MQTT reconnect by updating settings
  53. @client.put('/settings', fakekey: 'fakevalue')
  54. @mqtt_client.wait_for_listeners
  55. expect(seen_statuses).to include(*required_statuses)
  56. end
  57. it 'should send simple client status message when configured' do
  58. @client.patch_settings(simple_mqtt_client_status: true)
  59. # Clear any retained messages
  60. @mqtt_client.publish(@status_topic, nil)
  61. # Unfortunately, no way to easily simulate an unclean disconnect, so only test birth
  62. # and forced disconnect
  63. seen_statuses = Set.new
  64. required_statuses = %w(connected disconnected)
  65. @mqtt_client.on_message(@status_topic, 20) do |topic, message|
  66. seen_statuses << message
  67. required_statuses.all? { |x| seen_statuses.include?(x) }
  68. end
  69. # Force MQTT reconnect by updating settings
  70. @client.patch_settings(fakekey: 'fakevalue')
  71. @mqtt_client.wait_for_listeners
  72. expect(seen_statuses).to include(*required_statuses)
  73. end
  74. end
  75. context 'commands and state' do
  76. # Check state using HTTP
  77. it 'should affect state' do
  78. @client.patch_state({level: 50, status: 'off'}, @id_params)
  79. @mqtt_client.patch_state(@id_params, status: 'on', level: 70)
  80. # wait for packet to be sent...
  81. sleep(1)
  82. state = @client.get_state(@id_params)
  83. expect(state.keys).to include(*%w(level status))
  84. expect(state['status']).to eq('ON')
  85. expect(state['level']).to eq(70)
  86. end
  87. it 'should publish to state topics' do
  88. desired_state = {'status' => 'ON', 'level' => 80}
  89. seen_state = false
  90. @client.patch_state({status: 'off'}, @id_params)
  91. @mqtt_client.on_state(@id_params) do |id, message|
  92. seen_state = desired_state.all? { |k,v| v == message[k] }
  93. end
  94. @mqtt_client.patch_state(@id_params, desired_state)
  95. @mqtt_client.wait_for_listeners
  96. expect(seen_state).to be(true)
  97. end
  98. it 'should publish an update message for each new command' do
  99. tweak_params = {'hue' => 49, 'brightness' => 128, 'saturation' => 50}
  100. desired_state = {'state' => 'ON'}.merge(tweak_params)
  101. init_state = desired_state.merge(Hash[
  102. tweak_params.map do |k, v|
  103. [k, v + 10]
  104. end
  105. ])
  106. @client.patch_state(@id_params, init_state)
  107. accumulated_state = {}
  108. @mqtt_client.on_update(@id_params) do |id, message|
  109. desired_state == accumulated_state.merge!(message)
  110. end
  111. @mqtt_client.patch_state(@id_params, desired_state)
  112. @mqtt_client.wait_for_listeners
  113. expect(accumulated_state).to eq(desired_state)
  114. end
  115. it 'should respect the state update interval' do
  116. # Disable updates to prevent the negative effects of spamming commands
  117. @client.put(
  118. '/settings',
  119. mqtt_update_topic_pattern: '',
  120. mqtt_state_rate_limit: 500,
  121. packet_repeats: 1
  122. )
  123. # Set initial state
  124. @client.patch_state({status: 'ON', level: 0}, @id_params)
  125. last_seen = 0
  126. update_timestamp_gaps = []
  127. num_updates = 50
  128. @mqtt_client.on_state(@id_params) do |id, message|
  129. next_time = Time.now
  130. if last_seen != 0
  131. update_timestamp_gaps << next_time - last_seen
  132. end
  133. last_seen = next_time
  134. message['level'] == num_updates
  135. end
  136. (1..num_updates).each do |i|
  137. @mqtt_client.patch_state(@id_params, level: i)
  138. end
  139. @mqtt_client.wait_for_listeners
  140. # Discard first, retained messages mess with it
  141. avg = update_timestamp_gaps.sum / update_timestamp_gaps.length
  142. expect(update_timestamp_gaps.length).to be >= 3
  143. expect((avg - 0.5).abs).to be < 0.15, "Should be within margin of error of rate limit"
  144. end
  145. end
  146. context ':device_id token for command topic' do
  147. it 'should support hexadecimal device IDs' do
  148. seen = false
  149. @mqtt_client.on_state(@id_params) do |id, message|
  150. seen = (message['status'] == 'ON')
  151. end
  152. # Will use hex by default
  153. @mqtt_client.patch_state(@id_params, status: 'ON')
  154. @mqtt_client.wait_for_listeners
  155. expect(seen).to eq(true), "Should see update for hex param"
  156. end
  157. it 'should support decimal device IDs' do
  158. seen = false
  159. @mqtt_client.on_state(@id_params) do |id, message|
  160. seen = (message['status'] == 'ON')
  161. end
  162. @mqtt_client.publish(
  163. "#{@topic_prefix}commands/#{@id_params[:id]}/rgb_cct/1",
  164. status: 'ON'
  165. )
  166. @mqtt_client.wait_for_listeners
  167. expect(seen).to eq(true), "Should see update for decimal param"
  168. end
  169. end
  170. context ':hex_device_id for command topic' do
  171. before(:all) do
  172. @client.put(
  173. '/settings',
  174. mqtt_topic_pattern: "#{@topic_prefix}commands/:hex_device_id/:device_type/:group_id",
  175. )
  176. end
  177. after(:all) do
  178. @client.put(
  179. '/settings',
  180. mqtt_topic_pattern: "#{@topic_prefix}commands/:device_id/:device_type/:group_id",
  181. )
  182. end
  183. it 'should respond to commands' do
  184. seen = false
  185. @mqtt_client.on_state(@id_params) do |id, message|
  186. seen = (message['status'] == 'ON')
  187. end
  188. # Will use hex by default
  189. @mqtt_client.patch_state(@id_params, status: 'ON')
  190. @mqtt_client.wait_for_listeners
  191. expect(seen).to eq(true), "Should see update for hex param"
  192. end
  193. end
  194. context ':dec_device_id for command topic' do
  195. before(:all) do
  196. @client.put(
  197. '/settings',
  198. mqtt_topic_pattern: "#{@topic_prefix}commands/:dec_device_id/:device_type/:group_id",
  199. )
  200. end
  201. after(:all) do
  202. @client.put(
  203. '/settings',
  204. mqtt_topic_pattern: "#{@topic_prefix}commands/:device_id/:device_type/:group_id",
  205. )
  206. end
  207. it 'should respond to commands' do
  208. seen = false
  209. @mqtt_client.on_state(@id_params) do |id, message|
  210. seen = (message['status'] == 'ON')
  211. end
  212. # Will use hex by default
  213. @mqtt_client.patch_state(@id_params, status: 'ON')
  214. @mqtt_client.wait_for_listeners
  215. expect(seen).to eq(true), "Should see update for hex param"
  216. end
  217. end
  218. describe ':hex_device_id for update/state topics' do
  219. before(:all) do
  220. @client.put(
  221. '/settings',
  222. mqtt_state_topic_pattern: "#{@topic_prefix}state/:hex_device_id/:device_type/:group_id",
  223. mqtt_update_topic_pattern: "#{@topic_prefix}updates/:hex_device_id/:device_type/:group_id"
  224. )
  225. end
  226. after(:all) do
  227. @client.put(
  228. '/settings',
  229. mqtt_state_topic_pattern: "#{@topic_prefix}state/:device_id/:device_type/:group_id",
  230. mqtt_update_topic_pattern: "#{@topic_prefix}updates/:device_id/:device_type/:group_id"
  231. )
  232. end
  233. context 'state and updates' do
  234. it 'should publish updates with hexadecimal device ID' do
  235. seen_update = false
  236. @mqtt_client.on_update(@id_params) do |id, message|
  237. seen_update = (message['state'] == 'ON')
  238. end
  239. # Will use hex by default
  240. @mqtt_client.patch_state(@id_params, status: 'ON')
  241. @mqtt_client.wait_for_listeners
  242. expect(seen_update).to eq(true)
  243. end
  244. it 'should publish state with hexadecimal device ID' do
  245. seen_state = false
  246. @mqtt_client.on_state(@id_params) do |id, message|
  247. seen_state = (message['status'] == 'ON')
  248. end
  249. # Will use hex by default
  250. @mqtt_client.patch_state(@id_params, status: 'ON')
  251. @mqtt_client.wait_for_listeners
  252. expect(seen_state).to eq(true)
  253. end
  254. end
  255. end
  256. describe ':dec_device_id for update/state topics' do
  257. before(:all) do
  258. @client.put(
  259. '/settings',
  260. mqtt_state_topic_pattern: "#{@topic_prefix}state/:dec_device_id/:device_type/:group_id",
  261. mqtt_update_topic_pattern: "#{@topic_prefix}updates/:dec_device_id/:device_type/:group_id"
  262. )
  263. end
  264. after(:all) do
  265. @client.put(
  266. '/settings',
  267. mqtt_state_topic_pattern: "#{@topic_prefix}state/:device_id/:device_type/:group_id",
  268. mqtt_update_topic_pattern: "#{@topic_prefix}updates/:device_id/:device_type/:group_id"
  269. )
  270. end
  271. context 'state and updates' do
  272. it 'should publish updates with hexadecimal device ID' do
  273. seen_update = false
  274. @id_params = @id_params.merge(id_format: 'decimal')
  275. @mqtt_client.on_update(@id_params) do |id, message|
  276. seen_update = (message['state'] == 'ON')
  277. end
  278. # Will use hex by default
  279. @mqtt_client.patch_state(@id_params, status: 'ON')
  280. @mqtt_client.wait_for_listeners
  281. expect(seen_update).to eq(true)
  282. end
  283. it 'should publish state with hexadecimal device ID' do
  284. seen_state = false
  285. @id_params = @id_params.merge(id_format: 'decimal')
  286. @mqtt_client.on_state(@id_params) do |id, message|
  287. seen_state = (message['status'] == 'ON')
  288. end
  289. sleep 1
  290. # Will use hex by default
  291. @mqtt_client.patch_state(@id_params, status: 'ON')
  292. @mqtt_client.wait_for_listeners
  293. expect(seen_state).to eq(true)
  294. end
  295. end
  296. end
  297. describe 'device aliases' do
  298. before(:each) do
  299. @aliases_topic = "#{mqtt_topic_prefix()}commands/:device_alias"
  300. @client.patch_settings(
  301. mqtt_topic_pattern: @aliases_topic,
  302. group_id_aliases: {
  303. 'test_group' => [@id_params[:type], @id_params[:id], @id_params[:group_id]]
  304. }
  305. )
  306. @client.delete_state(@id_params)
  307. end
  308. context ':device_alias token' do
  309. it 'should accept it for command topic' do
  310. @client.patch_settings(mqtt_topic_pattern: @aliases_topic)
  311. @mqtt_client.publish("#{mqtt_topic_prefix()}commands/test_group", status: 'ON')
  312. sleep(1)
  313. state = @client.get_state(@id_params)
  314. expect(state['status']).to eq('ON')
  315. end
  316. it 'should support publishing state to device alias topic' do
  317. @client.patch_settings(
  318. mqtt_topic_pattern: @aliases_topic,
  319. mqtt_state_topic_pattern: "#{mqtt_topic_prefix()}state/:device_alias"
  320. )
  321. seen_alias = nil
  322. seen_state = nil
  323. @mqtt_client.on_message("#{mqtt_topic_prefix()}state/+") do |topic, message|
  324. parts = topic.split('/')
  325. seen_alias = parts.last
  326. seen_state = JSON.parse(message)
  327. seen_alias == 'test_group'
  328. end
  329. @mqtt_client.publish("#{mqtt_topic_prefix()}commands/test_group", status: 'ON')
  330. @mqtt_client.wait_for_listeners
  331. expect(seen_alias).to eq('test_group')
  332. expect(seen_state['status']).to eq('ON')
  333. end
  334. it 'should support publishing updates to device alias topic' do
  335. @client.patch_settings(
  336. mqtt_topic_pattern: @aliases_topic,
  337. mqtt_update_topic_pattern: "#{mqtt_topic_prefix()}updates/:device_alias"
  338. )
  339. seen_alias = nil
  340. seen_state = nil
  341. @mqtt_client.on_message("#{mqtt_topic_prefix()}updates/+") do |topic, message|
  342. parts = topic.split('/')
  343. seen_alias = parts.last
  344. seen_state = JSON.parse(message)
  345. seen_alias == 'test_group'
  346. end
  347. @mqtt_client.publish("#{mqtt_topic_prefix()}commands/test_group", status: 'ON')
  348. @mqtt_client.wait_for_listeners
  349. expect(seen_alias).to eq('test_group')
  350. expect(seen_state['state']).to eq('ON')
  351. end
  352. it 'should delete retained alias messages' do
  353. seen_empty_message = false
  354. @client.patch_settings(mqtt_state_topic_pattern: "#{mqtt_topic_prefix()}state/:device_alias")
  355. @client.patch_state(@id_params, status: 'ON')
  356. @mqtt_client.on_message("#{mqtt_topic_prefix()}state/test_group") do |topic, message|
  357. seen_empty_message = message.empty?
  358. end
  359. @client.patch_state(@id_params, hue: 100)
  360. @client.delete_state(@id_params)
  361. @mqtt_client.wait_for_listeners
  362. expect(seen_empty_message).to eq(true)
  363. end
  364. end
  365. end
  366. end