The Rotary Club

For my niece’s birthday this year, I decided to give her the gift every 6-year-old secretly dreams of: an old rotary phone!

This wonderful phone came from Facebook Marketplace, the new craigslist where I spend an embarrassing amount of my free time these days. Seriously. It’s incredible. I drove down to the Jersey suburbs of Philly one Saturday morning and a lovely dude handed off the phone and recommended I try some rolls from the world-class bakery across the street. Then he invited me into his garage and convinced me to take a dusty oscilloscope as well. A great day.

My niece is fancy and loves the finer things. She also loves FaceTiming her family on her iPad and chatting for hours. This phone was SCREAMING for her. I had no choice.

Plus, she – like me – is a born-and-bred Brooklynite. How lovely to be able to bestow upon her a 718 number, her birthright!

I guess phones are solved problems, but adapting this phone for my niece seemed more complicated than just hooking it up as some landline or to some VoIP adapter. For one thing, it has a rotary dial, which sends out pulses that get converted to numbers. It’s fundamentally different from how modern touch-tone phones work, and is incompatible with lots of modern VoIP hardware. (Though they sometimes work. Fifteen-ish years ago, I was so excited to learn that then-contemporary Vonage adapters still supported my grandmother’s old rotary phones!)

Also, well, she’s six. I spent some time testing the phone with standard ten-digit numbers and let me tell you: rotary phones were NOT meant for dialing extraneous numbers. It takes forever! I was immediately reminded that NYC’s 212 area code was selected for pure speed-of-execution on a rotary dial. How would something so labor-intensive compare with the beauty and simplicity of using an iPad? I was skeptical, and thought it might be better if I could customize the interaction model. Plus, given that she’s six and unlimited phone access seems suboptimal, I wanted to implement some simple filters and controls.

ALSO, I just wanted to take this thing apart. As a kid, my first memory of tinkering took place while visiting my aunt’s house on Long Island. In her kitchen lived a beautiful antique wooden wall phone. The kind with a separate cup and mouthpiece, rather than a single handset containing both the speaker and mic. I took it apart on her kitchen floor, but of course I had no idea how to get it back together once I was finished, and got into all sorts of trouble. Wouldn’t be the last time.

So, back to the phone. I started by taking it apart (of course) and trying to figure out the individual mechanisms.

It turns out that a rotary phone is basically just a series of switches, along with a speaker and a mic. I was able to quickly hook the handset up to a USB audio DAC, and was delighted to learn that the original speaker and carbon mic worked just fine — and gave me wonderfully scratchy lofi analog audio. (Eventually I stripped the DAC down to the bare PCB and soldered wires directly between the DAC, the speaker/mic, and Pi’s USB test pads, mostly so all the hardware would fit into the box.)

If you’ve never taken a video call using an old-timey handset… you should! (My arm got tired, though, not gonna lie.)

Then there’s the hook switch. That one’s easy. In this phone, the hardware’s mounted in a box beneath the marble tabletop, so the phone cradle just sits atop a simple spring-loaded button. I could simply connect that to a Pi GPIO pin to detect whether the handset was on its cradle or had been lifted.

Then, of course, the rotary dial. All this thing does is convert the number you’re dialing into a series of rapid pulses, much like the mechanism inside the jukebox I was working on at the same time.

Seriously, this thing has the mechanism of a Swiss clock inside. Basically it contains multiple sets of contacts that open and close at various points, e.g. when you start twisting the dial, when the dial is released and the first pulse is sent, and when the pulse train has finished. In my circuit, I didn’t even need all this complexity — I just needed one pair of contacts, since I didn’t need my signals sent in a way that could be properly interpreted by a standard analog landline system. Fortunately, the world of telephony is very standardized, so it was just a matter of learning that each digit is sent at about 10 pulses per second in North American systems. (Well, that and using a multimeter to determine which of these contacts flips on/off each time a pulse gets sent.)

Here’s my (LLM-assisted) code snippet for rotary dialing. It’s in a separate thread, so the script’s overall timing won’t interfere with properly decoding the dialer’s pulses.

class RotaryDial:
    """Handles rotary dial input for outbound calls, running in its own thread."""
    
    def __init__(self, pin, digit_gap=0.15, debounce=0.01):
        self.pin = pin
        self.digit_gap = digit_gap    # Gap between digits (seconds)
        self.debounce = debounce      # Debounce delay
        
        self.pulse_count = 0
        self.pulse_start = 0
        self.counting_active = False
        
        # Setup GPIO for the dialer input
        GPIO.setup(self.pin, GPIO.IN, pull_up_down=GPIO.PUD_UP)
        self.last_state = GPIO.input(self.pin)
        
        # Create a queue to store detected digits
        self.digit_queue = queue.Queue()
        
        # Thread running flag
        self.running = False
        self.thread = None

    def count_pulses(self):
        """
        Checks the state of the rotary dial GPIO. Returns a digit when a complete digit is detected,
        otherwise returns None.
        """
        current_state = GPIO.input(self.pin)
        current_time = time.time()
        
        # Detect initial release (finger stopper released)
        if not self.counting_active and current_state == 1 and self.last_state == 0:
            self.counting_active = True
            self.pulse_count = 0
            self.pulse_start = current_time
        elif self.counting_active:
            # Count when the dial goes back to 0 from 1
            if current_state == 0 and self.last_state == 1:
                self.pulse_count += 1
                self.pulse_start = current_time
            # If gap between pulses indicates the end of the digit
            elif current_time - self.pulse_start > self.digit_gap and self.pulse_count > 0:
                digit = 0 if self.pulse_count == 10 else self.pulse_count
                self.pulse_count = 0
                self.counting_active = False
                return digit
        
        self.last_state = current_state
        return None

    def _run(self):
        """Thread target: repeatedly check for pulses and enqueue any digits detected."""
        self.running = True
        while self.running:
            digit = self.count_pulses()
            if digit is not None:
                # Enqueue the detected digit for later processing by the main thread.
                self.digit_queue.put(digit)
            # Use a short sleep to sample as often as possible without hogging CPU.
            time.sleep(0.005)

    def start_thread(self):
        """Start the thread to run pulse detection."""
        if not self.thread or not self.thread.is_alive():
            self.thread = threading.Thread(target=self._run)
            self.thread.daemon = True
            self.thread.start()

    def stop_thread(self):
        """Stop the thread gracefully."""
        self.running = False
        if self.thread:
            self.thread.join()

(Once I had the rotary dialer working, I made a brief fun pivot — using the dialer, gpt-4o and the Spotify API to recommend and stream songs through the headset based on numbers dialed onto the phone.)

Tap for Instagram reel, which I apparently can’t embed inline!

After that, the bell. I really wanted to utilize the built-in bell, which in a typical copper-line phone system is powered by ~48vac. (If you’ve ever worked with a POTS phone system, you may have experienced this voltage as a mild shock when a call comes in!) For my system, I wound up using an Adafruit DRV8871 DC motor. It has two motor inputs, powered by 20vdc coming from USB-C (thanks to a USB-C power delivery board) which was enough power to ring the bell. The logic is controlled by 3.3v coming from the Pi. When a call comes in, the bell is rung by simulating alternating current via the motor bridge! We basically send the bell’s hammer back and forth at ~20Hz.

class PhoneRinger:
    """Controls the telephone bell ringer"""
    
    def __init__(self, in1_pin=23, in2_pin=24):
        # Setup GPIO pins
        self.in1_pin = in1_pin
        self.in2_pin = in2_pin
        self.running = False
        self.ring_thread = None
        
        GPIO.setup(self.in1_pin, GPIO.OUT)
        GPIO.setup(self.in2_pin, GPIO.OUT)
        
        # Initialize pins to LOW
        GPIO.output(self.in1_pin, GPIO.LOW)
        GPIO.output(self.in2_pin, GPIO.LOW)
    
    def _oscillate(self):
        """Oscillates the bell hammer at approximately 20Hz"""
        while self.running:
            # Forward direction
            GPIO.output(self.in1_pin, GPIO.HIGH)
            GPIO.output(self.in2_pin, GPIO.LOW)
            time.sleep(0.025)  # 25ms for ~20Hz
            
            # Reverse direction
            GPIO.output(self.in1_pin, GPIO.LOW)
            GPIO.output(self.in2_pin, GPIO.HIGH)
            time.sleep(0.025)
    
    def start_ringing(self):
        """Starts the ringing pattern"""
        # Only start if not already ringing
        if self.ring_thread is not None and self.ring_thread.is_alive():
            return
            
        self.running = True
        self.ring_thread = threading.Thread(target=self._oscillate)
        self.ring_thread.daemon = True
        self.ring_thread.start()
    
    def stop_ringing(self):
        """Stops the ringing pattern"""
        self.running = False
        
        # Wait for thread to finish if it exists and is alive
        if self.ring_thread is not None and self.ring_thread.is_alive():
            self.ring_thread.join(timeout=0.5)
            self.ring_thread = None
        
        # Set both pins low when stopped
        GPIO.output(self.in1_pin, GPIO.LOW)
        GPIO.output(self.in2_pin, GPIO.LOW)
    
    def cleanup(self):
        """Cleanup GPIO resources"""
        self.stop_ringing()

With software sorted, all the hardware wound up shoved ungraciously into the box that had been mounted under the tabletop:

At this point, maybe you’re curious about how the phone actually works.

I bought a carefully-curated-to-be-memorable 718 number from NumberBarn and ported it to voip.ms, my longtime VoIP carrier. Then… I ran into a brick wall looking for Pi-compatible VoIP clients and let this project gather dust for a couple of months!

Seriously. Why is it so hard to find a drop-in library for VoIP support on the Pi. There are a lot of options (pjsip being perhaps the most well-known), but many of the repos are outdated or the documentation is sparse. In many cases you have to build the libraries from source, which introduces all sorts of extra complexity. Plus many of these projects have complex or non-working Python wrappers.

Eventually I discovered linphonec, the command-line version of Linphone. Installation instructions and documentation are a bit sparse so it took me a while to get going, but I’m pretty sure a good old-fashioned sudo apt install linphone-cli ultimately did the trick.

Linphonec works great. I found these instructions most useful, but basically you start by running linphonec via the CLI, then running proxy add to set your VoIP server settings. You’re first prompted to Enter proxy sip address, which is simply your VoIP server. Then you’re prompted to provide Your identity for this proxy, which in my case was my voip.ms subaccount username @ my voip.ms server. I didn’t have to enter any other optional settings, I just tabbed through the defaults. Settings are ultimately saved in ~/.linphonerc.

Once your connection info is saved, double-check your audio settings by running soundcard list within linphonec. In ~/.linphonerc, I saved a custom ringback tone that sounded more like a landline, and specified my ALSA speaker and mic.

[sound]
#remote_ring=/usr/share/sounds/linphone/ringback.wav
remote_ring=/home/pi/US_ringback_tone.wav
playback_gain_db=0.000000
mic_gain_db=0.000000
playback_dev_id=ALSA: USB Audio Device
capture_dev_id=ALSA: USB Audio Device

After that, it’s just a matter of using some plain-language prompts to send and receive calls. For instance, if you get an incoming call, you can answer it in linphonec by typing answer and hitting enter. To make a call, try something like call 7185551212. To end a call, try terminate.

Importantly for my purposes, I learned that linphonec can be invoked with a pipe command, which creates a scriptable socket. This was great because I needed to trigger certain things (like my hardware bell) based on call status.

There appear to be Python wrappers for linphonec, but this wound up making me way too confused. It seemed to require me to download the entire Linphone SDK, plus nothing ever compiled right. Once I gave up on that, things went much more smoothly. After getting all the functionality working manually via the cli, I simply lifted-and-shifted to Python, controlling linphonec’s shell interface and monitoring its status updates using regex. I can track status updates by filtering for specific keywords in the status updates provided by linphonec, whether it’s a new incoming call or a call has been ended or an outgoing call that has been answered or an incoming call that was not answered.

Once I got this working, I added some custom improvements for my particular use case. For instance, I’ve added some hardcoded phone number shortcuts. This way, my niece doesn’t have to remember her recipients’ full phone numbers; she can just spell their names on the rotary dialer. I also set some quiet hours so the bell will never ring overnight. And I used a combination of voip.ms address book filtering and some hard-coded logic in Python to make sure that inbound and outbound calls are rejected unless they’re from that small address book of known and trusted family members. (This list will get updated and eventually become irrelevant as she gets older, and I’m sure I’ll improve the logic to make it easier to update. Hopefully she’s still using this thing by then!)


Of course you can’t have a gilded phone without gilded accessories. I bought gold pens for the pen holder, as well as a vintage gold address book on eBay. I even got a fancy gold braided USB-C cable and even a gold USB-C wall adapter to match (this last one was somehow the hardest thing to source!).

I was finally able to drop it off yesterday. My niece loved it! (Though tbh she definitely loved the gold pens a smidge more.)

The physical motion of dialing a rotary phone is definitely going to take some practice and patience. And I took for granted the intuitiveness of certain actions like simply placing the phone back on the cradle to hang up (rather than pressing a big red button) or picking up the phone and listening for the dialtone before dialing. But she was thrilled to call (and receive calls from) her grandparents and other family members — even if they were only in the next room!

Maybe now we’ll just move further back in time and I can see if she’s willing to learn Morse code for a telegraph…

Sample code can be found in this github repo.