├── README.md ├── asterisk-dsp_recognize_coins.patch ├── invalid_number.sln ├── not_deposited.sln └── payphone.agi /README.md: -------------------------------------------------------------------------------- 1 | ## Payphone Project 2 | 3 | These are some notes from my project to install a working payphone in my home and configure it to make and receive calls through an Asterisk PBX. 4 | 5 | [![](https://i.imgur.com/gn6paGq.jpg)](https://i.imgur.com/gn6paGq.jpg) [![](https://i.imgur.com/QBEGEvc.jpg)](https://i.imgur.com/QBEGEvc.jpg) 6 | 7 | #### The Phone 8 | 9 | This is a Western Electric/AT&T 1D2 single-slot "dumb" payphone, popular in the 1980s. It came with a T-key but no upper or lower locks or keys. The handset was pretty gross and there were no upper or lower instruction cards. Shipping was a bit expensive since the phone weighs nearly 50 pounds. 10 | 11 | I purchased a new [handset](http://www.payphone.com/Standard-Handset.html), [mounting backplate](http://www.payphone.com/Mounting-Backplate.html), and [mounting studs](http://www.payphone.com/Brass-Mounting-Stud.html). I located some old instruction cards on eBay and upper and lower locks with key sets. 12 | 13 | The instruction cards install by unscrewing a very tiny allen bolt in the front of the housing to allow the card to slide up and then back down. 14 | 15 | The replacement handset was wired differently than the handset that came with the phone, which initially caused the mouthpiece not to transmit any audio. By touching a AA battery to the wires of the disconnected handset, I was able to figure out which wires went to the earpiece and which to the mouthpiece. The handset's red wire is connected to terminal 3, yellow to 4, green to 6, and black to 8. 16 | 17 | #### Mounting 18 | 19 | To mount the phone to my wall, I drilled 5 holes into a wall stud and secured the backing plate to the wall with 2 1/4" screws. The mounting studs hand-screwed into the back of the phone which allowed the phone to easily hang on the backplate, aligning the 12 holes in the back of the phone to the 1/4x20 threaded holes in the backplate. 20 | 21 | The phone is mounted at the recommended height of 63" from the floor to the top of the housing. 22 | 23 | #### Connectivity 24 | 25 | Back when all payphones were owned by the phone company, a POTS line provisioned for a payphone provided dialtone to the phone. When coins were inserted, the phone's totalizer sent simultaneous 1700+2200 Hz tones down the line for each 5 cents (nickel = one 66 ms tone, dime = two 66 ms tones with a 66 ms pause in between, quarter = five 33 ms tones with 33 ms pauses). 26 | 27 | The phone company's Automated Coin Toll Service (ACTS) would respond to the tones generated by the phone (or your [red box](https://en.wikipedia.org/wiki/Red_box_%28phreaking%29)) and allow the dialed phone number to connect for a certain amount of time. 28 | 29 | Newer "smart" (Elcotel-style) payphones are commonly owned by private companies (Customer Owned Coin Telephones - COCOTs) and don't require a specially-provisioned phone line. The phone has an embedded circuit board that has to be programmed with rate information, allowing the phone itself to determine whether the call is allowed to go through based on the number dialed and the amount of coins inserted. These phones would have to get reprogrammed every so often by a company that calls into the pay phone and uploads new rate information to it. (If you're looking to acquire a payphone for personal use, avoid these "smart" phones. An easy way to tell them apart from the outside is that "dumb" phones have the coin slot on the left side and "smart" phones usually have it on the right. Inside it's easy to tell; just look for a big modern-looking circuit board.) 30 | 31 | Since this phone has always worked with a normal POTS line, making it work now just requires hooking up the red and green wires of an RJ11 cable to the Ring and Tip terminals in the phone. It rings, dials, and otherwise functions just like a normal analog telephone. 32 | 33 | I connected the phone to a Grandstream HT701 SIP ATA and was able to make and receive calls through an Asterisk soft PBX, with dialtone coming from the ATA. I registered an inbound number through Twilio (appropriately enough, one ending in -2600) and routed it to the Asterisk server via SIP. 34 | 35 | Since this a payphone, after all, it should require depositing coins to make a call. Much older phones (like 3-slot rotary phones) required coins to be deposited before hearing a dial tone. These were mostly phased out by the 1970s and replaced with phones that provided a dial tone first, allowing emergency calls without depositing coins as well as depositing extra coins for long-distance calls. Using Asterisk, this payphone will be configured to provide a dial tone first and allow free emergency calls, but require 25 cents to call any other number. 36 | 37 | #### Sending Coin Tones to Asterisk 38 | 39 | Since the ATA only establishes audio between the phone and Asterisk after a recognizable pattern of digits has been dialed (and sent to Asterisk all at once in one INVITE request), Asterisk would not be able to respond to coin tones generated before dialing. 40 | 41 | Using the ATA's "Offhook Auto-Dial" feature, I configured it to automatically (silently) dial `0` as soon as the handset was picked up. An AGI script on the Asterisk server would then take over, generating its own dialtone and responding to any tones sent by the phone. 42 | 43 | *Relevant Asterisk `sip.conf` configuration for the ATA:* 44 | 45 | [2600] 46 | canreinvite=no 47 | callerid=<...> 48 | context=payphone-totalizer 49 | dtmfmode=inband 50 | host=dynamic 51 | nat=yes 52 | progressinband=no 53 | qualify=3000 54 | secret=... 55 | type=friend 56 | disallow=all 57 | allow=ulaw 58 | allow=alaw 59 | 60 | *Relevant Asterisk `extensions.conf` configuration to take over the call as soon as `0` is dialed by the ATA:* 61 | 62 | [payphone-totalizer] 63 | exten => 0,1,Answer 64 | exten => 0,2,AGI(payphone.agi) 65 | exten => 0,3,Hangup 66 | 67 | #### Recognizing Coin Tones 68 | 69 | Now that Asterisk is receiving the 1700+2200 Hz tones generated when coins are inserted, some code is needed to actually recognize them. Using Asterisk EAGI would allow a program to read the raw audio stream and analyze it for the proper tone frequencies using the [Goertzel algorithm](https://en.wikipedia.org/wiki/Goertzel_algorithm), but doing so would be pretty complicated. 70 | 71 | Since Asterisk is already doing in-band dual-tone multi-frequency (DTMF) detection for numerical digits, modifying it to recognize the coin tones is much less complicated. [This patch to Asterisk's DSP module](asterisk-dsp_recognize_coins.patch) recognizes the 1700+2200 Hz tone and turns it into a `$` digit, allowing any AGI or other code handling numeric digits to easily recognize coin tones. 72 | 73 | *A small AGI script to recognize and accumulate inserted coin amounts, printing it to the Asterisk console:* 74 | 75 | #!/usr/bin/perl 76 | 77 | use Asterisk::AGI; 78 | use strict; 79 | 80 | my $inserted = 0; 81 | my $dialed = ""; 82 | my $AGI = new Asterisk::AGI; 83 | 84 | # generate dialtone while we wait for coins 85 | $AGI->exec("Playtones", "dial"); 86 | 87 | while (1) { 88 | my $digit = $AGI->wait_for_digit(1000); 89 | next if ($digit <= 0); 90 | 91 | if ($digit == ord('$')) { 92 | $inserted += 5; 93 | $AGI->verbose("got 5-cent tone (now " . $inserted . " cents)", 5); 94 | } 95 | else { 96 | $dialed .= chr($digit); 97 | $AGI->verbose("dialed " . chr($digit) . " (now " . $dialed . ")", 5); 98 | } 99 | } 100 | 101 | Now that the amount of inserted coins can be recognized, along with any digits dialed, it's possible to make a full script that can refuse to connect calls and play an error message until a certain amount of money is inserted. Toll-free numbers, 911, etc. can be connected before any coins are collected. 102 | 103 | My routing script is [under development here](payphone.agi). 104 | 105 | #### TODO 106 | 107 | - Make the coin hopper queue up coins when inserted rather than immediately dropping them into the coin box, to allow for refunding. This [requires sending high voltage](http://web.archive.org/web/20201127205016/http://oldphoneguy.net/images/MPPwk.pdf) ([2](http://web.archive.org/web/20171014015056/http://atcaonline.com/controller.html)) to the coin relay, and would have to be done out-of-band. 108 | -------------------------------------------------------------------------------- /asterisk-dsp_recognize_coins.patch: -------------------------------------------------------------------------------- 1 | # initially adapted from https://github.com/saizai/wumpusphone/blob/master/asterisk-1.8.0.patch#L251 2 | # less invastive version from https://github.com/hharte/1dcoinctrl/blob/master/asterisk/main/dsp.c-patch 3 | # updated for asterisk 20.2.0 4 | 5 | --- main/dsp.c.orig Mon Apr 10 11:39:45 2023 6 | +++ main/dsp.c Mon Apr 10 11:43:18 2023 7 | @@ -165,7 +165,7 @@ 8 | 9 | #define MAX_DTMF_DIGITS 128 10 | 11 | -#define DTMF_MATRIX_SIZE 4 12 | +#define DTMF_MATRIX_SIZE 5 13 | 14 | /* Basic DTMF (AT&T) specs: 15 | * 16 | @@ -317,15 +317,15 @@ 17 | } digit_detect_state_t; 18 | 19 | static const float dtmf_row[] = { 20 | - 697.0, 770.0, 852.0, 941.0 21 | + 697.0, 770.0, 852.0, 941.0, 1700.0, 22 | }; 23 | static const float dtmf_col[] = { 24 | - 1209.0, 1336.0, 1477.0, 1633.0 25 | + 1209.0, 1336.0, 1477.0, 1633.0, 2200.0, 26 | }; 27 | static const float mf_tones[] = { 28 | 700.0, 900.0, 1100.0, 1300.0, 1500.0, 1700.0 29 | }; 30 | -static const char dtmf_positions[] = "123A" "456B" "789C" "*0#D"; 31 | +static const char dtmf_positions[] = "123A-" "456B-" "789C-" "*0#D-" "----$"; 32 | static const char bell_mf_positions[] = "1247C-358A--69*---0B----#"; 33 | static int thresholds[THRESHOLD_MAX]; 34 | static float dtmf_normal_twist; /* AT&T = 8dB */ 35 | @@ -734,6 +734,8 @@ 36 | goertzel_sample(s->td.dtmf.col_out + 2, samp); 37 | goertzel_sample(s->td.dtmf.row_out + 3, samp); 38 | goertzel_sample(s->td.dtmf.col_out + 3, samp); 39 | + goertzel_sample(s->td.dtmf.row_out + 4, samp); 40 | + goertzel_sample(s->td.dtmf.col_out + 4, samp); 41 | /* go up to DTMF_MATRIX_SIZE - 1 */ 42 | } 43 | s->td.dtmf.current_sample += (limit - sample); 44 | @@ -776,9 +778,9 @@ 45 | } 46 | /* ... and fraction of total energy test */ 47 | if (i >= DTMF_MATRIX_SIZE && 48 | - (row_energy[best_row] + col_energy[best_col]) > DTMF_TO_TOTAL_ENERGY * s->td.dtmf.energy) { 49 | + (row_energy[best_row] + col_energy[best_col]) > (DTMF_TO_TOTAL_ENERGY / (relax ? 2 : 1)) * s->td.dtmf.energy) { 50 | /* Got a hit */ 51 | - hit = dtmf_positions[(best_row << 2) + best_col]; 52 | + hit = dtmf_positions[(best_row * DTMF_MATRIX_SIZE) + best_col]; 53 | ast_debug(10, "DTMF hit '%c'\n", hit); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /invalid_number.sln: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jcs/payphone/fe0e8bb146c3dc2813af114e505c59707131f85a/invalid_number.sln -------------------------------------------------------------------------------- /not_deposited.sln: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jcs/payphone/fe0e8bb146c3dc2813af114e505c59707131f85a/not_deposited.sln -------------------------------------------------------------------------------- /payphone.agi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use Asterisk::AGI; 4 | use Cwd "realpath"; 5 | use File::Basename; 6 | use Time::HiRes; 7 | use strict; 8 | 9 | use constant { 10 | # where we live 11 | SOUND_DIR => dirname(realpath($0)), 12 | 13 | # outbound context we'll send calls to (since our current context should 14 | # only allow dialing 0 from the ATA) 15 | OUTBOUND_CONTEXT => "payphone-outbound", 16 | 17 | # timeout in seconds to wait to get a number or a coin 18 | TIMEOUT => 30, 19 | 20 | # amount required to make a call 21 | CALL_CENTS => 25, 22 | 23 | # numbers allowed to be dialed without requiring coins 24 | FREE_CALLS => qr/^(1?(8[02345678]{2})[0-9]{7}|911)/, 25 | 26 | # valid numbers which will immediately dial instead of timing out 27 | VALID_NUMBER => qr/^(1?[2-8][0-9]{9}|911)/, 28 | }; 29 | 30 | my $AGI = new Asterisk::AGI; 31 | 32 | my $last_activity = time(); 33 | my @last_coins; 34 | my $inserted = 0; 35 | my $dialed = ""; 36 | 37 | # generate dialtone while we wait for coins 38 | $AGI->exec("Playtones", "dial"); 39 | my $dialtone = 1; 40 | 41 | while (1) { 42 | my $dig = $AGI->wait_for_digit(1000); 43 | 44 | if ($dig >= 1) { 45 | if ($dig == ord('$')) { 46 | $AGI->verbose("got coin tone at " . Time::HiRes::time, 4); 47 | 48 | # shift out old coins 49 | my @new_coins; 50 | for (my $x = 0; $x <= $#last_coins; $x++) { 51 | if (Time::HiRes::time - $last_coins[$x] < 0.5) { 52 | push(@new_coins, $last_coins[$x]); 53 | } 54 | } 55 | @last_coins = @new_coins; 56 | 57 | push(@last_coins, Time::HiRes::time); 58 | 59 | # if we've heard 3 coin tones in close proximity, always count it 60 | # as 5 since we might not hear the other 2 61 | if ($#last_coins == 2) { 62 | $inserted += 15; 63 | $AGI->verbose("got 5-cent tone, counting as 15 cents (now " 64 | . $inserted . " cents)", 4); 65 | # provide some audible feedback that it's ok to make a call 66 | $AGI->exec("Playtones", "stutter"); 67 | } 68 | elsif ($#last_coins > 2) { 69 | $AGI->verbose("ignoring 4th or 5th 5-cent tone of 25-cents " 70 | . "(still " . $inserted . " cents)", 4); 71 | } 72 | else { 73 | $inserted += 5; 74 | $AGI->verbose("got 5-cent tone (now " . $inserted . " cents)", 75 | 4); 76 | } 77 | } 78 | else { 79 | if ($dialtone) { 80 | $AGI->exec("StopPlaytones", "dial"); 81 | $dialtone = 0; 82 | } 83 | 84 | $dialed .= chr($dig); 85 | $AGI->verbose("dialed " . chr($dig) . " (now " . $dialed . ")", 4); 86 | } 87 | 88 | $last_activity = time(); 89 | } 90 | 91 | my $elapsed = time() - $last_activity; 92 | 93 | if ($dialed =~ FREE_CALLS) { 94 | $AGI->verbose("connecting to " . $dialed . " (free call)", 1); 95 | dial($dialed); 96 | exit; 97 | } 98 | elsif ($dialed =~ VALID_NUMBER) { 99 | if ($inserted < CALL_CENTS) { 100 | $AGI->verbose("need " . CALL_CENTS . " cents to dial " . $dialed 101 | . ", have " . $inserted, 4); 102 | 103 | $AGI->stream_file(SOUND_DIR . "/not_deposited", "0"); 104 | } 105 | else { 106 | $AGI->verbose("connecting to " . $dialed . " (inserted " 107 | . $inserted . " cents)", 1); 108 | dial($dialed); 109 | } 110 | exit; 111 | } 112 | elsif ($elapsed >= TIMEOUT) { 113 | $AGI->verbose("timed out waiting for coins or digits", 1); 114 | 115 | if ($dialed ne "") { 116 | $AGI->stream_file(SOUND_DIR . "/invalid_number", "0"); 117 | } 118 | 119 | exit; 120 | } 121 | } 122 | 123 | sub dial { 124 | my ($number) = @_; 125 | 126 | $AGI->set_context(OUTBOUND_CONTEXT); 127 | $AGI->set_extension($dialed); 128 | $AGI->exec("Goto", "1"); 129 | } 130 | --------------------------------------------------------------------------------