diff --git a/README.md b/README.md index 1db60db..24412fa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ Denon Remote ============ +Control Denon Professional DN-500AV surround preamplifier remotely. + ![Screenshot](screenshot-v0.3.0.png) Author: Raphael Doursenaud @@ -9,9 +11,10 @@ License: [GPLv3+](LICENSE) Language: [Python](https://python.org) 3 -Dependencies: +Fonts used: - [Unicode Power Symbol](https://unicodepowersymbol.com/) Copyright (c) 2013 Joe Loughry licensed under MIT +- [Free Serif](https://savannah.gnu.org/projects/freefont/) licensed under GPLv3 ### Features @@ -41,7 +44,7 @@ Dependencies: #### Controls -- [ ] Setup +- [x] Setup - [x] IP address - [ ] Serial port? - [ ] COM (Windows) @@ -83,6 +86,7 @@ Dependencies: - [ ] Left/Right VolPreset +/- - [ ] PgUp/PgDwn SrcPreset +/- - [x] Systray/Taskbar support using [pystray](https://pypi.org/project/pystray/) +- [ ] Only one instance should be allowed ##### Windows executable diff --git a/denonremote.spec b/denonremote.spec index 24e275f..8bf0a3d 100644 --- a/denonremote.spec +++ b/denonremote.spec @@ -9,10 +9,12 @@ block_cipher = None added_files = [ ('denonremote\\fonts', 'fonts'), ('denonremote\\images', 'images'), + ('denonremote\\settings', 'settings') ] dependencies = get_deps_all() # FIXME: minimize dependencies -dependencies['hiddenimports'].append('pystray._win32') +dependencies['hiddenimports'].append( + 'pystray._win32') # FIXME: use the hook at https://github.com/moses-palmer/pystray/issues/55 a = Analysis(['denonremote\\main.py'], pathex=['denonremote', '.\\venv\\Lib\\site-packages\\pystray'], diff --git a/denonremote/cli.py b/denonremote/cli.py index 70b46a1..28a4efe 100644 --- a/denonremote/cli.py +++ b/denonremote/cli.py @@ -1,10 +1,15 @@ +import kivy.config from twisted.internet import reactor -from config import RECEIVER_IP, RECEIVER_PORT from denon.communication import DenonClientFactory class DenonRemoteApp: def run(self): - reactor.connectTCP(RECEIVER_IP, RECEIVER_PORT, DenonClientFactory()) + # Get from config + # FIXME: or get from arguments + receiver_ip = kivy.config.Config.get('denonremote', 'receiver_ip') + receiver_port = kivy.config.Config.get('denonremote', 'receiver_port') + + reactor.connectTCP(receiver_ip, receiver_port, DenonClientFactory()) reactor.run() diff --git a/denonremote/config.py b/denonremote/config.py deleted file mode 100644 index efa2694..0000000 --- a/denonremote/config.py +++ /dev/null @@ -1,22 +0,0 @@ -from telnetlib import TELNET_PORT - -DEBUG = False - -GUI = True - -# TODO: override at build time -BUILD_DATE = '' - -RECEIVER_IP = '192.168.1.24' -RECEIVER_PORT = TELNET_PORT - -VOL_PRESET_1 = '-30.0dB' -VOL_PRESET_2 = '-24.0dB' -VOL_PRESET_3 = '-18.0dB' - -FAV_SRC_1_CODE = 'GAME' -FAV_SRC_1_LABEL = 'Computer HDMI' -FAV_SRC_2_CODE = 'CD' -FAV_SRC_2_LABEL = 'Pro Analog' -FAV_SRC_3_CODE = 'TV' -FAV_SRC_3_LABEL = 'Pro Digital' diff --git a/denonremote/denon/communication.py b/denonremote/denon/communication.py index 7642ae1..104d6c2 100644 --- a/denonremote/denon/communication.py +++ b/denonremote/denon/communication.py @@ -22,16 +22,26 @@ class DenonProtocol(LineOnlyReceiver): delimiter = b'\r' ongoing_calls = 0 # Delay handling. FIXME: should timeout after 200 ms. + def connectionMade(self): + logger.debug("Connection made") + if self.factory.gui: + self.factory.app.on_connection(self) + + def timeoutConnection(self): + logger.debug("Connection timed out") + self.transport.abortConnection() + if self.factory.gui: + self.factory.app.on_timeout() + def sendLine(self, line): if b'?' in line: # A request is made. We need to delay the next calls self.ongoing_calls += 1 logger.debug("Ongoing calls for delay: %s", self.ongoing_calls) - logger.debug("Will send line: %s", line) - if self.ongoing_calls: + delay = 0 + if self.ongoing_calls > 0: delay = self.DELAY * (self.ongoing_calls - 1) - else: - delay = self.DELAY + logger.debug("Will send line: %s in %f seconds", line, delay) return task.deferLater(reactor, delay=delay, callable=super().sendLine, line=line) @@ -74,10 +84,6 @@ def lineReceived(self, line): source = receiver.parameter_code self.factory.app.set_sources(source) - def connectionMade(self): - if self.factory.gui: - self.factory.app.on_connection(self) - def get_power(self): self.sendLine('PW?'.encode('ASCII')) @@ -132,3 +138,9 @@ def __init__(self, app): import kivy.logger global logger logger = kivy.logger.Logger + + def clientConnectionFailed(self, connector, reason): + self.app.on_connection_failed(connector, reason) + + def clientConnectionLost(self, connector, reason): + self.app.on_connection_lost(connector, reason) diff --git a/denonremote/denonremote.kv b/denonremote/denonremote.kv index 23c40fd..ad75c05 100644 --- a/denonremote/denonremote.kv +++ b/denonremote/denonremote.kv @@ -1,161 +1,173 @@ -#:import VOL_PRESET_1 config.VOL_PRESET_1 -#:import VOL_PRESET_2 config.VOL_PRESET_2 -#:import VOL_PRESET_3 config.VOL_PRESET_3 -#:import FAV_SRC_1_LABEL config.FAV_SRC_1_LABEL -#:import FAV_SRC_2_LABEL config.FAV_SRC_2_LABEL -#:import FAV_SRC_3_LABEL config.FAV_SRC_3_LABEL -#:import FAV_SRC_1_CODE config.FAV_SRC_1_CODE -#:import FAV_SRC_2_CODE config.FAV_SRC_2_CODE -#:import FAV_SRC_3_CODE config.FAV_SRC_3_CODE #:import VERSION main.__version__ -#:import DEBUG config.DEBUG -#:import BUILD_DATE config.BUILD_DATE #:import system platform.system -#:import BooleanProperty kivy.properties.BooleanProperty +#:import BUILD_DATE main.__BUILD_DATE__ FloatLayout: - Image: - id: denon_image - source: 'DN-500AV.png' - size: (0, 50) - size_hint: (.3, None) - pos_hint: {'top': .96, 'left': .99} - - Label: - id: name_label - text: "DENON REMOTE" - font_size: 40 - bold: True - size: (200, 50) - size_hint: (1, None) - pos_hint: {'top': .96} - color: [.75, .75, .75, 1] - - ToggleButton: - id: power - text: "⏻" - font_name: 'Unicode_IEC_symbol' - font_size: 50 - size: (80, 72) - size_hint: (None, None) - pos_hint: {'top': .983, 'right': .89} - color: [.1, .8, .1, 1] if self.state == 'down' else [.8, .1, .1, 1] # Green when down otherwise red - background_color: [.25, .25, .25, 1] - on_press: app.power_pressed(self) - - BoxLayout: - id: content - orientation: 'vertical' - spacing: 15 - size_hint: (1, .75) - pos: (0, 63) - pos_hint: {'top': .85} + FloatLayout: + id: main + disabled: True + + Image: + id: denon_image + source: 'DN-500AV.png' + size: (0, 50) + size_hint: (.25, None) + pos_hint: {'top': .96, 'left': 1} + + Label: + id: name + text: "DENON REMOTE" + font_size: 50 + bold: True + size: (200, 50) + size_hint: (1, None) + pos_hint: {'top': .96} + color: [.75, .75, .75, 1] + + ToggleButton: + id: power + text: "⏻" + font_name: 'Unicode_IEC_symbol' + font_size: 50 + size: (80, 72) + size_hint: (None, None) + pos_hint: {'top': .983, 'right': .89} + color: [.1, .8, .1, 1] if self.state == 'down' else [.8, .1, .1, 1] # Green when down otherwise red + background_color: [.25, .25, .25, 1] + on_press: app.power_pressed(self) BoxLayout: - id: volume_section + id: content orientation: 'vertical' - disabled: False if root.ids.power.state == 'down' else True - - TextInput: - id: volume_display - text: "---.-dB" - font_name: 'RobotoMono-Regular' - font_size: 36 - halign: 'center' - multiline: False - size: (200, 60) - size_hint: (1, None) - foreground_color: [.85, .85, .85, 1] - background_color: [.1, .1, .1, 1] - on_text_validate: app.volume_text_changed(self) + spacing: 15 + size_hint: (1, .75) + pos: (0, 63) + pos_hint: {'top': .85} BoxLayout: - id: volume_keys_layout - orientation: 'horizontal' + id: volume_section + orientation: 'vertical' + disabled: False if root.ids.power.state == 'down' else True + + TextInput: + id: volume_display + text: "---.-dB" + font_name: 'RobotoMono-Regular' + font_size: 36 + halign: 'center' + multiline: False + size: (200, 60) + size_hint: (1, None) + foreground_color: [.85, .85, .85, 1] + background_color: [.1, .1, .1, 1] + on_text_validate: app.volume_text_changed(self) + + BoxLayout: + id: volume_keys_layout + orientation: 'horizontal' + + Button + id: volume_minus + text: "-" + on_press: app.volume_minus_pressed(self) + + Button: + id: volume_plus + text: "+" + on_press: app.volume_plus_pressed(self) - Button - id: volume_minus - text: "-" - on_press: app.volume_minus_pressed(self) - - Button: - id: volume_plus - text: "+" - on_press: app.volume_plus_pressed(self) - - ToggleButton: - id: volume_mute - text: "Mute" - group: 'mute' - on_press: app.volume_mute_pressed(self) + ToggleButton: + id: volume_mute + text: "Mute" + group: 'mute' + on_press: app.volume_mute_pressed(self) + + BoxLayout + id: volume_presets_layout + orientation: 'horizontal' + + ToggleButton: + id: vol_preset_1 + text: app.config.get('denonremote', 'vol_preset_1') + group: 'vol_preset' + on_press: app.vol_preset_1_pressed(self) + + ToggleButton: + id: vol_preset_2 + text: app.config.get('denonremote', 'vol_preset_2') + group: 'vol_preset' + on_press: app.vol_preset_2_pressed(self) + + ToggleButton: + id: vol_preset_3 + text: app.config.get('denonremote', 'vol_preset_3') + group: 'vol_preset' + on_press: app.vol_preset_3_pressed(self) - BoxLayout - id: volume_presets_layout - orientation: 'horizontal' + BoxLayout: + id: sources_section + orientation: 'vertical' ToggleButton: - id: vol_preset_1 - text: VOL_PRESET_1 # FIXME: get from config - group: 'vol_preset' - on_press: app.vol_preset_1_pressed(self) + id: fav_src_1 + text: app.config.get('denonremote', 'fav_src_1_label') + group: 'sources' + on_press: app.fav_src_1_pressed(self) ToggleButton: - id: vol_preset_2 - text: VOL_PRESET_2 # FIXME: get from config - group: 'vol_preset' - on_press: app.vol_preset_2_pressed(self) + id: fav_src_2 + text: app.config.get('denonremote', 'fav_src_2_label') + group: 'sources' + on_press: app.fav_src_2_pressed(self) ToggleButton: - id: vol_preset_3 - text: VOL_PRESET_3 # FIXME: get from config - group: 'vol_preset' - on_press: app.vol_preset_3_pressed(self) + id: fav_src_3 + text: app.config.get('denonremote', 'fav_src_3_label') + group: 'sources' + on_press: app.fav_src_3_pressed(self) - BoxLayout: - id: sources_section - orientation: 'vertical' - ToggleButton: - id: fav_src_1 - text: FAV_SRC_1_LABEL # FIXME: get from config - group: 'sources' - on_press: app.fav_src_1_pressed(self) - - ToggleButton: - id: fav_src_2 - text: FAV_SRC_2_LABEL # FIXME: get from config - group: 'sources' - on_press: app.fav_src_2_pressed(self) - - ToggleButton: - id: fav_src_3 - text: FAV_SRC_3_LABEL # FIXME: get from config - group: 'sources' - on_press: app.fav_src_3_pressed(self) - - BoxLayout: - id: brand_layout - orientation: 'vertical' - size: (200, 65) + FloatLayout: + id: footer + disabled: False + size: (1, 60) size_hint: (1, None) - pos_hint: {'bottom': 1} - - Label: - id: brand_label - text: "EMA Tech." - Label: - id: version_label - text: "v%s %s (Built on %s)" % (VERSION, system(), BUILD_DATE) # FIXME: get from config - font_size: 10 - - TextInput: - id: debug_messages - text: "Initializing GUI...\n" - readonly: True - background_color: [0, 0, 0, 1] - foreground_color: [0, 1, 0, 1] - size: (200, 65) - size_hint: (1, None) + Button: + id: settings + text: "⚙" + font_name: 'FreeSerif' + font_size: 50 + size: (80, 60) + size_hint: (.25, None) + pos_hint: {'bottom': 1} + background_color: [0, 0, 0, 1] + disabled: False + on_press: app.open_settings(self) + BoxLayout: + id: brand_layout + orientation: 'vertical' + size: (200, 60) + size_hint: (1, None) + pos_hint: {'bottom': 1} + + Label: + id: brand_label + text: "EMA Tech." + + Label: + id: version_label + text: "v%s %s (Built on %s)" % (VERSION, system(), BUILD_DATE) + font_size: 10 + + TextInput: + id: debug_messages + text: "Initializing GUI...\n" + readonly: True + background_color: [0, 0, 0, 1] + foreground_color: [0, 1, 0, 1] + size: (200, 60) + size_hint: (.333, None) + pos_hint: {'bottom': 1, 'right': 1} diff --git a/denonremote/fonts/FreeSerif.ttf b/denonremote/fonts/FreeSerif.ttf new file mode 100644 index 0000000..889c594 Binary files /dev/null and b/denonremote/fonts/FreeSerif.ttf differ diff --git a/denonremote/gui.py b/denonremote/gui.py index 2e12cc1..142bc65 100644 --- a/denonremote/gui.py +++ b/denonremote/gui.py @@ -2,22 +2,23 @@ import os import sys +KIVY_NO_ARGS = 1 + import kivy.app import kivy.core import kivy.core.window import kivy.logger import kivy.resources import kivy.support -# FIXME: should be in Config object? +import kivy.uix.settings import pystray from kivy.clock import mainthread -from config import RECEIVER_IP, RECEIVER_PORT, VOL_PRESET_1, VOL_PRESET_2, VOL_PRESET_3, FAV_SRC_1_CODE, \ - FAV_SRC_2_CODE, FAV_SRC_3_CODE, DEBUG - # fix for pyinstaller packages app to avoid ReactorAlreadyInstalledError # See: https://github.com/kivy/kivy/issues/4182 # See: https://github.com/pyinstaller/pyinstaller/issues/3390 +from main import TITLE + if 'twisted.internet.reactor' in sys.modules: del sys.modules['twisted.internet.reactor'] @@ -30,7 +31,7 @@ logger = kivy.logger.Logger -APP_PATHS = ['fonts', 'images'] +APP_PATHS = ['fonts', 'images', 'settings'] # PyInstaller data support for path in APP_PATHS: @@ -45,42 +46,127 @@ class DenonRemoteApp(kivy.app.App): A remote for the Denon DN-500AV Receiver """ - title = "Denon Remote" + title: str = TITLE """Application title""" - icon = 'icon.png' + icon: str = 'icon.png' """Application icon""" - client = None - """Twisted IP client to the receiver""" + connector: twisted.internet.tcp.Connector = None + """Twisted connector""" + + client: DenonClientGUIFactory = None + """Twisted client of the receiver""" systray: pystray.Icon = None - hidden = True if kivy.config.Config.get('graphics', 'window_state') == 'hidden' else False + hidden: bool = True if kivy.config.Config.get('graphics', 'window_state') == 'hidden' else False + + settings_cls: kivy.uix.settings.Settings = kivy.uix.settings.SettingsWithSidebar + + def get_application_config(self, **kwargs): + """ + Store config into user directory + """ + return super().get_application_config('~/.%(appname)s.ini') + + def build_config(self, config): + config.adddefaultsection('denonremote') + from telnetlib import TELNET_PORT + config.setdefaults('denonremote', { + 'debug': False, + 'receiver_ip': '192.168.x.y', + 'receiver_port': TELNET_PORT, + 'vol_preset_1': '-30.0dB', + 'vol_preset_2': '-24.0dB', + 'vol_preset_3': '-18.0dB', + 'fav_src_1_code': 'GAME', + 'fav_src_1_label': 'Computer HDMI', + 'fav_src_2_code': 'CD', + 'fav_src_2_label': 'Pro Analog', + 'fav_src_3_code': 'TV', + 'fav_src_3_label': 'Pro Digital' + }) + + def build_settings(self, settings): + settings.add_json_panel("Communication", self.config, + filename=kivy.resources.resource_find('communication.json')) + settings.add_json_panel("Volume presets", self.config, + filename=kivy.resources.resource_find('volume.json')) + settings.add_json_panel("Favorite source 1", self.config, + filename=kivy.resources.resource_find('source1.json')) + settings.add_json_panel("Favorite source 2", self.config, + filename=kivy.resources.resource_find('source2.json')) + settings.add_json_panel("Favorite source 3", self.config, + filename=kivy.resources.resource_find('source3.json')) + + def on_config_change(self, config, section, key, value): + if config is self.config: + if section == 'denonremote': + if key == 'receiver_ip': + self._disconnect() + self._connect() + if key == 'vol_preset_1': + self.root.ids.vol_preset_1.text = value + if key == 'vol_preset_2': + self.root.ids.vol_preset_2.text = value + if key == 'vol_preset_3': + self.root.ids.vol_preset_3.text = value + if key == 'fav_src_1_label': + self.root.ids.fav_src_1.text = value + if key == 'fav_src_2_label': + self.root.ids.fav_src_2.text = value + if key == 'fav_src_3_label': + self.root.ids.fav_src_3.text = value + + def open_settings(self, *largs): + self.disable_keyboard_shortcuts() + super().open_settings() + + def close_settings(self, *largs): + self.enable_keyboard_shortcuts() + super().close_settings() def run_with_systray(self, systray): self.systray = systray super().run() + def _connect(self): + self.print_debug('Connecting to ' + self.config.get('denonremote', 'receiver_ip') + '...') + + client_factory = DenonClientGUIFactory(self) + self.connector = twisted.internet.reactor.connectTCP( + host=self.config.get('denonremote', 'receiver_ip'), + port=self.config.getint('denonremote', 'receiver_port'), + factory=client_factory, + timeout=1) + + def _disconnect(self): + if self.connector is not None: + self.print_debug('Disconnecting') + self.connector = self.connector.disconnect() + def on_start(self): """ Fired by Kivy on application startup :return: """ - self.systray.visible = True + if self.systray is not None: + self.systray.visible = True - # Hide window into systray - kivy.core.window.Window.bind(on_request_close=self.hide_on_close) - kivy.core.window.Window.bind(on_minimize=self.hide) - # Enable keyboard shortcuts - kivy.core.window.Window.bind(on_keyboard=self.on_keyboard) + # Hide window into systray + kivy.core.window.Window.bind(on_request_close=self.hide_on_close) + kivy.core.window.Window.bind(on_minimize=self.hide) + + self.enable_keyboard_shortcuts() - if not DEBUG: + if not self.config.getboolean('denonremote', 'debug'): # Hide debug_messages self.root.ids.debug_messages.size = (0, 0) + # Hide Kivy settings + self.use_kivy_settings: bool = False - self.print_debug('Connecting to ' + RECEIVER_IP + '...') - twisted.internet.reactor.connectTCP(RECEIVER_IP, RECEIVER_PORT, DenonClientGUIFactory(self)) + self._connect() def on_stop(self): """ @@ -108,12 +194,12 @@ def on_resume(self): def on_connection(self, connection): """ - Fired by Kivy when the Twisted reactor is connected + Fired by the Twisted client when the reactor is connected :param connection: :return: """ - self.print_debug('Connection successful!') + self.print_debug("Connection successful!") self.client = connection self.client.get_power() @@ -121,13 +207,31 @@ def on_connection(self, connection): self.client.get_mute() self.client.get_source() + self.root.ids.main.disabled = False + + def on_connection_failed(self, connector, reason): + if self.connector is connector: + logger.debug("Connection failed: %s", reason) + self.print_debug("Connection to receiver failed!") + # TODO: open error popup + self.root.ids.main.disabled = True + self.open_settings() + + def on_connection_lost(self, connector, reason): + if self.connector is connector: + logger.debug("Connection lost: %s", reason) + self.print_debug("Connection to receiver lost!") + # TODO: open error popup + + self.root.ids.main.disabled = True + @mainthread def show(self, window=None): if window is None: window = self.root_window window.restore() - window.raise_window() window.show() + window.raise_window() self.hidden = False @mainthread @@ -142,6 +246,12 @@ def hide_on_close(self, window, source=None): self.hide(window) return True # Keeps the application alive instead of stopping + def enable_keyboard_shortcuts(self): + kivy.core.window.Window.bind(on_keyboard=self.on_keyboard) + + def disable_keyboard_shortcuts(self): + kivy.core.window.Window.unbind(on_keyboard=self.on_keyboard) + def on_keyboard(self, window, key, scancode, codepoint, modifier): """ Handle keyboard shortcuts @@ -173,15 +283,15 @@ def power_pressed(self, instance): def update_volume(self, text=""): self.root.ids.volume_display.text = text - if text in VOL_PRESET_1: + if text in self.config.get('denonremote', 'vol_preset_1'): self.root.ids.vol_preset_1.state = 'down' else: self.root.ids.vol_preset_1.state = 'normal' - if text in VOL_PRESET_2: + if text in self.config.get('denonremote', 'vol_preset_2'): self.root.ids.vol_preset_2.state = 'down' else: self.root.ids.vol_preset_2.state = 'normal' - if text in VOL_PRESET_3: + if text in self.config.get('denonremote', 'vol_preset_3'): self.root.ids.vol_preset_3.state = 'down' else: self.root.ids.vol_preset_3.state = 'normal' @@ -214,27 +324,27 @@ def set_volume_mute(self, status=False): self.root.ids.volume_display.foreground_color = [.85, .85, .85, 1] def vol_preset_1_pressed(self, instance): - self.client.set_volume(VOL_PRESET_1) + self.client.set_volume(self.config.get('denonremote', 'vol_preset_1')) instance.state = 'down' # Disallow depressing the button manually def vol_preset_2_pressed(self, instance): - self.client.set_volume(VOL_PRESET_2) + self.client.set_volume(self.config.get('denonremote', 'vol_preset_2')) instance.state = 'down' def vol_preset_3_pressed(self, instance): - self.client.set_volume(VOL_PRESET_3) + self.client.set_volume(self.config.get('denonremote', 'vol_preset_3')) instance.state = 'down' def set_sources(self, source=None): - if source in FAV_SRC_1_CODE: + if source in self.config.get('denonremote', 'fav_src_1_code'): self.root.ids.fav_src_1.state = 'down' else: self.root.ids.fav_src_1.state = 'normal' - if source in FAV_SRC_2_CODE: + if source in self.config.get('denonremote', 'fav_src_2_code'): self.root.ids.fav_src_2.state = 'down' else: self.root.ids.fav_src_2.state = 'normal' - if source in FAV_SRC_3_CODE: + if source in self.config.get('denonremote', 'fav_src_3_code'): self.root.ids.fav_src_3.state = 'down' else: self.root.ids.fav_src_3.state = 'normal' @@ -242,15 +352,15 @@ def set_sources(self, source=None): # TODO: display other sources def fav_src_1_pressed(self, instance): - self.client.set_source(FAV_SRC_1_CODE) + self.client.set_source(self.config.get('denonremote', 'fav_src_1_code')) instance.state = 'down' # Disallow depressing the button manually def fav_src_2_pressed(self, instance): - self.client.set_source(FAV_SRC_2_CODE) + self.client.set_source(self.config.get('denonremote', 'fav_src_2_code')) instance.state = 'down' def fav_src_3_pressed(self, instance): - self.client.set_source(FAV_SRC_3_CODE) + self.client.set_source(self.config.get('denonremote', 'fav_src_3_code')) instance.state = 'down' def print_debug(self, msg): diff --git a/denonremote/main.py b/denonremote/main.py index ac9532c..6176917 100644 --- a/denonremote/main.py +++ b/denonremote/main.py @@ -7,23 +7,24 @@ @author Raphael Doursenaud """ -__author__ = 'Raphaël Doursenaud ' - -__version__ = '0.4.0' # FIXME: use setuptools +TITLE = "Denon Remote" +__version__ = "0.5.0" # FIXME: use setuptools +__BUILD_DATE__ = "" # TODO: override at build time +import argparse import logging +import os +import sys import PIL.Image import pystray -from config import DEBUG, GUI - logger = logging.getLogger() -def resource_path(relative_path): +# FIXME: use kivy.resources.resource_find instead +def resource_path(relative_path: str): """ Get absolute path to resource, works for dev and for PyInstaller """ - import os, sys if hasattr(sys, '_MEIPASS'): # PyInstaller creates a temp folder and stores path in _MEIPASS base_path = sys._MEIPASS @@ -33,49 +34,74 @@ def resource_path(relative_path): return os.path.join(base_path, relative_path) -def init_logging(): - global logger - - if not GUI: - if DEBUG: - logging.basicConfig(level=logging.DEBUG) - else: - logging.basicConfig(level=logging.INFO) - logging.getLogger('denon.dn500av').setLevel(logging.WARNING) # Silence module’s logging +def run_gui(systray: pystray.Icon = None): + from gui import DenonRemoteApp + if systray is not None: + DenonRemoteApp().run_with_systray(systray) else: - import kivy.logger - logger = kivy.logger.Logger - logging.Logger.manager.root = kivy.logger.Logger # Hack to retrieve logs from modules using standard logging into Kivy - if DEBUG: - logger.setLevel(kivy.logger.LOG_LEVELS['debug']) - else: - logger.setLevel(kivy.logger.LOG_LEVELS['info']) - logging.getLogger('denon.dn500av').setLevel(logging.WARNING) # Silence module’s logging - - -def run_from_systray(): - default_menu_item = pystray.MenuItem('Denon Remote', systray_clicked, default=True, visible=False) + DenonRemoteApp().run() + + +def run_gui_from_systray(): + default_menu_item = pystray.MenuItem(TITLE, systray_clicked, default=True, visible=False) quit_menu_item = pystray.MenuItem('Quit', quit_systray) systray_menu = pystray.Menu(default_menu_item, quit_menu_item) - systray = pystray.Icon('Denon Remote', menu=systray_menu) + systray = pystray.Icon(TITLE, menu=systray_menu) systray.icon = PIL.Image.open(resource_path(r'images/icon.png')) systray.run(setup=run_gui) -def run_gui(systray): +def configure(args: argparse.Namespace): import kivy.config - kivy.config.Config.set('kivy', 'window_icon', 'images/icon.png') + + # App specific configuration + # FIXME: use same implementation as Kivy to avoid issues + kivy.config.Config.read(os.path.expanduser('~/.denonremote.ini')) + + # Make sure we have our section + kivy.config.Config.adddefaultsection('denonremote') + # Fixed size window kivy.config.Config.set('graphics', 'resizable', False) - # Start hidden - kivy.config.Config.set('graphics', 'window_state', 'hidden') + # wm_pen and wm_touch conflicts with hidden window state. See https://github.com/kivy/kivy/issues/6428 kivy.config.Config.remove_option('input', 'wm_pen') kivy.config.Config.remove_option('input', 'wm_touch') + + # Hide annoying multitouch emulation + kivy.config.Config.set('input', 'mouse', 'mouse,multitouch_on_demand') + + # Start hidden when using systray + if args.no_systray: + kivy.config.Config.set('graphics', 'window_state', 'visible') + else: + kivy.config.Config.set('graphics', 'window_state', 'hidden') + + if args.debug: + kivy.config.Config.set('kivy', 'log_level', 'debug') + kivy.config.Config.set('denonremote', 'debug', '1') + else: + kivy.config.Config.set('kivy', 'log_level', 'warning') + kivy.config.Config.set('denonremote', 'debug', '0') + kivy.config.Config.write() - from gui import DenonRemoteApp - DenonRemoteApp().run_with_systray(systray) + +def init_logging(): + global logger + + import kivy.logger + logging.shutdown() + logger = kivy.logger.Logger + + # Replace python logger by Kivy logger to retrieve module logs + logging.Logger.manager.root = kivy.logger.Logger + + # Retrieve log level from config + log_level_name = kivy.config.Config.get('kivy', 'log_level') + log_level = kivy.logger.LOG_LEVELS[log_level_name] + logger.setLevel(log_level) + logging.getLogger('denon.dn500av').setLevel(log_level) # Sync module’s logging level def systray_clicked(icon: pystray.Icon, menu: pystray.MenuItem): @@ -98,14 +124,29 @@ def run_cli(): DenonRemoteApp().run() -def run(): - # FIXME: autodetect when running from CLI - if GUI: - run_from_systray() - else: +def run(args: argparse.Namespace): + if False: # FIXME: implement CLI commands run_cli() + elif args.no_systray: + run_gui() + else: + run_gui_from_systray() + + +def parse_args(): + # Disable Kivy arguments handling + os.environ["KIVY_NO_ARGS"] = "1" + + parser = argparse.ArgumentParser(prog=TITLE, + description="Control Denon Professional DN-500AV surround preamplifier remotely") + parser.add_argument('--debug', dest='debug', action='store_true', default=False, + help="Enable debugging output") + parser.add_argument('--no-systray', action='store_true', help="Disable systray") + return parser.parse_args() if __name__ == '__main__': + arguments = parse_args() + configure(arguments) init_logging() - run() + run(arguments) diff --git a/denonremote/settings/communication.json b/denonremote/settings/communication.json new file mode 100644 index 0000000..103d6c5 --- /dev/null +++ b/denonremote/settings/communication.json @@ -0,0 +1,9 @@ +[ + { + "type": "string", + "title": "Receiver IP address or network name", + "desc": "Set the receiver's IP address or name.\n(Menu > Network Setup > Network Info. > IP Address)", + "section": "denonremote", + "key": "receiver_ip" + } +] \ No newline at end of file diff --git a/denonremote/settings/source1.json b/denonremote/settings/source1.json new file mode 100644 index 0000000..6d890de --- /dev/null +++ b/denonremote/settings/source1.json @@ -0,0 +1,16 @@ +[ + { + "type": "string", + "title": "Label", + "desc": "Set favorite source 1 custom label", + "section": "denonremote", + "key": "fav_src_1_label" + }, + { + "type": "string", + "title": "Code", + "desc": "Set favorite source 1 code", + "section": "denonremote", + "key": "fav_src_1_code" + } +] \ No newline at end of file diff --git a/denonremote/settings/source2.json b/denonremote/settings/source2.json new file mode 100644 index 0000000..b04a7d9 --- /dev/null +++ b/denonremote/settings/source2.json @@ -0,0 +1,16 @@ +[ + { + "type": "string", + "title": "Label", + "desc": "Set favorite source 2 custom label", + "section": "denonremote", + "key": "fav_src_2_label" + }, + { + "type": "string", + "title": "Code", + "desc": "Set favorite source 2 code", + "section": "denonremote", + "key": "fav_src_2_code" + } +] \ No newline at end of file diff --git a/denonremote/settings/source3.json b/denonremote/settings/source3.json new file mode 100644 index 0000000..3ddab28 --- /dev/null +++ b/denonremote/settings/source3.json @@ -0,0 +1,16 @@ +[ + { + "type": "string", + "title": "Label", + "desc": "Set favorite source 3 custom label", + "section": "denonremote", + "key": "fav_src_3_label" + }, + { + "type": "string", + "title": "Code", + "desc": "Set favorite source 3 code", + "section": "denonremote", + "key": "fav_src_3_code" + } +] \ No newline at end of file diff --git a/denonremote/settings/volume.json b/denonremote/settings/volume.json new file mode 100644 index 0000000..dc5f570 --- /dev/null +++ b/denonremote/settings/volume.json @@ -0,0 +1,23 @@ +[ + { + "type": "string", + "title": "Preset 1", + "desc": "Set the volume of preset 1", + "section": "denonremote", + "key": "vol_preset_1" + }, + { + "type": "string", + "title": "Preset 2", + "desc": "Set the volume of preset 2", + "section": "denonremote", + "key": "vol_preset_2" + }, + { + "type": "string", + "title": "Preset 3", + "desc": "Set the volume of preset 3", + "section": "denonremote", + "key": "vol_preset_3" + } +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 6d41a49..44e5335 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ -twisted -kivy -pystray -PyInstaller \ No newline at end of file +twisted~=21.2.0 +kivy~=2.0.0 +pystray~=0.17.2 +Pillow~=8.1.2 +PyInstaller +buildozer \ No newline at end of file