aaadafddf5eade33051854425a8899f4bb4693f2
[SaveMySugar/python3-savemysugar.git] / src / savemysugar / CallDistanceTransceiver.py
1 #!/usr/bin/env python3
2 #
3 # CallDistanceTransceiver - send and receive Morse using phone calls distance
4 #
5 # Copyright (C) 2015  Antonio Ospite <ao2@ao2.it>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20
21 # This hack allows MorseTranslator to be imported also when
22 # __name__ == "__main__"
23 try:
24     from .MorseTranslator import MorseTranslator
25 except SystemError:
26     from MorseTranslator import MorseTranslator
27
28 import logging
29 import time
30
31
32 class SymbolTime(object):
33     """
34     In theory the symbol distance (the distance to discriminate symbols) can
35     be arbitrary, and it's only bounded below by the stability of the period
36     time, but in practice it can be necessary to wait a predefined minimum
37     amount of time between periods because of technological limits of the
38     transmitting devices, we call this time the "minimum inter-symbol
39     distance".
40     """
41
42     # pylint: disable=too-few-public-methods
43     def __init__(self, period_min, period_max, multiplier,
44                  min_inter_symbol_distance=0.0):
45         assert multiplier >= 0
46         if (period_min == period_max) and (min_inter_symbol_distance == 0):
47             raise ValueError("If (period_min == period_max) a non-zero",
48                              "inter-symbol distance MUST be specified")
49
50         symbol_distance = 2 * (period_max - period_min)
51         if symbol_distance == 0:
52             symbol_distance = min_inter_symbol_distance
53
54         # The time the transmitter has to wait to disambiguate between this
55         # symbol and a different one.
56         self.dist = min_inter_symbol_distance + symbol_distance * multiplier
57
58         # The minimum time which represents the symbol at the receiving end.
59         self.min = min_inter_symbol_distance + period_min + \
60             symbol_distance * multiplier
61
62         # The maximum time which represents the symbol at the receiving end
63         self.max = min_inter_symbol_distance + period_min + \
64             symbol_distance * (multiplier + 1)
65
66
67 class MorseDistanceModulator(object):
68     def __init__(self, period_min, period_max, pulse_min, pulse_max,
69                  inter_symbol_distance):
70         self.set_parameters(period_min, period_max,
71                             pulse_min, pulse_max,
72                             inter_symbol_distance)
73
74     def set_parameters(self, period_min, period_max, pulse_min, pulse_max,
75                        inter_symbol_distance):
76         self.period_min = period_min
77         self.period_max = period_max
78         self.pulse_min = pulse_min
79         self.pulse_max = pulse_max
80         self.inter_symbol_distance = inter_symbol_distance
81
82         self.dot_time = SymbolTime(period_min,
83                                    period_max, 0,
84                                    inter_symbol_distance)
85         self.dash_time = SymbolTime(period_min,
86                                     period_max, 1,
87                                     inter_symbol_distance)
88         self.signalspace_time = SymbolTime(period_min,
89                                            period_max, 2,
90                                            inter_symbol_distance)
91         self.wordspace_time = SymbolTime(period_min,
92                                          period_max, 3,
93                                          inter_symbol_distance)
94         self.eom_time = SymbolTime(period_min,
95                                    period_max, 4,
96                                    inter_symbol_distance)
97
98     def symbol_to_distance(self, symbol):
99         if symbol == ".":
100             return self.dot_time.dist
101         elif symbol == "-":
102             return self.dash_time.dist
103         elif symbol == " ":
104             return self.signalspace_time.dist
105         elif symbol == "/" or symbol == " / ":
106             return self.wordspace_time.dist
107         elif symbol == "EOM":
108             return self.eom_time.dist
109
110         raise ValueError("Unexpected symbol %s" % symbol)
111
112     def is_same_period(self, distance):
113         return distance > self.pulse_min and distance <= self.pulse_max
114
115     def distance_to_symbol(self, distance):
116         if distance > self.dot_time.min and \
117            distance <= self.dot_time.max:
118             return "."
119
120         if distance > self.dash_time.min and \
121            distance <= self.dash_time.max:
122             return "-"
123
124         if distance > self.signalspace_time.min and \
125            distance <= self.signalspace_time.max:
126             return " "
127
128         if distance > self.wordspace_time.min and \
129            distance <= self.wordspace_time.max:
130             return "/"
131
132         if distance > self.eom_time.min:
133             return "EOM"
134
135         raise ValueError("Unexpected distance %.2f" % distance)
136
137     def modulate(self, morse):
138         signals = morse.split(' ')
139         distances = []
140         for i, signal in enumerate(signals):
141             for symbol in signal:
142                 distances.append(self.symbol_to_distance(symbol))
143
144             # Transmit a signal separator only when strictly necessary.
145             #
146             # Avoid it in these cases:
147             #  - after the last symbol, because EOM is going to ne transmitted
148             #    anyway and that will mark the end of the last symbol.
149             #  - between words, because the word separator act as a symbol
150             #    separator too.
151             if i != len(signals) - 1 and signals[i + 1] != "/" and signal != "/":
152                 distances.append(self.symbol_to_distance(" "))
153
154         distances.append(self.symbol_to_distance("EOM"))
155
156         # Since the Morse signals are encoded in the distance between calls, an
157         # extra call is needed in order for receiver actually get the EOM and
158         # see that the transmission has terminated.
159         distances.append(0)
160
161         return distances
162
163
164 class CallDistanceTransceiver(object):
165     """Transmit Morse messages using the distance between calls.
166
167     This is basically a pulse-distance modulation (PDM).
168
169     A RING is a pulse and the Morse symbols are encoded in the pause between
170     the first ring of the previous call and the first ring of a new call.
171
172     This strategy is very slow but it can even be used with ancient analog
173     modems which don't have call progress notifications for outgoing calls, and
174     make it also hard to count multiple rings in the same call on the receiving
175     side because there is no explicit notification on the receiving side of
176     when the caller ends a calls.
177
178     For GSM modems, which have a more sophisticate call report signalling,
179     a more efficient encoding can be used (for example using ring counts to
180     encode Morse symbols, i.e. a pulse-length modulation), but for
181     a proof-of-concept, a slow encoding covering the most general setup is
182     fine.
183
184     Plus, supporting communications between analog modems is cool :)
185     """
186
187     def __init__(self, modem,
188                  call_setup_time_min=7, call_setup_time_max=15,
189                  ring_time_min=4.8, ring_time_max=5.2,
190                  add_inter_call_distance=True):
191         """Encode the Morse symbols using the distance between calls.
192
193         Args:
194
195             call_setup_time_min: the minimum time between  when the transmitter
196                 dials the number and the receiver reports RING notifications.
197             call_setup_time_max: the maximum time between when the transmitter
198                 dials the number and the receiver reports RING notifications.
199                 Waiting this time after dialing ensures that the receiver has
200                 received _at_least_ one ring. The default chosen here have been
201                 tested to work fine with old analog modems, provided that there
202                 is some more distance between two calls, and with Android
203                 receivers.
204             ring_time_min: the minimum time between two consecutive RINGs
205                 in the same call.
206             ring_time_max: the maximum time between two consecutive RINGs
207                 in the same call, the standard interval is about 5 seconds, but
208                 line and/or software delays can make it vary.  This is needed
209                 in order to ignore multiple ring in the same call when multiple
210                 RING in the same call are notified, and then be able to
211                 discriminate between two different calls.
212             add_inter_call_distance: specify if it is needed to wait an extra
213                 fixed time between calls.
214             """
215
216         self.modem = modem
217         self.translator = MorseTranslator()
218
219         self.call_setup_time_max = call_setup_time_max
220
221         if add_inter_call_distance:
222             # Analog modems don't like to dial a new call immediately after
223             # they hung up the previous one; an extra delay of about one
224             # ring time makes them happy.
225             inter_symbol_distance = ring_time_max
226         else:
227             inter_symbol_distance = 0
228
229         self.modulator = MorseDistanceModulator(call_setup_time_min,
230                                                 call_setup_time_max,
231                                                 ring_time_min,
232                                                 ring_time_max,
233                                                 inter_symbol_distance)
234
235         logging.debug("call setup time between %.2f and %.2f "
236                       "--------- dot transmit time: %.2f + %.2f "
237                       "receive time: between %.2f and %.2f",
238                       self.modulator.period_min,
239                       self.modulator.period_max,
240                       self.modulator.period_max,
241                       self.modulator.dot_time.dist,
242                       self.modulator.dot_time.min,
243                       self.modulator.dot_time.max)
244         logging.debug("call setup time between %.2f and %.2f "
245                       "-------- dash transmit time: %.2f + %.2f "
246                       "receive time: between %.2f and %.2f",
247                       self.modulator.period_min,
248                       self.modulator.period_max,
249                       self.modulator.period_max,
250                       self.modulator.dash_time.dist,
251                       self.modulator.dash_time.min,
252                       self.modulator.dash_time.max)
253         logging.debug("call setup time between %.2f and %.2f "
254                       "- signalspace transmit time: %.2f + %.2f "
255                       "receive time: between %.2f and %.2f",
256                       self.modulator.period_min,
257                       self.modulator.period_max,
258                       self.modulator.period_max,
259                       self.modulator.signalspace_time.dist,
260                       self.modulator.signalspace_time.min,
261                       self.modulator.signalspace_time.max)
262         logging.debug("call setup time between %.2f and %.2f "
263                       "--- wordspace transmit time: %.2f + %.2f "
264                       "receive time: between %.2f and %.2f",
265                       self.modulator.period_min,
266                       self.modulator.period_max,
267                       self.modulator.period_max,
268                       self.modulator.wordspace_time.dist,
269                       self.modulator.wordspace_time.min,
270                       self.modulator.wordspace_time.max)
271         logging.debug("call setup time between %.2f and %.2f "
272                       "--------- EOM transmit time: %.2f + %.2f "
273                       "receive time: between %.2f and inf",
274                       self.modulator.period_min,
275                       self.modulator.period_max,
276                       self.modulator.period_max,
277                       self.modulator.eom_time.dist,
278                       self.modulator.eom_time.min)
279
280         self.previous_ring_time = -1
281         self.previous_call_time = -1
282
283         self.morse_message = ""
284         self.text_message = ""
285
286         self.end_of_message = False
287
288     def log_symbol(self, distance, symbol, extra_info=""):
289         logging.info("distance: %.2f Received \"%s\"%s", distance, symbol,
290                      extra_info)
291
292     def receive_character(self):
293         current_ring_time = time.time()
294
295         if self.previous_ring_time == -1:
296             self.previous_ring_time = current_ring_time
297             self.previous_call_time = current_ring_time
298             self.log_symbol(0, "", "(The very first ring)")
299             return
300
301         ring_distance = current_ring_time - self.previous_ring_time
302         logging.debug("RINGs distance: %.2f", ring_distance)
303         self.previous_ring_time = current_ring_time
304
305         # Ignore multiple rings in the same call
306         if self.modulator.is_same_period(ring_distance):
307             logging.debug("multiple rings in the same call, distance: %.2f",
308                           ring_distance)
309             return
310
311         call_distance = current_ring_time - self.previous_call_time
312         self.previous_call_time = current_ring_time
313
314         try:
315             symbol = self.modulator.distance_to_symbol(call_distance)
316         except ValueError as err:
317             logging.error("%s", err)
318             logging.error("Check the transmitter and receiver parameters")
319             return
320
321         extra_info = ""
322         if symbol in [" ", "/", "EOM"]:
323             signal = self.morse_message.strip().split(' ')[-1]
324             character = self.translator.signal_to_character(signal)
325             extra_info = " got \"%s\"" % character
326
327         self.log_symbol(call_distance, symbol, extra_info)
328
329         if symbol != "EOM":
330             # Add spaces around the wordspace symbol to make it easier to split
331             # the Morse message in symbols later on
332             if symbol == "/":
333                 symbol = " / "
334             self.morse_message += symbol
335         else:
336             self.end_of_message = True
337             self.previous_ring_time = -1
338             self.previous_call_time = -1
339
340     def receive_loop(self):
341         while not self.end_of_message:
342             self.modem.get_response("RING")
343             self.receive_character()
344             logging.debug("Current message: %s", self.morse_message)
345
346         self.end_of_message = False
347         self.text_message = self.translator.morse_to_text(self.morse_message)
348         self.morse_message = ""
349
350     def get_text(self):
351         return self.text_message
352
353     def transmit_symbol(self, destination_number, symbol, sleep_time):
354         logging.info("Dial and wait %.2f = %.2f + %.2f seconds "
355                      "(transmitting '%s')",
356                      self.call_setup_time_max + sleep_time,
357                      self.call_setup_time_max,
358                      sleep_time,
359                      symbol)
360
361         # Dial, then wait self.call_setup_time_max to make sure the receiver
362         # gets at least one RING, and then hangup and sleep the time needed to
363         # transmit a symbol.
364         self.modem.send_command("ATDT" + destination_number + ";")
365         time.sleep(self.call_setup_time_max)
366         self.modem.send_command("ATH")
367         self.modem.get_response()
368         time.sleep(sleep_time)
369
370     def transmit(self, message, destination_number):
371         morse_message = self.translator.text_to_morse(message)
372         distances = self.modulator.modulate(morse_message)
373
374         logging.debug("Starting the transmission")
375         for i, distance in enumerate(distances):
376             # Use 'None' for the last call
377             if i == len(distances) - 1:
378                 symbol = None
379             else:
380                 total_sleep_time = self.call_setup_time_max + distance
381                 symbol = self.modulator.distance_to_symbol(total_sleep_time)
382
383             self.transmit_symbol(destination_number, symbol, distance)
384
385     def estimate_transmit_duration(self, message):
386         morsemessage = self.translator.text_to_morse(message)
387         logging.debug(morsemessage)
388
389         distances = self.modulator.modulate(morsemessage)
390
391         transmitting_time = 0
392         for distance in distances:
393             transmitting_time += self.call_setup_time_max
394             transmitting_time += distance
395
396         logging.debug("Estimated transmitting time: %.2f seconds",
397                       transmitting_time)
398
399
400 def test_transmit_receive():
401     logging.basicConfig(level=logging.DEBUG)
402     call_setup_time_min = 0
403     call_setup_time_max = 0.01
404     ring_time_min = 0
405     ring_time_max = 0
406
407     import random
408
409     class DummyModem(object):
410         """Always receive a '.', a '/' and then EOM, which results in 'E '."""
411
412         def __init__(self):
413             self.ring_count = 0
414
415             # Take trasmission times from a transceiver
416             self.transceiver = None
417
418             random.seed(None)
419
420         def send_command(self, command):
421             pass
422
423         def get_response(self, response=None):
424             # pylint: disable=unused-argument
425
426             setup_time = random.uniform(self.transceiver.modulator.period_min,
427                                         self.transceiver.modulator.period_max)
428
429             if self.ring_count == 0:
430                 # dummy ring
431                 pass
432             elif self.ring_count == 1:
433                 # received a '.'
434                 dot_time = self.transceiver.modulator.dot_time.dist
435                 time.sleep(setup_time + dot_time)
436             elif self.ring_count == 2:
437                 # received a '/'
438                 wordspace_time = self.transceiver.modulator.wordspace_time.dist
439                 time.sleep(setup_time + wordspace_time)
440             else:
441                 # received an 'EOM'
442                 eom_time = self.transceiver.modulator.eom_time.dist
443                 time.sleep(setup_time + eom_time)
444
445             self.ring_count += 1
446             self.ring_count %= 4
447
448     modem = DummyModem()
449
450     xcv = CallDistanceTransceiver(modem,
451                                   call_setup_time_min, call_setup_time_max,
452                                   ring_time_min, ring_time_max, True)
453
454     modem.transceiver = xcv
455
456     xcv.transmit("CODEX PARIS", "0")
457
458     while True:
459         modem.ring_count = 0
460         xcv.receive_loop()
461         print()
462         print("Message received!")
463         print("\"%s\"" % xcv.get_text(), flush=True)
464
465
466 if __name__ == "__main__":
467     test_transmit_receive()