mqtt_spec.rb 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  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. sleep 0.1
  139. end
  140. @mqtt_client.wait_for_listeners
  141. # Discard first, retained messages mess with it
  142. avg = update_timestamp_gaps.sum / update_timestamp_gaps.length
  143. expect(update_timestamp_gaps.length).to be >= 3
  144. expect((avg - 0.5).abs).to be < 0.15, "Should be within margin of error of rate limit"
  145. end
  146. end
  147. context ':device_id token for command topic' do
  148. it 'should support hexadecimal device IDs' do
  149. seen = false
  150. @mqtt_client.on_state(@id_params) do |id, message|
  151. seen = (message['status'] == 'ON')
  152. end
  153. # Will use hex by default
  154. @mqtt_client.patch_state(@id_params, status: 'ON')
  155. @mqtt_client.wait_for_listeners
  156. expect(seen).to eq(true), "Should see update for hex param"
  157. end
  158. it 'should support decimal device IDs' do
  159. seen = false
  160. @mqtt_client.on_state(@id_params) do |id, message|
  161. seen = (message['status'] == 'ON')
  162. end
  163. @mqtt_client.publish(
  164. "#{@topic_prefix}commands/#{@id_params[:id]}/rgb_cct/1",
  165. status: 'ON'
  166. )
  167. @mqtt_client.wait_for_listeners
  168. expect(seen).to eq(true), "Should see update for decimal param"
  169. end
  170. end
  171. context ':hex_device_id for command topic' do
  172. before(:all) do
  173. @client.put(
  174. '/settings',
  175. mqtt_topic_pattern: "#{@topic_prefix}commands/:hex_device_id/:device_type/:group_id",
  176. )
  177. end
  178. after(:all) do
  179. @client.put(
  180. '/settings',
  181. mqtt_topic_pattern: "#{@topic_prefix}commands/:device_id/:device_type/:group_id",
  182. )
  183. end
  184. it 'should respond to commands' do
  185. seen = false
  186. @mqtt_client.on_state(@id_params) do |id, message|
  187. seen = (message['status'] == 'ON')
  188. end
  189. # Will use hex by default
  190. @mqtt_client.patch_state(@id_params, status: 'ON')
  191. @mqtt_client.wait_for_listeners
  192. expect(seen).to eq(true), "Should see update for hex param"
  193. end
  194. end
  195. context ':dec_device_id for command topic' do
  196. before(:all) do
  197. @client.put(
  198. '/settings',
  199. mqtt_topic_pattern: "#{@topic_prefix}commands/:dec_device_id/:device_type/:group_id",
  200. )
  201. end
  202. after(:all) do
  203. @client.put(
  204. '/settings',
  205. mqtt_topic_pattern: "#{@topic_prefix}commands/:device_id/:device_type/:group_id",
  206. )
  207. end
  208. it 'should respond to commands' do
  209. seen = false
  210. @mqtt_client.on_state(@id_params) do |id, message|
  211. seen = (message['status'] == 'ON')
  212. end
  213. # Will use hex by default
  214. @mqtt_client.patch_state(@id_params, status: 'ON')
  215. @mqtt_client.wait_for_listeners
  216. expect(seen).to eq(true), "Should see update for hex param"
  217. end
  218. end
  219. describe ':hex_device_id for update/state topics' do
  220. before(:all) do
  221. @client.put(
  222. '/settings',
  223. mqtt_state_topic_pattern: "#{@topic_prefix}state/:hex_device_id/:device_type/:group_id",
  224. mqtt_update_topic_pattern: "#{@topic_prefix}updates/:hex_device_id/:device_type/:group_id"
  225. )
  226. end
  227. after(:all) do
  228. @client.put(
  229. '/settings',
  230. mqtt_state_topic_pattern: "#{@topic_prefix}state/:device_id/:device_type/:group_id",
  231. mqtt_update_topic_pattern: "#{@topic_prefix}updates/:device_id/:device_type/:group_id"
  232. )
  233. end
  234. context 'state and updates' do
  235. it 'should publish updates with hexadecimal device ID' do
  236. seen_update = false
  237. @mqtt_client.on_update(@id_params) do |id, message|
  238. seen_update = (message['state'] == 'ON')
  239. end
  240. # Will use hex by default
  241. @mqtt_client.patch_state(@id_params, status: 'ON')
  242. @mqtt_client.wait_for_listeners
  243. expect(seen_update).to eq(true)
  244. end
  245. it 'should publish state with hexadecimal device ID' do
  246. seen_state = false
  247. @mqtt_client.on_state(@id_params) do |id, message|
  248. seen_state = (message['status'] == 'ON')
  249. end
  250. # Will use hex by default
  251. @mqtt_client.patch_state(@id_params, status: 'ON')
  252. @mqtt_client.wait_for_listeners
  253. expect(seen_state).to eq(true)
  254. end
  255. end
  256. end
  257. describe ':dec_device_id for update/state topics' do
  258. before(:all) do
  259. @client.put(
  260. '/settings',
  261. mqtt_state_topic_pattern: "#{@topic_prefix}state/:dec_device_id/:device_type/:group_id",
  262. mqtt_update_topic_pattern: "#{@topic_prefix}updates/:dec_device_id/:device_type/:group_id"
  263. )
  264. end
  265. after(:all) do
  266. @client.put(
  267. '/settings',
  268. mqtt_state_topic_pattern: "#{@topic_prefix}state/:device_id/:device_type/:group_id",
  269. mqtt_update_topic_pattern: "#{@topic_prefix}updates/:device_id/:device_type/:group_id"
  270. )
  271. end
  272. context 'state and updates' do
  273. it 'should publish updates with hexadecimal device ID' do
  274. seen_update = false
  275. @id_params = @id_params.merge(id_format: 'decimal')
  276. @mqtt_client.on_update(@id_params) do |id, message|
  277. seen_update = (message['state'] == 'ON')
  278. end
  279. # Will use hex by default
  280. @mqtt_client.patch_state(@id_params, status: 'ON')
  281. @mqtt_client.wait_for_listeners
  282. expect(seen_update).to eq(true)
  283. end
  284. it 'should publish state with hexadecimal device ID' do
  285. seen_state = false
  286. @id_params = @id_params.merge(id_format: 'decimal')
  287. @mqtt_client.on_state(@id_params) do |id, message|
  288. seen_state = (message['status'] == 'ON')
  289. end
  290. sleep 1
  291. # Will use hex by default
  292. @mqtt_client.patch_state(@id_params, status: 'ON')
  293. @mqtt_client.wait_for_listeners
  294. expect(seen_state).to eq(true)
  295. end
  296. end
  297. end
  298. describe 'device aliases' do
  299. before(:each) do
  300. @aliases_topic = "#{mqtt_topic_prefix()}commands/:device_alias"
  301. @client.patch_settings(
  302. mqtt_topic_pattern: @aliases_topic,
  303. group_id_aliases: {
  304. 'test_group' => [@id_params[:type], @id_params[:id], @id_params[:group_id]]
  305. }
  306. )
  307. @client.delete_state(@id_params)
  308. end
  309. context ':device_alias token' do
  310. it 'should accept it for command topic' do
  311. @client.patch_settings(mqtt_topic_pattern: @aliases_topic)
  312. @mqtt_client.publish("#{mqtt_topic_prefix()}commands/test_group", status: 'ON')
  313. sleep(1)
  314. state = @client.get_state(@id_params)
  315. expect(state['status']).to eq('ON')
  316. end
  317. it 'should support publishing state to device alias topic' do
  318. @client.patch_settings(
  319. mqtt_topic_pattern: @aliases_topic,
  320. mqtt_state_topic_pattern: "#{mqtt_topic_prefix()}state/:device_alias"
  321. )
  322. seen_alias = nil
  323. seen_state = nil
  324. @mqtt_client.on_message("#{mqtt_topic_prefix()}state/+") do |topic, message|
  325. parts = topic.split('/')
  326. seen_alias = parts.last
  327. seen_state = JSON.parse(message)
  328. seen_alias == 'test_group'
  329. end
  330. @mqtt_client.publish("#{mqtt_topic_prefix()}commands/test_group", status: 'ON')
  331. @mqtt_client.wait_for_listeners
  332. expect(seen_alias).to eq('test_group')
  333. expect(seen_state['status']).to eq('ON')
  334. end
  335. it 'should support publishing updates to device alias topic' do
  336. @client.patch_settings(
  337. mqtt_topic_pattern: @aliases_topic,
  338. mqtt_update_topic_pattern: "#{mqtt_topic_prefix()}updates/:device_alias"
  339. )
  340. seen_alias = nil
  341. seen_state = nil
  342. @mqtt_client.on_message("#{mqtt_topic_prefix()}updates/+") do |topic, message|
  343. parts = topic.split('/')
  344. seen_alias = parts.last
  345. seen_state = JSON.parse(message)
  346. seen_alias == 'test_group'
  347. end
  348. @mqtt_client.publish("#{mqtt_topic_prefix()}commands/test_group", status: 'ON')
  349. @mqtt_client.wait_for_listeners
  350. expect(seen_alias).to eq('test_group')
  351. expect(seen_state['state']).to eq('ON')
  352. end
  353. it 'should delete retained alias messages' do
  354. seen_empty_message = false
  355. @client.patch_settings(mqtt_state_topic_pattern: "#{mqtt_topic_prefix()}state/:device_alias")
  356. @client.patch_state(@id_params, status: 'ON')
  357. @mqtt_client.on_message("#{mqtt_topic_prefix()}state/test_group") do |topic, message|
  358. seen_empty_message = message.empty?
  359. end
  360. @client.patch_state(@id_params, hue: 100)
  361. @client.delete_state(@id_params)
  362. @mqtt_client.wait_for_listeners
  363. expect(seen_empty_message).to eq(true)
  364. end
  365. end
  366. end
  367. end