MorseTranslator.py: don't sanitize away word spaces
[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 CallDistanceTransceiver(object):
33     """Transmit Morse messages using the distance between calls.
34
35     This is basically a pulse-distance modulation (PDM).
36
37     A RING is a pulse and the Morse symbols are encoded in the pause between
38     the first ring of the previous call and the first ring of a new call.
39
40     This strategy is very slow but it can even be used with ancient analog
41     modems which don't have call progress notifications for outgoing calls, and
42     make it also hard to count multiple rings in the same call on the receiving
43     side because there is no explicit notification on the receiving side of
44     when the caller ends a calls.
45
46     For GSM modems, which have a more sophisticate call report signalling,
47     a more efficient encoding can be used (for example using ring counts to
48     encode Morse symbols, i.e. a pulse-length modulation), but for
49     a proof-of-concept, a slow encoding covering the most general setup is
50     fine.
51
52     Plus, supporting communications between analog modems is cool :)
53     """
54
55     def __init__(self, modem,
56                  call_setup_average=8.5, call_setup_uncertainty=1.5,
57                  ring_distance_average=5, ring_uncertainty=0.2):
58         """Encode the Morse symbols using the distance between calls.
59
60         Args:
61             call_setup_average: the time between when the transmitter dials
62                 the number and the receiver reports RING notifications. This is
63                 needed in order to be sure than the receiver has received
64                 _at_least_ one ring. The default chosen here should work fine
65                 even with old analog modems, provided that there is some more
66                 distance between two calls.
67             call_setup_uncertainty: uncertainty of call_setup_average,
68                 a tolerance value
69             ring_distance_average: the time between two consecutive RINGs
70                 in the same call, the standard interval is about 5 seconds, but
71                 some tolerance is needed to account for line delays.
72                 This is needed in order to ignore rings in the same call, and
73                 differentiate two different calls.
74             ring_uncertainty: uncertainty of ring_distance_average, a tolerance
75                 value
76         """
77
78         self.modem = modem
79         self.translator = MorseTranslator()
80
81         self.destination_number = ""
82
83         self.call_setup_time = call_setup_average + call_setup_uncertainty
84         self.rings_distance = ring_distance_average + ring_uncertainty
85
86         # In theory the symbol distance, the distance between calls which
87         # represent symbols, can be arbitrary, but in practice it's better to
88         # wait at least the duration of a ring between terminating one call and
89         # initiating the next call, as pick-up and hang-up can take some time
90         # with old analog modems.
91         symbol_distance = self.rings_distance
92
93         def symbol_time(multiplier):
94             return self.call_setup_time + symbol_distance * multiplier
95
96         self.dot_time = symbol_time(1)
97         self.dash_time = symbol_time(2)
98         self.signalspace_time = symbol_time(3)
99         self.wordspace_time = symbol_time(4)
100         self.eom_time = symbol_time(5)
101
102         self.ring_uncertainty = ring_uncertainty
103         self.symbol_uncertainty = symbol_distance / 2.
104
105         logging.debug("dot ---------> transmit time: %.2f "
106                       "receive time: between %.2f and %.2f",
107                       self.dot_time,
108                       (self.dot_time - self.symbol_uncertainty),
109                       (self.dot_time + self.symbol_uncertainty))
110         logging.debug("dash --------> transmit time: %.2f "
111                       "receive time: between %.2f and %.2f",
112                       self.dash_time,
113                       (self.dash_time - self.symbol_uncertainty),
114                       (self.dash_time + self.symbol_uncertainty))
115         logging.debug("singalspace -> transmit time: %.2f "
116                       "receive time: between %.2f and %.2f",
117                       self.signalspace_time,
118                       (self.signalspace_time - self.symbol_uncertainty),
119                       (self.signalspace_time + self.symbol_uncertainty))
120         logging.debug("wordspace ---> transmit time: %.2f "
121                       "receive time: between %.2f and %.2f",
122                       self.wordspace_time,
123                       (self.wordspace_time - self.symbol_uncertainty),
124                       (self.wordspace_time + self.symbol_uncertainty))
125         logging.debug("EOM ---------> transmit time: %.2f "
126                       "receive time: between %.2f and inf",
127                       self.eom_time,
128                       (self.eom_time - self.symbol_uncertainty))
129
130         self.previous_ring_time = -1
131         self.previous_call_time = -1
132
133         self.morse_message = ""
134         self.text_message = ""
135
136         self.end_of_message = False
137
138     def log_symbol(self, distance, symbol, extra_info=""):
139         logging.info("distance: %.2f Received \"%s\" %s", distance, symbol,
140                      extra_info)
141
142     def receive_character(self):
143         current_ring_time = time.time()
144
145         if self.previous_ring_time == -1:
146             self.previous_ring_time = current_ring_time
147             self.previous_call_time = current_ring_time
148             self.log_symbol(0, "", "(The very first ring)")
149             return
150
151         ring_distance = current_ring_time - self.previous_ring_time
152         logging.debug("RINGs distance: %.2f", ring_distance)
153         self.previous_ring_time = current_ring_time
154
155         # Ignore multiple rings in the same call
156         if abs(ring_distance - self.rings_distance) < self.ring_uncertainty:
157             logging.debug("multiple rings in the same call, distance: %.2f",
158                           ring_distance)
159             return
160
161         call_distance = current_ring_time - self.previous_call_time
162         self.previous_call_time = current_ring_time
163
164         if abs(call_distance - self.dot_time) < self.symbol_uncertainty:
165             self.log_symbol(call_distance, '.')
166             self.morse_message += "."
167             return
168
169         if abs(call_distance - self.dash_time) < self.symbol_uncertainty:
170             self.log_symbol(call_distance, '-')
171             self.morse_message += "-"
172             return
173
174         if abs(call_distance - self.signalspace_time) < self.symbol_uncertainty:
175             signal = self.morse_message.strip().split(' ')[-1]
176             character = self.translator.signal_to_character(signal)
177             self.log_symbol(call_distance, ' ', "got \"%s\"" % character)
178             self.morse_message += " "
179             return
180
181         if abs(call_distance - self.wordspace_time) < self.symbol_uncertainty:
182             signal = self.morse_message.strip().split(' ')[-1]
183             character = self.translator.signal_to_character(signal)
184             self.log_symbol(call_distance, '/', "got \"%s\"" % character)
185             self.morse_message += " / "
186             return
187
188         if call_distance >= self.eom_time - self.symbol_uncertainty:
189             signal = self.morse_message.strip().split(' ')[-1]
190             character = self.translator.signal_to_character(signal)
191             self.log_symbol(call_distance, 'EOM', "got \"%s\"" % character)
192             self.end_of_message = True
193             self.previous_ring_time = -1
194             self.previous_call_time = -1
195             return
196
197         # if the code made it up to here, something fishy is going on
198         logging.error("Unexpected distance: %.2f", call_distance)
199         logging.error("Check the transmitter and receiver parameters")
200
201     def receive_loop(self):
202         while not self.end_of_message:
203             self.modem.get_response("RING")
204             self.receive_character()
205             logging.debug("Current message: %s", self.morse_message)
206
207         self.end_of_message = False
208         self.text_message = self.translator.morse_to_text(self.morse_message)
209         self.morse_message = ""
210
211     def get_text(self):
212         return self.text_message
213
214     def transmit_symbol(self, symbol):
215         if symbol == ".":
216             sleep_time = self.dot_time
217         elif symbol == "-":
218             sleep_time = self.dash_time
219         elif symbol == " ":
220             sleep_time = self.signalspace_time
221         elif symbol == "/":
222             sleep_time = self.wordspace_time
223         elif symbol == "EOM":
224             sleep_time = self.eom_time
225         elif symbol is None:
226             # To terminate the transmission just call and hangup, with no extra
227             # distance
228             sleep_time = self.call_setup_time
229
230         logging.info("Dial and wait %.2f seconds (transmitting '%s')",
231                      sleep_time, symbol)
232
233         # Dial, then wait self.call_setup_time to make sure the receiver gets
234         # at least one RING, and then hangup and sleep the remaining time
235         self.modem.send_command("ATDT" + self.destination_number + ";")
236         time.sleep(self.call_setup_time)
237         self.modem.send_command("ATH")
238         self.modem.get_response()
239         time.sleep(sleep_time - self.call_setup_time)
240
241     def transmit_signal(self, signal):
242         logging.debug("Transmitting signal: %s", signal)
243         for symbol in signal:
244             self.transmit_symbol(symbol)
245
246     def transmit(self, message, destination_number):
247         self.destination_number = destination_number
248
249         morse_message = self.translator.text_to_morse(message)
250         signals = morse_message.split()
251         print(signals)
252
253         logging.debug("Starting the transmission")
254         for i, signal in enumerate(signals):
255             logging.debug("Transmitting '%s' as '%s'", message[i], signal)
256             self.transmit_signal(signal)
257
258             # Transmit a signal separator only when strictly necessary:
259             #  - after the last symbol, we are going to transmit an EOM
260             #    anyway, and that will mark the end of the last symbol.
261             #  - between words the word separator act as a symbol separator
262             #    too.
263             if i != len(signals) - 1 and signals[i + 1] != "/" and signal != "/":
264                 self.transmit_symbol(" ")
265
266         self.transmit_symbol("EOM")
267
268         # Since the Morse signals are encoded in the distance between calls, an
269         # extra call is needed in order for receiver actually get the EOM and
270         # see that the transmission has terminated.
271         self.transmit_symbol(None)
272
273     def estimate_transmit_duration(self, message):
274         morsemessage = self.translator.text_to_morse(message)
275         signals = morsemessage.split()
276
277         logging.debug(signals)
278
279         transmitting_time = 0
280         for i, signal in enumerate(signals):
281             logging.debug("signal: %s", signal)
282
283             for symbol in signal:
284                 if symbol == ".":
285                     transmitting_time += self.dot_time
286                 elif symbol == "-":
287                     transmitting_time += self.dash_time
288                 elif symbol == "/":
289                     transmitting_time += self.wordspace_time
290
291                 if i != len(signals) - 1 and signals[i + 1] != "/" and symbol != "/":
292                     transmitting_time += self.signalspace_time
293
294         transmitting_time += self.eom_time
295
296         logging.debug("Estimated transmitting time: %.2f seconds",
297                       transmitting_time)
298
299
300 def test_send_receive():
301     logging.basicConfig(level=logging.DEBUG)
302     call_setup_time = 2
303     call_setup_uncertainty = 0.4
304     ring_time = 1
305     ring_uncertainty = 0.3
306
307     class DummyModem(object):
308         """Always receive a '.' and then a '/', which result in 'E '."""
309
310         def __init__(self):
311             self.ring_count = 0
312
313         def send_command(self, command):
314             pass
315
316         def get_response(self, response):
317             if self.ring_count % 2:
318                 # received a '.'
319                 time.sleep(call_setup_time + (ring_time + ring_uncertainty))
320             else:
321                 # received a '/'
322                 time.sleep(call_setup_time + (ring_time + ring_uncertainty) * 4)
323
324             self.ring_count += 1
325
326     xcv = CallDistanceTransceiver(DummyModem(), call_setup_time,
327                                   call_setup_uncertainty, ring_time,
328                                   ring_uncertainty)
329     xcv.receive_loop()
330
331
332 if __name__ == "__main__":
333     test_send_receive()