├── tests ├── __init__.py ├── devdeck │ ├── __init__.py │ ├── controls │ │ ├── __init__.py │ │ ├── test_command_control.py │ │ ├── test_timer_control.py │ │ └── test_name_list_control.py │ ├── settings │ │ ├── __init__.py │ │ ├── test_devdeck_settings_empty.yml │ │ ├── test_devdeck_settings_not_valid.yml │ │ ├── test_devdeck_settings_valid.yml │ │ ├── test_validation_error.py │ │ ├── test_control_setting.py │ │ └── test_devdeck_settings.py │ └── test_devdeck.py ├── test-icon.png └── testing_utils.py ├── devdeck ├── __init__.py ├── controls │ ├── __init__.py │ ├── command_control.py │ ├── clock_control.py │ ├── name_list_control.py │ ├── mic_mute_control.py │ ├── volume_mute_control.py │ ├── volume_level_control.py │ ├── sink_toggle_control.py │ └── timer_control.py ├── decks │ ├── __init__.py │ ├── single_page_deck_controller.py │ └── volume_deck.py ├── logging │ ├── __init__.py │ └── filters.py ├── settings │ ├── __init__.py │ ├── validation_error.py │ ├── control_settings.py │ ├── deck_settings.py │ └── devdeck_settings.py ├── assets │ └── font-awesome │ │ ├── users.png │ │ ├── microphone.png │ │ ├── stopwatch.png │ │ ├── microphone-mute.png │ │ ├── volume-up-solid.png │ │ ├── volume-off-solid.png │ │ └── licence.txt ├── deck_context.py └── devdeck.py ├── run-tests.sh ├── MANIFEST.in ├── .gitignore ├── run-pylint.sh ├── requirements.txt ├── setup.sh ├── .github └── workflows │ ├── release.yml │ └── ci.yml ├── setup.py ├── settings.yml ├── bin ├── device_info.py └── devdeck ├── README.md └── .pylintrc /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /devdeck/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /devdeck/controls/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /devdeck/decks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /devdeck/logging/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /devdeck/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/devdeck/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/devdeck/controls/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/devdeck/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/devdeck/settings/test_devdeck_settings_empty.yml: -------------------------------------------------------------------------------- 1 | decks: [] -------------------------------------------------------------------------------- /run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | ./venv/bin/pytest tests 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include devdeck/assets/font-awesome/* 2 | include requirements.txt 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | .coverage 3 | .idea/ 4 | __pycache__/ 5 | htmlcov/ 6 | venv/ 7 | -------------------------------------------------------------------------------- /run-pylint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | ./venv/bin/pip install pylint 3 | ./venv/bin/pylint devdeck -------------------------------------------------------------------------------- /tests/test-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therealmacjeezy/devdeck/main/tests/test-icon.png -------------------------------------------------------------------------------- /devdeck/assets/font-awesome/users.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therealmacjeezy/devdeck/main/devdeck/assets/font-awesome/users.png -------------------------------------------------------------------------------- /devdeck/assets/font-awesome/microphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therealmacjeezy/devdeck/main/devdeck/assets/font-awesome/microphone.png -------------------------------------------------------------------------------- /devdeck/assets/font-awesome/stopwatch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therealmacjeezy/devdeck/main/devdeck/assets/font-awesome/stopwatch.png -------------------------------------------------------------------------------- /devdeck/assets/font-awesome/microphone-mute.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therealmacjeezy/devdeck/main/devdeck/assets/font-awesome/microphone-mute.png -------------------------------------------------------------------------------- /devdeck/assets/font-awesome/volume-up-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therealmacjeezy/devdeck/main/devdeck/assets/font-awesome/volume-up-solid.png -------------------------------------------------------------------------------- /devdeck/assets/font-awesome/volume-off-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therealmacjeezy/devdeck/main/devdeck/assets/font-awesome/volume-off-solid.png -------------------------------------------------------------------------------- /devdeck/assets/font-awesome/licence.txt: -------------------------------------------------------------------------------- 1 | These icons are Fonr Awesome icons and are licenced under the following licencing agreement: 2 | 3 | https://fontawesome.com/license 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | assertpy 2 | cerberus 3 | devdeck-core==1.0.7 4 | emoji 5 | jsonschema 6 | pillow 7 | pulsectl 8 | pytest 9 | pyyaml 10 | requests 11 | streamdeck 12 | -------------------------------------------------------------------------------- /devdeck/logging/filters.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | class InfoFilter(logging.Filter): 5 | def filter(self, rec): 6 | return rec.levelno in (logging.DEBUG, logging.INFO) 7 | -------------------------------------------------------------------------------- /tests/devdeck/settings/test_devdeck_settings_not_valid.yml: -------------------------------------------------------------------------------- 1 | decks: 2 | - serial_number: "AL28J2C01115" 3 | name: 'devdeck.decks.single_page_deck_controller.SinglePageDeckController' -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | python3 -m pip install --user virtualenv 4 | python3 -m venv venv 5 | ./venv/bin/pip install --upgrade pip 6 | ./venv/bin/pip install -r requirements.txt 7 | -------------------------------------------------------------------------------- /tests/testing_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class TestingUtils: 5 | @staticmethod 6 | def get_filename(relative_path): 7 | return os.path.abspath(os.path.join(os.path.dirname(__file__), relative_path)) -------------------------------------------------------------------------------- /devdeck/settings/validation_error.py: -------------------------------------------------------------------------------- 1 | from devdeck_core.settings.cerberus_utils import CerberusUtils 2 | 3 | 4 | class ValidationError(Exception): 5 | def __init__(self, errors): 6 | self.errors = CerberusUtils.format_errors(errors) 7 | message = 'The following validation errors occurred:\n{}' \ 8 | .format('\n'.join([" * {}: {}.".format(field, msg) for field, msg in self.errors.items()])) 9 | super().__init__(message) 10 | -------------------------------------------------------------------------------- /tests/devdeck/settings/test_devdeck_settings_valid.yml: -------------------------------------------------------------------------------- 1 | decks: 2 | - serial_number: "ABC123" 3 | name: 'devdeck.decks.single_page_deck_controller.SinglePageDeckController' 4 | settings: 5 | controls: 6 | - name: 'devdeck.controls.mic_mute_control.MicMuteControl' 7 | key: 0 8 | settings: 9 | microphone: Scarlett Solo USB Analogue Stereo 10 | - name: 'devdeck.controls.timer_control.TimerControl' 11 | key: 3 12 | -------------------------------------------------------------------------------- /tests/devdeck/settings/test_validation_error.py: -------------------------------------------------------------------------------- 1 | from assertpy import assert_that 2 | 3 | from devdeck.settings.validation_error import ValidationError 4 | 5 | 6 | class TestValidationError: 7 | def test_errors_parsed(self): 8 | errors = {'decks': [{0: [{'controls': [{3: [{'keyx': ['unknown field']}]}]}]}]} 9 | exception = ValidationError(errors) 10 | assert_that(str(exception)) \ 11 | .is_equal_to('The following validation errors occurred:\n * decks.0.controls.3.keyx: unknown field.') 12 | -------------------------------------------------------------------------------- /devdeck/settings/control_settings.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | 4 | class ControlSettings: 5 | def __init__(self, settings): 6 | self.settings = settings 7 | 8 | def control_class(self): 9 | module_name, class_name = self.settings['name'].rsplit(".", 1) 10 | return getattr(importlib.import_module(module_name), class_name) 11 | 12 | def control_settings(self): 13 | if 'settings' in self.settings: 14 | return self.settings['settings'] 15 | return {} 16 | 17 | def key(self): 18 | return self.settings['key'] 19 | -------------------------------------------------------------------------------- /tests/devdeck/controls/test_command_control.py: -------------------------------------------------------------------------------- 1 | from devdeck_core.mock_deck_context import mock_context, assert_rendered 2 | 3 | from devdeck.controls.command_control import CommandControl 4 | from tests.testing_utils import TestingUtils 5 | 6 | 7 | class TestCommandControl: 8 | def test_initialize_sets_icon(self): 9 | control = CommandControl(0, **{'icon': TestingUtils.get_filename('test-icon.png')}) 10 | with mock_context(control) as ctx: 11 | control.initialize() 12 | assert_rendered(ctx, TestingUtils.get_filename('test-icon.png')) 13 | -------------------------------------------------------------------------------- /devdeck/settings/deck_settings.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from operator import itemgetter 3 | 4 | 5 | class DeckSettings: 6 | def __init__(self, config): 7 | self.config = config 8 | self.config['settings']['controls'] = sorted(self.config['settings']['controls'], key=itemgetter('key')) 9 | 10 | def serial_number(self): 11 | return self.config['serial_number'] 12 | 13 | def settings(self): 14 | return self.config['settings'] 15 | 16 | def deck_class(self): 17 | module_name, class_name = self.config['name'].rsplit(".", 1) 18 | return getattr(importlib.import_module(module_name), class_name) 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Set up Python 13 | uses: actions/setup-python@v1 14 | with: 15 | python-version: '3.8' 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install setuptools wheel twine 20 | - name: Build and publish 21 | env: 22 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 23 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 24 | run: | 25 | python setup.py sdist bdist_wheel 26 | twine upload dist/* 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-20.04 8 | strategy: 9 | matrix: 10 | python-version: [3.6, 3.7, 3.8] 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v1 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Updating apt packages 18 | run: sudo apt update 19 | - name: Install libpulse0 20 | run: sudo apt install -y libpulse0 21 | - name: Install venv and dependencies 22 | run: ./setup.sh 23 | # - name: pylint 24 | # run: ./run-pylint.sh 25 | - name: Tests 26 | run: ./run-tests.sh 27 | -------------------------------------------------------------------------------- /devdeck/controls/command_control.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from subprocess import Popen, DEVNULL 4 | 5 | from devdeck_core.controls.deck_control import DeckControl 6 | 7 | 8 | class CommandControl(DeckControl): 9 | def __init__(self, key_no, **kwargs): 10 | self.__logger = logging.getLogger('devdeck') 11 | super().__init__(key_no, **kwargs) 12 | 13 | def initialize(self): 14 | with self.deck_context() as context: 15 | with context.renderer() as r: 16 | r.image(os.path.expanduser(self.settings['icon'])).end() 17 | 18 | def pressed(self): 19 | try: 20 | Popen(self.settings['command'], stdout=DEVNULL, stderr=DEVNULL) 21 | except Exception as ex: 22 | self.__logger.error("Error executing command %s: %s", self.settings['command'], str(ex)) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import os 3 | import subprocess 4 | 5 | 6 | def get_version(): 7 | process = subprocess.Popen(["git", "describe", "--always", "--tags"], stdout=subprocess.PIPE, stderr=None) 8 | last_tag = process.communicate()[0].decode('ascii').strip() 9 | if '-g' in last_tag: 10 | return last_tag.split('-g')[0].replace('-', '.') 11 | else: 12 | return last_tag 13 | 14 | 15 | with open(os.path.abspath(os.path.join(os.path.dirname(__file__), 'requirements.txt'))) as f: 16 | install_reqs = f.read().splitlines() 17 | 18 | setup( 19 | name='devdeck', 20 | version=get_version(), 21 | description="A developer's approach to using a Stream Deck.", 22 | long_description=open('README.md').read(), 23 | author='James Ridgway', 24 | url='https://github.com/jamesridgway/devdeck', 25 | license='MIT', 26 | packages=find_packages(), 27 | scripts=['bin/devdeck'], 28 | install_requires=install_reqs, 29 | include_package_data=True 30 | ) 31 | -------------------------------------------------------------------------------- /settings.yml: -------------------------------------------------------------------------------- 1 | decks: 2 | - name: devdeck.decks.single_page_deck_controller.SinglePageDeckController 3 | serial_number: BL15K1B44842 4 | settings: 5 | controls: 6 | - key: 0 7 | name: devdeck.decks.volume_deck.VolumeDeck 8 | - key: 1 9 | name: devdeck.controls.clock_control.ClockControl 10 | - key: 2 11 | name: devdeck_slack.slack_deck.SlackDeck 12 | settings: 13 | api_key: 'your-api-key' 14 | actions: 15 | - action: online 16 | key: 0 17 | - action: away 18 | key: 1 19 | - action: status 20 | key: 2 21 | text: focus 22 | emoji: ':red_circle:' 23 | - action: status 24 | key: 3 25 | text: Lunch 26 | emoji: ':cookie:' 27 | - action: status 28 | key: 4 29 | text: Break 30 | emoji: ':pause_button:' 31 | until: tomorrow at 8am 32 | - action: dnd 33 | key: 5 34 | duration: 60 35 | - key: 3 36 | name: devdeck.controls.mic_mute_control.MicMuteControl 37 | - key: 4 38 | name: devdeck.controls.sink_toggle_control.SinkToggleControl -------------------------------------------------------------------------------- /tests/devdeck/settings/test_control_setting.py: -------------------------------------------------------------------------------- 1 | from assertpy import assert_that 2 | 3 | from devdeck.controls.timer_control import TimerControl 4 | from devdeck.settings.control_settings import ControlSettings 5 | 6 | 7 | class TestControlSetting: 8 | def test_control_settings_tolerates_no_settings(self): 9 | control_settings = ControlSettings({'name': 'devdeck.controls.timer_control.TimerControl', 'key': 123}) 10 | assert_that(control_settings.control_settings()).is_empty() 11 | 12 | def test_control_settings_parses_settings(self): 13 | control_settings = ControlSettings({ 14 | 'name': 'devdeck.controls.timer_control.TimerControl', 15 | 'key': 123, 16 | 'settings': {'hello': 'world'} 17 | }) 18 | assert_that(control_settings.control_settings()).is_equal_to({'hello': 'world'}) 19 | 20 | def test_control_class_provides_class(self): 21 | control_settings = ControlSettings({'name': 'devdeck.controls.timer_control.TimerControl', 'key': 123}) 22 | assert_that(control_settings.control_class()).is_equal_to(TimerControl) 23 | 24 | def test_key(self): 25 | control_settings = ControlSettings({'name': 'devdeck.controls.timer_control.TimerControl', 'key': 123}) 26 | assert_that(control_settings.key()).is_equal_to(123) 27 | -------------------------------------------------------------------------------- /devdeck/deck_context.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from PIL import ImageFont, Image, ImageDraw 4 | from StreamDeck.ImageHelpers import PILHelper 5 | from devdeck_core.image_processing import render_key_image 6 | from devdeck_core.renderer import RendererManager 7 | 8 | 9 | class DeckContext: 10 | def __init__(self, devdeck, deck): 11 | self.__devdeck = devdeck 12 | self.__deck = deck 13 | 14 | def set_icon(self, key_no, icon_filename): 15 | icon = self.render_image(icon_filename) 16 | self.set_key_image(key_no, icon) 17 | 18 | def reset_deck(self): 19 | keys = self.__deck.key_count() 20 | for key_no in range(keys): 21 | self.__deck.set_key_image(key_no, None) 22 | 23 | def render_image(self, icon_filename): 24 | return render_key_image(self.__deck, icon_filename) 25 | 26 | def set_key_image(self, key_no, icon): 27 | self.__deck.set_key_image(key_no, icon) 28 | 29 | def set_key_image_native(self, key_no, icon): 30 | image = PILHelper.to_native_format(self.__deck, icon) 31 | self.__deck.set_key_image(key_no, image) 32 | 33 | def set_active_deck(self, deck): 34 | self.__devdeck.set_active_deck(deck) 35 | 36 | def pop_active_deck(self): 37 | self.__devdeck.pop_active_deck() 38 | 39 | def renderer(self, key_no): 40 | return RendererManager(key_no, self) 41 | -------------------------------------------------------------------------------- /devdeck/controls/clock_control.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from datetime import datetime 3 | from time import sleep 4 | 5 | from devdeck_core.controls.deck_control import DeckControl 6 | 7 | 8 | class ClockControl(DeckControl): 9 | 10 | def __init__(self, key_no, **kwargs): 11 | super().__init__(key_no, **kwargs) 12 | self.thread = None 13 | self.running = False 14 | 15 | def initialize(self): 16 | self.thread = threading.Thread(target=self._update_display) 17 | self.running = True 18 | self.thread.start() 19 | 20 | def _update_display(self): 21 | while self.running is True: 22 | with self.deck_context() as context: 23 | now = datetime.now() 24 | 25 | with context.renderer() as r: 26 | r.text(now.strftime("%H:%M"))\ 27 | .center_horizontally() \ 28 | .center_vertically(-100) \ 29 | .font_size(150)\ 30 | .end() 31 | r.text(now.strftime("%a, %d %b")) \ 32 | .center_horizontally() \ 33 | .center_vertically(100) \ 34 | .font_size(75) \ 35 | .end() 36 | sleep(1) 37 | 38 | def dispose(self): 39 | self.running = False 40 | if self.thread: 41 | self.thread.join() 42 | 43 | -------------------------------------------------------------------------------- /bin/device_info.py: -------------------------------------------------------------------------------- 1 | from StreamDeck.DeviceManager import DeviceManager 2 | 3 | 4 | def print_deck_info(index, deck): 5 | image_format = deck.key_image_format() 6 | 7 | flip_description = { 8 | (False, False): "not mirrored", 9 | (True, False): "mirrored horizontally", 10 | (False, True): "mirrored vertically", 11 | (True, True): "mirrored horizontally/vertically", 12 | } 13 | 14 | print("Deck {} - {}.".format(index, deck.deck_type())) 15 | print("\t - ID: {}".format(deck.id())) 16 | print("\t - Serial: '{}'".format(deck.get_serial_number())) 17 | print("\t - Firmware Version: '{}'".format(deck.get_firmware_version())) 18 | print("\t - Key Count: {} (in a {}x{} grid)".format( 19 | deck.key_count(), 20 | deck.key_layout()[0], 21 | deck.key_layout()[1])) 22 | print("\t - Key Images: {}x{} pixels, {} format, rotated {} degrees, {}".format( 23 | image_format['size'][0], 24 | image_format['size'][1], 25 | image_format['format'], 26 | image_format['rotation'], 27 | flip_description[image_format['flip']])) 28 | 29 | 30 | if __name__ == "__main__": 31 | streamdecks = DeviceManager().enumerate() 32 | 33 | print("Found {} Stream Deck(s).\n".format(len(streamdecks))) 34 | 35 | for index, deck in enumerate(streamdecks): 36 | deck.open() 37 | deck.reset() 38 | 39 | print_deck_info(index, deck) 40 | 41 | deck.close() 42 | -------------------------------------------------------------------------------- /devdeck/controls/name_list_control.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from devdeck_core.controls.deck_control import DeckControl 4 | 5 | 6 | class NameListControl(DeckControl): 7 | 8 | def __init__(self, key_no, **kwargs): 9 | self.name_index = 0 10 | super().__init__(key_no, **kwargs) 11 | 12 | def initialize(self): 13 | self.name_index = 0 14 | with self.deck_context() as context: 15 | with context.renderer() as r: 16 | r.image(os.path.join(os.path.dirname(__file__), "../assets/font-awesome", 'users.png')).end() 17 | 18 | def pressed(self): 19 | if 'names' not in self.settings or len(self.settings['names']) == 0: 20 | return 21 | with self.deck_context() as context: 22 | with context.renderer() as r: 23 | if self.name_index > len(self.settings['names']) - 1: 24 | self.name_index = 0 25 | r.image(os.path.join(os.path.dirname(__file__), "../assets/font-awesome", 'users.png')).end() 26 | else: 27 | initials = ''.join(list(map(lambda x: x[0], self.settings['names'][self.name_index].split(' ')))) 28 | r.text(initials).font_size(256).center_vertically().center_horizontally().end() 29 | self.name_index += 1 30 | 31 | def settings_schema(self): 32 | return { 33 | 'names': { 34 | 'type': 'list', 35 | 'schema': { 36 | 'type': 'string' 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /devdeck/devdeck.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from devdeck.deck_context import DeckContext 4 | 5 | 6 | class DevDeck: 7 | def __init__(self, deck): 8 | self.__logger = logging.getLogger('devdeck') 9 | self.__deck = deck 10 | self.__deck.set_brightness(50) 11 | self.__deck.reset() 12 | self.__deck.set_key_callback(self.key_callback) 13 | self.decks = [] 14 | 15 | def set_active_deck(self, deck): 16 | self.__logger.info("Setting active deck: %s", type(deck).__name__) 17 | for deck_itr in self.decks: 18 | deck_itr.clear_deck_context() 19 | self.decks.append(deck) 20 | self.get_active_deck().render(DeckContext(self, self.__deck)) 21 | 22 | def get_active_deck(self): 23 | if not self.decks: 24 | return None 25 | return self.decks[-1] 26 | 27 | def pop_active_deck(self): 28 | if len(self.decks) == 1: 29 | return 30 | popped_deck = self.decks.pop() 31 | self.__logger.info("Exiting deck: %s", type(popped_deck).__name__) 32 | popped_deck.clear_deck_context() 33 | self.get_active_deck().render(DeckContext(self, self.__deck)) 34 | 35 | def key_callback(self, deck, key, state): 36 | if state: 37 | self.get_active_deck().pressed(key) 38 | else: 39 | self.get_active_deck().released(key) 40 | 41 | def close(self): 42 | keys = self.__deck.key_count() 43 | for deck in self.decks: 44 | deck.dispose() 45 | for key_no in range(keys): 46 | self.__deck.set_key_image(key_no, None) -------------------------------------------------------------------------------- /tests/devdeck/controls/test_timer_control.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from assertpy import assert_that 4 | 5 | from devdeck.controls.timer_control import TimerControl 6 | from devdeck_core.mock_deck_context import mock_context, assert_rendered 7 | 8 | from tests.testing_utils import TestingUtils 9 | 10 | 11 | class TestTimerControl: 12 | def test_initialize_sets_icon_and_resets_name_index(self): 13 | timer_control = TimerControl(0) 14 | with mock_context(timer_control) as ctx: 15 | timer_control.initialize() 16 | assert_rendered(ctx, TestingUtils.get_filename('../devdeck/assets/font-awesome/stopwatch.png')) 17 | 18 | def test_initial_state(self): 19 | timer_control = TimerControl(0) 20 | assert_that(timer_control.start_time).is_none() 21 | assert_that(timer_control.end_time).is_none() 22 | 23 | def test_time_diff_to_str(self): 24 | start_time = datetime(2020, 12, 22, 10, 48, 0, 0) 25 | end_time = datetime(2020, 12, 22, 11, 50, 3, 0) 26 | assert_that(TimerControl.time_diff_to_str(end_time - start_time)).is_equal_to("01:02:03") 27 | 28 | def test_pressed_transitions_state_correctly(self): 29 | timer_control = TimerControl(0) 30 | 31 | # Timer starts 32 | timer_control.pressed() 33 | assert_that(timer_control.start_time).is_not_none() 34 | assert_that(timer_control.end_time).is_none() 35 | 36 | # Time ends 37 | timer_control.pressed() 38 | assert_that(timer_control.start_time).is_not_none() 39 | assert_that(timer_control.end_time).is_not_none() 40 | assert_that(timer_control.end_time).is_greater_than_or_equal_to(timer_control.start_time) 41 | 42 | # Timer resets state 43 | timer_control.pressed() 44 | assert_that(timer_control.start_time).is_none() 45 | assert_that(timer_control.end_time).is_none() 46 | -------------------------------------------------------------------------------- /devdeck/decks/single_page_deck_controller.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from devdeck_core.decks.deck_controller import DeckController 5 | from devdeck.settings.control_settings import ControlSettings 6 | 7 | 8 | class SinglePageDeckController(DeckController): 9 | 10 | def __init__(self, key_no, **kwargs): 11 | self.__logger = logging.getLogger('devdeck') 12 | self.settings = kwargs 13 | super().__init__(key_no, **kwargs) 14 | 15 | def initialize(self): 16 | with self.deck_context() as context: 17 | with context.renderer() as r: 18 | r.image(os.path.expanduser(self.settings['icon'])).end() 19 | 20 | def deck_controls(self): 21 | controls = [ControlSettings(control_settings) for control_settings in self.settings['controls']] 22 | for control in controls: 23 | control_class = control.control_class() 24 | control_settings = control.control_settings() 25 | self.register_control(control.key(), control_class, **control_settings) 26 | 27 | def settings_schema(self): 28 | return { 29 | 'controls': { 30 | 'type': 'list', 31 | 'required': True, 32 | 'schema': { 33 | 'type': 'dict', 34 | 'schema': { 35 | 'name': { 36 | 'type': 'string', 37 | 'required': True 38 | }, 39 | 'key': { 40 | 'type': 'integer', 41 | 'required': True 42 | }, 43 | 'settings': { 44 | 'type': 'dict' 45 | } 46 | } 47 | } 48 | }, 49 | 'icon': { 50 | 'type': 'string', 51 | }, 52 | } 53 | -------------------------------------------------------------------------------- /devdeck/controls/mic_mute_control.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from pulsectl import pulsectl 5 | 6 | from devdeck_core.controls.deck_control import DeckControl 7 | 8 | 9 | class MicMuteControl(DeckControl): 10 | 11 | def __init__(self, key_no, **kwargs): 12 | self.pulse = None 13 | self.__logger = logging.getLogger('devdeck') 14 | super().__init__(key_no, **kwargs) 15 | 16 | def initialize(self): 17 | if self.pulse is None: 18 | self.pulse = pulsectl.Pulse('MicMuteControl') 19 | self.__render_icon() 20 | 21 | def pressed(self): 22 | mics = self.__get_mic() 23 | for mic in mics: 24 | self.pulse.source_mute(mic.index, mute=(not mic.mute)) 25 | self.__render_icon() 26 | 27 | def __get_mic(self): 28 | sources = self.pulse.source_list() 29 | return sources 30 | 31 | def __render_icon(self): 32 | with self.deck_context() as context: 33 | mic = self.__get_mic()[0] 34 | if mic is None: 35 | with context.renderer() as r: 36 | r \ 37 | .text('MIC \nNOT FOUND') \ 38 | .color('red') \ 39 | .center_vertically() \ 40 | .center_horizontally() \ 41 | .font_size(85) \ 42 | .text_align('center') \ 43 | .end() 44 | return 45 | if mic.mute == 0: 46 | with context.renderer() as r: 47 | r.image(os.path.join(os.path.dirname(__file__), "../assets/font-awesome", 'microphone.png')).end() 48 | else: 49 | with context.renderer() as r: 50 | r.image( 51 | os.path.join(os.path.dirname(__file__), "../assets/font-awesome", 'microphone-mute.png')).end() 52 | 53 | def settings_schema(self): 54 | return { 55 | } 56 | -------------------------------------------------------------------------------- /tests/devdeck/settings/test_devdeck_settings.py: -------------------------------------------------------------------------------- 1 | from assertpy import assert_that 2 | 3 | from devdeck.decks.single_page_deck_controller import SinglePageDeckController 4 | from devdeck.settings.deck_settings import DeckSettings 5 | from devdeck.settings.devdeck_settings import DevDeckSettings 6 | from devdeck.settings.validation_error import ValidationError 7 | from tests.testing_utils import TestingUtils 8 | 9 | 10 | class TestDevDeckSettings: 11 | def test_empty_config(self): 12 | devdeck_settings = DevDeckSettings.load( 13 | TestingUtils.get_filename('devdeck/settings/test_devdeck_settings_empty.yml')) 14 | assert_that(devdeck_settings.decks()).is_empty() 15 | 16 | def test_invalid_config_raises_exception(self): 17 | filename = TestingUtils.get_filename('devdeck/settings/test_devdeck_settings_not_valid.yml') 18 | assert_that(DevDeckSettings.load).raises(ValidationError).when_called_with(filename) \ 19 | .is_equal_to('The following validation errors occurred:\n * decks.0.settings: required field.') 20 | 21 | def test_deck_returns_settings_specific_for_deck(self): 22 | devdeck_settings = DevDeckSettings.load( 23 | TestingUtils.get_filename('devdeck/settings/test_devdeck_settings_valid.yml')) 24 | assert_that(devdeck_settings.deck('unknown s/n')).is_none() 25 | 26 | assert_that(devdeck_settings.deck('ABC123')).is_instance_of(DeckSettings) 27 | 28 | def test_valid_config(self): 29 | devdeck_settings = DevDeckSettings.load( 30 | TestingUtils.get_filename('devdeck/settings/test_devdeck_settings_valid.yml')) 31 | # There is one deck, with the series number ABC123 32 | assert_that(devdeck_settings.decks()).is_length(1) 33 | deck = devdeck_settings.decks()[0] 34 | assert_that(deck.serial_number()).is_equal_to('ABC123') 35 | assert_that(deck.deck_class()).is_equal_to(SinglePageDeckController) 36 | assert_that(deck.settings()['controls']).is_length(2) 37 | -------------------------------------------------------------------------------- /devdeck/decks/volume_deck.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from pulsectl import pulsectl 5 | from devdeck_core.decks.deck_controller import DeckController 6 | from devdeck.controls.volume_level_control import VolumeLevelControl 7 | 8 | 9 | class VolumeDeck(DeckController): 10 | def __init__(self, key_no, **kwargs): 11 | self.sinks = None 12 | self.pulse = None 13 | self.__logger = logging.getLogger('devdeck') 14 | super().__init__(key_no, **kwargs) 15 | 16 | def initialize(self): 17 | self.pulse = pulsectl.Pulse() 18 | self.sinks = self.pulse.sink_list() 19 | self.__render_icon() 20 | 21 | def deck_controls(self): 22 | for i in range(0, 7): 23 | control_settings = dict(self.settings) 24 | control_settings['volume'] = i * 20 25 | self.register_control(i, VolumeLevelControl, **control_settings) 26 | 27 | def __get_output(self): 28 | sinks = self.pulse.sink_list() 29 | return sinks 30 | 31 | def __render_icon(self): 32 | with self.deck_context() as context: 33 | sink = self.__get_output()[0] 34 | if sink is None: 35 | with context.renderer() as r: 36 | r \ 37 | .text('OUTPUT \nNOT FOUND') \ 38 | .color('red') \ 39 | .center_vertically() \ 40 | .center_horizontally() \ 41 | .font_size(85) \ 42 | .text_align('center') \ 43 | .end() 44 | return 45 | 46 | with context.renderer() as r: 47 | r.text("{:.0f}%".format(round(sink.volume.value_flat, 2) * 100)) \ 48 | .center_horizontally() \ 49 | .end() 50 | r.image(os.path.join(os.path.dirname(__file__), "../assets/font-awesome", 'volume-up-solid.png')) \ 51 | .width(380) \ 52 | .height(380) \ 53 | .center_horizontally() \ 54 | .y(132) \ 55 | .end() 56 | 57 | def settings_schema(self): 58 | return { 59 | } 60 | -------------------------------------------------------------------------------- /devdeck/controls/volume_mute_control.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from pulsectl import pulsectl 5 | 6 | from devdeck_core.controls.deck_control import DeckControl 7 | 8 | 9 | class VolumeMuteControl(DeckControl): 10 | 11 | def __init__(self, key_no, **kwargs): 12 | self.pulse = None 13 | self.__logger = logging.getLogger('devdeck') 14 | super().__init__(key_no, **kwargs) 15 | 16 | def initialize(self): 17 | self.pulse = pulsectl.Pulse() 18 | self.__render_icon() 19 | 20 | def pressed(self): 21 | output = self.__get_output() 22 | if output is None: 23 | return 24 | self.pulse.sink_mute(output.index, mute=(not output.mute)) 25 | self.__render_icon() 26 | 27 | def __get_output(self): 28 | sinks = self.pulse.sink_list() 29 | selected_output = [output for output in sinks if output.description == self.settings['output']] 30 | if len(selected_output) == 0: 31 | possible_ouputs = [output.description for output in sinks] 32 | self.__logger.warning("Output '%s' not found in list of possible outputs:\n%s", self.settings['output'], '\n'.join(possible_ouputs)) 33 | return None 34 | return selected_output[0] 35 | 36 | def __render_icon(self): 37 | with self.deck_context() as context: 38 | sink = self.__get_output() 39 | if sink is None: 40 | with context.renderer() as r: 41 | r\ 42 | .text('OUTPUT \nNOT FOUND')\ 43 | .color('red')\ 44 | .center_vertically()\ 45 | .center_horizontally()\ 46 | .font_size(85)\ 47 | .text_align('center')\ 48 | .end() 49 | return 50 | if sink.mute == 0: 51 | with context.renderer() as r: 52 | r.image(os.path.join(os.path.dirname(__file__), "../assets/font-awesome", 'volume-up-solid.png')).end() 53 | else: 54 | with context.renderer() as r: 55 | r.image(os.path.join(os.path.dirname(__file__), "../assets/font-awesome", 'volume-off-solid.png')).end() 56 | -------------------------------------------------------------------------------- /tests/devdeck/controls/test_name_list_control.py: -------------------------------------------------------------------------------- 1 | from assertpy import assert_that 2 | from devdeck_core.renderer import Renderer 3 | 4 | from devdeck.controls.name_list_control import NameListControl 5 | from devdeck_core.mock_deck_context import mock_context, assert_rendered 6 | 7 | from tests.testing_utils import TestingUtils 8 | 9 | 10 | class TestNameListControl: 11 | def test_initialize_sets_icon_and_resets_name_index(self): 12 | name_list_control = NameListControl(0) 13 | name_list_control.name_index = 1 14 | with mock_context(name_list_control) as ctx: 15 | name_list_control.initialize() 16 | assert_that(name_list_control.name_index).is_equal_to(0) 17 | assert_rendered(ctx, TestingUtils.get_filename('../devdeck/assets/font-awesome/users.png')) 18 | 19 | def test_pressed_iterates_initials(self): 20 | settings = {'names': ['Sarah Mcgrath', 'Eduardo Sanders', 'Ellis Banks']} 21 | name_list_control = NameListControl(0, **settings) 22 | with mock_context(name_list_control) as ctx: 23 | # Initial state 24 | assert_that(name_list_control.name_index).is_equal_to(0) 25 | 26 | name_list_control.pressed() 27 | assert_rendered(ctx, Renderer().text('SM').font_size(256).center_vertically().center_horizontally().end()) 28 | assert_that(name_list_control.name_index).is_equal_to(1) 29 | 30 | name_list_control.pressed() 31 | assert_rendered(ctx, Renderer().text('ES').font_size(256).center_vertically().center_horizontally().end()) 32 | assert_that(name_list_control.name_index).is_equal_to(2) 33 | 34 | name_list_control.pressed() 35 | assert_rendered(ctx, Renderer().text('EB').font_size(256).center_vertically().center_horizontally().end()) 36 | assert_that(name_list_control.name_index).is_equal_to(3) 37 | 38 | name_list_control.pressed() 39 | assert_rendered(ctx, TestingUtils.get_filename('../devdeck/assets/font-awesome/users.png')) 40 | 41 | name_list_control.pressed() 42 | assert_rendered(ctx, Renderer().text('SM').font_size(256).center_vertically().center_horizontally().end()) 43 | assert_that(name_list_control.name_index).is_equal_to(1) 44 | -------------------------------------------------------------------------------- /devdeck/controls/volume_level_control.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from pulsectl import pulsectl 5 | 6 | from devdeck_core.controls.deck_control import DeckControl 7 | 8 | 9 | class VolumeLevelControl(DeckControl): 10 | 11 | def __init__(self, key_no, **kwargs): 12 | self.pulse = None 13 | self.volume = None 14 | self.__logger = logging.getLogger('devdeck') 15 | super().__init__(key_no, **kwargs) 16 | 17 | def initialize(self): 18 | if self.pulse is None: 19 | self.pulse = pulsectl.Pulse('VolumeLevelControl') 20 | self.volume = float(self.settings['volume']) / 100 21 | self.__render_icon() 22 | 23 | def pressed(self): 24 | outputs = self.__get_output() 25 | if outputs is None: 26 | return 27 | for output in outputs: 28 | self.pulse.volume_set_all_chans(output, self.volume) 29 | self.__render_icon() 30 | 31 | def __get_output(self): 32 | sinks = self.pulse.sink_list() 33 | return sinks 34 | 35 | def __render_icon(self): 36 | with self.deck_context() as context: 37 | sink = self.__get_output()[0] 38 | if sink is None: 39 | with context.renderer() as r: 40 | r \ 41 | .text('OUTPUT \nNOT FOUND') \ 42 | .color('red') \ 43 | .center_vertically() \ 44 | .center_horizontally() \ 45 | .font_size(85) \ 46 | .text_align('center') \ 47 | .end() 48 | return 49 | 50 | with context.renderer() as r: 51 | r.text("{:.0f}%".format(round(self.volume, 2) * 100)) \ 52 | .center_horizontally() \ 53 | .end() 54 | r.image(os.path.join(os.path.dirname(__file__), "../assets/font-awesome", 'volume-up-solid.png')) \ 55 | .width(380) \ 56 | .height(380) \ 57 | .center_horizontally() \ 58 | .y(132) \ 59 | .end() 60 | if round(self.volume, 2) == round(sink.volume.value_flat, 2): 61 | r.colorize('red') 62 | 63 | def settings_schema(self): 64 | return { 65 | 'volume': { 66 | 'type': 'integer' 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /devdeck/controls/sink_toggle_control.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import numpy as np 4 | from pulsectl import pulsectl 5 | 6 | from devdeck_core.controls.deck_control import DeckControl 7 | 8 | 9 | class SinkToggleControl(DeckControl): 10 | 11 | def __init__(self, key_no, **kwargs): 12 | # self.names = ['Built-in Audio Analog Stereo', 'PCM2902 Audio Codec Analog Stereo'] 13 | self.pulse = None 14 | self.sinks = None 15 | self.active_device = None 16 | self.__logger = logging.getLogger('devdeck') 17 | super().__init__(key_no, **kwargs) 18 | 19 | def initialize(self): 20 | if self.pulse is None: 21 | self.pulse = pulsectl.Pulse('SinkToggleControl') 22 | 23 | self.sinks = self.pulse.sink_list() 24 | self.active_device = [s for s in self.sinks if s.name == self.pulse.server_info().default_sink_name][0] 25 | self.old_id = np.where([s == self.active_device for s in self.sinks])[0][0] 26 | self.__render_icon() 27 | 28 | def pressed(self): 29 | self.sinks = self.pulse.sink_list() 30 | print([s for s in self.sinks]) 31 | 32 | new_id = (self.old_id + 1) % len(self.sinks) 33 | new_device = self.sinks[new_id] 34 | self.old_id = new_id 35 | self.pulse.default_set(new_device) 36 | self.active_device = new_device 37 | self.__render_icon() 38 | 39 | def __get_mic(self): 40 | sources = self.pulse.source_list() 41 | 42 | return sources 43 | 44 | def __render_icon(self): 45 | with self.deck_context() as context: 46 | mic = self.__get_mic()[0] 47 | 48 | with context.renderer() as r: 49 | r \ 50 | .text(self.active_device.description.replace(' ', '\n')) \ 51 | .color('white') \ 52 | .center_vertically() \ 53 | .center_horizontally() \ 54 | .font_size(85) \ 55 | .text_align('center') \ 56 | .end() 57 | # if mic.mute == 0: 58 | # with context.renderer() as r: 59 | # r.image(os.path.join(os.path.dirname(__file__), "../assets/font-awesome", 'microphone.png')).end() 60 | # else: 61 | # with context.renderer() as r: 62 | # r.image(os.path.join(os.path.dirname(__file__), "../assets/font-awesome", 'microphone-mute.png')).end() 63 | 64 | def settings_schema(self): 65 | return { 66 | } 67 | -------------------------------------------------------------------------------- /devdeck/settings/devdeck_settings.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | from cerberus import Validator 3 | 4 | from devdeck.settings.deck_settings import DeckSettings 5 | from devdeck.settings.validation_error import ValidationError 6 | 7 | schema = { 8 | 'decks': { 9 | 'type': 'list', 10 | 'schema': { 11 | 'type': 'dict', 12 | 'schema': { 13 | 'serial_number': { 14 | 'type': 'string', 15 | 'required': True 16 | }, 17 | 'name': { 18 | 'type': 'string', 19 | 'required': True 20 | }, 21 | 'settings': { 22 | 'type': 'dict', 23 | 'required': True 24 | } 25 | } 26 | } 27 | } 28 | } 29 | 30 | 31 | class DevDeckSettings: 32 | def __init__(self, settings): 33 | self.settings = settings 34 | 35 | def deck(self, serial_number): 36 | settings_for_deck = [deck_setting for deck_setting in self.decks() if 37 | deck_setting.serial_number() == serial_number[0:12]] 38 | if settings_for_deck: 39 | return settings_for_deck[0] 40 | return None 41 | 42 | def decks(self): 43 | return [DeckSettings(deck_setting) for deck_setting in self.settings['decks']] 44 | 45 | @staticmethod 46 | def load(filename): 47 | with open(filename, 'r') as stream: 48 | settings = yaml.safe_load(stream) 49 | 50 | validator = Validator(schema) 51 | if validator.validate(settings, schema): 52 | return DevDeckSettings(settings) 53 | raise ValidationError(validator.errors) 54 | 55 | @staticmethod 56 | def generate_default(filename, serial_numbers): 57 | default_configs = [] 58 | for serial_number in serial_numbers: 59 | deck_config = { 60 | 'serial_number': serial_number, 61 | 'name': 'devdeck.decks.single_page_deck_controller.SinglePageDeckController', 62 | 'settings': { 63 | 'controls': [ 64 | { 65 | 'name': 'devdeck.controls.clock_control.ClockControl', 66 | 'key': 0 67 | } 68 | ] 69 | } 70 | } 71 | default_configs.append(deck_config) 72 | with open(filename, 'w') as f: 73 | yaml.dump({'decks': default_configs}, f) 74 | -------------------------------------------------------------------------------- /devdeck/controls/timer_control.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import threading 4 | from time import sleep 5 | 6 | from devdeck_core.controls.deck_control import DeckControl 7 | 8 | 9 | class TimerControl(DeckControl): 10 | 11 | def __init__(self, key_no, **kwargs): 12 | self.start_time = None 13 | self.end_time = None 14 | self.thread = None 15 | super().__init__(key_no, **kwargs) 16 | 17 | def initialize(self): 18 | with self.deck_context() as context: 19 | with context.renderer() as r: 20 | r.image(os.path.join(os.path.dirname(__file__), "../assets/font-awesome", 'stopwatch.png')).end() 21 | 22 | def pressed(self): 23 | if self.start_time is None: 24 | self.start_time = datetime.datetime.now() 25 | self.thread = threading.Thread(target=self._update_display) 26 | self.thread.start() 27 | elif self.end_time is None: 28 | self.end_time = datetime.datetime.now() 29 | self.thread.join() 30 | with self.deck_context() as context: 31 | with context.renderer() as r: 32 | r.text(TimerControl.time_diff_to_str(self.end_time - self.start_time))\ 33 | .font_size(120)\ 34 | .color('red')\ 35 | .center_vertically().center_horizontally().end() 36 | else: 37 | self.start_time = None 38 | self.end_time = None 39 | with self.deck_context() as context: 40 | with context.renderer() as r: 41 | r.image(os.path.join( 42 | os.path.join(os.path.dirname(__file__), "../assets/font-awesome", 'stopwatch.png'))).end() 43 | 44 | def _update_display(self): 45 | while self.end_time is None: 46 | if self.start_time is None: 47 | sleep(1) 48 | continue 49 | cutoff = datetime.datetime.now() if self.end_time is None else self.end_time 50 | with self.deck_context() as context: 51 | with context.renderer() as r: 52 | r.text(TimerControl.time_diff_to_str(cutoff - self.start_time)) \ 53 | .font_size(120) \ 54 | .center_vertically().center_horizontally().end() 55 | sleep(1) 56 | 57 | @staticmethod 58 | def time_diff_to_str(diff): 59 | seconds = diff.total_seconds() 60 | minutes, seconds = divmod(seconds, 60) 61 | hours, minutes = divmod(minutes, 60) 62 | return f'{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d}' 63 | -------------------------------------------------------------------------------- /tests/devdeck/test_devdeck.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from assertpy import assert_that 4 | 5 | from devdeck.devdeck import DevDeck 6 | 7 | 8 | class TestDevDeck: 9 | @mock.patch('StreamDeck.Devices.StreamDeck.StreamDeck') 10 | @mock.patch('StreamDeck.Devices.StreamDeck.StreamDeck') 11 | def test_set_active_deck(self, first_mock_deck, second_mock_deck): 12 | dev_deck = DevDeck(first_mock_deck) 13 | 14 | assert_that(dev_deck.get_active_deck()).is_none() 15 | 16 | # Sets active deck 17 | dev_deck.set_active_deck(first_mock_deck) 18 | assert_that(dev_deck.get_active_deck()).is_equal_to(first_mock_deck) 19 | 20 | # Set active deck to another instance 21 | dev_deck.set_active_deck(second_mock_deck) 22 | first_mock_deck.clear_deck_context.assert_called_once() 23 | assert_that(dev_deck.get_active_deck()).is_equal_to(second_mock_deck) 24 | 25 | @mock.patch('StreamDeck.Devices.StreamDeck.StreamDeck') 26 | @mock.patch('StreamDeck.Devices.StreamDeck.StreamDeck') 27 | def test_pop_active_deck(self, first_mock_deck, second_mock_deck): 28 | dev_deck = DevDeck(first_mock_deck) 29 | 30 | # Two active decks and the second is active 31 | dev_deck.set_active_deck(first_mock_deck) 32 | dev_deck.set_active_deck(second_mock_deck) 33 | assert_that(dev_deck.get_active_deck()).is_equal_to(second_mock_deck) 34 | 35 | dev_deck.pop_active_deck() 36 | second_mock_deck.clear_deck_context.assert_called_once() 37 | assert_that(dev_deck.get_active_deck()).is_equal_to(first_mock_deck) 38 | 39 | @mock.patch('StreamDeck.Devices.StreamDeck.StreamDeck') 40 | def test_pop_active_deck_does_not_remove_root_deck(self, first_mock_deck): 41 | dev_deck = DevDeck(first_mock_deck) 42 | dev_deck.set_active_deck(first_mock_deck) 43 | assert_that(dev_deck.get_active_deck()).is_equal_to(first_mock_deck) 44 | 45 | # Root deck is still active even if a pop is attempted 46 | dev_deck.pop_active_deck() 47 | assert_that(dev_deck.get_active_deck()).is_equal_to(first_mock_deck) 48 | 49 | @mock.patch('StreamDeck.Devices.StreamDeck.StreamDeck') 50 | def test_key_callback_propogates_to_active_deck(self, first_mock_deck): 51 | dev_deck = DevDeck(first_mock_deck) 52 | dev_deck.set_active_deck(first_mock_deck) 53 | 54 | # Pressed 55 | dev_deck.key_callback(first_mock_deck, 12, True) 56 | first_mock_deck.pressed.called_pnce_with(12) 57 | 58 | # Released 59 | dev_deck.key_callback(first_mock_deck, 23, False) 60 | first_mock_deck.released.called_pnce_with(23) -------------------------------------------------------------------------------- /bin/devdeck: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import logging 3 | import os 4 | import sys 5 | import threading 6 | from logging.handlers import RotatingFileHandler 7 | from pathlib import Path 8 | 9 | from StreamDeck.DeviceManager import DeviceManager 10 | 11 | from devdeck.devdeck import DevDeck 12 | from devdeck.logging.filters import InfoFilter 13 | from devdeck.settings.devdeck_settings import DevDeckSettings 14 | from devdeck.settings.validation_error import ValidationError 15 | 16 | if __name__ == "__main__": 17 | os.makedirs(os.path.join(str(Path.home()), '.devdeck'), exist_ok=True) 18 | 19 | root = logging.getLogger('devdeck') 20 | root.setLevel(logging.DEBUG) 21 | 22 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 23 | 24 | info_handler = logging.StreamHandler(sys.stdout) 25 | info_handler.setLevel(logging.INFO) 26 | info_handler.setFormatter(formatter) 27 | info_handler.addFilter(InfoFilter()) 28 | root.addHandler(info_handler) 29 | 30 | error_handler = logging.StreamHandler(sys.stderr) 31 | error_handler.setLevel(logging.WARNING) 32 | error_handler.setFormatter(formatter) 33 | root.addHandler(error_handler) 34 | 35 | fileHandler = RotatingFileHandler(os.path.join(str(Path.home()), '.devdeck', 'devdeck.log'), maxBytes=100000, 36 | backupCount=5) 37 | fileHandler.setFormatter(formatter) 38 | root.addHandler(fileHandler) 39 | 40 | streamdecks = DeviceManager().enumerate() 41 | 42 | settings_filename = os.path.join(str(Path.home()), '.devdeck', 'settings.yml') 43 | if not os.path.exists(settings_filename): 44 | root.warning("No settings file detected!") 45 | 46 | serial_numbers = [] 47 | for index, deck in enumerate(streamdecks): 48 | deck.open() 49 | serial_numbers.append(deck.get_serial_number()) 50 | deck.close() 51 | if len(serial_numbers) > 0: 52 | root.info("Generating a setting file as none exist: %s", settings_filename) 53 | DevDeckSettings.generate_default(settings_filename, serial_numbers) 54 | else: 55 | root.info("""No stream deck connected. Please connect a stream deck to generate an initial config file. \n 56 | If you are having difficulty detecting your stream deck please follow the installation 57 | instructions: https://github.com/jamesridgway/devdeck/wiki/Installation""") 58 | exit(0) 59 | 60 | try: 61 | settings = DevDeckSettings.load(settings_filename) 62 | except ValidationError as validation_error: 63 | print(validation_error) 64 | 65 | for index, deck in enumerate(streamdecks): 66 | deck.open() 67 | root.info('Connecting to deck: %s (S/N: %s)', deck.id(), deck.get_serial_number()) 68 | 69 | deck_settings = settings.deck(deck.get_serial_number()) 70 | if deck_settings is None: 71 | root.info("Skipping deck %s (S/N: %s) - no settings present", deck.id(), deck.get_serial_number()) 72 | deck.close() 73 | continue 74 | 75 | dev_deck = DevDeck(deck) 76 | 77 | # Instantiate deck 78 | main_deck = deck_settings.deck_class()(None, **deck_settings.settings()) 79 | dev_deck.set_active_deck(main_deck) 80 | 81 | for t in threading.enumerate(): 82 | if t is threading.currentThread(): 83 | continue 84 | 85 | if t.is_alive(): 86 | try: 87 | t.join() 88 | except KeyboardInterrupt as ex: 89 | dev_deck.close() 90 | deck.close() 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dev Deck 2 | ![CI](https://github.com/jamesridgway/devdeck/workflows/CI/badge.svg?branch=main) 3 | 4 | Stream Deck control software for software developer's. 5 | 6 | [![DevDeck Demo](https://files.james-ridgway.co.uk/images/streamdeck-yt-thumbnail.png)](https://www.youtube.com/watch?v=4ZvrVFW562w) 7 | 8 | ## Getting Started 9 | 10 | If this is your fist time using a StreamDeck make sure to follow the [Pre-requisite: LibUSB HIDAPI Backend](https://github.com/jamesridgway/devdeck/wiki/Installation#pre-requisite-libusb-hidapi-backend) steps documented in the wiki 11 | 12 | Install DevDeck 13 | 14 | pip install devdeck 15 | 16 | 17 | You should then be able to run DevDeck by running: 18 | 19 | devdeck 20 | 21 | The first time that DevDeck is run, it will generate a basic `~/.devdeck/settings.yml` populated with the clock control for any Stream Decks that are connected. 22 | 23 | 24 | ## Built-in Controls 25 | Dev Deck ships with the following controls: 26 | 27 | * [Clock Control](https://github.com/jamesridgway/devdeck/wiki/Controls#clock-control) 28 | 29 | `devdeck.controls.clock_control.ClockControl` is a clock widget for displaying the date and time 30 | 31 | * [Command Execution](https://github.com/jamesridgway/devdeck/wiki/Controls#command-control) 32 | 33 | `devdeck.controls.command_control.CommandControl` is a control for executing commands on your computer. You can 34 | specify any command and icon for the given action. 35 | 36 | * [Microphone Mute Toggle](https://github.com/jamesridgway/devdeck/wiki/Controls#mic-mute-control) 37 | 38 | `devdeck.controls.mic_mute_control.MicMuteControl` toggles the mute on a given microphone input. 39 | 40 | * [Name List](https://github.com/jamesridgway/devdeck/wiki/Controls#name-list-control) 41 | 42 | `devdeck.controls.name_list_control.NameListControl` cycles through initials from a list of names. Useful for things 43 | like stand-ups were you need to rotate through a team and make sure you cover everyone. 44 | 45 | * [Timer](https://github.com/jamesridgway/devdeck/wiki/Controls#timer-control) 46 | 47 | `devdeck.controls.timer_control.TimerControl` a basic stopwatch timer that can be used to start/stop/reset timing. 48 | 49 | * [Volume Control](https://github.com/jamesridgway/devdeck/wiki/Controls#volume-level-control) 50 | 51 | `devdeck.controls.volume_level_control.VolumeLevelControl` sets the volume for a given output to a specified volume 52 | level. 53 | 54 | 55 | * [Volume Mute Control](https://github.com/jamesridgway/devdeck/wiki/Controls#volume-mute-control) 56 | 57 | `devdeck.controls.volume_mute_control.VolumeMuteControl` toggles the muting of a given output. 58 | 59 | 60 | ## Built-in Decks 61 | 62 | * [Single Page Deck](https://github.com/jamesridgway/devdeck/wiki/Decks#singlepagedeckcontroller) 63 | 64 | `devdeck.decks.single_page_deck_controller.SinglePageDeckController` provides a basic single page deck for 65 | controls to be arranged on. 66 | 67 | * [Volume Deck](https://github.com/jamesridgway/devdeck/wiki/Decks#volumedeck) 68 | 69 | `devdeck.decks.volume_deck.VolumeDeck` is a pre-built volume deck which will show volume toggles between 0% and 100% 70 | at 10% increments. 71 | 72 | ## Plugins 73 | There are a few controls that are provided as plugins. You can always write your own plugin if you can't find the 74 | functionality that you're after: 75 | 76 | * [devdeck-slack](https://github.com/jamesridgway/devdeck-slack) 77 | 78 | Controls and decks for Slack. Toggle presence, change status, snooze notifications, etc. 79 | 80 | * [devdeck-home-assistant](https://github.com/jamesridgway/devdeck-home-assistant) 81 | 82 | Controls and decks for Home Assistant. Toggle lights, switches, etc. 83 | 84 | * [devdeck-key-light](https://github.com/jamesridgway/devdeck-key-light) 85 | 86 | Controls and decks for controlling an Elgato Key Light. 87 | 88 | ## Implementing Custom Controls 89 | Can't find support for what you want? Implement your own `DeckControl` or `DeckController`· 90 | 91 | * `DeckControl` 92 | 93 | A `DeckControl` is an individual button that can be placed on a deck. 94 | 95 | * `DeckController` 96 | 97 | A `DeckController` is fronted by a button, pressing the button will take you to a deck screen tailored for the 98 | given functionality. 99 | 100 | For example: Slack is implemented as a DeckController. Pressing the slack button will then present you with buttons 101 | for specific statuses. 102 | 103 | ## Developing for DevDeck 104 | Pull requesta and contributions to this project are welcome. 105 | 106 | You can get setup with a virtual environment and all necessary dependencies by running: 107 | 108 | ./setup.sh 109 | 110 | Tests can be run by running: 111 | 112 | ./run-tests.sh 113 | 114 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code. 6 | extension-pkg-whitelist= 7 | 8 | # Specify a score threshold to be exceeded before program exits with error. 9 | fail-under=10 10 | 11 | # Add files or directories to the blacklist. They should be base names, not 12 | # paths. 13 | ignore=CVS 14 | 15 | # Add files or directories matching the regex patterns to the blacklist. The 16 | # regex matches against base names, not paths. 17 | ignore-patterns= 18 | 19 | # Python code to execute, usually for sys.path manipulation such as 20 | # pygtk.require(). 21 | #init-hook= 22 | 23 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 24 | # number of processors available to use. 25 | jobs=1 26 | 27 | # Control the amount of potential inferred values when inferring a single 28 | # object. This can help the performance when dealing with large functions or 29 | # complex, nested conditions. 30 | limit-inference-results=100 31 | 32 | # List of plugins (as comma separated values of python module names) to load, 33 | # usually to register additional checkers. 34 | load-plugins= 35 | 36 | # Pickle collected data for later comparisons. 37 | persistent=yes 38 | 39 | # When enabled, pylint would attempt to guess common misconfiguration and emit 40 | # user-friendly hints instead of false-positive error messages. 41 | suggestion-mode=yes 42 | 43 | # Allow loading of arbitrary C extensions. Extensions are imported into the 44 | # active Python interpreter and may run arbitrary code. 45 | unsafe-load-any-extension=no 46 | 47 | 48 | [MESSAGES CONTROL] 49 | 50 | # Only show warnings with the listed confidence levels. Leave empty to show 51 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 52 | confidence= 53 | 54 | # Disable the message, report, category or checker with the given id(s). You 55 | # can either give multiple identifiers separated by comma (,) or put this 56 | # option multiple times (only on the command line, not in the configuration 57 | # file where it should appear only once). You can also use "--disable=all" to 58 | # disable everything first and then reenable specific checks. For example, if 59 | # you want to run only the similarities checker, you can use "--disable=all 60 | # --enable=similarities". If you want to run only the classes checker, but have 61 | # no Warning level messages displayed, use "--disable=all --enable=classes 62 | # --disable=W". 63 | disable=print-statement, 64 | parameter-unpacking, 65 | unpacking-in-except, 66 | old-raise-syntax, 67 | backtick, 68 | long-suffix, 69 | old-ne-operator, 70 | old-octal-literal, 71 | import-star-module-level, 72 | non-ascii-bytes-literal, 73 | raw-checker-failed, 74 | bad-inline-option, 75 | locally-disabled, 76 | file-ignored, 77 | suppressed-message, 78 | useless-suppression, 79 | deprecated-pragma, 80 | use-symbolic-message-instead, 81 | apply-builtin, 82 | basestring-builtin, 83 | buffer-builtin, 84 | cmp-builtin, 85 | coerce-builtin, 86 | execfile-builtin, 87 | file-builtin, 88 | long-builtin, 89 | raw_input-builtin, 90 | reduce-builtin, 91 | standarderror-builtin, 92 | unicode-builtin, 93 | xrange-builtin, 94 | coerce-method, 95 | delslice-method, 96 | getslice-method, 97 | setslice-method, 98 | no-absolute-import, 99 | old-division, 100 | dict-iter-method, 101 | dict-view-method, 102 | next-method-called, 103 | metaclass-assignment, 104 | indexing-exception, 105 | raising-string, 106 | reload-builtin, 107 | oct-method, 108 | hex-method, 109 | nonzero-method, 110 | cmp-method, 111 | input-builtin, 112 | round-builtin, 113 | intern-builtin, 114 | unichr-builtin, 115 | map-builtin-not-iterating, 116 | zip-builtin-not-iterating, 117 | range-builtin-not-iterating, 118 | filter-builtin-not-iterating, 119 | using-cmp-argument, 120 | eq-without-hash, 121 | div-method, 122 | idiv-method, 123 | rdiv-method, 124 | exception-message-attribute, 125 | invalid-str-codec, 126 | sys-max-int, 127 | bad-python3-import, 128 | deprecated-string-function, 129 | deprecated-str-translate-call, 130 | deprecated-itertools-function, 131 | deprecated-types-field, 132 | next-method-defined, 133 | dict-items-not-iterating, 134 | dict-keys-not-iterating, 135 | dict-values-not-iterating, 136 | deprecated-operator-function, 137 | deprecated-urllib-function, 138 | xreadlines-attribute, 139 | deprecated-sys-function, 140 | exception-escape, 141 | comprehension-escape, 142 | missing-module-docstring, 143 | missing-class-docstring, 144 | missing-function-docstring 145 | 146 | # Enable the message, report, category or checker with the given id(s). You can 147 | # either give multiple identifier separated by comma (,) or put this option 148 | # multiple time (only on the command line, not in the configuration file where 149 | # it should appear only once). See also the "--disable" option for examples. 150 | enable=c-extension-no-member 151 | 152 | 153 | [REPORTS] 154 | 155 | # Python expression which should return a score less than or equal to 10. You 156 | # have access to the variables 'error', 'warning', 'refactor', and 'convention' 157 | # which contain the number of messages in each category, as well as 'statement' 158 | # which is the total number of statements analyzed. This score is used by the 159 | # global evaluation report (RP0004). 160 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 161 | 162 | # Template used to display messages. This is a python new-style format string 163 | # used to format the message information. See doc for all details. 164 | #msg-template= 165 | 166 | # Set the output format. Available formats are text, parseable, colorized, json 167 | # and msvs (visual studio). You can also give a reporter class, e.g. 168 | # mypackage.mymodule.MyReporterClass. 169 | output-format=text 170 | 171 | # Tells whether to display a full report or only the messages. 172 | reports=no 173 | 174 | # Activate the evaluation score. 175 | score=yes 176 | 177 | 178 | [REFACTORING] 179 | 180 | # Maximum number of nested blocks for function / method body 181 | max-nested-blocks=5 182 | 183 | # Complete name of functions that never returns. When checking for 184 | # inconsistent-return-statements if a never returning function is called then 185 | # it will be considered as an explicit return statement and no message will be 186 | # printed. 187 | never-returning-functions=sys.exit 188 | 189 | 190 | [FORMAT] 191 | 192 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 193 | expected-line-ending-format= 194 | 195 | # Regexp for a line that is allowed to be longer than the limit. 196 | ignore-long-lines=^\s*(# )??$ 197 | 198 | # Number of spaces of indent required inside a hanging or continued line. 199 | indent-after-paren=4 200 | 201 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 202 | # tab). 203 | indent-string=' ' 204 | 205 | # Maximum number of characters on a single line. 206 | max-line-length=120 207 | 208 | # Maximum number of lines in a module. 209 | max-module-lines=1000 210 | 211 | # List of optional constructs for which whitespace checking is disabled. `dict- 212 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 213 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 214 | # `empty-line` allows space-only lines. 215 | no-space-check=trailing-comma, 216 | dict-separator 217 | 218 | # Allow the body of a class to be on the same line as the declaration if body 219 | # contains single statement. 220 | single-line-class-stmt=no 221 | 222 | # Allow the body of an if to be on the same line as the test if there is no 223 | # else. 224 | single-line-if-stmt=no 225 | 226 | 227 | [MISCELLANEOUS] 228 | 229 | # List of note tags to take in consideration, separated by a comma. 230 | notes=FIXME, 231 | XXX, 232 | TODO 233 | 234 | # Regular expression of note tags to take in consideration. 235 | #notes-rgx= 236 | 237 | 238 | [TYPECHECK] 239 | 240 | # List of decorators that produce context managers, such as 241 | # contextlib.contextmanager. Add to this list to register other decorators that 242 | # produce valid context managers. 243 | contextmanager-decorators=contextlib.contextmanager 244 | 245 | # List of members which are set dynamically and missed by pylint inference 246 | # system, and so shouldn't trigger E1101 when accessed. Python regular 247 | # expressions are accepted. 248 | generated-members= 249 | 250 | # Tells whether missing members accessed in mixin class should be ignored. A 251 | # mixin class is detected if its name ends with "mixin" (case insensitive). 252 | ignore-mixin-members=yes 253 | 254 | # Tells whether to warn about missing members when the owner of the attribute 255 | # is inferred to be None. 256 | ignore-none=yes 257 | 258 | # This flag controls whether pylint should warn about no-member and similar 259 | # checks whenever an opaque object is returned when inferring. The inference 260 | # can return multiple potential results while evaluating a Python object, but 261 | # some branches might not be evaluated, which results in partial inference. In 262 | # that case, it might be useful to still emit no-member and other checks for 263 | # the rest of the inferred objects. 264 | ignore-on-opaque-inference=yes 265 | 266 | # List of class names for which member attributes should not be checked (useful 267 | # for classes with dynamically set attributes). This supports the use of 268 | # qualified names. 269 | ignored-classes=optparse.Values,thread._local,_thread._local 270 | 271 | # List of module names for which member attributes should not be checked 272 | # (useful for modules/projects where namespaces are manipulated during runtime 273 | # and thus existing member attributes cannot be deduced by static analysis). It 274 | # supports qualified module names, as well as Unix pattern matching. 275 | ignored-modules= 276 | 277 | # Show a hint with possible names when a member name was not found. The aspect 278 | # of finding the hint is based on edit distance. 279 | missing-member-hint=yes 280 | 281 | # The minimum edit distance a name should have in order to be considered a 282 | # similar match for a missing member name. 283 | missing-member-hint-distance=1 284 | 285 | # The total number of similar names that should be taken in consideration when 286 | # showing a hint for a missing member. 287 | missing-member-max-choices=1 288 | 289 | # List of decorators that change the signature of a decorated function. 290 | signature-mutators= 291 | 292 | 293 | [VARIABLES] 294 | 295 | # List of additional names supposed to be defined in builtins. Remember that 296 | # you should avoid defining new builtins when possible. 297 | additional-builtins= 298 | 299 | # Tells whether unused global variables should be treated as a violation. 300 | allow-global-unused-variables=yes 301 | 302 | # List of strings which can identify a callback function by name. A callback 303 | # name must start or end with one of those strings. 304 | callbacks=cb_, 305 | _cb 306 | 307 | # A regular expression matching the name of dummy variables (i.e. expected to 308 | # not be used). 309 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 310 | 311 | # Argument names that match this expression will be ignored. Default to name 312 | # with leading underscore. 313 | ignored-argument-names=_.*|^ignored_|^unused_ 314 | 315 | # Tells whether we should check for unused import in __init__ files. 316 | init-import=no 317 | 318 | # List of qualified module names which can have objects that can redefine 319 | # builtins. 320 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 321 | 322 | 323 | [SPELLING] 324 | 325 | # Limits count of emitted suggestions for spelling mistakes. 326 | max-spelling-suggestions=4 327 | 328 | # Spelling dictionary name. Available dictionaries: none. To make it work, 329 | # install the python-enchant package. 330 | spelling-dict= 331 | 332 | # List of comma separated words that should not be checked. 333 | spelling-ignore-words= 334 | 335 | # A path to a file that contains the private dictionary; one word per line. 336 | spelling-private-dict-file= 337 | 338 | # Tells whether to store unknown words to the private dictionary (see the 339 | # --spelling-private-dict-file option) instead of raising a message. 340 | spelling-store-unknown-words=no 341 | 342 | 343 | [SIMILARITIES] 344 | 345 | # Ignore comments when computing similarities. 346 | ignore-comments=yes 347 | 348 | # Ignore docstrings when computing similarities. 349 | ignore-docstrings=yes 350 | 351 | # Ignore imports when computing similarities. 352 | ignore-imports=no 353 | 354 | # Minimum lines number of a similarity. 355 | min-similarity-lines=4 356 | 357 | 358 | [LOGGING] 359 | 360 | # The type of string formatting that logging methods do. `old` means using % 361 | # formatting, `new` is for `{}` formatting. 362 | logging-format-style=old 363 | 364 | # Logging modules to check that the string format arguments are in logging 365 | # function parameter format. 366 | logging-modules=logging 367 | 368 | 369 | [STRING] 370 | 371 | # This flag controls whether inconsistent-quotes generates a warning when the 372 | # character used as a quote delimiter is used inconsistently within a module. 373 | check-quote-consistency=no 374 | 375 | # This flag controls whether the implicit-str-concat should generate a warning 376 | # on implicit string concatenation in sequences defined over several lines. 377 | check-str-concat-over-line-jumps=no 378 | 379 | 380 | [BASIC] 381 | 382 | # Naming style matching correct argument names. 383 | argument-naming-style=snake_case 384 | 385 | # Regular expression matching correct argument names. Overrides argument- 386 | # naming-style. 387 | #argument-rgx= 388 | 389 | # Naming style matching correct attribute names. 390 | attr-naming-style=snake_case 391 | 392 | # Regular expression matching correct attribute names. Overrides attr-naming- 393 | # style. 394 | #attr-rgx= 395 | 396 | # Bad variable names which should always be refused, separated by a comma. 397 | bad-names=foo, 398 | bar, 399 | baz, 400 | toto, 401 | tutu, 402 | tata 403 | 404 | # Bad variable names regexes, separated by a comma. If names match any regex, 405 | # they will always be refused 406 | bad-names-rgxs= 407 | 408 | # Naming style matching correct class attribute names. 409 | class-attribute-naming-style=any 410 | 411 | # Regular expression matching correct class attribute names. Overrides class- 412 | # attribute-naming-style. 413 | #class-attribute-rgx= 414 | 415 | # Naming style matching correct class names. 416 | class-naming-style=PascalCase 417 | 418 | # Regular expression matching correct class names. Overrides class-naming- 419 | # style. 420 | #class-rgx= 421 | 422 | # Naming style matching correct constant names. 423 | const-naming-style=UPPER_CASE 424 | 425 | # Regular expression matching correct constant names. Overrides const-naming- 426 | # style. 427 | #const-rgx= 428 | 429 | # Minimum line length for functions/classes that require docstrings, shorter 430 | # ones are exempt. 431 | docstring-min-length=-1 432 | 433 | # Naming style matching correct function names. 434 | function-naming-style=snake_case 435 | 436 | # Regular expression matching correct function names. Overrides function- 437 | # naming-style. 438 | #function-rgx= 439 | 440 | # Good variable names which should always be accepted, separated by a comma. 441 | good-names=i, 442 | j, 443 | k, 444 | ex, 445 | Run, 446 | _ 447 | 448 | # Good variable names regexes, separated by a comma. If names match any regex, 449 | # they will always be accepted 450 | good-names-rgxs= 451 | 452 | # Include a hint for the correct naming format with invalid-name. 453 | include-naming-hint=no 454 | 455 | # Naming style matching correct inline iteration names. 456 | inlinevar-naming-style=any 457 | 458 | # Regular expression matching correct inline iteration names. Overrides 459 | # inlinevar-naming-style. 460 | #inlinevar-rgx= 461 | 462 | # Naming style matching correct method names. 463 | method-naming-style=snake_case 464 | 465 | # Regular expression matching correct method names. Overrides method-naming- 466 | # style. 467 | #method-rgx= 468 | 469 | # Naming style matching correct module names. 470 | module-naming-style=snake_case 471 | 472 | # Regular expression matching correct module names. Overrides module-naming- 473 | # style. 474 | #module-rgx= 475 | 476 | # Colon-delimited sets of names that determine each other's naming style when 477 | # the name regexes allow several styles. 478 | name-group= 479 | 480 | # Regular expression which should only match function or class names that do 481 | # not require a docstring. 482 | no-docstring-rgx=^_ 483 | 484 | # List of decorators that produce properties, such as abc.abstractproperty. Add 485 | # to this list to register other decorators that produce valid properties. 486 | # These decorators are taken in consideration only for invalid-name. 487 | property-classes=abc.abstractproperty 488 | 489 | # Naming style matching correct variable names. 490 | variable-naming-style=snake_case 491 | 492 | # Regular expression matching correct variable names. Overrides variable- 493 | # naming-style. 494 | #variable-rgx= 495 | 496 | 497 | [CLASSES] 498 | 499 | # List of method names used to declare (i.e. assign) instance attributes. 500 | defining-attr-methods=__init__, 501 | __new__, 502 | setUp, 503 | __post_init__ 504 | 505 | # List of member names, which should be excluded from the protected access 506 | # warning. 507 | exclude-protected=_asdict, 508 | _fields, 509 | _replace, 510 | _source, 511 | _make 512 | 513 | # List of valid names for the first argument in a class method. 514 | valid-classmethod-first-arg=cls 515 | 516 | # List of valid names for the first argument in a metaclass class method. 517 | valid-metaclass-classmethod-first-arg=cls 518 | 519 | 520 | [IMPORTS] 521 | 522 | # List of modules that can be imported at any level, not just the top level 523 | # one. 524 | allow-any-import-level= 525 | 526 | # Allow wildcard imports from modules that define __all__. 527 | allow-wildcard-with-all=no 528 | 529 | # Analyse import fallback blocks. This can be used to support both Python 2 and 530 | # 3 compatible code, which means that the block might have code that exists 531 | # only in one or another interpreter, leading to false positives when analysed. 532 | analyse-fallback-blocks=no 533 | 534 | # Deprecated modules which should not be used, separated by a comma. 535 | deprecated-modules=optparse,tkinter.tix 536 | 537 | # Create a graph of external dependencies in the given file (report RP0402 must 538 | # not be disabled). 539 | ext-import-graph= 540 | 541 | # Create a graph of every (i.e. internal and external) dependencies in the 542 | # given file (report RP0402 must not be disabled). 543 | import-graph= 544 | 545 | # Create a graph of internal dependencies in the given file (report RP0402 must 546 | # not be disabled). 547 | int-import-graph= 548 | 549 | # Force import order to recognize a module as part of the standard 550 | # compatibility libraries. 551 | known-standard-library= 552 | 553 | # Force import order to recognize a module as part of a third party library. 554 | known-third-party=enchant 555 | 556 | # Couples of modules and preferred modules, separated by a comma. 557 | preferred-modules= 558 | 559 | 560 | [DESIGN] 561 | 562 | # Maximum number of arguments for function / method. 563 | max-args=5 564 | 565 | # Maximum number of attributes for a class (see R0902). 566 | max-attributes=7 567 | 568 | # Maximum number of boolean expressions in an if statement (see R0916). 569 | max-bool-expr=5 570 | 571 | # Maximum number of branch for function / method body. 572 | max-branches=12 573 | 574 | # Maximum number of locals for function / method body. 575 | max-locals=15 576 | 577 | # Maximum number of parents for a class (see R0901). 578 | max-parents=7 579 | 580 | # Maximum number of public methods for a class (see R0904). 581 | max-public-methods=20 582 | 583 | # Maximum number of return / yield for function / method body. 584 | max-returns=6 585 | 586 | # Maximum number of statements in function / method body. 587 | max-statements=50 588 | 589 | # Minimum number of public methods for a class (see R0903). 590 | min-public-methods=2 591 | 592 | 593 | [EXCEPTIONS] 594 | 595 | # Exceptions that will emit a warning when being caught. Defaults to 596 | # "BaseException, Exception". 597 | overgeneral-exceptions=BaseException, 598 | Exception 599 | --------------------------------------------------------------------------------