Browse Source

new file: btc-ticker
new file: data_files/audio/Counterpoint.wav
new file: data_files/audio/Positive.wav
new file: data_files/audio/Rhodes.wav
new file: data_files/audio/ringtone.wav
new file: mmgen/node_tools/Global.py
new file: mmgen/node_tools/Sound.py
new file: mmgen/node_tools/Term.py
new file: mmgen/node_tools/Util.py
new file: mmgen/node_tools/__init__.py
new file: mmnode-play-sound
new file: setup.py

philemon 8 years ago
parent
commit
11a60687af

+ 982 - 0
btc-ticker

@@ -0,0 +1,982 @@
+#!/usr/bin/python
+# -*- coding: UTF-8 -*-
+#
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
+# Copyright (C)2013-2016 Philemon <mmgen-py@yandex.com>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+"""
+btc-ticker: ticker and alarm clock for mmgen-node-tools
+"""
+
+import sys,os,time,subprocess
+import threading as th
+from collections import OrderedDict
+from decimal import Decimal
+
+from mmgen.share import Opts
+from mmgen.util import msg,msg_r,die,die_pause
+from mmgen.node_tools.Global import *
+from mmgen.node_tools.Util import *
+from mmgen.node_tools.Sound import *
+from mmgen.node_tools.Term import *
+
+from mmgen.color import *
+init_color()
+
+quit = False
+sound_vol = float(100)
+audio_host,repeat_spec = '','3s:5m,1m:30m,5m:1h,30m:1d'
+# repeat_spec = '1:5m'  # DEBUG
+valid_loglevels = OrderedDict([(1,'info'),(2,'data'),(3,'debug')])
+logfile = 'ticker_log.txt'
+alrm_clock = 'off'
+num_digits = 6
+proxy = None
+fx_host= 'https://finance.yahoo.com'
+
+sounds_dir = nt.system_data_dir+'/audio'
+sounds = {
+	'lo':          [sounds_dir+'/ringtone.wav',     95],
+	'hi':          [sounds_dir+'/Positive.wav',     102],
+	'conn_err':    [sounds_dir+'/Rhodes.wav',       105],
+	'alrm_clock':  [sounds_dir+'/Counterpoint.wav', 105],
+}
+alrm_names = tuple(sounds.keys())
+
+dlock,llock,alock = th.Lock(),th.Lock(),th.Lock()
+
+data_dir = nt.data_dir+'/lib/ticker/'
+
+try: os.makedirs(data_dir)
+except: pass
+
+def update_fx(a,b,debug=False):
+	if debug:
+		with open('/tmp/ticker.out') as f: text = f.read()
+	else:
+		text = get_url('{}/q?s={}{}'.format(fx_host,a,b),gzip_ok=True,proxy=proxy)
+	if not text: return False
+	import re
+	ret = re.split('yfs_l10_{}{}=.+?>'.format(a,b),text,maxsplit=1)[1].split('<',1)[0][:10]
+	try:
+		globals()[a+b] = float(ret)
+		with open(data_dir+'/{}{}.txt'.format(a,b),'wb') as f: f.write(ret+'\n')
+		return True
+	except:
+		return False
+
+def get_fx(a,b):
+	try:
+		with open(data_dir+'/{}{}.txt'.format(a,b)) as f:
+			d = f.read().rstrip()
+		globals()[a+b] = float(d)
+		return True
+	except:
+		return update_fx(a,b)
+
+with open('/etc/timezone') as f:
+	os.environ['TZ'] = f.read().rstrip()
+
+class Xch(object): pass
+class Src(object): pass
+
+num_xchgs = 2
+xchgs = tuple([Xch() for i in range(num_xchgs)])
+
+sources = OrderedDict([('tc','ticker')])
+for g in xchgs:
+	for k in sources:
+		setattr(g,k,Src())
+		setattr(getattr(g,k),'desc',sources[k])
+
+for g in [xchgs[0]]:
+	g.cur = 'USD'
+	g.Desc = 'BitFinex'
+	g.desc = 'bitfinex'
+	g.desc_short = 'bfx'
+	g.tc.url = 'https://api.bitfinex.com/v1/pubticker/btcusd'
+	g.poll_secs = 60
+	g.cur_sign = '$'
+	g.xcur_sign = '¥'
+	g.fiat_precision = 2
+	g.hi_alrm = 999999
+	g.lo_alrm = 1
+	g.cc_unit = 'BTC'
+
+for g in [xchgs[1]]:
+	g.cur = 'CNY'
+	g.Desc = 'OKCoin'
+	g.desc = 'okcoin'
+	g.desc_short = 'okc'
+	g.tc.url = 'https://www.okcoin.cn/api/v1/ticker.do?symbol=btc_cny'
+	g.poll_secs = 60
+	g.cur_sign = '¥'
+	g.xcur_sign = '$'
+	g.fiat_precision = 1
+	g.hi_alrm = 999999
+	g.lo_alrm = 1
+	g.cc_unit = 'BTC'
+
+opts_data = {
+	'prog_name': sys.argv[0].split('/')[-1],
+	'desc': 'Price alarm for Bitcoin exchange',
+	'usage': '[opts] [<low price alarm> <high price alarm>]',
+#-b, --big-number         Print big numbers
+	'options': """
+-h, --help               Print this help message
+-a, --alarm-clock-only   Disable ticker, use as alarm clock only
+-c, --cur-ticker-only    Display only current exchange's ticker price
+-d, --debug              Debug mode.  Use saved HTTP data from files
+-l, --log=             l Log all data to file '{lf}' at levels 'l' (comma-separated: {lls})
+-n, --num-digits=      n Display 'n' number of big digits
+-p, --poll-intervals=i1[,i2] Poll servers every 'i' seconds (default: '{pi}')
+-P, --proxy=           x Connect via proxy 'x' (see PROXY EXAMPLES below for format)
+-r, --repeat-spec        Sleep interval/duration program for the alarm
+                         (default: '{r}')
+                         (see REPEAT SPEC FORMAT below)
+-R, --resize-window      Resize window to optimum dimensions
+-t, --testing            Testing mode.  Don't execute shell commands
+-u, --utc                Show times in UTC rather than local time
+-v, --volume=          n Adjust sound volume by percentage 'n' (default: {v})
+-V, --test-volume        Test the alarm volume and exit
+-x, --xchgs=     x,y[,…] Display only exchanges 'x,y[,…]' (comma-separated
+                         list of integers, see CONFIGURED EXCHANGES below)
+""".format(
+		v=int(sound_vol),
+		pi = ','.join([str(g.poll_secs) for g in xchgs]),
+		r=repeat_spec,
+		lf=logfile,
+		lls=', '.join(['%s=%s'%(i,j) for i,j in valid_loglevels.items()])
+	),
+	'notes': '''
+
+REPEAT SPEC FORMAT:
+  <interval>:<duration>[,<interval>:<duration>[,…]]
+  For example, '3s:5m,1m:2h,30m:1d' means:
+    ring alarm every 3 seconds for 5 minutes,
+    then every minute for 2 hours,
+    then every 30 minutes for 1 day
+
+PROXY EXAMPLES:
+  socks5h://localhost:9050 (for Tor running on localhost)
+  http://192.168.0.4:8118 (for Privoxy running on host 192.168.0.4)
+
+CONFIGURED EXCHANGES:
+  {x}
+'''.format(
+		x='\n  '.join(['%s - %s'%(n,x.Desc) for n,x in enumerate(xchgs,1)])
+	)
+}
+
+opts,args = Opts.parse_opts(sys.argv,opts_data)[:2]
+
+# Global var: the currently displayed exchange
+if 'alarm_clock_only' in opts:
+	num_digits = 4
+	mute_alrms = False
+	ga = None
+	xchgs = []
+else:
+	mute_alrms = True
+	ga = xchgs[0]
+	fx_pairs = [('usd','cny')]
+	for a,b in fx_pairs:
+		msg_r('Getting {}/{} exchange rate...'.format(a.upper(),b.upper()))
+		if get_fx(a,b):
+			msg('OK')
+		else:
+			die_pause(1,'Unable to get {}/{} exchange rate'.format(a.upper(),b.upper()))
+	if 'proxy' in opts: proxy = opts['proxy']
+
+	if 'xchgs' in opts:
+		usr_xchgs = [int(i)-1 for i in opts['xchgs'].split(',')]
+		xchgs = [xchgs[i] for i in usr_xchgs]
+
+	# Initialize some variables for active exchanges
+	for g in xchgs:
+		g.retry_interval = 30
+		g.conn_alrm_timeout = 300
+		g.tc.bal = 0.0,0.0,0.0
+		if not hasattr(g,'desc_short'): g.desc_short = g.desc
+		for k in sources:
+			s = getattr(g,k)
+
+main_thrd_names = tuple([x.desc+'_ticker' for x in xchgs])
+kill_flgs = dict([(k,th.Event()) for k in main_thrd_names + ('alrm','clock','log')])
+
+debug = 'debug' in opts
+
+if debug: msg('Debugging mode.  Using saved data from files')
+
+def do_errmsg(s,k='ticker'):
+	if k == 'ticker':
+		with dlock:
+			blank_ticker()
+			msg_r(CUR_HOME)
+			msgred_r(s)
+			time.sleep(5)
+
+def toggle_option(k):
+	with dlock:
+		if k in opts: del opts[k]
+		else: opts[k] = True
+
+def get_thrd_names(): return [i.name for i in th.enumerate()]
+
+def start_alrm(name):
+	kill_flgs['alrm'].clear()
+	t = th.Thread(
+			target = play_sound,
+			name = name,
+			kwargs = {
+				'fn': sounds[name][0], # (fn,vol)
+				'repeat_spec': repeat_spec,
+				'remote_host': audio_host,
+				'vol': sound_vol * sounds[name][1] / 100,
+				'kill_flg': kill_flgs['alrm'],
+				'testing': False
+			}
+		)
+	t.daemon = True
+	t.start()
+
+def start_alrm_maybe(my_thrd_name,kill_list=None):
+
+	if mute_alrms: return
+
+	# Allow for an empty kill list
+	klist = kill_list if kill_list != None else alrm_names
+
+	with alock: # kill alarm thrds except mine
+		if any([n in klist and n != my_thrd_name for n in get_thrd_names()]):
+			kill_flgs['alrm'].set()
+
+		if not my_thrd_name: return  # if thread name 'None', kill first, then return
+
+		if not any([n in alrm_names for n in get_thrd_names()]):
+			start_alrm(my_thrd_name)
+
+def parse_loglevel_arg(s):
+	m1 = 'Invalid loglevel argument: %s\n' % s
+	try: ret = [int(i) for i in s.split(',')]
+	except:
+		m2 = 'Loglevels must be comma-separated int values'
+		return False, m1+m2
+	if not set(ret) <= set(valid_loglevels):
+		m2 = 'Valid loglevels: %s' % ','.join([str(i) for i in valid_loglevels])
+		return False, m1+m2
+	return ret,'OK'
+
+def set_alrm_vals(s):
+	ret = []
+	m1 = 'Invalid alarm argument: %s\n' % s
+	for e in s.split(':'):
+		try: ret.append([Decimal(i) for i in e.split(',')])
+		except:
+			m2 = 'Alarms must be comma-separated decimal values'
+			return False, m1+m2
+		if len(ret[-1]) != 2:
+			m2 = 'Each element of alarm list must have 2 items (lo_alrm,hi_alrm)'
+			return False, m1+m2
+
+	if len(ret) != len(xchgs):
+		m2 = 'Alarm list must be %s colon-separated comma-separated lists' % len(xchgs)
+		return False, m1+m2
+
+	for g,a in zip(xchgs,ret):
+		lo,hi = a
+		if lo > hi:
+			m2 = 'Low alarm (%s) is greater than high alarm (%s)' % (lo,hi)
+			return False, m1+m2
+		setattr(g,'lo_alrm',lo)
+		setattr(g,'hi_alrm',hi)
+
+	return ret,'OK'
+
+def set_poll_intervals(s,xchg=None):
+	ret = []
+	m1 = 'Invalid poll interval argument: %s\n' % s
+	m2 = 'Poll intervals must be comma-separated integer values'
+	m3 = 'Poll interval must be integer value'
+	for e in (s.split(','),[s])[bool(xchg)]:
+		try: ret.append(float(e))
+		except:
+			return False, m1+(m2,m3)[bool(xchg)]
+
+	if not xchg and len(ret) != len(xchgs):
+		m2 = 'Poll interval list have %s items' % len(xchgs)
+		return False, m1+m2
+
+	for g,p in zip((xchgs,[xchg])[bool(xchg)],ret):
+		setattr(g,'poll_secs',float(p))
+
+	return ret,'OK'
+
+def set_xch_param(source,param,s,desc):
+	ret = []
+	m1 = 'Invalid %s argument: %s\n' % (desc,s)
+	for e in s.split(':'):
+		try: ret.append(float(e))
+		except:
+			m2 = '%s arg must be colon-separated float values' % desc.capitalize()
+			return False, m1+m2
+
+	if len(ret) != len(xchgs):
+		m2 = '%s list must have %s colon-separated items' % (
+			desc.capitalize(),len(xchgs))
+		return False, m1+m2
+
+	for g,p in zip(xchgs,ret):
+		a = getattr(g,source)
+		setattr(a,param,float(p))
+
+	return ret,'OK'
+
+if 'log' in opts:
+	loglevels,errmsg = parse_loglevel_arg(opts['log'])
+	if not loglevels: die_pause(1,errmsg)
+	msg('Logging at level{} {}'.format(
+		('s','')[len(loglevels)==1],
+		' '.join([valid_loglevels[i].upper() for i in loglevels])))
+else:
+	loglevels = []
+
+if 'repeat_spec' in opts:
+	repeat_spec = opts['repeat_spec']
+	msg("Using program '{}' for alarm".format(repeat_spec))
+
+if 'num_digits' in opts:
+	n = opts['num_digits']
+	if len(n) != 1 or n not in '56789':
+		die_pause(1,"'%s': invalid value for --num-digits option" % n)
+	num_digits = int(n)
+
+if 'volume' in opts:
+	sound_vol = int(opts['volume'])
+	for k in sounds:
+		sounds[k][1] = float(sounds[k][1] * sound_vol / 100)
+	msg('Adjusting sound volume by {}%'.format(sound_vol))
+
+if 'test_volume' in opts:
+	for k in sounds:
+		msg("Playing '{}' ({})".format(k,sounds[k][0]))
+		play_sound(
+			fn=sounds[k][0],
+			vol=sound_vol * sounds[k][1] / 100,
+			testing='testing' in opts
+		)
+	sys.exit()
+
+if len(args) > 1: Opts.usage(opts_data)
+
+if len(args) == 1:
+	ret,errmsg = set_alrm_vals(args[0])
+	if not ret: die_pause(1,'Error: ' + errmsg)
+	msg('Setting alarms to %s' % ', '.join(['{} {}'.format(i,j) for i,j in ret]))
+
+if 'poll_intervals' in opts:
+	ret,errmsg = set_poll_intervals(opts['poll_intervals'])
+	if not ret: die_pause(1,errmsg)
+	msg('Polling every %s seconds' % ret)
+
+tmux = 'TMUX' in os.environ
+if tmux:
+	subprocess.check_output(['tmux','set','set-titles','on'])
+	subprocess.check_output(['tmux','set','status','off'])
+#	dcmd = "date -R%s | cut -d' ' -f2-5" % ('','u')['utc' in opts]
+#	subprocess.check_output(['tmux','set','status-right','#(%s)' % dcmd])
+
+infoW = 15
+bigDigitsW = big_digits['w']*num_digits + big_digits['pw']*1
+lPaneW = bigDigitsW+infoW+1
+topPaneH,rPaneW = 6,23
+
+def CUR_UP(n):    return '\033[%sA' % n
+def CUR_DOWN(n):  return '\033[%sB' % n
+def CUR_RIGHT(n): return '\033[%sC' % n
+def CUR_LEFT(n):  return '\033[%sD' % n
+def WIN_TITLE(s): return '\033]0;%s\033\\' % s
+def WIN_RESIZE(w,h): return '\033[8;%s;%st' % (h,w)
+def WIN_CORNER(): return '\033[3;200;0t'
+
+CUR_UP1    = '\033[A'
+CUR_DN1    = '\033[B'
+CUR_SHOW    = '\033[?25h'
+CUR_HIDE    = '\033[?25l'
+BLINK       = '\033[5m'
+RESET       = '\033[0m'
+CUR_HOME    = '\033[H'
+ERASE_ALL   = '\033[0J'
+
+def draw_rectangle(s,l,h):
+	msg_r(s + '\n'.join([l] * h))
+def blank_ticker():
+	draw_rectangle(CUR_HOME,' ' * lPaneW,topPaneH)
+def blank_big_digits():
+	draw_rectangle(CUR_HOME,' ' * (bigDigitsW+1),topPaneH)
+def park_cursor():
+	msg_r('\r' + CUR_RIGHT(lPaneW-1))
+def colorize_bal(g,n):
+	d = g.tc
+	fs = '{:.%sf}' % g.fiat_precision
+	return (nocolor,green,red)[
+			(bool(d.save_bal[n]) and
+				(d.bal[n]!=d.save_bal[n]) + (d.bal[n]<d.save_bal[n]))
+		](fs.format(d.bal[n]))
+
+def display_ac_info():
+	ac_fmt = green('--:--') if alrm_clock == 'off' else yelbg(alrm_clock)
+	info_lines = (
+		'{}'.format(ac_fmt),
+		'snd vol: {}%'.format(int(sound_vol)),
+		'{}'.format((yellow('unmuted'),'muted')[mute_alrms]),
+		'{}'.format(blue(audio_host[:infoW] or 'localhost'))
+		)
+	r = CUR_RIGHT(bigDigitsW+1)
+	msg_r(CUR_HOME+r+('\n'+r).join(info_lines))
+	park_cursor()
+	ts = 'Alarm Clock: {}{}'.format(alrm_clock.upper(),('',' (m)')[mute_alrms])
+	if tmux:
+ 		subprocess.check_output(['tmux','set','set-titles-string',ts])
+ 	else:
+		msg_r(WIN_TITLE(ts))
+
+def display_ticker(g,called_by_clock=False):
+	if not g: return
+	d = g.tc
+	if not hasattr(d,'timestamp'): return
+
+	avg = sum((d.bal[i] or d.save_bal[i]) for i in range(3)) / 3
+	alrm,lb,hb,rst = (
+		(None,'','',''),
+		('lo',BLINK,'',RESET),
+		('hi','',BLINK,RESET)
+		)[(avg<g.lo_alrm)+(avg>g.hi_alrm)*2]
+
+	start_alrm_maybe(alrm,['lo','hi'])
+
+	if (g is not ga) and not called_by_clock: return
+
+	lfmt = '{n:.{p}f}'.format(p=g.fiat_precision,n=d.bal[2])
+	msg_r(CUR_HOME+lb+hb+display_big_digits(lfmt,pre=' '+rst))
+
+	if (g is not ga) and called_by_clock:
+		park_cursor()
+		return
+
+	hms = get_hms(d.timestamp,utc='utc' in opts)
+	xcur = '{:.2f}'.format(d.bal[2]/usdcny if g.cur == 'CNY' else d.bal[2]*usdcny)
+	ac_fmt = green('--:--') if alrm_clock == 'off' else yelbg(alrm_clock)
+	info_lines = (
+		'{} {}'.format(hms,('%ss'%int(g.poll_secs))),
+		'{} bid/ask'.format(cyan(g.desc_short)),
+		'{} {}'.format(colorize_bal(g,0), colorize_bal(g,1)),
+		'{g.xcur_sign}{x} {h}'.format(g=g,x=xcur,
+			h=('',blue(audio_host[:infoW-len(xcur)-2]))[bool(audio_host)]),
+		'{} {} {}'.format(yellow(('♫','-')[mute_alrms]),
+			lb+yellow(str(g.lo_alrm))+rst,
+			hb+yellow(str(g.hi_alrm))+rst),
+		'{} vol {}%'.format(ac_fmt,int(sound_vol))
+		)
+
+	r = CUR_RIGHT(bigDigitsW+1)
+	msg_r(CUR_HOME+r+('\n'+r).join(info_lines))
+	park_cursor()
+
+	ccs = ('฿','Ł')[g.cc_unit=='LTC']
+
+	if len(xchgs) == 2:
+		x1,x2 = xchgs
+		ts = '{}{:.{}f} / {}{:.{}f}'.format(
+			x1.cur_sign,
+			x1.tc.bal[2],
+			x1.fiat_precision,
+			x2.cur_sign,
+			x2.tc.bal[2],
+			x2.fiat_precision,
+			)
+	else:
+		ts = '{}: {}{:.{}f} ({})'.format(
+			ccs,
+			g.cur_sign,
+			d.bal[2],
+			g.fiat_precision,
+			g.Desc
+			)
+	if tmux:
+# 		subprocess.call(['tmux','rename-session',g.Desc], stderr=subprocess.PIPE)
+ 		subprocess.check_output(['tmux','set','set-titles-string',ts])
+ 	else:
+		msg_r(WIN_TITLE(ts))
+
+def log(*args): # [lvl,g,src,data] OR [lvl,data]
+	if 'log' in opts and args[0] in loglevels + [0]:
+		if len(args) == 2:
+			s = '{}: {}\n'.format(get_day_hms(),args[1])
+		elif len(args) == 4:
+			s = '{}: {} {} - {}\n'.format(
+				get_day_hms(),
+				args[1].desc_short.upper(),
+				args[2].upper(),
+				args[3]
+			)
+		with llock:
+			fd = os.open(logfile,os.O_RDWR|os.O_APPEND|os.O_CREAT|os.O_SYNC)
+			os.write(fd,s)
+			os.close(fd)
+
+def get_market_data(g,d,connfail_msg='full'):
+#		, post_data={}
+
+	tcd = 'TRADING_CONSOLE_DEBUG_CONNFAIL'
+	debug_connfail = tcd in os.environ and os.environ[tcd]
+	null = None  # hack for eval'ing Huobi trades data
+
+	if debug:
+		fn = 'debug_market_data/%s_ticker.json' % g.desc
+		try:
+			with open(fn) as f: text = f.read()
+			return eval(text)
+		except:
+			die(2,'Unable to open datafile %s' % fn)
+
+	fail_count,conn_begin_time = 0,time.time()
+	if debug_connfail:
+		retry_interval,conn_alrm_timeout = 10,0
+	else:
+		retry_interval,conn_alrm_timeout = g.retry_interval,g.conn_alrm_timeout
+
+	while True:
+		try:
+			text = get_url(d.url,proxy=proxy)
+			log(2,g,d.desc,text)
+			return eval(text)
+		except KeyboardInterrupt:
+			die(1,'\nUser interrupt (get_market_data)\n')
+		except EOFError:
+			die(1,'\nEnd of file\n')
+		except Exception as e:
+			fail_count += 1
+			if connfail_msg:
+				with dlock:
+					if g is ga:
+						m = {
+							'short':'Connect fail ({})'.format(fail_count),
+							'full': 'Connect fail.  Retry in {} seconds ({})'.format(
+											retry_interval,fail_count)
+						}
+						dn = CUR_DOWN(topPaneH-1)
+						blank_big_digits()
+						msg_r(CUR_HOME+dn+m[connfail_msg]+' \b')
+
+			k = '%s_%s' % (g.desc,d.desc)
+
+			if time.time() - conn_begin_time > conn_alrm_timeout:
+				if fail_count == 1:
+					log(1,'Connect error (%s)' % k)
+				if d.desc == 'ticker':
+					with dlock: d.bal = d.bal[:2] + (0.0,) # can't assign to tuple, so this
+				start_alrm_maybe('conn_err')
+
+			# Sleep until user interrupt
+			if kill_flgs[k].wait(retry_interval):
+				kill_flgs[k].clear()
+				return False
+
+def killwait(g,d):
+	log(3,g,d.desc,'Begin wait')
+
+	k = '%s_%s' % (g.desc,d.desc)
+	if kill_flgs[k].wait(g.poll_secs):
+		kill_flgs[k].clear()
+		if quit: return True
+
+	log(3,g,d.desc,'End wait')
+
+	return False
+
+def log_loop():
+	while True:
+		for g in xchgs:
+			with dlock:
+				lstr = '{} last: {}{}'.format(g.desc_short.upper(),g.cur_sign,g.tc.bal[2])
+			log(1,lstr)
+
+		if kill_flgs['log'].wait(30):
+			kill_flgs['log'].clear()
+			if quit: return True
+
+def ticker_loop(g):
+	d = g.tc
+	while True:
+		ret = get_market_data(g,d)
+
+		if not ret: # kill flag was set
+			if quit: break
+			continue
+
+		errmsg = 'ticker_loop: HTTP returned bad data'
+		with dlock:
+			if g.desc == 'bitfinex':
+				a = ret
+				ts,bal = 'timestamp',('bid','ask','last_price')
+			else:
+				try:
+					a = ret['ticker']
+				except:
+					log(1,errmsg); continue
+				ts = ('time','date')[g.desc=='okcoin']
+				bal = 'buy','sell','last'
+# okc: {"date":"1477932232","ticker":{"buy":"4844.13","high":"4873.36","last":"4844.16","low":"4660.0","sell":"4844.14","vol":"2992115.73393084"}}
+
+			try:
+				d.timestamp = int(float(ret[ts]))
+			except:
+				log(1,errmsg); continue
+
+			d.save_bal = d.bal
+			try:
+				d.bal = tuple([float(a[k]) for k in bal])
+			except:
+				log(1,errmsg); continue
+
+		if killwait(g,d): break
+
+def clock_loop():
+	ac_active = False
+	ac_prefix=('     ','')['alarm_clock_only' in opts]
+
+	def do_ticker(x):
+		with dlock:
+			blank_big_digits()
+			display_ticker(x,called_by_clock=True)
+		if kill_flgs['clock'].wait(2): sys.exit()
+
+	while True:
+		if 'alarm_clock_only' in opts:
+			with dlock:
+				blank_ticker()
+				display_ac_info()
+		elif not ac_active:
+			if 'cur_ticker_only' in opts:
+				do_ticker(ga)
+			else:
+				for x in xchgs: do_ticker(x)
+
+		if alrm_clock == 'off' and ac_active:
+			ac_active = False
+
+		if alrm_clock != 'off' or 'alarm_clock_only' in opts:
+			now = get_hms(no_secs=True)
+			if now == alrm_clock:
+				ac_active = True
+				start_alrm_maybe('alrm_clock',['alrm_clock'])
+			if not any([(n in alrm_names and n != 'alrm_clock') for n in get_thrd_names()]):
+				bl,rs = (('',''),(BLINK,RESET))[ac_active]
+				with dlock:
+					blank_big_digits()
+					msg_r(CUR_HOME+bl+display_big_digits(now,pre=ac_prefix)+rs)
+					park_cursor()
+				if kill_flgs['clock'].wait(2): return
+
+def input_loop():
+
+	global ga,sound_vol,alrm_clock,quit,mute_alrms
+	from mmgen.node_tools.Term import get_keypress
+
+	ch = get_keypress("Hit 'q' to quit, any other key to continue: ")
+	if ch == 'q': die(0,'')
+
+	if 'resize_window' in opts:
+		if tmux:
+			msg('\nWARNING: Window resizing doesn\'t work in tmux')
+		else:
+			msg_r(WIN_RESIZE(lPaneW,topPaneH))
+			msg_r(WIN_CORNER())
+			time.sleep(0.1)
+
+	from mmgen.term import set_terminal_vars
+	set_terminal_vars()
+	from mmgen.term import get_terminal_size
+	twid = get_terminal_size()[0]
+	if twid < lPaneW:
+		die_pause(1,'\nTerminal window must be at least %s characters wide' % lPaneW)
+
+	msg_r(CUR_HIDE)
+#	raw_input('\nPress any key to exit'); sys.exit() # DEBUG
+	blank_ticker()
+
+	for k in xchgs:
+		th.Thread(target=globals()['ticker_loop'],args=[k],name=k.desc+'_ticker').start()
+
+	th.Thread(target=clock_loop,name='clock').start()
+	th.Thread(target=log_loop,name='log').start()
+
+	time.sleep(1)  # Hack for get_keypress()
+	def redraw():
+		blank_ticker()
+		display_ticker(ga)
+
+	help_texts = {
+		'tc': '''
+a - set alarm clock          l - set low alarm
+A - set remote audio host    m - mute alarms
+e - update USD/CNY rate      M - write message to log
+G - set log levels           p - set poll interval
+h - set high alarm           t - reload ticker
+k - kill alarm               v - set sound volume
+L - log current state        x - cycle thru exchanges
+c - toggle display of current ticker only
+-/+ - adjust sound volume
+''',
+		'ac': '''
+a - set alarm clock         m - mute alarms
+A - set remote audio host   k - kill alarm
+M - write message to log    v - set sound volume
+-/+ - adjust sound volume
+'''
+	}
+	help_text = ['{:^{w}}'.format('KEYSTROKE COMMANDS',w=lPaneW)]
+	help_text += help_texts[('tc','ac')['alarm_clock_only' in opts]].strip().split('\n')
+	help_text_prompt = 'ESC or ENTER to exit, ↑ and ↓ to scroll'
+
+	def do_help():
+		scrollpos = 0
+		def fmt_help_txt(pos):
+			return '\n'.join(help_text[pos:pos+5])+'\n'+help_text_prompt+' '
+		with dlock:
+			while True:
+				blank_ticker()
+				ch = get_keypress(CUR_HOME+fmt_help_txt(scrollpos),esc_sequences=True)
+				if ch == CUR_DN1 and scrollpos < len(help_text) - topPaneH + 1:
+					scrollpos += 1
+				elif ch == CUR_UP1 and scrollpos != 0:
+					scrollpos -= 1
+				elif ch in '\n\033': break
+			blank_ticker()
+			display_ticker(ga)
+
+	def ks_msg_nolock(s,delay):
+		blank_ticker()
+		msg_r(CUR_HOME+s)
+		time.sleep(delay)
+		blank_ticker()
+		display_ticker(ga)
+
+	def ks_msg(s,delay):
+		with dlock:
+			ks_msg_nolock(s,delay)
+
+	def set_value(v,prompt,vtype=int,poll=False,source=None,
+					all_srcs=False,global_var=False,allow_empty=False):
+
+		def type_chk(vtype,val):
+			try:
+				vtype(val); return True
+			except:
+				return False
+
+		errmsg = ''
+		with dlock:
+			while True:
+				blank_ticker()
+				em = errmsg+'\n' if errmsg else ''
+				termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_termattrs)
+#				time.sleep(0.1)
+				s = raw_input(CUR_HOME+CUR_SHOW+em+prompt)
+				msg_r(CUR_HIDE)
+				if not s:
+					if allow_empty:
+						ks_msg_nolock('Value unset',0.5)
+					else:
+						blank_ticker()
+						display_ticker(ga)
+						return False
+
+				if v == 'alrm_clock':
+					if s == 'off':
+						globals()[v] = s
+						if v in get_thrd_names():
+							kill_flgs['alrm'].set()
+						return s
+					a = s.split(':')
+					ok = True
+					if len(a) != 2: ok = False
+					if ok:
+						try:    h,m = int(a[0]),int(a[1])
+						except: ok = False
+					if ok:
+						if not (24 >= h >= 0): ok = False
+						if not (60 >= m >= 0): ok = False
+					if ok:
+						globals()[v] = '{:02d}:{:02d}'.format(h,m)
+						break
+					else:
+						errmsg = 'Incorrect alarm clock value'
+						continue
+				elif v == 'loglevels':
+					ret,err = parse_loglevel_arg(s)
+					if ret:
+						globals()[v] = ret; break
+					else:
+						errmsg = err; continue
+				elif v == 'poll_secs':
+					ret,errmsg = set_poll_intervals(s,xchg=ga)
+					if ret: break
+					else: continue
+				elif all_srcs:
+					vals = s.split(',')
+					if len(vals) != len(sources):
+						errmsg = 'Input must be %s comma-separated %s values'%(
+							len(sources),vtype.__name__)
+						continue
+					for k,val in zip(sources,vals):
+						ret = type_chk(vtype,val)
+						if ret: setattr(getattr(ga,k),v,vtype(val))
+						else: break
+					if ret: break
+				elif source:
+					if type_chk(vtype,s):
+						setattr(getattr(ga,source),v,vtype(s)); break
+				else:
+					if type_chk(vtype,s):
+						if v == 'hi_alrm' and vtype(s) < ga.lo_alrm:
+							errmsg = 'High alarm must be >= low alarm'
+							continue
+						if v == 'lo_alrm' and vtype(s) > ga.hi_alrm:
+							errmsg = 'Low alarm must be <= high alarm'
+							continue
+						if global_var:
+							globals()[v] = vtype(s); break
+						else:
+							setattr(ga,v,vtype(s)); break
+
+				errmsg = 'Value%s must be of type %s' % (
+						('','s')[all_srcs],vtype.__name__)
+
+			redraw()
+
+ 		if poll:
+			kill_flgs[ga.desc+'_ticker'].set()
+
+		return True
+
+	while True:
+		ch = get_keypress()
+		if ch == 'q':
+			quit = True
+			for k in kill_flgs: kill_flgs[k].set()
+			for i in th.enumerate():
+				if i.name in main_thrd_names + alrm_names + ('clock',): i.join()
+			msg('')
+			break
+		elif ch == 'a':
+			m1 = 'Current alarm clock: %s' % alrm_clock
+			m2 = "Enter alarm clock time or 'off': "
+			if set_value('alrm_clock',m1+'\n'+m2):
+				ks_msg("Alarm clock set to %s" % alrm_clock,1.5)
+		elif ch == 'A':
+			set_value('audio_host','Enter remote audio host: ',
+				vtype=str,global_var=True,allow_empty=True)
+		elif ch in 'H?': do_help()
+		elif ch == 'k':
+			if 'alrm_clock' in get_thrd_names(): alrm_clock = 'off'
+			a = any([i in alrm_names for i in get_thrd_names()])
+			log(3,'\n  alrm_names: {}\n  get_thrd_names(): {}'.format(
+				alrm_names,get_thrd_names()))
+			if a: kill_flgs['alrm'].set()
+			ks_msg(('No active alarms','Killing alarm')[a],0.5)
+		elif ch == 'M':
+			with dlock:
+				blank_ticker()
+				termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_termattrs)
+				s = raw_input(CUR_HOME+'Enter log message: ')
+				log(0,'USR_MSG: '+s)
+				display_ticker(ga)
+		elif ch == 'm':
+			mute_alrms = not mute_alrms
+			if mute_alrms: kill_flgs['alrm'].set()
+			ks_msg(('Unm','M')[mute_alrms] + 'uting alarms',1)
+			with dlock:
+				blank_ticker()
+				display_ticker(ga)
+		elif ch == 'v': set_value('sound_vol','Enter sound volume: ',global_var=True)
+		elif ch in '-+':
+			with dlock:
+				sound_vol += (1,-1)[ch=='-']
+				blank_ticker(); display_ticker(ga)
+		elif not 'alarm_clock_only' in opts:
+			if ch == 'c':
+				toggle_option('cur_ticker_only')
+				ks_msg('Displaying %s' %
+					('all tickers','current ticker only')['cur_ticker_only' in opts],0.5)
+			elif ch == 'G': set_value('loglevels','Enter log levels: ',global_var=True)
+			elif ch == 'h': set_value('hi_alrm','Enter high alarm: ',Decimal)
+			elif ch == 'l': set_value('lo_alrm','Enter low alarm: ',Decimal)
+			elif ch == 'L':
+				ks_msg('Logging current ticker at %s' % get_hms(),1.5)
+				with dlock:
+					log(0,'{} CUR_STATE\n BID/ASK/LAST {}\n'.format(ga.desc.upper(),ga.tc.bal))
+			elif ch == 'p': set_value('poll_secs',
+				'Enter poll interval: ',float,poll=True)
+			elif ch == 't':
+				key = '%s_ticker' % (ga.desc)
+				if key in kill_flgs:
+					kill_flgs[key].set()
+					ks_msg('Reloading ticker data',1)
+				else:
+					ks_msg('%s: no such key' % key,1)
+			elif ch == 'x':
+				for x in range(len(xchgs)):
+					if ga is xchgs[x]: break
+				new_g = xchgs[x+1 if x+1 < len(xchgs) else 0]
+				ks_msg('Switching to exchange %s' % new_g.Desc,0.5)
+				with dlock:
+					ga = new_g
+					redraw()
+			elif ch == 'e':
+				for a,b in fx_pairs:
+					ks_msg('Updating {}/{} rate'.format(a.upper(),b.upper()),0.5)
+					ret = update_fx(a,b)
+					m,d = (('Unable to update',2),('Updated',0.7))[bool(ret)]
+					ks_msg('{} {}/{} rate'.format(m,a.upper(),b.upper()),d)
+
+def launch(name,*args,**kwargs):
+
+	def at_exit():
+# 		msg_r(CUR_HOME+ERASE_ALL) # DEBUG
+ 		msg_r(CUR_SHOW)
+		termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_termattrs)
+		for k in kill_flgs: kill_flgs[k].set()
+		for i in th.enumerate():
+			if i.name in main_thrd_names + alrm_names + ('clock',): i.join()
+		if 'log' in opts: log(0,'Exiting')
+
+	import atexit
+	atexit.register(at_exit)
+
+	log(0,'Starting log')
+
+	try:
+		globals()[name](*args,**kwargs)
+	except KeyboardInterrupt:
+		quit = True
+		for k in kill_flgs: kill_flgs[k].set()
+		sys.stderr.write('\nUser interrupt\n')
+
+import termios
+old_termattrs = termios.tcgetattr(sys.stdin.fileno())
+launch('input_loop')

BIN
data_files/audio/Counterpoint.wav


BIN
data_files/audio/Positive.wav


BIN
data_files/audio/Rhodes.wav


BIN
data_files/audio/ringtone.wav


+ 27 - 0
mmgen/node_tools/Global.py

@@ -0,0 +1,27 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+#
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
+# Copyright (C)2013-2016 Philemon <mmgen-py@yandex.com>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+"""
+node_tools.Global: global variables for MMGen node tools
+"""
+
+import os
+
+class nt(object):
+	system_data_dir = '/usr/local/share/mmgen/node_tools'
+	data_dir = os.getenv('HOME') + '/.mmgen/node_tools'

+ 81 - 0
mmgen/node_tools/Sound.py

@@ -0,0 +1,81 @@
+#!/usr/bin/python
+# -*- coding: UTF-8 -*-
+#
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
+# Copyright (C)2013-2016 Philemon <mmgen-py@yandex.com>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+"""
+node_tools.Sound: audio-related functions for MMGen node tools
+"""
+
+import sys,os,time
+from mmgen.node_tools.Util import *
+
+_alsa_config_file = '/tmp/alsa-config-' + os.path.basename(sys.argv[0])
+_dvols = { 'Master': 78, 'Speaker': 78, 'Headphone': 15, 'PCM': 190 }
+
+def timespec2secs(ts):
+	import re
+	mul = { 's': 1, 'm': 60, 'h': 60*60, 'd': 60*60*24 }
+	pat = r'^([0-9]+)([smhd]*)$'
+	m = re.match(pat,ts)
+	if m == None:
+		die(2,"'%s': invalid time specifier" % ts)
+	a,b = m.groups()
+	return int(a) * (mul[b] if b else 1)
+
+def parse_repeat_spec(rs):
+	return [(timespec2secs(i),timespec2secs(j))
+				for i,j in [a.split(':') for a in rs.split(',')]]
+
+def init_sound():
+	def _restore_sound():
+#	msg('Restoring sound volume')
+		do_system('alsactl restore -f ' + _alsa_config_file)
+		os.unlink(_alsa_config_file)
+	import atexit
+	atexit.register(_restore_sound)
+	do_system('alsactl store -f ' + _alsa_config_file)
+
+def play_sound(fn,vol,repeat_spec='',remote_host='',kill_flg=None,testing=False):
+	if not remote_host:
+		do_system('alsactl store -f ' + _alsa_config_file)
+		for k in 'Master','Speaker','Headphone':
+			do_system(('amixer -q set %s on' % k),testing)
+#		do_system('amixer -q set Headphone off')
+
+		vols = dict([(k,int(_dvols[k] * float(vol) / 100)) for k in _dvols])
+		for k in vols:
+			do_system('amixer -q set %s %s' % (k,vols[k]),testing)
+
+	fn = os.path.expanduser(fn)
+	cmd = (
+		'aplay -q %s' % fn,
+		'ssh %s mmnode-play-sound -v%d %s' % (remote_host,vol,fn)
+	)[bool(remote_host)]
+
+	if repeat_spec and kill_flg:
+		for interval,duration in parse_repeat_spec(repeat_spec):
+			start = time.time()
+			while time.time() < start + duration:
+				do_system(cmd,testing)
+				if kill_flg.wait(interval):
+					if not remote_host:
+						do_system('alsactl restore -f ' + _alsa_config_file)
+					return
+	else: # Play once
+		do_system(cmd,testing)
+		if not remote_host:
+			do_system('alsactl restore -f ' + _alsa_config_file)

+ 50 - 0
mmgen/node_tools/Term.py

@@ -0,0 +1,50 @@
+#!/usr/bin/python
+# -*- coding: UTF-8 -*-
+#
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
+# Copyright (C)2013-2016 Philemon <mmgen-py@yandex.com>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+"""
+node_tools.Term: terminal routines for MMGen node tools
+"""
+
+import sys,os,termios
+
+def get_keypress(prompt="",esc_sequences=False):
+
+	import time,tty,select
+	sys.stderr.write(prompt)
+
+	fd = sys.stdin.fileno()
+#	old = termios.tcgetattr(fd) # see below
+	tty.setcbreak(fd) # must do this, even if it was set at program launch
+
+	def osread_chk(n):
+		while True:
+			try:
+				return os.read(fd,n)
+			except:
+				time.sleep(0.1)
+
+	# Must use os.read() for unbuffered read, otherwise select() will never return true
+	s = osread_chk(1)
+	if esc_sequences:
+		if s == '\x1b':
+			if select.select([sys.stdin],[],[],0)[0]:
+				s += osread_chk(2)
+
+# Leave the term in cbreak mode, restore at exit
+#	termios.tcsetattr(fd, termios.TCSADRAIN, old)
+	return s

+ 117 - 0
mmgen/node_tools/Util.py

@@ -0,0 +1,117 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+#
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
+# Copyright (C)2013-2016 Philemon <mmgen-py@yandex.com>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+"""
+node_tools.Util: utility functions for MMGen node tools
+"""
+
+import time,subprocess
+
+def get_hms(t=None,utc=False,no_secs=False):
+	secs = t or time.time()
+	ret = (time.localtime,time.gmtime)[utc](secs)
+	fs,n = (('{:02}:{:02}:{:02}',6),('{:02}:{:02}',5))[no_secs]
+	return fs.format(*ret[3:n])
+
+def get_day_hms(t=None,utc=False):
+	secs = t or time.time()
+	ret = (time.localtime,time.gmtime)[utc](secs)
+	return '{:04}-{:02}-{:02} {:02}:{:02}:{:02}'.format(*ret[0:6])
+
+def do_system(cmd,testing=False,shell=False):
+	if testing:
+		msg("Would execute: '%s'" % cmd)
+		return True
+	else:
+		return subprocess.call((cmd if shell else cmd.split()),shell,stderr=subprocess.PIPE)
+
+def get_url(url,gzip_ok=False,proxy=None,timeout=60,verbose=False):
+	import pycurl,cStringIO
+	c = pycurl.Curl()
+	c_out = cStringIO.StringIO()
+	c.setopt(pycurl.WRITEFUNCTION,c_out.write)
+	c.setopt(pycurl.TIMEOUT,timeout)
+	c.setopt(pycurl.FOLLOWLOCATION,True)
+	c.setopt(pycurl.COOKIEFILE,'')
+	c.setopt(pycurl.VERBOSE,verbose)
+	if gzip_ok:
+		c.setopt(pycurl.USERAGENT,'Lynx/2.8.9dev.8 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/3.4.9')
+		c.setopt(pycurl.HTTPHEADER, [
+	'Accept: text/html, text/plain, text/sgml, text/css, application/xhtml+xml, */*;q=0.01',
+	'Accept-Encoding: gzip',
+	'Accept-Language: en']
+		)
+	if proxy:
+		c.setopt(pycurl.PROXY,proxy)
+	c.setopt(pycurl.URL,url)
+	c.perform()
+	text = c_out.getvalue()
+	if text[:2] == '\x1f\x8b': # gzip magic number
+		c_out.seek(0,0)
+		import gzip
+		with gzip.GzipFile(fileobj=c_out) as f:
+			text = f.read()
+	c_out.close()
+	c.close()
+	return text
+
+# big_digits = """
+#  ███    █    ███   ███     █  █████  ███  █████  ███   ███
+# █   █  ██   █   █     █   ██  █     █         █ █   █ █   █
+# █   █   █     ██    ██   █ █  ████  ████     █   ███   ████
+# █   █   █    █        █ ████      █ █   █   █   █   █     █
+#  ███    █   █████  ███     █  ████   ███   █     ███   ███   ██
+#
+# """
+
+big_digits = {
+	'w': 7, 'h': 6, 'n': 10, 'nums': """
+ ████     █    ████   ████      █  █████   ████  ██████  ████   ████
+█    █   ██   █    █      █    ██  █      █           █ █    █ █    █
+█    █    █       █    ███    █ █  ████   █████      █   ████   █████
+█    █    █     ██        █  █  █      █  █    █    █   █    █      █
+█    █    █    █          █ █████      █  █    █   █    █    █      █
+ ████     █   ██████  ████      █  ████    ████   █      ████   ████
+""",
+	'pw': 5, 'pn': 2, 'punc': """
+
+
+      ██
+
+      ██
+ ██
+"""
+}
+
+_bnums_c,_bpunc_c = [[l.strip('\n') + ' ' * (big_digits[m]*big_digits['n'])
+	for l in big_digits[k][1:].decode('utf8').split('\n')]
+		for k,m in ('nums','w'),('punc','pw')]
+
+_bnums_n,_bpunc_n = [[[l[0+(j*w):w+(j*w)] for l in i]
+					for j in range(big_digits[n])] for n,w,i in
+						('n',big_digits['w'],_bnums_c),('pn',big_digits['pw'],_bpunc_c)]
+
+def display_big_digits(s,pre='',suf=''):
+	s = [int((d,10,11)[(d in '.:')+(d==':')]) for d in s]
+	return pre + ('\n'+pre).join(
+		[''.join([(_bnums_n+_bpunc_n)[d][l] for d in s]) + suf for l in range(big_digits['h'])]
+	)
+
+if __name__ == '__main__':
+	num = '2345.17'
+	print display_big_digits(num,pre='+ ',suf='  +')

+ 0 - 0
mmgen/node_tools/__init__.py


+ 55 - 0
mmnode-play-sound

@@ -0,0 +1,55 @@
+#!/usr/bin/python
+#
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
+# Copyright (C)2013-2016 Philemon <mmgen-py@yandex.com>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+"""
+mmnode-play-sound: play a sound with controlled volume
+"""
+
+import sys
+from mmgen.util import die
+from mmgen.node_tools.Sound import *
+from mmgen.share import Opts
+volume = 100
+
+opts_data = {
+	'prog_name': sys.argv[0].split('/')[-1],
+	'desc': 'Play a sound file at controlled volume',
+	'usage': '[opts]',
+	'options': """
+-h, --help       Print this help message
+-v, --volume=  n Adjust sound volume by percentage 'n' (default: {})
+""".format(volume)
+}
+
+opts,args = Opts.parse_opts(sys.argv,opts_data)[:2]
+
+if 'volume' in opts:
+	volume = opts['volume']
+	try:
+		volume = int(volume)
+		assert 1 <= volume <= 120
+	except:
+		die(1,'Sound volume must be an integer between 1 and 120')
+
+if len(args) != 1:
+	die(1,'You must supply a sound file')
+
+try:    os.stat(args[0])
+except: die(1,"Couldn't stat file '{}'".format(args[0]))
+
+play_sound(args[0],volume)

+ 51 - 0
setup.py

@@ -0,0 +1,51 @@
+#!/usr/bin/env python
+#
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
+# Copyright (C)2013-2016 Philemon <mmgen-py@yandex.com>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import os
+from distutils.core import setup
+from distutils.command.install_data import install_data
+from mmgen.globalvars import g
+
+class my_install_data(install_data):
+	def run(self):
+		sdir = os.path.join('data_files','audio')
+		for f in [e for e in os.listdir(sdir) if e[-4:] == '.wav']:
+			os.chmod(os.path.join(sdir,f),0644)
+		install_data.run(self)
+
+setup(
+		name         = 'mmgen-node-tools',
+		description  = 'Optional tools for the MMGen wallet system',
+		version      = g.version,
+		author       = g.author,
+		author_email = g.email,
+		url          = g.proj_url,
+		license      = 'GNU GPL v3',
+		platforms    = 'Linux, MS Windows, Raspberry Pi',
+		keywords     = g.keywords,
+		packages     = ['mmgen.node_tools'],
+		scripts      = ['btc-ticker','mmnode-play-sound'],
+		data_files = [('share/mmgen/node_tools/audio', [
+				'data_files/audio/ringtone.wav',     # source files must have 0644 mode
+				'data_files/audio/Positive.wav',
+				'data_files/audio/Rhodes.wav',
+				'data_files/audio/Counterpoint.wav'
+				])
+		],
+		cmdclass  = { 'install_data': my_install_data },
+	)