diff --git a/CHANGELOG b/CHANGELOG index 5c5d285..570a822 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,11 @@ +2.2.0 +- fixed Bayer pattern order for HQ camera (pycamera2 or libcamera have change they way they report Bayer pattern order), + BAYERPAT in FITS does not depend on "Rotation" property anymore (force_Rotation has no effect anymore), + but Bayer pattern can now be forced with force_BayerOrder +- sorting raw modes by size and bit depth to make the mode with most pixel and most bits/pixel the default +- saving and loading configurations +- bug fix: after aborting an exposure the camera stayed busy and EKOS field solver could not start a new exposure + 2.1.0 - fixed division by 0 when focal length equals 0 - improved driver exit when indiserver is closed (to avoid driver continue to run) diff --git a/README.md b/README.md index 79776a4..333ce05 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,34 @@ There are more settings, mostly to support debugging. An example for a configuration file can be found in this repository. +## Saving a Configuration +The driver allows you to save up to 6 different configurations. The "Options" tab has 3 controls for that: +- You to select which of the 6 configurations you want to save, load or delete with control "Configs". +- "Config Name" allows you to give the configuration a name. This is optional. It only helps you to remember +what this configuration is made for. +- The buttons in "Configuration" trigger the actions: + - "Load" loads and applies the configuration. + - "Save" stores the configuration. + - "Default" restores the driver defaults. It does not overwrite the stored configuration. + - "Purge" removes the stored configuration. + +When you try to load a non-existing configuration no settings will be changed. + +Many clients load "Config #1" automatically. If you do not want this you must purge "Config #1". + +Not all driver settings will be stored. For instance all settings which trigger an action (like "Connect", +"Expose" and "Abort") will not be stored and load. Also, "Scope Location" (your place on earth), "Eq. Coordinates" +(the scope pointing coordinates) and "Pier Side" will not be stored and loaded because these will typically set +by snooping the mount driver. Telescope focal length and aperture are stored but get overwritten immediately by +client (EKOS) when snooping. Generally all settings coming from client (EKOS) will overwrite settings you loaded +previously from a configuration file. + +To save and load configurations you must be connected to a camera. The configuration will only be valid +for this particular camera. It is not recommended to load a configuration which was saved for a different type +of camera. + +Configurations get stored in `~/.indi_pylibcamera/CONFIG*.json`. + ## Error when restarting indiserver When killing the indiserver sometimes the driver process continues to run. You can see this with: diff --git a/src/indi_pylibcamera/CameraControl.py b/src/indi_pylibcamera/CameraControl.py index f6fe41b..e612608 100755 --- a/src/indi_pylibcamera/CameraControl.py +++ b/src/indi_pylibcamera/CameraControl.py @@ -287,22 +287,6 @@ def getRawCameraModes(self): if not re.match("S[RGB]{4}[0-9]+", sensor_format): logging.warning(f'raw mode not supported: {sensor_mode}') continue - # it seems that self.CamProps["Rotation"] determines the orientation of the Bayer pattern - if self.CamProps["Rotation"] == 0: - # at least V1 camera has this - FITS_format = sensor_format[1:5] - elif self.CamProps["Rotation"] == 180: - # at least HQ camera has this - FITS_format = sensor_format[4:0:-1] - elif self.CamProps["Rotation"] == 90: - # don't know if there is such a camera and if the following rotation is right - FITS_format = "".join([sensor_format[2], sensor_format[4], sensor_format[1], sensor_format[3]]) - elif self.CamProps["Rotation"] in [270, -90]: - # don't know if there is such a camera and if the following rotation is right - FITS_format = "".join([sensor_format[3], sensor_format[1], sensor_format[4], sensor_format[2]]) - else: - logging.warning(f'Sensor rotation {self.CamProps["Rotation"]} not supported!') - FITS_format = sensor_format[1:5] # size = sensor_mode["size"] # adjustments for cameras: @@ -347,17 +331,16 @@ def getRawCameraModes(self): logging.warning(f'Unsupported frame size {size} for imx708!') # add to list of raw formats raw_mode = { - "label": f'{size[0]}x{size[1]} {FITS_format} {sensor_mode["bit_depth"]}bit', + "label": f'{size[0]}x{size[1]} {sensor_format[1:5]} {sensor_mode["bit_depth"]}bit', "size": size, "true_size": true_size, "camera_format": sensor_format, "bit_depth": sensor_mode["bit_depth"], - "FITS_format": FITS_format, "binning": binning, } raw_modes.append(raw_mode) - # sort list of raw formats by size in descending order - raw_modes.sort(key=lambda k: k["size"][0] * k["size"][1], reverse=True) + # sort list of raw formats by size and bit depth in descending order + raw_modes.sort(key=lambda k: k["size"][0] * k["size"][1] * 100 + k["bit_depth"], reverse=True) return raw_modes def openCamera(self, idx: int): @@ -370,12 +353,6 @@ def openCamera(self, idx: int): self.CamProps = self.picam2.camera_properties logging.info(f'camera properties: {self.CamProps}') # force properties with values from config file - if "Rotation" not in self.CamProps: - logging.warning("Camera properties do not have Rotation value. Need to force from config file!") - self.CamProps["Rotation"] = self.config.getint( - "driver", "force_Rotation", - fallback=self.CamProps["Rotation"] if "Rotation" in self.CamProps else 0 - ) if "UnitCellSize" not in self.CamProps: logging.warning("Camera properties do not have UnitCellSize value. Need to force from config file!") self.CamProps["UnitCellSize"] = ( @@ -531,6 +508,9 @@ def createBayerFits(self, array, metadata): with self.parent.knownVectorsLock: # determine frame type FrameType = self.parent.knownVectors["CCD_FRAME_TYPE"].get_OnSwitchesLabels()[0] + # determine Bayer pattern order + BayerPattern = self.picam2.camera_configuration()["raw"]["format"][1:5] + BayerPattern = self.parent.config.get("driver", "force_BayerOrder", fallback=BayerPattern) # FITS header and metadata FitsHeader = [ ("BZERO", 2 ** (bit_pix - 1), "offset data range"), @@ -538,8 +518,7 @@ def createBayerFits(self, array, metadata): ("ROWORDER", "TOP-DOWN", "Row order"), ("INSTRUME", self.parent.device, "CCD Name"), ("TELESCOP", self.parent.knownVectors["ACTIVE_DEVICES"]["ACTIVE_TELESCOPE"].value, "Telescope name"), - ("OBSERVER", self.parent.knownVectors["FITS_HEADER"]["FITS_OBSERVER"].value, "Observer name"), - ("OBJECT", self.parent.knownVectors["FITS_HEADER"]["FITS_OBJECT"].value, "Object name"), + ] + self.parent.knownVectors["FITS_HEADER"].get_FitsHeaderList() + [ ("EXPTIME", metadata["ExposureTime"]/1e6, "Total Exposure Time (s)"), ("CCD-TEMP", metadata.get('SensorTemperature', 0), "CCD Temperature (Celsius)"), ("PIXSIZE1", self.getProp("UnitCellSize")[0] / 1e3, "Pixel Size 1 (microns)"), @@ -553,7 +532,7 @@ def createBayerFits(self, array, metadata): ] + self.snooped_FitsHeader() + [ ("XBAYROFF", 0, "X offset of Bayer array"), ("YBAYROFF", 0, "Y offset of Bayer array"), - ("BAYERPAT", self.present_CameraSettings.RawMode["FITS_format"], "Bayer color pattern"), + ("BAYERPAT", BayerPattern, "Bayer color pattern"), ] FitsHeader += [("Gain", metadata.get("AnalogueGain", 0.0), "Gain"), ] if "SensorBlackLevels" in metadata: @@ -607,8 +586,7 @@ def createRgbFits(self, array, metadata): #("ROWORDER", "TOP-DOWN", "Row Order"), ("INSTRUME", self.parent.device, "CCD Name"), ("TELESCOP", self.parent.knownVectors["ACTIVE_DEVICES"]["ACTIVE_TELESCOPE"].value, "Telescope name"), - ("OBSERVER", self.parent.knownVectors["FITS_HEADER"]["FITS_OBSERVER"].value, "Observer name"), - ("OBJECT", self.parent.knownVectors["FITS_HEADER"]["FITS_OBJECT"].value, "Object name"), + ] + self.parent.knownVectors["FITS_HEADER"].get_FitsHeaderList() + [ ("EXPTIME", metadata["ExposureTime"]/1e6, "Total Exposure Time (s)"), ("CCD-TEMP", metadata.get('SensorTemperature', 0), "CCD Temperature (Celsius)"), ("PIXSIZE1", self.getProp("UnitCellSize")[0] / 1e3, "Pixel Size 1 (microns)"), @@ -639,6 +617,7 @@ def checkAbort(self): if self.parent.knownVectors["CCD_ABORT_EXPOSURE"]["ABORT"].value == ISwitchState.ON: self.parent.setVector("CCD_FAST_COUNT", "FRAMES", value=0, state=IVectorState.OK) self.parent.setVector("CCD_ABORT_EXPOSURE", "ABORT", value=ISwitchState.OFF, state=IVectorState.OK) + self.parent.setVector("CCD_EXPOSURE", "CCD_EXPOSURE_VALUE", value=0, state=IVectorState.OK) return True return False @@ -693,6 +672,7 @@ def __ExposureLoop(self): if self.Sig_ActionExit.is_set(): # exit exposure loop self.picam2.stop_() + self.parent.setVector("CCD_EXPOSURE", "CCD_EXPOSURE_VALUE", value=0, state=IVectorState.OK) return # picam2 needs to be open! if self.picam2 is None: @@ -754,6 +734,7 @@ def __ExposureLoop(self): if self.Sig_ActionExit.is_set(): # exit exposure loop self.picam2.stop_() + self.parent.setVector("CCD_EXPOSURE", "CCD_EXPOSURE_VALUE", value=0, state=IVectorState.OK) return with self.parent.knownVectorsLock: Abort = self.checkAbort() @@ -777,6 +758,7 @@ def __ExposureLoop(self): if self.Sig_ActionExit.is_set(): # exit exposure loop self.picam2.stop_() + self.parent.setVector("CCD_EXPOSURE", "CCD_EXPOSURE_VALUE", value=0, state=IVectorState.OK) return # allow to abort exposure with self.parent.knownVectorsLock: @@ -789,16 +771,18 @@ def __ExposureLoop(self): time.sleep(PollingPeriod_s) # get frame and its metadata if not Abort: - (array, ), metadata = self.picam2.wait(job) + (array, ), metadata = self.picam2.wait(job) logging.info("got exposed frame") - # inform client about progress - self.parent.setVector("CCD_EXPOSURE", "CCD_EXPOSURE_VALUE", value=0, state=IVectorState.BUSY) - # at least HQ camera reports CCD temperature in meta data - self.parent.setVector("CCD_TEMPERATURE", "CCD_TEMPERATURE_VALUE", value=metadata.get('SensorTemperature', 0)) + # at least HQ camera reports CCD temperature in meta data + self.parent.setVector("CCD_TEMPERATURE", "CCD_TEMPERATURE_VALUE", + value=metadata.get('SensorTemperature', 0)) + # inform client about progress + self.parent.setVector("CCD_EXPOSURE", "CCD_EXPOSURE_VALUE", value=0, state=IVectorState.BUSY) # last chance to exit or abort before sending blob if self.Sig_ActionExit.is_set(): # exit exposure loop self.picam2.stop_() + self.parent.setVector("CCD_EXPOSURE", "CCD_EXPOSURE_VALUE", value=0, state=IVectorState.OK) return with self.parent.knownVectorsLock: Abort = Abort or self.checkAbort() diff --git a/src/indi_pylibcamera/CameraInfos/Raspi_HQ_IMX477.txt b/src/indi_pylibcamera/CameraInfos/Raspi_HQ_IMX477.txt index 91663fa..0417381 100644 --- a/src/indi_pylibcamera/CameraInfos/Raspi_HQ_IMX477.txt +++ b/src/indi_pylibcamera/CameraInfos/Raspi_HQ_IMX477.txt @@ -1,32 +1,39 @@ +Testing numpy: + numpy 1.19.5 + +Testing astropy: + astropy 4.2 + Found 1 cameras. Camera 0: {'Id': '/base/soc/i2c0mux/i2c@1/imx477@1a', 'Location': 2, 'Model': 'imx477', - 'Rotation': 180} + 'Rotation': 0} Camera properties: {'ColorFilterArrangement': 0, 'Location': 2, 'Model': 'imx477', - 'PixelArrayActiveAreas': (8, 16, 4056, 3040), + 'PixelArrayActiveAreas': [(8, 16, 4056, 3040)], 'PixelArraySize': (4056, 3040), - 'Rotation': 180, + 'Rotation': 0, 'ScalerCropMaximum': (0, 0, 0, 0), + 'SystemDevices': (20750, 20751, 20737, 20738, 20739), 'UnitCellSize': (1550, 1550)} Raw sensor modes: [{'bit_depth': 10, 'crop_limits': (696, 528, 2664, 1980), - 'exposure_limits': (31, 667234896, None), + 'exposure_limits': (31, None), 'format': SRGGB10_CSI2P, 'fps': 120.05, 'size': (1332, 990), 'unpacked': 'SRGGB10'}, {'bit_depth': 12, 'crop_limits': (0, 440, 4056, 2160), - 'exposure_limits': (60, 674181621, None), + 'exposure_limits': (60, 667244877, None), 'format': SRGGB12_CSI2P, 'fps': 50.03, 'size': (2028, 1080), @@ -40,12 +47,32 @@ Raw sensor modes: 'unpacked': 'SRGGB12'}, {'bit_depth': 12, 'crop_limits': (0, 0, 4056, 3040), - 'exposure_limits': (114, 694422939, None), + 'exposure_limits': (114, 674191602, None), 'format': SRGGB12_CSI2P, 'fps': 10.0, 'size': (4056, 3040), 'unpacked': 'SRGGB12'}] +Camera configuration: +{'buffer_count': 4, + 'colour_space': , + 'controls': {'FrameDurationLimits': (100, 83333), + 'NoiseReductionMode': }, + 'display': 'main', + 'encode': 'main', + 'lores': None, + 'main': {'format': 'XBGR8888', + 'framesize': 1228800, + 'size': (640, 480), + 'stride': 2560}, + 'queue': True, + 'raw': {'format': 'SBGGR12_CSI2P', + 'framesize': 18580480, + 'size': (4056, 3040), + 'stride': 6112}, + 'transform': , + 'use_case': 'preview'} + Camera controls: {'AeConstraintMode': (0, 3, 0), 'AeEnable': (False, True, None), @@ -55,21 +82,18 @@ Camera controls: 'AwbEnable': (False, True, None), 'AwbMode': (0, 7, 0), 'Brightness': (-1.0, 1.0, 0.0), - 'ColourCorrectionMatrix': (-16.0, 16.0, None), 'ColourGains': (0.0, 32.0, None), 'Contrast': (0.0, 32.0, 1.0), - 'ExposureTime': (114, 694422939, None), + 'ExposureTime': (114, 674191602, None), 'ExposureValue': (-8.0, 8.0, 0.0), 'FrameDurationLimits': (100000, 694434742, None), 'NoiseReductionMode': (0, 4, 0), 'Saturation': (0.0, 32.0, 1.0), - 'ScalerCrop': (libcamera.Rectangle(0, 0, 64, 64), - libcamera.Rectangle(0, 0, 4056, 3040), - None), + 'ScalerCrop': ((0, 0, 64, 64), (0, 0, 4056, 3040), (2, 0, 4053, 3040)), 'Sharpness': (0.0, 16.0, 1.0)} Exposure time: - min: 114, max: 694422939, default: None + min: 114, max: 674191602, default: None AnalogGain: min: 1.0, max: 22.2608699798584, default: None diff --git a/src/indi_pylibcamera/CameraInfos/Raspi_V1_OV5647.txt b/src/indi_pylibcamera/CameraInfos/Raspi_V1_OV5647.txt index 783be6e..37d00bc 100644 --- a/src/indi_pylibcamera/CameraInfos/Raspi_V1_OV5647.txt +++ b/src/indi_pylibcamera/CameraInfos/Raspi_V1_OV5647.txt @@ -1,3 +1,9 @@ +Testing numpy: + numpy 1.19.5 + +Testing astropy: + astropy 4.2 + Found 1 cameras. Camera 0: @@ -10,42 +16,63 @@ Camera properties: {'ColorFilterArrangement': 2, 'Location': 2, 'Model': 'ov5647', - 'PixelArrayActiveAreas': (16, 6, 2592, 1944), + 'PixelArrayActiveAreas': [(16, 6, 2592, 1944)], 'PixelArraySize': (2592, 1944), 'Rotation': 0, 'ScalerCropMaximum': (0, 0, 0, 0), + 'SystemDevices': (20749, 20737, 20738, 20739), 'UnitCellSize': (1400, 1400)} Raw sensor modes: [{'bit_depth': 10, 'crop_limits': (16, 0, 2560, 1920), - 'exposure_limits': (134, 1103219, None), + 'exposure_limits': (134, 2147483647, None), 'format': SGBRG10_CSI2P, 'fps': 58.92, 'size': (640, 480), 'unpacked': 'SGBRG10'}, {'bit_depth': 10, 'crop_limits': (0, 0, 2592, 1944), - 'exposure_limits': (92, 760636, None), + 'exposure_limits': (92, 760565, None), 'format': SGBRG10_CSI2P, 'fps': 43.25, 'size': (1296, 972), 'unpacked': 'SGBRG10'}, {'bit_depth': 10, 'crop_limits': (348, 434, 1928, 1080), - 'exposure_limits': (118, 969249, None), + 'exposure_limits': (118, 760636, None), 'format': SGBRG10_CSI2P, 'fps': 30.62, 'size': (1920, 1080), 'unpacked': 'SGBRG10'}, {'bit_depth': 10, 'crop_limits': (0, 0, 2592, 1944), - 'exposure_limits': (130, 1064891, None), + 'exposure_limits': (130, 969249, None), 'format': SGBRG10_CSI2P, 'fps': 15.63, 'size': (2592, 1944), 'unpacked': 'SGBRG10'}] +Camera configuration: +{'buffer_count': 4, + 'colour_space': , + 'controls': {'FrameDurationLimits': (100, 83333), + 'NoiseReductionMode': }, + 'display': 'main', + 'encode': 'main', + 'lores': None, + 'main': {'format': 'XBGR8888', + 'framesize': 1228800, + 'size': (640, 480), + 'stride': 2560}, + 'queue': True, + 'raw': {'format': 'SGBRG10_CSI2P', + 'framesize': 6345216, + 'size': (2592, 1944), + 'stride': 3264}, + 'transform': , + 'use_case': 'preview'} + Camera controls: {'AeConstraintMode': (0, 3, 0), 'AeEnable': (False, True, None), @@ -55,21 +82,18 @@ Camera controls: 'AwbEnable': (False, True, None), 'AwbMode': (0, 7, 0), 'Brightness': (-1.0, 1.0, 0.0), - 'ColourCorrectionMatrix': (-16.0, 16.0, None), 'ColourGains': (0.0, 32.0, None), 'Contrast': (0.0, 32.0, 1.0), - 'ExposureTime': (130, 1064891, None), + 'ExposureTime': (130, 969249, None), 'ExposureValue': (-8.0, 8.0, 0.0), 'FrameDurationLimits': (63965, 1065021, None), 'NoiseReductionMode': (0, 4, 0), 'Saturation': (0.0, 32.0, 1.0), - 'ScalerCrop': (libcamera.Rectangle(0, 0, 64, 64), - libcamera.Rectangle(0, 0, 2592, 1944), - None), + 'ScalerCrop': ((0, 0, 64, 64), (0, 0, 2592, 1944), (0, 0, 2592, 1944)), 'Sharpness': (0.0, 16.0, 1.0)} Exposure time: - min: 130, max: 1064891, default: None + min: 130, max: 969249, default: None AnalogGain: min: 1.0, max: 63.9375, default: None diff --git a/src/indi_pylibcamera/__init__.py b/src/indi_pylibcamera/__init__.py index d87bb12..b2a2ff0 100644 --- a/src/indi_pylibcamera/__init__.py +++ b/src/indi_pylibcamera/__init__.py @@ -2,4 +2,4 @@ INDI driver for libcamera supported cameras """ -__version__ = "2.1.1" +__version__ = "2.2.0" diff --git a/src/indi_pylibcamera/indi_pylibcamera.ini b/src/indi_pylibcamera/indi_pylibcamera.ini index 4a37b6d..a1742e7 100644 --- a/src/indi_pylibcamera/indi_pylibcamera.ini +++ b/src/indi_pylibcamera/indi_pylibcamera.ini @@ -12,7 +12,8 @@ SendTimeStamps=no # Do not activate that when not absolutely needed. #force_UnitCellSize_X=2900 #force_UnitCellSize_Y=2900 -#force_Rotation=0 +#force_Rotation=0 # this has no effect anymore! +#force_BayerOrder=BGGR # The following sets the initial value of the logging level. Possible values can be: # "Debug", "Info", "Warning", "Error". After startup you can change the logging level diff --git a/src/indi_pylibcamera/indi_pylibcamera.py b/src/indi_pylibcamera/indi_pylibcamera.py index 1d62a01..7c1e916 100755 --- a/src/indi_pylibcamera/indi_pylibcamera.py +++ b/src/indi_pylibcamera/indi_pylibcamera.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 - +import logging import sys import os import os.path @@ -7,6 +7,8 @@ import subprocess import signal import traceback +from collections import OrderedDict +import json from picamera2 import Picamera2 @@ -19,14 +21,16 @@ logging.basicConfig(filename=None, level=logging.INFO, format='%(name)s-%(levelname)s- %(message)s') +IniPath = Path(Path.home(), ".indi_pylibcamera") +IniPath.mkdir(parents=True, exist_ok=True) def read_config(): # iterative list of INI files to load - configfiles = [Path(__file__) / Path("indi_pylibcamera.ini")] + configfiles = [Path(__file__, "indi_pylibcamera.ini")] if "INDI_PYLIBCAMERA_CONFIG_PATH" in os.environ: - configfiles += [Path(os.environ["INDI_PYLIBCAMERA_CONFIG_PATH"]) / Path("indi_pylibcamera.ini")] - configfiles += [Path(os.environ["HOME"]) / Path(".indi_pylibcamera") / Path("indi_pylibcamera.ini")] - configfiles += [Path(os.getcwd()) / Path("indi_pylibcamera.ini")] + configfiles += [Path(os.environ["INDI_PYLIBCAMERA_CONFIG_PATH"], "indi_pylibcamera.ini")] + configfiles += [IniPath / Path("indi_pylibcamera.ini")] + configfiles += [Path(Path.cwd(), "indi_pylibcamera.ini")] # create config parser instance config = ConfigParser() config.read(configfiles) @@ -102,7 +106,7 @@ def __init__(self, parent): ISwitch(name="DISCONNECT", label="Disonnect", value=ISwitchState.ON), ], label="Connection", group="Main Control", - rule=ISwitchRule.ONEOFMANY, + rule=ISwitchRule.ONEOFMANY, is_savable=False, ) def set_byClient(self, values: dict): @@ -146,7 +150,7 @@ def __init__(self, parent, min_exp, max_exp): INumber(name="CCD_EXPOSURE_VALUE", label="Duration (s)", min=min_exp / 1e6, max=max_exp / 1e6, step=0.001, value=1.0, format="%.3f"), ], - label="Expose", group="Main Control", + label="Expose", group="Main Control", is_savable=False, ) def set_byClient(self, values: dict): @@ -359,6 +363,41 @@ def set_byClient(self, values: dict): ) +class FitsHeaderVector(ITextVector): + """INDI Text vector with other devices to snoop + """ + + def __init__(self, parent): + self.parent = parent + self.FitsHeader = OrderedDict() + super().__init__( + device=self.parent.device, timestamp=self.parent.timestamp, name="FITS_HEADER", + # empty values mean do not snoop + elements=[ + IText(name="KEYWORD_NAME", label="Name", value=""), + IText(name="KEYWORD_VALUE", label="Value", value=""), + IText(name="KEYWORD_COMMENT", label="Comment", value=""), + ], + label="FITS Header", group="General Info", perm=IPermission.WO, is_savable=False, + ) + + def set_byClient(self, values: dict): + """called when vector gets set by client + special version for activating snooping + + Args: + values: dict(propertyName: value) of values to set + """ + super().set_byClient(values=values) + self.FitsHeader[values["KEYWORD_NAME"]] = (values["KEYWORD_VALUE"], values["KEYWORD_COMMENT"]) + + def get_FitsHeaderList(self): + FitsHeaderList = [] + for name, value_comment in self.FitsHeader.items(): + FitsHeaderList.append((name, value_comment[0], value_comment[1])) + return FitsHeaderList + + class DoSnoopingVector(ISwitchVector): """INDI SwitchVector to enable/disable snooping; gets initialized from config file """ @@ -389,7 +428,7 @@ def __init__(self, parent): ISwitch(name="PRINT_SNOOPED", label="Print", value=ISwitchState.OFF), ], label="Print snooped values", group="Snooping", - rule=ISwitchRule.ATMOST1, + rule=ISwitchRule.ATMOST1, is_savable=False, ) def set_byClient(self, values: dict): @@ -405,6 +444,69 @@ def set_byClient(self, values: dict): self.send_setVector() +class ConfigProcessVector(ISwitchVector): + """INDI Switch vector to save and load configurations + """ + + def __init__(self, parent): + self.parent=parent + super().__init__( + device=self.parent.device, timestamp=self.parent.timestamp, name="CONFIG_PROCESS", + elements=[ + ISwitch(name="CONFIG_LOAD", label="Load", value=ISwitchState.OFF), + ISwitch(name="CONFIG_SAVE", label="Save", value=ISwitchState.OFF), + ISwitch(name="CONFIG_DEFAULT", label="Default", value=ISwitchState.OFF), + ISwitch(name="CONFIG_PURGE", label="Purge", value=ISwitchState.OFF), + ], + label="Configuration", group="Options", + rule=ISwitchRule.ATMOST1, is_savable=False, + ) + + def set_byClient(self, values: dict): + """called when vector gets set by client + special version for saving and loading configurations + + Args: + values: dict(propertyName: value) of values to set + """ + super().set_byClient(values=values) + config_filename = IniPath / f'{self.parent.knownVectors["APPLY_CONFIG"].get_OnSwitches()[0]}.json' + actions = self.get_OnSwitches() + if len(actions) > 0: + action = actions[0] + if action == "CONFIG_LOAD": + if config_filename.exists(): + logging.info(f'loading configuration from {config_filename}') + with open(config_filename, "r") as fh: + states = json.load(fh) + for vector in states: + if vector["name"] in self.parent.knownVectors: + self.parent.knownVectors[vector["name"]].set_byClient(vector["values"]) + else: + logging.warning(f'Ignoring unknown vector {vector["name"]}!') + else: + logging.info(f'configuration {config_filename} does not exist') + elif action == "CONFIG_SAVE": + logging.info(f'saving configuration in {config_filename}') + states = list() + for vector in self.parent.knownVectors: + state = vector.save() + if state is not None: + states.append(state) + with open(config_filename, "w") as fh: + json.dump(states, fh, indent=4) + elif action == "CONFIG_DEFAULT": + logging.info(f'restoring driver defaults') + for vector in self.parent.knownVectors: + vector.restore_DriverDefault() + else: # action == "CONFIG_PURGE" + logging.info(f'deleting configuration {config_filename}') + config_filename.unlink(missing_ok=True) + # set all buttons Off again + super().set_byClient(values={element.name: ISwitchState.OFF for element in self.elements}) + + + def kill_oldDriver(): """test if another instance of driver is already running and kill it @@ -478,7 +580,7 @@ def __init__(self, config=None): ) for i in range(len(self.Cameras)) ], label="Camera", group="Main Control", - rule=ISwitchRule.ONEOFMANY, + rule=ISwitchRule.ONEOFMANY, is_savable=False, ) ) self.checkin( @@ -494,7 +596,7 @@ def __init__(self, config=None): IText(name="DRIVER_INTERFACE", label="Interface", value="2"), # This is a CCD! ], label="Driver Info", group="General Info", - perm=IPermission.RO, + perm=IPermission.RO, is_savable=False, ) ) self.checkin( @@ -521,7 +623,7 @@ def __init__(self, config=None): INumber(name="ELEV", label="Elevation (m)", min=-200, max=10000, step=0, value=0, format="%g"), ], label="Scope Location", group="Snooping", - perm=IPermission.RW, + perm=IPermission.RW, is_savable=False, ), ) self.checkin( @@ -532,7 +634,7 @@ def __init__(self, config=None): INumber(name="DEC", label="DEC (dd:mm:ss)", min=-90, max=90, step=0, value=0, format="%010.6m"), ], label="Eq. Coordinates", group="Snooping", - perm=IPermission.RW, + perm=IPermission.RW, is_savable=False, ), ) # TODO: "EQUATORIAL_COORD" (J2000 coordinates from mount) are not used! @@ -545,7 +647,7 @@ def __init__(self, config=None): INumber(name="DEC", label="DEC (dd:mm:ss)", min=-90, max=90, step=0, value=0, format="%010.6m"), ], label="Eq. J2000 Coordinates", group="Snooping", - perm=IPermission.RW, + perm=IPermission.RW, is_savable=False, ), ) self.checkin( @@ -556,7 +658,7 @@ def __init__(self, config=None): ISwitch(name="PIER_EAST", value=ISwitchState.OFF, label="East (pointing west)"), ], label="Pier Side", group="Snooping", - rule=ISwitchRule.ONEOFMANY, + rule=ISwitchRule.ONEOFMANY, is_savable=False, ) ) self.checkin( @@ -633,7 +735,7 @@ def openCamera(self): IText(name="CAMERA_UNITCELLSIZE", label="Pixel size", value=str(self.CameraThread.getProp("UnitCellSize"))), ], label="Camera Info", group="General Info", - state=IVectorState.OK, perm=IPermission.RO, + state=IVectorState.OK, perm=IPermission.RO, is_savable=False, ), send_defVector=True, ) @@ -686,7 +788,7 @@ def openCamera(self): ISwitch(name="ABORT", label="Abort", value=ISwitchState.OFF), ], label="Abort", group="Main Control", - rule=ISwitchRule.ATMOST1, + rule=ISwitchRule.ATMOST1, is_savable=False, ), send_defVector=True, ) @@ -705,7 +807,7 @@ def openCamera(self): step=0, value=self.CameraThread.getProp("PixelArraySize")[1], format="%4.0f"), ], label="Frame", group="Image Info", - perm=IPermission.RO, + perm=IPermission.RO, is_savable=False, # TODO: make it savable after implementing frame cropping ), send_defVector=True, ) @@ -718,7 +820,7 @@ def openCamera(self): ISwitch(name="RESET", label="Reset", value=ISwitchState.OFF), ], label="Frame Values", group="Image Settings", - rule=ISwitchRule.ONEOFMANY, perm=IPermission.WO, + rule=ISwitchRule.ONEOFMANY, perm=IPermission.WO, is_savable=False, ), send_defVector=True, ) @@ -735,15 +837,7 @@ def openCamera(self): self.CameraVectorNames.append("CCD_BINNING") # self.checkin( - ITextVector( - device=self.device, timestamp=self.timestamp, name="FITS_HEADER", - elements=[ - IText(name="FITS_OBSERVER", label="Observer", value="Unknown"), - IText(name="FITS_OBJECT", label="Object", value="Unknown"), - ], - label="FITS Header", group="General Info", - state=IVectorState.IDLE, perm=IPermission.RW, - ), + FitsHeaderVector(parent=self,), send_defVector=True, ) self.CameraVectorNames.append("FITS_HEADER") @@ -755,7 +849,7 @@ def openCamera(self): INumber(name="CCD_TEMPERATURE_VALUE", label="Temperature (C)", min=-50, max=50, step=0, value=0, format="%5.2f"), ], label="Temperature", group="Main Control", - state=IVectorState.IDLE, perm=IPermission.RO, + state=IVectorState.IDLE, perm=IPermission.RO, is_savable=False, ), send_defVector=True, ) @@ -780,7 +874,7 @@ def openCamera(self): value=8 if len(self.CameraThread.RawModes) < 1 else self.CameraThread.RawModes[0]["bit_depth"], format="%.f"), ], label="CCD Information", group="Image Info", - state=IVectorState.IDLE, perm=IPermission.RO, + state=IVectorState.IDLE, perm=IPermission.RO, is_savable=False, ), send_defVector=True, ) @@ -812,7 +906,7 @@ def openCamera(self): IBlob(name="CCD1", label="Image"), ], label="Image Data", group="Image Info", - state=IVectorState.OK, perm=IPermission.RO, + state=IVectorState.OK, perm=IPermission.RO, is_savable=False, ), send_defVector=True, ) @@ -882,7 +976,7 @@ def openCamera(self): elements=[ INumber(name="FRAMES", label="Frames", min=0, max=100000, step=1, value=1, format="%.f"), ], - label="Fast Count", group="Main Control", + label="Fast Count", group="Main Control", is_savable=False, ), send_defVector=True, ) @@ -902,6 +996,39 @@ def openCamera(self): ) self.CameraVectorNames.append("CCD_GAIN") # + # configuration save and load + self.checkin( + ISwitchVector( + device=self.device, timestamp=self.timestamp, name="APPLY_CONFIG", + elements=[ + ISwitch(name=f"CONFIG{i}", label=f"Config #{i}", value=ISwitchState.ON if i == 1 else ISwitchState.OFF) + for i in range(1, 7) + ], + label="Configs", group="Options", + rule=ISwitchRule.ONEOFMANY, + ), + send_defVector=True, + ) + self.CameraVectorNames.append("APPLY_CONFIG") + # + self.checkin( + ITextVector( + device=self.device, timestamp=self.timestamp, name="CONFIG_NAME", + elements=[ + IText(name="CONFIG_NAME", label="Config Name", value=""), + ], + label="Configuration Name", group="Options", + ), + send_defVector=True, + ) + self.CameraVectorNames.append("CONFIG_NAME") + # + self.checkin( + ConfigProcessVector(parent=self,), + send_defVector=True, + ) + self.CameraVectorNames.append("CONFIG_PROCESS") + # # Maybe needed: CCD_CFA # self.checkin( # ITextVector( diff --git a/src/indi_pylibcamera/indi_pylibcamera.xml b/src/indi_pylibcamera/indi_pylibcamera.xml index c4173cd..aae4d43 100644 --- a/src/indi_pylibcamera/indi_pylibcamera.xml +++ b/src/indi_pylibcamera/indi_pylibcamera.xml @@ -2,7 +2,7 @@ indi_pylibcamera - 2.1.1 + 2.2.0 diff --git a/src/indi_pylibcamera/indidevice.py b/src/indi_pylibcamera/indidevice.py index b4c3e50..1b88295 100755 --- a/src/indi_pylibcamera/indidevice.py +++ b/src/indi_pylibcamera/indidevice.py @@ -68,6 +68,7 @@ class ISwitchState: class UnblockTTY: """configure stdout for unblocking write """ + # shameless copy from https://stackoverflow.com/questions/67351928/getting-a-blockingioerror-when-printing-or-writting-to-stdout def __enter__(self): self.fd = sys.stdout.fileno() @@ -149,9 +150,10 @@ class IVector: def __init__( self, device: str, name: str, elements: list = [], - label: str =None, group: str ="", + label: str = None, group: str = "", state: str = IVectorState.IDLE, perm: str = IPermission.RW, - timeout: int = 60, timestamp: bool = False, message: str = None + timeout: int = 60, timestamp: bool = False, message: str = None, + is_savable: bool = True, ): """constructor @@ -166,11 +168,13 @@ def __init__( timeout: timeout timestamp: send messages with (True) or without (False) timestamp message: message send to client + is_savable: can be saved """ self._vectorType = "NotSet" self.device = device self.name = name self.elements = elements + self.driver_default = {element.name: element.value for element in self.elements} if label: self.label = label else: @@ -181,6 +185,7 @@ def __init__( self.timeout = timeout self.timestamp = timestamp self.message = message + self.is_savable = is_savable def __str__(self) -> str: return f"" @@ -242,8 +247,8 @@ def get_defVector(self) -> str: xml += f' rule="{self.rule}"' xml += f' perm="{self.perm}" state="{self.state}" group="{self.group}"' xml += f' label="{self.label}" name="{self.name}"' - #if self.timeout: - # xml += f' timeout="{self.timeout}"' + if self.timeout: + xml += f' timeout="{self.timeout}"' if self.timestamp: xml += f' timestamp="{get_TimeStamp()}"' if self.message: @@ -327,6 +332,26 @@ def set_byClient(self, values: dict): self.send_setVector() self.message = "" + def save(self): + """return Vector state + + Returns: + None if Vector is not savable + dict with Vector state + """ + state = None + if self.is_savable: + state = dict() + state["name"] = self.name + state["values"] = {element.name: element.value for element in self.elements} + return state + + def restore_DriverDefault(self): + """restore driver defaults for savable vector + """ + if self.is_savable: + self.set_byClient(self.driver_default) + class IText(IProperty): """INDI Text property @@ -362,12 +387,13 @@ def __init__( self, device: str, name: str, elements: list = [], label: str = None, group: str = "", - state: str = IVectorState.IDLE, perm: str =IPermission.RW, - timeout: int = 60, timestamp: bool = False, message: str = None + state: str = IVectorState.IDLE, perm: str = IPermission.RW, + timeout: int = 60, timestamp: bool = False, message: str = None, + is_savable: bool = True, ): super().__init__( device=device, name=name, elements=elements, label=label, group=group, - state=state, perm=perm, timeout=timeout, timestamp=timestamp, message=message + state=state, perm=perm, timeout=timeout, timestamp=timestamp, message=message, is_savable=is_savable, ) self._vectorType = "TextVector" @@ -416,11 +442,12 @@ def __init__( device: str, name: str, elements: list = [], label: str = None, group: str = "", state: str = IVectorState.IDLE, perm: str = IPermission.RW, - timeout: int = 60, timestamp: bool = False, message: str = None + timeout: int = 60, timestamp: bool = False, message: str = None, + is_savable: bool = True, ): super().__init__( device=device, name=name, elements=elements, label=label, group=group, - state=state, perm=perm, timeout=timeout, timestamp=timestamp, message=message + state=state, perm=perm, timeout=timeout, timestamp=timestamp, message=message, is_savable=is_savable, ) self._vectorType = "NumberVector" @@ -461,11 +488,12 @@ def __init__( label: str = None, group: str = "", state: str = IVectorState.IDLE, perm: str = IPermission.RW, rule: str = ISwitchRule.ONEOFMANY, - timeout: int = 60, timestamp: bool = False, message: str = None + timeout: int = 60, timestamp: bool = False, message: str = None, + is_savable: bool = True, ): super().__init__( device=device, name=name, elements=elements, label=label, group=group, - state=state, perm=perm, timeout=timeout, timestamp=timestamp, message=message + state=state, perm=perm, timeout=timeout, timestamp=timestamp, message=message, is_savable=is_savable, ) self._vectorType = "SwitchVector" self.rule = rule @@ -526,7 +554,6 @@ def update_SwitchStates(self, values: dict) -> str: message = "; ".join(errmsgs) return message - def set_byClient(self, values: dict): """called when vector gets set by client Special implementation for ISwitchVector to follow switch rules. @@ -558,7 +585,7 @@ def __init__(self, name: str, label: str = None): self.data = b'' self.enabled = "Only" - def set_data(self, data: bytes, format: str =".fits", compress: bool =False): + def set_data(self, data: bytes, format: str = ".fits", compress: bool = False): """set BLOB data Args: @@ -583,7 +610,7 @@ def get_defProperty(self) -> str: def get_oneProperty(self) -> str: """return XML for oneBLOB message """ - xml ="" + xml = "" if self.enabled in ["Also", "Only"]: xml += f'' xml += base64.b64encode(self.data).decode() @@ -600,14 +627,21 @@ def __init__( device: str, name: str, elements: list = [], label: str = None, group: str = "", state: str = IVectorState.IDLE, perm: str = IPermission.RO, - timeout: int = 60, timestamp: bool = False, message: str = None + timeout: int = 60, timestamp: bool = False, message: str = None, + is_savable: bool = True, ): super().__init__( device=device, name=name, elements=elements, label=label, group=group, - state=state, perm=perm, timeout=timeout, timestamp=timestamp, message=message + state=state, perm=perm, timeout=timeout, timestamp=timestamp, message=message, is_savable=is_savable, ) self._vectorType = "BLOBVector" + def send_setVector(self): + """tell client about vector data, special version for IBlobVector to avoid double calculation of setVector + """ + # logging.debug(f'send_setVector: {self.get_setVector()[:100]}') + to_server(self.get_setVector()) + class IVectorList: """list of vectors @@ -700,7 +734,6 @@ def __init__(self, device: str): # snooping self.SnoopingManager = SnoopingManager.SnoopingManager(parent=self, to_server_func=to_server) - def send_Message(self, message: str, severity: str = "INFO", timestamp: bool = False): """send message to client @@ -760,10 +793,12 @@ def message_loop(self): with self.knownVectorsLock: vector.set_byClient(values) else: - logging.error(f'could not interpret client request: {etree.tostring(xml, pretty_print=True).decode()}') + logging.error( + f'could not interpret client request: {etree.tostring(xml, pretty_print=True).decode()}') else: # can be a snooped device - if xml.tag in ["setNumberVector", "setTextVector", "setSwitchVector", "defNumberVector", "defTextVector", "defSwitchVector"]: + if xml.tag in ["setNumberVector", "setTextVector", "setSwitchVector", "defNumberVector", + "defTextVector", "defSwitchVector"]: vectorName = xml.attrib["name"] values = {ele.attrib["name"]: (ele.text.strip() if type(ele.text) is str else "") for ele in xml} with self.knownVectorsLock: @@ -772,8 +807,8 @@ def message_loop(self): # snooped device got closed pass else: - logging.error(f'could not interpret client request: {etree.tostring(xml, pretty_print=True).decode()}') - + logging.error( + f'could not interpret client request: {etree.tostring(xml, pretty_print=True).decode()}') def checkin(self, vector: IVector, send_defVector: bool = False): """add vector to knownVectors list @@ -789,7 +824,7 @@ def checkout(self, name: str): """ self.knownVectors.checkout(name) - def setVector(self, name: str, element: str, value = None, state: IVectorState = None, send: bool = True): + def setVector(self, name: str, element: str, value=None, state: IVectorState = None, send: bool = True): """update vector value and/or state Args: diff --git a/src/indi_pylibcamera/print_camera_information.py b/src/indi_pylibcamera/print_camera_information.py index 053995d..a0926ee 100755 --- a/src/indi_pylibcamera/print_camera_information.py +++ b/src/indi_pylibcamera/print_camera_information.py @@ -39,6 +39,9 @@ def main(): print("Raw sensor modes:") pprint.pprint(picam2.sensor_modes) print() + print("Camera configuration:") + pprint.pprint(picam2.camera_configuration()) + print() print('Camera controls:') pprint.pprint(picam2.camera_controls) print()