Skip to content

Commit

Permalink
Support different device types #24
Browse files Browse the repository at this point in the history
  • Loading branch information
3ll3d00d committed Feb 6, 2021
1 parent 2277255 commit 3016a9c
Show file tree
Hide file tree
Showing 7 changed files with 135 additions and 54 deletions.
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# ezbeq

A simple web browser for [beqcatalogue](https://beqcatalogue.readthedocs.io/en/latest/) which integrates with [minidsp-rs](https://github.com/mrene/minidsp-rs)
for local remote control of a minidsp.
for local remote control of a minidsp or HTP-1.

# Setup

Expand Down Expand Up @@ -29,8 +29,14 @@ Example is provided for rpi users
$ . bin/activate
$ pip install ezbeq

### Using with a Minidsp

Install minidsp-rs as per the provided instructionshttps://github.com/mrene/minidsp-rs#installation

### Using with a Monolith HTP-1

See the configuration section below

## Upgrade

$ ssh pi@myrpi
Expand All @@ -57,9 +63,12 @@ See `$HOME/.ezbeq/ezbeq.yml`

Options that are intended for override are:

* port: listens on port 8080 by default
* minidspExe : full path to the minidsp-rs app, defaults to minidsp so assumes the binary is already on your PATH
* minidspOptions : additional command line switches to pass to the minidsp binary
* port: listens on port 8080 by default
* if using a minidsp
* minidspExe: full path to the minidsp-rs app, defaults to minidsp so assumes the binary is already on your PATH
* minidspOptions: additional command line switches to pass to the minidsp binary
* if using a htp1:
* TODO

## Starting ezbeq on bootup

Expand Down
10 changes: 5 additions & 5 deletions ezbeq/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from ezbeq.catalogue import CatalogueProvider, Authors, Years, AudioTypes, CatalogueSearch, CatalogueMeta, ContentTypes
from ezbeq.config import Config, Version
from ezbeq.minidsp import MinidspSender, MinidspBridge, Minidsps, MinidspState
from ezbeq.device import DeviceSender, Minidsp, Devices, DeviceState, DeviceBridge

API_PREFIX = '/api/1'

Expand All @@ -22,16 +22,16 @@
cfg = Config('ezbeq')
resource_args = {
'config': cfg,
'minidsp_state': MinidspState(cfg),
'minidsp_bridge': MinidspBridge(cfg),
'device_state': DeviceState(cfg),
'device_bridge': DeviceBridge(cfg),
'catalogue': CatalogueProvider(cfg)
}

# GET: slot state
api.add_resource(Minidsps, f"{API_PREFIX}/minidsps", resource_class_kwargs=resource_args)
api.add_resource(Devices, f"{API_PREFIX}/devices", resource_class_kwargs=resource_args)
# PUT: set config
# DELETE: remove config
api.add_resource(MinidspSender, f"{API_PREFIX}/minidsp/<slot>", resource_class_kwargs=resource_args)
api.add_resource(DeviceSender, f"{API_PREFIX}/device/<slot>", resource_class_kwargs=resource_args)
# GET: distinct authors in the catalogue
api.add_resource(Authors, f"{API_PREFIX}/authors", resource_class_kwargs=resource_args)
# GET: distinct years in the catalogue
Expand Down
1 change: 1 addition & 0 deletions ezbeq/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def __init__(self, name, default_port=8080):
self.__service_url = f"http://{self.hostname}:{self.port}"
self.minidsp_exe = self.config.get('minidspExe', 'minidsp')
self.minidsp_options = self.config.get('minidspOptions', None)
self.htp1_options = self.config.get('htp1', None)
self.webapp_path = self.config.get('webappPath', None)
self.use_twisted = self.config.get('useTwisted', True)

Expand Down
141 changes: 106 additions & 35 deletions ezbeq/minidsp.py → ezbeq/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
import logging
import os
import time
from abc import ABC, abstractmethod
from concurrent.futures.thread import ThreadPoolExecutor
from contextlib import contextmanager
from typing import List, Optional
from typing import List, Optional, Union

from flask import request
from flask_restful import Resource
Expand All @@ -15,58 +16,77 @@
logger = logging.getLogger('ezbeq.minidsp')


class MinidspState:
def get_channels(cfg: Config) -> List[str]:
if cfg.minidsp_exe:
return [str(i+1) for i in range(4)]
else:
return sorted(cfg.htp1_options['channels'])


class DeviceState:

def __init__(self, cfg: Config):
self.__state = [{'id': id, 'last': 'Empty'} for id in range(4)]
self.__file_name = os.path.join(cfg.config_path, 'minidsp.json')
self.__state = [{'id': id, 'last': 'Empty'} for id in get_channels(cfg)]
self.__can_activate = cfg.minidsp_exe is not None
self.__file_name = os.path.join(cfg.config_path, 'device.json')
self.__active_slot = None
if os.path.exists(self.__file_name):
with open(self.__file_name, 'r') as f:
self.__state = json.load(f)
logger.info(f"Loaded {self.__state} from {self.__file_name}")

def activate(self, slot: int):
j = json.load(f)
saved_ids = {v['id'] for v in j}
current_ids = {v['id'] for v in self.__state}
if saved_ids == current_ids:
self.__state = j
logger.info(f"Loaded {self.__state} from {self.__file_name}")
else:
logger.warning(f"Discarded {j} from {self.__file_name}, does not match {self.__state}")

def activate(self, slot: str):
self.__active_slot = slot

def put(self, slot, entry: Catalogue):
def put(self, slot: str, entry: Catalogue):
self.__update(slot, entry.title)

def __update(self, slot, value: str):
def __update(self, slot: str, value: str):
logger.info(f"Storing {value} in slot {slot}")
self.__state[int(slot)]['last'] = value
for s in self.__state:
if s['id'] == slot:
s['last'] = value
with open(self.__file_name, 'w') as f:
json.dump(self.__state, f, sort_keys=True)
self.activate(int(slot))
self.activate(slot)

def error(self, slot: int):
def error(self, slot: str):
self.__update(slot, 'ERROR')

def clear(self, slot: int):
def clear(self, slot: str):
self.__update(slot, 'Empty')

def get(self):
return [{**s, 'active': True if self.__active_slot is not None and s['id'] == self.__active_slot else False}
for s in self.__state]
def get(self) -> List[dict]:
return [{
**s,
'canActivate': self.__can_activate,
'active': True if self.__active_slot is not None and s['id'] == self.__active_slot else False
} for s in self.__state]


class Minidsps(Resource):
class Devices(Resource):

def __init__(self, **kwargs):
self.__state: MinidspState = kwargs['minidsp_state']
self.__bridge: MinidspBridge = kwargs['minidsp_bridge']
self.__state: DeviceState = kwargs['device_state']
self.__bridge: DeviceBridge = kwargs['device_bridge']

def get(self):
def get(self) -> List[dict]:
self.__state.activate(self.__bridge.state())
return self.__state.get()


class MinidspSender(Resource):
class DeviceSender(Resource):

def __init__(self, **kwargs):
self.__bridge: MinidspBridge = kwargs['minidsp_bridge']
self.__bridge: DeviceBridge = kwargs['device_bridge']
self.__catalogue_provider: CatalogueProvider = kwargs['catalogue']
self.__state: MinidspState = kwargs['minidsp_state']
self.__state: DeviceState = kwargs['device_state']

def put(self, slot):
payload = request.get_json()
Expand All @@ -75,8 +95,8 @@ def put(self, slot):
logger.info(f"Sending {id} to Slot {slot}")
match: Catalogue = next(c for c in self.__catalogue_provider.catalogue if c.idx == int(id))
try:
self.__bridge.send(MinidspBeqCommandGenerator.filt(match, int(slot), self.__bridge.state()), int(slot))
self.__state.put(int(slot), match)
self.__bridge.send(slot, match)
self.__state.put(slot, match)
except Exception as e:
logger.exception(f"Failed to write {id} to Slot {slot}")
self.__state.error(slot)
Expand All @@ -86,8 +106,8 @@ def put(self, slot):
if cmd == 'activate':
logger.info(f"Activating Slot {slot}")
try:
self.__bridge.send(MinidspBeqCommandGenerator.activate(int(slot)), int(slot))
self.__state.activate(int(slot))
self.__bridge.send(slot, True)
self.__state.activate(slot)
except Exception as e:
logger.exception(f"Failed to activate Slot {slot}")
self.__state.error(slot)
Expand All @@ -97,14 +117,39 @@ def put(self, slot):
def delete(self, slot):
logger.info(f"Clearing Slot {slot}")
try:
self.__bridge.send(MinidspBeqCommandGenerator.filt(None, int(slot), self.__bridge.state()), int(slot))
self.__bridge.send(slot, None)
self.__state.clear(slot)
except Exception as e:
logger.exception(f"Failed to clear Slot {slot}")
self.__state.error(slot)
return self.__state.get(), 200


class Bridge(ABC):

@abstractmethod
def state(self) -> Optional[str]:
pass

@abstractmethod
def send(self, slot: str, entry: Union[Optional[Catalogue], bool]):
pass


class DeviceBridge(Bridge):

def __init__(self, cfg: Config):
self.__bridge = Minidsp(cfg) if cfg.minidsp_exe else Htp1(cfg) if cfg.htp1_options else None
if self.__bridge is None:
raise ValueError('Must have minidsp or HTP1 in confi')

def state(self) -> Optional[str]:
return self.__bridge.state()

def send(self, slot: str, entry: Union[Optional[Catalogue], bool]):
return self.__bridge.send(slot, entry)


class MinidspBeqCommandGenerator:

@staticmethod
Expand Down Expand Up @@ -145,7 +190,7 @@ def bypass(channel: int, idx: int, bypass: bool):
return f"input {channel} peq {idx} bypass {'on' if bypass else 'off'}"


class MinidspBridge:
class Minidsp(Bridge):

def __init__(self, cfg: Config):
from plumbum import local
Expand All @@ -157,10 +202,10 @@ def __init__(self, cfg: Config):
else:
self.__runner = cmd

def state(self) -> Optional[int]:
def state(self) -> Optional[str]:
return self.__executor.submit(self.__get_state).result(timeout=60)

def __get_state(self) -> Optional[int]:
def __get_state(self) -> Optional[str]:
active_slot = None
lines = None
try:
Expand All @@ -173,14 +218,19 @@ def __get_state(self) -> Optional[int]:
vals = line[idx+2:-2].split(', ')
for v in vals:
if v.startswith('preset: '):
active_slot = int(v[8:])
active_slot = str(int(v[8:]) + 1)
except:
logger.exception(f"Unable to locate active preset in {lines}")
return active_slot

def send(self, cmds: List[str], slot: int):
def send(self, slot: str, entry: Union[Optional[Catalogue], bool]):
target_slot_idx = int(slot) - 1
if entry is None or isinstance(entry, Catalogue):
cmds = MinidspBeqCommandGenerator.filt(entry, target_slot_idx, int(self.state()) - 1)
else:
cmds = MinidspBeqCommandGenerator.activate(target_slot_idx)
with tmp_file(cmds) as file_name:
return self.__executor.submit(self.__do_run, self.__runner['-f', file_name], len(cmds), slot).result(timeout=60)
return self.__executor.submit(self.__do_run, self.__runner['-f', file_name], len(cmds), target_slot_idx).result(timeout=60)

def __do_run(self, cmd, count: int, slot: int):
kwargs = {'retcode': None} if self.__ignore_retcode else {}
Expand Down Expand Up @@ -216,3 +266,24 @@ def tmp_file(cmds: List[str]):
finally:
if tmp_name:
os.unlink(tmp_name)


class Htp1(Bridge):

def __init__(self, cfg: Config):
self.__ip = cfg.htp1_options['ip']
self.__channels = cfg.htp1_options['channels']
if not self.__channels:
raise ValueError('No channels supplied for HTP-1')

@abstractmethod
def state(self) -> Optional[str]:
pass

@abstractmethod
def send(self, slot: str, entry: Union[Optional[Catalogue], bool]):
if entry is None or isinstance(entry, Catalogue):
pass
else:
raise ValueError('Activation not supported')

2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

setup(name='ezbeq',
version=version,
description='A small webapp which can send beqcatalogue filters to a minidsp',
description='A small webapp which can send beqcatalogue filters to a DSP device',
long_description=readme,
long_description_content_type='text/markdown',
classifiers=[
Expand Down
8 changes: 4 additions & 4 deletions ui/src/components/Devices.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const Devices = ({selectedEntryId}) => {
const [pending, setPending] = useState([]);

useEffect(() => {
pushData(setSlots, ezbeq.getMinidspConfig);
pushData(setSlots, ezbeq.getDeviceConfig);
}, []);

const trackDeviceUpdate = async (action, slotId, valProvider) => {
Expand Down Expand Up @@ -84,12 +84,12 @@ const Devices = ({selectedEntryId}) => {
};

// grid definitions
const minidspGridColumns = [
const deviceGridColumns = [
{
field: 'id',
headerName: ' ',
width: 25,
valueFormatter: params => `${params.value + 1}${params.getValue('active') ? '*' : ''}`
valueFormatter: params => `${params.value}${params.getValue('active') ? '*' : ''}`
},
{
field: 'actions',
Expand Down Expand Up @@ -132,7 +132,7 @@ const Devices = ({selectedEntryId}) => {
<Grid container direction={'column'} className={classes.noLeft}>
<Grid item style={{height: '190px', width: '100%'}}>
<DataGrid rows={slots}
columns={minidspGridColumns}
columns={deviceGridColumns}
autoPageSize
hideFooter
density={'compact'}/>
Expand Down

0 comments on commit 3016a9c

Please sign in to comment.