├── test-requirements.in ├── .gitignore ├── src ├── main.py ├── buzzer.py ├── config.json ├── helpers.py ├── textutils.py ├── buttonpress_async.py ├── spotify_auth.py ├── uurequests.py ├── spotify_api.py ├── ssd1306.py ├── oled.py └── spotify.py ├── stl ├── case.stl └── lid.stl ├── images ├── wiring.jpg ├── prototype.jpg ├── pcb_in_case.jpg ├── pcb_wiring.jpg ├── 2_42inch_oled_back.jpg └── 2_42inch_oled_in_case.jpg ├── test-requirements.txt ├── .github └── workflows │ └── main.yml ├── Makefile ├── LICENSE ├── Wiring.md ├── Configuration.md ├── README.md └── Case.md /test-requirements.in: -------------------------------------------------------------------------------- 1 | pylint 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | target 3 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import spotify 2 | 3 | s = spotify.Spotify() 4 | s.start() 5 | -------------------------------------------------------------------------------- /stl/case.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vergoh/micropython-spotify-status-display/HEAD/stl/case.stl -------------------------------------------------------------------------------- /stl/lid.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vergoh/micropython-spotify-status-display/HEAD/stl/lid.stl -------------------------------------------------------------------------------- /images/wiring.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vergoh/micropython-spotify-status-display/HEAD/images/wiring.jpg -------------------------------------------------------------------------------- /images/prototype.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vergoh/micropython-spotify-status-display/HEAD/images/prototype.jpg -------------------------------------------------------------------------------- /images/pcb_in_case.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vergoh/micropython-spotify-status-display/HEAD/images/pcb_in_case.jpg -------------------------------------------------------------------------------- /images/pcb_wiring.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vergoh/micropython-spotify-status-display/HEAD/images/pcb_wiring.jpg -------------------------------------------------------------------------------- /images/2_42inch_oled_back.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vergoh/micropython-spotify-status-display/HEAD/images/2_42inch_oled_back.jpg -------------------------------------------------------------------------------- /images/2_42inch_oled_in_case.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vergoh/micropython-spotify-status-display/HEAD/images/2_42inch_oled_in_case.jpg -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.10 3 | # by the following command: 4 | # 5 | # pip-compile test-requirements.in 6 | # 7 | astroid==2.4.2 8 | # via pylint 9 | isort==5.5.3 10 | # via pylint 11 | lazy-object-proxy==1.4.3 12 | # via astroid 13 | mccabe==0.6.1 14 | # via pylint 15 | pylint==2.6.0 16 | # via -r test-requirements.in 17 | six==1.15.0 18 | # via astroid 19 | toml==0.10.1 20 | # via pylint 21 | wrapt==1.12.1 22 | # via astroid 23 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [ master ] 5 | pull_request: 6 | branches: [ master ] 7 | 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Setup Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: '3.10' 22 | 23 | - name: Install requirements 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install -r test-requirements.txt 27 | 28 | - name: make check 29 | run: make check 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TARGETS = target/main.py target/buttonpress_async.mpy target/buzzer.mpy target/helpers.mpy target/oled.mpy target/spotify_api.mpy target/spotify_auth.mpy target/spotify.mpy target/ssd1306.mpy target/textutils.mpy target/uurequests.mpy 2 | 3 | default: mpy 4 | 5 | .PHONY: check 6 | check: 7 | pylint --disable=R,C,import-error,bare-except,too-many-locals,no-member,dangerous-default-value,broad-except,unspecified-encoding src 8 | 9 | target: 10 | mkdir target 11 | 12 | target/%.mpy: src/%.py target 13 | mpy-cross $< -o $@ 14 | 15 | target/main.py: src/main.py target 16 | cp -f src/main.py target/main.py 17 | 18 | .PHONY: mpy 19 | mpy: $(TARGETS) 20 | 21 | .PHONY: clean 22 | clean: 23 | rm -fr target 24 | -------------------------------------------------------------------------------- /src/buzzer.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # Copyright (c) 2022 Teemu Toivola 3 | # https://github.com/vergoh/micropython-spotify-status-display 4 | 5 | import time 6 | from machine import PWM 7 | 8 | class buzzer(): 9 | def __init__(self, buzzerpin = None, frequency = 2000, duty = 512): 10 | self._pin = buzzerpin 11 | self._frequency = frequency 12 | self._duty = duty 13 | self._pwm = None 14 | 15 | if self._pin is not None: 16 | self._pwm = PWM(self._pin) 17 | self._pwm.freq(self._frequency) 18 | self._pwm.duty(0) 19 | time.sleep_ms(100) 20 | 21 | def buzz(self, duration_ms = 25): 22 | if self._pwm is not None: 23 | self._pwm.duty(self._duty) 24 | time.sleep_ms(duration_ms) 25 | self._pwm.duty(0) 26 | -------------------------------------------------------------------------------- /src/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "use_led": false, 3 | "setup_network": true, 4 | "enable_webrepl": false, 5 | "use_display": true, 6 | "show_progress_ticks": true, 7 | "contrast": 127, 8 | "low_contrast_mode": false, 9 | "status_poll_interval_seconds": 20, 10 | "standby_status_poll_interval_minutes": 2, 11 | "idle_standby_minutes": 5, 12 | "blank_oled_on_standby": false, 13 | "long_press_duration_milliseconds": 500, 14 | "api_request_dot_size": 1, 15 | "use_buzzer": true, 16 | "buzzer_frequency": 4000, 17 | "buzzer_duty": 200, 18 | "spotify": { 19 | "client_id": "", 20 | "client_secret": "" 21 | }, 22 | "pins": { 23 | "led": 22, 24 | "scl": 18, 25 | "sda": 23, 26 | "button_playpause": 17, 27 | "button_next": 5, 28 | "buzzer": 27 29 | }, 30 | "wlan": { 31 | "ssid": "", 32 | "password": "", 33 | "mdns": "spostatus" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Teemu Toivola 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. 22 | -------------------------------------------------------------------------------- /src/helpers.py: -------------------------------------------------------------------------------- 1 | # reduced from https://github.com/blainegarrett/urequests2 2 | 3 | import binascii 4 | 5 | always_safe = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' 6 | 'abcdefghijklmnopqrstuvwxyz' 7 | '0123456789' '_.-') 8 | 9 | def quote(s): 10 | res = [] 11 | for c in s: 12 | if c in always_safe: 13 | res.append(c) 14 | continue 15 | res.append('%%%x' % ord(c)) 16 | return ''.join(res) 17 | 18 | def quote_plus(s): 19 | if ' ' in s: 20 | s = s.replace(' ', '+') 21 | return quote(s) 22 | 23 | def urlencode(query): 24 | if isinstance(query, dict): 25 | query = query.items() 26 | l = [] 27 | for k, v in query: 28 | if not isinstance(v, list): 29 | v = [v] 30 | for value in v: 31 | k = quote_plus(str(k)) 32 | v = quote_plus(str(value)) 33 | l.append(k + '=' + v) 34 | return '&'.join(l) 35 | 36 | def b64encode(s): 37 | """Reproduced from micropython base64""" 38 | if not isinstance(s, (bytes, bytearray)): 39 | raise TypeError("expected bytes, not %s" % s.__class__.__name__) 40 | # Strip off the trailing newline 41 | encoded = binascii.b2a_base64(s)[:-1] 42 | return encoded 43 | -------------------------------------------------------------------------------- /Wiring.md: -------------------------------------------------------------------------------- 1 | # Wiring 2 | 3 | Example connections for "Lolin32 Lite" pins using a SSD1306 based OLED. Pins may vary on other ESP32 boards. For case build, see [Case.md](Case.md). 4 | 5 | ![Wiring Diagram](images/wiring.jpg) 6 | 7 | ## SSD1306 OLED 8 | 9 | For 0.96" and 1.3" OLEDs using the SSD1306 chip in i2c mode. 10 | 11 | | ESP32 | OLED | 12 | | --- | --- | 13 | | 3V3 | VCC | 14 | | GND | GND | 15 | | 23 | SDA | 16 | | 18 | SCK | 17 | 18 | ## SSD1309 OLED 19 | 20 | For 2.42" OLEDs using the SSD1309 chip in i2c mode, use the same wiring as for the SSD1306 OLED shown above. Note that most of these SSD1309 OLEDs require resistors to be moved for enabling the i2c mode. Additionally, a capacitor of around 220-470 μF is needed between ESP32 3V3 and GND in order to avoid brownout during wlan startup when OLED is being active at the same time. This issue can be seen as a "Brownout detector was triggered" console message followed by a forced reset. Also OLED RES needs to be connected to OLED VCC using a 10 kΩ resistor and to GND using a 10-100 μF capacitor: 21 | 22 | | OLED | component | OLED | 23 | | --- | --- | --- 24 | | RES | 10 kΩ resistor | VCC | 25 | | RES | 10-100 μF capacitor | GND | 26 | 27 | ## Buttons 28 | 29 | | ESP32 | button | ESP32 | 30 | | --- | --- | --- | 31 | | D4 | left button | GND | 32 | | D5 | right button | GND | 33 | 34 | Button pins need to support internal pullups. 35 | 36 | ## Piezo Buzzer (Optional) 37 | 38 | | ESP32 | Buzzer | 39 | | --- | --- | 40 | | 27 | VCC | 41 | | GND | GND | 42 | -------------------------------------------------------------------------------- /src/textutils.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # Copyright (c) 2020 Teemu Toivola 3 | # https://github.com/vergoh/micropython-spotify-status-display 4 | 5 | import re 6 | 7 | def wrap(inputstring, width = 70, center = False): 8 | if inputstring is None: 9 | return [""] 10 | 11 | output = [] 12 | chunks = inputstring.split(" ") 13 | o_buffer = "" 14 | 15 | for chunk in chunks: 16 | if len(o_buffer) == 0: 17 | if len(chunk) <= width: 18 | o_buffer = chunk 19 | continue 20 | else: 21 | if len(o_buffer) + len(chunk) + 1 <= width: 22 | o_buffer = "{} {}".format(o_buffer, chunk) 23 | continue 24 | 25 | if len(chunk) <= width: 26 | output.append(o_buffer) 27 | o_buffer = chunk 28 | continue 29 | 30 | # force split anything longer than "width" 31 | while len(chunk): 32 | if len(o_buffer) == 0: 33 | space = width 34 | else: 35 | space = width - len(o_buffer) - 1 36 | 37 | small_chunk = chunk[:space] 38 | chunk = chunk[space:] 39 | 40 | if len(o_buffer) == 0: 41 | output.append(small_chunk) 42 | else: 43 | o_buffer = "{} {}".format(o_buffer, small_chunk) 44 | output.append(o_buffer) 45 | o_buffer = "" 46 | 47 | if len(o_buffer): 48 | output.append(o_buffer) 49 | 50 | for i in range(len(output)): 51 | output[i] = re.sub(r'^- | -$', '', output[i]) 52 | if center: 53 | output[i] = "{:^{width}}".format(output[i], width=width) 54 | 55 | return output 56 | -------------------------------------------------------------------------------- /Configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | ## Get Spotify client_id and client_secret 4 | 5 | 1. Login do [Spotify developer dashboard](https://developer.spotify.com/dashboard/login) 6 | 2. Select "Create an app" 7 | 3. Fill "Status display" or similar as app name, description can be a link to this project or anything else 8 | 4. Click "Edit setting" and add `http://spostatus.local/callback/` as "Redirect URI" 9 | - `spostatus` needs to match the `mdns` name configured in the next section 10 | - `http://` prefix and `.local/callback/` must remain as shown 11 | 5. Save the settings dialog 12 | 6. Click "Show client secret" and take note of both "Client ID" and "Client Secret" 13 | 14 | ## Edit src/config.json 15 | 16 | 1. Fill `client_id` and `client_secret` with values acquired in previous step 17 | 2. Fill `pins` section according to used wiring 18 | 3. Fill `wlan` section, use `mdns` value selected in previous step 19 | 20 | ## Send implementation and config to device 21 | 22 | 1. Transfer the implementation using a serial connection with MicroPython command line 23 | - **Option 1** - direct source files, higher memory usage: 24 | 1. With MicroPython command line, `put` the content of `src` directory to the root of the device 25 | - **Option 2** - precompiled binaries, lower memory usage but requires extra step: 26 | 1. With `mpy-cross` installed using `pip`, run `make` to compile the binaries 27 | - The used `mpy-cross` version needs to match used MicroPython release, see [MicroPython documentation](https://docs.micropython.org/en/latest/reference/mpyfiles.html#versioning-and-compatibility-of-mpy-files) for version compatibility details 28 | 2. With MicroPython command line, `put` the content of `target` directory to the root of the device 29 | - Possible previously installed `.py` files need to be removed before this step when upgrading 30 | 2. Start `repl` and soft reset the device with ctrl-d 31 | 3. Fix any possible configuration errors based on shown output 32 | 4. Login to Spotify using the provided url and accept requested permissions 33 | 34 | If a Spotify device doesn't currently have playback active then the display should reflect the situation. Start playback and the display should react to the change within the configured poll interval. 35 | -------------------------------------------------------------------------------- /src/buttonpress_async.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # Copyright (c) 2020 Teemu Toivola 3 | # https://github.com/vergoh/micropython-spotify-status-display 4 | 5 | import time 6 | import uasyncio as asyncio 7 | 8 | DEBOUNCE = 30 9 | 10 | class button_async(): 11 | def __init__(self, buttonpin = None, long_press_duration_ms = 1000, buzzer = None): 12 | self.pin = buttonpin 13 | self.long_press_duration_ms = long_press_duration_ms 14 | self._buzzer = buzzer 15 | self._pressed = False 16 | self._was_pressed = False 17 | self._press_duration_ms = 0 18 | loop = asyncio.get_event_loop() 19 | loop.create_task(self.run()) 20 | 21 | async def run(self): 22 | while True: 23 | if self.pin == None or self.pin.value() == 1: 24 | await asyncio.sleep_ms(10) 25 | continue 26 | 27 | press_start_time_ms = time.ticks_ms() 28 | 29 | self._pressed = True 30 | if self._buzzer is not None: 31 | self._buzzer.buzz() 32 | 33 | await asyncio.sleep_ms(DEBOUNCE) 34 | 35 | long_press_buzzed = False 36 | while self.pin.value() == 0: 37 | if self._buzzer is not None and long_press_buzzed is False and time.ticks_diff(time.ticks_ms(), press_start_time_ms) >= self.long_press_duration_ms: 38 | self._buzzer.buzz() 39 | long_press_buzzed = True 40 | await asyncio.sleep_ms(10) 41 | self._pressed = False 42 | self._press_duration_ms = time.ticks_diff(time.ticks_ms(), press_start_time_ms) 43 | 44 | await asyncio.sleep_ms(DEBOUNCE) 45 | 46 | self._was_pressed = True 47 | 48 | def was_pressed(self): 49 | if self._was_pressed: 50 | return True 51 | return False 52 | 53 | def was_longpressed(self): 54 | if self._was_pressed and self._press_duration_ms >= self.long_press_duration_ms: 55 | return True 56 | return False 57 | 58 | def reset_press(self): 59 | self._was_pressed = False 60 | self._press_duration_ms = 0 61 | 62 | async def wait_for_press(self): 63 | self.reset_press() 64 | while True: 65 | if self.was_pressed(): 66 | self.reset_press() 67 | break 68 | else: 69 | await asyncio.sleep_ms(10) 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # micropython-spotify-status-display 2 | 3 | MicroPython implementation for ESP32 using a small OLED display to show the "currently playing" information of a Spotify playback device. Two buttons can optionally be added for controlling the playback device. For intended usability, having the buttons is highly recommended. 4 | 5 | ![2.42" OLED in 3D printed case](images/2_42inch_oled_in_case.jpg) 6 | 7 | ## Features 8 | 9 | - "currently playing" information with progress bar 10 | - artist + track 11 | - show/podcast + episode 12 | - playback control (optional) 13 | - play / pause 14 | - next track 15 | - pause after current track 16 | - add current track to library 17 | - configurable poll interval and behaviour 18 | - access token stored in device after initial login 19 | - buzzer (optional) for confirming button presses 20 | - screensaver for standby mode 21 | - self contained implementation 22 | - [custom 3D printable case](stl/case.stl) with [lid](stl/lid.stl) 23 | 24 | ## Requirements 25 | 26 | - ESP32 with [MicroPython](https://micropython.org/) 1.14 or later 27 | - version 1.18 or later recommended 28 | - SSD1306 or SSD1309 compatible 128x64 pixel OLED display in i2c mode 29 | - optional if buttons are only needed / used 30 | - verified 31 | - [0.96" SSD1306](https://www.google.com/search?q=128x64+oled+i2c+0.96+ssd1306) 32 | - [2.42" SSD1309](https://www.google.com/search?q=128x64+oled+i2c+2.42+ssd1309) (recommended) 33 | - most likely ok 34 | - [1.3" SSD1306](https://www.google.com/search?q=128x64+oled+i2c+1.3+ssd1306) 35 | - not verified 36 | - [1.3" SH1106](https://www.google.com/search?q=128x64+oled+i2c+1.3+sh1106) 37 | - wlan connectivity 38 | - Spotify account 39 | - Premium needed for playback control 40 | - control buttons (optional) 41 | - buzzer (optional) 42 | 43 | See also beginning of [Case.md](Case.md) for a full list of needed components for building the cased solution shown above. 44 | 45 | ## Limitations 46 | 47 | - buttons don't react during api requests / server communication 48 | - buttons require Spotify Premium due to api restrictions 49 | - default font supports mainly us-ascii characters 50 | - unsupported western characters are however automatically mapped to closest us-ascii equivalents 51 | - playback device isn't aware of the status display resulting in delayed status changes when the playback device is directly controlled 52 | 53 | ## TODO 54 | 55 | - better handling of rare cases of `ECONNABORTED` followed with `EHOSTUNREACH` which gets displayed 56 | - async api requests / server communication (if possible) 57 | 58 | ## Building it 59 | 60 | - [3D printed case build](Case.md) or [DIY wiring](Wiring.md) explains the hardware setup 61 | - [Configuration](Configuration.md) contains the install instructions 62 | 63 | ## Controls 64 | 65 | | | Left button | Right button | 66 | | --- | --- | --- | 67 | | active, short press | play / pause / resume | next track | 68 | | active, long press | save track | pause after current track | 69 | | standby | wake up and resume playback | wake up | 70 | 71 | Long press is >= 500 ms by default. 72 | 73 | ## Included 3rd party implementations 74 | 75 | | file | description | 76 | | --- | --- | 77 | | `ssd1306.py` | based on | 78 | | `uurequests.py` | based on | 79 | | `helpers.py` | reduced from | 80 | -------------------------------------------------------------------------------- /src/spotify_auth.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # Copyright (c) 2023 Teemu Toivola 3 | # https://github.com/vergoh/micropython-spotify-status-display 4 | 5 | import socket 6 | import select 7 | 8 | def get_authorization_code(client_id, redirect_uri, ip, mdns): 9 | spotify_auth_url = "https://accounts.spotify.com/authorize" 10 | scopes = "user-read-currently-playing user-read-playback-state user-modify-playback-state user-library-modify" 11 | 12 | user_login_url = "{}?client_id={}&response_type=code&redirect_uri={}&scope={}".format(spotify_auth_url, client_id, redirect_uri, scopes).replace(' ', '%20') 13 | 14 | addr = socket.getaddrinfo('0.0.0.0', 80)[0][-1] 15 | http_server = socket.socket() 16 | http_server.bind(addr) 17 | http_server.listen(1) 18 | serv_counter = 0 19 | callback_code = None 20 | 21 | print("listening on {} as http://{} - login at http://{}.local".format(addr, ip, mdns)) 22 | 23 | while True: 24 | read_sockets = [http_server] 25 | (r, _, _) = select.select(read_sockets, [], []) 26 | 27 | if http_server in r: 28 | reqpath = None 29 | cl, addr = http_server.accept() 30 | print("client connected from {}".format(addr)) 31 | cl_file = cl.makefile('rwb', 0) 32 | while True: 33 | line = cl_file.readline() 34 | if not line or line == b'\r\n': 35 | break 36 | else: 37 | if line.startswith(b'GET '): 38 | print(line.decode().strip()) 39 | reqpath = line.decode().strip().split(" ")[1] 40 | 41 | if reqpath is not None and reqpath.startswith('/callback') and '?' in reqpath: 42 | reqparams = reqpath.split('?')[1] 43 | params = {} 44 | for reqparam in reqparams.split('&'): 45 | p = reqparam.split('=') 46 | if len(p) != 2: 47 | continue 48 | params[p[0]] = p[1] 49 | print("got params: {}".format(params)) 50 | cl.send('HTTP/1.0 200 OK\r\nContent-type: text/html\r\n\r\n') 51 | if 'code' in params: 52 | cl.send("Login completeLogin complete, this page can now be closed\r\n") 53 | else: 54 | cl.send("Login error{}\r\n".format(params)) 55 | cl.close() 56 | callback_code = params.get('code') 57 | print("reply reports error: {}".format(params.get('error'))) 58 | break 59 | elif reqpath is not None and reqpath == '/': 60 | print("not callback path, giving login") 61 | cl.send('HTTP/1.0 200 OK\r\nContent-type: text/html\r\n\r\n') 62 | cl.send("Redirect to login".format(user_login_url)) 63 | cl.close() 64 | else: 65 | print("unknown path, sending 404") 66 | cl.send('HTTP/1.0 404 Not Found\r\nContent-type: text/plain\r\n\r\nNot Found\r\n') 67 | cl.close() 68 | 69 | serv_counter += 1 70 | if serv_counter == 5: 71 | print("counter limit reached") 72 | break 73 | 74 | http_server.close() 75 | 76 | return callback_code 77 | -------------------------------------------------------------------------------- /Case.md: -------------------------------------------------------------------------------- 1 | # Case 2 | 3 | ## Required components 4 | 5 | - [Lolin32 Lite](https://www.google.com/search?q=Lolin32+Lite) 6 | - [2.42" SSD1309 based OLED](https://www.google.com/search?q=128x64+oled+i2c+2.42+ssd1309) 7 | - 2 x [R13-507](https://www.google.com/search?q=R13-507) push buttons 8 | - 1 x 6V 470 μF capacitor 9 | - 1 x 6V 100 μF capacitor 10 | - 1 x 10 kΩ resistor 11 | - 4 x M2 hex socket head cap screw, 10-12 mm 12 | - head cap is visible in the front panel, select accordingly, black hex socket works well 13 | - 2 x M2 hex socket head cap screw, 6-8 mm 14 | - head cap is hidden under the case and can be anything available 15 | - 6 x M2 nut 16 | - 4 x 2x6 or 2x10 screw (metric, for lid) 17 | - single row 2.54 mm male pin header 18 | - 4 pins for buttons 19 | - 5 pins for OLED 20 | - 2 x 8 pins for Lolin32 Lite (may come with it) 21 | - 2 x 8 pin single row 2.54 mm female pin socket 22 | - 1 x 5 pin single row 2.54 mm female pin header connector 23 | - for OLED 24 | - 2 x 2 pin single row 2.54 mm female pin header connector 25 | - for buttons 26 | - 1 x piezo buzzer (optional), 9 mm diameter preferably but 13 mm diamater should also fit 27 | - 1 x [4x6 cm double sided prototype pcb](https://www.google.com/search?q=4x6+cm+double+sided+prototype+pcb) 28 | - 28 AWG (or similar size) wire 29 | 30 | Additionally some suitable tools, a soldering iron and access to a 3D printer is needed. 31 | 32 | ## Printing 33 | 34 | Print [case.stl](stl/case.stl) and [lid.stl](stl/lid.stl) using PLA or similar plastic. Use 0.1 - 0.2 mm layer height depending on wanted finish. The case model doesn't need support when printed with the model front facing the bed as shown in the preview. The lid however should be rotated and printed flat. 35 | 36 | ## OLED I2C setup 37 | 38 | The 2.42" SSD1309 based OLED may come by default setup in SPI mode. In that case, the back side of the pcb should look the following: 39 | 40 | ![OLED back pcb](images/2_42inch_oled_back.jpg) 41 | 42 | For I2C mode, remove the resistor R4 and bridge R3, R5 and R7. 43 | 44 | ## Assembly 45 | 46 | ![Case from behind](images/pcb_in_case.jpg) 47 | 48 | 1. Clean the case of any excess plastic left after printing. 49 | 2. Attach the OLED to the case using the longer M2 screws and nuts. Don't overtighten. 50 | 3. Solder pin headers to Lolin32 Lite with pins facing up. If full length pins headers aren't available then start pins from 3V3 and GND on both sides. 51 | 4. Attach female pin header connector on top of pin headers from previous step with pins starting from 3V3 on one side and GND on the other side. 52 | 5. Place 4x6 cm prototype pcb on top of female pin header connectors and center the board. The unused empty line should be on the side of the USB connector. Solder the female pin header connectors to the pcb. 53 | 6. Wire PCB connections according to below image. Keep the components height low when possible. The 100 μF capacitor should be set on its side while the 470 μF capacitor can stay upright even if built further back than shown in the image. Wires for OLED connector (shown as pin header in the image) can be soldered to connect directly to the source pins or other available locations. Connecting the piezo buzzer shown near the 470 μF capacitor is optional. 54 | ![PCB wiring](images/pcb_wiring.jpg) 55 | 7. Attach Lolin32 Lite board without the prototype pcb on top of it to the case using the shorter M2 screws / nuts. 56 | 8. Connect the display connector from the prototype pcb to the connector on the OLED and attach the prototype pcb on top of Lolin32 lite. 57 | 9. Solder wires with connectors to push buttons, make the solder connections face sideways instead of directly down. 58 | 10. Attach the push buttons to the case and connect the wires to the prototype pcb. 59 | 11. Visually inspect the end result for possible bad connections or extra parts and shake the case to check nothing is loose. 60 | 12. Screw the back lid close. 61 | 62 | Continue with [Configuration](Configuration.md). 63 | -------------------------------------------------------------------------------- /src/uurequests.py: -------------------------------------------------------------------------------- 1 | # based on https://github.com/pfalcon/pycopy-lib/blob/master/uurequests/uurequests.py 2 | 3 | import usocket 4 | 5 | class Response: 6 | 7 | def __init__(self, f): 8 | self.raw = f 9 | self.encoding = "utf-8" 10 | self._cached = None 11 | self.status_code = 0 12 | self.reason = "" 13 | self.headers = {} 14 | 15 | def close(self): 16 | if self.raw: 17 | self.raw.close() 18 | self.raw = None 19 | self._cached = None 20 | 21 | @property 22 | def content(self): 23 | if self._cached is None: 24 | try: 25 | self._cached = self.raw.read() 26 | finally: 27 | self.raw.close() 28 | self.raw = None 29 | return self._cached 30 | 31 | @property 32 | def text(self): 33 | return str(self.content, self.encoding) 34 | 35 | def json(self): 36 | import ujson 37 | return ujson.loads(self.content) 38 | 39 | 40 | def request(method, url, data=None, json=None, headers={}, parse_headers=True): 41 | redir_cnt = 1 42 | while True: 43 | try: 44 | proto, dummy, host, path = url.split("/", 3) 45 | except ValueError: 46 | proto, dummy, host = url.split("/", 2) 47 | path = "" 48 | if proto == "http:": 49 | port = 80 50 | elif proto == "https:": 51 | import ussl 52 | port = 443 53 | else: 54 | raise ValueError("Unsupported protocol: " + proto) 55 | 56 | if ":" in host: 57 | host, port = host.split(":", 1) 58 | port = int(port) 59 | 60 | ai = usocket.getaddrinfo(host, port, 0, usocket.SOCK_STREAM) 61 | ai = ai[0] 62 | 63 | resp_d = None 64 | if parse_headers is not False: 65 | resp_d = {} 66 | 67 | s = usocket.socket(ai[0], ai[1], ai[2]) 68 | try: 69 | s.connect(ai[-1]) 70 | if proto == "https:": 71 | #ctx = ussl.SSLContext() 72 | s = ussl.wrap_socket(s, server_hostname=host) 73 | s.write(b"%s /%s HTTP/1.0\r\n" % (method, path)) 74 | if not "Host" in headers: 75 | s.write(b"Host: %s\r\n" % host) 76 | # Iterate over keys to avoid tuple alloc 77 | for k in headers: 78 | s.write(k) 79 | s.write(b": ") 80 | s.write(headers[k]) 81 | s.write(b"\r\n") 82 | if json is not None: 83 | assert data is None 84 | import ujson 85 | data = ujson.dumps(json) 86 | s.write(b"Content-Type: application/json\r\n") 87 | if data: 88 | s.write(b"Content-Length: %d\r\n" % len(data)) 89 | else: 90 | s.write(b"Content-Length: 0\r\n") 91 | s.write(b"Connection: close\r\n\r\n") 92 | if data: 93 | s.write(data) 94 | 95 | l = s.readline() 96 | #print(l) 97 | l = l.split(None, 2) 98 | status = int(l[1]) 99 | reason = "" 100 | if len(l) > 2: 101 | reason = l[2].rstrip() 102 | while True: 103 | l = s.readline() 104 | if not l or l == b"\r\n": 105 | break 106 | #print(l) 107 | 108 | if l.startswith(b"Transfer-Encoding:"): 109 | if b"chunked" in l: 110 | raise ValueError("Unsupported " + l.decode()) 111 | elif (l.startswith(b"Location:") or l.startswith(b"location:")) and 300 <= status <= 399: 112 | if not redir_cnt: 113 | raise ValueError("Too many redirects") 114 | redir_cnt -= 1 115 | url = l[9:].decode().strip() 116 | #print("redir to:", url) 117 | status = 300 118 | break 119 | 120 | if parse_headers is False: 121 | pass 122 | elif parse_headers is True: 123 | l = l.decode() 124 | k, v = l.split(":", 1) 125 | resp_d[k] = v.strip() 126 | else: 127 | parse_headers(l, resp_d) 128 | except OSError: 129 | s.close() 130 | raise 131 | 132 | if status != 300: 133 | break 134 | 135 | resp = Response(s) 136 | resp.status_code = status 137 | resp.reason = reason 138 | if resp_d is not None: 139 | resp.headers = resp_d 140 | return resp 141 | 142 | 143 | def head(url, **kw): 144 | return request("HEAD", url, **kw) 145 | 146 | def get(url, **kw): 147 | return request("GET", url, **kw) 148 | 149 | def post(url, **kw): 150 | return request("POST", url, **kw) 151 | 152 | def put(url, **kw): 153 | return request("PUT", url, **kw) 154 | 155 | def patch(url, **kw): 156 | return request("PATCH", url, **kw) 157 | 158 | def delete(url, **kw): 159 | return request("DELETE", url, **kw) 160 | -------------------------------------------------------------------------------- /src/spotify_api.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # Copyright (c) 2020 Teemu Toivola 3 | # https://github.com/vergoh/micropython-spotify-status-display 4 | 5 | import gc 6 | import time 7 | from micropython import const 8 | 9 | # imports from additional files 10 | import uurequests as requests 11 | from helpers import b64encode, urlencode 12 | 13 | _spotify_account_api_base = const("https://accounts.spotify.com/api") 14 | _spotify_api_base = const("https://api.spotify.com") 15 | 16 | def _spotify_api_request(method, url, data = None, headers = None, retry = True): 17 | ret = {'status_code': 0, 'json': {}, 'text': 'No reply content'} 18 | print("{} {}".format(method, url)) 19 | try: 20 | r = requests.request(method, url, data = data, headers = headers) 21 | except OSError as e: 22 | print("OSError: {}".format(e)) 23 | ret['text'] = str(e) 24 | r = None 25 | 26 | if r is None or r.status_code < 200 or r.status_code >= 500: 27 | if retry: 28 | if r is None: 29 | print("failed, retrying...") 30 | elif r is not None: 31 | print("status {}, retrying...".format(r.status_code)) 32 | r.close() 33 | del r 34 | time.sleep_ms(500) 35 | return _spotify_api_request(method, url, data = data, headers = headers, retry = False) 36 | else: 37 | return ret 38 | 39 | ret['status_code'] = r.status_code 40 | try: 41 | ret['json'] = r.json() 42 | except Exception as e: 43 | if r.status_code == 200 and method == "GET": 44 | print("json decoding failed: {}".format(e)) 45 | if retry: 46 | if r is not None: 47 | r.close() 48 | del r 49 | print("retrying...") 50 | time.sleep_ms(500) 51 | gc.collect() 52 | return _spotify_api_request(method, url, data = data, headers = headers, retry = False) 53 | ret['status_code'] = 0 54 | ret['json'] = {'exception': 1} 55 | ret['text'] = str(e) 56 | 57 | if len(ret['json']) == 0: 58 | try: 59 | ret['text'] = r.text 60 | except: 61 | pass 62 | 63 | try: 64 | r.close() 65 | except: 66 | pass 67 | 68 | del r 69 | return ret 70 | 71 | def get_api_tokens(authorization_code, redirect_uri, client_id, client_secret): 72 | spotify_token_api_url = "{}/token".format(_spotify_account_api_base) 73 | reqdata = { 'grant_type': 'authorization_code', 'code': authorization_code, 'redirect_uri': redirect_uri } 74 | 75 | b64_auth = "Basic {}".format(b64encode(b"{}:{}".format(client_id, client_secret)).decode()) 76 | headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': b64_auth } 77 | 78 | return _spotify_api_request("POST", spotify_token_api_url, data = urlencode(reqdata), headers = headers) 79 | 80 | def refresh_access_token(api_tokens, client_id, client_secret): 81 | spotify_token_api_url = "{}/token".format(_spotify_account_api_base) 82 | reqdata = { 'grant_type': 'refresh_token', 'refresh_token': api_tokens['refresh_token'] } 83 | 84 | b64_auth = "Basic {}".format(b64encode(b"{}:{}".format(client_id, client_secret)).decode()) 85 | headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': b64_auth } 86 | 87 | return _spotify_api_request("POST", spotify_token_api_url, data = urlencode(reqdata), headers = headers) 88 | 89 | def get_currently_playing(api_tokens): 90 | spotify_player_api_url = "{}/v1/me/player/currently-playing?additional_types=track,episode".format(_spotify_api_base) 91 | headers = { 'Authorization': "Bearer {}".format(api_tokens['access_token']) } 92 | 93 | return _spotify_api_request("GET", spotify_player_api_url, headers = headers) 94 | 95 | def get_current_device_id(api_tokens): 96 | spotify_player_api_url = "{}/v1/me/player".format(_spotify_api_base) 97 | headers = { 'Authorization': "Bearer {}".format(api_tokens['access_token']) } 98 | 99 | return _spotify_api_request("GET", spotify_player_api_url, headers = headers) 100 | 101 | def pause_playback(api_tokens): 102 | spotify_player_api_url = "{}/v1/me/player/pause".format(_spotify_api_base) 103 | headers = { 'Authorization': "Bearer {}".format(api_tokens['access_token']) } 104 | 105 | return _spotify_api_request("PUT", spotify_player_api_url, headers = headers) 106 | 107 | def resume_playback(api_tokens, device_id = None): 108 | spotify_player_api_url = "{}/v1/me/player/play".format(_spotify_api_base) 109 | headers = { 'Authorization': "Bearer {}".format(api_tokens['access_token']) } 110 | if device_id is not None: 111 | spotify_player_api_url += "?device_id={}".format(device_id) 112 | 113 | return _spotify_api_request("PUT", spotify_player_api_url, headers = headers) 114 | 115 | def next_playback(api_tokens, device_id = None): 116 | spotify_player_api_url = "{}/v1/me/player/next".format(_spotify_api_base) 117 | headers = { 'Authorization': "Bearer {}".format(api_tokens['access_token']) } 118 | if device_id is not None: 119 | spotify_player_api_url += "?device_id={}".format(device_id) 120 | 121 | return _spotify_api_request("POST", spotify_player_api_url, headers = headers) 122 | 123 | def save_track(api_tokens, track_id): 124 | spotify_me_api_url = "{}/v1/me/tracks?ids={}".format(_spotify_api_base, track_id) 125 | headers = { 'Authorization': "Bearer {}".format(api_tokens['access_token']) } 126 | 127 | return _spotify_api_request("PUT", spotify_me_api_url, headers = headers) 128 | -------------------------------------------------------------------------------- /src/ssd1306.py: -------------------------------------------------------------------------------- 1 | # MicroPython SSD1306 OLED driver, I2C and SPI interfaces 2 | # based on 3 | # https://github.com/adafruit/micropython-adafruit-ssd1306 4 | 5 | import time 6 | import framebuf 7 | 8 | 9 | # register definitions 10 | # pylint: disable=undefined-variable 11 | SET_CONTRAST = const(0x81) 12 | SET_ENTIRE_ON = const(0xa4) 13 | SET_NORM_INV = const(0xa6) 14 | SET_DISP = const(0xae) 15 | SET_MEM_ADDR = const(0x20) 16 | SET_COL_ADDR = const(0x21) 17 | SET_PAGE_ADDR = const(0x22) 18 | SET_DISP_START_LINE = const(0x40) 19 | SET_SEG_REMAP = const(0xa0) 20 | SET_MUX_RATIO = const(0xa8) 21 | SET_COM_OUT_DIR = const(0xc0) 22 | SET_DISP_OFFSET = const(0xd3) 23 | SET_COM_PIN_CFG = const(0xda) 24 | SET_DISP_CLK_DIV = const(0xd5) 25 | SET_PRECHARGE = const(0xd9) 26 | SET_VCOM_DESEL = const(0xdb) 27 | SET_CHARGE_PUMP = const(0x8d) 28 | 29 | 30 | class SSD1306: 31 | def __init__(self, width, height, external_vcc): 32 | self.width = width 33 | self.height = height 34 | self.external_vcc = external_vcc 35 | self.pages = self.height // 8 36 | # Note the subclass must initialize self.framebuf to a framebuffer. 37 | # This is necessary because the underlying data buffer is different 38 | # between I2C and SPI implementations (I2C needs an extra byte). 39 | self.poweron() 40 | self.init_display() 41 | 42 | def init_display(self): 43 | for cmd in ( 44 | SET_DISP | 0x00, # off 45 | # address setting 46 | SET_MEM_ADDR, 0x00, # horizontal 47 | # resolution and layout 48 | SET_DISP_START_LINE | 0x00, 49 | SET_SEG_REMAP | 0x01, # column addr 127 mapped to SEG0 50 | SET_MUX_RATIO, self.height - 1, 51 | SET_COM_OUT_DIR | 0x08, # scan from COM[N] to COM0 52 | SET_DISP_OFFSET, 0x00, 53 | SET_COM_PIN_CFG, 0x02 if self.height == 32 else 0x12, 54 | # timing and driving scheme 55 | SET_DISP_CLK_DIV, 0x80, 56 | SET_PRECHARGE, 0x22 if self.external_vcc else 0xf1, 57 | SET_VCOM_DESEL, 0x30, # 0.83*Vcc 58 | # display 59 | SET_CONTRAST, 0xff, # maximum 60 | SET_ENTIRE_ON, # output follows RAM contents 61 | SET_NORM_INV, # not inverted 62 | # charge pump 63 | SET_CHARGE_PUMP, 0x10 if self.external_vcc else 0x14, 64 | SET_DISP | 0x01): # on 65 | self.write_cmd(cmd) 66 | self.fill(0) 67 | self.show() 68 | 69 | def poweroff(self): 70 | self.write_cmd(SET_DISP | 0x00) 71 | 72 | def contrast(self, contrast): 73 | self.write_cmd(SET_CONTRAST) 74 | self.write_cmd(contrast) 75 | 76 | def precharge(self, precharge): 77 | self.write_cmd(SET_PRECHARGE) 78 | self.write_cmd(precharge) 79 | 80 | def invert(self, invert): 81 | self.write_cmd(SET_NORM_INV | (invert & 1)) 82 | 83 | def show(self): 84 | x0 = 0 85 | x1 = self.width - 1 86 | if self.width == 64: 87 | # displays with width of 64 pixels are shifted by 32 88 | x0 += 32 89 | x1 += 32 90 | self.write_cmd(SET_COL_ADDR) 91 | self.write_cmd(x0) 92 | self.write_cmd(x1) 93 | self.write_cmd(SET_PAGE_ADDR) 94 | self.write_cmd(0) 95 | self.write_cmd(self.pages - 1) 96 | self.write_framebuf() 97 | 98 | def fill(self, col): 99 | self.framebuf.fill(col) 100 | 101 | def pixel(self, x, y, col): 102 | self.framebuf.pixel(x, y, col) 103 | 104 | def scroll(self, dx, dy): 105 | self.framebuf.scroll(dx, dy) 106 | 107 | def text(self, string, x, y, col=1): 108 | self.framebuf.text(string, x, y, col) 109 | 110 | 111 | class SSD1306_I2C(SSD1306): 112 | def __init__(self, width, height, i2c, addr=0x3c, external_vcc=False): 113 | self.i2c = i2c 114 | self.addr = addr 115 | self.temp = bytearray(2) 116 | # Add an extra byte to the data buffer to hold an I2C data/command byte 117 | # to use hardware-compatible I2C transactions. A memoryview of the 118 | # buffer is used to mask this byte from the framebuffer operations 119 | # (without a major memory hit as memoryview doesn't copy to a separate 120 | # buffer). 121 | self.buffer = bytearray(((height // 8) * width) + 1) 122 | self.buffer[0] = 0x40 # Set first byte of data buffer to Co=0, D/C=1 123 | self.framebuf = framebuf.FrameBuffer1(memoryview(self.buffer)[1:], width, height) 124 | super().__init__(width, height, external_vcc) 125 | 126 | def write_cmd(self, cmd): 127 | self.temp[0] = 0x80 # Co=1, D/C#=0 128 | self.temp[1] = cmd 129 | self.i2c.writeto(self.addr, self.temp) 130 | 131 | def write_framebuf(self): 132 | # Blast out the frame buffer using a single I2C transaction to support 133 | # hardware I2C interfaces. 134 | self.i2c.writeto(self.addr, self.buffer) 135 | 136 | def poweron(self): 137 | pass 138 | 139 | 140 | class SSD1306_SPI(SSD1306): 141 | def __init__(self, width, height, spi, dc, res, cs, external_vcc=False): 142 | self.rate = 10 * 1024 * 1024 143 | dc.init(dc.OUT, value=0) 144 | res.init(res.OUT, value=0) 145 | cs.init(cs.OUT, value=1) 146 | self.spi = spi 147 | self.dc = dc 148 | self.res = res 149 | self.cs = cs 150 | self.buffer = bytearray((height // 8) * width) 151 | self.framebuf = framebuf.FrameBuffer1(self.buffer, width, height) 152 | super().__init__(width, height, external_vcc) 153 | 154 | def write_cmd(self, cmd): 155 | self.spi.init(baudrate=self.rate, polarity=0, phase=0) 156 | self.cs.high() 157 | self.dc.low() 158 | self.cs.low() 159 | self.spi.write(bytearray([cmd])) 160 | self.cs.high() 161 | 162 | def write_framebuf(self): 163 | self.spi.init(baudrate=self.rate, polarity=0, phase=0) 164 | self.cs.high() 165 | self.dc.high() 166 | self.cs.low() 167 | self.spi.write(self.buffer) 168 | self.cs.high() 169 | 170 | def poweron(self): 171 | self.res.high() 172 | time.sleep_ms(1) 173 | self.res.low() 174 | time.sleep_ms(10) 175 | self.res.high() 176 | -------------------------------------------------------------------------------- /src/oled.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # Copyright (c) 2020 Teemu Toivola 3 | # https://github.com/vergoh/micropython-spotify-status-display 4 | 5 | from machine import Pin, SoftI2C 6 | import ssd1306 7 | import textutils 8 | 9 | class OLED: 10 | 11 | def __init__(self, scl_pin = 22, sda_pin = 21, contrast = 127, enable = True): 12 | self.oled_width = 128 13 | self.oled_height = 64 14 | self.standby_x = 0 15 | self.standby_y = 0 16 | self.status_dot = False 17 | self.status_dot_size = 1 18 | 19 | if enable is False: 20 | self.enabled = False 21 | return 22 | 23 | self.i2c = SoftI2C(scl = Pin(scl_pin), sda = Pin(sda_pin)) 24 | self.oled = ssd1306.SSD1306_I2C(self.oled_width, self.oled_height, self.i2c) 25 | self.oled.fill(0) 26 | self.oled.contrast(contrast) 27 | self.oled.text(" ... ", 4, 30) 28 | self.oled.show() 29 | self.enabled = True 30 | 31 | def _replace_chars(self, text): 32 | result = [] 33 | replaces = {192: 'A', 193: 'A', 194: 'A', 195: 'A', 196: 'A', 197: 'A', 198: 'A', 199: 'C', 200: 'E', 201: 'E', 202: 'E', 203: 'E', 204: 'I', 34 | 205: 'I', 206: 'I', 207: 'I', 208: 'D', 209: 'N', 210: 'O', 211: 'O', 212: 'O', 213: 'O', 214: 'O', 215: 'x', 216: 'O', 217: 'U', 35 | 218: 'U', 219: 'U', 220: 'U', 221: 'Y', 222: 'P', 223: 'B', 224: 'a', 225: 'a', 226: 'a', 227: 'a', 228: 'a', 229: 'a', 230: 'a', 36 | 231: 'c', 232: 'e', 233: 'e', 234: 'e', 235: 'e', 236: 'i', 237: 'i', 238: 'i', 239: 'i', 240: 'o', 241: 'n', 242: 'o', 243: 'o', 37 | 244: 'o', 245: 'o', 246: 'o', 247: '/', 248: 'o', 249: 'u', 250: 'u', 251: 'u', 252: 'u', 253: 'y', 254: 'p', 255: 'y', 256: 'A', 38 | 257: 'a', 258: 'A', 259: 'a', 260: 'A', 261: 'a', 262: 'C', 263: 'c', 264: 'C', 265: 'c', 266: 'C', 267: 'c', 268: 'C', 269: 'c', 39 | 270: 'D', 271: 'd', 272: 'D', 273: 'd', 274: 'E', 275: 'e', 276: 'E', 277: 'e', 278: 'E', 279: 'e', 280: 'E', 281: 'e', 282: 'E', 40 | 283: 'e', 284: 'G', 285: 'g', 286: 'G', 287: 'g', 288: 'G', 289: 'g', 290: 'G', 291: 'g', 292: 'H', 293: 'h', 294: 'H', 295: 'h', 41 | 296: 'I', 297: 'i', 298: 'I', 299: 'i', 300: 'I', 301: 'i', 302: 'I', 303: 'i', 304: 'I', 305: 'i', 306: 'I', 307: 'i', 308: 'J', 42 | 309: 'j', 310: 'K', 311: 'k', 312: 'k', 313: 'L', 314: 'l', 315: 'L', 316: 'l', 317: 'L', 318: 'l', 319: 'L', 320: 'l', 321: 'L', 43 | 322: 'l', 323: 'N', 324: 'n', 325: 'N', 326: 'n', 327: 'N', 328: 'n', 329: 'n', 330: 'N', 331: 'n', 332: 'O', 333: 'o', 334: 'O', 44 | 335: 'o', 336: 'O', 337: 'o', 340: 'R', 341: 'r', 342: 'R', 343: 'r', 344: 'R', 345: 'r', 346: 'S', 347: 's', 45 | 348: 'S', 349: 's', 350: 'S', 351: 's', 352: 'S', 353: 's', 354: 'T', 355: 't', 356: 'T', 357: 't', 358: 'T', 359: 't', 360: 'U', 46 | 361: 'u', 362: 'U', 363: 'u', 364: 'U', 365: 'u', 366: 'U', 367: 'u', 368: 'U', 369: 'u', 370: 'U', 371: 'u', 372: 'W', 373: 'w', 47 | 374: 'Y', 375: 'y', 376: 'Y', 377: 'Z', 378: 'z', 379: 'Z', 380: 'z', 381: 'Z', 382: 'z'} 48 | 49 | for i in range(0, len(text)): 50 | c = ord(text[i]) 51 | if 32 <= c <= 126: 52 | result.append(text[i]) 53 | elif c in replaces: 54 | result.append(replaces[c]) 55 | else: 56 | result.append('?') 57 | 58 | return ''.join(result) 59 | 60 | def show(self, artist, title, progress = None, ticks = True, separator = True): 61 | if not self.enabled: 62 | if progress is not None: 63 | print("Display: {} - {} ({}%)".format(artist.strip(), title.strip(), progress)) 64 | else: 65 | print("Display: {} - {}".format(artist.strip(), title.strip())) 66 | return 67 | 68 | self.oled.fill(0) 69 | 70 | if self.status_dot: 71 | for x in range(self.status_dot_size): 72 | for y in range(self.status_dot_size): 73 | self.oled.pixel(x, y, 1) 74 | 75 | y = 0 76 | a = textutils.wrap(self._replace_chars(artist.strip()), width = int(self.oled_width / 8), center = True) 77 | t = textutils.wrap(self._replace_chars(title.strip()), width = int(self.oled_width / 8), center = True) 78 | 79 | if len(a) == 1 and len(a) + len(t) <= 4: 80 | y = 10 81 | 82 | for a_line in a: 83 | x = 0 84 | if len(a_line.strip()) % 2 == 1: 85 | x = 4 86 | self.oled.text(a_line, x, y) 87 | y = y + 10 88 | 89 | if len(a) + len(t) <= 5: 90 | spacing = 10 91 | else: 92 | spacing = 4 93 | 94 | y = y + spacing 95 | 96 | if progress is not None: 97 | if ticks: 98 | for i in range(self.oled_width): 99 | if i % 32 == 0: 100 | self.oled.pixel(i, y - int(spacing / 2) - 2, 1) 101 | self.oled.pixel(i, y - int(spacing / 2) - 1, 1) 102 | self.oled.pixel(self.oled_width - 1, y - int(spacing / 2) - 2, 1) 103 | self.oled.pixel(self.oled_width - 1, y - int(spacing / 2) - 1, 1) 104 | 105 | if progress < 0: 106 | progress = 0 107 | if progress > 100: 108 | progress = 100 109 | 110 | barwidth = int(round(progress / 100 * self.oled_width, 0)) 111 | 112 | for i in range(barwidth): 113 | self.oled.pixel(i, y - int(spacing / 2) - 2, 1) 114 | self.oled.pixel(i, y - int(spacing / 2) - 1, 1) 115 | else: 116 | if separator: 117 | for i in range(31, 95): 118 | self.oled.pixel(i, y - int(spacing / 2) - 2, 1) 119 | self.oled.pixel(i, y - int(spacing / 2) - 1, 1) 120 | 121 | for t_line in t: 122 | x = 0 123 | if len(t_line.strip()) % 2 == 1: 124 | x = 4 125 | self.oled.text(t_line, x, y) 126 | y = y + 10 127 | 128 | self.oled.show() 129 | 130 | def standby(self): 131 | if not self.enabled: 132 | return 133 | 134 | self.oled.fill(0) 135 | self.oled.pixel(self.standby_x, self.standby_y, 1) 136 | self.oled.show() 137 | 138 | if self.standby_y == 0 and self.standby_x < self.oled_width - 1: 139 | self.standby_x += 1 140 | elif self.standby_x == self.oled_width - 1 and self.standby_y < self.oled_height - 1: 141 | self.standby_y += 1 142 | elif self.standby_y == self.oled_height - 1 and self.standby_x > 0: 143 | self.standby_x -= 1 144 | elif self.standby_x == 0 and self.standby_y > 0: 145 | self.standby_y -= 1 146 | 147 | def _corner_dot(self, fill, size = 1): 148 | if not self.enabled: 149 | return 150 | 151 | for x in range(self.oled_width - size, self.oled_width): 152 | for y in range(size): 153 | self.oled.pixel(x, y, fill) 154 | 155 | self.oled.show() 156 | 157 | def show_corner_dot(self, size = 1): 158 | self._corner_dot(1, size = size) 159 | 160 | def hide_corner_dot(self, size = 1): 161 | self._corner_dot(0, size = size) 162 | 163 | def enable_status_dot(self, size = 1): 164 | self.status_dot = True 165 | self.status_dot_size = size 166 | 167 | def disable_status_dot(self): 168 | self.status_dot = False 169 | 170 | def clear(self): 171 | if not self.enabled: 172 | return 173 | 174 | self.oled.fill(0) 175 | self.oled.show() 176 | -------------------------------------------------------------------------------- /src/spotify.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # Copyright (c) 2020 Teemu Toivola 3 | # https://github.com/vergoh/micropython-spotify-status-display 4 | 5 | import gc 6 | import time 7 | import ujson 8 | import network 9 | import uasyncio as asyncio 10 | from machine import Pin 11 | from micropython import const, mem_info 12 | 13 | # imports from additional files 14 | import oled 15 | import spotify_api 16 | from buttonpress_async import button_async 17 | from buzzer import buzzer 18 | 19 | _app_name = const("Spotify status") 20 | 21 | class Spotify: 22 | 23 | def __init__(self): 24 | self.device_id = None 25 | self.pause_after_current = False 26 | self._set_memory_debug() 27 | self.config = {} 28 | with open('config.json', 'r') as f: 29 | self.config = ujson.load(f) 30 | 31 | if self.config['use_led']: 32 | self.led = Pin(self.config['pins']['led'], Pin.OUT) 33 | for v in [1, 0, 1]: 34 | self.led.value(v) 35 | time.sleep_ms(100) 36 | self.led.value(0) 37 | 38 | if self.config['use_display']: 39 | self.oled = oled.OLED(scl_pin = self.config['pins']['scl'], sda_pin = self.config['pins']['sda'], contrast = self.config['contrast']) 40 | if self.config['low_contrast_mode']: 41 | self.oled.oled.precharge(0x22) 42 | else: 43 | self.oled = oled.OLED(enable = False) 44 | self.oled.show(_app_name, "__init__", separator = False) 45 | 46 | self._validate_config() 47 | 48 | if self.config['use_buzzer']: 49 | self.buzzer = buzzer(Pin(self.config['pins']['buzzer'], Pin.OUT), frequency = self.config['buzzer_frequency'], duty = self.config['buzzer_duty']) 50 | self.buzzer.buzz() 51 | else: 52 | self.buzzer = None 53 | 54 | if not self.config['spotify'].get('client_id') or not self.config['spotify'].get('client_secret'): 55 | self.oled.show(_app_name, "client not configured", separator = False) 56 | raise RuntimeError("client_id and/or client_secret not configured") 57 | 58 | self.button_playpause = button_async(Pin(self.config['pins']['button_playpause'], Pin.IN, Pin.PULL_UP), long_press_duration_ms = self.config['long_press_duration_milliseconds'], buzzer = self.buzzer) 59 | self.button_next = button_async(Pin(self.config['pins']['button_next'], Pin.IN, Pin.PULL_UP), long_press_duration_ms = self.config['long_press_duration_milliseconds'], buzzer = self.buzzer) 60 | print("buttons enabled") 61 | 62 | if self.config['setup_network']: 63 | self.wlan_ap = network.WLAN(network.AP_IF) 64 | self.wlan_ap.active(False) 65 | self.wlan = network.WLAN(network.STA_IF) 66 | try: 67 | self.wlan.active(True) 68 | self.wlan.connect(self.config['wlan']['ssid'], self.config['wlan']['password']) 69 | self.wlan.config(dhcp_hostname=self.config['wlan']['mdns']) 70 | except Exception as e: 71 | self.oled.show(e.__class__.__name__, str(e)) 72 | if str(e) == "Wifi Internal Error": 73 | time.sleep(3) 74 | import machine 75 | machine.reset() 76 | else: 77 | raise 78 | print("network configured") 79 | else: 80 | self.wlan = network.WLAN() 81 | print("using existing network configuration") 82 | 83 | if self.config['enable_webrepl']: 84 | import webrepl 85 | webrepl.start() 86 | 87 | self._wait_for_connection() 88 | 89 | self.ip = self.wlan.ifconfig()[0] 90 | self.redirect_uri = "http://{}.local/callback/".format(self.config['wlan']['mdns']) 91 | 92 | self.oled.show(_app_name, "__init__ connected {}".format(self.ip), separator = False) 93 | print("connected at {} as {}".format(self.ip, self.config['wlan']['mdns'])) 94 | 95 | def _set_memory_debug(self): 96 | import os 97 | self.memdebug = False 98 | 99 | try: 100 | if os.stat("memdebug") != 0: 101 | self.memdebug = True 102 | except Exception: 103 | pass 104 | 105 | if self.memdebug: 106 | print("memory debug enabled") 107 | else: 108 | print("no \"memdebug\" file or directory found, memory debug output disabled") 109 | 110 | def _validate_config(self): 111 | boolean_entries = const("use_display,use_led,use_buzzer,setup_network,enable_webrepl,show_progress_ticks,low_contrast_mode,blank_oled_on_standby") 112 | integer_entries = const("contrast,status_poll_interval_seconds,standby_status_poll_interval_minutes,idle_standby_minutes,long_press_duration_milliseconds,api_request_dot_size,buzzer_frequency,buzzer_duty") 113 | dict_entries = const("spotify,pins,wlan") 114 | spotify_entries = const("client_id,client_secret") 115 | pin_entries = const("led,scl,sda,button_playpause,button_next,buzzer") 116 | wlan_entries = const("ssid,password,mdns") 117 | 118 | for b in boolean_entries.split(','): 119 | if b not in self.config or type(self.config[b]) is not bool: 120 | self._raise_config_error("\"{}\" not configured or not boolean".format(b)) 121 | 122 | for i in integer_entries.split(','): 123 | if i not in self.config or type(self.config[i]) is not int: 124 | self._raise_config_error("\"{}\" not configured or not integer".format(i)) 125 | 126 | for d in dict_entries.split(','): 127 | if d not in self.config or type(self.config[d]) is not dict: 128 | self._raise_config_error("\"{}\" not configured or not dict".format(d)) 129 | 130 | for s in spotify_entries.split(','): 131 | if s not in self.config['spotify'] or self.config['spotify'][s] is None or len(self.config['spotify'][s]) < 16: 132 | self._raise_config_error("\"{}\" not configured or is invalid".format(s)) 133 | 134 | for p in pin_entries.split(','): 135 | if p not in self.config['pins'] or type(self.config['pins'][p]) is not int: 136 | self._raise_config_error("\"{}\" not configured or is invalid".format(p)) 137 | 138 | for w in wlan_entries.split(','): 139 | if w not in self.config['wlan'] or self.config['wlan'][w] is None or len(self.config['wlan'][w]) < 1: 140 | self._raise_config_error("\"{}\" not configured or is invalid".format(w)) 141 | 142 | def _raise_config_error(self, e): 143 | self.oled.show("config.json", e) 144 | raise RuntimeError(e) 145 | 146 | def _wait_for_connection(self): 147 | was_connected = self.wlan.isconnected() 148 | 149 | if not self.config['use_display'] and not self.wlan.isconnected(): 150 | print("waiting for connection...") 151 | 152 | while not self.wlan.isconnected(): 153 | if self.config['use_display']: 154 | self.oled.show(_app_name, "waiting for connection", separator = False) 155 | time.sleep_ms(500) 156 | self.oled.show(_app_name, "waiting for connection", separator = True) 157 | time.sleep_ms(500) 158 | 159 | if not was_connected: 160 | self._reset_button_presses() 161 | 162 | def _reset_button_presses(self): 163 | self.button_playpause.reset_press() 164 | self.button_next.reset_press() 165 | 166 | def _check_button_presses(self): 167 | if self.button_playpause.was_pressed() or self.button_next.was_pressed(): 168 | return True 169 | return False 170 | 171 | def _handle_buttons(self, api_tokens, playing): 172 | if not self._check_button_presses(): 173 | return 174 | 175 | if self.button_playpause.was_pressed(): 176 | print("play/pause button pressed") 177 | if playing: 178 | if self.button_playpause.was_longpressed(): 179 | self.oled.show(_app_name, "saving track", separator = False) 180 | currently_playing = self._get_currently_playing(api_tokens) 181 | if currently_playing is not None: 182 | if 'item' in currently_playing and 'id' in currently_playing['item']: 183 | self._save_track(api_tokens, currently_playing['item'].get('id')) 184 | else: 185 | self.oled.show(_app_name, "pausing playback", separator = False) 186 | self.device_id = self._get_current_device_id(api_tokens) 187 | self._pause_playback(api_tokens) 188 | else: 189 | self.oled.show(_app_name, "resuming playback", separator = False) 190 | self._resume_playback(api_tokens, self.device_id) 191 | 192 | elif self.button_next.was_pressed(): 193 | print("next button pressed") 194 | if playing: 195 | if self.button_next.was_longpressed(): 196 | if self.pause_after_current: 197 | self.oled.disable_status_dot() 198 | self.oled.show(_app_name, "not pausing after current", separator = False) 199 | self.pause_after_current = False 200 | else: 201 | self.oled.enable_status_dot(self.config['api_request_dot_size']) 202 | self.oled.show(_app_name, "pausing after current", separator = False) 203 | self.pause_after_current = True 204 | else: 205 | self.oled.show(_app_name, "requesting next", separator = False) 206 | self._next_playback(api_tokens) 207 | else: 208 | self.oled.show(_app_name, "requesting next", separator = False) 209 | self._next_playback(api_tokens, self.device_id) 210 | 211 | self._reset_button_presses() 212 | 213 | def _validate_api_reply(self, api_call_name, api_reply, ok_status_list = [], warn_status_list = [], raise_status_list = [], warn_duration_ms = 5000): 214 | print("{} status received: {}".format(api_call_name, api_reply['status_code'])) 215 | 216 | if api_reply['status_code'] in ok_status_list: 217 | return True 218 | 219 | if api_reply['status_code'] in warn_status_list: 220 | warning_text = "{} api {}: {}".format(api_call_name, api_reply['status_code'], api_reply['text']) 221 | print(warning_text) 222 | self.oled.show(_app_name, warning_text, separator = False) 223 | time.sleep_ms(warn_duration_ms) 224 | return False 225 | 226 | if len(raise_status_list) == 0 or api_reply['status_code'] in raise_status_list: 227 | self.oled.show(_app_name, "{} api error {}".format(api_call_name, api_reply['status_code']), separator = False) 228 | raise RuntimeError("{} api error {} - {}".format(api_call_name, api_reply['status_code'], api_reply['text'])) 229 | 230 | self.oled.show(_app_name, "{} api unhandled error {}".format(api_call_name, api_reply['status_code']), separator = False) 231 | raise RuntimeError("{} api unhandled status_code {} - {}".format(api_call_name, api_reply['status_code'], api_reply['text'])) 232 | 233 | def _get_api_tokens(self, authorization_code): 234 | self.oled.show_corner_dot(self.config['api_request_dot_size']) 235 | r = spotify_api.get_api_tokens(authorization_code, self.redirect_uri, self.config['spotify']['client_id'], self.config['spotify']['client_secret']) 236 | self.oled.hide_corner_dot(self.config['api_request_dot_size']) 237 | 238 | self._validate_api_reply("token", r, ok_status_list = [200]) 239 | 240 | print("api tokens received") 241 | api_tokens = r['json'] 242 | 243 | print("received: {}".format(api_tokens)) 244 | api_tokens['timestamp'] = time.time() 245 | 246 | if 'refresh_token' in api_tokens: 247 | with open('refresh_token.txt', 'w') as f: 248 | f.write(api_tokens['refresh_token']) 249 | print("refresh_token.txt created") 250 | 251 | return api_tokens 252 | 253 | def _refresh_access_token(self, api_tokens): 254 | self.oled.show_corner_dot(self.config['api_request_dot_size']) 255 | r = spotify_api.refresh_access_token(api_tokens, self.config['spotify']['client_id'], self.config['spotify']['client_secret']) 256 | self.oled.hide_corner_dot(self.config['api_request_dot_size']) 257 | 258 | warn_status_list = [] 259 | if 'timestamp' in api_tokens: 260 | warn_status_list.append(0) 261 | 262 | if not self._validate_api_reply("refresh", r, ok_status_list = [200], warn_status_list = warn_status_list): 263 | return api_tokens 264 | 265 | print("refreshed api tokens received") 266 | new_api_tokens = r['json'] 267 | 268 | print("received: {}".format(new_api_tokens)) 269 | new_api_tokens['timestamp'] = time.time() 270 | 271 | if 'refresh_token' in new_api_tokens: 272 | if new_api_tokens['refresh_token'] != api_tokens['refresh_token']: 273 | with open('refresh_token.txt', 'w') as f: 274 | f.write(new_api_tokens['refresh_token']) 275 | print("refresh_token.txt updated") 276 | else: 277 | new_api_tokens['refresh_token'] = api_tokens['refresh_token'] 278 | 279 | return new_api_tokens 280 | 281 | def _get_currently_playing(self, api_tokens): 282 | self.oled.show_corner_dot(self.config['api_request_dot_size']) 283 | r = spotify_api.get_currently_playing(api_tokens) 284 | self.oled.hide_corner_dot(self.config['api_request_dot_size']) 285 | 286 | if not self._validate_api_reply("c-playing", r, ok_status_list = [200, 202, 204], warn_status_list = [0, 401, 403, 429]): 287 | return {'warn_shown': 1} 288 | 289 | if r['status_code'] != 200: 290 | return None 291 | 292 | if 'is_playing' not in r['json'] or r['json']['is_playing'] is not True or 'item' not in r['json']: 293 | if 'is_playing' not in r['json']: 294 | print("missing content, status unknown: {}".format(r['json'])) 295 | return None 296 | 297 | return r['json'] 298 | 299 | def _get_current_device_id(self, api_tokens): 300 | self.oled.show_corner_dot(self.config['api_request_dot_size']) 301 | r = spotify_api.get_current_device_id(api_tokens) 302 | self.oled.hide_corner_dot(self.config['api_request_dot_size']) 303 | 304 | self._validate_api_reply("player", r, ok_status_list = [200], warn_status_list = [202, 204, 401, 403, 429]) 305 | 306 | print("player received") 307 | 308 | player_status = r['json'] 309 | 310 | device_id = None 311 | if 'device' in player_status: 312 | if 'id' in player_status['device']: 313 | if player_status['device']['id'] is not None and len(player_status['device']['id']) > 8: 314 | device_id = player_status['device']['id'] 315 | print("current device id: {}".format(device_id)) 316 | 317 | return device_id 318 | 319 | def _pause_playback(self, api_tokens): 320 | self.oled.show_corner_dot(self.config['api_request_dot_size']) 321 | r = spotify_api.pause_playback(api_tokens) 322 | self.oled.hide_corner_dot(self.config['api_request_dot_size']) 323 | 324 | self._validate_api_reply("pause", r, ok_status_list = [200, 202, 204], warn_status_list = [0, 401, 403, 429]) 325 | 326 | print("playback paused") 327 | 328 | def _resume_playback(self, api_tokens, device_id = None): 329 | self.oled.show_corner_dot(self.config['api_request_dot_size']) 330 | r = spotify_api.resume_playback(api_tokens, device_id = device_id) 331 | self.oled.hide_corner_dot(self.config['api_request_dot_size']) 332 | 333 | self._validate_api_reply("resume", r, ok_status_list = [200, 202, 204, 404], warn_status_list = [403]) 334 | 335 | if r['status_code'] == 404: 336 | print("no active device found") 337 | self.oled.show(_app_name, "no active device found", separator = False) 338 | time.sleep(3) 339 | else: 340 | print("playback resuming") 341 | 342 | def _next_playback(self, api_tokens, device_id = None): 343 | self.oled.show_corner_dot(self.config['api_request_dot_size']) 344 | r = spotify_api.next_playback(api_tokens, device_id = device_id) 345 | self.oled.hide_corner_dot(self.config['api_request_dot_size']) 346 | 347 | self._validate_api_reply("next", r, ok_status_list = [200, 202, 204, 404], warn_status_list = [0, 401, 403, 429]) 348 | 349 | if r['status_code'] == 404: 350 | print("no active device found") 351 | self.oled.show(_app_name, "no active device found", separator = False) 352 | time.sleep(3) 353 | else: 354 | print("playback next") 355 | 356 | def _save_track(self, api_tokens, track_id): 357 | self.oled.show_corner_dot(self.config['api_request_dot_size']) 358 | r = spotify_api.save_track(api_tokens, track_id) 359 | self.oled.hide_corner_dot(self.config['api_request_dot_size']) 360 | 361 | self._validate_api_reply("save track", r, ok_status_list = [200, 202, 204], warn_status_list = [0, 401, 403, 429]) 362 | 363 | print("track saved") 364 | 365 | def _initial_token_request(self): 366 | import spotify_auth 367 | import machine 368 | 369 | self.oled.show("Login", "http:// {}.local".format(self.config['wlan']['mdns']), separator = False) 370 | authorization_code = spotify_auth.get_authorization_code(self.config['spotify']['client_id'], self.redirect_uri, self.ip, self.config['wlan']['mdns']) 371 | 372 | if authorization_code == None: 373 | self.oled.show(_app_name, "get_auth_code() failed", separator = False) 374 | raise RuntimeError("get_auth_code() failed") 375 | 376 | self.oled.show(_app_name, "authorized", separator = False) 377 | print("authorization_code content: {}".format(authorization_code)) 378 | 379 | self._get_api_tokens(authorization_code) 380 | 381 | self.oled.show(_app_name, "authorized, rebooting", separator = False) 382 | time.sleep(2) 383 | machine.reset() 384 | 385 | async def _show_play_progress_for_seconds(self, api_tokens, cp, seconds): 386 | if 'progress_ms' not in cp or 'duration_ms' not in cp['item']: 387 | if cp.get('currently_playing_type', '') == 'track': 388 | self.oled.show(cp['item'].get('artists', [{}])[0].get('name', 'Unknown Artist'), cp['item'].get('name', 'Unknown Track')) 389 | elif cp.get('currently_playing_type', '') == 'episode': 390 | self.oled.show(cp['item'].get('show', {}).get('name', 'Unknown Podcast'), cp['item'].get('name', 'Unknown Episode')) 391 | else: 392 | self.oled.show("Unknown content", "") 393 | await asyncio.sleep(seconds) 394 | else: 395 | show_progress = True 396 | progress_start = time.time() 397 | progress = None 398 | 399 | if 'progress_ms' in cp and 'duration_ms' in cp['item']: 400 | progress_ms = cp['progress_ms'] 401 | progress = True 402 | 403 | while True: 404 | interval_begins = time.ticks_ms() 405 | 406 | if progress is not None: 407 | # compared remaining playback time needs to be longer than the 1000 ms loop interval and some approximation 408 | # of the time it takes for the Spotify API call to get executed from display to Spotify server and 409 | # then from Spotify server to playback client, the API doesn't directly support "pause after current", 410 | # pausing early rather than late appears to be the better option 411 | if self.pause_after_current and cp['item']['duration_ms'] - progress_ms <= 2000: 412 | self._pause_playback(api_tokens) 413 | break 414 | if progress_ms > cp['item']['duration_ms']: 415 | break 416 | progress = progress_ms / cp['item']['duration_ms'] * 100 417 | 418 | playing_artist = "Unknown content" 419 | playing_title = "" 420 | 421 | if cp.get('currently_playing_type', '') == 'track': 422 | playing_artist = cp['item'].get('artists', [{}])[0].get('name', 'Unknown Artist') 423 | playing_title = cp['item'].get('name', 'Unknown Track') 424 | elif cp.get('currently_playing_type', '') == 'episode': 425 | playing_artist = cp['item'].get('show', {}).get('name', 'Unknown Podcast') 426 | playing_title = cp['item'].get('name', 'Unknown Episode') 427 | 428 | if show_progress: 429 | self.oled.show(playing_artist, playing_title, progress = progress, ticks = self.config['show_progress_ticks']) 430 | if not self.config['use_display']: 431 | show_progress = False 432 | 433 | if time.time() >= progress_start + seconds: 434 | break 435 | 436 | if await self._wait_for_button_press_ms(1000): 437 | break 438 | 439 | progress_ms += time.ticks_diff(time.ticks_ms(), interval_begins) 440 | 441 | async def _wait_for_button_press_ms(self, milliseconds): 442 | interval_begins = time.ticks_ms() 443 | button_pressed = self._check_button_presses() 444 | 445 | while not button_pressed and time.ticks_diff(time.ticks_ms(), interval_begins) < milliseconds: 446 | await asyncio.sleep_ms(50) 447 | button_pressed = self._check_button_presses() 448 | 449 | return button_pressed 450 | 451 | async def _standby(self): 452 | print("standby") 453 | self._reset_button_presses() 454 | button_pressed = self._check_button_presses() 455 | 456 | if self.config['blank_oled_on_standby']: 457 | self.oled.clear() 458 | else: 459 | self.oled.standby() 460 | oled_updated = time.time() 461 | 462 | standby_start = time.time() 463 | 464 | while not button_pressed: 465 | if not self.config['blank_oled_on_standby']: 466 | if time.time() >= oled_updated + 10: 467 | self.oled.standby() 468 | oled_updated = time.time() 469 | button_pressed = await self._wait_for_button_press_ms(1000) 470 | if self.config['standby_status_poll_interval_minutes'] > 0: 471 | if time.time() >= standby_start + ( 60 * self.config['standby_status_poll_interval_minutes'] ): 472 | print("standby status poll") 473 | break 474 | 475 | if button_pressed: 476 | if not self.button_playpause.was_pressed(): 477 | self._reset_button_presses() 478 | self.oled.show(_app_name, "resuming operations", separator = False) 479 | return True 480 | else: 481 | return False 482 | 483 | async def _start_standby(self, last_playing): 484 | loop_begins = time.time() 485 | show_progress = True 486 | 487 | while loop_begins + (self.config['status_poll_interval_seconds'] - 1) > time.time(): 488 | 489 | standby_time = last_playing + self.config['idle_standby_minutes'] * 60 490 | progress = (standby_time - time.time()) / (self.config['idle_standby_minutes'] * 60) * 100 491 | 492 | if time.time() >= standby_time: 493 | return True 494 | 495 | if show_progress: 496 | self.oled.show("Spotify", "not playing", progress = progress, ticks = False) 497 | if not self.config['use_display']: 498 | show_progress = False 499 | 500 | if await self._wait_for_button_press_ms(1000): 501 | return False 502 | 503 | return False 504 | 505 | async def _looper(self): 506 | self.oled.show(_app_name, "start", separator = False) 507 | 508 | api_tokens = None 509 | 510 | try: 511 | refresh_token_file = open('refresh_token.txt', 'r') 512 | except OSError: 513 | refresh_token_file = None 514 | 515 | if refresh_token_file is None: 516 | self._initial_token_request() 517 | else: 518 | refresh_token = refresh_token_file.readline().strip() 519 | refresh_token_file.close() 520 | api_tokens = self._refresh_access_token({ 'refresh_token': refresh_token }) 521 | 522 | self.oled.show(_app_name, "tokenized", separator = False) 523 | print("api_tokens content: {}".format(api_tokens)) 524 | 525 | playing = False 526 | last_playing = time.time() 527 | self._reset_button_presses() 528 | 529 | while True: 530 | gc.collect() 531 | gc.threshold(gc.mem_free() // 4 + gc.mem_alloc()) 532 | if self.memdebug: 533 | mem_info() 534 | 535 | self._wait_for_connection() 536 | 537 | if 'expires_in' not in api_tokens or 'access_token' not in api_tokens or time.time() >= api_tokens['timestamp'] + api_tokens['expires_in'] - 30: 538 | api_tokens = self._refresh_access_token(api_tokens) 539 | if 'expires_in' not in api_tokens or 'access_token' not in api_tokens: 540 | time.sleep_ms(1000) 541 | continue 542 | 543 | self._handle_buttons(api_tokens, playing) 544 | 545 | currently_playing = self._get_currently_playing(api_tokens) 546 | 547 | if currently_playing is not None: 548 | if 'warn_shown' in currently_playing: 549 | continue 550 | playing = True 551 | last_playing = time.time() 552 | if self.device_id is None: 553 | self.device_id = self._get_current_device_id(api_tokens) 554 | else: 555 | playing = False 556 | self.pause_after_current = False 557 | self.oled.disable_status_dot() 558 | 559 | if playing: 560 | await self._show_play_progress_for_seconds(api_tokens, currently_playing, self.config['status_poll_interval_seconds']) 561 | else: 562 | if await self._start_standby(last_playing): 563 | if await self._standby(): 564 | last_playing = time.time() 565 | continue 566 | 567 | def start(self): 568 | loop = asyncio.get_event_loop() 569 | try: 570 | loop.run_until_complete(self._looper()) 571 | except KeyboardInterrupt: 572 | print("keyboard interrupt received, stopping") 573 | self.oled.clear() 574 | except RuntimeError: 575 | raise 576 | except Exception as e: 577 | self.oled.show(e.__class__.__name__, str(e)) 578 | raise 579 | --------------------------------------------------------------------------------