├── .github └── ISSUE_TEMPLATE │ ├── -unhandled-call--error.md │ └── bug_report.md ├── .gitignore ├── LICENSE ├── README.md ├── common ├── images.py ├── messages.py └── sb_common.py ├── notes.md ├── resources ├── logo.jpg └── oldhome.jpg ├── superbird-server.py ├── superbird_secrets-template.py └── utils ├── bt_utils.py ├── handlers ├── bt_handler.py ├── graphql_handler.py ├── pubsub_handler.py └── update_handler.py ├── remote_api.py └── wamp ├── wamp_builder.py └── wamp_handler.py /.github/ISSUE_TEMPLATE/-unhandled-call--error.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: '"Unhandled call" error' 3 | about: Report any "Unhandled call" errors with this template 4 | title: "[CALL] Unhandled Call" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the error** 11 | A screenshot or copy-paste of the error. Unhandled calls look like this: 12 | 13 | ``` 14 | Superbird: Unhandled call: com.spotify.superbird.pause 15 | Request ID: 22703 16 | WAMP Options: {} 17 | Arguments: [] 18 | nArgumentsKw: {} 19 | ``` 20 | 21 | **To Reproduce** 22 | Describe what you were doing when the error appeared: 23 | 1. Go to '...' 24 | 2. Tap on '....' 25 | 3. Scroll down to '....' 26 | 4. See error 27 | 28 | **Desktop:** 29 | - OS: [e.g. Debian 12] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report any exceptions with this template 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the error** 11 | A screenshot or copy-paste of the error. Exceptions look like this: 12 | 13 | ``` 14 | ~~~~~ Exception Start ~~~~~ 15 | Traceback (most recent call last): 16 | File "/home/user/Desktop/unamed-superbird-connector/utils/wamp/wamp_handler.py", line 236, in function_handler 17 | return True, resp, with_event, event 18 | ^^^^ 19 | UnboundLocalError: cannot access local variable 'resp' where it is not associated with a value 20 | ~~~~~ Exception End ~~~~~ 21 | ``` 22 | If you see multiple exceptions, send them all in one issue. 23 | 24 | **To Reproduce** 25 | Describe what you were doing when the error appeared: 26 | 1. Go to '...' 27 | 2. Tap on '....' 28 | 3. Scroll down to '....' 29 | 4. See error 30 | 31 | **Desktop:** 32 | - OS: [e.g. Debian 12] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | dont_commit 3 | scratchpad.py 4 | superbird_session.json 5 | superbird_secrets.py 6 | .cache 7 | audio.ogg 8 | .vscode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 CarThingHax Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # unnamed-superbird-connector 2 | 3 | **Join the Discord! https://discord.gg/DM2AqyPJAA** 4 | 5 | **This is currently in very early development and has a very small set of features.** 6 | **Currently, most things you see on your Superbird when running this is a placeholder** 7 | Tool to connect Superbird to the Spotify Web API \ 8 | Currently only tested on Debian 9 | 10 | **If you get any exceptions or `Unhandled call` errors, please open an issue that describes what you were doing and a copy of the error. Every little bit of info helps!** 11 | 12 | # Bug Disclaimer 13 | Sometimes Connector can cause Superbird to freeze up or bug out in some way. I haven't encountered any issues that required anything more than a reboot to fix 14 | but in the event that a reboot doesn't fix it, you can try factory resetting Superbird by holding the preset 2 and back buttons while plugging it in until the 15 | Spotify logo appears. 16 | 17 | Another thing, there's currently no local caching so sometimes there may be visual artifacts such as songs not skipping immediately or play/pause taking a few seconds to update. 18 | 19 | There's a very small chance this will exceed rate limits of the Spotify Web API. If you start seeing 429 errors in the terminal, stop Connector for a little bit to allow the API to cool down. 20 | 21 | # Current Progress 22 | Most, if not all messages to/from Superbird are implemented. In most cases the data returned is just placeholder data.\ 23 | Currently playback control (except queue and saving tracks) and switching devices is implemented. 24 | 25 | Superbird actually processes some commands on device so you can already use "Hey Spotify". These are the available commands: 26 | `resume, previous, stop, pause, mute, next, skip` 27 | 28 | # Prerequisites 29 | Python 3.10+ - Connector makes heavy use of match case statements which were introduced in 3.10 30 | 31 | umsgpack - https://github.com/vsergeev/u-msgpack-python \ 32 | `pip install u-msgpack-python` 33 | 34 | spotipy - https://github.com/spotipy-dev/spotipy \ 35 | `pip install spotipy` 36 | 37 | Pillow - https://pypi.org/project/pillow/ \ 38 | `pip install pillow` 39 | 40 | # Spotify API Setup 41 | 1. Go to https://developer.spotify.com/dashboard 42 | 2. Click "Create app" 43 | 3. Put whatever you want for the name and description, put `http://127.0.0.1:9696/connector-auth` in "Redirect URIs" and select "Web SDK" then click save. 44 | 4. In the Dasboard that shows up, go to "Settings", click "Show client sectet" and make note of the Client ID and secret 45 | 5. Rename `superbird_secrets-template.py` to `superbird_secrets.py`, open it in a text editor and put the client ID and secret in there. 46 | 47 | # Running 48 | Simply try running `python3 superbird-server.py` then pair your Superbird. \ 49 | Once it connects, you can go through all the menus as if Superbird was connected to your phone. 50 | 51 | # Contributing / forking 52 | If you'd like to add support for another service, feel free to make a fork of this repo! All the code that talks to the Spotify API 53 | is in remote_api.py so that *should* be the only file you need to edit unless you're adding brand new features. If you need any help/guidance, feel free to ask in the Discord! 54 | 55 | # Credits 56 | https://github.com/Merlin04/superbird-webapp - Lots of communication is handled by the webapp. The reconstructed code makes it easy to figure out how to handle messages 57 | 58 | https://github.com/relative/deskthing - Early base for this code 59 | 60 | # Disclaimer 61 | "Spotify", "Car Thing" and the Spotify logo are registered trademarks or trademarks of Spotify AB. Thing Labs is not affiliated with, endorsed by, or sponsored by Spotify AB. All other trademarks, service marks, and trade names are the property of their respective owners. 62 | -------------------------------------------------------------------------------- /common/images.py: -------------------------------------------------------------------------------- 1 | 2 | import requests 3 | from PIL import Image 4 | import base64 5 | import traceback 6 | from io import BytesIO 7 | 8 | def download_img(url, thumbnail=False): 9 | if "spotify:image:" in url: 10 | url = url.replace('spotify:image:', '') 11 | try: 12 | match url: 13 | case 'old_home': 14 | img_data = base64.b64decode(old_home) 15 | case 'carthinghax_logo': 16 | img_data = base64.b64decode(carthinghax_logo) 17 | case _: 18 | img_data = requests.get(url).content 19 | 20 | img_data = BytesIO(img_data) 21 | img_out = BytesIO() 22 | img = Image.open(img_data).convert("RGB") 23 | w, h = img.size 24 | if thumbnail: 25 | w, h = (96, 96) 26 | img=img.resize((96,96)) 27 | print("resize") 28 | elif (w>300) & (h>300): 29 | w, h = (300, 300) 30 | img=img.resize((300,300)) 31 | img.save(img_out, format="JPEG") 32 | img_b64 = base64.b64encode(img_out.getvalue()).decode('utf-8') 33 | 34 | resp = {'image_data': img_b64, 'width': w, 'height': h} 35 | return resp 36 | except Exception: 37 | print("\n\n~~~~~ Exception Start ~~~~~") 38 | traceback.print_exc() 39 | print("~~~~~ Exception End ~~~~~\n") 40 | 41 | 42 | 43 | 44 | old_home = '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' 45 | 46 | carthinghax_logo = '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' -------------------------------------------------------------------------------- /common/messages.py: -------------------------------------------------------------------------------- 1 | # A place for message generators and long messages that don't change often 2 | import random 3 | 4 | # Configuration json that Superbird requests upon connection 5 | remote_config_response = { 6 | "result":{ 7 | # General Settings 8 | 'ota_inactivity_timeout': 10, # How long to wait before resuming OTA download 9 | 'non_spotify_playback_android': True, # Allows the simple now playing screen to be used 10 | 'non_spotify_playback_ios': True, # iOS has a different way of handling the simple now playing screen 11 | 'handle_incoming_phone_calls': False, # If we figure out phone calls, this can be used to show incoming calls on Superbird 12 | 'developer_menu_enabled': True, # Shows the Developer Menu in the Settings menu 13 | 'local_command_stop_enabled': True, # Disables / enables local voice command processing. Refer to README 14 | 15 | # Night mode decreases the screen contrast when the enviroment is dim 16 | 'night_mode_enabled': True, # Doesn't seem to affect anything 17 | 'night_mode_strength': 40, # This adjusts the strength 18 | 'night_mode_slope': 10, # Not sure what exactly this does, it's best to leave it alone 19 | 20 | # Sends the recording of you saying Hey Spotify as a .wav file to Connector 21 | 'upload_wakeword': False, 22 | 23 | # We don't care about logs (for now at least) 24 | 'log_signal_strength': False, 25 | 'log_requests': False, 26 | 'batch_ubi_logs': False, 27 | 28 | # Controls the behaviour of the sunset screen. Has no effect before OS 8.9.2 29 | # Changing these isn't recommended but if you do it shouldn't harm anything 30 | 'sunset_kill_switch': False, # Shows "Car Thing is discontinued" message 31 | 'sunset_info_screen_nag': False, # No effect? 32 | 'sunset_info_screen': False, # Sometimes makes the sunset message comes up but it's tempermental 33 | 34 | # Effect is unknown. Changing these isn't recommended 35 | "app_launch_rssi_limit":0, 36 | "auto_restart_after_ota":False, 37 | "enable_push_to_talk_npv":False, 38 | "enable_push_to_talk_shelf":False, 39 | "error_messaging_no_network":True, 40 | "get_home_enabled":True, 41 | "hide_home_more_button":True, 42 | "long_press_settings_power_off_v2":True, 43 | "podcast_speed_change_enabled":True, 44 | "podcast_trailer_enabled":True, 45 | "queue_enabled":True, 46 | "tips_enabled":True, 47 | "tips_interaction_delay":4, 48 | "tips_on_demand_enabled":True, 49 | "tips_request_interval":900, 50 | "tips_show_time":20, 51 | "tips_startup_delay":600, 52 | "tips_track_change_delay":10, 53 | "tracklist_context_menu_enabled":True, 54 | "use_new_voice_ui":True, 55 | "use_relative_volume_control":True, 56 | "volume_control":True, 57 | 58 | # Can / will break stuff. Don't touch unless you know what you're doing. 59 | 'graphql_endpoint_enabled': True, 60 | 'graphql_for_shelf_enabled': True, 61 | 'use_superbird_namespace': True, 62 | 'use_volume_superbird_namespace': True, 63 | 'use_playerstate_superbird_namespace': True 64 | } 65 | } 66 | 67 | # JSON sent when homescreen is requested 68 | def get_graphql_homescreen(): 69 | rand = str(random.random()) 70 | out = { 71 | 'data':{ 72 | 'shelf':{ 73 | 'items':[ 74 | { 75 | 'title':'Home', 76 | 'id':'featured', 77 | 'total':1, 78 | 'children':[ 79 | { 80 | 'uri':'spotify:user:fake:collection', 81 | 'title':'CarThingHax', 82 | 'subtitle':'Home card', 83 | 'image_id':"carthinghax_logo" 84 | } 85 | ] 86 | }, 87 | { 88 | 'title':'Voice', # Used for voice results 89 | 'id':'voice', 90 | 'total':1, 91 | 'children':[ 92 | { 93 | 'uri':'spotify:user:fake:collection', 94 | 'title':'CarThingHax', 95 | 'subtitle':'Voice result card', 96 | 'image_id':'carthinghax_logo' 97 | } 98 | ] 99 | }, 100 | { 101 | 'title':'Playlists', 102 | 'id':'playlists', 103 | 'total':1, 104 | 'children':[ 105 | { 106 | 'uri':'spotify:user:fake:collection', 107 | 'title':'CarThingHax', 108 | 'subtitle':'Playlist Card', 109 | 'image_id':'carthinghax_logo' 110 | } 111 | ] 112 | }, 113 | { 114 | 'title':'Podcasts', 115 | 'id':'podcasts', 116 | 'total':1, 117 | 'children':[ 118 | { 119 | 'uri':'spotify:playlist:fake', 120 | 'title':'CarThingHax', 121 | 'subtitle':'Podcast Card', 122 | 'image_id':'carthinghax_logo' 123 | } 124 | ] 125 | }, 126 | { 127 | 'title':'Artists', 128 | 'id':'artists', 129 | 'total':1, 130 | 'children':[ 131 | { 132 | 'uri':'spotify:artist:fake', 133 | 'title':'CarThingHax', 134 | 'subtitle':'Artist Card', 135 | 'image_id':'carthinghax_logo' 136 | } 137 | ] 138 | }, 139 | { 140 | 'title':'Albums', 141 | 'id':'albums', 142 | 'total':1, 143 | 'children':[ 144 | { 145 | 'uri':'spotify:album:fake', 146 | 'title':'CarThingHax', 147 | 'subtitle':'Album Card', 148 | 'image_id':'carthinghax_logo' 149 | } 150 | ] 151 | }, 152 | { 153 | 'title':'Devices', 154 | 'id':'devices', 155 | 'total':1, 156 | 'children':[ 157 | { 158 | 'uri':'spotify:user:CONNECTOR:collection:DEVICE_SEL:::' + rand, # Trick Superbird into refreshing the menu every time home is refreshed and hiding the save button 159 | 'title':'Devices', 160 | 'subtitle':'Spotify Connect', 161 | 'image_id':'carthinghax_logo' 162 | } 163 | ] 164 | } 165 | ] 166 | } 167 | } 168 | } 169 | return out 170 | 171 | get_presets_resp = { 172 | 'data':{ 173 | 'presets':{ 174 | 'presets':[ 175 | { 176 | 'context_uri':'spotify:playlist:none', 177 | 'name':'Preset 1', 178 | 'slot_index':1, 179 | 'description':'Preset 1 desc.', 180 | 'image_url':'carthinghax_logo' 181 | }, 182 | { 183 | 'context_uri':'spotify:playlist:none', 184 | 'name':'Preset 2', 185 | 'slot_index':2, 186 | 'description':'Preset 2 desc.', 187 | 'image_url':'carthinghax_logo' 188 | }, 189 | { 190 | 'context_uri':'spotify:playlist:none', 191 | 'name':'Preset 3', 192 | 'slot_index':3, 193 | 'description':'Preset 3 desc.', 194 | 'image_url':'carthinghax_logo' 195 | }, 196 | { 197 | 'context_uri':'spotify:playlist:none', 198 | 'name':'Preset 4', 199 | 'slot_index':4, 200 | 'description':'Preset 4 desc.', 201 | 'image_url':'carthinghax_logo' 202 | } 203 | ] 204 | } 205 | } 206 | } 207 | 208 | get_children_resp = { 209 | 'limit':10000, 210 | 'offset':0, 211 | 'total':1, 212 | 'items':[ 213 | { 214 | 'id':'spotify:track:aaaaaaaaaaaaaaaaaaaaaa', # Needs to be valid Spotify URI (format is spotify::<22 characters>) 215 | 'uri':'spotify:track:aaaaaaaaaaaaaaaaaaaaaa', # Needs to be valid Spotify URI (format is spotify::<22 characters>) 216 | 'image_id':'carthinghax_logo', 217 | 'title':'CarThingHax', 218 | 'subtitle':'Child of item', 219 | 'playable':True, 220 | 'has_children':False, 221 | 'available_offline':False, 222 | 'metadata':{ 223 | 'is_explicit_content':False, 224 | 'is_19_plus_content':False, 225 | 'duration_ms':160000 226 | } 227 | } 228 | ] 229 | } 230 | 231 | 232 | # Superbird has 2 different ways of getting the home screen, with graphql 233 | # or com.spotify.superbird.get_home. The get_home function is almost never used 234 | # so we just put a placeholder incase it happens to be used. 235 | old_homescreen = { 236 | 'items':[ 237 | { 238 | 'uri':'spotify:space_item:superbird:superbird-featured', 239 | 'title':'Home', 240 | 'total':2, 241 | 'children':[ 242 | { 243 | 'uri':'spotify:user:fake:collection', 244 | 'title':'Close and open', 245 | 'subtitle':'the home screen', 246 | 'image_id':"old_home" 247 | }, 248 | { 249 | 'uri':'spotify:user:fake:collection', 250 | 'title':'to refresh.', 251 | 'subtitle':'', 252 | 'image_id':"old_home" 253 | } 254 | ] 255 | } 256 | ] 257 | } 258 | 259 | ### Everything below this line is unused in normal operation but kept for reference ### 260 | 261 | example_play_queue = { 262 | 'next':[ 263 | { 264 | 'uid':'null', 265 | 'uri':'spotify:track:baaaaaaaaaaaaaaaaaaaaa', 266 | 'name':'song 2 title', 267 | 'artists':[ 268 | 269 | ], 270 | 'image_uri':'spotify:image:carthinghax_logo', 271 | 'provider':'context' 272 | }, 273 | { 274 | 'uid':'null', 275 | 'uri':'spotify:track:caaaaaaaaaaaaaaaaaaaaa', 276 | 'name':'song 3 title', 277 | 'artists':[ 278 | { 279 | "name":"test", 280 | "uri":"spotify:artist:aaaaaaaaaaaaaaaaaaaaaa" 281 | }, 282 | { 283 | "name":"test 2", 284 | "uri":"spotify:artist:aaaaaaaaaaaaaaaaaaaaaa" 285 | } 286 | ], 287 | 'image_uri':'spotify:image:carthinghax_logo', 288 | 'provider':'context' 289 | } 290 | ] , 291 | 'current':{ 292 | 'uid':'null', 293 | 'uri':'spotify:track:aaaaaaaaaaaaaaaaaaaaab', 294 | 'name':'Superbird connector', 295 | 'artists':[ 296 | 297 | ], 298 | 'image_uri':'carthinghax_logo', 299 | 'provider':'context' 300 | }, 301 | 'previous':[ 302 | 303 | ] 304 | } 305 | 306 | # Player that shows up when normally playing music from Spotify 307 | example_player_state_msg = { 308 | "context_uri":"spotify:user:aaaaaaaaaaaaaaaaaaaaaa:collection", 309 | "is_paused":False, 310 | "is_paused_bool":False, 311 | "playback_options":{ 312 | "repeat":0, 313 | "shuffle":False 314 | }, 315 | "playback_position":3000, 316 | "playback_restrictions":{ 317 | "can_repeat_context":True, 318 | "can_repeat_track":True, 319 | "can_seek":True, 320 | "can_skip_next":True, 321 | "can_skip_prev":True, 322 | "can_toggle_shuffle":True 323 | }, 324 | "playback_speed":0, # Playback speed multiplier (for progress bar). 1 = realtime 325 | "track":{ 326 | "album":{ 327 | "name":"album name", 328 | "uri":"spotify:album:aaaaaaaaaaaaaaaaaaaaaa" 329 | }, 330 | "artist":{ 331 | "name":"is running!", 332 | "uri":"spotify:artist:aaaaaaaaaaaaaaaaaaaaaa" 333 | }, 334 | "artists":[ # Used for multiple artists, 2 for example. First artist should be same as above 335 | { 336 | "name":"is running!", 337 | "uri":"spotify:artist:aaaaaaaaaaaaaaaaaaaaaa" 338 | }, 339 | { 340 | "name":"artist 2", 341 | "uri":"spotify:artist:aaaaaaaaaaaaaaaaaaaaaa" 342 | } 343 | ], 344 | "duration_ms":6000, 345 | "image_id":"carthinghax_logo", # Rarely used, keep the same as play_queue just in case 346 | "is_episode":False, 347 | "is_podcast":False, 348 | "name": "Superbird connector", 349 | "saved": True, 350 | 'uid':'null', 351 | "uri":"spotify:track:aaaaaaaaaaaaaaaaaaaaab" 352 | } 353 | } 354 | 355 | # Simple player that would show up when playing music from another app 356 | example_player_state_simple = { 357 | "currently_active_application":{ 358 | "id":"com.example", 359 | "name":"Example app" 360 | }, 361 | "context_uri":"spotify:context:fake", 362 | "context_title":"context", 363 | "is_paused":False, 364 | "is_paused_bool":False, 365 | "playback_options":{ 366 | "repeat":0, 367 | "shuffle":False 368 | }, 369 | "playback_position":2500, 370 | "playback_restrictions":{ 371 | "can_repeat_context":True, 372 | "can_repeat_track":True, 373 | "can_seek":True, 374 | "can_skip_next":True, 375 | "can_skip_prev":True, 376 | "can_toggle_shuffle":True 377 | }, 378 | "playback_speed":0, 379 | "track":{ 380 | "album":{ 381 | "name":"album name", 382 | "uri":"spotify:album:fake" 383 | }, 384 | "artist":{ 385 | "name":"is running!", 386 | "uri":"spotify:artist:fake" 387 | }, 388 | "artists":[ # Used for multiple artists, 2 for example. First artist should be same as above 389 | { 390 | "name":"artist name", 391 | "uri":"spotify:artist:fake" 392 | }, 393 | { 394 | "name":"artist 2", 395 | "uri":"spotify:artist:fake" 396 | } 397 | ], 398 | "duration_ms":5000, 399 | "image_id":"carthinghax_logo", 400 | #"image_bytes": "image_bytes", # The current album art sent as a bytearray. Format should be png. If excluded, get_image is used instead 401 | "is_episode":False, 402 | "is_podcast":False, 403 | "name": "Superbird connector", 404 | "saved":False, 405 | "uid":"fake", 406 | "uri":"spotify:track:fake" 407 | } 408 | } 409 | 410 | # Idle (No Media) 411 | example_player_idle = { 412 | "context_uri":"", 413 | "is_paused":False, 414 | "is_paused_bool":False, 415 | "playback_options":{ 416 | "repeat":0, 417 | "shuffle":False 418 | }, 419 | "playback_position":0, 420 | "playback_restrictions":{ 421 | "can_repeat_context":True, 422 | "can_repeat_track":True, 423 | "can_seek":False, 424 | "can_skip_next":False, 425 | "can_skip_prev":False, 426 | "can_toggle_shuffle":True 427 | }, 428 | "playback_speed":0 429 | } 430 | -------------------------------------------------------------------------------- /common/sb_common.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | # Common functions or variables that are used here and there 3 | 4 | # Session json: Empty at first, filled by wamp_handler.hello_handler 5 | superbird_session = {} 6 | 7 | # WAMP opCodes 8 | class opCodes(Enum): 9 | HELLO = 1 10 | WELCOME = 2 11 | ABORT = 3 12 | CHALLENGE = 4 13 | AUTHENTICATE = 5 14 | GOODBYE = 6 15 | ERROR = 8 16 | PUBLISH = 16 17 | PUBLISHED = 17 18 | SUBSCRIBE = 32 19 | SUBSCRIBED = 33 20 | UNSUBSCRIBE = 34 21 | UNSUBSCRIBED = 35 22 | EVENT = 36 23 | CALL = 48 24 | CANCEL = 49 25 | RESULT = 50 26 | REGISTER = 64 27 | REGISTERED = 65 28 | UNREGISTER = 66 29 | UNREGISTERED = 67 30 | INVOCATION = 68 31 | INTERRUPT = 69 32 | YIELD = 70 -------------------------------------------------------------------------------- /notes.md: -------------------------------------------------------------------------------- 1 | # To-do and other notes 2 | 3 | ## Hey Spotify 4 | When Superbird detects "Hey Spotify", it starts streaming microphone audio to the phone in ogg format in .5 second chunks. Currently, connector just writes the audio to a .ogg file. 5 | 6 | Need to implement transcript response from the Spotify app. Looks like this:\ 7 | In this example, I said "testing". 8 | ``` 9 | { 10 | 'session_id':'2024-06-01T08_47_34.893Z.ogg', 11 | 'utterance_id':'fe83181a-8c50-4880-b6a9-e60f8b1470f8', 12 | 'message':'AsrResponse', 13 | 'asr':{ 14 | 'transcript':'testing', 15 | 'isFinal':True, 16 | 'isEndOfSpeech':True, 17 | 'score':0.8085283041000366 18 | } 19 | } 20 | ``` 21 | This is sent as an EVENT to `com.spotify.superbird.voice.session_updates`\ 22 | `session_id` is the .ogg filename in microphone data packets\ 23 | `transcript` can be a partial or final transcript\ 24 | When speech-to-text is done, `isFinal` and `isEndOfSpeech` get set to True and Superbird displays the transcript.\ 25 | I'm currently not 100% how search results are added to the home screen, but it's likely with the message below. 26 | 27 | When Spotify starts speaking, it sends `{'state': 'STARTED'}` as an EVENT to `com.spotify.superbird.tts.state`. 28 | 29 | When results are ready, Spotify sends following message as an EVENT to `com.spotify.superbird.voice.session_updates` 30 | ``` 31 | { 32 | 'session_id':'2024-06-01T09_04_33.963Z.ogg', 33 | 'utterance_id':'3d4e3dc9-e0aa-42c4-9b84-e5203fb17c05', 34 | 'message':'NluResponse', 35 | 'nlu':{ 36 | 'body':[ 37 | { 38 | 'text':{ 39 | 'title':'52 Hearts', 40 | 'subtitle':'Bao The Whale' 41 | }, 42 | 'images':{ 43 | 'main':{ 44 | 'uri':'spotify:image:ab67616d00001e02d9508aea9edad0274d1b37bd' 45 | } 46 | }, 47 | 'target':{ 48 | 'uri':'spotify:track:7MGLRs9ZsPiOHSY3zMIjhm' 49 | }, 50 | 'custom':{ 51 | 'albumReleaseDate':'2020-11-08', 52 | 'popularity':41 53 | } 54 | }, 55 | { 56 | 'text':{ 57 | 'title':'rare animal', 58 | 'subtitle':'glass beach' 59 | }, 60 | 'images':{ 61 | 'main':{ 62 | 'uri':'spotify:image:ab67616d00001e0237295d61bcd9fc7fa2fe33c9' 63 | } 64 | }, 65 | 'target':{ 66 | 'uri':'spotify:track:0KCbWi4LF8rpY8U93T1Mwu' 67 | }, 68 | 'custom':{ 69 | 'albumReleaseDate':'2024-01-19', 70 | 'popularity':40 71 | } 72 | }, 73 | { 74 | 'text':{ 75 | 'title':'Silhouette (feat. Milk Talk)', 76 | 'subtitle':'Moe Shop, Milk Talk' 77 | }, 78 | 'images':{ 79 | 'main':{ 80 | 'uri':'spotify:image:ab67616d00001e02f43d59dd385e10e9d393a89c' 81 | } 82 | }, 83 | 'target':{ 84 | 'uri':'spotify:track:3geVGPVgAH9SNk0YBG6Y3E' 85 | }, 86 | 'custom':{ 87 | 'albumReleaseDate':'2023-09-22', 88 | 'popularity':34 89 | } 90 | } 91 | { 92 | 'text':{ 93 | 'title':'classic j dies and goes to hell part 1', 94 | 'subtitle':'glass beach' 95 | }, 96 | 'images':{ 97 | 'main':{ 98 | 'uri':'spotify:image:ab67616d00001e02382ddf73e0132cecf399c718' 99 | } 100 | }, 101 | 'target':{ 102 | 'uri':'spotify:track:3ezuOjWuTirncJITAb8ahf' 103 | }, 104 | 'custom':{ 105 | 'albumReleaseDate':'2019-05-18', 106 | 'popularity':45 107 | } 108 | }, 109 | { 110 | 'text':{ 111 | 'title':'Revive', 112 | 'subtitle':'LIONE' 113 | }, 114 | 'images':{ 115 | 'main':{ 116 | 'uri':'spotify:image:ab67616d00001e0295970b868b8c32728ff95fd4' 117 | } 118 | }, 119 | 'target':{ 120 | 'uri':'spotify:track:4Bb53fsDAero14LpAbsmft' 121 | }, 122 | 'custom':{ 123 | 'albumReleaseDate':'2019-10-24', 124 | 'popularity':40 125 | } 126 | }, 127 | { 128 | 'text':{ 129 | 'title':'Fantasy', 130 | 'subtitle':'Moe Shop, MONICO' 131 | }, 132 | 'images':{ 133 | 'main':{ 134 | 'uri':'spotify:image:ab67616d00001e025fcd8144ff007592c8018cd7' 135 | } 136 | }, 137 | 'target':{ 138 | 'uri':'spotify:track:1awjNR40wYscCumpP4zFVM' 139 | }, 140 | 'custom':{ 141 | 'albumReleaseDate':'2018-03-15', 142 | 'popularity':33 143 | } 144 | }, 145 | { 146 | 'text':{ 147 | 'title':'Citrus Love', 148 | 'subtitle':'Bao The Whale, Overspace' 149 | }, 150 | 'images':{ 151 | 'main':{ 152 | 'uri':'spotify:image:ab67616d00001e027827895c48fd8598a3507494' 153 | } 154 | }, 155 | 'target':{ 156 | 'uri':'spotify:track:3RiYi67LMqBpopK1b1D0fb' 157 | }, 158 | 'custom':{ 159 | 'albumReleaseDate':'2023-04-15', 160 | 'popularity':44 161 | } 162 | }, 163 | { 164 | 'text':{ 165 | 'title':'You Can Give Spaghetti to a Rat', 166 | 'subtitle':'Classic J' 167 | }, 168 | 'images':{ 169 | 'main':{ 170 | 'uri':'spotify:image:ab67616d00001e02e770277875c6eebda14019bd' 171 | } 172 | }, 173 | 'target':{ 174 | 'uri':'spotify:track:1h63tRjR186VhlXehT6P44' 175 | }, 176 | 'custom':{ 177 | 'albumReleaseDate':'2020-03-20', 178 | 'popularity':11 179 | } 180 | }, 181 | { 182 | 'text':{ 183 | 'title':'gemini', 184 | 'subtitle':"Snail's House" 185 | }, 186 | 'images':{ 187 | 'main':{ 188 | 'uri':'spotify:image:ab67616d00001e02330cfe664d55a588e96e27f2' 189 | } 190 | }, 191 | 'target':{ 192 | 'uri':'spotify:track:3CHpLb1IXma99y3brtXfco' 193 | }, 194 | 'custom':{ 195 | 'albumReleaseDate':'2023-02-03', 196 | 'popularity':29 197 | } 198 | } 199 | ], 200 | 'custom':{ 201 | 'ttsPrompt':'Sure, 52 Hearts plus other search results.', 202 | 'content_id':'spotify:space_item:superbird:superbird-voice', 203 | 'spotify_active':True, 204 | 'ttsUrl': 'Omitted just in case. Spotify does TTS on their servers using ReadSpeaker and this url points to the TTS mp3', 205 | 'query':'testing', 206 | 'action':'SHOW_TRACK', 207 | 'intent':'SHOW', 208 | 'connect_action_taken':False 209 | } 210 | } 211 | } 212 | ``` 213 | After it's done speaking, it sends `{'state': 'FINISHED'}` as an event to `com.spotify.superbird.voice.session_updates` 214 | 215 | Sometimes the app will send other EVENTs like this: (In this example, I said 'save this song') 216 | ``` 217 | { 218 | 'session_id':'2024-06-07T05_32_09.977Z.ogg', 219 | 'utterance_id':'3366b181-3147-46d9-ab6b-bbf87ac1fe1c', 220 | 'message':'NluResponse', 221 | 'nlu':{ 222 | 'body':[ 223 | 224 | ], 225 | 'custom':{ 226 | 'slots':{ 227 | 'requestedEntityType':[ 228 | 'song' 229 | ] 230 | }, 231 | 'ttsPrompt':'Saved', 232 | 'content_id':'spotify:space_item:superbird:superbird-voice', 233 | 'spotify_active':True, 234 | 'ttsUrl':'Omitted just in case. Spotify does TTS on their servers using ReadSpeaker and this url points to the TTS mp3', 235 | 'query':'save this song', 236 | 'action':'SAVE_TO_COLLECTION_TRACK', 237 | 'intent':'ADD_TO_COLLECTION', 238 | 'connect_action_taken':False 239 | } 240 | } 241 | } 242 | ``` 243 | Intent and action handler code can be found here: [https://github.com/Merlin04/superbird-webapp/tree/modded/component/VoiceConfirmation](https://github.com/Merlin04/superbird-webapp/tree/modded/component/VoiceConfirmation) 244 | 245 | ## Potential future features 246 | - Phone calls - On iOS, phone calls are handled through iAP (Apple accessory protocol) but there might be a way to trigger the call screen from Connector 247 | https://github.com/Merlin04/superbird-webapp/blob/5781976ec7fb56aceeedc7c4bcb7c83b70067636/store/AndroidPhoneCallStore.ts#L41 248 | -------------------------------------------------------------------------------- /resources/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thinglabsoss/unnamed-superbird-connector/e3747a6475193f06d4851905916e4fff85d62fcd/resources/logo.jpg -------------------------------------------------------------------------------- /resources/oldhome.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thinglabsoss/unnamed-superbird-connector/e3747a6475193f06d4851905916e4fff85d62fcd/resources/oldhome.jpg -------------------------------------------------------------------------------- /superbird-server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import umsgpack 3 | import time 4 | import threading 5 | import traceback 6 | import common.sb_common as sb_c 7 | import utils.handlers.bt_handler as bt_handler 8 | import utils.wamp.wamp_handler as wamp_h 9 | import utils.handlers.pubsub_handler as pubsub_handler 10 | import utils.bt_utils as bt_utils 11 | 12 | # Un-MessagePack messages and send them to their respective handlers 13 | def processMsg(data: bytearray): 14 | try: 15 | send = False 16 | with_event = False 17 | msg = umsgpack.unpackb(data) 18 | msg_opcode = sb_c.opCodes(msg[0]) 19 | match msg_opcode: 20 | case sb_c.opCodes.HELLO: # More info in superbird_util.py 21 | send, resp, with_event, event = wamp_h.hello_handler(msg) 22 | case sb_c.opCodes.AUTHENTICATE: 23 | send, resp, with_event, event = wamp_h.authenticate_handler() 24 | case sb_c.opCodes.CALL: # More info in superbird_util.py 25 | send, resp, with_event, event = wamp_h.function_handler(msg) 26 | case sb_c.opCodes.SUBSCRIBE: # More info in superbird_util.py 27 | send, resp, with_event, event = wamp_h.subscribe_handler(msg) 28 | case sb_c.opCodes.UNSUBSCRIBE: 29 | send, resp, with_event, event = wamp_h.subscribe_handler(msg, True) 30 | case _: 31 | print("Unhandled opcode:", msg_opcode, " Msg:", msg) 32 | if send: 33 | bt_handler.addToOutbox(resp) 34 | if with_event: 35 | pubsub_handler.update_status() 36 | # if with_event: # Sometimes we also need to send an event # Might not be needed 37 | # bt_handler.addToOutbox(event) 38 | except Exception: 39 | print("\n\n~~~~~ Exception Start ~~~~~") 40 | traceback.print_exc() 41 | print("~~~~~ Exception End ~~~~~\n") 42 | pass 43 | 44 | def resetVars(): 45 | wamp_h.last_subscription = 63 46 | sb_c.superbird_session = {} 47 | pubsub_handler.pub_id = 1 48 | 49 | connected = False 50 | registered = False 51 | stopThreads = threading.Event() 52 | while True: 53 | if not connected: 54 | # We advertise the RFCOMM service that Superbird is expecting and accept any connections to it 55 | server_sock = bt_utils.open_socket() 56 | time.sleep(1) 57 | while not registered: # We just need to register the service once 58 | bt_utils.register_sdp() 59 | print("Waiting for connection on RFCOMM channel", bt_utils.port) 60 | registered = True 61 | client_sock, client_info = server_sock.accept() 62 | print("Connected") 63 | outbox_thread = threading.Thread(target=bt_handler.outboxThread, args=(client_sock, stopThreads,), daemon=True) 64 | sub_handler_thread = threading.Thread(target=pubsub_handler.subHandlerThread, args=(stopThreads,), daemon=True) 65 | # Start outbox thread 66 | outbox_thread.start() 67 | # Start subscription handler thread 68 | sub_handler_thread.start() 69 | connected = True 70 | 71 | if connected: 72 | try: 73 | while True: 74 | data = bt_handler.get_msg(client_sock) 75 | processMsg(data) 76 | if not data: 77 | break 78 | except KeyboardInterrupt: 79 | pass 80 | except Exception: 81 | print("\n\n~~~~~ Exception Start ~~~~~") 82 | traceback.print_exc() 83 | print("~~~~~ Exception End ~~~~~\n") 84 | pass 85 | client_sock.close() 86 | stopThreads.set() 87 | time.sleep(2) #allow time for threads to stop 88 | server_sock.close() 89 | client_sock.close() 90 | connected = False 91 | resetVars() 92 | print("\n\nDisconnected. Reopening socket in 5s. Press Ctrl + C to stop") 93 | try: 94 | time.sleep(5) 95 | stopThreads.clear() 96 | except KeyboardInterrupt: 97 | print("Quitting...") 98 | raise SystemExit(0) -------------------------------------------------------------------------------- /superbird_secrets-template.py: -------------------------------------------------------------------------------- 1 | # Secrets such as API keys 2 | 3 | spotify_client_id = "CLIENT_ID_HERE" 4 | spotify_client_secret = "CLIENT_SECRET_HERE" 5 | spotify_redir_uri = "http://127.0.0.1:9696/connector-auth" -------------------------------------------------------------------------------- /utils/bt_utils.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | p = platform.system() 4 | 5 | def open_socket(): 6 | match p: 7 | case "Linux": 8 | return(open_socket_linux()) 9 | case _: 10 | raise Exception('OS not supported') 11 | 12 | def register_sdp(): 13 | match p: 14 | case "Linux": 15 | return(register_sdp_linux()) 16 | case _: 17 | raise Exception('OS not supported') 18 | 19 | 20 | 21 | service_record = """ 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | """ 64 | port = 5 65 | def open_socket_linux(): 66 | import socket 67 | s = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM) 68 | s.bind((socket.BDADDR_ANY, port)) 69 | s.listen(1) 70 | print("Opened RFCOMM Socket (Linux)") 71 | return s 72 | 73 | def register_sdp_linux(): 74 | import dbus 75 | bus = dbus.SystemBus() 76 | manager = dbus.Interface(bus.get_object("org.bluez", "/org/bluez"), 77 | "org.bluez.ProfileManager1") 78 | manager.RegisterProfile("/bluez", 79 | "e3cccccd-33b7-457d-a03c-aa1c54bf617f", 80 | {"AutoConnect":True, "ServiceRecord":service_record}) 81 | print("Registered SDP") 82 | -------------------------------------------------------------------------------- /utils/handlers/bt_handler.py: -------------------------------------------------------------------------------- 1 | 2 | import struct 3 | import umsgpack 4 | import time 5 | import common.sb_common as sb_c 6 | # Bluetooth logic for sending, receiving, serializing, etc. 7 | 8 | # I keep running into issues with pubsub_handler and the main server talking over 9 | # so I'll have them add messages to an outbox instead 10 | outbox = [] 11 | 12 | # Messages to/from Superbird have a 4 byte length header, then the rest of the message is MessagePack 13 | # We read the first 4 bytes, convert that to an int then read again with the int as the size 14 | def get_msg(sock): 15 | len_bytes = get_msg_with_len(sock, 4) 16 | if not len_bytes: 17 | return None 18 | len = struct.unpack('>I', len_bytes)[0] 19 | return get_msg_with_len(sock, len) 20 | 21 | def get_msg_with_len(sock, n): 22 | data = bytearray() 23 | while len(data) < n: 24 | packet = sock.recv(n - len(data)) 25 | if not packet: 26 | return None 27 | data.extend(packet) 28 | return data 29 | 30 | # Sometimes messages are too big so we chop em up and send across multiple chunks 31 | def send_chunks(data, sock, chunk_size): 32 | for i in range(0, len(data), chunk_size): 33 | chunk = data[i:i+chunk_size] 34 | sock.send(chunk) 35 | time.sleep(.01) 36 | 37 | # Pack messages into MessagePack format and send them to Superbird 38 | def sendFromOutbox(client_sock): 39 | data = umsgpack.packb(outbox.pop()) 40 | data_len = struct.pack('>I', len(data)) 41 | data = data_len + data 42 | if len(data) >= 990: 43 | send_chunks(data, client_sock, 990) 44 | else: 45 | client_sock.send(data) 46 | 47 | # Add messages to the outbox 48 | def addToOutbox(data_in): outbox.insert(0, data_in) 49 | 50 | def outboxThread(sock, threadStop): 51 | while not threadStop.is_set(): 52 | if outbox: 53 | sendFromOutbox(sock) 54 | print("Outbox thread stopped") -------------------------------------------------------------------------------- /utils/handlers/graphql_handler.py: -------------------------------------------------------------------------------- 1 | import common.messages as sb_msgs 2 | import utils.remote_api as remote_api 3 | def graphql_resp(func_argskw): 4 | if "tipsOnDemand" in str(func_argskw): 5 | print("Tips requested") 6 | # You can send multiple tips but it only wants to show the first one unless you spam next 7 | payload = {'data': {'tipsOnDemand': {'tips': [{'id': 1, 'title': ':3', 'description': 'Hello CarThingHax!'}]}}} 8 | elif "query{shelf" in str(func_argskw): 9 | print("Home screen requested") 10 | payload = sb_msgs.get_graphql_homescreen() 11 | elif "query{presets" in str(func_argskw): 12 | print("Presets requested") 13 | payload = sb_msgs.get_presets_resp 14 | else: 15 | payload = {} 16 | return payload -------------------------------------------------------------------------------- /utils/handlers/pubsub_handler.py: -------------------------------------------------------------------------------- 1 | import time 2 | import traceback 3 | import utils.wamp.wamp_builder as wamp_b 4 | import common.sb_common as sb_c 5 | import utils.handlers.bt_handler as bt_handler 6 | import utils.remote_api as remote_api 7 | 8 | # Goes through subscriptions and sends EVENTS as needed 9 | quiet = True 10 | print_real = print 11 | def print(input): 12 | if not quiet: 13 | print_real(input) 14 | 15 | pub_id = 0 16 | def subHandlerThread(threadStop): 17 | global pub_id 18 | session = sb_c.superbird_session 19 | print("Sub thread spawned!") 20 | while "serial" not in session.keys(): 21 | session = sb_c.superbird_session 22 | print("waiting") 23 | time.sleep(1) 24 | print("Sub: Session json found") 25 | while not threadStop.is_set(): 26 | try: 27 | update_status() 28 | session = sb_c.superbird_session 29 | for sub_name, sub_info in session['subscriptions'].items(): 30 | sendSubMsg(sub_name, sub_info) 31 | #print(sub_name) 32 | #print(sub_info) 33 | time.sleep(1) # Don't remove unless you want to eat through your Spotify API quota 34 | except Exception: 35 | print_real("\n\n~~~~~ Exception Start ~~~~~") 36 | print_real(traceback.format_exc()) 37 | print_real("~~~~~ Exception End ~~~~~\n") 38 | print_real("PubSub thread stopped") 39 | 40 | 41 | def update_status(): 42 | global pub_id 43 | session = sb_c.superbird_session 44 | try: 45 | pstate = remote_api.get_player_state() 46 | np_queue = remote_api.get_queue() 47 | context = remote_api.get_context() 48 | if "com.spotify.superbird.player_state" in session['subscriptions']: # Different fw versions sub to different state events? 49 | print("Sub: Send player state") 50 | pub_id += 1 51 | info = wamp_b.build_wamp_event(session['subscriptions']['com.spotify.superbird.player_state']['sub_id'], pub_id, pstate) 52 | bt_handler.addToOutbox(info) 53 | 54 | if "com.spotify.player_state" in session['subscriptions']: # Different fw versions sub to different state events? 55 | print("Sub: Send player state") 56 | pub_id += 1 57 | info = wamp_b.build_wamp_event(session['subscriptions']['com.spotify.player_state']['sub_id'], pub_id, pstate) 58 | bt_handler.addToOutbox(info) 59 | 60 | if "com.spotify.play_queue" in session['subscriptions']: 61 | print("Sub: Send queue") 62 | pub_id += 1 63 | info = wamp_b.build_wamp_event(session['subscriptions']['com.spotify.play_queue']['sub_id'], pub_id, np_queue) 64 | bt_handler.addToOutbox(info) 65 | 66 | if "com.spotify.current_context" in session['subscriptions']: 67 | print("Sub: Send context") 68 | pub_id += 1 69 | info = wamp_b.build_wamp_event(session['subscriptions']['com.spotify.current_context']['sub_id'], pub_id, context) 70 | bt_handler.addToOutbox(info) 71 | 72 | except Exception: 73 | print_real(traceback.format_exc()) 74 | 75 | # These were only seen once in packet captures 76 | sessionOnce = False 77 | statusOnce = False 78 | updateOnce = False 79 | def sendSubMsg(sub_name, sub_info): 80 | global pub_id, sessionOnce, statusOnce, updateOnce 81 | session = sb_c.superbird_session 82 | match sub_name: 83 | case "com.spotify.session_state": 84 | if not sessionOnce: 85 | print("Sub: Send session info") 86 | pub_id += 1 87 | info = wamp_b.build_wamp_event(sub_info['sub_id'], pub_id, {'is_offline': False, 'is_in_forced_offline_mode': False, 'is_logged_in': True, 'connection_type': 'wlan'}) 88 | bt_handler.addToOutbox(info) 89 | sessionOnce = True 90 | 91 | case "com.spotify.status": 92 | if not statusOnce: 93 | print("Sub: Send status") 94 | pub_id += 1 95 | info = wamp_b.build_wamp_event(sub_info['sub_id'], pub_id, {'code': 0, 'short_text': '', 'long_text': ''}) 96 | bt_handler.addToOutbox(info) 97 | statusOnce = True 98 | 99 | # When car mode is not an empty string, Superbird will show "Phone volume unavailable with " 100 | # when trying to change the volume and will not send volume events 101 | # can be anything. It'll be displayed on the screen when showing the above error 102 | case "com.spotify.superbird.car_mode": 103 | print("Sub: Send car mode") 104 | pub_id += 1 105 | if remote_api.canUseVolume(): 106 | info = wamp_b.build_wamp_event(sub_info['sub_id'], pub_id, {'mode': ''}) 107 | else: 108 | info = wamp_b.build_wamp_event(sub_info['sub_id'], pub_id, {'mode': 'current device.'}) 109 | bt_handler.addToOutbox(info) 110 | 111 | case "com.spotify.superbird.volume.volume_state": 112 | print("Sub: Send volume") 113 | pub_id += 1 114 | info = wamp_b.build_wamp_event(sub_info['sub_id'], pub_id, {'volume': int(session['vol'])/100, 'volume_steps': 25}) 115 | bt_handler.addToOutbox(info) 116 | 117 | case "com.spotify.superbird.ota.package_state": 118 | if session["ota_ready"] & (not updateOnce): 119 | print("Sub: Send OTA state") 120 | pub_id += 1 121 | ota_json = { 122 | 'state':'download_success', 123 | 'name':'superbird-os', 124 | 'version':'0.0.0', # Whatever the latest fw is 125 | 'hash':'MD5_HASH', 126 | 'size':0 127 | } 128 | ota_state = wamp_b.build_wamp_event(sub_info['sub_id'], pub_id, ota_json) 129 | bt_handler.addToOutbox(ota_state) 130 | updateOnce = True 131 | session['ota_active'] = True -------------------------------------------------------------------------------- /utils/handlers/update_handler.py: -------------------------------------------------------------------------------- 1 | import common.sb_common as sb_c 2 | 3 | ota_available_json = { 4 | 'result':[ 5 | { 6 | 'version':'NEW_VER', # Whatever the SWU file updates to 7 | 'name':'superbird-os', 8 | 'hash':'MD5_OF_SWU', # MD5 hash of SWU file 9 | 'url':'http://example.com/swu', # URL of SWU file, isn't actually used 10 | 'critical':False, # If true, Superbird will refuse to do anything until the update is done 11 | 'size_bytes': 0, # Size of SWU file 12 | 'auto_updatable':True 13 | } 14 | ] 15 | } 16 | 17 | def check(json_in): # Put OTA check logic here at some point, for now we hardcode 18 | try: 19 | session = sb_c.superbird_session 20 | update_available = False 21 | if update_available: 22 | print("Update available") 23 | session["ota_ready"] = True 24 | return ota_available_json 25 | else: 26 | print("No update available") 27 | return {} 28 | except: 29 | return {} 30 | 31 | def send_ota_chunk(json): # When there's an update available, Superbird will pull the update in chunks 32 | print(json) 33 | offset = json['offset'] 34 | size = json['size'] 35 | print("O: ", offset, "S: ", size) 36 | f = open("SWU_FILE", "rb") 37 | f.seek(offset, 0) 38 | chunk = f.read(size) 39 | print(len(chunk)) 40 | f.close() 41 | return {'data': chunk} -------------------------------------------------------------------------------- /utils/remote_api.py: -------------------------------------------------------------------------------- 1 | import spotipy 2 | from spotipy.oauth2 import SpotifyOAuth 3 | import superbird_secrets as s_secrets 4 | import traceback 5 | import utils.handlers.pubsub_handler as pubsub_handler 6 | sp = spotipy.Spotify(auth_manager=SpotifyOAuth(client_id=s_secrets.spotify_client_id, 7 | client_secret=s_secrets.spotify_client_secret, 8 | redirect_uri=s_secrets.spotify_redir_uri, 9 | scope="user-read-playback-state,user-modify-playback-state,user-library-read")) 10 | 11 | def action(act, arg = None): 12 | match act: 13 | case "pause": 14 | sp.pause_playback() 15 | case "play": 16 | sp.start_playback() 17 | case "next": 18 | sp.next_track() 19 | case "prev": 20 | sp.previous_track() 21 | case "shuffle": 22 | sp.shuffle(arg) 23 | case "save": 24 | pass 25 | #saveItem(arg) 26 | case "seek_to": 27 | sp.seek_track(round(arg)) 28 | case "select_device": 29 | sp.transfer_playback(arg) 30 | case "add_queue": 31 | sp.add_to_queue(arg) 32 | pubsub_handler.update_status() 33 | 34 | def get_queue(): 35 | out = { 36 | 'next':[], 37 | 'current':{ 38 | 'uid':'null', 39 | 'uri':'spotify:track:aaaaaaaaaaaaaaaaaaaaab', 40 | 'name':'Select a device or start playing music', 41 | 'artists':[ 42 | { 43 | "name":"", 44 | "uri":"spotify:artist:aaaaaaaaaaaaaaaaaaaaaa" 45 | }, 46 | ], 47 | 'image_uri':'carthinghax_logo', 48 | 'provider':'context' 49 | }, 50 | 'previous':[] # Not returned by API, will need to manually implement 51 | } 52 | try: 53 | if sp.queue()['currently_playing'] == None: 54 | return out 55 | api_current = sp.queue()['currently_playing'] 56 | api_queue = sp.queue()['queue'] 57 | current_artists = [] 58 | for i in api_current['artists']: 59 | current_artists.append({ 60 | 'name': i['name'], 61 | 'uri': i['uri'] 62 | }) 63 | 64 | queue_next = [] 65 | 66 | for i in api_queue: 67 | next_artists = [] 68 | for a in i['artists']: 69 | next_artists.append({ 70 | 'name': a['name'], 71 | 'uri': a['uri'] 72 | }) 73 | queue_next.append({ 74 | 'uid': i['id'], 75 | 'uri': i['uri'], 76 | 'name': i['name'], 77 | 'artists': next_artists, 78 | 'image_uri': i['album']['images'][0]['url'], 79 | }) 80 | out['next'] = queue_next 81 | out['current'] = { 82 | 'uid': api_current['id'], 83 | 'uri': api_current['uri'], 84 | 'name': api_current['name'], 85 | 'artists': current_artists, 86 | 'image_uri': api_current['album']['images'][0]['url'], 87 | 'provider': 'a' # ? 88 | } 89 | except Exception: 90 | print("Spotify API: Get queue failed") 91 | print(traceback.format_exc()) 92 | return out 93 | 94 | def get_player_state(): 95 | out = { 96 | "context_uri":"context_uri", 97 | 'context_title': 'Context Title', 98 | "is_paused":True, 99 | "is_paused_bool":True, 100 | "playback_options":{ 101 | "repeat":0, 102 | "shuffle":False 103 | }, 104 | "playback_position":3000, 105 | "playback_restrictions":{ 106 | "can_repeat_context":True, 107 | "can_repeat_track":True, 108 | "can_seek":True, 109 | "can_skip_next":True, 110 | "can_skip_prev":True, 111 | "can_toggle_shuffle":True 112 | }, 113 | "playback_speed":1, # Playback speed multiplier (for progress bar). 1 = realtime 114 | "track":{ 115 | "album":{}, 116 | "artist":{ 117 | "name":"", 118 | "uri":"spotify:artist:fake" 119 | }, 120 | "artists":[], 121 | "duration_ms":0, 122 | "image_id":"carthinghax_logo", # Rarely used, keep the same as play_queue just in case 123 | "is_episode":False, 124 | "is_podcast":False, 125 | "name": "Select a device or start playing music", 126 | "saved": False, 127 | 'uid':'null', 128 | "uri":"none" 129 | } 130 | } 131 | api_state = sp.current_playback() 132 | if api_state == None: 133 | return out 134 | try: 135 | out['context_uri'] = api_state['context']['uri'] 136 | out['context_title'] = getNameFromURI(api_state['context']['uri']) 137 | except: 138 | out['context_uri'] = 'spotify:none' 139 | out['context_title'] = "" 140 | out['is_paused'] = not api_state['is_playing'] 141 | out['is_paused_bool'] = not api_state['is_playing'] 142 | repeat = 0 143 | match api_state['repeat_state']: 144 | case "off": 145 | repeat = 0 146 | case "track": 147 | repeat = 1 148 | case "context": 149 | repeat = 2 150 | out['playback_options'] = { 151 | 'repeat': repeat, # API returns text for different states 152 | 'shuffle': api_state['shuffle_state'] 153 | } 154 | out['playback_position'] = api_state['progress_ms'] 155 | out['playback_restrictions'] = { 156 | # "can_repeat_context": not api_state['actions']['disallows']['toggling_repeat_context'], 157 | # "can_repeat_track": not api_state['actions']['disallows']['toggling_repeat_track'], 158 | "can_repeat_context": True, 159 | "can_repeat_track": True, 160 | "can_seek":True, 161 | "can_skip_next":True, 162 | "can_skip_prev":True, 163 | "can_toggle_shuffle": True 164 | # "can_toggle_shuffle": not api_state['actions']['disallows']['toggling_shuffle'] 165 | } 166 | out['track']['album'] = { 167 | "name":api_state['item']['album']['name'], 168 | "uri":api_state['item']['album']['uri'] 169 | } 170 | out['track']['artist'] = { 171 | "name":api_state['item']['artists'][0]['name'], 172 | "uri":api_state['item']['artists'][0]['uri'] 173 | } 174 | current_artists = [] 175 | for i in api_state['item']['artists']: 176 | current_artists.append({ 177 | 'name': i['name'], 178 | 'uri': i['uri'] 179 | }) 180 | out['track']['artists'] = current_artists 181 | out['track']['duration_ms'] = api_state['item']['duration_ms'] 182 | out['track']['image_id'] = api_state['item']['album']['images'][0]['url'] # Rarely used, keep the same as play_queue just in case 183 | out['track']['is_episode'] = False # API has 'currently_playing_type' 184 | out['track']['is_podcast'] = False # API has 'currently_playing_type' 185 | out['track']['name'] = api_state['item']['name'] 186 | out['track']['saved'] = sp.current_user_saved_tracks_contains([api_state['item']['uri']])[0] 187 | out['track']['uid'] = api_state['item']['uri'] # API doesn't return a UID 188 | out['track']['uri'] = api_state['item']['uri'] 189 | #print(out) 190 | return out 191 | 192 | def get_context(): 193 | curr_api = sp.current_playback() 194 | out = { 195 | 'id':'spotify:none', 196 | 'uri':'spotify:none', 197 | 'title':'Context Title', 198 | 'subtitle':'Context Subtitle', 199 | 'type':'playlist', 200 | 'repeat_track':False, 201 | 'repeat_context':False, 202 | 'shuffle':False, 203 | 'can_repeat_track':True, 204 | 'can_repeat_context':True, 205 | 'can_shuffle':True 206 | } 207 | if (curr_api == None) or (curr_api['context'] == None): 208 | return out 209 | out['title'] = getNameFromURI(curr_api['context']['uri']) 210 | out['id'] = curr_api['context']['uri'] 211 | out['uri'] = curr_api['context']['uri'] 212 | # out['title'] = curr_api['context']['title'] 213 | out['type'] = curr_api['context']['type'] 214 | out['shuffle'] = curr_api['shuffle_state'] 215 | return out 216 | 217 | 218 | { 219 | 'id':'spotify:playlist:0CryHan5NsIsI2vsKxwDqD', 220 | 'uri':'spotify:playlist:0CryHan5NsIsI2vsKxwDqD', 221 | 'title':'Low energy', 222 | 'subtitle':'Playing from Playlist', 223 | 'type':'playlist', 224 | 'repeat_track':False, 225 | 'repeat_context':False, 226 | 'shuffle':False, 227 | 'can_repeat_track':True, 228 | 'can_repeat_context':True, 229 | 'can_shuffle':True 230 | } 231 | 232 | def get_devices(): 233 | out = { 234 | 'limit':1, 235 | 'offset':0, 236 | 'total':1, 237 | 'items':[] 238 | } 239 | device_count = 1 240 | devices = [] 241 | try: 242 | active_dev = sp.current_playback()['device']['name'] 243 | except: 244 | active_dev = "None" 245 | devices.append({ 246 | 'uri':'spotify:track:activeDev0000000000000', 247 | 'title':"Active Device: " + active_dev, 248 | 'subtitle':"Close and repoen to refresh", 249 | 'image_id':'carthinghax_logo' 250 | }) 251 | for i in sp.devices()['devices']: 252 | device_count += 1 253 | devices.append({ 254 | 'uri':'spotify:track:' + "DEVICE".zfill(22) + ":DEVID:" + i['id'], 255 | 'title':i['name'], 256 | 'subtitle':i['type'], 257 | 'image_id':'carthinghax_logo' 258 | }) 259 | out['limit'] = device_count 260 | out['total'] = device_count 261 | out['items'] = devices 262 | return out 263 | 264 | def canUseVolume(): 265 | try: return sp.current_playback()['device']['supports_volume'] 266 | except: return False 267 | 268 | def getNameFromURI(id): 269 | type = str(id).split(":")[1] 270 | uid = str(id).split(":")[2] 271 | match type: 272 | case "user": 273 | return "Liked Songs" 274 | case "playlist": 275 | if uid == "37i9dQZF1EYkqdzj48dyYq": return "DJ" # Spotify API doesn't return DJ info despite technically being a playlist 276 | return sp.playlist(uid)['name'] 277 | case "artist": 278 | return sp.artist(uid)['name'] 279 | case _: 280 | return "" 281 | 282 | def getChildrenOfItem(id, full_info): 283 | out = { 284 | 'limit':1000, 285 | 'offset':0, 286 | 'total':0, 287 | 'items':[] 288 | } 289 | try: 290 | type = str(id).split(":")[1] 291 | uid = str(id).split(":")[2] 292 | except: 293 | return out 294 | items = [] 295 | match type: 296 | case "playlist": 297 | playlist_tracks = sp.playlist_items 298 | for i in playlist_tracks: 299 | out["total"] += 1 300 | track_artists = i['artists'].pop(0)['name'] 301 | if len(i['artists']) >= 1: 302 | for a in i['artists']: 303 | track_artists += ", " + a['name'] 304 | items.append({ 305 | 'id': i['uri'], 306 | 'uri': i['uri'], 307 | 'image_id': i['album']['images'][0]['url'], 308 | 'title': i['name'], 309 | 'subtitle': track_artists, 310 | 'playable': i['is_playable'], 311 | 'has_children':False, 312 | 'available_offline':False, 313 | 'metadata':{ 314 | 'is_explicit_content':i['explicit'], 315 | 'is_19_plus_content':i['explicit'], 316 | 'duration_ms':i['duration_ms'] 317 | } 318 | }) 319 | 320 | case "artist": 321 | artist_tracks = sp.artist_top_tracks(uid)['tracks'] 322 | for i in artist_tracks: 323 | out["total"] += 1 324 | track_artists = i['artists'].pop(0)['name'] 325 | if len(i['artists']) >= 1: 326 | for a in i['artists']: 327 | track_artists += ", " + a['name'] 328 | items.append({ 329 | 'id': i['uri'], 330 | 'uri': i['uri'], 331 | 'image_id': i['album']['images'][0]['url'], 332 | 'title': i['name'], 333 | 'subtitle': track_artists, 334 | 'playable': i['is_playable'], 335 | 'has_children':False, 336 | 'available_offline':False, 337 | 'metadata':{ 338 | 'is_explicit_content':i['explicit'], 339 | 'is_19_plus_content':i['explicit'], 340 | 'duration_ms':i['duration_ms'] 341 | } 342 | }) 343 | 344 | case "album": 345 | album_tracks = sp.album_tracks(uid)['items'] 346 | for i in album_tracks: 347 | out["total"] += 1 348 | track_artists = i['artists'].pop(0)['name'] 349 | if len(i['artists']) >= 1: 350 | for a in i['artists']: 351 | track_artists += ", " + a['name'] 352 | items.append({ 353 | 'id': i['uri'], 354 | 'uri': i['uri'], 355 | #'image_id': i['album']['images'][0]['url'], # Not needed for album apparently 356 | 'title': i['name'], 357 | 'subtitle': track_artists, 358 | 'playable': True, 359 | 'has_children':False, 360 | 'available_offline':False, 361 | 'metadata':{ 362 | 'is_explicit_content':i['explicit'], 363 | 'is_19_plus_content':i['explicit'], 364 | 'duration_ms':i['duration_ms'] 365 | } 366 | }) 367 | 368 | out['items'] = items 369 | return out -------------------------------------------------------------------------------- /utils/wamp/wamp_builder.py: -------------------------------------------------------------------------------- 1 | import common.sb_common as sb_common 2 | 3 | # Functions for building WAMP messages 4 | 5 | # Generic message builder 6 | def build_wamp(opcode: sb_common.opCodes, request_id, payload, wamp_options = {}): 7 | wamp = [opcode.value, request_id, wamp_options, payload] 8 | return wamp 9 | 10 | # SUBSCRIBED message builder 11 | # When Superbird subscribes to something, we give it a sub_id that events will use 12 | # Example: Superbird asks to subscribe to player_state events. 13 | # We respond by telling it that events with sub_id 7 are player_state events. 14 | # (Note: sub_id is decided by us and can be any int, as long as it's different per subscription) 15 | def build_wamp_subbed(request_id, sub_id, opcode = sb_common.opCodes.SUBSCRIBED): 16 | wamp = [opcode.value, request_id, sub_id] 17 | return wamp 18 | 19 | 20 | # UNSUBSCRIBED message builder 21 | # When Superbird unsubscribes from something, we just need to acknowledge the request 22 | # and remove the subscription from superbird_session 23 | def build_wamp_unsubbed(request_id, opcode = sb_common.opCodes.UNSUBSCRIBED): 24 | wamp = [opcode.value, request_id] 25 | return wamp 26 | 27 | # EVENT message builder 28 | # When we want to send an event we include a pub_id that maps back to the original subscription 29 | # Example: We want to tell Superbird that playback has paused so we send a playload containing some info 30 | # with sub_id 7 which maps back to player_state 31 | def build_wamp_event(sub_id: int, pub_id, payload, pub_args = [], pub_argskw = [], opcode = sb_common.opCodes.EVENT): 32 | wamp = [opcode.value, sub_id, pub_id, {}, [], payload] 33 | return wamp 34 | 35 | -------------------------------------------------------------------------------- /utils/wamp/wamp_handler.py: -------------------------------------------------------------------------------- 1 | # This file handles incoming WAMP messages 2 | 3 | import datetime 4 | import traceback 5 | import utils.wamp.wamp_builder as wamp_b 6 | import common.sb_common as sb_c 7 | import common.messages as sb_msgs 8 | import utils.handlers.graphql_handler as gql 9 | import common.images as sb_img 10 | import utils.handlers.update_handler as updater 11 | import utils.remote_api as remote_api 12 | import utils.handlers.pubsub_handler as pubsub_handler 13 | import time 14 | 15 | # When a user says "Hey Spotify", Connector can record the data to audio.ogg 16 | saveVoiceRecording = False 17 | 18 | # AUTHENTICATE handler: AUTHENTICATE is the response to our "CHALLENGE" message. 19 | # We tell Superbird that it passed the challenge/response by sending a "WELCOME" message 20 | def authenticate_handler(): 21 | print('Welcoming Superbird...\n') 22 | resp = wamp_b.build_wamp(sb_c.opCodes.WELCOME, 1, {'roles': { 23 | 'dealer': {}, 24 | 'broker': {}}, 25 | 'app_version': '8.9.42.575', 26 | 'authprovider': '', 27 | 'authid': '', 28 | 'authrole': '', 29 | 'authmethod': '', 30 | 'date_time': datetime.datetime.now().isoformat()}) 31 | return True, resp, False, [] 32 | 33 | # HELLO handler: When Superbird sends a "HELLO" WAMP message, we reply with a "CHALLENGE" message 34 | # Luckily, Superbird doesn't check the challenge/response process so we can just throw whatever we want 35 | # at it, claim it passed auth and it'll be happy with that. 36 | # We also fill the superbird_session variable, which include(s) info like serial number, active subscriptions, etc. 37 | def hello_handler(msg): 38 | json_in = msg[2] 39 | with_event = False 40 | event = [] 41 | print("Superbird authenticating:") 42 | print("Firmware:", json_in['info']['version']) 43 | print("Serial No:", json_in['info']['device_identifier']) 44 | try: 45 | sb_c.superbird_session = {"serial": json_in['info']['device_identifier'], "subscriptions": {}, "vol_supported": True, "vol": 50, "pub_id": 1, "ota_ready": False, "ota_active": False, "sending": False} 46 | except Exception: 47 | print("\n\n~~~~~ Exception Start ~~~~~") 48 | traceback.print_exc() 49 | print("~~~~~ Exception End ~~~~~\n") 50 | 51 | challenge_str = {'challenge': '{"nonce":"dummy_nonce","authid":"' + json_in['info']['id'] + '","timestamp":"' + datetime.datetime.now().isoformat() + '","authmethod":"wampcra"}'} 52 | resp = [sb_c.opCodes.CHALLENGE.value, 'wampcra', challenge_str] 53 | return True, resp, with_event, event 54 | 55 | # Function handler: Superbird will send a "CALL" WAMP message when it wants something done. 56 | # If needed, we respond with a "RESULT" message 57 | 58 | def function_handler(msg): 59 | try: 60 | with_event = False 61 | event = [] 62 | request_id = msg[1] 63 | wamp_options = msg[2] 64 | called_func = msg[3] 65 | func_args = msg[4] 66 | func_argskw = msg[5] 67 | sendResp = True 68 | # Generic response, basically an ACK 69 | resp = wamp_b.build_wamp(sb_c.opCodes.RESULT, request_id, {}) 70 | try: 71 | match called_func: 72 | case "com.spotify.superbird.pitstop.log": # Pitstop log - some logs are very long 73 | if len(str(func_argskw)) > 1024: 74 | print("Superbird pitstop log: *longer than 1024* Length:", len(str(func_argskw))) 75 | else: 76 | print("Superbird pitstop log:", func_argskw) 77 | 78 | case "com.spotify.superbird.instrumentation.log": # Instrumentation log - some logs are very long 79 | if len(str(func_argskw)) > 1024: 80 | print("Superbird instrumentation log: *longer than 1024* Length:", len(str(func_argskw))) 81 | else: 82 | print("Superbird instrumentation log:", func_argskw) 83 | 84 | case "com.spotify.superbird.instrumentation.request": 85 | if len(str(func_argskw)) > 1024: 86 | print("Superbird instrumentation request: *longer than 1024* Length:", len(str(func_argskw))) 87 | else: 88 | print("Superbird instrumentation request:", func_argskw) 89 | 90 | case "com.spotify.superbird.crashes.report": 91 | if len(str(func_argskw)) > 1024: 92 | print("Superbird crash report: *longer than 1024* Length:", len(str(func_argskw))) 93 | else: 94 | print("Superbird crash report:", func_argskw) 95 | 96 | case "com.spotify.superbird.ota.check_for_updates": # Update check 97 | print("Superbird: Checked for updates") 98 | ret = updater.check(func_argskw) 99 | resp = wamp_b.build_wamp(sb_c.opCodes.RESULT, request_id, ret) 100 | 101 | case "com.spotify.superbird.ota.transfer": 102 | print("Superbird: Get OTA chunk") 103 | ret = updater.send_ota_chunk(func_argskw) 104 | resp = wamp_b.build_wamp(sb_c.opCodes.RESULT, request_id, ret) 105 | 106 | case "com.spotify.superbird.graphql": # Proper handling of this should be implemented at some point. 107 | print("Superbird graphql: ", func_argskw) 108 | payload = gql.graphql_resp(func_argskw) 109 | resp = wamp_b.build_wamp(sb_c.opCodes.RESULT, request_id, payload) 110 | 111 | case "com.spotify.superbird.permissions": # The only permission seems to be can_use_superbird 112 | print("Superbird: Got permissions") 113 | resp = wamp_b.build_wamp(sb_c.opCodes.RESULT, request_id, {'can_use_superbird': True}) 114 | 115 | case "com.spotify.superbird.setup.get_state": 116 | print("Superbird: Got setup state") 117 | resp = wamp_b.build_wamp(sb_c.opCodes.RESULT, request_id, {'state': 'finished'}) 118 | 119 | case "com.spotify.superbird.register_device": 120 | print("Superbird: Registering") 121 | 122 | case "com.spotify.superbird.tts.speak": 123 | print("Superbird: Requesting TTS:", func_argskw) 124 | resp = wamp_b.build_wamp(sb_c.opCodes.RESULT, request_id, {'state': 'STARTED'}) 125 | 126 | case "com.spotify.superbird.voice.start_session": 127 | print("Superbird: Start voice session") 128 | 129 | case "com.spotify.superbird.voice.data": 130 | if saveVoiceRecording: 131 | print("Superbird: Sending voice data, writing to audio.ogg") 132 | open("audio.ogg", "ab").write(func_argskw['voice_data']) 133 | else: 134 | print("Superbird: Sending voice data, ignoring") 135 | sendResp = False 136 | resp = {} 137 | 138 | case "com.spotify.superbird.voice.cancel_session": 139 | print("Superbird: Stop voice session") 140 | 141 | case "com.spotify.superbird.wakeword.upload": 142 | print("Superbird: Upload wakeword") 143 | open("wakeword_" + func_argskw['filename'], "wb").write(func_argskw['wakeword']) 144 | 145 | case "com.spotify.superbird.remote_configuration": 146 | print("Superbird: Request config") 147 | resp = wamp_b.build_wamp(sb_c.opCodes.RESULT, request_id, sb_msgs.remote_config_response) 148 | 149 | case "com.spotify.superbird.set_active_app": 150 | print("Superbird: Change active app:", func_argskw) 151 | 152 | case "com.spotify.superbird.tipsandtricks.get_tips_and_tricks": 153 | print("Superbird: Get tips n' tricks") 154 | tips_json = {'result': [{'id': 1, 'title': 'Hello there!', 'description': '“There should be a tip somewhere around here...”', 'action': ''}]} 155 | resp = wamp_b.build_wamp(sb_c.opCodes.RESULT, request_id, tips_json) 156 | 157 | case "com.spotify.superbird.get_home": # Only gets called after a factory reset. After reboot, it's handled by graphql 158 | print("Superbird: Get old home") 159 | home_json = sb_msgs.old_homescreen 160 | resp = wamp_b.build_wamp(sb_c.opCodes.RESULT, request_id, home_json) 161 | 162 | case "com.spotify.get_image": 163 | print("Superbird: Get image:", func_argskw['id']) 164 | resp = wamp_b.build_wamp(sb_c.opCodes.RESULT, request_id, sb_img.download_img(func_argskw['id'])) 165 | 166 | case "com.spotify.get_thumbnail_image": 167 | print("Superbird: Get thumbnail:", func_argskw) 168 | resp = wamp_b.build_wamp(sb_c.opCodes.RESULT, request_id, sb_img.download_img(func_argskw['id'], True)) 169 | 170 | case "com.spotify.set_saved": 171 | print("Superbird: Set saved for", func_argskw['uri'], "to", func_argskw["saved"]) 172 | 173 | case "com.spotify.get_saved": 174 | print("Superbird: Check if saved") 175 | resp = wamp_b.build_wamp(sb_c.opCodes.RESULT, request_id, {'uri': func_argskw['id'], 'saved': False, 'can_save': True}) 176 | 177 | case "com.spotify.superbird.presets.get_presets": # Only gets called after a factory reset. After reboot, it's handled by graphql 178 | print("Superbird: Get presets") 179 | 180 | case "com.spotify.get_children_of_item": 181 | print("Superbird: Get children of item", func_argskw) 182 | if "CONNECTOR:collection:DEVICE_SEL" in func_argskw['parent_id']: 183 | ret = remote_api.get_devices() 184 | else: 185 | ret = remote_api.getChildrenOfItem(func_argskw['parent_id'], func_argskw) 186 | resp = wamp_b.build_wamp(sb_c.opCodes.RESULT, request_id, ret) 187 | 188 | case "com.spotify.superbird.play_uri": 189 | if "CONNECTOR" in str(func_argskw["uri"]): 190 | print("Superbird: Play URI: Detected Connector Msg") 191 | print(func_argskw["uri"]) 192 | if "DEVICE_SEL" in str(func_argskw["uri"]): 193 | try: 194 | dev_id = str(func_argskw["skip_to_uri"]).split("DEVID:",1)[1] 195 | remote_api.action("select_device", dev_id) 196 | except: 197 | pass 198 | else: 199 | context = "" 200 | if "skip_to_uri" in func_argskw: 201 | context = " in context " + str(func_argskw["uri"]) 202 | uri = func_argskw["skip_to_uri"] 203 | else: 204 | uri = str(func_argskw["uri"]) 205 | print("Superbird: Play uri " + uri + context) 206 | with_event = True 207 | 208 | case "com.spotify.superbird.seek_to": 209 | remote_api.action("seek_to", func_argskw['position']) 210 | with_event = True 211 | 212 | case "com.spotify.superbird.set_shuffle": 213 | print("Superbird: Set shuffle to", func_argskw['shuffle']) 214 | remote_api.action("shuffle", func_argskw['shuffle']) 215 | with_event = True 216 | 217 | case "com.spotify.superbird.volume.volume_up": 218 | print("Superbird: Volume Up") 219 | 220 | case "com.spotify.superbird.volume.volume_down": 221 | print("Superbird: Volume Down") 222 | 223 | case "com.spotify.superbird.pause": 224 | print("Superbird: Pause media") 225 | remote_api.action("pause") 226 | time.sleep(.5) 227 | with_event = True 228 | 229 | case "com.spotify.superbird.resume": 230 | print("Superbird: Resume media") 231 | remote_api.action("play") 232 | time.sleep(.5) 233 | with_event = True 234 | 235 | case "com.spotify.superbird.skip_prev": 236 | print("Superbird: Previous Track") 237 | remote_api.action("prev") 238 | time.sleep(.5) 239 | with_event = True 240 | 241 | case "com.spotify.superbird.skip_next": 242 | print("Superbird: Next track") 243 | remote_api.action("next") 244 | time.sleep(.5) 245 | with_event = True 246 | 247 | case "com.spotify.queue_spotify_uri": 248 | print("Superbird: Add to queue. URI:", func_argskw['uri']) 249 | remote_api.action("add_queue", func_argskw['uri']) 250 | with_event = True 251 | 252 | case "com.spotify.superbird.dj.summon": 253 | print("Superbird: Summon DJ (Not supported)") 254 | 255 | case _: # Calls that don't have a handler just get an empty response and get printed to console 256 | print("\n\nSuperbird: Unhandled call:", called_func, "\nRequest ID:", request_id, "\nWAMP Options:", wamp_options, "\nArguments:", func_args, "\nnArgumentsKw:", func_argskw, '\n') 257 | 258 | except Exception: 259 | print("\n\n~~~~~ Exception Start ~~~~~") 260 | traceback.print_exc() 261 | print("~~~~~ Exception End ~~~~~\n") 262 | 263 | return sendResp, resp, with_event, event 264 | 265 | except Exception: 266 | print("\n\n~~~~~ Exception Start ~~~~~") 267 | traceback.print_exc() 268 | print("~~~~~ Exception End ~~~~~\n") 269 | 270 | # Subscription handler: Superbird sends a SUBSCRIBE request, we send back a SUBSCRIBED message that 271 | # contains a subscription ID that is used in EVENT messages to map back to the original request. 272 | # We store active subscriptions and their IDs in sb_c.superbird_session 273 | # WAMP has an unsubscribe message but I haven't seen it in use. 274 | 275 | last_subscription = 63 276 | def subscribe_handler(msg, unsub = False): 277 | global last_subscription 278 | session = sb_c.superbird_session 279 | try: 280 | if not unsub: 281 | request_id = msg[1] 282 | wamp_options = msg[2] 283 | sub_target = msg[3] 284 | with_event = False 285 | event = [] 286 | if sub_target not in session["subscriptions"]: 287 | try: 288 | last_subscription += 1 289 | sub_id = last_subscription 290 | print("Superbird: subscribing to", sub_target, "with ID:", sub_id) 291 | session["subscriptions"][sub_target] = {"sub_id": sub_id, "options": wamp_options} 292 | resp = wamp_b.build_wamp_subbed(request_id, sub_id) 293 | return True, resp, with_event, event 294 | except Exception: 295 | print(traceback.format_exc()) 296 | else: 297 | print("Superbird already subscribed to", sub_target, "with ID:", session["subscriptions"][sub_target]["sub_id"]) 298 | else: 299 | request_id = msg[1] 300 | sub_id = msg[2] 301 | with_event = False 302 | event = [] 303 | for i in session["subscriptions"]: 304 | if session["subscriptions"][i]['sub_id'] == sub_id: 305 | print("Superbird: unsubscribing from", i) 306 | del session["subscriptions"][i] 307 | resp = wamp_b.build_wamp_unsubbed(request_id) 308 | return True, resp, with_event, event 309 | except Exception: 310 | print("\n\n~~~~~ Exception Start ~~~~~") 311 | traceback.print_exc() 312 | print("~~~~~ Exception End ~~~~~\n") 313 | --------------------------------------------------------------------------------