Skip to content

Commit

Permalink
Merge pull request #14 from micro-nova/update_groups
Browse files Browse the repository at this point in the history
Add various changes to update the api and make group support more robust

+ Make pretty printed output for full api status (including groups)
+ Add flags to make testing configuration easier for Ephraim and Jeremy
+ Add group fields that are aggregates of their zone properties to ensure a uniform implementation of group handling
  • Loading branch information
linknum23 authored Nov 9, 2020
2 parents ee5266a + de43fe9 commit 296a931
Show file tree
Hide file tree
Showing 5 changed files with 207 additions and 56 deletions.
20 changes: 9 additions & 11 deletions doc/ethaudio_api.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
"name":"new name", // sets the friendly name for the zone, ie "bathroom" or "kitchen 1"
"source_id": 0 | 1 | 2 | 3,
"mute": false | true, // this mutes the zone regardless of set volume
"stby": false | true, // this sets the zone to standby, very low power consumption
"vol": 0 to -79, // this sets the zone attenuation, 0 is max volume, -79 is min volume
"disabled": false | true // set this to true if the zone is not connected to any speakers and not in use
}
Expand All @@ -39,7 +38,6 @@
"source_id": 0 | 1 | 2 | 3, // change all zones in group to different source
"zones": [0,1,2...], // specify new array of zones that make up the group
"mute": false | true, // mutes all zones in group
"stby": false | true, // sets all zone in group to standby
"vol_delta": 0 to 79 // CHANGES the volume of each zone in the group by this much. For each zone, will saturate if out of range
}

Expand Down Expand Up @@ -70,16 +68,16 @@
{ "id":3, "name":"Source 4", "digital":false }
],
"zones": [ // this is an array of zones, array length depends on # of boxes connected
{ "id":0, "name":"Zone 1", "source_id":0, "mute":false, "stby":false, "disabled":false, "vol":0 },
{ "id":1, "name":"Zone 2", "source_id":0, "mute":false, "stby":false, "disabled":false, "vol":0 },
{ "id":2, "name":"Zone 3", "source_id":0, "mute":false, "stby":false, "disabled":false, "vol":0 },
{ "id":3, "name":"Zone 4", "source_id":0, "mute":false, "stby":false, "disabled":false, "vol":0 },
{ "id":4, "name":"Zone 5", "source_id":0, "mute":false, "stby":false, "disabled":false, "vol":0 },
{ "id":5, "name":"Zone 6", "source_id":0, "mute":false, "stby":false, "disabled":false, "vol":0 }
{ "id":0, "name":"Zone 1", "source_id":0, "mute":false, "disabled":false, "vol":0 },
{ "id":1, "name":"Zone 2", "source_id":0, "mute":false, "disabled":false, "vol":0 },
{ "id":2, "name":"Zone 3", "source_id":0, "mute":false, "disabled":false, "vol":0 },
{ "id":3, "name":"Zone 4", "source_id":0, "mute":false, "disabled":false, "vol":0 },
{ "id":4, "name":"Zone 5", "source_id":0, "mute":false, "disabled":false, "vol":0 },
{ "id":5, "name":"Zone 6", "source_id":0, "mute":false, "disabled":false, "vol":0 }
],
"groups": [ // this is an array of groups that have been created , each group has a friendly name and an array of member zones
{ "id":0, "name":"Group 1", "zones": [0,1,2] },
{ "id":1, "name":"Group 2", "zones": [2,3,4] },
{ "id":2, "name":"Group 3", "zones": [5] }
{ "id":0, "name":"Group 1", "source_id":0, "mute": false, "vol_delta":0, "zones": [0,1,2] },
{ "id":1, "name":"Group 2", "source_id":0, "mute": false, "vol_delta":0, "zones": [2,3,4] },
{ "id":2, "name":"Group 3", "source_id":0, "mute": false, "vol_delta":0, "zones": [5] }
]
}
184 changes: 152 additions & 32 deletions python/ethaudio/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,17 @@
from copy import deepcopy
import deepdiff

import serial
import pprint

DISABLE_HW = False # disable hardware based packages (smbus2 is not installable on Windows)
DEBUG_PREAMPS = False # print out preamp state after register write
DEBUG_API = True # print out a graphical state of the api after each call

import time
from smbus2 import SMBus

if not DISABLE_HW:
import serial
from smbus2 import SMBus

# Helper functions
def encode(pydata):
Expand Down Expand Up @@ -38,6 +46,36 @@ def updated_val(update, val):
def clamp(x, xmin, xmax):
return max(xmin, min(x, xmax))

def compact_str(l):
""" stringify a compact list"""
assert type(l) == list
return str(l).replace(' ', '')

def max_len(items, len_determiner=len):
""" determine the item with the max len, based on the @len_determiner's definition of length
Args:
items: iterable items
len_determiner: function that returns an integer, TODO: how to specify function return/type?
Returns:
len: integer
This is useful for lining up lists printed in a table-like format
"""
largest = max(items, key=len_determiner)
return len_determiner(largest)

def vol_string(vol, min_vol=-79, max_vol=0):
""" Make a visual representation of a volume """
VOL_RANGE = max_vol - min_vol + 1
VOL_STR_LEN = 20
VOL_SCALE = VOL_RANGE / VOL_STR_LEN
vol_level = int((vol - min_vol) / VOL_SCALE)
assert vol_level >= 0 and vol_level < VOL_STR_LEN
vol_string = ['-'] * VOL_STR_LEN
vol_string[vol_level] = '|' # place the volume slider bar at its current spot
return ''.join(vol_string) # turn that char array into a string

# Preamp register addresses
REG_ADDRS = {
'SRC_AD' : 0x00,
Expand All @@ -61,7 +99,9 @@ def clamp(x, xmin, xmax):
class Preamps:
def __init__(self, mock=False):
self.preamps = dict()
if not mock:
if DISABLE_HW or mock:
self.bus = None
else:
# Setup serial connection via UART pins - set I2C addresses for preamps
# ser = serial.Serial ("/dev/ttyS0") <--- for RPi4!
ser = serial.Serial ("/dev/ttyAMA0")
Expand All @@ -86,8 +126,6 @@ def __init__(self, mock=False):
if p == PREAMPS[0]:
print('Error: no preamps found')
break
else:
self.bus = None

def new_preamp(self, index):
self.preamps[index] = [
Expand All @@ -112,7 +150,8 @@ def write_byte_data(self, preamp_addr, reg, data):
# dynamically update preamps
if preamp_addr not in self.preamps:
self.new_preamp(preamp_addr)
print("writing to 0x{:02x} @ 0x{:02x} with 0x{:02x}".format(preamp_addr, reg, data))
if DEBUG_PREAMPS:
print("writing to 0x{:02x} @ 0x{:02x} with 0x{:02x}".format(preamp_addr, reg, data))
self.preamps[preamp_addr][reg] = data
# TODO: need to handle volume modifying mute state in mock
if self.bus is not None:
Expand All @@ -123,7 +162,7 @@ def write_byte_data(self, preamp_addr, reg, data):
time.sleep(0.01)
self.bus = SMBus(1)
self.bus.write_byte_data(preamp_addr, reg, data)

def probe_preamp(self, index):
# Scan for preamps, and set source registers to be completely digital
try:
Expand Down Expand Up @@ -151,18 +190,6 @@ def print(self):
for zone in range(6):
self.print_zone_state(6 * (preamp - 1) + zone)

def vol_string(self, vol):
MAX_VOL = 0
MIN_VOL = -79
VOL_RANGE = MAX_VOL - MIN_VOL + 1
VOL_STR_LEN = 20
VOL_SCALE = VOL_RANGE / VOL_STR_LEN
vol_level = int((vol - MIN_VOL) / VOL_SCALE)
assert vol_level >= 0 and vol_level < VOL_STR_LEN
vol_string = ['-'] * VOL_STR_LEN
vol_string[vol_level] = '|' # place the volume slider bar at its current spot
return ''.join(vol_string) # turn that char array into a string

def print_zone_state(self, zone):
assert zone >= 0
preamp = (int(zone / 6) + 1) * 8
Expand All @@ -176,7 +203,7 @@ def print_zone_state(self, zone):
state = []
if muted:
state += ['muted']
print(' {}({}) --> zone {} vol [{}] {}'.format(src, src_type[0], zone, self.vol_string(vol), ', '.join(state)))
print(' {}({}) --> zone {} vol [{}] {}'.format(src, src_type[0], zone, vol_string(vol), ', '.join(state)))

class MockRt:
""" Mock of an EthAudio Runtime
Expand Down Expand Up @@ -425,15 +452,48 @@ def __init__(self, rt = MockRt()):
{ "id": 14, "name": "Zone 15", "source_id": 0, "mute": True, "disabled": False, "vol": -79 },
{ "id": 15, "name": "Zone 16", "source_id": 0, "mute": True, "disabled": False, "vol": -79 },
{ "id": 16, "name": "Zone 17", "source_id": 0, "mute": True, "disabled": False, "vol": -79 },
{ "id": 17, "name": "Zone 18", "source_id": 0, "mute": True, "disabled": False, "vol": -79 }
{ "id": 17, "name": "Zone 18", "source_id": 0, "mute": True, "disabled": False, "vol": -79 },
],
"groups": [ # this is an array of groups that have been created , each group has a friendly name and an array of member zones
{ "id": 0, "name": "Group 1", "zones": [0,1,2] },
{ "id": 1, "name": "Group 2", "zones": [2,3,4] },
{ "id": 2, "name": "Group 3", "zones": [5] }
{ "id": 0, "name": "Group 1", "zones": [0,1,2], "source_id": 0, "mute": True, "vol_delta": -79 },
{ "id": 1, "name": "Group 2", "zones": [2,3,4], "source_id": 0, "mute": True, "vol_delta": -79 },
{ "id": 2, "name": "Group 3", "zones": [5], "source_id": 0, "mute": True, "vol_delta": -79 },
]
}

def visualize_api(self, prev_status=None):
viz = ''
# visualize source configuration
src_cfg = [{True: 'Digital', False: 'Analog'}.get(s['digital']) for s in self.status['sources']]
viz += ' [{}]\n'.format(', '.join(src_cfg))
# visualize zone configuration
enabled_zones = [z for z in self.status['zones'] if not z['disabled']]
viz += 'zones:\n'
zone_len = max_len(enabled_zones, lambda z: len(z['name']))
for z in enabled_zones:
src = z['source_id']
src_type = {True: 'D', False: 'A'}.get(self.status['sources'][src]['digital'])
muted = 'muted' if z['mute'] else ''
zone_fmt = ' {}({}) --> {:' + str(zone_len) + '} vol [{}] {}\n'
viz += zone_fmt.format(src, src_type, z['name'], vol_string(z['vol']), muted)
# print group configuration
viz += 'groups:\n'
enabled_groups = self.status['groups']
gzone_len = max_len(enabled_groups, lambda g: len(compact_str(g['zones'])))
gname_len = max_len(enabled_groups, lambda g: len(g['name']))
for g in enabled_groups:
if g['source_id']:
src = g['source_id']
src_type = {True: 'D', False: 'A'}.get(self.status['sources'][src]['digital'])
else:
src = ' '
src_type = ' '
muted = 'muted' if g['mute'] else ''
vol = vol_string(g['vol_delta'])
group_fmt = ' {}({}) --> {:' + str(gname_len) + '} {:' + str(gzone_len) + '} vol [{}] {}\n'
viz += group_fmt.format(src, src_type, g['name'], compact_str(g['zones']), vol, muted)
return viz

def parse_cmd(self, cmd):
""" process an individual command
Expand Down Expand Up @@ -461,6 +521,11 @@ def parse_cmd(self, cmd):
else:
output = error('command {} is not supported'.format(command))

if output:
print(output)
elif DEBUG_API:
print(self.visualize_api())

return output
except Exception as e:
return error(str(e)) # TODO: handle exception more verbosely
Expand Down Expand Up @@ -500,7 +565,7 @@ def set_source(self, id, name = None, digital = None):
if self._rt.update_sources(digital_cfg):
# update the status
src['digital'] = bool(digital)
if type(self._rt) == RpiRt:
if type(self._rt) == RpiRt and DEBUG_PREAMPS:
self._rt._bus.print()
return None
else:
Expand Down Expand Up @@ -540,7 +605,7 @@ def set_zone(self, id, name=None, source_id=None, mute=None, vol=None, disabled=
return error('failed to set zone, error getting current state: {}'.format(e))
try:
sid = parse_int(source_id, [0, 1, 2, 3, 4])
vol = parse_int(vol, range(-79, 1))
vol = parse_int(vol, range(-79, 79)) # hold additional state for group delta volume adjustments, output volume will be saturated to 0dB
zones = self.status['zones']
# update non hw state
z['name'] = name
Expand All @@ -561,12 +626,16 @@ def set_zone(self, id, name=None, source_id=None, mute=None, vol=None, disabled=
else:
return error('set zone failed: unable to update zone mute')
if update_vol:
if self._rt.update_zone_vol(idx, vol):
real_vol = clamp(vol, -79, 0)
if self._rt.update_zone_vol(idx, real_vol):
z['vol'] = vol
else:
return error('set zone failed: unable to update zone volume')

if type(self._rt) == RpiRt:
# update the group stats (individual zone volumes, sources, and mute configuration can effect a group)
self.update_groups()

if type(self._rt) == RpiRt and DEBUG_PREAMPS:
self._rt._bus.print()
return None
except Exception as e:
Expand All @@ -580,29 +649,71 @@ def get_group(self, id):
return i,g
return -1, None

def update_groups(self):
""" Update the group's aggregate fields to maintain consistency and simplify app interface """
for g in self.status['groups']:
zones = [ self.status['zones'][z] for z in g['zones'] ]
mutes = [ z['mute'] for z in zones ]
sources = set([ z['source_id'] for z in zones ])
vols = [ z['vol'] for z in zones ]
vols.sort()
g['mute'] = False not in mutes # group is only considered muted if all zones are muted
if len(sources) == 1:
g['source_id'] = sources.pop() # TODO: how should we handle different sources in the group?
else: # multiple sources
g['source_id'] = None
g['vol_delta'] = (vols[0] + vols[-1]) // 2 # group volume is the midpoint between the highest and lowest source

def set_group(self, id, name=None, source_id=None, zones=None, mute=None, vol_delta=None):
""" Configure an existing group
parameters will be used to configure each sone in the group's zones
all parameters besides the group id, @id, are optional
Args:
id: group id (a guid)
name: group name
source_id: group source
zones: zones that belong to the group
mute: group mute setting (muted=True)
vol_delta: volume adjustment to apply to each zone [-79,79]
Returns:
'None' on success, otherwise error (dict)
"""
_, g = self.get_group(id)
if g is None:
return error('set group failed, group {} not found'.format(id))
if type(zones) is str:
try:
zones = eval(zones)
except Exception as e:
return error('failed to configure group, error parsing zones: {}'.format(e))
try:
name, _ = updated_val(name, g['name'])
zones, _ = updated_val(zones, g['zones'])
vol_delta, vol_updated = updated_val(vol_delta, g['vol_delta'])
if vol_updated:
vol_change = vol_delta - g['vol_delta']
else:
vol_change = 0
except Exception as e:
return error('failed to configure group, error getting current state: {}'.format(e))

g['name'] = name
g['zones'] = zones

for z in [ self.status['zones'][zone] for zone in zones ]:
if vol_delta is not None:
vol = clamp(z['vol'] + vol_delta, -79, 0)
if vol_change != 0:
# TODO: make this use volume delta adjustment, for now its a fixed group volume
vol = vol_delta # vol = z['vol'] + vol_change
else:
vol = None
self.set_zone(z['id'], None, source_id, mute, vol)
g['vol_delta'] = vol_delta

if type(self._rt) == RpiRt:
# update the group stats
self.update_groups()

if type(self._rt) == RpiRt and DEBUG_PREAMPS:
self._rt._bus.print()

def new_group_id(self):
Expand All @@ -625,13 +736,22 @@ def create_group(self, name, zones):
if name in names:
return error('create group failed: {} already exists'.format(name))

if type(zones) is str:
try:
zones = eval(zones)
except Exception as e:
return error('failed to configure group, error parsing zones: {}'.format(e))

# get the new groug's id
id = self.new_group_id()

# add the new group
group = { 'id': id, 'name' : name, 'zones' : zones }
group = { 'id': id, 'name' : name, 'zones' : zones, 'vol_delta' : 0 }
self.status['groups'].append(group)

# update the group stats and populate uninitialized fields of the group
self.update_groups()

def delete_group(self, id):
"""delete an existing group"""
try:
Expand Down
7 changes: 7 additions & 0 deletions python/ethaudio/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
from http.server import HTTPServer, BaseHTTPRequestHandler
import threading

# pretty printing
import pprint

# get my ip
import socket

Expand Down Expand Up @@ -69,6 +72,9 @@ def parse_command(self, command_json_text):
'None' on success, otherwise a json encoded error
"""
cmd = decode(command_json_text)
print("received command: ")
pp = pprint.PrettyPrinter(indent=2)
pp.pprint(cmd)
return self.eth_audio_instance.parse_cmd(cmd)

def craft_error(self, error):
Expand Down Expand Up @@ -137,6 +143,7 @@ def do_POST(self):
content = self.rfile.read(content_length)

# attempt to parse
print('parsing: {}'.format(content))
parse_error = self.eth_audio_server.parse_command(content)
# reply with appropriate HTTP code
if(parse_error == None):
Expand Down
Loading

0 comments on commit 296a931

Please sign in to comment.