Browse Source

Initial commit

Yann Weber 2 years ago
commit
c63732b5a3

+ 3
- 0
.gitignore View File

@@ -0,0 +1,3 @@
1
+.*.swp
2
+__pycache__
3
+config.ini

+ 88
- 0
README.md View File

@@ -0,0 +1,88 @@
1
+# PyWSClock
2
+
3
+Websocket clock server, handling timezones and alarms.
4
+
5
+## Dependencies
6
+
7
+* Python >= 3.9
8
+* python3-dateutil 2.8.1
9
+* python3-websockets 8.1
10
+
11
+**Debian-based dependencies installation**
12
+
13
+`apt install python3-dateutil python3-websockets`
14
+
15
+**Installing dependencies using pip**
16
+
17
+`pip3 install -r requirements.txt`
18
+
19
+## Running the server
20
+
21
+`python3 -m pyws_clock`
22
+
23
+Get some help using `python3 -m pyws_clock --help`
24
+
25
+## Deployment
26
+
27
+Systemd service will run the server using the `run_server.sh` script. This
28
+script runs `python3 -m pyws_clock -C config.ini` allowing to configure the
29
+daemon with a `config.ini` file.
30
+
31
+### Create a configuration file
32
+
33
+`cp config.ini.inc config.ini; edit config.ini`
34
+
35
+### Create a systemd service
36
+
37
+```sh
38
+cp pyws_clock.service.inc pyws_clock.service
39
+edit pyws_clock.service
40
+cp pyws_clock.service /etc/systemd/system/
41
+systemctl enable pyws_clock.service
42
+systemctl start pyws_clock.service
43
+systemctl status pyws_clock.service
44
+```
45
+
46
+### Testing the websocket server
47
+
48
+Run this command and send a dummy session id by pressing enter.
49
+`python3 -m websockets ws://127.0.0.1:8901`
50
+
51
+### Example deployment on Debian 11
52
+
53
+```sh
54
+adduser --system pyws_clock
55
+cp -R pyws_clock /home/pyws_clock/
56
+cd /home/pyws_clock/pyws_clock/
57
+
58
+cp config.ini.inc config.ini
59
+echo "session_directory=/var/run/pyws_clock/sessions/
60
+logfile=/var/log/pyws_clock/pyws_clock.log" >> config.ini
61
+
62
+cp pyws_clock.service.inc pyws_clock.service
63
+echo -e "User=pyws_clock
64
+Group=nogroup
65
+WorkingDirectory=/home/pyws_clock/pyws_clock/
66
+ExecStart=/home/pyws_clock/pyws_clock/run_server.sh" >> pyws_clock.service
67
+mv pyws_clock.service /etc/systemd/system/
68
+
69
+# log dir & rotation config
70
+mkdir /var/log/pyws_clock/
71
+chown pyws_clock: /var/log/pyws_clock
72
+cp logrotate_pyws_clock.inc /etc/logrotate.d/pyws_clock
73
+systemctl restart logrotate
74
+
75
+# session directory creation
76
+mkdir -p /var/run/pyws_clock/sessions/
77
+chown -R pyws_clock: /var/run/pyws_clock
78
+
79
+# Enabling & starting the systemd service
80
+systemctl enable pyws_clock.service
81
+systemctl start pyws_clock.service
82
+systemctl status pyws_clock.service
83
+```
84
+
85
+### Nginx configuration
86
+
87
+A sample configuration is available in `nginx_server.conf`.
88
+

+ 11
- 0
config.ini.inc View File

@@ -0,0 +1,11 @@
1
+[pyws_clock]
2
+# Listen on given TCP port
3
+port=8901
4
+# Bind to TCP address (0.0.0.0 for any)
5
+listen=127.0.0.1
6
+## Uncomment the line bellow to activate file sessions stored in given directory
7
+#session_directory=/var/run/pyws_clock/sessions/
8
+# Loglevels : 0 WARNING, 1 INFO, 2 DEBUG
9
+loglevel=1
10
+## Uncomment the line bellow to log in a file instead of stderr 
11
+#logfile=/var/log/pyws_clock/pyws_clock.log

+ 7
- 0
logrotate_pyws_clock.inc View File

@@ -0,0 +1,7 @@
1
+/var/log/pyws_clock/*.log {
2
+    daily
3
+    missingok
4
+    rotate 7
5
+    compress
6
+    copytruncate
7
+}

+ 24
- 0
nginx_server.conf View File

@@ -0,0 +1,24 @@
1
+server {
2
+        listen 80 default_server;
3
+        listen [::]:80 default_server;
4
+
5
+        root /var/www/clock/;
6
+
7
+        index index.html;
8
+
9
+        server_name _;
10
+
11
+        location / {
12
+                try_files $uri $uri/ =404;
13
+        }
14
+
15
+        location /wsclock/ {
16
+                proxy_pass http://127.0.0.1:8901;
17
+                proxy_http_version 1.1;
18
+                proxy_set_header X-Real-IP $remote_addr;
19
+                proxy_set_header Upgrade $http_upgrade;
20
+                proxy_set_header Connection "Upgrade";
21
+                proxy_set_header Host $Host;
22
+        }
23
+
24
+}

+ 15
- 0
pyws_clock.service.inc View File

@@ -0,0 +1,15 @@
1
+[Unit]
2
+Description=Websocket clock server
3
+After=network.target
4
+
5
+[Install]
6
+WantedBy=multi-user.target
7
+
8
+[Service]
9
+Type=simple
10
+## You have to set the config bellow
11
+#User=SOMEUSER
12
+#Group=SOMEGROUP
13
+#WorkingDirectory=/SOMEDIR/pyws_clock/
14
+#ExecStart=/SOMEDIR/pyws_clock/run_server.sh
15
+

+ 2
- 0
pyws_clock/__init__.py View File

@@ -0,0 +1,2 @@
1
+from . import command
2
+from . import commands

+ 140
- 0
pyws_clock/__main__.py View File

@@ -0,0 +1,140 @@
1
+#!/usr/bin/env python3
2
+
3
+import argparse
4
+import asyncio
5
+import logging
6
+from logging.handlers import WatchedFileHandler
7
+
8
+import websockets
9
+
10
+from .command import Command
11
+from .config import parse_config
12
+from .server import websocket_handler
13
+from .session import Session
14
+
15
+async def main(listen_addr:str, port:int):
16
+    """ Create the websocket and handle new connection with websocket_handler
17
+    """
18
+    logging.warning('Listening on %s:%d' % (listen_addr, port))
19
+    async with websockets.serve(websocket_handler, listen_addr, port):
20
+        await asyncio.Future()
21
+
22
+def cli():
23
+    """ Run CLI """
24
+    proto_desc = '''
25
+Before entering the main loop, the listening server waits for 1st message
26
+containing a session ID. If an empty/dummy/invalid session ID is sent by the
27
+client, the server replies with a new, valid, session ID and enter the main loop.
28
+If a valid session ID is sent, the server reply the same session ID and enter
29
+the main loop, formated using SESSION:<SESSION_ID>.
30
+
31
+
32
+Main loop :
33
+-----------
34
+
35
+The server send the current time (ISO 8601 format) every second.
36
+
37
+Command results :
38
+-----------------
39
+
40
+The client can interact with the server sending one of the command listed using
41
+the -L flag.
42
+
43
+When a client send a command, the next message from the server will be a command
44
+result. Results are string formatted using <STATUS>:<DETAILS>. Status can be
45
+one of 'ERR' or 'OK', details are optionnale for OK statuses.
46
+
47
+Alarms rings :
48
+--------------
49
+
50
+When an alarm is ringing, after sending time, the server send messages formatted
51
+using ALRM:<JSON_ARRAY> where JSON_ARRAY is a JSON array containing the name of
52
+the ringin alarms.
53
+
54
+Timezone informations :
55
+-----------------------
56
+
57
+Before the first time is sent and every time the timezone changes (inclucing
58
+summer/winter transitions), the clock. Timezones messages are formatted using
59
+TZN:<TZNAME>
60
+
61
+Messages types summary :
62
+------------------------
63
+Session ID
64
+    SESSION:<SESSION_ID>
65
+Clock tick
66
+    ISO8601_Datetime
67
+Timezone name
68
+    TZN:<TZNAME>
69
+Alarm rings
70
+    ALRM:<JSON_ARRAY>
71
+Command results
72
+    OK:<DETAILS>
73
+    ERR:<REASON>
74
+'''
75
+    parser = argparse.ArgumentParser(description='Run a simple clock websocket \
76
+server sending time every seconds', epilog=proto_desc,
77
+formatter_class=argparse.RawDescriptionHelpFormatter)
78
+
79
+    parser.add_argument('-C', '--config', default=None,
80
+                        help="Indicate a config.ini file")
81
+    parser.add_argument('-p', '--port', default=80, type=int,
82
+                        help='The TCP port to listen to')
83
+    parser.add_argument('-l', '--listen-address', default='localhost', type=str,
84
+                        help='Bind to this address (localhost by default)')
85
+    parser.add_argument('-L', '--list-commands', default=False,
86
+                        action='store_true',
87
+                        help='List available commands on the websocket and exit')
88
+    parser.add_argument('-H', '--help-command', metavar='CMD',
89
+                        choices=Command.list()+['ALL'],
90
+                        help='Print a command help and exit')
91
+    parser.add_argument('-v', '--verbose', action='count', default=0,
92
+                        help='Increase verbosity level')
93
+    parser.add_argument('-F', '--file-session', action="store_true", default=False,
94
+                        help="Use persistent file session instead of RAM session")
95
+    parser.add_argument('-S', '--session-dir', type=str, default=None,
96
+                        help="Store session in given directory (implies -F)")
97
+    parser.add_argument('--logfile', type=str,
98
+                        help="Log to given file instead of stderr")
99
+
100
+    args = parser.parse_args()
101
+
102
+    if args.config:
103
+        parse_config(args.config, args)
104
+
105
+    loglvls = [logging.WARN, logging.INFO, logging.DEBUG]
106
+    if args.verbose >= len(loglvls):
107
+        lvl = loglvls[-1]
108
+    else:
109
+        lvl = loglvls[args.verbose]
110
+    bconfig_kwargs = {'level':lvl,
111
+                      'format':'%(asctime)s %(levelname)s:%(message)s'}
112
+    if args.logfile:
113
+        bconfig_kwargs['handlers'] = [WatchedFileHandler(args.logfile)]
114
+    logging.basicConfig(**bconfig_kwargs)
115
+
116
+    if args.file_session or args.session_dir:
117
+        Session.set_file_handler(args.session_dir)
118
+
119
+    all_cmd = Command.all()
120
+    if args.list_commands:
121
+        for cmd in all_cmd.values():
122
+            usage = ':'.join(cmd.usage.split(':')[1:]).strip()
123
+            print('# %s\n\t%s' % (cmd.description, usage))
124
+        exit(0)
125
+
126
+    if args.help_command:
127
+        if args.help_command == 'ALL':
128
+            print(('-'*16).join([cmd.help
129
+                                 for cmd in all_cmd.values()]))
130
+            exit(0)
131
+        if args.help_command not in all_cmd:
132
+            logging.critical('Unknown command %r' % args.help_command)
133
+            exit(1)
134
+        print(all_cmd[args.help_command].help)
135
+        exit(0)
136
+
137
+    asyncio.run(main(args.listen_address, args.port))
138
+
139
+if __name__ == '__main__':
140
+    cli()

+ 114
- 0
pyws_clock/clock.py View File

@@ -0,0 +1,114 @@
1
+import datetime
2
+
3
+import dateutil
4
+import dateutil.tz
5
+
6
+from .errors import AlarmNameError
7
+from .session import Session
8
+from .clock_alarm import ClockAlarm
9
+
10
+
11
+class Clock(object):
12
+    """ Represents a timezone aware clock with programmable alarms """
13
+
14
+    def __init__(self, session_id=None, tzname:str='UTC'):
15
+        """ Instanciate a new clock
16
+            @param tzname : the name of the timezone (UTC for UTC)
17
+        """
18
+        self.__session = Session.get(session_id)
19
+        if 'tzname' in self.__session:
20
+            tzname = self.__session['tzname']
21
+        else:
22
+            self.__session['tzname'] = tzname
23
+
24
+        self.__tzname = tzname
25
+        self.__tz = None
26
+        if 'alarms' not in self.__session:
27
+            self.__session['alarms'] = dict()
28
+        self.alarms = self.__session['alarms'] # updated by reference
29
+
30
+        self.set_tz(self.__tzname)
31
+
32
+    @property
33
+    def tz(self) -> datetime.tzinfo:
34
+        """ Returns the clock tzinfo """
35
+        return self.__tz
36
+
37
+    @property
38
+    def tzname(self) -> str:
39
+        """ Returns the clock timezone name """
40
+        tzn1 = datetime.datetime.now(tz=self.__tz).tzname()
41
+        tzn2 = self.__tzname
42
+        return tzn1 if tzn1 == tzn2 else '%s(%s)' % (tzn2, tzn1)
43
+
44
+    @property
45
+    def utcoffset(self) -> int:
46
+        """ Returns utcoffset in seconds """
47
+        return datetime.datetime.now(tz=self.__tz).utcoffset()
48
+
49
+    @property
50
+    def session_id(self) -> str:
51
+        """ Returns the sesison id """
52
+        return self.__session.session_id
53
+
54
+    def now(self) -> datetime.datetime:
55
+        """ Return the clock localtime """
56
+        self.__session.tick()
57
+        now_dt = datetime.datetime.now(tz=self.__tz).replace(microsecond=0)
58
+        for alrm in self.alarms.values():
59
+            alrm.is_ringing()
60
+        return now_dt
61
+
62
+    def set_tz(self, tzname:str) -> bool:
63
+        """ Set the clock timezone from dateutil.tz.gettz
64
+            @param tzname : the name of the timezone (UTC for UTC)
65
+            @return True if timezone found else False
66
+        """
67
+        newtz = dateutil.tz.gettz(tzname)
68
+        if newtz is None:
69
+            return False
70
+        self.__tzname = tzname
71
+        self.__tz = dateutil.tz.gettz(tzname)
72
+        self.__session['tzname'] = self.__tzname
73
+        return True
74
+
75
+    def alarm_add(self, name:str) -> None:
76
+        """ Add an alarm to the clock
77
+            @param name : alarm name,must be unique
78
+            @throws AlarmNameError if name is not unique or empty
79
+        """
80
+        if name in self.alarms:
81
+            raise AlarmNameError('There is allready an alarm %r' % name)
82
+        self.alarms[name] = ClockAlarm()
83
+
84
+    def alarm_del(self, name:str) -> None:
85
+        """ Delete an alarm from the clock
86
+            @param name : the alarm name
87
+            @throws AlarmNameError if alarm not found
88
+        """
89
+        if name not in self.alarms:
90
+            raise AlarmNameError('There is no alarm %r' % name)
91
+        del self.alarms[name]
92
+
93
+    def alarm_get(self, name:str) -> None:
94
+        """ Return an alarm given its name
95
+            @param name : the alarm name
96
+            @throws AlarmNameError if alarm not found
97
+        """
98
+        if name not in self.alarms:
99
+            raise AlarmNameError('There is no alarm %r' % name)
100
+        return self.alarms[name]
101
+
102
+    def alarm_rings(self) -> list:
103
+        """ Return ringing alarm's names
104
+            @return a list of strings
105
+        """
106
+        return [name for name, alrm in self.alarms.items()
107
+                if alrm.is_ringing()]
108
+
109
+    def alarm_snooze(self, minutes:float=15) -> None:
110
+        """ Snooze all ringing alarms
111
+            @param minutes : snooze for this amount of minutes
112
+        """
113
+        for name in self.alarm_rings():
114
+            self.alarms[name].snooze(minutes)

+ 132
- 0
pyws_clock/clock_alarm.py View File

@@ -0,0 +1,132 @@
1
+import datetime
2
+import json
3
+
4
+import dateutil
5
+
6
+utctz = datetime.timezone.utc
7
+
8
+class ClockAlarm(object):
9
+    """ Represent an alarm """
10
+
11
+    def __init__(self, _utcnow=lambda: datetime.datetime.now(tz=utctz)):
12
+        """ Instanciate a new alarm
13
+            @param _utcnow : a function returning current UTC datetime
14
+        """
15
+        self.__utcnow = _utcnow
16
+        self._time = datetime.time(0, 0, 0, tzinfo=datetime.timezone.utc)
17
+        self._ringing = False
18
+        self._next_ring = None
19
+        self._snooze = False
20
+
21
+    @property
22
+    def is_on(self) -> bool:
23
+        """ @return True if the alarm is on (will ring) else False """
24
+        return self._next_ring is not None
25
+
26
+    @property
27
+    def json(self) -> str:
28
+        """ @return a json representation of the instance """
29
+        ret = {'time':self._time.isoformat(), 'ringing':self._ringing,
30
+               'snooze':self._snooze}
31
+        if self._next_ring:
32
+            ret['next_ring']  = self._next_ring.isoformat()
33
+        return json.dumps(ret)
34
+
35
+    def _from_json(self, data:str):
36
+        """ Load internal state from json (as given by ClockAlarm.json property)
37
+        """
38
+        data = json.loads(data)
39
+        self._time = datetime.time.fromisoformat(data['time'])
40
+        self._ringing = data['ringing']
41
+        self._next_ring = None if 'next_ring' not in data else data['next_ring']
42
+        if self._next_ring:
43
+            self._next_ring = datetime.datetime.fromisoformat(self._next_ring)
44
+        self._snooze = data['snooze']
45
+        return self
46
+
47
+    def get_time(self, tz:datetime.tzinfo=utctz):
48
+        """ @return The tz aware alarm ringing datetime.time """
49
+        return self._get_next_ring().astimezone(tz=tz).timetz()
50
+
51
+    def set_time(self, ringtime:datetime.time, tz:datetime.tzinfo=utctz) -> None:
52
+        """ Set the ringtime
53
+            @param ringtime : non-tz aware datetime.time
54
+            @param tz : the given ringtime timezone
55
+        """
56
+        ringtime_dt = self.get_next_gte_datetime(ringtime, tz)
57
+        self._time = ringtime_dt.astimezone(tz=utctz).timetz()
58
+        if self.is_on:
59
+            self.__set_next_ring()
60
+
61
+    def set_state(self, on:bool) -> None:
62
+        """ Set the ON/OFF state
63
+            @param on : if True the alarm will ring at configured time
64
+        """
65
+        if on:
66
+            self.__set_next_ring()
67
+        else:
68
+            self._next_ring = None
69
+            self._ringing = False
70
+            self._snooze = False
71
+
72
+    def snooze(self, minutes:int=15) -> bool:
73
+        """ Stop ringing alarm for specified amount of minutes
74
+            @param minutes : amount of time after wich the alarm should ring again
75
+            @return False if alarm was not ringing else True
76
+        """
77
+        if not self._ringing:
78
+            return False
79
+        self._next_ring = self.__utcnow() + datetime.timedelta(minutes=minutes)
80
+        self._ringing = False
81
+        self._snooze = True
82
+        return True
83
+
84
+    def is_ringing(self) -> bool:
85
+        """ Check if an alarm should be ringing
86
+            @return True if the alarm is ringing else False
87
+        """
88
+        if not self.is_on:
89
+            return False
90
+        if self._ringing:
91
+            return True
92
+        utcnow = self.__utcnow()
93
+        if self._next_ring < utcnow:
94
+            self._ringing = True
95
+            self._snooze = False
96
+            self.__set_next_ring()
97
+
98
+        return self._ringing
99
+
100
+    def is_snooze(self) -> bool:
101
+        """ @return True if an alarm is snoozing (ringing again in a specified
102
+                amount of time after a ClockAlarm.snooze() call)
103
+        """
104
+        return self._snooze
105
+
106
+    def _get_next_ring(self) -> datetime.datetime:
107
+        """ @return The next ringing time in utc """
108
+        return self.get_next_gte_datetime(self._time)
109
+
110
+    def __set_next_ring(self) -> None:
111
+        """ Set the next ring time of the alarm """
112
+        self._next_ring=self._get_next_ring()
113
+
114
+
115
+    @staticmethod
116
+    def get_next_gte_datetime(wanted:datetime.time,
117
+            tz:datetime.tzinfo=utctz) -> datetime.datetime:
118
+        """ Return the next datetime with a specific time
119
+            @param wanted : the wanted time, timezone info will be dropped
120
+            @param tz : the wanted datetime timezone
121
+            @return The next datetime > now with wanted time
122
+        """
123
+        tznow_orig = datetime.datetime.now(tz=tz).replace(microsecond=0)
124
+        tznow = tznow_orig.replace(hour=tznow_orig.hour)
125
+        while True:
126
+            wanted_dt = tznow.replace(hour=wanted.hour,
127
+                                      minute=wanted.minute,
128
+                                      second=wanted.second)
129
+            if wanted_dt > tznow_orig:
130
+                break
131
+            tznow += datetime.timedelta(days=1)
132
+        return wanted_dt

+ 112
- 0
pyws_clock/command.py View File

@@ -0,0 +1,112 @@
1
+import shlex
2
+from argparse import Namespace
3
+from typing import Callable
4
+
5
+from .clock import Clock
6
+from .errors import DuplicatedCommandError, ArgumentParserError
7
+from .parser import CommandParser
8
+
9
+class Command(object):
10
+
11
+    _commands = dict()
12
+
13
+    def __init__(self, name:str, run_fun:Callable[[Clock, Namespace], str],
14
+                 **argument_parser_kwargs:dict):
15
+        """ Register a new command
16
+            @param name : The command
17
+            @param run_fun : The callable handling the command (taking two
18
+                             arguments, the Clock instance and parsed args)
19
+            @throws DuplicatedCommandError on duplicated name
20
+        """
21
+        if name in self.__class__._commands:
22
+            msg = 'A command named %r allready exists' % name
23
+            raise DuplicatedCommandError(msg)
24
+
25
+        self.__name = name
26
+        self.__parser = CommandParser(prog=name, **argument_parser_kwargs)
27
+        self.__run = run_fun
28
+
29
+        self.__class__._commands[self.__name] = self
30
+
31
+    @property
32
+    def name(self) -> str:
33
+        """ The command name """
34
+        return self.__name
35
+
36
+    @property
37
+    def description(self) -> str:
38
+        """ The command description """
39
+        return self.__parser.description
40
+
41
+    @property
42
+    def usage(self) -> str:
43
+        """ The command usage """
44
+        return self.__parser.format_usage().strip()
45
+
46
+    @property
47
+    def help(self) -> str:
48
+        """ The command help text """
49
+        return self.__parser.format_help()
50
+
51
+    @staticmethod
52
+    def fmt_ok(message:str='') -> str:
53
+        """ Format a success command result message """
54
+        return 'OK:%s' % message
55
+
56
+    @staticmethod
57
+    def fmt_err(reason:str) -> str:
58
+        """ Format an error command result message """
59
+        return 'ERR:%s' % reason
60
+
61
+    @classmethod
62
+    def list(cls) -> list[str]:
63
+        """ Return the list of all command names """
64
+        return list(cls._commands.keys())
65
+
66
+    @classmethod
67
+    def all(cls) -> dict:
68
+        """ Return a dict associating command name and Command instance """
69
+        return {k:v for k,v in cls._commands.items()}
70
+
71
+    @classmethod
72
+    def run_command(cls, clock:Clock, command:str) -> str:
73
+        """ Run a command on the given Clock instance
74
+            @param clock : The Clock instance
75
+            @param command : A string with the command name and its arguments
76
+        """
77
+        try:
78
+            argv = shlex.split(command)
79
+        except ValueError as expt:
80
+            return cls.fmt_err(expt)
81
+
82
+        if len(argv) == 0:
83
+            return cls.fmt_err('Empty command')
84
+
85
+        if argv[0] not in cls._commands:
86
+            return cls.fmt_err('Unknown command %r' % argv[0])
87
+
88
+        return cls._commands[argv[0]]._run(clock, argv)
89
+
90
+    def add_argument(self, name:str, *args, **kwargs):
91
+        """ Add an argument to the CommandParser instance.
92
+
93
+            Same signature than argparse.ArgumentParser.add_argument()
94
+        """
95
+        return self.__parser.add_argument(name, *args, **kwargs)
96
+
97
+    def add_subparsers(self, *args,**kwargs):
98
+        """ Add a subparser to the command's ArgumentParser """
99
+        return self.__parser.add_subparsers(*args, **kwargs)
100
+
101
+    def _run(self, clock:Clock, argv:list) -> str:
102
+        """ Run the command on the given Clock instance
103
+            @param clock : The Clock instance
104
+            @param message : The command and its arguments
105
+            @return Command result as text (formatted by one of the
106
+                    Command.fmt_ok() or Command.fmt_err() commands)
107
+        """
108
+        try:
109
+            args = self.__parser.parse_args(argv[1:])
110
+        except ArgumentParserError as expt:
111
+            return self.fmt_err(expt)
112
+        return self.__run(clock, args)

+ 109
- 0
pyws_clock/commands.py View File

@@ -0,0 +1,109 @@
1
+import argparse
2
+import datetime
3
+import json
4
+
5
+from .errors import AlarmNameError
6
+from .clock import Clock
7
+from .command import Command
8
+
9
+
10
+def tzset_cmd(clk:Clock, args:argparse.Namespace) -> str:
11
+    """ Function called when "tzset" command received
12
+        @param clk : the Clock instance
13
+        @param args : the arguments parsed by argparse
14
+        @return a well formatted response ready to be sent through the websocket
15
+    """
16
+    if clk.set_tz(args.tzname):
17
+        return Command.fmt_ok()
18
+    return Command.fmt_err('Invalid timezone %r' % args.tzname)
19
+
20
+
21
+# tzset command declaration
22
+tzset = Command('tzset', tzset_cmd, description='Set the clock timezone')
23
+tzset.add_argument('tzname', help='The new timezone (UTC for utc timezone)')
24
+
25
+
26
+def alarm_cmd(clk:Clock, args:argparse.Namespace) -> str:
27
+    """ Function called when "alarm *" commmand received
28
+        @param clk : the Clock instance
29
+        @param args : the arguments parsed by argparse
30
+        @return a well formatted response ready to be sent through the websocket
31
+        @throws NotImplementedError if an alarm subcommand was added but not
32
+                implemented
33
+    """
34
+    cmd = args.subcmd
35
+    ret_details = ''
36
+    if hasattr(args, 'name') and args.name is not None and cmd != 'add':
37
+        # try to fetch alarm if needed
38
+        try:
39
+            alrm = None if args.name is None else clk.alarm_get(args.name)
40
+        except AlarmNameError as expt:
41
+            return Command.fmt_err(expt)
42
+
43
+    try:
44
+        if cmd == 'add':
45
+            clk.alarm_add(args.name)
46
+        elif cmd == 'del':
47
+            clk.alarm_del(args.name)
48
+        elif cmd == 'list':
49
+            if args.all:
50
+                ret = list(clk.alarms.keys())
51
+            else:
52
+                ret = clk.alarm_rings()
53
+
54
+            alarms = [(name, clk.alarms[name]) for name in ret]
55
+
56
+            ret = {name:{'time':alrm.get_time(clk.tz).strftime('%H:%M:%S'),
57
+                         'on': alrm.is_on,
58
+                         'ringing': alrm.is_ringing(),
59
+                         'snooze': alrm.is_snooze()}
60
+                   for name, alrm in alarms}
61
+            ret_details = json.dumps(ret)
62
+        elif cmd == 'snooze':
63
+            alrm.snooze(args.minutes)
64
+        elif cmd == 'set':
65
+            ringtime = datetime.time(args.hour, args.minute, args.second)
66
+            alrm.set_time(ringtime, clk.tz)
67
+        elif cmd == 'on':
68
+            alrm.set_state(True)
69
+        elif cmd == 'off':
70
+            alrm.set_state(False)
71
+        else:
72
+            raise NotImplementedError('Command %r not implemented' % cmd)
73
+    except AlarmNameError as expt:
74
+        return Command.fmt_err(expt)
75
+
76
+    return Command.fmt_ok(ret_details)
77
+
78
+
79
+# parser template for alarm name
80
+_name_parser =  argparse.ArgumentParser(add_help=False)
81
+_name_parser.add_argument('name', type=str, help='The alarm name')
82
+
83
+# alarm command & sub-commands declarations
84
+alrm = Command('alarm', alarm_cmd, description='Alarm handling')
85
+
86
+subalrm = alrm.add_subparsers(required=True, dest='subcmd')
87
+
88
+subalrm.add_parser('add', help='Add an alarm', parents=[_name_parser])
89
+subalrm.add_parser('del', help='Delete an alarm', parents=[_name_parser])
90
+subalrm.add_parser('on', help='Set an alarm ON', parents=[_name_parser])
91
+subalrm.add_parser('off', help='Set an alarm OFF', parents=[_name_parser])
92
+
93
+alrm_snooze = subalrm.add_parser('snooze', help='Snooze ringing alarms',
94
+                                 parents=[_name_parser])
95
+alrm_snooze.add_argument('minutes', nargs='?', default=15, type=float,
96
+                         help='Ring again after this amount of minutes')
97
+
98
+alrm_set = subalrm.add_parser('set', help='Set an alarm ringing time',
99
+                              parents=[_name_parser])
100
+alrm_set.add_argument('hour', metavar='HH', type=int, choices=range(24))
101
+alrm_set.add_argument('minute', metavar='MM', type=int, choices=range(60),
102
+                      default=0, nargs='?')
103
+alrm_set.add_argument('second', metavar='SS', type=int, choices=range(60),
104
+                      default=0, nargs='?')
105
+
106
+alrm_list = subalrm.add_parser('list', help='List ringing alarms returning a \
107
+JSON object')
108
+alrm_list.add_argument('-a', '--all', action='store_true', default=False,
109
+                       help='List all alarms')

+ 43
- 0
pyws_clock/config.py View File

@@ -0,0 +1,43 @@
1
+import argparse
2
+import configparser
3
+
4
+from .errors import ConfigError
5
+
6
+# Config file section
7
+CONFIG_SECTION = 'pyws_clock'
8
+# Maps config key name to argparse arg name and type
9
+CONFIG_ARGS = {
10
+    'port': ('port', int),
11
+    'listen': ('listen_address', str),
12
+    'session_directory': ('session_dir', str),
13
+    'loglevel': ('verbose', int),
14
+    'logfile': ('logfile', str),
15
+}
16
+
17
+def parse_config(filename:str, args:argparse.Namespace):
18
+    """ Updates args parsed by argparse using a configuration file
19
+        @param filename : configuration file name
20
+        @param args : Namespace returned by ArgumentParser.parse_args()
21
+        @return updated Namespace
22
+        @note args is modified by reference
23
+    """
24
+
25
+    config = configparser.ConfigParser()
26
+    config.read(filename)
27
+
28
+    if CONFIG_SECTION not in config:
29
+        err = 'There is no section named %r in %r' % (CONFIG_SECTION, filename)
30
+        raise ConfigError(err)
31
+
32
+    err_key = []
33
+    for key in config[CONFIG_SECTION]:
34
+        if key not in CONFIG_ARGS:
35
+            err_key.append(key)
36
+            continue
37
+        argname, argtype = CONFIG_ARGS[key]
38
+        setattr(args, argname, argtype(config[CONFIG_SECTION][key]))
39
+    if len(err_key) > 0:
40
+        err = 'Invalid configuration key in %r : %r' % (filename,
41
+                                                        ','.join(err_key))
42
+        raise ConfigError(err)
43
+    return args

+ 15
- 0
pyws_clock/errors.py View File

@@ -0,0 +1,15 @@
1
+
2
+class ConfigError(RuntimeError):
3
+    pass
4
+
5
+class CommandError(ValueError):
6
+    pass
7
+
8
+class ArgumentParserError(CommandError):
9
+    pass
10
+
11
+class DuplicatedCommandError(NameError):
12
+    pass
13
+
14
+class AlarmNameError(ArgumentParserError):
15
+    pass

+ 24
- 0
pyws_clock/parser.py View File

@@ -0,0 +1,24 @@
1
+import argparse
2
+
3
+from .errors import ArgumentParserError
4
+
5
+class CommandParser(argparse.ArgumentParser):
6
+    """ ArgumentParser raising exception instead of exiting and printing help
7
+    """
8
+
9
+    def error(self, message):
10
+        """ Raise an exception instead of printing to stderr and exiting """
11
+        raise ArgumentParserError(message)
12
+
13
+    def exit(self, *args):
14
+        """ Do not allow a parser to exit the programm """
15
+        pass
16
+
17
+    def print_help(self, *args):
18
+        """ If print_help() is called raise an exception """
19
+        raise ArgumentParserError(self.format_usage())
20
+
21
+    def add_subparsers(self, *args, **kwargs):
22
+        """ Create subparsers indicated the good parser class """
23
+        return super().add_subparsers(*args, **kwargs,
24
+                                      parser_class=self.__class__)

+ 67
- 0
pyws_clock/server.py View File

@@ -0,0 +1,67 @@
1
+import asyncio
2
+import logging
3
+import time
4
+
5
+import websockets
6
+from websockets.server import WebSocketServerProtocol
7
+
8
+from .clock import Clock
9
+from .command import Command
10
+
11
+logger = logging.getLogger(__name__)
12
+
13
+
14
+async def websocket_handler(sock_cli:WebSocketServerProtocol,
15
+                            path:str=None) -> None:
16
+    """ Called for each connecting client
17
+        @param sock_cli : Client socket
18
+        @param path : (depends on websockets version?) websocket path
19
+    """
20
+    cli_addr = sock_cli.remote_address[0]
21
+    if 'X-Real-IP' in sock_cli.request_headers:
22
+        ## TODO add a configuration to indicate expected IP from
23
+        #  reverse proxy to check if the connection comes from it.
24
+        cli_addr = sock_cli.request_headers['X-Real-IP']
25
+    r_addr = '%s(%d)' % (cli_addr,
26
+                         sock_cli.remote_address[1])
27
+    logger.info('%s connected' % r_addr)
28
+
29
+    # Clock restore/creation given a session_id
30
+    try:
31
+        session_id = await sock_cli.recv()
32
+
33
+        clock = Clock(session_id=session_id) # per socket Clock  instance
34
+
35
+        await sock_cli.send('SESSION:'+clock.session_id)
36
+
37
+        last_sent = 0 # last time we sent the hour to the client (timestamp)
38
+        last_tz = None
39
+
40
+
41
+        while sock_cli.open:
42
+            # Send date/time + alarms if not sent for more than 1s
43
+            if time.time() - last_sent >= 1:
44
+                if last_tz != clock.tzname:
45
+                    last_tz = clock.tzname
46
+                    await sock_cli.send('TZN:%s' % last_tz)
47
+                await sock_cli.send(clock.now().isoformat())
48
+                last_sent = time.time()
49
+                for alrm in clock.alarm_rings():
50
+                    await sock_cli.send('ALRM:%s' % alrm)
51
+            # Attempt to read message from client
52
+            tleft = time.time() - last_sent + 1
53
+            tleft = .01 if tleft < .01 else tleft
54
+            try:
55
+                message = await asyncio.wait_for(sock_cli.recv(), tleft)
56
+                logger.debug('%s << %r' % (r_addr, message))
57
+                ret = Command.run_command(clock, message)
58
+                logger.debug('%s >> %r' % (r_addr, ret))
59
+                await sock_cli.send(ret)
60
+            except asyncio.TimeoutError:
61
+                pass # no message in socket
62
+    except websockets.exceptions.ConnectionClosedOK:
63
+        logger.info('%s closed connection' %  r_addr)
64
+    try:
65
+        del clock
66
+    except UnboundLocalError:
67
+        pass

+ 273
- 0
pyws_clock/session.py View File

@@ -0,0 +1,273 @@
1
+import logging
2
+import json
3
+import os
4
+import os.path
5
+import pathlib
6
+import secrets
7
+import tempfile
8
+import time
9
+import random
10
+
11
+from .clock_alarm import ClockAlarm
12
+
13
+SESSION_EXPIRE = 3600*24
14
+MAX_SESSIONS = 0x1000
15
+
16
+TOKEN_SZ = 16
17
+
18
+logger = logging.getLogger(__name__)
19
+
20
+class SessionHandler(object):
21
+    """ Abstract class for session handler definition """
22
+
23
+    def __init__(self, session_id:str):
24
+        """ Instanciate a new session with a valid session_id """
25
+        self.__content = dict()
26
+        self.__session_id = self.validate_session_id(session_id)
27
+        if self.__class__.__name__ == 'SessionHandler':
28
+            raise NotImplementedError()
29
+
30
+    def tick(self):
31
+        """ Change expiration time"""
32
+        raise NotImplementedError()
33
+
34
+    def save(self):
35
+        """ Save the session """
36
+        return None
37
+
38
+    @property
39
+    def session_id(self) -> str:
40
+        """ Return the session's ID """
41
+        return self.__session_id
42
+
43
+    @property
44
+    def content(self) -> dict:
45
+        """ Return the session's dict """
46
+        return self.__content # ! no protection against write by reference !
47
+
48
+    def __getitem__(self, key:str):
49
+        """ Allows [] access """
50
+        return self.__content[key]
51
+
52
+    def __setitem__(self, key:str, value:any):
53
+        """ Allows [] access """
54
+        self.__content[key] = value
55
+
56
+    def __delitem__(self, key:str):
57
+        """ Delete given item from session """
58
+        del self.__content[key]
59
+
60
+    def __contains__(self, key:str):
61
+        """ Return true if key is present is session's data """
62
+        return key in self.__content
63
+
64
+    def __iter__(self):
65
+        """ Iterate over session data keys """
66
+        return iter(self.__content.keys())
67
+
68
+    @classmethod
69
+    def _purge(cls):
70
+        """ Purge expired sessions """
71
+        raise NotImplementedError()
72
+
73
+    @classmethod
74
+    def validate_session_id(cls, session_id:str) -> str:
75
+        """ If given session_id is valid return it, else return a new valid
76
+            session id """
77
+        if not len(session_id) == TOKEN_SZ*2:
78
+            return cls.new_id()
79
+        try:
80
+            int(session_id, 16)
81
+        except Exception:
82
+            return cls.new_id()
83
+        return session_id
84
+
85
+    @classmethod
86
+    def new_id(cls) -> str:
87
+        """ Return a new session id """
88
+        return secrets.token_hex(TOKEN_SZ)
89
+
90
+
91
+class RamSession(SessionHandler):
92
+    """ Session handler storing sessions in memory (all data are lost
93
+        when the server's process is stopped """
94
+
95
+    _sessions = dict()
96
+    _expires = dict()
97
+
98
+    def __init__(self, session_id:str):
99
+        """ Create/Restor a session """
100
+        session_id = self.validate_session_id(session_id)
101
+        if session_id in self.__class__._sessions:
102
+            logger.info('Restoring session %r' % session_id)
103
+            content = self.__class__._sessions[session_id]
104
+        else:
105
+            session_id = self.new_id()
106
+            logger.info('New session %r' % session_id)
107
+            content = dict()
108
+        super().__init__(session_id)
109
+
110
+        for key,val in content.items():
111
+            self[key] = val
112
+
113
+        self.tick()
114
+        self._purge() # purge after ticking, allowing late session restore
115
+
116
+    def tick(self):
117
+        """ Update session's expiration time """
118
+        self._expires[self.session_id] = time.time() + SESSION_EXPIRE
119
+
120
+    def __del__(self):
121
+        """ Save session data & update expiration time """
122
+        self._sessions[self.session_id] = self.content
123
+        self.tick()
124
+
125
+    @classmethod
126
+    def _purge(cls):
127
+        """ Delete expired sessions """
128
+        min_expire = None
129
+        min_key = None
130
+        for key in cls._sessions:
131
+            # Delete expired sessions
132
+            if key not in cls._expires or cls._expires[key] <= time.time():
133
+                del cls._sessions[key]
134
+                if key in cls._expires:
135
+                    del cls._expires[key]
136
+            # Looks for older session in case we reached the MAX_SESSIONS count
137
+            if min_expire is None or cls._expires[key] < min_expire:
138
+                min_expire = cls._expires[key]
139
+                min_key = key
140
+
141
+        if len(cls._sessions) >= MAX_SESSIONS - 1:
142
+            if min_key is None:
143
+                random.choice(list(cls._sessions.keys()))
144
+            del cls._sessions[min_key]
145
+            if min_key in self._expires:
146
+                del cls._expires[min_key]
147
+
148
+
149
+class FileSessionJsonEncoder(json.JSONEncoder):
150
+    """ Json encoder able to encode ClockAlarm instances """
151
+
152
+    def default(self, obj):
153
+        """ Default encoder """
154
+        if isinstance(obj, ClockAlarm):
155
+            return obj.json
156
+        return json.JSONEncoder.default(self, obj)
157
+
158
+class FileSession(SessionHandler):
159
+    """ Session handler storing sessions into files """
160
+
161
+    _session_dir = os.path.join(tempfile.gettempdir(), 'pyws_clock_sessions')
162
+
163
+    def __init__(self, session_id:str):
164
+        """ Create/Restore a session """
165
+        if not os.path.isdir(self.__class__._session_dir):
166
+            os.mkdir(self.__class__._session_dir)
167
+        self._purge()
168
+
169
+        session_id = self.validate_session_id(session_id)
170
+
171
+        self.__path = pathlib.Path(self.getpath(session_id))
172
+        if not os.path.isfile(self.path):
173
+            logger.info('New session %s' % self.path)
174
+            content = dict()
175
+            session_id = self.new_id()
176
+            self.__path = pathlib.Path(self.getpath(session_id))
177
+            self.path.touch()
178
+        else:
179
+            logger.info('Restoring session %s' % self.path)
180
+            with self.path.open('r') as fp:
181
+                try:
182
+                    content = json.load(fp)
183
+                    content['alarms'] = {k:ClockAlarm()._from_json(v)
184
+                                         for k,v in content['alarms'].items()}
185
+                    if not isinstance(content, dict):
186
+                        raise TypeError('Corrupted session file %s' % self.path)
187
+                except (TypeError,json.decoder.JSONDecodeError) as expt:
188
+                    logger.error(expt)
189
+                    content = dict()
190
+        super().__init__(session_id)
191
+
192
+        for k,v in content.items():
193
+            self[k] = v
194
+
195
+        self.save()
196
+
197
+
198
+    @property
199
+    def path(self) -> pathlib.Path:
200
+        """ Return session's file path """
201
+        return self.__path
202
+
203
+    def save(self):
204
+        """ Save session to disk """
205
+        with self.path.open('w+') as fp:
206
+            json.dump(self.content, fp, cls=FileSessionJsonEncoder)
207
+
208
+    def __del__(self):
209
+        """ Save session to disk """
210
+        logger.debug('Saving session in %s' % self.__path)
211
+        self.save()
212
+
213
+    def tick(self):
214
+        """ Update file session modification time """
215
+        self.path.touch()
216
+
217
+    @classmethod
218
+    def _purge(cls):
219
+        """ Delete expired sessions """
220
+        min_expire = None
221
+        min_path = None
222
+        session_count = 0
223
+        for fname in os.listdir(cls._session_dir):
224
+            session_count += 1
225
+            path = os.path.join(cls._session_dir, fname)
226
+            mtime = os.path.getmtime(path)
227
+            if mtime + SESSION_EXPIRE <= time.time():
228
+                os.remove(path)
229
+            # Looks for older session in case we reached the MAX_SESSIONS count
230
+            if min_expire is None or mtime < min_expire:
231
+                min_expire = mtime
232
+                min_path = path
233
+
234
+        if session_count >= MAX_SESSIONS - 1:
235
+            if min_path is not None:
236
+                os.remove(min_path)
237
+
238
+    @classmethod
239
+    def getpath(cls, name:str):
240
+        """ Given a session id return a file path """
241
+        return os.path.join(cls._session_dir, name)
242
+
243
+    @classmethod
244
+    def new_id(cls):
245
+        """ Create a new uniq session id """
246
+        cls._purge()
247
+        for _ in range(1024):
248
+            res = super().new_id()
249
+            if not os.path.isfile(cls.getpath(res)):
250
+                return res
251
+        err = 'Unable to create new session is %r' % cls._session_dir
252
+        raise RuntimeError(err)
253
+
254
+
255
+class Session(object):
256
+    """ Wrapper for FileSession and RamSession handlers """
257
+
258
+    _handler = RamSession
259
+
260
+    def __init__(self):
261
+        raise NotImplementedError()
262
+
263
+    @classmethod
264
+    def get(cls, session_id:str):
265
+        """ Create/Restore a session given its ID """
266
+        return cls._handler(session_id)
267
+
268
+    @classmethod
269
+    def set_file_handler(cls, tempdir:str=None):
270
+        """ Configure the session handle to FileSession """
271
+        if tempdir is not None:
272
+            FileSession._session_dir = tempdir
273
+        cls._handler = FileSession

+ 2
- 0
requirements.txt View File

@@ -0,0 +1,2 @@
1
+python-dateutil==2.8.1
2
+websockets==8.1

+ 3
- 0
run_server.sh View File

@@ -0,0 +1,3 @@
1
+#!/bin/sh
2
+
3
+/usr/bin/env python3 -m pyws_clock -C config.ini

Loading…
Cancel
Save