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