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_connection heartbeats (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: object

Manages a persistent image streaming connection.

Created by start_stream(). Call stop() (or pass to stop_stream()) to cleanly shut down.

ip

Seestar IP address.

Type:

str

port

TCP port in use.

Type:

int

is_running

True while 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’s VideoCapture.

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. Use RTSP_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 .fits extension to save the full 16-bit data losslessly (requires astropy).

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; raises socket.timeout if 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-zero width and height) and payload is the raw (compressed) image payload. Pass both to decode_payload() to get a NumPy array, or to save_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_frames budget (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 equal MAGIC_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 .fits files, the full 16-bit RGB data is saved as a FITS image (requires astropy). 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 .fits to save lossless 16-bit data.

  • stretch (bool, optional) – Apply an aggressive auto-stretch to bring out faint detail (default True). Set to False for 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" or ips=[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 (default True). Set to False for a simple linear 16-to-8-bit scale.

  • port (int, optional) – Image stream port. Default is IMAGE_PORT (4800, telephoto). Use IMAGE_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 current DEFAULT_IP), an int (2seestar-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 every HEARTBEAT_INTERVAL seconds 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 to stop_stream() to shut down.

Return type:

StreamSession

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.