Skip to content

Commit

Permalink
Add basic keyboard shortcuts and native systray
Browse files Browse the repository at this point in the history
  • Loading branch information
rdoursenaud committed Mar 13, 2021
1 parent c9e75cc commit 7567fe6
Show file tree
Hide file tree
Showing 6 changed files with 203 additions and 33 deletions.
70 changes: 62 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Dependencies:

#### Target hardware

- [x] DN-500AV
- [x] Denon Professional DN-500AV (Seems based on the same platform as the Denon AVR-1912 and AVR-2112CI.)
- [ ] More? Contributions welcome!

#### Communication
Expand Down Expand Up @@ -55,8 +55,8 @@ Dependencies:
- [x] Relative
- [x] Absolute
- [x] Mute
- [x] Presets! (-18dBFS, -24dBFS…)
- [ ] SPL calibrated display (-18dBFS = 85dBSPL)
- [x] Presets! (-18dB, -24dB…)
- [ ] SPL calibrated display (SMPTE RP200: -18dBFS = 85dB C SPL)
- [ ] Input select
- [ ] Security
- [ ] Panel Lock
Expand All @@ -77,19 +77,23 @@ Dependencies:
##### GUI

- [x] Using [Kivy](https://kivy.org)
- [ ] Keyboard shortcuts:
- [x] M for Mute
- [x] Up/Down Vol +/-
- [ ] Left/Right VolPreset +/-
- [ ] PgUp/PgDwn SrcPreset +/-
- [x] Systray/Taskbar support using [pystray](https://pypi.org/project/pystray/)

##### Windows executable

- [x] Find a way to make it resident in the task bar with a nice icon, like soundcard control panel
- [x] [RBTray](https://sourceforge.net/projects/rbtray/files/latest/download)
- [ ] The Pythonic Way
- [ ] Handle shutdown to power off the device
- [x] PyInstaller
- [x] [PyInstaller](https://www.pyinstaller.org)
- [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`
- [ ] [cx-Freeze](https://pypi.org/project/cx-Freeze/) for multiplatform support?
- [ ] 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/)
Expand All @@ -98,4 +102,54 @@ Dependencies:

- [ ] Autonomous mobile app? Kivy enables that!
- [ ] Android
- [ ] iOS/iPadOS
- [ ] iOS/iPadOS

#### Proxy?

The receiver only allows 1 active connection. A dispatcher proxy could allow multiple simultaneous remotes (Desktop and
mobile).

### Other opportunities

Open ports:

- 23/tcp (TELNET): BridgeCo AG Telnet server
AVR serial protocol used here
- 80/tcp (HTTP): GoAhead WebServer
Web control (index.asp) Shows nothing.
Most of the useful code is commented!
CSS loading at "css/mainMenu.css" times out.
Main control is available at "MainZone/index.html"!
- 443/tcp (HTTPS): ERR_SSL_PROTOCOL_ERROR in Google Chrome
SSL_ERROR_EXTRACT_PUBLIC_KEY_FAILURE in Mozilla Firefox
- 1026/tcp (RTSP): Apple AirTunes rtspd 103.2
- 6666/tcp: ?
- 8080/tcp (HTTP): AV receiver http config

### Similar projects

Android

- [AVR-Remote](https://github.com/pskiwi/avr-remote)

JavaScript:

- https://github.com/phillipsnick/denon-avr
- https://github.com/murderbeard/com.moz.denon
- https://github.com/jtangelder/denon-remote

PHP

- https://github.com/Wolbolar/IPSymconDenon (IP Symcon automation)

Python:

- https://github.com/jeroenvds/denonremote (XBMC plugin)
- 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)
- https://github.com/auchter/denonavr_serial (Library)
- https://github.com/jphutchins/pyavreceiver (Nice library)
- https://github.com/frawau/aiomadeavr (Library)
- https://github.com/scarface-4711/denonavr (Uses the HTTP/XML interface. Library)
16 changes: 9 additions & 7 deletions denonremote.spec
Original file line number Diff line number Diff line change
@@ -1,27 +1,29 @@
# -*- mode: python ; coding: utf-8 -*-

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
from kivy.tools.packaging.pyinstaller_hooks import get_deps_all, hookspath, runtime_hooks
from kivy_deps import sdl2, glew

block_cipher = None

added_files = [
('denonremote\\fonts', 'fonts'),
('denonremote\\images', 'images')
('denonremote\\images', 'images'),
]

dependencies = get_deps_all() # FIXME: minimize dependencies
dependencies['hiddenimports'].append('pystray._win32')

a = Analysis(['denonremote\\main.py'],
pathex=['denonremote'],
pathex=['denonremote', '.\\venv\\Lib\\site-packages\\pystray'],
datas=added_files,
hookspath=hookspath(),
runtime_hooks=[],
runtime_hooks=runtime_hooks(),
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
**get_deps_all())
**dependencies)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz, Tree('denonremote'),
Expand Down
2 changes: 1 addition & 1 deletion denonremote/denonremote.kv
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ FloatLayout:

TextInput:
id: debug_messages
text: u"Initializing GUI...\n"
text: "Initializing GUI...\n"
readonly: True
background_color: [0, 0, 0, 1]
foreground_color: [0, 1, 0, 1]
Expand Down
72 changes: 66 additions & 6 deletions denonremote/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
import sys

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 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

Expand All @@ -23,8 +28,7 @@

kivy.require('2.0.0')

# Fixed size window
kivy.Config.set('graphics', 'resizable', False)
logger = kivy.logger.Logger

APP_PATHS = ['fonts', 'images']

Expand All @@ -41,20 +45,36 @@ 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"""

client = None
"""Twisted IP client to the receiver"""

systray: pystray.Icon = None

hidden = True if kivy.config.Config.get('graphics', 'window_state') == 'hidden' else False

def run_with_systray(self, systray):
self.systray = systray
super().run()

def on_start(self):
"""
Fired by Kivy on application startup
:return:
"""
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)

if not DEBUG:
# Hide debug_messages
self.root.ids.debug_messages.size = (0, 0)
Expand Down Expand Up @@ -101,6 +121,46 @@ def on_connection(self, connection):
self.client.get_mute()
self.client.get_source()

@mainthread
def show(self, window=None):
if window is None:
window = self.root_window
window.restore()
window.raise_window()
window.show()
self.hidden = False

@mainthread
def hide(self, window=None):
if window is None:
window = self.root_window
window.hide()
self.hidden = True

def hide_on_close(self, window, source=None):
logger.debug("Hide from %s", source)
self.hide(window)
return True # Keeps the application alive instead of stopping

def on_keyboard(self, window, key, scancode, codepoint, modifier):
"""
Handle keyboard shortcuts
:param window:
:param key:
:param scancode:
:param codepoint:
:param modifier:
:return:
"""
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 scancode == 82: # Up
self.root.ids.volume_plus.trigger_action()
if scancode == 81: # Down
self.root.ids.volume_minus.trigger_action()

def update_power(self, status=True):
if status:
self.root.ids.power.state = 'down'
Expand Down
75 changes: 64 additions & 11 deletions denonremote/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,30 @@

__author__ = 'Raphaël Doursenaud <[email protected]>'

__version__ = '0.3.0' # FIXME: use setuptools
__version__ = '0.4.0' # FIXME: use setuptools

import logging

import PIL.Image
import pystray

from config import DEBUG, GUI

logger = logging.getLogger()


def resource_path(relative_path):
""" 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
else:
base_path = os.getcwd()

return os.path.join(base_path, relative_path)


def init_logging():
global logger

Expand All @@ -35,22 +50,60 @@ def init_logging():
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)
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.icon = PIL.Image.open(resource_path(r'images/icon.png'))
systray.run(setup=run_gui)


def run_gui(systray):
import kivy.config
kivy.config.Config.set('kivy', 'window_icon', 'images/icon.png')
# 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')
kivy.config.Config.write()

from gui import DenonRemoteApp
DenonRemoteApp().run_with_systray(systray)


def systray_clicked(icon: pystray.Icon, menu: pystray.MenuItem):
import kivy.app
app = kivy.app.App.get_running_app()
if app.hidden:
app.show()
else:
app.hide()


def quit_systray(icon: pystray.Icon, menu: pystray.MenuItem):
import kivy.app
kivy.app.App.get_running_app().stop()
icon.stop()


def run_cli():
from cli import DenonRemoteApp
DenonRemoteApp().run()


def run():
# FIXME: autodetect when running from CLI
if GUI:
from gui import DenonRemoteApp

# PyInstaller data support
import os, sys
import kivy.resources
if hasattr(sys, '_MEIPASS'):
kivy.resources.resource_add_path(os.path.join(sys._MEIPASS))
run_from_systray()
else:
from cli import DenonRemoteApp

DenonRemoteApp().run()
run_cli()


if __name__ == '__main__':
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
twisted
kivy
pystray
PyInstaller

0 comments on commit 7567fe6

Please sign in to comment.