Home

Documentation

Project Support

GraspExtrasSoftwareFitspipeSocketProtocol: fitspipe-get.py

#!/usr/bin/python
#
# fitspipe-get,py - an attempt to validate the FitspipeProtocol
# wiki page by reading it and producing a simple working a client
# that can fetch frames.
#
# Not really intended for serious use, and probably not a great
# advert for python best proactices, either - just a quick hack job
# to validate the documentation.

import argparse # Argument parsing
import shlex    # Simplest way found for parsing name=value pairs after 2 secs googling.
import socket   # Networking
import sys      # To get at sys.args, sys.stderr, etc.
import string   # Using splitlines() to iterate through lines in response.

from enum import Enum # Will enumerate status indicators to try to make things more readable.

# Indexes the different prefixes we expect in the first character of
# a response from the server.
class MSG_PREFIX(Enum):
  CONTINUE = 1
  ERROR = 2
  GET = 3
  OK = 4

# Strings for prefixes.
PREFIX_STR = {}
PREFIX_STR[MSG_PREFIX.CONTINUE.value] = '+ '
PREFIX_STR[MSG_PREFIX.ERROR.value] = '! '
PREFIX_STR[MSG_PREFIX.GET.value] = '# '
PREFIX_STR[MSG_PREFIX.OK.value] = '. '

# Some FITS header lengths.
FITS_BLOCK_LEN   = 2880 # The length of one block of FITS header data.
FITS_CARD_LEN    = 80   # Length of a single card in the header.
FITS_KEYWORD_LEN = 8    # Keywords are this long.

###################################################################################

##########
# Sanity-checking of command line args, used by parse_args(). Returns
# boolean indication of valid args or not.
def check_args(args):
  if(args.port < 2) or (args.port > 65535):
    sys.stderr.write("error: Port %d is invalid (%d..%d)\n" % (args.port, 1, 65535))
    return False

  # Not interested in supporting ridiculously large captures - this
  # is just a proof of concept.
  #
  # We could support infinitely long streams by perhaps allowing nframe to
  # be None, and then looping accordingly in get_frames().
  if(args.nframe < 1) or (args.nframe > 1000):
    sys.stderr.write("error: Number of frames %d is invalid (%d..%d)\n" % (args.nframe, 1, 100))
    return False

  return True


##########
# Python's argparse module uses args that are of the form
# --argname value rather than our name=value pairs that we
# tend to like. Still, it's not the point of this to worry
# about that, so using what's quickly and readily available
# to create something readable.
def parse_args(argv):
  parser = argparse.ArgumentParser()

  parser.add_argument('server',
                      help='Hostname/address of fitspipe server')

  parser.add_argument('--port',
                      dest='port',
                      required=False,
                      type=int,
                      default=9999,
                      help='Port number for fitspipe server [9999]')

  parser.add_argument('--feed',
                      dest='feed',
                      required=False,
                      default='default',
                      help='fitspipe feed to fetch frame(s) from [default]')

  parser.add_argument('--nframe',
                      dest='nframe',
                      required=False,
                      type=int,
                      default=1,
                      help='Number of frames to retrieve [1]')

  parser.add_argument('--out',
                      dest='out',
                      required=False,
                      default=None,
                      help='Partial path to write FITS files to. Leave out to write to stdout')

  args = parser.parse_args()

  if check_args(args) != True:
    return None
  else:
    return args


##########
# Takes a string containing whitespace-separated name=value
# parameters, turns it into a dict.
def dict_from_name_value_pairs(s):
  lexer = shlex.shlex(s, posix=True)
  lexer.whitespace_split = True
  lexer.whitespace = ' '
  return dict(pair.split('=', 1) for pair in lexer)


##########
# Turns one line of response from 'ls' command into something
# usable. Returns a dict containing all the info about the
# feed, with key names matching those from the 'ls' command
# (e.g. feed name is 'feed', etc.).
#
# Does not validate the magic status character at the head
# of the line. Assumes that the response has already been
# checked for sanity.
def parse_feed(line):
  # Parse everything after the message prefix.
  return dict_from_name_value_pairs(line[2:])


##########
# Parses the prefix on the head of a line of response from
# the server. Returns an entry from the MSG_PREFIX enumeration
# if that status indication was found, otherwise None if there
# was nothing sensible.
def get_resp_prefix(line):

  for prefix in MSG_PREFIX:
    if line[:2] == PREFIX_STR[prefix.value]:
      return prefix

  # Nothing found.
  return None


##########
# Gets info for the requested feed from the command line.
# Returns a dict representing feed, with keys with names
# and values that match what came out of the server.
def get_feed(args, sock):

  # Server will respond to 'ls' commandsends info on all feeds, one per line.
  msg='ls'
  sock.send(msg.encode())

  # In the real world, we'd possibly worry about receiving
  # large lists of feeds and would do something more sensible
  # involving receiving multiple lines, etc. For this little
  # charade, we'll just assume# an upper limit that's far larger
  # than anything we're likely to see in a simple system.
  # Note: response decoded as ascii text so we can treat it as a
  # string.
  resp = sock.recv(10000).decode("utf-8")

  # Try to match the feed we were given on the command line
  # with whatever the server reckons it has.
  found_feed = None
  for line in resp.splitlines():

    # Get prefix from message.
    prefix = get_resp_prefix(line)

    # Still looking for named feed.
    if found_feed is None:

      # Explicit error?
      if prefix == MSG_PREFIX.ERROR:
        sys.stderr.write("error: Server returned error\n")
        return None

      # If the server indicates success, then we didn't
      # find a matching feed.
      elif prefix == MSG_PREFIX.OK:
        return found_feed

      # If we hit anything but one of the continuation lines,
      # then that's also an error,
      elif prefix != MSG_PREFIX.CONTINUE:
        sys.stderr.write("error: Server returned unexpected line prefix\n")
        return {'feed':'!notfound'}

      # Figure out if this is the feed we're looking for.
      feed = parse_feed(line)
      if feed['feed'] == args.feed:
        found_feed = feed

    else:
      # Found our feed. Waiting for an indication of success.
      if prefix == MSG_PREFIX.OK:
        if found_feed is None:
          sys.stderr.write("No feed '%s' found on server.\n" % args.feed)
        return found_feed

  # If we get here, then something went wrong in the response from the server.
  sys.stderr.write("error: Unexpected response to 'ls' command from server.\n")
  return None


##########
# Takes a 40-byte initial response to 'get' and makes sense of it.
# Strongly based on the assumption that the response has a very
# rigid format - if this ever changes on the server side, this will
# likely break horribly.
def parse_get_header(line):
  info = {'frame':0, 'width':0, 'height':0, 'nbytes':0}

  # Anything but the magic prefix on the head of the response is an error.
  prefix = get_resp_prefix(line)
  if prefix != MSG_PREFIX.GET:
    sys.stderr.write("error: Unexpected response from server '%s'\n" % line)
    return None

  # Fields are guaranteed to be in fixed positions in the string.
  info['frame'] = int(line[2:13])
  info['width'] = int(line[13:23])
  info['height'] = int(line[26:36])

  # 16bpp pixels.
  info['nbytes'] = info['width'] * info['height'] * 2

  return info

##########
# Receives a fixed amount of data (n bytes) from the socket (s),
# blocking until all data received (hopefully).
#
# Used only when the amount of data expected back from the server
# is known ahead of time (i.e. the 40-line intiial response to 'get',
# blocks of FITS headers, or the image data).
#
# Since python doesn't provide any standard way of completely receiving
# a fixed amount of data and I couldn't be bothered writing my own,
# this was stolen from:
#
#   https://stackoverflow.com/questions/17667903/python-socket-receive-large-amount-of-data
#
# Not exactly guaranteed to be completely safe in the face of various
# problems, but that's not the point of this example, which demonstrates
# receiving fitspipe frames. If you want robust networking, rip out the
# socket handling side of this tool and replace it with something more
# completely thought-out.
def recvall(sock, n):
    # Helper function to recv n bytes or return None if EOF is hit
    data = b''
    while len(data) < n:
        packet = sock.recv(n - len(data))
        if not packet:
            return None
        data += packet
    return data


##########
# Given a block of FITS header data, this indicates whether this is
# the last block of the header or not. Returns boolean to show whether
# this is the end of the header.
def last_fits_header_block(block):
  i = 0

  # Walk through header, 80 bytes at a time, looking for an 'END' keyword
  # that signifies the end of the header. Only look at the beginning of
  # 80-byte boundaries so we don't get spoofed by other arbitrary strings,
  # and look for trailing spaces in keyword so we don't get caught out by
  # other keywords that start with 'END'.
  while i < FITS_BLOCK_LEN:
    if block[i:i+FITS_KEYWORD_LEN] == "END     ":
      return True

    i += FITS_CARD_LEN

  return False


##########
# Gets a full FITS header from the server without actually parsing it.
# The trick here is that we don't know ahead of time how large the header
# is going to be, but with a few simple FITS rules, we can figure it out
# as we go along.
def get_fits_header(sock):
  header= b''

  # Repeatedly get blocks of FITS header until we find the last one.
  # We'd probably do something more intelligent in a 'real' fitspipe
  # tool, but the length check here is just to bail if we've done
  # something inadvisable and not get stuck in an infinite loop.
  while len(header) < (100 * FITS_BLOCK_LEN):

    # Get next block.
    block = recvall(sock, FITS_BLOCK_LEN)
    if block is None:
      sys.stderr.write("error: Failed to get FITS header block from server.\n")
      return None

    # Add this block to the header received so far.
    header += block

    # Is this the last block?
    if last_fits_header_block(block):
      return header

  # If we get here, either the header is weirdly large or we did
  # something very wrong.
  sys.stderr.write("error: Getting FITS header took unexpectedly long. Bailing.\n")
  return None


##########
# Gets one or more frames from server. 'feed' is the
# info retreived from the server for the feed with 'ls' ahead
# of the transfer.
def get_frames(args, feed, sock):

  i = 1
  while i <= args.nframe:
    next_frame = int(feed['newest']) + i #- int(feed['depth'])

    # Request the next frame from the server.
    msg = 'get feed=' + feed['feed'] + ' frame=' + str(next_frame) + ' fullheader=1'
    sock.send(msg.encode())

    # Get the first line response from the server. This is a fixed-length
    # message.
    resp = recvall(sock, 40).decode("utf-8")
    image_info = parse_get_header(resp)

    if image_info is None:
      return

    # Get the FITS header from the server ahead of the image data.
    header = get_fits_header(sock)
    if header is None:
      return

    # Get expected image size from server.
    sys.stderr.write("Frame %d: getting %d bytes\n" %(next_frame, image_info['nbytes']))
    image = recvall(sock, image_info['nbytes'])

    if image is None:
      sys.stderr.write("error: Failed to receive data for frame %d\n" % (next_frame))
      return

    # FITS standard requires that we pad out to the end of the
    # nearest block size with zeroes. Figure out how many bytes
    # are going to be needed.
    if (image_info['nbytes'] % FITS_BLOCK_LEN) != 0:
      padding_len = FITS_BLOCK_LEN - (image_info['nbytes'] % FITS_BLOCK_LEN)

      # Generate padding, helpfully zeroed out and ready to go.
      padding = bytearray(padding_len)
    else:
      padding = None

    # FITS frame either gets chucked on stdout, or it ends up in a file.
    if args.out is None:
      sys.stdout.write(header)
      sys.stdout.write(image)
      if padding is not None:
        sys.stdout.write(padding)

    else:
      # Padding frame number with leading zeroes in filename.
      filename = args.out + str(i).zfill(5) + ".fits"
      f = open(filename, "w")
      f.write(header)
      f.write(image)
      if padding is not None:
        f.write(padding)
      f.close()

    # Next frame.
    i+=1


###################################################################################

# Parse command line.
args = parse_args(sys.argv)
if args is None:
  exit(1)

# Create a socket and connect to the server. Note complete lack
# of error checking here - that's not the point of this example.
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((args.server, args.port))

# Get info for the requested feed.
feed = get_feed(args, s)

# If we found a feed, then we're OK to go get frame(s) from
# the server.
if feed is not None:
  get_frames(args, feed, s)

# Done.
s.close()