diff --git a/.gitignore b/.gitignore index b4e6c29..1f05d7f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ build dist __pycache__ # Asset sources -sources +assets diff --git a/README.md b/README.md index 7c139dc..c623cca 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Denon Remote ============ -![Screenshot](screenshot-v0.2.0.png) +![Screenshot](screenshot-v0.3.0.png) Author: Raphael Doursenaud @@ -85,6 +85,11 @@ Dependencies: - [ ] The Pythonic Way - [ ] Handle shutdown to power off the device - [x] PyInstaller + - [x] Generate icon with [IconMaker](https://github.com/Inedo/iconmaker) + - [x] [UPX](https://upx.github.io/) support + - How to build: + - Review [denonremote.spec](denonremote.spec) + - Use `python -m PyInstaller denonremote.spec --upx-dir=c:\upx-3.96-win64` - [ ] VST plugin? (Not required if MIDI input is implemented but would be neat to have in the monitoring section of a DAW) - [ ] See [PyVST](https://pypi.org/project/pyvst/) diff --git a/constraints.txt b/constraints.txt deleted file mode 100644 index 6f799fb..0000000 --- a/constraints.txt +++ /dev/null @@ -1,2 +0,0 @@ -# Twisted doesn't support python 3.9 yet -python <= 3.8 \ No newline at end of file diff --git a/denonremote.spec b/denonremote.spec index 1b82830..4038dce 100644 --- a/denonremote.spec +++ b/denonremote.spec @@ -2,28 +2,29 @@ from kivy_deps import sdl2, glew +# Minimize dependencies bundling +from kivy.tools.packaging.pyinstaller_hooks import get_deps_minimal, get_deps_all, hookspath, runtime_hooks + block_cipher = None added_files = [ - ( 'Unicode_IEC_symbol.ttf', '.' ), - ('assets', 'assets') + ('denonremote\\fonts', 'fonts'), + ('denonremote\\images', 'images') ] -a = Analysis(['main.py'], - pathex=['G:\\raph\\Documents\\GitHub\\denonremote'], - binaries=[], +a = Analysis(['denonremote\\main.py'], + pathex=['denonremote'], datas=added_files, - hiddenimports=[], - hookspath=[], + hookspath=hookspath(), runtime_hooks=[], - excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, - noarchive=False) + noarchive=False, + **get_deps_all()) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) -exe = EXE(pyz, Tree('G:\\raph\\Documents\\GitHub\\denonremote\\'), +exe = EXE(pyz, Tree('denonremote'), a.scripts, a.binaries, a.zipfiles, @@ -37,4 +38,5 @@ exe = EXE(pyz, Tree('G:\\raph\\Documents\\GitHub\\denonremote\\'), upx=True, upx_exclude=[], runtime_tmpdir=None, - console=False) + console=False, + icon='icon.ico') diff --git a/cli.py b/denonremote/cli.py similarity index 100% rename from cli.py rename to denonremote/cli.py diff --git a/config.py b/denonremote/config.py similarity index 100% rename from config.py rename to denonremote/config.py diff --git a/denon/__init__.py b/denonremote/denon/__init__.py similarity index 100% rename from denon/__init__.py rename to denonremote/denon/__init__.py diff --git a/denon/communication.py b/denonremote/denon/communication.py similarity index 71% rename from denon/communication.py rename to denonremote/denon/communication.py index aa70d45..7642ae1 100644 --- a/denon/communication.py +++ b/denonremote/denon/communication.py @@ -6,13 +6,9 @@ from twisted.internet.protocol import ClientFactory from twisted.protocols.basic import LineOnlyReceiver -from config import GUI -from denon.dn500av import DN500AVMessages, DN500AVFormat +from denon.dn500av import DN500AVMessage, DN500AVFormat -if GUI: - from kivy import Logger - - logger = Logger +logger = logging.getLogger(__name__) # TODO: Implement Serial ? @@ -24,40 +20,35 @@ class DenonProtocol(LineOnlyReceiver): MAX_LENGTH = 135 DELAY = 0.04 # in seconds. The documentation requires 200 ms. 40 ms seems safe. delimiter = b'\r' - ongoing_calls = -1 # Delay handling. FIXME: should timeout after 200 ms. - - logger = None + ongoing_calls = 0 # Delay handling. FIXME: should timeout after 200 ms. def sendLine(self, line): if b'?' in line: # A request is made. We need to delay the next calls self.ongoing_calls += 1 - self.logger.debug("Ongoing calls for delay: %s", self.ongoing_calls) - self.logger.debug("Will send line: %s", line) - return task.deferLater(reactor, delay=self.DELAY * self.ongoing_calls, callable=super().sendLine, line=line) + logger.debug("Ongoing calls for delay: %s", self.ongoing_calls) + logger.debug("Will send line: %s", line) + if self.ongoing_calls: + delay = self.DELAY * (self.ongoing_calls - 1) + else: + delay = self.DELAY + return task.deferLater(reactor, delay=delay, + callable=super().sendLine, line=line) def lineReceived(self, line): if self.ongoing_calls: # We received a reply self.ongoing_calls -= 1 - self.logger.debug("Ongoing calls for delay: %s", self.ongoing_calls) - receiver = DN500AVMessages(logger=self.logger) + logger.debug("Ongoing calls for delay: %s", self.ongoing_calls) + receiver = DN500AVMessage() receiver.parse_response(line) - self.logger.info("Received line: %s", receiver.response) - if self.factory.gui: - self.factory.app.print_message(receiver.response) - # FIXME: abstract - # MUTE - if receiver.command_code == 'MU': - state = False - if receiver.parameter_code == 'ON': - state = True - self.factory.app.update_volume_mute(state) + logger.info("Received line: %s", receiver.response) - # VOLUME - if receiver.command_code == 'MV': - if receiver.subcommand_code is None: - self.factory.app.update_volume(receiver.parameter_label) + # FIXME: parse message into state + + # FIXME: abstract away with a callback to the factory + if self.factory.gui: + self.factory.app.print_debug(receiver.response) # POWER if receiver.command_code == 'PW': @@ -66,10 +57,22 @@ def lineReceived(self, line): state = False self.factory.app.update_power(state) + # VOLUME + if receiver.command_code == 'MV': + if receiver.subcommand_code is None: + self.factory.app.update_volume(receiver.parameter_label) + + # MUTE + if receiver.command_code == 'MU': + state = False + if receiver.parameter_code == 'ON': + state = True + self.factory.app.set_volume_mute(state) + # SOURCE if receiver.command_code == 'SI': source = receiver.parameter_code - self.factory.app.update_source(source) + self.factory.app.set_sources(source) def connectionMade(self): if self.factory.gui: @@ -79,7 +82,7 @@ def get_power(self): self.sendLine('PW?'.encode('ASCII')) def set_power(self, state): - self.logger.debug("Entering power callback") + logger.debug("Entering power callback") if state: self.sendLine('PWON'.encode('ASCII')) else: @@ -91,7 +94,7 @@ def get_volume(self): def set_volume(self, value): rawvalue = DN500AVFormat().mv_reverse_params.get(value) if rawvalue is None: - self.logger.warning("Set volume value %s is invalid.", value) + logger.warning("Set volume value %s is invalid.", value) else: message = 'MV' + rawvalue self.sendLine(message.encode('ASCII')) @@ -118,7 +121,6 @@ class DenonClientFactory(ClientFactory): def __init__(self): self.gui = False - self.protocol.logger = logging.getLogger(__name__) class DenonClientGUIFactory(ClientFactory): @@ -127,4 +129,6 @@ class DenonClientGUIFactory(ClientFactory): def __init__(self, app): self.gui = True self.app = app - self.protocol.logger = Logger + import kivy.logger + global logger + logger = kivy.logger.Logger diff --git a/denon/dn500av.py b/denonremote/denon/dn500av.py similarity index 94% rename from denon/dn500av.py rename to denonremote/denon/dn500av.py index a761281..e0c69bf 100644 --- a/denon/dn500av.py +++ b/denonremote/denon/dn500av.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- -from config import GUI +""" +Denon DN-500AV serial and IP communication protocol description +""" -if GUI: - from kivy import Logger +import logging - logger = Logger +logger = logging.getLogger(__name__) commands = { 'PW': "Power", @@ -722,12 +723,10 @@ def compute_lfe_volume_label(value): # TODO: abstract device -class DN500AVMessages(): +class DN500AVMessage: # From DN-500 manual (DN-500AVEM_ENG_CD-ROM_v00.pdf) # Pages 93-101 (99-107 in PDF form) - logger = None - command_code = None command_label = None subcommand_code = None @@ -736,15 +735,15 @@ class DN500AVMessages(): parameter_label = None response = None - def __init__(self, logger=None): - self.logger = logger + def __init__(self): + pass def parse_response(self, status_command): # Handle strings and bytes if type(status_command) is bytes: # FIXME: some parts can be UTF-8 encoded status_command = status_command.decode('ASCII') - self.logger.debug("Received status command: %s", status_command) + logger.debug("Received status command: %s", status_command) # Commands are of known sizes. Try the largest first. for i in range(commands_max_size, commands_min_size - 1, -1): @@ -754,19 +753,19 @@ def parse_response(self, status_command): break if self.command_label is None: - self.logger.error("Command unknown: %s", status_command) + logger.error("Command unknown: %s", status_command) return else: - self.logger.info("Parsed command %s: %s", self.command_code, self.command_label) + logger.info("Parsed command %s: %s", self.command_code, self.command_label) # Trim command from status command stream status_command = status_command[len(self.command_code):] # Handle subcommands if commands_subcommands.get(self.command_code) is None: - self.logger.debug("The command %s doesn't have any known subcommands.", self.command_code) + logger.debug("The command %s doesn't have any known subcommands.", self.command_code) else: - self.logger.debug("Searching for subcommands in: %s", status_command) + logger.debug("Searching for subcommands in: %s", status_command) # Subcommands are of known sizes. Try the largest first. for i in range(commands_subcommands_max_size[self.command_code], @@ -777,23 +776,23 @@ def parse_response(self, status_command): break if self.subcommand_label is None: - self.logger.debug("Subcommand unknown. Probably a parameter: %s", status_command) + logger.debug("Subcommand unknown. Probably a parameter: %s", status_command) self.subcommand_code = None else: - self.logger.info("Parsed subcommand %s: %s", self.subcommand_code, self.subcommand_label) + logger.info("Parsed subcommand %s: %s", self.subcommand_code, self.subcommand_label) # Trim subcommand from status command stream status_command = status_command[ len(self.subcommand_code) + 1:] # Subcommands have a space before the parameter # Handle parameters - self.logger.debug("Searching for parameters in: %s", status_command) + logger.debug("Searching for parameters in: %s", status_command) self.parameter_code = status_command if self.command_code == 'PS': self.parameter_label = commands_params[self.command_code][self.subcommand_code].get(self.parameter_code) else: self.parameter_label = commands_params[self.command_code].get(self.parameter_code) if self.parameter_label is None: - self.logger.error("Parameter unknown: %s", status_command) + logger.error("Parameter unknown: %s", status_command) self.parameter_code = None else: # Trim parameters from status command stream @@ -801,7 +800,7 @@ def parse_response(self, status_command): # Handle unexpected leftovers if status_command: - self.logger.error("Unexpected unparsed data found: %s", status_command) + logger.error("Unexpected unparsed data found: %s", status_command) if self.subcommand_label: self.response = "%s, %s: %s" % (self.command_label, self.subcommand_label, self.parameter_label) @@ -809,17 +808,14 @@ def parse_response(self, status_command): self.response = "%s: %s" % (self.command_label, self.parameter_label) -class DN500AVFormat(): - logger = None - +class DN500AVFormat: mv_reverse_params = {} - def __init__(self, logger=None): - self.logger = logger + def __init__(self): self.mv_reverse_params = dict([(value, key) for key, value in mv_params.items()]) def get_raw_volume_value_from_db_value(self, value): - self.logger.debug('value: %s', value) + logger.debug('value: %s', value) raw_value = self.mv_reverse_params['value'] - self.logger.debug('rawvalue: %s', raw_value) + logger.debug('rawvalue: %s', raw_value) return raw_value diff --git a/denonremote/denonremote.kv b/denonremote/denonremote.kv new file mode 100644 index 0000000..f3b98a3 --- /dev/null +++ b/denonremote/denonremote.kv @@ -0,0 +1,161 @@ +#: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 + +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} + + BoxLayout: + 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) + + 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: VOL_PRESET_1 # FIXME: get from config + group: 'vol_preset' + on_press: app.vol_preset_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) + + ToggleButton: + id: vol_preset_3 + text: VOL_PRESET_3 # FIXME: get from config + group: 'vol_preset' + on_press: app.vol_preset_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) + 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: u"Initializing GUI...\n" + readonly: True + background_color: [0, 0, 0, 1] + foreground_color: [0, 1, 0, 1] + size: (200, 65) + size_hint: (1, None) + diff --git a/Unicode_IEC_symbol.ttf b/denonremote/fonts/Unicode_IEC_symbol.ttf similarity index 100% rename from Unicode_IEC_symbol.ttf rename to denonremote/fonts/Unicode_IEC_symbol.ttf diff --git a/denonremote/gui.py b/denonremote/gui.py new file mode 100644 index 0000000..3b9cd62 --- /dev/null +++ b/denonremote/gui.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +import os +import sys + +import kivy.app +import kivy.resources +import kivy.support + +# FIXME: should be in Config object? +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 +if 'twisted.internet.reactor' in sys.modules: + del sys.modules['twisted.internet.reactor'] + +# Must be called before importing or using the reactor +kivy.support.install_twisted_reactor() +import twisted.internet +from denon.communication import DenonClientGUIFactory + +kivy.require('2.0.0') + +# Fixed size window +kivy.Config.set('graphics', 'resizable', False) + +APP_PATHS = ['fonts', 'images'] + +# PyInstaller data support +for path in APP_PATHS: + if hasattr(sys, '_MEIPASS'): + kivy.resources.resource_add_path(os.path.join(sys._MEIPASS, path)) + else: + kivy.resources.resource_add_path(path) + + +class DenonRemoteApp(kivy.app.App): + """ + A remote for the Denon DN-500AV Receiver + """ + + client = None + """Twisted IP client to the receiver""" + + title = "Denon Remote" + """Application title""" + + icon = 'icon.png' + """Application icon""" + + def on_start(self): + """ + Fired by Kivy on application startup + :return: + """ + if not DEBUG: + # Hide debug_messages + self.root.ids.debug_messages.size = (0, 0) + + self.print_debug('Connecting to ' + RECEIVER_IP + '...') + twisted.internet.reactor.connectTCP(RECEIVER_IP, RECEIVER_PORT, DenonClientGUIFactory(self)) + + def on_stop(self): + """ + Fired by Kivy on application shutdown + + :return: + """ + pass + + def on_pause(self): + """ + Fired by Kivy on application pause + + :return: + """ + pass + + def on_resume(self): + """ + Fired by Kivy on application resume after pause + + :return: + """ + pass + + def on_connection(self, connection): + """ + Fired by Kivy when the Twisted reactor is connected + + :param connection: + :return: + """ + self.print_debug('Connection successful!') + self.client = connection + + self.client.get_power() + self.client.get_volume() + self.client.get_mute() + self.client.get_source() + + def update_power(self, status=True): + if status: + self.root.ids.power.state = 'down' + else: + self.root.ids.power.state = 'normal' + + def power_pressed(self, instance): + power = False if instance.state == 'normal' else True + self.client.set_power(power) + + def update_volume(self, text=""): + self.root.ids.volume_display.text = text + if text in 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: + self.root.ids.vol_preset_2.state = 'down' + else: + self.root.ids.vol_preset_2.state = 'normal' + if text in VOL_PRESET_3: + self.root.ids.vol_preset_3.state = 'down' + else: + self.root.ids.vol_preset_3.state = 'normal' + + def volume_text_changed(self, instance): + # TODO: validate user input + if len(instance.text) != 7: + self.client.get_volume() + return + self.client.set_volume(instance.text) + + def volume_minus_pressed(self, instance): + self.client.set_volume('Down') + + def volume_plus_pressed(self, instance): + self.client.set_volume('Up') + + def volume_mute_pressed(self, instance): + mute = True if instance.state == 'down' else False + # Stay down. Updated on message received + self.root.ids.volume_mute.state = 'down' + self.client.set_mute(mute) + + def set_volume_mute(self, status=False): + if status: + self.root.ids.volume_mute.state = 'down' + self.root.ids.volume_display.foreground_color = [.3, .3, .3, 1] + else: + self.root.ids.volume_mute.state = 'normal' + 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) + instance.state = 'down' # Disallow depressing the button manually + + def vol_preset_2_pressed(self, instance): + self.client.set_volume(VOL_PRESET_2) + instance.state = 'down' + + def vol_preset_3_pressed(self, instance): + self.client.set_volume(VOL_PRESET_3) + instance.state = 'down' + + def set_sources(self, source=None): + if source in 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: + self.root.ids.fav_src_2.state = 'down' + else: + self.root.ids.fav_src_2.state = 'normal' + if source in FAV_SRC_3_CODE: + self.root.ids.fav_src_3.state = 'down' + else: + self.root.ids.fav_src_3.state = 'normal' + + # TODO: display other sources + + def fav_src_1_pressed(self, instance): + self.client.set_source(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) + instance.state = 'down' + + def fav_src_3_pressed(self, instance): + self.client.set_source(FAV_SRC_3_CODE) + instance.state = 'down' + + def print_debug(self, msg): + self.root.ids.debug_messages.text += "{}\n".format(msg) diff --git a/assets/DN-500AV.png b/denonremote/images/DN-500AV.png similarity index 100% rename from assets/DN-500AV.png rename to denonremote/images/DN-500AV.png diff --git a/denonremote/images/icon.png b/denonremote/images/icon.png new file mode 100644 index 0000000..3134a05 Binary files /dev/null and b/denonremote/images/icon.png differ diff --git a/main.py b/denonremote/main.py similarity index 50% rename from main.py rename to denonremote/main.py index e6d61bc..9926ba0 100644 --- a/main.py +++ b/denonremote/main.py @@ -2,49 +2,55 @@ # -*- coding: utf-8 -*- """ -DN-500AV Remote +Denon DN-500AV Remote @author Raphael Doursenaud """ -__author__ = 'Raphaël Doursenaud' +__author__ = 'Raphaël Doursenaud ' + +__version__ = '0.3.0' # FIXME: use setuptools import logging from config import DEBUG, GUI +logger = logging.getLogger() + 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 + logging.getLogger('denon.dn500av').setLevel(logging.WARNING) # Silence module’s logging else: - from kivy import Logger - logger = Logger + 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(logging.DEBUG) + logger.setLevel(kivy.logger.LOG_LEVELS['debug']) else: - logger.setLevel(logging.INFO) + logger.setLevel(kivy.logger.LOG_LEVELS['info']) def run(): # FIXME: autodetect when running from CLI - if GUI == True: + if GUI: from gui import DenonRemoteApp # PyInstaller data support import os, sys - from kivy.resources import resource_add_path + import kivy.resources if hasattr(sys, '_MEIPASS'): - resource_add_path(os.path.join(sys._MEIPASS)) - - DenonRemoteApp().run() + kivy.resources.resource_add_path(os.path.join(sys._MEIPASS)) else: from cli import DenonRemoteApp - DenonRemoteApp().run() + + DenonRemoteApp().run() if __name__ == '__main__': diff --git a/gui.py b/gui.py deleted file mode 100644 index 2fae398..0000000 --- a/gui.py +++ /dev/null @@ -1,273 +0,0 @@ -# -*- coding: utf-8 -*- - -import platform -import sys - -import kivy -from kivy import Logger, Config - -Config.set('graphics', 'resizable', False) -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.button import Button -from kivy.uix.floatlayout import FloatLayout -from kivy.uix.image import Image -from kivy.uix.textinput import TextInput -from kivy.uix.togglebutton import ToggleButton - -from config import RECEIVER_IP, RECEIVER_PORT, VOL_PRESET_1, VOL_PRESET_2, VOL_PRESET_3, FAV_SRC_1_LABEL, \ - FAV_SRC_2_LABEL, FAV_SRC_3_LABEL, FAV_SRC_1_CODE, FAV_SRC_2_CODE, FAV_SRC_3_CODE, DEBUG, BUILD_DATE - -# fix for pyinstaller packages app to avoid ReactorAlreadyInstalledError -# See: https://github.com/kivy/kivy/issues/4182 -# See: https://github.com/pyinstaller/pyinstaller/issues/3390 -if 'twisted.internet.reactor' in sys.modules: - del sys.modules['twisted.internet.reactor'] - -# install twisted reactor -from kivy.support import install_twisted_reactor - -install_twisted_reactor() - -from denon.communication import DenonClientGUIFactory - -kivy.require('2.0.0') - -from kivy.app import App -from kivy.uix.label import Label -from twisted.internet import reactor - -logging = Logger - - -class DenonRemoteApp(App): - connection = None - debug_messages = None - power = None - volume_display = None - volume_plus = None - volume_minus = None - vol_preset_1 = None - vol_preset_2 = None - vol_preset_3 = None - volume_mute = None - fav_src_1 = None - fav_src_2 = None - fav_src_3 = None - - def build(self): - self.title = "Denon Remote" - # TODO: self.icon = '' - root = self.setup_gui() - self.connect_to_receiver() - return root - - def setup_gui(self): - # TODO: switch to kv lang? - - root = FloatLayout() - - denon_image = Image(source='assets/DN-500AV.png') - denon_image.size = (0, 50) - denon_image.size_hint = (.3, None) - denon_image.pos_hint = {'top': .96, 'left': .99} - root.add_widget(denon_image) - - name_label = Label(text="DENON REMOTE", font_size=40, bold=True) - name_label.size = (200, 50) - name_label.size_hint = (1, None) - name_label.pos_hint = {'top': .96} - name_label.color = [.75, .75, .75, 1] - - root.add_widget(name_label) - - content = BoxLayout(orientation='vertical', spacing=15) - content.size_hint = (1, .75) - content.pos = (0, 65) - content.pos_hint = {'top': .85} - - self.power = ToggleButton(text="⏻", font_name='Unicode_IEC_symbol', font_size=50) - self.power.size = (80, 72) - self.power.size_hint = (None, None) - self.power.pos_hint = {'top': .983, 'right': .87} - self.power.background_color = [.3, .3, .3, 1] - root.add_widget(self.power) - - volume_section = BoxLayout(orientation='vertical') - self.volume_display = TextInput(text="---.-dB", font_name="RobotoMono-Regular", font_size=36, - halign='center', multiline=False) - self.volume_display.size = (200, 60) - self.volume_display.size_hint = (1, None) - self.volume_display.foreground_color = [.85, .85, .85, 1] - self.volume_display.background_color = [.1, .1, .1, 1] - volume_section.add_widget(self.volume_display) - volume_keys_layout = BoxLayout(orientation='horizontal') - self.volume_plus = Button(text="+") - self.volume_minus = Button(text="-") - volume_keys_layout.add_widget(self.volume_minus) - volume_keys_layout.add_widget(self.volume_plus) - volume_section.add_widget(volume_keys_layout) - self.volume_mute = ToggleButton(text="Mute", group='mute') - volume_section.add_widget(self.volume_mute) - volume_presets_layout = BoxLayout(orientation='horizontal') - self.vol_preset_1 = ToggleButton(text=VOL_PRESET_1, group='vol_preset') - self.vol_preset_2 = ToggleButton(text=VOL_PRESET_2, group='vol_preset') - self.vol_preset_3 = ToggleButton(text=VOL_PRESET_3, group='vol_preset') - volume_presets_layout.add_widget(self.vol_preset_1) - volume_presets_layout.add_widget(self.vol_preset_2) - volume_presets_layout.add_widget(self.vol_preset_3) - volume_section.add_widget(volume_presets_layout) - content.add_widget(volume_section) - - sources_section = BoxLayout(orientation='vertical') - self.fav_src_1 = ToggleButton(text=FAV_SRC_1_LABEL, group='sources') - self.fav_src_2 = ToggleButton(text=FAV_SRC_2_LABEL, group='sources') - self.fav_src_3 = ToggleButton(text=FAV_SRC_3_LABEL, group='sources') - sources_section.add_widget(self.fav_src_1) - sources_section.add_widget(self.fav_src_2) - sources_section.add_widget(self.fav_src_3) - content.add_widget(sources_section) - - root.add_widget(content) - - brand_layout = BoxLayout(orientation='vertical') - brand_label = Label(text="EMA Tech.") - os_name = platform.system() - build_date = BUILD_DATE - version_label = Label(text="pre-release %s, (Built on %s)" % (os_name, build_date), font_size=10) - brand_layout.add_widget(brand_label) - brand_layout.add_widget(version_label) - brand_layout.size = (200, 65) - brand_layout.size_hint = (1, None) - brand_layout.pos_hint = {'bottom': 1} - - root.add_widget(brand_layout) - - self.debug_messages = TextInput(background_color=[0, 0, 0, 1], foreground_color=[0, 1, 0, 1]) - self.debug_messages.size = (200, 65) - self.debug_messages.size_hint = (1, None) - if DEBUG: - root.add_widget(self.debug_messages) - - return root - - def connect_to_receiver(self): - self.print_message('Connection to ' + RECEIVER_IP + '...\n') - reactor.connectTCP(RECEIVER_IP, RECEIVER_PORT, DenonClientGUIFactory(self)) - - def on_connection(self, connection): - self.print_message('... successful!\n') - self.connection = connection - - self.connection.get_power() - self.connection.get_volume() - self.connection.get_mute() - self.connection.get_source() - - self.power.bind(on_press=self.power_callback) - self.volume_display.bind(on_text_validate=self.volume_callback) - self.volume_minus.bind(on_press=self.volume_minus_callback) - self.volume_plus.bind(on_press=self.volume_plus_callback) - self.volume_mute.bind(on_press=self.volume_mute_callback) - self.vol_preset_1.bind(on_press=self.vol_preset_1_callback) - self.vol_preset_2.bind(on_press=self.vol_preset_2_callback) - self.vol_preset_3.bind(on_press=self.vol_preset_3_callback) - self.fav_src_1.bind(on_press=self.fav_src_1_callback) - self.fav_src_2.bind(on_press=self.fav_src_2_callback) - self.fav_src_3.bind(on_press=self.fav_src_3_callback) - - def print_message(self, msg): - self.debug_messages.text += "{}\n".format(msg) - - def update_power(self, status=True): - if status: - self.power.state = 'down' - self.power.color = [.1, .8, .1, 1] # Green - else: - self.power.state = 'normal' - self.power.color = [.8, .1, .1, 1] # Red - - def power_callback(self, instance): - power = False if instance.state == 'normal' else True - self.connection.set_power(power) - - def update_volume(self, text=""): - self.volume_display.text = text - if text in VOL_PRESET_1: - self.vol_preset_1.state = 'down' - else: - self.vol_preset_1.state = 'normal' - if text in VOL_PRESET_2: - self.vol_preset_2.state = 'down' - else: - self.vol_preset_2.state = 'normal' - if text in VOL_PRESET_3: - self.vol_preset_3.state = 'down' - else: - self.vol_preset_3.state = 'normal' - - def volume_callback(self, instance): - # TODO: validate user input - if len(instance.text) != 7: - self.connection.get_volume() - return - self.connection.set_volume(instance.text) - - def volume_minus_callback(self, instance): - self.connection.set_volume('Down') - - def volume_plus_callback(self, instance): - self.connection.set_volume('Up') - - def volume_mute_callback(self, instance): - mute = True if instance.state == 'down' else False - # Stay down. Updated on message received - self.volume_mute.state = 'down' - self.connection.set_mute(mute) - - def update_volume_mute(self, status=False): - if status: - self.volume_mute.state = 'down' - self.volume_display.foreground_color = [.3, .3, .3, 1] - else: - self.volume_mute.state = 'normal' - self.volume_display.foreground_color = [.85, .85, .85, 1] - - def vol_preset_1_callback(self, instance): - self.connection.set_volume(VOL_PRESET_1) - instance.state = 'down' # Disallow depressing the button manually - - def vol_preset_2_callback(self, instance): - self.connection.set_volume(VOL_PRESET_2) - instance.state = 'down' - - def vol_preset_3_callback(self, instance): - self.connection.set_volume(VOL_PRESET_3) - instance.state = 'down' - - def update_source(self, source=None): - if source in FAV_SRC_1_CODE: - self.fav_src_1.state = 'down' - else: - self.fav_src_1.state = 'normal' - if source in FAV_SRC_2_CODE: - self.fav_src_2.state = 'down' - else: - self.fav_src_2.state = 'normal' - if source in FAV_SRC_3_CODE: - self.fav_src_3.state = 'down' - else: - self.fav_src_3.state = 'normal' - - # TODO: display other sources - - def fav_src_1_callback(self, instance): - self.connection.set_source(FAV_SRC_1_CODE) - instance.state = 'down' # Disallow depressing the button manually - - def fav_src_2_callback(self, instance): - self.connection.set_source(FAV_SRC_2_CODE) - instance.state = 'down' - - def fav_src_3_callback(self, instance): - self.connection.set_source(FAV_SRC_3_CODE) - instance.state = 'down' diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000..f7d76d1 Binary files /dev/null and b/icon.ico differ diff --git a/requirements.txt b/requirements.txt index 2c6c913..6c4873c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ --c constraints.txt twisted kivy PyInstaller \ No newline at end of file diff --git a/screenshot-v0.3.0.png b/screenshot-v0.3.0.png new file mode 100644 index 0000000..410b505 Binary files /dev/null and b/screenshot-v0.3.0.png differ