diff --git a/README.md b/README.md index f7369e0..06cfd92 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ Denon Remote Control [Denon Professional DN-500AV surround preamplifier](https://www.denonpro.com/index.php/products/view/dn-500av) remotely. -![Screenshot](screenshot-v0.5.0-main.png) +![Screenshot](screenshot-v0.7.0-main.png) -![Settings Screenshot](screenshot-v0.5.0-settings.png) +![Settings Screenshot](screenshot-v0.7.0-settings.png) Author: Raphael Doursenaud @@ -19,17 +19,22 @@ 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 + #### Target hardware - [x] Denon Professional DN-500AV (Seems based on the same platform as the Denon AVR-1912 and AVR-2112CI.) - [ ] More? Contributions welcome! + #### Communication - [x] Ethernet - - [x] Using [Twisted](https://twistedmatrix.com): + - [x] Using [Twisted](https://twistedmatrix.com) + - [x] connection status detection + - [x] automatically try to reconnect with exponential backoff - [ ] RS-232? also using Twisted - [ ] General MIDI input using [Mido](https://mido.readthedocs.io/en/latest/) - [ ] Define control scheme. @@ -87,6 +92,7 @@ Fonts used: - [x] Update the GUI - [ ] Import EQ settings - [ ] From [REW](https://www.roomeqwizard.com/) value file + - [ ] Only use negative values! You can’t compensate a destructive room mode by adding energy to it. - [ ] Full Profiles/presets? ##### GUI @@ -98,7 +104,11 @@ Fonts used: - [ ] Left/Right VolPreset +/- - [ ] PgUp/PgDwn SrcPreset +/- - [x] Systray/Taskbar support using [pystray](https://pypi.org/project/pystray/) -- [ ] Only one instance should be allowed +- [x] Only one instance is allowed (Microsoft Windows only) +- [X] Option to make window stay always on top (Microsoft Windows only) +- [x] Touch doesn’t activate the window and doesn’t steal focus (Microsoft Windows only) +- [x] Trigger events without having to activate the window first (Microsoft Windows only) +- [ ] Draw it on the first touch enabled display if available instead of the main one ##### Windows executable @@ -161,7 +171,7 @@ PHP Python: - https://github.com/jeroenvds/denonremote (XBMC plugin) -- https://github.com/Tom360V/DenonAvr (Similar objectives? +- https://github.com/Tom360V/DenonAvr (Similar objectives?) - https://github.com/toebsen/python-denonavr (HTTP RESTful server) - https://github.com/MrJavaWolf/DenonPhoneController (Landline phone controller) - https://github.com/troykelly/python-denon-avr-serial-over-ip (Library) diff --git a/denonremote/denon/communication.py b/denonremote/denon/communication.py index 104d6c2..ac79bca 100644 --- a/denonremote/denon/communication.py +++ b/denonremote/denon/communication.py @@ -5,6 +5,7 @@ from twisted.internet import task, reactor from twisted.internet.protocol import ClientFactory from twisted.protocols.basic import LineOnlyReceiver +from twisted.protocols.policies import TimeoutMixin from denon.dn500av import DN500AVMessage, DN500AVFormat @@ -15,12 +16,20 @@ # See: https://twistedmatrix.com/documents/15.4.0/api/twisted.internet.serialport.SerialPort.html -class DenonProtocol(LineOnlyReceiver): +class DenonProtocol(LineOnlyReceiver, TimeoutMixin): # From DN-500 manual (DN-500AVEM_ENG_CD-ROM_v00.pdf) page 91 (97 in PDF form) MAX_LENGTH = 135 - DELAY = 0.04 # in seconds. The documentation requires 200 ms. 40 ms seems safe. + DELAY = 0.04 + """ + Delay between messages in seconds. + The documentation requires 200 ms. 40 ms seems safe. + """ + TIMEOUT = 0.2 + """ + Requests shall time out if no reply is received in under 200 ms. + """ delimiter = b'\r' - ongoing_calls = 0 # Delay handling. FIXME: should timeout after 200 ms. + ongoing_calls = 0 # Delay handling. def connectionMade(self): logger.debug("Connection made") @@ -38,14 +47,22 @@ def sendLine(self, 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) - delay = 0 + delay = 0 # Send now if self.ongoing_calls > 0: - delay = self.DELAY * (self.ongoing_calls - 1) + delay = self.DELAY * (self.ongoing_calls - 1) # Send after other messages logger.debug("Will send line: %s in %f seconds", line, delay) return task.deferLater(reactor, delay=delay, - callable=super().sendLine, line=line) + callable=self.sendLineWithTimeout, line=line) + + def sendLineWithTimeout(self, line): + timeout = self.TIMEOUT if self.timeOut is None else self.timeOut + self.TIMEOUT + self.setTimeout(timeout) + del timeout + super().sendLine(line) def lineReceived(self, line): + self.resetTimeout() + self.setTimeout(None) if self.ongoing_calls: # We received a reply self.ongoing_calls -= 1 diff --git a/denonremote/denon/dn500av.py b/denonremote/denon/dn500av.py index e0c69bf..ec0ff34 100644 --- a/denonremote/denon/dn500av.py +++ b/denonremote/denon/dn500av.py @@ -83,42 +83,46 @@ def compute_master_volume_label(value, zerodb_ref=MASTER_VOLUME_ZERODB_REF): """Convert Master Volume ASCII value to dB""" # TODO: Handle absolute values label = '---.-dB' + result = None if int(value[:2]) < MASTER_VOLUME_MIN or int(value[:2]) > MASTER_VOLUME_MAX: logger.error("Master volume value %s out of bounds (%s-%s)", value, MASTER_VOLUME_MIN, MASTER_VOLUME_MAX) # Quirks - if value == '99': - result = "-∞dB" - elif value == '995': - result = "-80.5dB" elif len(value) == 2: - # General case - result = str(float(value) - zerodb_ref) + if value == '99': + result = "" # Minus inf + else: + # General case + result = str(float(value) - zerodb_ref) elif len(value) == 3: # Handle undocumented special case for half dB - # Hardcode values around 0 because of computing sign uncertainty - # FIXME: test '985' which seems invalid - if value == str((zerodb_ref - 1)) + '5': - result = "-0.5" - elif value == str(zerodb_ref) + '5': - result = "0.5" + if value == '995': + result = "-80.5" else: - value = int(value[:2]) # Drop the third digit - offset = 0 - if value < zerodb_ref: - offset = 1 - logger.debug("Add offset %i to dB calculation with value %i5", offset, value) - result = str(int(value + offset - zerodb_ref)) + ".5" + + # Hardcode values around 0 because of computing sign uncertainty + if value == str((zerodb_ref - 1)) + '5': + result = "-0.5" + elif value == str(zerodb_ref) + '5': + result = "0.5" + else: + value = int(value[:2]) # Drop the third digit + offset = 0 + if value < zerodb_ref: + offset = 1 + logger.debug("Add offset %i to dB calculation with value %i5", offset, value) + result = str(int(value + offset - zerodb_ref)) + ".5" else: raise ValueError # Format label with fixed width like the actual display: # [ NEG SIGN or EMPTY ] [ DIGIT er EMPTY ] [ DIGIT ] [ DOT ] [ DIGIT ] [ d ] [ B ] - label = "%s%s%s.%sdB" % ( - result[0] if result[0] == '-' else " ", - " " if len(result) <= 3 or result[-4] == '-' else result[-4], - result[-3], - result[-1]) + if result: + label = "%s%s%s.%sdB" % ( + result[0] if result[0] == '-' else " ", + " " if len(result) <= 3 or result[-4] == '-' else result[-4], + result[-3], + result[-1]) logger.debug(label) return label diff --git a/denonremote/denonremote.kv b/denonremote/denonremote.kv index d13c89d..85152c2 100644 --- a/denonremote/denonremote.kv +++ b/denonremote/denonremote.kv @@ -60,12 +60,12 @@ FloatLayout: id: mode_k20 text: "SMPTE/K-20" group: 'mode_ref' + state: 'down' # Default on_press: app.mode_changed(self) ForcedToggleButton id: mode_ebu text: "EBU" - state: 'down' # Default group: 'mode_ref' on_press: app.mode_changed(self) @@ -131,7 +131,7 @@ FloatLayout: TextInput: id: volume_display - text: "---.-dB" + text: "---.-dB" # TODO: decorrelate display from serial commands (SI mandates a space before the unit) font_name: 'RobotoMono-Regular' font_size: 36 halign: 'center' diff --git a/denonremote/gui.py b/denonremote/gui.py index 2af24e0..a95d2ab 100644 --- a/denonremote/gui.py +++ b/denonremote/gui.py @@ -2,6 +2,8 @@ import os import sys +import KivyOnTop + KIVY_NO_ARGS = 1 import kivy.app @@ -14,6 +16,9 @@ import kivy.uix.settings import pystray from kivy.clock import mainthread +from KivyOnTop import register_topmost, unregister_topmost +import win32gui +import win32con # fix for pyinstaller packages app to avoid ReactorAlreadyInstalledError # See: https://github.com/kivy/kivy/issues/4182 @@ -37,6 +42,7 @@ # PyInstaller data support for path in APP_PATHS: if hasattr(sys, '_MEIPASS'): + # noinspection PyProtectedMember kivy.resources.resource_add_path(os.path.join(sys._MEIPASS, path)) else: kivy.resources.resource_add_path(path) @@ -56,6 +62,9 @@ class DenonRemoteApp(kivy.app.App): connector: twisted.internet.tcp.Connector = None """Twisted connector""" + _backoff = 0.5 + """Retry failed or lost connection with exponential backoff""" + client: DenonClientGUIFactory = None """Twisted client of the receiver""" @@ -78,6 +87,7 @@ def build_config(self, config): 'debug': False, 'receiver_ip': '192.168.x.y', 'receiver_port': TELNET_PORT, + 'always_on_top': True, 'reference_level': '-20', # SMPTE RP200:2012 & Katz metering system also equivalent to EBU 83dbSPLC@-20dBFS 'reference_spl': '83', 'reference_volume': '-18', # The best alignment level with my current setup (Dynaudio BM5A) @@ -95,18 +105,20 @@ def build_config(self, config): }) def build_settings(self, settings): + settings.add_json_panel("Window", self.config, + filename=kivy.resources.resource_find('window.json')) settings.add_json_panel("Communication", self.config, - filename=kivy.resources.resource_find('settings/communication.json')) + filename=kivy.resources.resource_find('communication.json')) settings.add_json_panel("Volume display", self.config, - filename=kivy.resources.resource_find('settings/display.json')) + filename=kivy.resources.resource_find('volume_display.json')) settings.add_json_panel("Volume presets", self.config, - filename=kivy.resources.resource_find('settings/volume.json')) + filename=kivy.resources.resource_find('volume.json')) settings.add_json_panel("Favorite source 1", self.config, - filename=kivy.resources.resource_find('settings/source1.json')) + filename=kivy.resources.resource_find('source1.json')) settings.add_json_panel("Favorite source 2", self.config, - filename=kivy.resources.resource_find('settings/source2.json')) + filename=kivy.resources.resource_find('source2.json')) settings.add_json_panel("Favorite source 3", self.config, - filename=kivy.resources.resource_find('settings/source3.json')) + filename=kivy.resources.resource_find('source3.json')) def on_config_change(self, config, section, key, value): if config is self.config: @@ -141,8 +153,8 @@ 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') + '...') + def _connect(self, *_): + self.print_debug('Connecting to ' + self.config.get('denonremote', 'receiver_ip') + '...', True) client_factory = DenonClientGUIFactory(self) self.connector = twisted.internet.reactor.connectTCP( @@ -153,7 +165,7 @@ def _connect(self): def _disconnect(self): if self.connector is not None: - self.print_debug('Disconnecting') + self.print_debug('Disconnecting', True) self.connector = self.connector.disconnect() def on_start(self): @@ -161,6 +173,18 @@ def on_start(self): Fired by Kivy on application startup :return: """ + # FIXME: Windows only ATM. + if self.config.getboolean('denonremote', 'always_on_top'): + register_topmost(kivy.core.window.Window, TITLE) + kivy.core.window.Window.bind(on_stop= + lambda *args, w=kivy.core.window.Window, t=TITLE: unregister_topmost(w, t)) + + # Don’t steal focus + win32gui.SetWindowLong(KivyOnTop.find_hwnd(TITLE), win32con.GWL_EXSTYLE, win32con.WS_EX_NOACTIVATE) + + # Raise when mouse enters + kivy.core.window.Window.bind(on_cursor_enter=lambda *__: kivy.core.window.Window.raise_window()) + if self.systray is not None: self.systray.visible = True @@ -209,32 +233,46 @@ def on_connection(self, connection): :param connection: :return: """ - self.print_debug("Connection successful!") + self.print_debug("Connection successful!", True) self.client = connection + self._backoff = 0.5 self.client.get_power() self.client.get_volume() self.client.get_mute() self.client.get_source() + self.close_settings() + 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.client = None + # TODO: open error popup? self.root.ids.main.disabled = True self.open_settings() + self._reconnect() + 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.client = None + # TODO: open error popup? self.root.ids.main.disabled = True + self._reconnect() + + def _reconnect(self): + """Try to reconnect with exponential backoff""" + self._backoff = self._backoff * 2 + self.print_debug(f"Trying to reconnect in {self._backoff} seconds.", True) + kivy.clock.Clock.schedule_once(self._connect, self._backoff) + @mainthread def show(self, window=None): if window is None: @@ -275,11 +313,14 @@ def on_keyboard(self, window, key, scancode, codepoint, modifier): """ logger.debug("key: %s, scancode: %s, codepoint: %s, modifier: %s", key, scancode, codepoint, modifier) if codepoint == 'm': - self.root.ids.volume_mute.trigger_action() + if not self.root.ids.volume_mute.disabled: + self.root.ids.volume_mute.trigger_action() if scancode == 82: # Up - self.root.ids.volume_plus.trigger_action() + if not self.root.ids.volume_plus.disabled: + self.root.ids.volume_plus.trigger_action() if scancode == 81: # Down - self.root.ids.volume_minus.trigger_action() + if not self.root.ids.volume_minus.disabled: + self.root.ids.volume_minus.trigger_action() def update_power(self, status=True): if status: @@ -294,10 +335,12 @@ def power_pressed(self, instance): def update_volume(self, text="", ref_level=None): # If we get no text, retrieve the currently displayed one if text == "": + # We don't need to update the volume display if no text is passed text = self.root.ids.volume_display.text else: - # We don't need to update the volume display if no text is passed self.root.ids.volume_display.text = text + + # Update volume presets if text in self.config.get('denonremote', 'vol_preset_1'): self.root.ids.vol_preset_1.state = 'down' else: @@ -315,6 +358,15 @@ def update_volume(self, text="", ref_level=None): else: self.root.ids.vol_preset_4.state = 'normal' + # Disable buttons on boundaries + if text == "---.-dB": + self.root.ids.volume_minus.disabled = True + elif text == ' 0.0dB': + self.root.ids.volume_plus.disabled = True + else: + self.root.ids.volume_minus.disabled = False + self.root.ids.volume_plus.disabled = False + # Retrieve the displayed reference level if not passed if ref_level is None: # Get pressed option @@ -330,17 +382,22 @@ def update_volume(self, text="", ref_level=None): def _compute_spl_text(self, text="", ref_level=-18): # FIXME: Handle Absolute mode # Relative mode computation - volume = float(text[:-2]) # strip dB + volume = float('-inf') if text == '---.-dB' else float(text.replace(' ', '')[:-2]) # Strip "dB" volume_delta = volume - float( self.config.get('denonremote', 'reference_volume')) # compute delta with reference volume - spl = int(round( - float(self.config.get('denonremote', 'reference_spl')) + volume_delta)) # apply delta to reference SPL + if volume == float('-inf'): + spl = volume + else: + spl = int(round( + float(self.config.get('denonremote', 'reference_spl')) + volume_delta)) # apply delta to reference SPL # Reference mode handling ref_delta = ref_level - int( self.config.get('denonremote', 'reference_level')) # compute delta with reference level spl = spl + ref_delta - spl_text = "%i dB SPL" % spl # format string with computed SPL and reference level mode - ref_text = "@ %i dBFS" % ref_level + if spl == float('-inf'): + spl = 0 + spl_text = f"{spl:d} dB SPL" # format string with computed SPL and reference level mode + ref_text = f"@ {ref_level:d} dBFS" text = (spl_text, ref_text) return text @@ -434,5 +491,7 @@ def fav_src_3_pressed(self, instance): self.client.set_source(self.config.get('denonremote', 'fav_src_3_code')) instance.state = 'down' - def print_debug(self, msg): + def print_debug(self, msg, echo_to_logger=False): + if echo_to_logger: + logger.debug(msg) self.root.ids.debug_messages.text += "{}\n".format(msg) diff --git a/denonremote/main.py b/denonremote/main.py index c281114..b4d988f 100644 --- a/denonremote/main.py +++ b/denonremote/main.py @@ -8,7 +8,7 @@ """ TITLE = "Denon Remote" -__version__ = "0.6.0" # FIXME: use setuptools +__version__ = "0.7.0" # FIXME: use setuptools __BUILD_DATE__ = "" # TODO: override at build time import argparse @@ -18,6 +18,9 @@ import PIL.Image import pystray +import win32api +import win32event +from winerror import ERROR_ALREADY_EXISTS logger = logging.getLogger() @@ -27,6 +30,7 @@ def resource_path(relative_path: str): """ Get absolute path to resource, works for dev and for PyInstaller """ if hasattr(sys, '_MEIPASS'): # PyInstaller creates a temp folder and stores path in _MEIPASS + # noinspection PyProtectedMember base_path = sys._MEIPASS else: base_path = os.getcwd() @@ -146,6 +150,12 @@ def parse_args(): if __name__ == '__main__': + # Make sure only one instance is running + # FIXME: Windows only ATM. + mutex = win32event.CreateMutex(None, False, TITLE) + if ERROR_ALREADY_EXISTS == win32api.GetLastError(): + exit(f"{TITLE} is already running") + arguments = parse_args() configure(arguments) init_logging() diff --git a/denonremote/settings/display.json b/denonremote/settings/volume_display.json similarity index 99% rename from denonremote/settings/display.json rename to denonremote/settings/volume_display.json index 2911284..79d32f8 100644 --- a/denonremote/settings/display.json +++ b/denonremote/settings/volume_display.json @@ -20,4 +20,4 @@ "section": "denonremote", "key": "reference_volume" } -] \ No newline at end of file +] diff --git a/denonremote/settings/window.json b/denonremote/settings/window.json new file mode 100644 index 0000000..a7df35d --- /dev/null +++ b/denonremote/settings/window.json @@ -0,0 +1,9 @@ +[ + { + "type": "bool", + "title": "Always on top", + "desc": "Sets the window to be on top of other windows", + "section": "denonremote", + "key": "always_on_top" + } +] diff --git a/requirements.txt b/requirements.txt index 7c04a71..f097bd3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ kivy~=2.0.0 pystray~=0.17.3 Pillow~=8.2.0 PyInstaller -buildozer \ No newline at end of file +buildozer +KivyOnTop diff --git a/screenshot-v0.7.0-main.png b/screenshot-v0.7.0-main.png new file mode 100644 index 0000000..fd830be Binary files /dev/null and b/screenshot-v0.7.0-main.png differ diff --git a/screenshot-v0.7.0-settings.png b/screenshot-v0.7.0-settings.png new file mode 100644 index 0000000..eb073b4 Binary files /dev/null and b/screenshot-v0.7.0-settings.png differ