31365f9bccd373124ecbea805721ac52ef578393
[uccvend-vendserver.git] / sql-edition / servers / VendServer.py
1 #!/usr/bin/python
2 # vim:ts=4
3
4 USE_DB = 0
5
6 import ConfigParser
7 import sys, os, string, re, pwd, signal
8 import logging, logging.handlers
9 from traceback import format_tb
10 if USE_DB: import pg
11 from time import time, sleep
12 from popen2 import popen2
13 from LATClient import LATClient, LATClientException
14 from VendingMachine import VendingMachine, VendingException
15 from HorizScroll import HorizScroll
16 from random import random, seed
17 from Idler import TrainIdler,GrayIdler
18 import socket
19 from posix import geteuid
20
21 GREETING = 'UCC SNACKS'
22 PIN_LENGTH = 4
23
24 DOOR = 1
25 SWITCH = 2
26 KEY = 3
27
28 class DispenseDatabaseException(Exception): pass
29
30 class DispenseDatabase:
31         def __init__(self, vending_machine, host, name, user, password):
32                 self.vending_machine = vending_machine
33                 self.db = pg.DB(dbname = name, host = host, user = user, passwd = password)
34                 self.db.query('LISTEN vend_requests')
35
36         def process_requests(self):
37                 logging.debug('database processing')
38                 query = 'SELECT request_id, request_slot FROM vend_requests WHERE request_handled = false'
39                 try:
40                         outstanding = self.db.query(query).getresult()
41                 except (pg.error,), db_err:
42                         raise DispenseDatabaseException('Failed to query database: %s\n'%(db_err.strip()))
43                 for (id, slot) in outstanding:
44                         (worked, code, string) = self.vending_machine.vend(slot)
45                         logging.debug (str((worked, code, string)))
46                         if worked:
47                                 query = 'SELECT vend_success(%s)'%id
48                                 self.db.query(query).getresult()
49                         else:
50                                 query = 'SELECT vend_failed(%s)'%id
51                                 self.db.query(query).getresult()
52
53         def handle_events(self):
54                 notifier = self.db.getnotify()
55                 while notifier is not None:
56                         self.process_requests()
57                         notify = self.db.getnotify()
58
59 def scroll_options(username, mk, welcome = False):
60         if welcome:
61                 msg = [(center('WELCOME'), False, 0.8),
62                            (center(username), False, 0.8)]
63         else:
64                 msg = []
65         choices = ' '*10+'CHOICES: '
66         try:
67                 coke_machine = file('/home/other/coke/coke_contents')
68                 cokes = coke_machine.readlines()
69                 coke_machine.close()
70         except:
71                 cokes = []
72                 pass
73         for c in cokes:
74                 c = c.strip()
75                 (slot_num, price, slot_name) = c.split(' ', 2)
76                 if slot_name == 'dead': continue
77                 choices += '%s8-%s (%sc) '%(slot_num, slot_name, price)
78         choices += '55-DOOR '
79         choices += 'OR A SNACK. '
80         choices += '99 TO READ AGAIN. '
81         choices += 'CHOICE?   '
82         msg.append((choices, False, None))
83         mk.set_messages(msg)
84
85 def get_pin(uid):
86         try:
87                 info = pwd.getpwuid(uid)
88         except KeyError:
89                 return None
90         if info.pw_dir == None: return False
91         pinfile = os.path.join(info.pw_dir, '.pin')
92         try:
93                 s = os.stat(pinfile)
94         except OSError:
95                 return None
96         if s.st_mode & 077:
97                 return None
98         try:
99                 f = file(pinfile)
100         except IOError:
101                 return None
102         pinstr = f.readline()
103         f.close()
104         if not re.search('^'+'[0-9]'*PIN_LENGTH+'$', pinstr):
105                 return None
106         return int(pinstr)
107
108 def has_good_pin(uid):
109         return get_pin(uid) != None
110
111 def verify_user_pin(uid, pin):
112         if get_pin(uid) == pin:
113                 info = pwd.getpwuid(uid)
114                 return info.pw_name
115         else:
116                 return None
117
118 def door_open_mode(v):
119         logging.warning("Entering open door mode")
120         v.display("-FEED  ME-")
121         while True:
122                 e = v.next_event()
123                 if e == None: break
124                 (event, params) = e
125                 if event == DOOR:
126                         if params == 1: # door closed
127                                 logging.warning('Leaving open door mode')
128                                 v.display("-YUM YUM!-")
129                                 sleep(1)
130                                 return
131
132 def cookie(v):
133         seed(time())
134         messages = ['  WASSUP! ', 'PINK FISH ', ' SECRETS ', '  ESKIMO  ', ' FORTUNES ', 'MORE MONEY']
135         choice = int(random()*len(messages))
136         msg = messages[choice]
137         left = range(len(msg))
138         for i in range(len(msg)):
139                 if msg[i] == ' ': left.remove(i)
140         reveal = 1
141         while left:
142                 s = ''
143                 for i in range(0, len(msg)):
144                         if i in left:
145                                 if reveal == 0:
146                                         left.remove(i)
147                                         s += msg[i]
148                                 else:
149                                         s += chr(int(random()*26)+ord('A'))
150                                 reveal += 1
151                                 reveal %= 17
152                         else:
153                                 s += msg[i]
154                 v.display(s)
155
156 def center(str):
157         LEN = 10
158         return ' '*((LEN-len(str))/2)+str
159
160 class MessageKeeper:
161         def __init__(self, vendie):
162                 # Each element of scrolling_message should be a 3-tuple of
163                 # ('message', True/False if it is to be repeated, time to display)
164                 self.scrolling_message = []
165                 self.v = vendie
166                 self.next_update = None
167
168         def set_message(self, string):
169                 self.scrolling_message = [(string, False, None)]
170                 self.update_display(True)
171
172         def set_messages(self, strings):
173                 self.scrolling_message = strings
174                 self.update_display(True)
175
176         def update_display(self, forced = False):
177                 if not forced and self.next_update != None and time() < self.next_update:
178                         return
179                 if len(self.scrolling_message) > 0:
180                         if len(self.scrolling_message[0][0]) > 10:
181                                 (m, r, t) = self.scrolling_message[0]
182                                 a = []
183                                 exp = HorizScroll(m).expand(padding = 0, wraparound = True)
184                                 if t == None:
185                                         t = 0.1
186                                 else:
187                                         t = t / len(exp)
188                                 for x in exp:
189                                         a.append((x, r, t))
190                                 del self.scrolling_message[0]
191                                 self.scrolling_message = a + self.scrolling_message
192                         newmsg = self.scrolling_message[0]
193                         if newmsg[2] != None:
194                                 self.next_update = time() + newmsg[2]
195                         else:
196                                 self.next_update = None
197                         self.v.display(self.scrolling_message[0][0])
198                         if self.scrolling_message[0][1]:
199                                 self.scrolling_message.append(self.scrolling_message[0])
200                         del self.scrolling_message[0]
201
202         def done(self):
203                 return len(self.scrolling_message) == 0
204
205 def run_forever(rfh, wfh, options, cf):
206         v = VendingMachine(rfh, wfh)
207         logging.debug('PING is' + str(v.ping()))
208
209         if USE_DB: db = DispenseDatabase(v, cf.DBServer, cf.DBName, cf.DBUser, cf.DBPassword)
210         cur_user = ''
211         cur_pin = ''
212         cur_selection = ''
213
214         mk = MessageKeeper(v)
215         mk.set_message(GREETING)
216         time_to_autologout = None
217         #idler = TrainIdler(v)
218         #idler = GrayIdler(v)
219         idler = GrayIdler(v,one="*",zero="-")
220         time_to_idle = None
221         last_timeout_refresh = None
222
223         while True:
224                 if USE_DB:
225                         try:
226                                 db.handle_events()
227                         except DispenseDatabaseException, e:
228                                 logging.error('Database error: '+str(e))
229
230                 if time_to_autologout != None:
231                         time_left = time_to_autologout - time()
232                         if time_left < 6 and (last_timeout_refresh is None or last_timeout_refresh > time_left):
233                                 mk.set_message('LOGOUT: '+str(int(time_left)))
234                                 last_timeout_refresh = int(time_left)
235                                 cur_selection = ''
236
237                 if time_to_autologout != None and time_to_autologout - time() <= 0:
238                         time_to_autologout = None
239                         cur_user = ''
240                         cur_pin = ''
241                         cur_selection = ''
242                         mk.set_message(GREETING)
243
244                 if time_to_autologout and not mk.done(): time_to_autologout = None
245                 if cur_user == '' and time_to_autologout: time_to_autologout = None
246                 if len(cur_pin) == PIN_LENGTH and mk.done() and time_to_autologout == None:
247                         # start autologout
248                         time_to_autologout = time() + 15
249
250                 if time_to_idle == None and cur_user == '': time_to_idle = time() + 60
251                 if time_to_idle != None and cur_user != '': time_to_idle = None
252                 if time_to_idle is not None and time() > time_to_idle: idler.next()
253
254                 mk.update_display()
255
256                 e = v.next_event(0)
257                 if e == None:
258                         e = v.next_event(0.1)
259                         if e == None:
260                                 continue
261                 time_to_idle = None
262                 (event, params) = e
263                 logging.debug('Got event: ' + repr(e))
264                 if event == DOOR:
265                         if params == 0:
266                                 door_open_mode(v);
267                                 cur_user = ''
268                                 cur_pin = ''
269                                 mk.set_message(GREETING)
270                 elif event == SWITCH:
271                         # don't care right now.
272                         pass
273                 elif event == KEY:
274                         key = params
275                         # complicated key handling here:
276                         if len(cur_user) < 5:
277                                 if key == 11:
278                                         cur_user = ''
279                                         mk.set_message(GREETING)
280                                         continue
281                                 cur_user += chr(key + ord('0'))
282                                 mk.set_message('UID: '+cur_user)
283                                 if len(cur_user) == 5:
284                                         uid = int(cur_user)
285                                         if not has_good_pin(uid):
286                                                 #mk.set_messages(
287                                                         #[(center('INVALID'), False, 0.7),
288                                                          #(center('PIN'), False, 0.7),
289                                                          #(center('SETUP'), False, 1.0),
290                                                          #(GREETING, False, None)])
291                                                 mk.set_messages(
292                                                         [(' '*10+'INVALID PIN SETUP'+' '*10, False, 3),
293                                                          (GREETING, False, None)])
294                                                 cur_user = ''
295                                                 cur_pin = ''
296                                                 continue
297                                         cur_pin = ''
298                                         mk.set_message('PIN: ')
299                                         continue
300                         elif len(cur_pin) < PIN_LENGTH:
301                                 if key == 11:
302                                         if cur_pin == '':
303                                                 cur_user = ''
304                                                 mk.set_message(GREETING)
305                                                 continue
306                                         cur_pin = ''
307                                         mk.set_message('PIN: ')
308                                         continue
309                                 cur_pin += chr(key + ord('0'))
310                                 mk.set_message('PIN: '+'X'*len(cur_pin))
311                                 if len(cur_pin) == PIN_LENGTH:
312                                         username = verify_user_pin(int(cur_user), int(cur_pin))
313                                         if username:
314                                                 v.beep(0, False)
315                                                 cur_selection = ''
316                                                 scroll_options(username, mk, True)
317                                                 continue
318                                         else:
319                                                 v.beep(40, False)
320                                                 mk.set_messages(
321                                                         [(center('BAD PIN'), False, 1.0),
322                                                          (center('SORRY'), False, 0.5),
323                                                          (GREETING, False, None)])
324                                                 cur_user = ''
325                                                 cur_pin = ''
326                                                 continue
327                         elif len(cur_selection) == 0:
328                                 if key == 11:
329                                         cur_pin = ''
330                                         cur_user = ''
331                                         cur_selection = ''
332                                         mk.set_messages(
333                                                 [(center('BYE!'), False, 1.5),
334                                                  (GREETING, False, None)])
335                                         continue
336                                 cur_selection += chr(key + ord('0'))
337                                 mk.set_message('SELECT: '+cur_selection)
338                                 time_to_autologout = None
339                         elif len(cur_selection) == 1:
340                                 if key == 11:
341                                         cur_selection = ''
342                                         time_to_autologout = None
343                                         scroll_options(username, mk)
344                                         continue
345                                 else:
346                                         cur_selection += chr(key + ord('0'))
347                                         #make_selection(cur_selection)
348                                         # XXX this should move somewhere else:
349                                         if cur_selection == '55':
350                                                 mk.set_message('OPENSESAME')
351                                                 if geteuid() == 0:
352                                                         ret = os.system('su - "%s" -c "dispense door"'%username)
353                                                 else:
354                                                         ret = os.system('dispense door')
355                                                 if ret == 0:
356                                                         mk.set_message(center('DOOR OPEN'))
357                                                 else:
358                                                         mk.set_message(center('BAD DOOR'))
359                                                 sleep(1)
360                                         elif cur_selection == '91':
361                                                 cookie(v)
362                                         elif cur_selection == '99':
363                                                 scroll_options(username, mk)
364                                                 cur_selection = ''
365                                                 continue
366                                         elif cur_selection[1] == '8':
367                                                 v.display('GOT COKE?')
368                                                 os.system('su - "%s" -c "dispense %s"'%(username, cur_selection[0]))
369                                         else:
370                                                 v.display('HERES A '+cur_selection)
371                                                 v.vend(cur_selection)
372                                         sleep(0.5)
373                                         v.display('THANK YOU')
374                                         sleep(0.5)
375                                         cur_selection = ''
376                                         time_to_autologout = time() + 8
377
378 def connect_to_vend(options, cf):
379         # Open vending machine via LAT?
380         if options.use_lat:
381                 latclient = LATClient(service = cf.ServiceName, password = cf.ServicePassword, server_name = cf.ServerName, connect_password = cf.ConnectPassword, priv_password = cf.PrivPassword)
382                 rfh, wfh = latclient.get_fh()
383         else:
384                 #(rfh, wfh) = popen2('../../virtualvend/vvend.py')
385                 import socket
386                 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
387                 sock.connect((options.host, options.port))
388                 rfh = sock.makefile('r')
389                 wfh = sock.makefile('w')
390                 
391         return rfh, wfh
392
393 def parse_args():
394         from optparse import OptionParser
395
396         op = OptionParser(usage="%prog [OPTION]...")
397         op.add_option('-f', '--config-file', default='/etc/dispense/servers.conf', metavar='FILE', dest='config_file', help='use the specified config file instead of /etc/dispense/servers.conf')
398         op.add_option('--virtualvend', action='store_false', default=True, dest='use_lat', help='use the virtual vending server instead of LAT')
399         op.add_option('-n', '--hostname', dest='host', default='localhost', help='the hostname to connect to for virtual vending machine mode (default: localhost)')
400         op.add_option('-p', '--port', dest='port', default=5150, type='int', help='the port number to connect to (default: 5150)')
401         op.add_option('-l', '--log-file', metavar='FILE', dest='log_file', default='', help='log output to the specified file')
402         op.add_option('-s', '--syslog', dest='syslog', action='store_true', default=False, help='log output to syslog')
403         op.add_option('-d', '--daemon', dest='daemon', action='store_true', default=False, help='run as a daemon')
404         op.add_option('-v', '--verbose', dest='verbose', action='store_true', default=False, help='spit out lots of debug output')
405         op.add_option('-q', '--quiet', dest='quiet', action='store_true', default=False, help='only report errors')
406         op.add_option('--pid-file', dest='pid_file', metavar='FILE', default='', help='store daemon\'s pid in the given file')
407         options, args = op.parse_args()
408
409         if len(args) != 0:
410                 op.error('extra command line arguments: ' + ' '.join(args))
411
412         return options
413
414 config_options = {
415         'DBServer': ('Database', 'Server'),
416         'DBName': ('Database', 'Name'),
417         'DBUser': ('VendingMachine', 'DBUser'),
418         'DBPassword': ('VendingMachine', 'DBPassword'),
419         
420         'ServiceName': ('VendingMachine', 'ServiceName'),
421         'ServicePassword': ('VendingMachine', 'Password'),
422         
423         'ServerName': ('DecServer', 'Name'),
424         'ConnectPassword': ('DecServer', 'ConnectPassword'),
425         'PrivPassword': ('DecServer', 'PrivPassword'),
426         }
427
428 class VendConfigFile:
429         def __init__(self, config_file, options):
430                 try:
431                         cp = ConfigParser.ConfigParser()
432                         cp.read(config_file)
433
434                         for option in options:
435                                 section, name = options[option]
436                                 value = cp.get(section, name)
437                                 self.__dict__[option] = value
438                 
439                 except ConfigParser.Error, e:
440                         logging.critical("Error reading config file "+config_file+": " + str(e))
441                         logging.critical("Bailing out")
442                         sys.exit(1)
443
444 def create_pid_file(name):
445         try:
446                 pid_file = file(name, 'w')
447                 pid_file.write('%d\n'%os.getpid())
448                 pid_file.close()
449         except IOError, e:
450                 logging.warning('unable to write to pid file '+name+': '+str(e))
451
452 def set_stuff_up():
453         def do_nothing(signum, stack):
454                 signal.signal(signum, do_nothing)
455         def stop_server(signum, stack): raise KeyboardInterrupt
456         signal.signal(signal.SIGHUP, do_nothing)
457         signal.signal(signal.SIGTERM, stop_server)
458         signal.signal(signal.SIGINT, stop_server)
459
460         options = parse_args()
461         set_up_logging(options)
462         config_opts = VendConfigFile(options.config_file, config_options)
463         if options.daemon: become_daemon()
464         if options.pid_file != '': create_pid_file(options.pid_file)
465
466         return options, config_opts
467
468 def clean_up_nicely(options, config_opts):
469         if options.pid_file != '':
470                 try:
471                         os.unlink(options.pid_file)
472                         logging.debug('Removed pid file '+options.pid_file)
473                 except OSError: pass  # if we can't delete it, meh
474
475 def set_up_logging(options):
476         logger = logging.getLogger()
477         
478         stderr_logger = logging.StreamHandler(sys.stderr)
479         stderr_logger.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
480         logger.addHandler(stderr_logger)
481         
482         if options.log_file != '':
483                 try:
484                         file_logger = logging.FileHandler(options.log_file)
485                         file_logger.setFormatter(logging.Formatter('%(asctime)s %(levelname)s: %(message)s'))
486                         logger.addHandler(file_logger)
487                 except IOError, e:
488                         logger.warning('unable to write to log file '+options.log_file+': '+str(e))
489
490         if options.syslog:
491                 sys_logger = logging.handlers.SysLogHandler('/dev/log', 'daemon')
492                 sys_logger.setFormatter(logging.Formatter('vendserver[%d]'%(os.getpid()) + ' %(levelname)s: %(message)s'))
493                 logger.addHandler(sys_logger)
494
495         if options.quiet:
496                 logger.setLevel(logging.WARNING)
497         elif options.verbose:
498                 logger.setLevel(logging.DEBUG)
499         else:
500                 logger.setLevel(logging.INFO)
501
502 def become_daemon():
503         dev_null = file('/dev/null')
504         fd = dev_null.fileno()
505         os.dup2(fd, 0)
506         os.dup2(fd, 1)
507         os.dup2(fd, 2)
508         if os.fork() != 0:
509                 sys.exit(0)
510
511 def do_vend_server(options, config_opts):
512         while True:
513                 try:
514                         rfh, wfh = connect_to_vend(options, config_opts)
515                 except (LATClientException, socket.error), e:
516                         (exc_type, exc_value, exc_traceback) = sys.exc_info()
517                         del exc_traceback
518                         logging.error("Connection error: "+str(exc_type)+" "+str(e))
519                         logging.info("Trying again in 5 seconds.")
520                         sleep(5)
521                         continue
522                 
523                 try:
524                         run_forever(rfh, wfh, options, config_opts)
525                 except VendingException:
526                         logging.error("Connection died, trying again...")
527                         logging.info("Trying again in 5 seconds.")
528                         sleep(5)
529
530 if __name__ == '__main__':
531         options, config_opts = set_stuff_up()
532         while True:
533                 try:
534                         logging.warning('Starting Vend Server')
535                         do_vend_server(options, config_opts)
536                         logging.error('Vend Server finished unexpectedly, restarting')
537                 except KeyboardInterrupt:
538                         logging.info("Killed by signal, cleaning up")
539                         clean_up_nicely(options, config_opts)
540                         logging.warning("Vend Server stopped")
541                         break
542                 except:
543                         (exc_type, exc_value, exc_traceback) = sys.exc_info()
544                         tb = format_tb(exc_traceback, 20)
545                         del exc_traceback
546                         
547                         logging.critical("Uh-oh, unhandled " + str(exc_type) + " exception")
548                         logging.critical("Message: " + str(exc_value))
549                         logging.critical("Traceback:")
550                         for event in tb:
551                                 for line in event.split('\n'):
552                                         logging.critical('    '+line)
553                         logging.critical("This message should be considered a bug in the Vend Server.")
554                         logging.critical("Please report this to someone who can fix it.")
555                         sleep(10)
556                         logging.warning("Trying again anyway (might not help, but hey...)")
557

UCC git Repository :: git.ucc.asn.au