343898c9fb81fe90a55e64560c61a7ad6ec746bb
[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 time:         transmit: %.2f receive: (%.2f, %.2f)",
106                       self.dot_time,
107                       (self.dot_time - self.symbol_uncertainty),
108                       (self.dot_time + self.symbol_uncertainty))
109         logging.debug("dash time:        transmit: %.2f receive: (%.2f, %.2f)",
110                       self.dash_time,
111                       (self.dash_time - self.symbol_uncertainty),
112                       (self.dash_time + self.symbol_uncertainty))
113         logging.debug("signalspace time: transmit: %.2f receive: (%.2f, %.2f)",
114                       self.signalspace_time,
115                       (self.signalspace_time - self.symbol_uncertainty),
116                       (self.signalspace_time + self.symbol_uncertainty))
117         logging.debug("wordspace time:   transmit: %.2f receive: (%.2f, %.2f)",
118                       self.wordspace_time,
119                       (self.wordspace_time - self.symbol_uncertainty),
120                       (self.wordspace_time + self.symbol_uncertainty))
121         logging.debug("EOM time:         transmit: %.2f receive: (%.2f, +inf)",
122                       self.eom_time,
123                       (self.eom_time - self.symbol_uncertainty))
124
125         self.previous_ring_time = -1
126         self.previous_call_time = -1
127
128         self.morse_message = ""
129         self.text_message = ""
130
131         self.end_of_message = False
132
133     def log_symbol(self, distance, symbol, extra_info=""):
134         logging.info("distance: %.2f Received \"%s\" %s", distance, symbol,
135                      extra_info)
136
137     def receive_character(self):
138         current_ring_time = time.time()
139
140         if self.previous_ring_time == -1:
141             self.previous_ring_time = current_ring_time
142             self.previous_call_time = current_ring_time
143             self.log_symbol(0, "", "(The very first ring)")
144             return
145
146         ring_distance = current_ring_time - self.previous_ring_time
147         logging.debug("RINGs distance: %.2f", ring_distance)
148         self.previous_ring_time = current_ring_time
149
150         # Ignore multiple rings in the same call
151         if abs(ring_distance - self.rings_distance) < self.ring_uncertainty:
152             logging.debug("multiple rings in the same call, distance: %.2f",
153                           ring_distance)
154             return
155
156         call_distance = current_ring_time - self.previous_call_time
157         self.previous_call_time = current_ring_time
158
159         if abs(call_distance - self.dot_time) < self.symbol_uncertainty:
160             self.log_symbol(call_distance, '.')
161             self.morse_message += "."
162             return
163
164         if abs(call_distance - self.dash_time) < self.symbol_uncertainty:
165             self.log_symbol(call_distance, '-')
166             self.morse_message += "-"
167             return
168
169         if abs(call_distance - self.signalspace_time) < self.symbol_uncertainty:
170             signal = self.morse_message.strip().split(' ')[-1]
171             character = self.translator.signal_to_character(signal)
172             self.log_symbol(call_distance, ' ', "got \"%s\"" % character)
173             self.morse_message += " "
174             return
175
176         if abs(call_distance - self.wordspace_time) < self.symbol_uncertainty:
177             signal = self.morse_message.strip().split(' ')[-1]
178             character = self.translator.signal_to_character(signal)
179             self.log_symbol(call_distance, '/', "got \"%s\"" % character)
180             self.morse_message += " / "
181             return
182
183         if call_distance >= self.eom_time - self.symbol_uncertainty:
184             signal = self.morse_message.strip().split(' ')[-1]
185             character = self.translator.signal_to_character(signal)
186             self.log_symbol(call_distance, 'EOM', "got \"%s\"" % character)
187             self.end_of_message = True
188             self.previous_ring_time = -1
189             self.previous_call_time = -1
190             return
191
192         # if the code made it up to here, something fishy is going on
193         logging.error("Unexpected distance: %.2f", call_distance)
194         logging.error("Check the transmitter and receiver parameters")
195
196     def receive_loop(self):
197         while not self.end_of_message:
198             self.modem.get_response("RING")
199             self.receive_character()
200             logging.debug("Current message: %s", self.morse_message)
201
202         self.end_of_message = False
203         self.text_message = self.translator.morse_to_text(self.morse_message)
204         self.morse_message = ""
205
206     def get_text(self):
207         return self.text_message
208
209     def transmit_symbol(self, symbol):
210         if symbol == ".":
211             sleep_time = self.dot_time
212         elif symbol == "-":
213             sleep_time = self.dash_time
214         elif symbol == " ":
215             sleep_time = self.signalspace_time
216         elif symbol == "/":
217             sleep_time = self.wordspace_time
218         elif symbol == "EOM":
219             sleep_time = self.eom_time
220         elif symbol is None:
221             # To terminate the transmission just call and hangup, with no extra
222             # distance
223             sleep_time = self.call_setup_time
224
225         logging.info("Dial and wait %.2f seconds (transmitting '%s')",
226                      sleep_time, symbol)
227
228         # Dial, then wait self.call_setup_time to make sure the receiver gets
229         # at least one RING, and then hangup and sleep the remaining time
230         self.modem.send_command("ATDT" + self.destination_number + ";")
231         time.sleep(self.call_setup_time)
232         self.modem.send_command("ATH")
233         self.modem.get_response()
234         time.sleep(sleep_time - self.call_setup_time)
235
236     def transmit_signal(self, signal):
237         logging.debug("Transmitting signal: %s", signal)
238         for symbol in signal:
239             self.transmit_symbol(symbol)
240
241     def transmit(self, message, destination_number):
242         self.destination_number = destination_number
243
244         morse_message = self.translator.text_to_morse(message)
245         signals = morse_message.split()
246         print(signals)
247
248         logging.debug("Starting the transmission")
249         for i, signal in enumerate(signals):
250             logging.debug("Transmitting '%s' as '%s'", message[i], signal)
251             self.transmit_signal(signal)
252
253             # Transmit a signal separator only when strictly necessary:
254             #  - after the last symbol, we are going to transmit an EOM
255             #    anyway, and that will mark the end of the last symbol.
256             #  - between words the word separator act as a symbol separator
257             #    too.
258             if i != len(signals) - 1 and signals[i + 1] != "/" and signal != "/":
259                 self.transmit_symbol(" ")
260
261         self.transmit_symbol("EOM")
262
263         # Since the Morse signals are encoded in the distance between calls, an
264         # extra call is needed in order for receiver actually get the EOM and
265         # see that the transmission has terminated.
266         self.transmit_symbol(None)
267
268     def estimate_transmit_duration(self, message):
269         morsemessage = self.translator.text_to_morse(message)
270         signals = morsemessage.split()
271
272         logging.debug(signals)
273
274         transmitting_time = 0
275         for i, signal in enumerate(signals):
276             logging.debug("signal: %s", signal)
277
278             for symbol in signal:
279                 if symbol == ".":
280                     transmitting_time += self.dot_time
281                 elif symbol == "-":
282                     transmitting_time += self.dash_time
283                 elif symbol == "/":
284                     transmitting_time += self.wordspace_time
285
286                 if i != len(signals) - 1 and signals[i + 1] != "/" and symbol != "/":
287                     transmitting_time += self.signalspace_time
288
289         transmitting_time += self.eom_time
290
291         logging.debug("Estimated transmitting time: %.2f seconds",
292                       transmitting_time)
293
294
295 def test_send_receive():
296     logging.basicConfig(level=logging.DEBUG)
297     call_setup_time = 2
298     call_setup_uncertainty = 0.4
299     ring_time = 1
300     ring_uncertainty = 0.3
301
302     class DummyModem(object):
303         """Always receive a '.' and then a '/', which result in 'E '."""
304
305         def __init__(self):
306             self.ring_count = 0
307
308         def send_command(self, command):
309             pass
310
311         def get_response(self, response):
312             if self.ring_count % 2:
313                 # received a '.'
314                 time.sleep(call_setup_time + (ring_time + ring_uncertainty))
315             else:
316                 # received a '/'
317                 time.sleep(call_setup_time + (ring_time + ring_uncertainty) * 4)
318
319             self.ring_count += 1
320
321     xcv = CallDistanceTransceiver(DummyModem(), call_setup_time,
322                                   call_setup_uncertainty, ring_time,
323                                   ring_uncertainty)
324     xcv.receive_loop()
325
326
327 if __name__ == "__main__":
328     test_send_receive()