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