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

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