diff --git a/btc-ticker b/btc-ticker new file mode 100755 index 0000000..197d529 --- /dev/null +++ b/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 +# +# 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 . +# +""" +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] [ ]', +#-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: + :[,:[,…]] + 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]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') diff --git a/data_files/audio/Counterpoint.wav b/data_files/audio/Counterpoint.wav new file mode 100644 index 0000000..f5ad681 Binary files /dev/null and b/data_files/audio/Counterpoint.wav differ diff --git a/data_files/audio/Positive.wav b/data_files/audio/Positive.wav new file mode 100644 index 0000000..0ebee8f Binary files /dev/null and b/data_files/audio/Positive.wav differ diff --git a/data_files/audio/Rhodes.wav b/data_files/audio/Rhodes.wav new file mode 100644 index 0000000..5da03fe Binary files /dev/null and b/data_files/audio/Rhodes.wav differ diff --git a/data_files/audio/ringtone.wav b/data_files/audio/ringtone.wav new file mode 100644 index 0000000..6f5eca9 Binary files /dev/null and b/data_files/audio/ringtone.wav differ diff --git a/mmgen/node_tools/Global.py b/mmgen/node_tools/Global.py new file mode 100644 index 0000000..b6a62ed --- /dev/null +++ b/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 +# +# 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 . +""" +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' diff --git a/mmgen/node_tools/Sound.py b/mmgen/node_tools/Sound.py new file mode 100644 index 0000000..e8f26f8 --- /dev/null +++ b/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 +# +# 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 . +""" +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) diff --git a/mmgen/node_tools/Term.py b/mmgen/node_tools/Term.py new file mode 100644 index 0000000..b99d8fd --- /dev/null +++ b/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 +# +# 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 . +""" +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 diff --git a/mmgen/node_tools/Util.py b/mmgen/node_tools/Util.py new file mode 100644 index 0000000..91ecba6 --- /dev/null +++ b/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 +# +# 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 . +""" +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=' +') diff --git a/mmgen/node_tools/__init__.py b/mmgen/node_tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mmnode-play-sound b/mmnode-play-sound new file mode 100755 index 0000000..5af9897 --- /dev/null +++ b/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 +# +# 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 . +# +""" +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) diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..20eca66 --- /dev/null +++ b/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 +# +# 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 . + +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 }, + )