CallDistanceTransceiver.py: use a min,max scheme instead of avg,uncertainty
[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 pulse
36     time, but in practice it can be necessary to wait a predefined minimum
37     amount of time between pulses 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, pulse_min, pulse_max, multiplier,
44                  min_inter_symbol_distance=0.0):
45         assert multiplier >= 0
46         if (pulse_min == pulse_max) and (min_inter_symbol_distance == 0):
47             raise ValueError("If (pulse_min == pulse_max) a non-zero",
48                              "inter-symbol distance MUST be specified")
49
50         symbol_distance = 2 * (pulse_max - pulse_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 + pulse_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 + pulse_min + \
64             symbol_distance * (multiplier + 1)
65
66
67 class CallDistanceTransceiver(object):
68     """Transmit Morse messages using the distance between calls.
69
70     This is basically a pulse-distance modulation (PDM).
71
72     A RING is a pulse and the Morse symbols are encoded in the pause between
73     the first ring of the previous call and the first ring of a new call.
74
75     This strategy is very slow but it can even be used with ancient analog
76     modems which don't have call progress notifications for outgoing calls, and
77     make it also hard to count multiple rings in the same call on the receiving
78     side because there is no explicit notification on the receiving side of
79     when the caller ends a calls.
80
81     For GSM modems, which have a more sophisticate call report signalling,
82     a more efficient encoding can be used (for example using ring counts to
83     encode Morse symbols, i.e. a pulse-length modulation), but for
84     a proof-of-concept, a slow encoding covering the most general setup is
85     fine.
86
87     Plus, supporting communications between analog modems is cool :)
88     """
89
90     def __init__(self, modem,
91                  call_setup_time_min=7, call_setup_time_max=15,
92                  ring_time_min=4.8, ring_time_max=5.2,
93                  add_inter_call_distance=True):
94         """Encode the Morse symbols using the distance between calls.
95
96         Args:
97
98             call_setup_time_min: the minimum time between  when the transmitter
99                 dials the number and the receiver reports RING notifications.
100             call_setup_time_max: the maximum time between when the transmitter
101                 dials the number and the receiver reports RING notifications.
102                 Waiting this time after dialing ensures that the receiver has
103                 received _at_least_ one ring. The default chosen here have been
104                 tested to work fine with old analog modems, provided that there
105                 is some more distance between two calls, and with Android
106                 receivers.
107             ring_time_min: the minimum time between two consecutive RINGs
108                 in the same call.
109             ring_time_max: the maximum time between two consecutive RINGs
110                 in the same call, the standard interval is about 5 seconds, but
111                 line and/or software delays can make it vary.  This is needed
112                 in order to ignore multiple ring in the same call when multiple
113                 RING in the same call are notified, and then be able to
114                 discriminate between two different calls.
115             add_inter_call_distance: specify if it is needed to wait an extra
116                 fixed time between calls.
117             """
118
119         self.modem = modem
120         self.translator = MorseTranslator()
121
122         self.destination_number = ""
123
124         self.call_setup_time_min = call_setup_time_min
125         self.call_setup_time_max = call_setup_time_max
126
127         self.ring_time_min = ring_time_min
128         self.ring_time_max = ring_time_max
129
130         if add_inter_call_distance:
131             # Analog modems don't like to dial a new call immediately after
132             # they hung up the previous one; an extra delay of about one
133             # ring time makes them happy.
134             inter_symbol_distance = ring_time_max
135         else:
136             inter_symbol_distance = 0
137
138         self.dot_time = SymbolTime(call_setup_time_min,
139                                    call_setup_time_max, 0,
140                                    inter_symbol_distance)
141         self.dash_time = SymbolTime(call_setup_time_min,
142                                     call_setup_time_max, 1,
143                                     inter_symbol_distance)
144         self.signalspace_time = SymbolTime(call_setup_time_min,
145                                            call_setup_time_max, 2,
146                                            inter_symbol_distance)
147         self.wordspace_time = SymbolTime(call_setup_time_min,
148                                          call_setup_time_max, 3,
149                                          inter_symbol_distance)
150         self.eom_time = SymbolTime(call_setup_time_min,
151                                    call_setup_time_max, 4,
152                                    inter_symbol_distance)
153
154         logging.debug("call setup time between %.2f and %.2f "
155                       "--------- dot transmit time: %.2f + %.2f "
156                       "receive time: between %.2f and %.2f",
157                       self.call_setup_time_min,
158                       self.call_setup_time_max,
159                       self.call_setup_time_max,
160                       self.dot_time.dist,
161                       self.dot_time.min,
162                       self.dot_time.max)
163         logging.debug("call setup time between %.2f and %.2f "
164                       "-------- dash transmit time: %.2f + %.2f "
165                       "receive time: between %.2f and %.2f",
166                       self.call_setup_time_min,
167                       self.call_setup_time_max,
168                       self.call_setup_time_max,
169                       self.dash_time.dist,
170                       self.dash_time.min,
171                       self.dash_time.max)
172         logging.debug("call setup time between %.2f and %.2f "
173                       "- signalspace transmit time: %.2f + %.2f "
174                       "receive time: between %.2f and %.2f",
175                       self.call_setup_time_min,
176                       self.call_setup_time_max,
177                       self.call_setup_time_max,
178                       self.signalspace_time.dist,
179                       self.signalspace_time.min,
180                       self.signalspace_time.max)
181         logging.debug("call setup time between %.2f and %.2f "
182                       "--- wordspace transmit time: %.2f + %.2f "
183                       "receive time: between %.2f and %.2f",
184                       self.call_setup_time_min,
185                       self.call_setup_time_max,
186                       self.call_setup_time_max,
187                       self.wordspace_time.dist,
188                       self.wordspace_time.min,
189                       self.wordspace_time.max)
190         logging.debug("call setup time between %.2f and %.2f "
191                       "--------- EOM transmit time: %.2f + %.2f "
192                       "receive time: between %.2f and inf",
193                       self.call_setup_time_min,
194                       self.call_setup_time_max,
195                       self.call_setup_time_max,
196                       self.eom_time.dist,
197                       self.eom_time.min)
198
199         self.previous_ring_time = -1
200         self.previous_call_time = -1
201
202         self.morse_message = ""
203         self.text_message = ""
204
205         self.end_of_message = False
206
207     def log_symbol(self, distance, symbol, extra_info=""):
208         logging.info("distance: %.2f Received \"%s\" %s", distance, symbol,
209                      extra_info)
210
211     def receive_character(self):
212         current_ring_time = time.time()
213
214         if self.previous_ring_time == -1:
215             self.previous_ring_time = current_ring_time
216             self.previous_call_time = current_ring_time
217             self.log_symbol(0, "", "(The very first ring)")
218             return
219
220         ring_distance = current_ring_time - self.previous_ring_time
221         logging.debug("RINGs distance: %.2f", ring_distance)
222         self.previous_ring_time = current_ring_time
223
224         # Ignore multiple rings in the same call
225         if ring_distance > self.ring_time_min and \
226            ring_distance <= self.ring_time_max:
227             logging.debug("multiple rings in the same call, distance: %.2f",
228                           ring_distance)
229             return
230
231         call_distance = current_ring_time - self.previous_call_time
232         self.previous_call_time = current_ring_time
233
234         if call_distance > self.dot_time.min and \
235            call_distance <= self.dot_time.max:
236             self.log_symbol(call_distance, '.')
237             self.morse_message += "."
238             return
239
240         if call_distance > self.dash_time.min and \
241            call_distance <= self.dash_time.max:
242             self.log_symbol(call_distance, '-')
243             self.morse_message += "-"
244             return
245
246         if call_distance > self.signalspace_time.min and \
247            call_distance <= self.signalspace_time.max:
248             signal = self.morse_message.strip().split(' ')[-1]
249             character = self.translator.signal_to_character(signal)
250             self.log_symbol(call_distance, ' ', "got \"%s\"" % character)
251             self.morse_message += " "
252             return
253
254         if call_distance > self.wordspace_time.min and \
255            call_distance <= self.wordspace_time.max:
256             signal = self.morse_message.strip().split(' ')[-1]
257             character = self.translator.signal_to_character(signal)
258             self.log_symbol(call_distance, '/', "got \"%s\"" % character)
259             self.morse_message += " / "
260             return
261
262         if call_distance > self.eom_time.min:
263             signal = self.morse_message.strip().split(' ')[-1]
264             character = self.translator.signal_to_character(signal)
265             self.log_symbol(call_distance, 'EOM', "got \"%s\"" % character)
266             self.end_of_message = True
267             self.previous_ring_time = -1
268             self.previous_call_time = -1
269             return
270
271         # if the code made it up to here, something fishy is going on
272         logging.error("Unexpected distance: %.2f", call_distance)
273         logging.error("Check the transmitter and receiver parameters")
274
275     def receive_loop(self):
276         while not self.end_of_message:
277             self.modem.get_response("RING")
278             self.receive_character()
279             logging.debug("Current message: %s", self.morse_message)
280
281         self.end_of_message = False
282         self.text_message = self.translator.morse_to_text(self.morse_message)
283         self.morse_message = ""
284
285     def get_text(self):
286         return self.text_message
287
288     def transmit_symbol(self, symbol):
289         if symbol == ".":
290             sleep_time = self.dot_time.dist
291         elif symbol == "-":
292             sleep_time = self.dash_time.dist
293         elif symbol == " ":
294             sleep_time = self.signalspace_time.dist
295         elif symbol == "/":
296             sleep_time = self.wordspace_time.dist
297         elif symbol == "EOM":
298             sleep_time = self.eom_time.dist
299         elif symbol is None:
300             # To terminate the transmission just call and hangup, with no extra
301             # distance
302             sleep_time = 0
303
304         logging.info("Dial and wait %.2f = %.2f + %.2f seconds "
305                      "(transmitting '%s')",
306                      self.call_setup_time_max + sleep_time,
307                      self.call_setup_time_max,
308                      sleep_time,
309                      symbol)
310
311         # Dial, then wait self.call_setup_time_max to make sure the receiver
312         # gets at least one RING, and then hangup and sleep the time needed to
313         # transmit a symbol.
314         self.modem.send_command("ATDT" + self.destination_number + ";")
315         time.sleep(self.call_setup_time_max)
316         self.modem.send_command("ATH")
317         self.modem.get_response()
318         time.sleep(sleep_time)
319
320     def transmit_signal(self, signal):
321         logging.debug("Transmitting signal: %s", signal)
322         for symbol in signal:
323             self.transmit_symbol(symbol)
324
325     def transmit(self, message, destination_number):
326         self.destination_number = destination_number
327
328         morse_message = self.translator.text_to_morse(message)
329         signals = morse_message.split()
330
331         logging.debug("Starting the transmission")
332         for i, signal in enumerate(signals):
333             logging.debug("Transmitting '%s' as '%s'", message[i], signal)
334             self.transmit_signal(signal)
335
336             # Transmit a signal separator only when strictly necessary:
337             #  - after the last symbol, we are going to transmit an EOM
338             #    anyway, and that will mark the end of the last symbol.
339             #  - between words the word separator act as a symbol separator
340             #    too.
341             if i != len(signals) - 1 and signals[i + 1] != "/" and signal != "/":
342                 self.transmit_symbol(" ")
343
344         self.transmit_symbol("EOM")
345
346         # Since the Morse signals are encoded in the distance between calls, an
347         # extra call is needed in order for receiver actually get the EOM and
348         # see that the transmission has terminated.
349         self.transmit_symbol(None)
350
351     def estimate_transmit_duration(self, message):
352         morsemessage = self.translator.text_to_morse(message)
353         signals = morsemessage.split()
354
355         logging.debug(signals)
356
357         transmitting_time = 0
358         for i, signal in enumerate(signals):
359             logging.debug("signal: %s", signal)
360
361             for symbol in signal:
362                 transmitting_time += self.call_setup_time_max
363                 if symbol == ".":
364                     transmitting_time += self.dot_time.dist
365                 elif symbol == "-":
366                     transmitting_time += self.dash_time.dist
367                 elif symbol == "/":
368                     transmitting_time += self.wordspace_time.dist
369
370                 if i != len(signals) - 1 and signals[i + 1] != "/" and symbol != "/":
371                     transmitting_time += self.call_setup_time_max
372                     transmitting_time += self.signalspace_time.dist
373
374         transmitting_time += self.call_setup_time_max
375         transmitting_time += self.eom_time.dist
376
377         # The final call needed for the receiver to get the EOM
378         transmitting_time += self.call_setup_time_max
379
380         logging.debug("Estimated transmitting time: %.2f seconds",
381                       transmitting_time)
382
383
384 def test_send_receive():
385     logging.basicConfig(level=logging.DEBUG)
386     call_setup_time = 2
387     call_setup_uncertainty = 0.4
388     ring_time = 1
389     ring_uncertainty = 0.3
390
391     class DummyModem(object):
392         """Always receive a '.' and then a '/', which result in 'E '."""
393
394         def __init__(self):
395             self.ring_count = 0
396
397         def send_command(self, command):
398             pass
399
400         def get_response(self, response):
401             if self.ring_count % 2:
402                 # received a '.'
403                 time.sleep(call_setup_time + (ring_time + ring_uncertainty))
404             else:
405                 # received a '/'
406                 time.sleep(call_setup_time + (ring_time + ring_uncertainty) * 4)
407
408             self.ring_count += 1
409
410     xcv = CallDistanceTransceiver(DummyModem(), call_setup_time,
411                                   call_setup_uncertainty, ring_time,
412                                   ring_uncertainty)
413     xcv.receive_loop()
414
415
416 if __name__ == "__main__":
417     test_send_receive()