Skip to main content

A Hardware Satellite Modem

The other day I found myself getting up unusually early to attend a ham radio market. 50% off everything really makes me do the most unlikely things. the haul ended up being 3 “METEOSAT Wefax” decoders and a Cisco 2950.

Now, 2 of those decoders ended up being broken; in fact one of them was completely empty. One of them though, a GRUNDIG MST 100 (Manual included!), was still fully functional it seems. It turned on and showed what seems to have been a corrupted image, probably due to the volatile memory used for storing pictures.

The modem has an RF input for the downconverted bands of the two METEOSAT channels (137.2-137.8 MHz, 140.7-141.3 MHz) as well as an audio input and output for recording/replaying images from tape. Images are directly displayed from either an AV or CVBS port.

I had an old, very long recording of METEOSAT 7 wefax I had found somewhere a long time ago and played that into the tape input of the modem and it worked perfectly:

Now, as readers may or may not know, this format of WEFAX is not used anywhere in the world anymore. The closest relatives of this still in use would be NOAA-APT on Noaa 15 and 19 and HF-Weatherfax for marine use, though both have some differences that make them incompatible with this modem.

So, with some help from our AI overlords I made an encoder that turns any 800×800 image into a METEOSAT Wefax transmission (without the final FM modulation). After a bunch of bugfixes, here’s the result

Apart from the image being mirrored, pretty good I’d say. This could enable using this modem as some vintage looking display. if you attached a microphone to it, it could allow for image transmission through soundwaves maybe. Here’s the audio (volume warning!):

And the code to generate this in python:

import numpy as np
import wave
from PIL import Image

# Constants
LINE_DURATION = 0.25  # 250 ms
SYNC_PIXELS = 28       # 14 white + 14 black
IMAGE_WIDTH = 800
SAMPLE_RATE = 11025    # Audio sample rate in Hz
SUBCARRIER_FREQ = 2400 # Hz
MOD_DEPTH = 0.8        # 80% modulation

def generate_sync_line():
    """Generates a sync pulse: alternating 2 white and 2 black pixels, 7 times, with 2 leading and 10 trailing white pixels."""
    pattern = np.array([255] * 2 + [0] * 2, dtype=np.uint8)
    sync_core = np.tile(pattern, 7)
    sync_line = np.concatenate(([255] * 2, sync_core, [255] * 10))
    return sync_line

def amplitude_modulate(pixels, sample_rate, duration, freq, mod_depth):
    """AM modulate a signal based on pixel brightness."""
    num_samples = int(sample_rate * duration)
    t = np.linspace(0, duration, num_samples, endpoint=False)

    signal = np.interp(
        np.linspace(0, len(pixels), num_samples, endpoint=False),
        np.arange(len(pixels)),
        pixels / 255.0
    )
    carrier = np.sin(2 * np.pi * freq * t)
    modulated = (1 + mod_depth * (signal - 0.5) * 2) * carrier
    return modulated

def encode_ascii_header(text):
    """Encodes 50-character ASCII text into a WEFAX header line."""
    text = text.ljust(50)[:50]  # Ensure exactly 50 characters
    bits = ''.join(f'{ord(c):08b}' for c in text)
    pixels = [255 if bit == '1' else 0 for bit in bits for _ in range(2)]  # 2 pixels per bit
    return np.array(pixels, dtype=np.uint8)

def encode_image_to_wefax(image_path, output_wave, header_text="MET5 IR 15 MAY 03 1215 VIS01"):
    image = Image.open(image_path).convert('L')
    image = image.resize((IMAGE_WIDTH, IMAGE_WIDTH))
    img_array = np.array(image, dtype=np.uint8)

    audio = []

    # Add 3s of alternating black/white pixels at 300Hz modulating the carrier
    num_cycles = int(3 * 300)  # 300 Hz for 3 seconds
    samples_per_half_cycle = int(SAMPLE_RATE / (300 * 2))
    pattern = np.array([0, 255] * num_cycles, dtype=np.uint8)
    pattern = np.repeat(pattern, samples_per_half_cycle // 2)
    start_signal = amplitude_modulate(pattern, SAMPLE_RATE, 3, SUBCARRIER_FREQ, MOD_DEPTH)
    audio.extend(start_signal)

    # Add 5s phasing signal (each 250ms: 12.5ms black + 237.5ms white)
    for _ in range(20):
        black_duration = int(IMAGE_WIDTH * (12.5 / 250.0))
        white_duration = IMAGE_WIDTH - black_duration
        blk_black = np.zeros(black_duration, dtype=np.uint8)
        blk_white = np.ones(white_duration, dtype=np.uint8) * 255
        blk = np.concatenate((blk_black, blk_white))
        phasing = amplitude_modulate(blk, SAMPLE_RATE, LINE_DURATION, SUBCARRIER_FREQ, MOD_DEPTH)
        audio.extend(phasing)

    # Encode 2 header lines
    header_pixels = encode_ascii_header(header_text)
    for _ in range(2):
        sync = generate_sync_line()
        line = np.concatenate((sync, header_pixels))
        modulated = amplitude_modulate(line, SAMPLE_RATE, LINE_DURATION, SUBCARRIER_FREQ, MOD_DEPTH)
        audio.extend(modulated)

    # Encode image lines in reverse (bottom-up)
    for row in reversed(img_array):
        sync = generate_sync_line()
        line = np.concatenate((sync, row))
        modulated = amplitude_modulate(line, SAMPLE_RATE, LINE_DURATION, SUBCARRIER_FREQ, MOD_DEPTH)
        audio.extend(modulated)

    # Add 5s of alternating black/white pixels at 450Hz modulating the carrier (end tone)
    num_cycles = int(5 * 450)  # 450 Hz for 5 seconds
    samples_per_half_cycle = int(SAMPLE_RATE / (450 * 2))
    pattern = np.array([0, 255] * num_cycles, dtype=np.uint8)
    pattern = np.repeat(pattern, samples_per_half_cycle // 2)
    end_signal = amplitude_modulate(pattern, SAMPLE_RATE, 5, SUBCARRIER_FREQ, MOD_DEPTH)
    audio.extend(end_signal)

    audio_np = np.array(audio)
    audio_pcm = np.int16(audio_np / np.max(np.abs(audio_np)) * 32767)
    with wave.open(output_wave, 'w') as f:
        f.setnchannels(1)
        f.setsampwidth(2)
        f.setframerate(SAMPLE_RATE)
        f.writeframes(audio_pcm.tobytes())

encode_image_to_wefax("example.png", "output.wav", header_text="MET5 IR 15 MAY 03 1215 VIS01")

One thought on “A Hardware Satellite Modem

  • Avatar Rom
    Rom says:

    Wow this is so cool mr. Cho

Write a reply or comment

Your email address will not be published. Required fields are marked *