CallDistanceTransceiver.py: consider possible delays in transmit_symbol()
[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 importing local modules also when
22 # __name__ == "__main__"
23 try:
24     from .MorseDistanceModulator import MorseDistanceModulator
25     from .MorseTranslator import MorseTranslator
26 except SystemError:
27     from MorseDistanceModulator import MorseDistanceModulator
28     from MorseTranslator import MorseTranslator
29
30 import logging
31 import time
32
33
34 def log_symbol(distance, symbol, extra_info=""):
35     logging.info("distance: %.2f Received \"%s\"%s", distance, symbol,
36                  extra_info)
37
38
39 class CallDistanceTransceiver(object):
40     """Transmit Morse messages using the distance between calls.
41
42     This is basically a pulse-distance modulation (PDM).
43
44     A RING is a pulse and the Morse symbols are encoded in the pause between
45     the first ring of the previous call and the first ring of a new call.
46
47     This strategy is very slow but it can even be used with ancient analog
48     modems which don't have call progress notifications for outgoing calls, and
49     make it also hard to count multiple rings in the same call on the receiving
50     side because there is no explicit notification on the receiving side of
51     when the caller ends a calls.
52
53     For GSM modems, which have a more sophisticate call report signalling,
54     a more efficient encoding can be used (for example using ring counts to
55     encode Morse symbols, i.e. a pulse-length modulation), but for
56     a proof-of-concept, a slow encoding covering the most general setup is
57     fine.
58
59     Plus, supporting communications between analog modems is cool :)
60     """
61
62     def __init__(self, modem,
63                  call_setup_time_min=7, call_setup_time_max=16.5,
64                  ring_time_min=4.8, ring_time_max=5.2,
65                  add_inter_call_distance=True):
66         """Encode the Morse symbols using the distance between calls.
67
68         Args:
69
70             call_setup_time_min: the minimum time between  when the transmitter
71                 dials the number and the receiver reports RING notifications.
72             call_setup_time_max: the maximum time between when the transmitter
73                 dials the number and the receiver reports RING notifications.
74                 Waiting this time after dialing ensures that the receiver has
75                 received _at_least_ one ring. The default chosen here have been
76                 tested to work fine with old analog modems, provided that there
77                 is some more distance between two calls, and with Android
78                 receivers.
79             ring_time_min: the minimum time between two consecutive RINGs
80                 in the same call.
81             ring_time_max: the maximum time between two consecutive RINGs
82                 in the same call, the standard interval is about 5 seconds, but
83                 line and/or software delays can make it vary.  This is needed
84                 in order to ignore multiple ring in the same call when multiple
85                 RING in the same call are notified, and then be able to
86                 discriminate between two different calls.
87             add_inter_call_distance: specify if it is needed to wait an extra
88                 fixed time between calls.
89             """
90
91         self.modem = modem
92         self.translator = MorseTranslator()
93
94         self.call_setup_time_max = call_setup_time_max
95
96         if add_inter_call_distance:
97             # Analog modems don't like to dial a new call immediately after
98             # they hung up the previous one; an extra delay of about one
99             # ring time makes them happy.
100             inter_symbol_distance = ring_time_max
101         else:
102             inter_symbol_distance = 0
103
104         self.modulator = MorseDistanceModulator(call_setup_time_min,
105                                                 call_setup_time_max,
106                                                 ring_time_min,
107                                                 ring_time_max,
108                                                 inter_symbol_distance)
109
110         logging.debug("call setup time between %.2f and %.2f "
111                       "--------- dot transmit time: %.2f + %.2f "
112                       "receive time: between %.2f and %.2f",
113                       self.modulator.period_min,
114                       self.modulator.period_max,
115                       self.modulator.period_max,
116                       self.modulator.dot_time.dist,
117                       self.modulator.dot_time.min,
118                       self.modulator.dot_time.max)
119         logging.debug("call setup time between %.2f and %.2f "
120                       "-------- dash transmit time: %.2f + %.2f "
121                       "receive time: between %.2f and %.2f",
122                       self.modulator.period_min,
123                       self.modulator.period_max,
124                       self.modulator.period_max,
125                       self.modulator.dash_time.dist,
126                       self.modulator.dash_time.min,
127                       self.modulator.dash_time.max)
128         logging.debug("call setup time between %.2f and %.2f "
129                       "- signalspace transmit time: %.2f + %.2f "
130                       "receive time: between %.2f and %.2f",
131                       self.modulator.period_min,
132                       self.modulator.period_max,
133                       self.modulator.period_max,
134                       self.modulator.signalspace_time.dist,
135                       self.modulator.signalspace_time.min,
136                       self.modulator.signalspace_time.max)
137         logging.debug("call setup time between %.2f and %.2f "
138                       "--- wordspace transmit time: %.2f + %.2f "
139                       "receive time: between %.2f and %.2f",
140                       self.modulator.period_min,
141                       self.modulator.period_max,
142                       self.modulator.period_max,
143                       self.modulator.wordspace_time.dist,
144                       self.modulator.wordspace_time.min,
145                       self.modulator.wordspace_time.max)
146         logging.debug("call setup time between %.2f and %.2f "
147                       "--------- EOM transmit time: %.2f + %.2f "
148                       "receive time: between %.2f and inf",
149                       self.modulator.period_min,
150                       self.modulator.period_max,
151                       self.modulator.period_max,
152                       self.modulator.eom_time.dist,
153                       self.modulator.eom_time.min)
154
155         self.previous_ring_time = -1
156         self.previous_call_time = -1
157
158         self.morse_message = ""
159         self.text_message = ""
160
161         self.end_of_message = False
162
163     def receive_symbol(self):
164         current_ring_time = time.time()
165
166         if self.previous_ring_time == -1:
167             self.previous_ring_time = current_ring_time
168             self.previous_call_time = current_ring_time
169             log_symbol(0, "", "(The very first ring)")
170             return
171
172         ring_distance = current_ring_time - self.previous_ring_time
173         logging.debug("RINGs distance: %.2f", ring_distance)
174         self.previous_ring_time = current_ring_time
175
176         # Ignore multiple rings in the same call
177         if self.modulator.is_same_period(ring_distance):
178             logging.debug("multiple rings in the same call, distance: %.2f",
179                           ring_distance)
180             return
181
182         call_distance = current_ring_time - self.previous_call_time
183         self.previous_call_time = current_ring_time
184
185         try:
186             symbol = self.modulator.distance_to_symbol(call_distance)
187         except ValueError as err:
188             logging.error("%s", err)
189             logging.error("Check the transmitter and receiver parameters")
190             return
191
192         extra_info = ""
193         if symbol in [" ", "/", "EOM"]:
194             signal = self.morse_message.strip().split(' ')[-1]
195             character = self.translator.signal_to_character(signal)
196             extra_info = " got \"%s\"" % character
197
198         log_symbol(call_distance, symbol, extra_info)
199
200         if symbol != "EOM":
201             # Add spaces around the wordspace symbol to make it easier to split
202             # the Morse message in symbols later on
203             if symbol == "/":
204                 symbol = " / "
205             self.morse_message += symbol
206         else:
207             self.end_of_message = True
208             self.previous_ring_time = -1
209             self.previous_call_time = -1
210
211     def receive_loop(self):
212         while not self.end_of_message:
213             self.modem.get_response("RING")
214             self.receive_symbol()
215             logging.debug("Current message: %s", self.morse_message)
216
217         self.end_of_message = False
218         self.text_message = self.translator.morse_to_text(self.morse_message)
219         self.morse_message = ""
220
221     def get_text(self):
222         return self.text_message
223
224     def transmit_symbol(self, destination_number, symbol, sleep_time):
225         logging.info("Dial and wait %.2f = %.2f + %.2f seconds "
226                      "(transmitting '%s')",
227                      self.call_setup_time_max + sleep_time,
228                      self.call_setup_time_max,
229                      sleep_time,
230                      symbol)
231
232         # Dial, then wait self.call_setup_time_max to make sure the receiver
233         # gets at least one RING, and then hangup and sleep the time needed to
234         # transmit a symbol.
235         time_before = time.time()
236         self.modem.send_command("ATDT" + destination_number + ";")
237         time.sleep(self.call_setup_time_max)
238         self.modem.send_command("ATH")
239         self.modem.get_response()
240         time_after = time.time()
241
242         # Account for possible delays in order to be as adherent as
243         # possible to the nominal total symbol transmission distance.
244         delay = (time_after - time_before) - self.call_setup_time_max
245         logging.debug("Delay %.2f", delay)
246
247         remaining_sleep_time = sleep_time - delay
248         if remaining_sleep_time < 0:
249             remaining_sleep_time = 0
250
251         logging.debug("Should sleep %.2f. Will sleep %.2f", sleep_time,
252                       remaining_sleep_time)
253         time.sleep(remaining_sleep_time)
254
255     def transmit(self, message, destination_number):
256         morse_message = self.translator.text_to_morse(message)
257         distances = self.modulator.modulate(morse_message)
258
259         logging.debug("Starting the transmission")
260         for i, distance in enumerate(distances):
261             # Use 'None' for the last call
262             if i == len(distances) - 1:
263                 symbol = None
264             else:
265                 total_sleep_time = self.call_setup_time_max + distance
266                 symbol = self.modulator.distance_to_symbol(total_sleep_time)
267
268             self.transmit_symbol(destination_number, symbol, distance)
269
270     def estimate_transmit_duration(self, message):
271         morsemessage = self.translator.text_to_morse(message)
272         logging.debug(morsemessage)
273
274         distances = self.modulator.modulate(morsemessage)
275
276         transmitting_time = 0
277         for distance in distances:
278             transmitting_time += self.call_setup_time_max
279             transmitting_time += distance
280
281         logging.debug("Estimated transmitting time: %.2f seconds",
282                       transmitting_time)
283
284
285 def test_transmit_receive():
286     logging.basicConfig(level=logging.DEBUG)
287     call_setup_time_min = 0
288     call_setup_time_max = 0.01
289     ring_time_min = 0
290     ring_time_max = 0
291
292     import random
293
294     class DummyModem(object):
295         """Always receive a '.', a '/' and then EOM, which results in 'E '."""
296
297         def __init__(self):
298             self.ring_count = 0
299
300             # Take trasmission times from a transceiver
301             self.transceiver = None
302
303             random.seed(None)
304
305         def send_command(self, command):
306             pass
307
308         def get_response(self, response=None):
309             # pylint: disable=unused-argument
310
311             setup_time = random.uniform(self.transceiver.modulator.period_min,
312                                         self.transceiver.modulator.period_max)
313
314             if self.ring_count == 0:
315                 # dummy ring
316                 pass
317             elif self.ring_count == 1:
318                 # received a '.'
319                 dot_time = self.transceiver.modulator.dot_time.dist
320                 time.sleep(setup_time + dot_time)
321             elif self.ring_count == 2:
322                 # received a '/'
323                 wordspace_time = self.transceiver.modulator.wordspace_time.dist
324                 time.sleep(setup_time + wordspace_time)
325             else:
326                 # received an 'EOM'
327                 eom_time = self.transceiver.modulator.eom_time.dist
328                 time.sleep(setup_time + eom_time)
329
330             self.ring_count += 1
331             self.ring_count %= 4
332
333     modem = DummyModem()
334
335     xcv = CallDistanceTransceiver(modem,
336                                   call_setup_time_min, call_setup_time_max,
337                                   ring_time_min, ring_time_max, True)
338
339     modem.transceiver = xcv
340
341     xcv.transmit("CODEX PARIS", "0")
342
343     while True:
344         modem.ring_count = 0
345         xcv.receive_loop()
346         print()
347         print("Message received!")
348         print("\"%s\"" % xcv.get_text(), flush=True)
349
350
351 if __name__ == "__main__":
352     test_transmit_receive()