Live Image Streaming Module
Live image streaming from the Seestar’s binary socket protocol.
The Seestar exposes two independent image streams over raw TCP:
Port 4800 — Telephoto camera (stacked deep-sky images)
Port 4804 — Wide-angle camera
Each stream uses a custom binary framing protocol: a 34-byte header
followed by an image payload. The payload is a streaming ZIP archive
containing deflate-compressed raw pixel data (16-bit RGB, 48 bpp).
The connection is kept alive with a JSON-RPC test_connection
heartbeat every 4 seconds.
Note
Reverse-engineered from the Seestar Android app v3.0.2 (decompiled with JADX) and confirmed via live capture on 2026-02-27.
Quick start
Grab and save (uses auto-discovered IP):
>>> from seestarpy import stream
>>> stream.get_live_image(filename="latest.png")
The saved PNG is auto-stretched to reveal faint nebulosity.
One-shot grab (raw data):
>>> header, payload = stream.get_live_image()
>>> pixels = stream.decode_payload(payload, header)
>>> pixels.shape # (height, width, 3) uint16
(3840, 2160, 3)
Save as FITS:
>>> stream.get_live_image(filename="latest.fits")
Live display (blocks until window closed):
>>> stream.start_stream(with_matplotlib=True)
Continuous streaming with custom callback:
>>> def on_frame(header, data):
... print(f"Frame {header['image_id']}: {header['width']}x{header['height']}")
>>> session = stream.start_stream(on_image=on_frame)
>>> # ... later ...
>>> stream.stop_stream(session)
RTSP video URL (for use with ffplay / OpenCV):
>>> stream.build_rtsp_url()
'rtsp://192.168.1.246:4554/stream'
- seestarpy.stream.HEADER_SIZE = 34
Size of the binary frame header in bytes.
- seestarpy.stream.HEARTBEAT_INTERVAL = 4
Seconds between
test_connectionheartbeats (matches the app).
- seestarpy.stream.IMAGE_PORT = 4800
Default port for the telephoto camera image stream.
- seestarpy.stream.IMAGE_PORT_WIDE = 4804
Port for the wide-angle camera image stream.
- seestarpy.stream.IMG_TYPE_PREVIEW = 1
Single unstacked preview frame.
- seestarpy.stream.IMG_TYPE_STACKED = 5
Progressively stacked deep-sky image.
- seestarpy.stream.MAGIC_NUMBER = 963
Magic number (963) that marks the start of every image frame.
- seestarpy.stream.RTSP_PORT = 4554
Default RTSP port for the telephoto live video feed.
- seestarpy.stream.RTSP_PORT_WIDE = 4555
RTSP port for the wide-angle live video feed.
- class seestarpy.stream.StreamSession(ip, port, sock, on_image)
Bases:
objectManages a persistent image streaming connection.
Created by
start_stream(). Callstop()(or pass tostop_stream()) to cleanly shut down.- ip
Seestar IP address.
- Type:
str
- port
TCP port in use.
- Type:
int
- is_running
Truewhile the stream is active.- Type:
bool
- show()
Open a live matplotlib window showing streamed frames.
This method blocks the main thread (runs the matplotlib event loop). Closing the window stops the stream.
The display auto-stretches each frame with
_auto_stretch()so faint nebulosity is visible. Frame metadata is shown in the window title.Note
Requires
matplotlib. Must be called from the main thread (which is normal for interactive / script usage).Examples
>>> session = stream.start_stream() >>> session.show() # blocks until window is closed
Or as a one-liner via
start_stream():>>> stream.start_stream(with_matplotlib=True)
- stop()
Stop streaming and close the connection.
Safe to call multiple times.
- seestarpy.stream.build_rtsp_url(ip=None, port=4554)
Build an RTSP URL for the Seestar’s live video feed.
This returns the URL used by the official app to connect to the Seestar’s RTSP server. You can open it with
ffplay, VLC, or OpenCV’sVideoCapture.- Parameters:
ip (str, optional) – Seestar IP address. Defaults to
connection.DEFAULT_IP.port (int, optional) – RTSP port. Default is
RTSP_PORT(4554) for the telephoto camera. UseRTSP_PORT_WIDE(4555) for the wide-angle camera.
- Returns:
RTSP URL, e.g.
"rtsp://192.168.1.246:4554/stream".- Return type:
str
Examples
>>> from seestarpy import stream >>> stream.build_rtsp_url() 'rtsp://192.168.1.246:4554/stream' >>> stream.build_rtsp_url(port=stream.RTSP_PORT_WIDE) 'rtsp://192.168.1.246:4555/stream'
- seestarpy.stream.decode_payload(payload, header)
Decode a raw frame payload into a NumPy pixel array.
Handles both payload formats the Seestar sends:
Stacked frames (img_type=5, data_type=3): ZIP-compressed 16-bit RGB →
(H, W, 3)uint16.Preview frames (img_type=1, data_type=2): Raw (uncompressed) 16-bit Bayer →
(H, W)uint16.
- Parameters:
payload (bytes) – Raw payload bytes from
get_live_image()or the streaming callback.header (dict) – Parsed frame header (from
parse_header()), used for width and height.
- Returns:
(height, width, 3)uint16 for stacked frames, or(height, width)uint16 for preview (Bayer) frames.- Return type:
numpy.ndarray
- Raises:
ValueError – If the payload cannot be decoded or dimensions don’t match.
ImportError – If NumPy is not installed.
- seestarpy.stream.get_live_image(ip=None, port=4800, method='get_stacked_img', filename=None, *, max_ack_frames=10, fallback=True, read_timeout=30.0)
Connect, grab a single image frame, and disconnect.
This is the simplest way to get the current live-stacked image from the Seestar. It opens a TCP connection, requests a frame, reads past any zero-dimension ack/keepalive frames the Seestar interleaves, and closes the socket.
If filename is given the image is also saved to disk. PNG and JPEG files get an automatic stretch to bring out faint nebulosity. Use a
.fitsextension to save the full 16-bit data losslessly (requiresastropy).- Parameters:
ip (str, optional) – Seestar IP address. Defaults to
connection.DEFAULT_IP(auto-discovered via mDNS).port (int, optional) – Image stream port. Default is
IMAGE_PORT(4800).method (str, optional) –
JSON-RPC method to request a frame. One of:
"get_stacked_img"(default) — latest stacked image."get_current_img"— current single frame / preview.
filename (str, optional) – If provided, save the image to this path. The extension determines the format (
.png,.jpg,.fits, etc.).max_ack_frames (int, optional) – Maximum number of zero-dimension ack/keepalive frames to skip past while waiting for a real image frame (default
10). The Seestar typically sends one or two acks before the actual image, so 10 is generous.fallback (bool, optional) – If
True(default) and method is"get_stacked_img"but no stacked image is ready (e.g. the scope just woke), retry once with"get_current_img"on the same socket so callers always get something renderable when one is available.read_timeout (float, optional) – Per-recv socket timeout in seconds (default
30.0). Bounds how long we’ll wait for any single frame; raisessocket.timeoutif the Seestar goes silent. The default is sized to comfortably cover the 8MP S30 Pro frames over Wi-Fi.
- Returns:
(header, payload)where header is the parsed 34-byte header dict (with non-zerowidthandheight) and payload is the raw (compressed) image payload. Pass both todecode_payload()to get a NumPy array, or tosave_image()to write a file.- Return type:
tuple[dict, bytes]
- Raises:
RuntimeError – If no image-bearing frame is received within the per-method
max_ack_framesbudget (and, if fallback is True, the fallback method also produced nothing).
Examples
>>> from seestarpy import stream >>> stream.get_live_image(filename="latest.png") # auto-stretched >>> stream.get_live_image(filename="raw.fits") # 16-bit FITS >>> header, payload = stream.get_live_image() >>> pixels = stream.decode_payload(payload, header)
- seestarpy.stream.parse_header(buf)
Parse a 34-byte Seestar image frame header.
- Parameters:
buf (bytes) – Exactly 34 bytes read from the image socket.
- Returns:
Parsed header fields:
magic(int): Must equalMAGIC_NUMBER(0x03C3).version(int): Protocol version.length(int): Payload size in bytes.is_big_endian(int): Endianness flag.img_type(int): 1 = preview, 5 = stacked.data_type(int): Data format identifier.frame_id(int): Frame ID.width(int): Image width in pixels.height(int): Image height in pixels.hfd_x(int): Half-flux-diameter X (reserved).hfd_y(int): Half-flux-diameter Y (reserved).hfd(int): Half-flux-diameter value (reserved).can_debayer(int): Bayer pattern flag (reserved).image_id(int): Sequential frame counter.
- Return type:
dict
- Raises:
ValueError – If buf is not exactly 34 bytes.
- seestarpy.stream.save_image(payload, header, path, stretch=True)
Decode a frame payload and save it as an image file.
For
.fitsfiles, the full 16-bit RGB data is saved as a FITS image (requiresastropy). For all other formats (.png,.jpg,.tiff, …) the data is auto-stretched to reveal faint nebulosity and saved as 8-bit via Pillow.- Parameters:
payload (bytes) – Raw payload bytes.
header (dict) – Parsed frame header.
path (str) – Output file path. The extension determines the format. Use
.fitsto save lossless 16-bit data.stretch (bool, optional) – Apply an aggressive auto-stretch to bring out faint detail (default
True). Set toFalsefor a simple linear 16-to-8-bit scale. Ignored for FITS output.
- Raises:
ImportError – If required libraries are not installed (NumPy always; Pillow for image formats; astropy for FITS).
- seestarpy.stream.show_current_stack(stretch=True, port=4800, block=True, ips=None)
Grab the latest stacked image(s) and show them in matplotlib.
Low-friction one-liner for “show me what the Seestar is seeing right now”. When ips selects a single scope a single figure is shown. With multiple scopes (e.g.
ips="all"orips=[1, 2]) the frames are fetched in parallel and tiled into a subplot grid on a single figure.- Parameters:
stretch (bool, optional) – Apply the aggressive midtone stretch from
_auto_stretch()to bring out faint nebulosity (defaultTrue). Set toFalsefor a simple linear 16-to-8-bit scale.port (int, optional) – Image stream port. Default is
IMAGE_PORT(4800, telephoto). UseIMAGE_PORT_WIDE(4804) for the wide-angle camera.block (bool, optional) – If
True(default),matplotlib.pyplot.show()blocks until the window is closed.ips (str, int, or list, optional) – Target Seestar(s). Accepts the same shapes as elsewhere in the package:
None(the currentDEFAULT_IP), an int (2→seestar-2.local), a hostname/IP string, the literal"all", or a list mixing any of these.
- Returns:
For a single scope:
(header, arr8). For multiple scopes: a dict{ip: (header, arr8)}(failures map to the raised exception instance instead).- Return type:
tuple[dict, numpy.ndarray] or dict
Examples
>>> from seestarpy import stream >>> stream.show_current_stack() >>> stream.show_current_stack(port=stream.IMAGE_PORT_WIDE) >>> stream.show_current_stack(ips="all") >>> stream.show_current_stack(ips=[1, 2])
- seestarpy.stream.start_stream(ip=None, port=4800, on_image=None, with_matplotlib=False)
Start a persistent image stream from the Seestar.
Opens a TCP connection to the Seestar’s image port and begins receiving frames continuously. Each frame is delivered to the on_image callback on a background thread.
A heartbeat (
test_connection) is sent everyHEARTBEAT_INTERVALseconds to keep the connection alive.- Parameters:
ip (str, optional) – Seestar IP address. Defaults to
connection.DEFAULT_IP.port (int, optional) – Image stream port. Default is
IMAGE_PORT(4800).on_image (callable, optional) –
on_image(header: dict, data: bytes)called for each frame. header is the parsed 34-byte header dict; data is the raw image payload.with_matplotlib (bool, optional) – If
True, open a live matplotlib window that displays each frame as it arrives (auto-stretched). The call blocks until the window is closed, then the stream is stopped automatically. Your on_image callback (if any) is still called for every frame alongside the display.
- Returns:
Session handle — call
session.stop()or pass tostop_stream()to shut down.- Return type:
Examples
Live display (blocks until window closed):
>>> from seestarpy import stream >>> stream.start_stream(with_matplotlib=True)
Custom callback:
>>> frames = [] >>> def collect(header, data): ... frames.append((header, data)) ... if len(frames) >= 5: ... session.stop() >>> session = stream.start_stream(on_image=collect)
- seestarpy.stream.stop_stream(session)
Stop a streaming session started by
start_stream().- Parameters:
session (StreamSession) – The session to stop.