├── setup.py ├── pyproject.toml ├── src ├── indi_pylibcamera │ ├── __init__.py │ ├── indi_pylibcamera2.py │ ├── indi_pylibcamera3.py │ ├── indi_pylibcamera4.py │ ├── indi_pylibcamera5.py │ ├── indi_pylibcamera.xml │ ├── indi_pylibcamera2.xml │ ├── indi_pylibcamera3.xml │ ├── indi_pylibcamera4.xml │ ├── indi_pylibcamera5.xml │ ├── make_driver_xml.py │ ├── print_camera_information.py │ ├── indi_pylibcamera_postinstall.py │ ├── indi_pylibcamera3.ini │ ├── indi_pylibcamera4.ini │ ├── indi_pylibcamera5.ini │ ├── indi_pylibcamera.ini │ ├── indi_pylibcamera2.ini │ ├── SnoopingManager.py │ ├── indidevice.py │ └── CameraControl.py ├── indi_pylibcamera.sh ├── indi_pylibcamera2.sh ├── indi_pylibcamera3.sh ├── indi_pylibcamera4.sh └── indi_pylibcamera5.sh ├── testpattern └── RBG_testpattern.png ├── MANIFEST.in ├── LICENSE ├── setup.cfg ├── CameraInfos ├── Arducam_Pivariety_IMX462.txt ├── IMX290.txt ├── Raspi_M3_IMX708.txt ├── Raspi_GlobalShutter_IMX296.txt ├── Raspi_V1_OV5647.txt └── Raspi_HQ_IMX477.txt ├── .gitignore ├── CHANGELOG └── README.md /setup.py: -------------------------------------------------------------------------------- 1 | 2 | from setuptools import setup 3 | 4 | 5 | setup() 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | INDI driver for libcamera supported cameras 3 | """ 4 | 5 | __version__ = "3.1.0" 6 | -------------------------------------------------------------------------------- /testpattern/RBG_testpattern.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scriptorron/indi_pylibcamera/HEAD/testpattern/RBG_testpattern.png -------------------------------------------------------------------------------- /src/indi_pylibcamera/indi_pylibcamera2.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from .indi_pylibcamera import main as main1 4 | 5 | 6 | def main(driver_instance="2"): 7 | main1(driver_instance=driver_instance) 8 | 9 | if __name__ == "__main__": 10 | main() 11 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/indi_pylibcamera3.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from .indi_pylibcamera import main as main1 4 | 5 | 6 | def main(driver_instance="3"): 7 | main1(driver_instance=driver_instance) 8 | 9 | 10 | if __name__ == "__main__": 11 | main() 12 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/indi_pylibcamera4.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from .indi_pylibcamera import main as main1 4 | 5 | 6 | def main(driver_instance="4"): 7 | main1(driver_instance=driver_instance) 8 | 9 | 10 | if __name__ == "__main__": 11 | main() 12 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/indi_pylibcamera5.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from .indi_pylibcamera import main as main1 4 | 5 | 6 | def main(driver_instance="5"): 7 | main1(driver_instance=driver_instance) 8 | 9 | 10 | if __name__ == "__main__": 11 | main() 12 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/indi_pylibcamera.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | indi_pylibcamera 5 | 3.1.0 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/indi_pylibcamera2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | indi_pylibcamera2 5 | 3.1.0 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/indi_pylibcamera3.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | indi_pylibcamera3 5 | 3.1.0 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/indi_pylibcamera4.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | indi_pylibcamera4 5 | 3.1.0 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/indi_pylibcamera5.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | indi_pylibcamera5 5 | 3.1.0 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/indi_pylibcamera.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | # script to start indi_pylibcamera driver without pip installation 4 | import re 5 | import sys 6 | from indi_pylibcamera.indi_pylibcamera import main 7 | if __name__ == '__main__': 8 | sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) 9 | sys.exit(main()) 10 | -------------------------------------------------------------------------------- /src/indi_pylibcamera2.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | # script to start indi_pylibcamera driver without pip installation 4 | import re 5 | import sys 6 | from indi_pylibcamera.indi_pylibcamera2 import main 7 | if __name__ == '__main__': 8 | sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) 9 | sys.exit(main()) 10 | -------------------------------------------------------------------------------- /src/indi_pylibcamera3.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | # script to start indi_pylibcamera driver without pip installation 4 | import re 5 | import sys 6 | from indi_pylibcamera.indi_pylibcamera3 import main 7 | if __name__ == '__main__': 8 | sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) 9 | sys.exit(main()) 10 | -------------------------------------------------------------------------------- /src/indi_pylibcamera4.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | # script to start indi_pylibcamera driver without pip installation 4 | import re 5 | import sys 6 | from indi_pylibcamera.indi_pylibcamera4 import main 7 | if __name__ == '__main__': 8 | sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) 9 | sys.exit(main()) 10 | -------------------------------------------------------------------------------- /src/indi_pylibcamera5.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | # script to start indi_pylibcamera driver without pip installation 4 | import re 5 | import sys 6 | from indi_pylibcamera.indi_pylibcamera5 import main 7 | if __name__ == '__main__': 8 | sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) 9 | sys.exit(main()) 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include src/indi_pylibcamera/indi_pylibcamera.ini 2 | include src/indi_pylibcamera/indi_pylibcamera.xml 3 | include src/indi_pylibcamera/indi_pylibcamera2.ini 4 | include src/indi_pylibcamera/indi_pylibcamera2.xml 5 | include src/indi_pylibcamera/indi_pylibcamera3.ini 6 | include src/indi_pylibcamera/indi_pylibcamera3.xml 7 | include src/indi_pylibcamera/indi_pylibcamera4.ini 8 | include src/indi_pylibcamera/indi_pylibcamera4.xml 9 | include src/indi_pylibcamera/indi_pylibcamera5.ini 10 | include src/indi_pylibcamera/indi_pylibcamera5.xml 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022, 2023 scriptorron 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = indi_pylibcamera 3 | version = attr: indi_pylibcamera.__version__ 4 | author = Ronald Schreiber 5 | author_email = ronald.schreiber01@gmx.de 6 | description = An INDI driver for Raspberry Pi cameras supported by libcamera 7 | long_description = file: README.md, CHANGELOG, LICENSE 8 | long_description_content_type = text/markdown 9 | url = https://github.com/scriptorron/indi_pylibcamera 10 | license_files = LICENSE 11 | 12 | [options] 13 | package_dir= 14 | =src 15 | packages = find: 16 | include_package_data = True 17 | python_requires = >=3.7 18 | #install_requires = 19 | # numpy>=1.24 20 | # astropy>=5.2 21 | # picamera2>=0.3 22 | 23 | [options.packages.find] 24 | where=src 25 | 26 | 27 | [options.entry_points] 28 | console_scripts = 29 | indi_pylibcamera = indi_pylibcamera.indi_pylibcamera:main 30 | indi_pylibcamera2 = indi_pylibcamera.indi_pylibcamera2:main 31 | indi_pylibcamera3 = indi_pylibcamera.indi_pylibcamera3:main 32 | indi_pylibcamera4 = indi_pylibcamera.indi_pylibcamera4:main 33 | indi_pylibcamera5 = indi_pylibcamera.indi_pylibcamera5:main 34 | indi_pylibcamera_postinstall = indi_pylibcamera.indi_pylibcamera_postinstall:main 35 | indi_pylibcamera_print_camera_information = indi_pylibcamera.print_camera_information:main 36 | 37 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/make_driver_xml.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | make indi_pylibcamera.xml for integration in EKOS 4 | """ 5 | 6 | from __init__ import __version__ 7 | from lxml import etree 8 | 9 | 10 | def make_driver_xml(instance): 11 | """create driver XML 12 | 13 | Returns: 14 | ElementTree object with contents of XML file 15 | """ 16 | driversList = etree.Element("driversList") 17 | devGroup = etree.SubElement(driversList, "devGroup", {"group": "CCDs"}) 18 | device = etree.SubElement(devGroup, "device", {"label": "INDI pylibcamera" + instance}) 19 | driver = etree.SubElement(device, "driver", {"name": "INDI pylibcamera" + instance}) 20 | driver.text = "indi_pylibcamera" + instance 21 | version = etree.SubElement(device, "version") 22 | version.text = str(__version__) 23 | return etree.ElementTree(driversList) 24 | 25 | def write_driver_xml(filename, instance=""): 26 | make_driver_xml(instance).write(filename, pretty_print=True) 27 | 28 | 29 | # main entry point 30 | if __name__ == "__main__": 31 | write_driver_xml(filename="indi_pylibcamera.xml") 32 | write_driver_xml(filename="indi_pylibcamera2.xml", instance="2") 33 | write_driver_xml(filename="indi_pylibcamera3.xml", instance="3") 34 | write_driver_xml(filename="indi_pylibcamera4.xml", instance="4") 35 | write_driver_xml(filename="indi_pylibcamera5.xml", instance="5") 36 | -------------------------------------------------------------------------------- /CameraInfos/Arducam_Pivariety_IMX462.txt: -------------------------------------------------------------------------------- 1 | Found 1 cameras. 2 | 3 | Camera 0: 4 | {'Id': '/base/soc/i2c0mux/i2c@1/arducam_pivariety@c', 5 | 'Location': 2, 6 | 'Model': 'arducam-pivariety', 7 | 'Rotation': 0} 8 | 9 | Camera properties: 10 | {'ColorFilterArrangement': 0, 11 | 'Location': 2, 12 | 'Model': 'arducam-pivariety', 13 | 'PixelArrayActiveAreas': (libcamera.Rectangle(0, 0, 1920, 1080),), 14 | 'PixelArraySize': (1920, 1080), 15 | 'Rotation': 0, 16 | 'ScalerCropMaximum': (0, 0, 0, 0)} 17 | 18 | Raw sensor modes: 19 | [{'bit_depth': 10, 20 | 'crop_limits': (0, 0, 1920, 1080), 21 | 'exposure_limits': (14, 15534385, None), 22 | 'format': SRGGB10_CSI2P, 23 | 'fps': 60.0, 24 | 'size': (1920, 1080), 25 | 'unpacked': 'SRGGB10'}] 26 | 27 | Camera controls: 28 | {'AeConstraintMode': (0, 3, 0), 29 | 'AeEnable': (False, True, None), 30 | 'AeExposureMode': (0, 3, 0), 31 | 'AeMeteringMode': (0, 3, 0), 32 | 'AnalogueGain': (1.0, 200.0, None), 33 | 'AwbEnable': (False, True, None), 34 | 'AwbMode': (0, 7, 0), 35 | 'Brightness': (-1.0, 1.0, 0.0), 36 | 'ColourCorrectionMatrix': (-16.0, 16.0, None), 37 | 'ColourGains': (0.0, 32.0, None), 38 | 'Contrast': (0.0, 32.0, 1.0), 39 | 'ExposureTime': (14, 15534385, None), 40 | 'ExposureValue': (-8.0, 8.0, 0.0), 41 | 'FrameDurationLimits': (16666, 15534444, None), 42 | 'NoiseReductionMode': (0, 4, 0), 43 | 'Saturation': (0.0, 32.0, 1.0), 44 | 'ScalerCrop': ((0, 0, 64, 64), (0, 0, 1920, 1080), (240, 0, 1440, 1080)), 45 | 'Sharpness': (0.0, 16.0, 1.0)} 46 | 47 | Exposure time: 48 | min: 14, max: 15534385, default: None 49 | 50 | AnalogGain: 51 | min: 1.0, max: 200.0, default: None 52 | 53 | -------------------------------------------------------------------------------- /CameraInfos/IMX290.txt: -------------------------------------------------------------------------------- 1 | Found 1 cameras. 2 | 3 | Camera 0: 4 | {'Id': '/base/soc/i2c0mux/i2c@1/imx290@1a', 5 | 'Location': 2, 6 | 'Model': 'imx290', 7 | 'Rotation': 0} 8 | 9 | Camera properties: 10 | {'ColorFilterArrangement': 0, 11 | 'Location': 2, 12 | 'Model': 'imx290', 13 | 'PixelArrayActiveAreas': [(12, 8, 1920, 1080)], 14 | 'PixelArraySize': (1945, 1097), 15 | 'Rotation': 0, 16 | 'ScalerCropMaximum': (0, 0, 0, 0), 17 | 'UnitCellSize': (2900, 2900)} 18 | 19 | Raw sensor modes: 20 | [{'bit_depth': 10, 21 | 'crop_limits': (320, 180, 1280, 720), 22 | 'exposure_limits': (22, 115686250, None), 23 | 'format': SRGGB10_CSI2P, 24 | 'fps': 60.0, 25 | 'size': (1280, 720), 26 | 'unpacked': 'SRGGB10'}, 27 | {'bit_depth': 10, 28 | 'crop_limits': (0, 0, 1920, 1080), 29 | 'exposure_limits': (14, 115686258, None), 30 | 'format': SRGGB10_CSI2P, 31 | 'fps': 60.0, 32 | 'size': (1920, 1080), 33 | 'unpacked': 'SRGGB10'}, 34 | {'bit_depth': 12, 35 | 'crop_limits': (320, 180, 1280, 720), 36 | 'exposure_limits': (22, 115686250, None), 37 | 'format': SRGGB12_CSI2P, 38 | 'fps': 60.0, 39 | 'size': (1280, 720), 40 | 'unpacked': 'SRGGB12'}, 41 | {'bit_depth': 12, 42 | 'crop_limits': (0, 0, 1920, 1080), 43 | 'exposure_limits': (14, 115686258, None), 44 | 'format': SRGGB12_CSI2P, 45 | 'fps': 60.0, 46 | 'size': (1920, 1080), 47 | 'unpacked': 'SRGGB12'}] 48 | 49 | Camera controls: 50 | {'AeConstraintMode': (0, 3, 0), 51 | 'AeEnable': (False, True, None), 52 | 'AeExposureMode': (0, 3, 0), 53 | 'AeMeteringMode': (0, 3, 0), 54 | 'AnalogueGain': (1.0, 31.62277603149414, None), 55 | 'AwbEnable': (False, True, None), 56 | 'AwbMode': (0, 7, 0), 57 | 'Brightness': (-1.0, 1.0, 0.0), 58 | 'ColourCorrectionMatrix': (-16.0, 16.0, None), 59 | 'ColourGains': (0.0, 32.0, None), 60 | 'Contrast': (0.0, 32.0, 1.0), 61 | 'ExposureTime': (14, 115686258, None), 62 | 'ExposureValue': (-8.0, 8.0, 0.0), 63 | 'FrameDurationLimits': (16666, 115687148, None), 64 | 'NoiseReductionMode': (0, 4, 0), 65 | 'Saturation': (0.0, 32.0, 1.0), 66 | 'ScalerCrop': ((0, 0, 64, 64), (0, 0, 1920, 1080), (240, 0, 1440, 1080)), 67 | 'Sharpness': (0.0, 16.0, 1.0)} 68 | 69 | Exposure time: 70 | min: 14, max: 115686258, default: None 71 | 72 | AnalogGain: 73 | min: 1.0, max: 31.62277603149414, default: None 74 | 75 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/print_camera_information.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | from picamera2 import Picamera2 5 | import pprint 6 | 7 | def main(): 8 | # important libraries and their versions 9 | print("Testing numpy:") 10 | try: 11 | import numpy 12 | except BaseException as e: 13 | print(e) 14 | else: 15 | print(f' numpy {numpy.__version__}') 16 | print() 17 | print("Testing astropy:") 18 | try: 19 | import astropy 20 | except BaseException as e: 21 | print(e) 22 | else: 23 | print(f' astropy {astropy.__version__}') 24 | print() 25 | 26 | # list of available cameras: 27 | cameras = Picamera2.global_camera_info() 28 | print(f'Found {len(cameras)} cameras.') 29 | print() 30 | 31 | for c, camera in enumerate(cameras): 32 | print(f'Camera {c}:') 33 | pprint.pprint(cameras[c]) 34 | print() 35 | picam2 = Picamera2(c) 36 | print('Camera properties:') 37 | pprint.pprint(picam2.camera_properties) 38 | print() 39 | print("Raw sensor modes:") 40 | pprint.pprint(picam2.sensor_modes) 41 | print() 42 | print("Camera configuration:") 43 | pprint.pprint(picam2.camera_configuration()) 44 | print() 45 | print('Camera controls:') 46 | pprint.pprint(picam2.camera_controls) 47 | print() 48 | if "ExposureTime" in picam2.camera_controls: 49 | print('Exposure time:') 50 | min_exp, max_exp, default_exp = picam2.camera_controls["ExposureTime"] 51 | print(f' min: {min_exp}, max: {max_exp}, default: {default_exp}') 52 | else: 53 | print("ERROR: ExposureTime not in camera controls!") 54 | print() 55 | if "AnalogueGain" in picam2.camera_controls: 56 | print('AnalogGain:') 57 | min_again, max_again, default_again = picam2.camera_controls["AnalogueGain"] 58 | print(f' min: {min_again}, max: {max_again}, default: {default_again}') 59 | else: 60 | print("ERROR: AnalogueGain not in camera controls!") 61 | print() 62 | return 0 63 | 64 | 65 | if __name__ == "__main__": 66 | sys.exit(main()) 67 | -------------------------------------------------------------------------------- /CameraInfos/Raspi_M3_IMX708.txt: -------------------------------------------------------------------------------- 1 | Found 1 cameras. 2 | 3 | Camera 0: 4 | {'Id': '/base/soc/i2c0mux/i2c@1/imx708@1a', 5 | 'Location': 2, 6 | 'Model': 'imx708_noir', 7 | 'Rotation': 180} 8 | 9 | Camera properties: 10 | {'ColorFilterArrangement': 0, 11 | 'Location': 2, 12 | 'Model': 'imx708_noir', 13 | 'PixelArrayActiveAreas': (libcamera.Rectangle(16, 24, 4608, 2592),), 14 | 'PixelArraySize': (4608, 2592), 15 | 'Rotation': 180, 16 | 'ScalerCropMaximum': (0, 0, 0, 0), 17 | 'UnitCellSize': (1400, 1400)} 18 | 19 | Raw sensor modes: 20 | [{'bit_depth': 10, 21 | 'crop_limits': (0, 0, 4608, 2592), 22 | 'exposure_limits': (9, 603302, None), 23 | 'format': SRGGB10_CSI2P, 24 | 'fps': 120.13, 25 | 'size': (1536, 864), 26 | 'unpacked': 'SRGGB10'}, 27 | {'bit_depth': 10, 28 | 'crop_limits': (0, 0, 4608, 2592), 29 | 'exposure_limits': (13, 875283, None), 30 | 'format': SRGGB10_CSI2P, 31 | 'fps': 56.03, 32 | 'size': (2304, 1296), 33 | 'unpacked': 'SRGGB10'}, 34 | {'bit_depth': 10, 35 | 'crop_limits': (0, 0, 4608, 2592), 36 | 'exposure_limits': (26, 1722331, None), 37 | 'format': SRGGB10_CSI2P, 38 | 'fps': 14.35, 39 | 'size': (4608, 2592), 40 | 'unpacked': 'SRGGB10'}] 41 | 42 | Camera controls: 43 | {'AeConstraintMode': (0, 3, 0), 44 | 'AeEnable': (False, True, None), 45 | 'AeExposureMode': (0, 3, 0), 46 | 'AeMeteringMode': (0, 3, 0), 47 | 'AfMetering': (0, 1, 0), 48 | 'AfMode': (0, 2, 0), 49 | 'AfPause': (0, 2, 0), 50 | 'AfRange': (0, 2, 0), 51 | 'AfSpeed': (0, 1, 0), 52 | 'AfTrigger': (0, 1, 0), 53 | 'AfWindows': ((0, 0, 0, 0), (65535, 65535, 65535, 65535), (0, 0, 0, 0)), 54 | 'AnalogueGain': (1.0, 16.0, None), 55 | 'AwbEnable': (False, True, None), 56 | 'AwbMode': (0, 7, 0), 57 | 'Brightness': (-1.0, 1.0, 0.0), 58 | 'ColourCorrectionMatrix': (-16.0, 16.0, None), 59 | 'ColourGains': (0.0, 32.0, None), 60 | 'Contrast': (0.0, 32.0, 1.0), 61 | 'ExposureTime': (26, 1722331, None), 62 | 'ExposureValue': (-8.0, 8.0, 0.0), 63 | 'FrameDurationLimits': (69669, 1722936, None), 64 | 'LensPosition': (0.0, 32.0, 1.0), 65 | 'NoiseReductionMode': (0, 4, 0), 66 | 'Saturation': (0.0, 32.0, 1.0), 67 | 'ScalerCrop': ((0, 0, 64, 64), (0, 0, 4608, 2592), (576, 0, 3456, 2592)), 68 | 'Sharpness': (0.0, 16.0, 1.0)} 69 | 70 | Exposure time: 71 | min: 26, max: 1722331, default: None 72 | 73 | AnalogGain: 74 | min: 1.0, max: 16.0, default: None 75 | 76 | -------------------------------------------------------------------------------- /CameraInfos/Raspi_GlobalShutter_IMX296.txt: -------------------------------------------------------------------------------- 1 | Testing numpy: 2 | numpy 1.26.3 3 | 4 | Testing astropy: 5 | astropy 6.0.0 6 | 7 | Found 1 cameras. 8 | 9 | Camera 0: 10 | {'Id': '/base/soc/i2c0mux/i2c@1/imx296@1a', 11 | 'Location': 2, 12 | 'Model': 'imx296', 13 | 'Num': 0, 14 | 'Rotation': 180} 15 | 16 | Camera properties: 17 | {'ColorFilterArrangement': 5, 18 | 'Location': 2, 19 | 'Model': 'imx296', 20 | 'PixelArrayActiveAreas': [(0, 0, 1456, 1088)], 21 | 'PixelArraySize': (1456, 1088), 22 | 'Rotation': 180, 23 | 'ScalerCropMaximum': (0, 0, 0, 0), 24 | 'SystemDevices': (20749, 20741, 20743, 20744), 25 | 'UnitCellSize': (3450, 3450)} 26 | 27 | Raw sensor modes: 28 | [{'bit_depth': 10, 29 | 'crop_limits': (0, 0, 1456, 1088), 30 | 'exposure_limits': (29, None), 31 | 'format': R10_CSI2P, 32 | 'fps': 60.38, 33 | 'size': (1456, 1088), 34 | 'unpacked': 'R10'}] 35 | 36 | Camera configuration: 37 | {'buffer_count': 4, 38 | 'colour_space': , 39 | 'controls': {'FrameDurationLimits': (100, 83333), 40 | 'NoiseReductionMode': }, 41 | 'display': 'main', 42 | 'encode': 'main', 43 | 'lores': None, 44 | 'main': {'format': 'XBGR8888', 45 | 'framesize': 1228800, 46 | 'size': (640, 480), 47 | 'stride': 2560}, 48 | 'queue': True, 49 | 'raw': {'format': 'R10_CSI2P', 50 | 'framesize': 1984512, 51 | 'size': (1456, 1088), 52 | 'stride': 1824}, 53 | 'sensor': {'bit_depth': 10, 'output_size': (1456, 1088)}, 54 | 'transform': , 55 | 'use_case': 'preview'} 56 | 57 | Camera controls: 58 | {'AeConstraintMode': (0, 3, 0), 59 | 'AeEnable': (False, True, None), 60 | 'AeExposureMode': (0, 3, 0), 61 | 'AeFlickerMode': (0, 1, 0), 62 | 'AeFlickerPeriod': (100, 1000000, None), 63 | 'AeMeteringMode': (0, 3, 0), 64 | 'AnalogueGain': (1.0, 251.1886444091797, None), 65 | 'Brightness': (-1.0, 1.0, 0.0), 66 | 'Contrast': (0.0, 32.0, 1.0), 67 | 'ExposureTime': (29, 0, None), 68 | 'ExposureValue': (-8.0, 8.0, 0.0), 69 | 'FrameDurationLimits': (16562, 15534444, None), 70 | 'HdrMode': (0, 4, 0), 71 | 'NoiseReductionMode': (0, 4, 0), 72 | 'ScalerCrop': ((0, 0, 64, 64), (0, 0, 1456, 1088), (3, 0, 1450, 1088)), 73 | 'Sharpness': (0.0, 16.0, 1.0)} 74 | 75 | Exposure time: 76 | min: 29, max: 0, default: None 77 | 78 | AnalogGain: 79 | min: 1.0, max: 251.1886444091797, default: None 80 | 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /CameraInfos/Raspi_V1_OV5647.txt: -------------------------------------------------------------------------------- 1 | Testing numpy: 2 | numpy 1.19.5 3 | 4 | Testing astropy: 5 | astropy 4.2 6 | 7 | Found 1 cameras. 8 | 9 | Camera 0: 10 | {'Id': '/base/soc/i2c0mux/i2c@1/ov5647@36', 11 | 'Location': 2, 12 | 'Model': 'ov5647', 13 | 'Rotation': 0} 14 | 15 | Camera properties: 16 | {'ColorFilterArrangement': 2, 17 | 'Location': 2, 18 | 'Model': 'ov5647', 19 | 'PixelArrayActiveAreas': [(16, 6, 2592, 1944)], 20 | 'PixelArraySize': (2592, 1944), 21 | 'Rotation': 0, 22 | 'ScalerCropMaximum': (0, 0, 0, 0), 23 | 'SystemDevices': (20749, 20737, 20738, 20739), 24 | 'UnitCellSize': (1400, 1400)} 25 | 26 | Raw sensor modes: 27 | [{'bit_depth': 10, 28 | 'crop_limits': (16, 0, 2560, 1920), 29 | 'exposure_limits': (134, 2147483647, None), 30 | 'format': SGBRG10_CSI2P, 31 | 'fps': 58.92, 32 | 'size': (640, 480), 33 | 'unpacked': 'SGBRG10'}, 34 | {'bit_depth': 10, 35 | 'crop_limits': (0, 0, 2592, 1944), 36 | 'exposure_limits': (92, 760565, None), 37 | 'format': SGBRG10_CSI2P, 38 | 'fps': 43.25, 39 | 'size': (1296, 972), 40 | 'unpacked': 'SGBRG10'}, 41 | {'bit_depth': 10, 42 | 'crop_limits': (348, 434, 1928, 1080), 43 | 'exposure_limits': (118, 760636, None), 44 | 'format': SGBRG10_CSI2P, 45 | 'fps': 30.62, 46 | 'size': (1920, 1080), 47 | 'unpacked': 'SGBRG10'}, 48 | {'bit_depth': 10, 49 | 'crop_limits': (0, 0, 2592, 1944), 50 | 'exposure_limits': (130, 969249, None), 51 | 'format': SGBRG10_CSI2P, 52 | 'fps': 15.63, 53 | 'size': (2592, 1944), 54 | 'unpacked': 'SGBRG10'}] 55 | 56 | Camera configuration: 57 | {'buffer_count': 4, 58 | 'colour_space': , 59 | 'controls': {'FrameDurationLimits': (100, 83333), 60 | 'NoiseReductionMode': }, 61 | 'display': 'main', 62 | 'encode': 'main', 63 | 'lores': None, 64 | 'main': {'format': 'XBGR8888', 65 | 'framesize': 1228800, 66 | 'size': (640, 480), 67 | 'stride': 2560}, 68 | 'queue': True, 69 | 'raw': {'format': 'SGBRG10_CSI2P', 70 | 'framesize': 6345216, 71 | 'size': (2592, 1944), 72 | 'stride': 3264}, 73 | 'transform': , 74 | 'use_case': 'preview'} 75 | 76 | Camera controls: 77 | {'AeConstraintMode': (0, 3, 0), 78 | 'AeEnable': (False, True, None), 79 | 'AeExposureMode': (0, 3, 0), 80 | 'AeMeteringMode': (0, 3, 0), 81 | 'AnalogueGain': (1.0, 63.9375, None), 82 | 'AwbEnable': (False, True, None), 83 | 'AwbMode': (0, 7, 0), 84 | 'Brightness': (-1.0, 1.0, 0.0), 85 | 'ColourGains': (0.0, 32.0, None), 86 | 'Contrast': (0.0, 32.0, 1.0), 87 | 'ExposureTime': (130, 969249, None), 88 | 'ExposureValue': (-8.0, 8.0, 0.0), 89 | 'FrameDurationLimits': (63965, 1065021, None), 90 | 'NoiseReductionMode': (0, 4, 0), 91 | 'Saturation': (0.0, 32.0, 1.0), 92 | 'ScalerCrop': ((0, 0, 64, 64), (0, 0, 2592, 1944), (0, 0, 2592, 1944)), 93 | 'Sharpness': (0.0, 16.0, 1.0)} 94 | 95 | Exposure time: 96 | min: 130, max: 969249, default: None 97 | 98 | AnalogGain: 99 | min: 1.0, max: 63.9375, default: None 100 | 101 | -------------------------------------------------------------------------------- /CameraInfos/Raspi_HQ_IMX477.txt: -------------------------------------------------------------------------------- 1 | Testing numpy: 2 | numpy 1.19.5 3 | 4 | Testing astropy: 5 | astropy 4.2 6 | 7 | Found 1 cameras. 8 | 9 | Camera 0: 10 | {'Id': '/base/soc/i2c0mux/i2c@1/imx477@1a', 11 | 'Location': 2, 12 | 'Model': 'imx477', 13 | 'Rotation': 0} 14 | 15 | Camera properties: 16 | {'ColorFilterArrangement': 0, 17 | 'Location': 2, 18 | 'Model': 'imx477', 19 | 'PixelArrayActiveAreas': [(8, 16, 4056, 3040)], 20 | 'PixelArraySize': (4056, 3040), 21 | 'Rotation': 0, 22 | 'ScalerCropMaximum': (0, 0, 0, 0), 23 | 'SystemDevices': (20750, 20751, 20737, 20738, 20739), 24 | 'UnitCellSize': (1550, 1550)} 25 | 26 | Raw sensor modes: 27 | [{'bit_depth': 10, 28 | 'crop_limits': (696, 528, 2664, 1980), 29 | 'exposure_limits': (31, None), 30 | 'format': SRGGB10_CSI2P, 31 | 'fps': 120.05, 32 | 'size': (1332, 990), 33 | 'unpacked': 'SRGGB10'}, 34 | {'bit_depth': 12, 35 | 'crop_limits': (0, 440, 4056, 2160), 36 | 'exposure_limits': (60, 667244877, None), 37 | 'format': SRGGB12_CSI2P, 38 | 'fps': 50.03, 39 | 'size': (2028, 1080), 40 | 'unpacked': 'SRGGB12'}, 41 | {'bit_depth': 12, 42 | 'crop_limits': (0, 0, 4056, 3040), 43 | 'exposure_limits': (60, 674181621, None), 44 | 'format': SRGGB12_CSI2P, 45 | 'fps': 40.01, 46 | 'size': (2028, 1520), 47 | 'unpacked': 'SRGGB12'}, 48 | {'bit_depth': 12, 49 | 'crop_limits': (0, 0, 4056, 3040), 50 | 'exposure_limits': (114, 674191602, None), 51 | 'format': SRGGB12_CSI2P, 52 | 'fps': 10.0, 53 | 'size': (4056, 3040), 54 | 'unpacked': 'SRGGB12'}] 55 | 56 | Camera configuration: 57 | {'buffer_count': 4, 58 | 'colour_space': , 59 | 'controls': {'FrameDurationLimits': (100, 83333), 60 | 'NoiseReductionMode': }, 61 | 'display': 'main', 62 | 'encode': 'main', 63 | 'lores': None, 64 | 'main': {'format': 'XBGR8888', 65 | 'framesize': 1228800, 66 | 'size': (640, 480), 67 | 'stride': 2560}, 68 | 'queue': True, 69 | 'raw': {'format': 'SBGGR12_CSI2P', 70 | 'framesize': 18580480, 71 | 'size': (4056, 3040), 72 | 'stride': 6112}, 73 | 'transform': , 74 | 'use_case': 'preview'} 75 | 76 | Camera controls: 77 | {'AeConstraintMode': (0, 3, 0), 78 | 'AeEnable': (False, True, None), 79 | 'AeExposureMode': (0, 3, 0), 80 | 'AeMeteringMode': (0, 3, 0), 81 | 'AnalogueGain': (1.0, 22.2608699798584, None), 82 | 'AwbEnable': (False, True, None), 83 | 'AwbMode': (0, 7, 0), 84 | 'Brightness': (-1.0, 1.0, 0.0), 85 | 'ColourGains': (0.0, 32.0, None), 86 | 'Contrast': (0.0, 32.0, 1.0), 87 | 'ExposureTime': (114, 674191602, None), 88 | 'ExposureValue': (-8.0, 8.0, 0.0), 89 | 'FrameDurationLimits': (100000, 694434742, None), 90 | 'NoiseReductionMode': (0, 4, 0), 91 | 'Saturation': (0.0, 32.0, 1.0), 92 | 'ScalerCrop': ((0, 0, 64, 64), (0, 0, 4056, 3040), (2, 0, 4053, 3040)), 93 | 'Sharpness': (0.0, 16.0, 1.0)} 94 | 95 | Exposure time: 96 | min: 114, max: 674191602, default: None 97 | 98 | AnalogGain: 99 | min: 1.0, max: 22.2608699798584, default: None 100 | 101 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/indi_pylibcamera_postinstall.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Post-installation script for indi_pylibcamera. 4 | 5 | This script creates a symbolic link to indi_pylibcamera.xml in /usr/share/indi 6 | Run it with root privileges. 7 | """ 8 | 9 | import sys 10 | import os 11 | import os.path 12 | 13 | 14 | default_indi_path = "/usr/share/indi" 15 | 16 | 17 | def create_Link(indi_path, overwrite=True): 18 | xml_name = "indi_pylibcamera.xml" 19 | src = os.path.join(os.path.dirname(__file__), xml_name) 20 | dest = os.path.join(indi_path, xml_name) 21 | if overwrite: 22 | try: 23 | os.remove(dest) 24 | except FileNotFoundError: 25 | # file was not existing 26 | pass 27 | except PermissionError: 28 | print(f'ERROR: You need to run this with root permissions (sudo).') 29 | return -3 30 | try: 31 | os.symlink(src, dest) 32 | except FileExistsError: 33 | print(f'ERROR: File {dest} exists. Please remove it before running this script.') 34 | return -1 35 | except FileNotFoundError: 36 | print(f'ERROR: File {dest} could not be created. Is the INDI path wrong?') 37 | return -2 38 | except PermissionError: 39 | print(f'ERROR: You need to run this with root permissions (sudo).') 40 | return -3 41 | return 0 42 | 43 | 44 | def create_LinkInteractive(interactive, indi_path): 45 | if interactive: 46 | print(""" 47 | This script tells INDI about the installation of the indi_pylibcamera driver. It is only needed to run this 48 | script once after installing INDI (KStars) and indi_pylibcamera. 49 | 50 | Please run this script with root privileges (sudo). 51 | 52 | """) 53 | while True: 54 | inp_cont = input("Do you want to continue? (Y/n): ").lower() 55 | if inp_cont in ["", "y", "yes"]: 56 | break 57 | elif inp_cont in ["n", "no"]: 58 | return 59 | inp_indi_path = input( 60 | f'Path to INDI driver XMLs (must contain "driver.xml") (press ENTER to leave default {indi_path}): ' 61 | ) 62 | if len(inp_indi_path) > 0: 63 | indi_path = inp_indi_path 64 | print(f'Creating symbolic link in {indi_path}...') 65 | ret = create_Link(indi_path=indi_path, overwrite=True) 66 | if interactive: 67 | if ret == 0: 68 | print("Done.") 69 | else: 70 | print(f'Exit with error {ret}.') 71 | return ret 72 | 73 | 74 | def main(): 75 | import argparse 76 | 77 | parser = argparse.ArgumentParser( 78 | prog="indi_pylibcamera_postinstall", 79 | description="Make settings in INDI to use indi_pylibcamera.", 80 | ) 81 | parser.add_argument("-s", "--silent", action="store_true", help="run silently") 82 | parser.add_argument("-p", "--path", type=str, default=default_indi_path, 83 | help=f'path to INDI driver XMLs, default: {default_indi_path}') 84 | args = parser.parse_args() 85 | # 86 | create_LinkInteractive(interactive=not args.silent, indi_path=args.path) 87 | 88 | 89 | if __name__ == "__main__": 90 | sys.exit(main()) 91 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/indi_pylibcamera3.ini: -------------------------------------------------------------------------------- 1 | [driver] 2 | 3 | # DeviceName is the camera name you will see in EKOS or INDI client 4 | DeviceName=pylibcamera3 5 | 6 | # The driver can connect to different camera devices. Your client software will show you a list of available 7 | # devices (for instance: "imx477, Num0, Loc2" or "imx477, Num1, Loc2") and it may have the option to automatically 8 | # connect. The following setting determines, which camera device in the list is initially selected. Counting starts 9 | # with 0. 10 | # If not set the fist camera will be selected and you may get an error when auto-connecting the same camera with 11 | # multiple drivers. 12 | SelectCameraDevice=2 13 | 14 | # INDI messages can have a time stamp. In most cases this is not needed to enable. 15 | # If you enable it make sure the system time on your Raspberry Pi is correct! 16 | SendTimeStamps=no 17 | 18 | # Libcamera does not tell all needed parameter for some cameras. The following can set or overwrite 19 | # these parameter. The values can be found in the datasheet of your camera. 20 | # Do not activate that when not absolutely needed. 21 | #force_UnitCellSize_X=2900 22 | #force_UnitCellSize_Y=2900 23 | #force_Rotation=0 # this has no effect anymore! 24 | #force_BayerOrder=BGGR 25 | 26 | # The following sets the initial value of the logging level. Possible values can be: 27 | # "Debug", "Info", "Warning", "Error". After startup you can change the logging level 28 | # by setting the "Logging" parameter. 29 | LoggingLevel=Info 30 | 31 | # The driver can ask other drivers for information. The INDI specification calls this "snooping". This allows 32 | # extended metadata in the FITS images, for instance telescope name, focal length, aperture, pointing direction 33 | # and more. 34 | # This feature requires the system time on your Raspberry Pi to be correct! 35 | DoSnooping=yes 36 | 37 | # Some cameras crash after the first exposure. Restarting the camera before every frame exposure can solve this issue. 38 | # Valid values are: 39 | # no - Do not restart if not needed to reconfigure camera. 40 | # yes - Always restart. Can lead to longer time between frames. 41 | # auto - automatically choose based on list of critical cameras 42 | # Default if not otherwise set in INI file is "auto". 43 | #force_Restart=auto 44 | #force_Restart=no 45 | #force_Restart=yes 46 | 47 | # From time to time astropy downloads the latest IERS-A table from internet. This will raise an error when the 48 | # the camera is not connected to internet. Therefore the auto update is disabled by default. That can lead to 49 | # small errors in the object coordinates stored in the FITS header. If your camera is internet connected you can 50 | # enable the autoupdate here: 51 | #enable_IERS_autoupdate=yes 52 | 53 | ##################################### 54 | # The following settings are to help debugging. Don't change them unasked! 55 | # 56 | # set CameraAdjustments=no to simulate an unknown camera 57 | CameraAdjustments=yes 58 | 59 | # set IgnoreRawModes=yes to simulate a camera without raw modes 60 | IgnoreRawModes=no 61 | 62 | # add button that prints all snooped values as info in log 63 | PrintSnoopedValuesButton=no 64 | 65 | # write information about exposed frame to log 66 | log_FrameInformation=no 67 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/indi_pylibcamera4.ini: -------------------------------------------------------------------------------- 1 | [driver] 2 | 3 | # DeviceName is the camera name you will see in EKOS or INDI client 4 | DeviceName=pylibcamera4 5 | 6 | # The driver can connect to different camera devices. Your client software will show you a list of available 7 | # devices (for instance: "imx477, Num0, Loc2" or "imx477, Num1, Loc2") and it may have the option to automatically 8 | # connect. The following setting determines, which camera device in the list is initially selected. Counting starts 9 | # with 0. 10 | # If not set the fist camera will be selected and you may get an error when auto-connecting the same camera with 11 | # multiple drivers. 12 | SelectCameraDevice=3 13 | 14 | # INDI messages can have a time stamp. In most cases this is not needed to enable. 15 | # If you enable it make sure the system time on your Raspberry Pi is correct! 16 | SendTimeStamps=no 17 | 18 | # Libcamera does not tell all needed parameter for some cameras. The following can set or overwrite 19 | # these parameter. The values can be found in the datasheet of your camera. 20 | # Do not activate that when not absolutely needed. 21 | #force_UnitCellSize_X=2900 22 | #force_UnitCellSize_Y=2900 23 | #force_Rotation=0 # this has no effect anymore! 24 | #force_BayerOrder=BGGR 25 | 26 | # The following sets the initial value of the logging level. Possible values can be: 27 | # "Debug", "Info", "Warning", "Error". After startup you can change the logging level 28 | # by setting the "Logging" parameter. 29 | LoggingLevel=Info 30 | 31 | # The driver can ask other drivers for information. The INDI specification calls this "snooping". This allows 32 | # extended metadata in the FITS images, for instance telescope name, focal length, aperture, pointing direction 33 | # and more. 34 | # This feature requires the system time on your Raspberry Pi to be correct! 35 | DoSnooping=yes 36 | 37 | # Some cameras crash after the first exposure. Restarting the camera before every frame exposure can solve this issue. 38 | # Valid values are: 39 | # no - Do not restart if not needed to reconfigure camera. 40 | # yes - Always restart. Can lead to longer time between frames. 41 | # auto - automatically choose based on list of critical cameras 42 | # Default if not otherwise set in INI file is "auto". 43 | #force_Restart=auto 44 | #force_Restart=no 45 | #force_Restart=yes 46 | 47 | # From time to time astropy downloads the latest IERS-A table from internet. This will raise an error when the 48 | # the camera is not connected to internet. Therefore the auto update is disabled by default. That can lead to 49 | # small errors in the object coordinates stored in the FITS header. If your camera is internet connected you can 50 | # enable the autoupdate here: 51 | #enable_IERS_autoupdate=yes 52 | 53 | ##################################### 54 | # The following settings are to help debugging. Don't change them unasked! 55 | # 56 | # set CameraAdjustments=no to simulate an unknown camera 57 | CameraAdjustments=yes 58 | 59 | # set IgnoreRawModes=yes to simulate a camera without raw modes 60 | IgnoreRawModes=no 61 | 62 | # add button that prints all snooped values as info in log 63 | PrintSnoopedValuesButton=no 64 | 65 | # write information about exposed frame to log 66 | log_FrameInformation=no 67 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/indi_pylibcamera5.ini: -------------------------------------------------------------------------------- 1 | [driver] 2 | 3 | # DeviceName is the camera name you will see in EKOS or INDI client 4 | DeviceName=pylibcamera5 5 | 6 | # The driver can connect to different camera devices. Your client software will show you a list of available 7 | # devices (for instance: "imx477, Num0, Loc2" or "imx477, Num1, Loc2") and it may have the option to automatically 8 | # connect. The following setting determines, which camera device in the list is initially selected. Counting starts 9 | # with 0. 10 | # If not set the fist camera will be selected and you may get an error when auto-connecting the same camera with 11 | # multiple drivers. 12 | SelectCameraDevice=4 13 | 14 | # INDI messages can have a time stamp. In most cases this is not needed to enable. 15 | # If you enable it make sure the system time on your Raspberry Pi is correct! 16 | SendTimeStamps=no 17 | 18 | # Libcamera does not tell all needed parameter for some cameras. The following can set or overwrite 19 | # these parameter. The values can be found in the datasheet of your camera. 20 | # Do not activate that when not absolutely needed. 21 | #force_UnitCellSize_X=2900 22 | #force_UnitCellSize_Y=2900 23 | #force_Rotation=0 # this has no effect anymore! 24 | #force_BayerOrder=BGGR 25 | 26 | # The following sets the initial value of the logging level. Possible values can be: 27 | # "Debug", "Info", "Warning", "Error". After startup you can change the logging level 28 | # by setting the "Logging" parameter. 29 | LoggingLevel=Info 30 | 31 | # The driver can ask other drivers for information. The INDI specification calls this "snooping". This allows 32 | # extended metadata in the FITS images, for instance telescope name, focal length, aperture, pointing direction 33 | # and more. 34 | # This feature requires the system time on your Raspberry Pi to be correct! 35 | DoSnooping=yes 36 | 37 | # Some cameras crash after the first exposure. Restarting the camera before every frame exposure can solve this issue. 38 | # Valid values are: 39 | # no - Do not restart if not needed to reconfigure camera. 40 | # yes - Always restart. Can lead to longer time between frames. 41 | # auto - automatically choose based on list of critical cameras 42 | # Default if not otherwise set in INI file is "auto". 43 | #force_Restart=auto 44 | #force_Restart=no 45 | #force_Restart=yes 46 | 47 | # From time to time astropy downloads the latest IERS-A table from internet. This will raise an error when the 48 | # the camera is not connected to internet. Therefore the auto update is disabled by default. That can lead to 49 | # small errors in the object coordinates stored in the FITS header. If your camera is internet connected you can 50 | # enable the autoupdate here: 51 | #enable_IERS_autoupdate=yes 52 | 53 | ##################################### 54 | # The following settings are to help debugging. Don't change them unasked! 55 | # 56 | # set CameraAdjustments=no to simulate an unknown camera 57 | CameraAdjustments=yes 58 | 59 | # set IgnoreRawModes=yes to simulate a camera without raw modes 60 | IgnoreRawModes=no 61 | 62 | # add button that prints all snooped values as info in log 63 | PrintSnoopedValuesButton=no 64 | 65 | # write information about exposed frame to log 66 | log_FrameInformation=no 67 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/indi_pylibcamera.ini: -------------------------------------------------------------------------------- 1 | [driver] 2 | 3 | # DeviceName is the camera name you will see in EKOS or INDI client 4 | DeviceName=pylibcamera Main 5 | 6 | # The driver can connect to different camera devices. Your client software will show you a list of available 7 | # devices (for instance: "imx477, Num0, Loc2" or "imx477, Num1, Loc2") and it may have the option to automatically 8 | # connect. The following setting determines, which camera device in the list is initially selected. Counting starts 9 | # with 0. 10 | # If not set the fist camera will be selected and you may get an error when auto-connecting the same camera with 11 | # multiple drivers. 12 | SelectCameraDevice=0 13 | 14 | # INDI messages can have a time stamp. In most cases this is not needed to enable. 15 | # If you enable it make sure the system time on your Raspberry Pi is correct! 16 | SendTimeStamps=no 17 | 18 | # Libcamera does not tell all needed parameter for some cameras. The following can set or overwrite 19 | # these parameter. The values can be found in the datasheet of your camera. 20 | # Do not activate that when not absolutely needed. 21 | #force_UnitCellSize_X=2900 22 | #force_UnitCellSize_Y=2900 23 | #force_Rotation=0 # this has no effect anymore! 24 | #force_BayerOrder=BGGR 25 | 26 | # The following sets the initial value of the logging level. Possible values can be: 27 | # "Debug", "Info", "Warning", "Error". After startup you can change the logging level 28 | # by setting the "Logging" parameter. 29 | LoggingLevel=Info 30 | 31 | # The driver can ask other drivers for information. The INDI specification calls this "snooping". This allows 32 | # extended metadata in the FITS images, for instance telescope name, focal length, aperture, pointing direction 33 | # and more. 34 | # This feature requires the system time on your Raspberry Pi to be correct! 35 | DoSnooping=yes 36 | 37 | # Some cameras crash after the first exposure. Restarting the camera before every frame exposure can solve this issue. 38 | # Valid values are: 39 | # no - Do not restart if not needed to reconfigure camera. 40 | # yes - Always restart. Can lead to longer time between frames. 41 | # auto - automatically choose based on list of critical cameras 42 | # Default if not otherwise set in INI file is "auto". 43 | #force_Restart=auto 44 | #force_Restart=no 45 | #force_Restart=yes 46 | 47 | # From time to time astropy downloads the latest IERS-A table from internet. This will raise an error when the 48 | # the camera is not connected to internet. Therefore the auto update is disabled by default. That can lead to 49 | # small errors in the object coordinates stored in the FITS header. If your camera is internet connected you can 50 | # enable the autoupdate here: 51 | #enable_IERS_autoupdate=yes 52 | 53 | ##################################### 54 | # The following settings are to help debugging. Don't change them unasked! 55 | # 56 | # set CameraAdjustments=no to simulate an unknown camera 57 | CameraAdjustments=yes 58 | 59 | # set IgnoreRawModes=yes to simulate a camera without raw modes 60 | IgnoreRawModes=no 61 | 62 | # add button that prints all snooped values as info in log 63 | PrintSnoopedValuesButton=no 64 | 65 | # write information about exposed frame to log 66 | log_FrameInformation=no 67 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/indi_pylibcamera2.ini: -------------------------------------------------------------------------------- 1 | [driver] 2 | 3 | # DeviceName is the camera name you will see in EKOS or INDI client 4 | DeviceName=pylibcamera2 Guide 5 | 6 | # The driver can connect to different camera devices. Your client software will show you a list of available 7 | # devices (for instance: "imx477, Num0, Loc2" or "imx477, Num1, Loc2") and it may have the option to automatically 8 | # connect. The following setting determines, which camera device in the list is initially selected. Counting starts 9 | # with 0. 10 | # If not set the fist camera will be selected and you may get an error when auto-connecting the same camera with 11 | # multiple drivers. 12 | SelectCameraDevice=1 13 | 14 | # INDI messages can have a time stamp. In most cases this is not needed to enable. 15 | # If you enable it make sure the system time on your Raspberry Pi is correct! 16 | SendTimeStamps=no 17 | 18 | # Libcamera does not tell all needed parameter for some cameras. The following can set or overwrite 19 | # these parameter. The values can be found in the datasheet of your camera. 20 | # Do not activate that when not absolutely needed. 21 | #force_UnitCellSize_X=2900 22 | #force_UnitCellSize_Y=2900 23 | #force_Rotation=0 # this has no effect anymore! 24 | #force_BayerOrder=BGGR 25 | 26 | # The following sets the initial value of the logging level. Possible values can be: 27 | # "Debug", "Info", "Warning", "Error". After startup you can change the logging level 28 | # by setting the "Logging" parameter. 29 | LoggingLevel=Info 30 | 31 | # The driver can ask other drivers for information. The INDI specification calls this "snooping". This allows 32 | # extended metadata in the FITS images, for instance telescope name, focal length, aperture, pointing direction 33 | # and more. 34 | # This feature requires the system time on your Raspberry Pi to be correct! 35 | DoSnooping=yes 36 | 37 | # Some cameras crash after the first exposure. Restarting the camera before every frame exposure can solve this issue. 38 | # Valid values are: 39 | # no - Do not restart if not needed to reconfigure camera. 40 | # yes - Always restart. Can lead to longer time between frames. 41 | # auto - automatically choose based on list of critical cameras 42 | # Default if not otherwise set in INI file is "auto". 43 | #force_Restart=auto 44 | #force_Restart=no 45 | #force_Restart=yes 46 | 47 | # From time to time astropy downloads the latest IERS-A table from internet. This will raise an error when the 48 | # the camera is not connected to internet. Therefore the auto update is disabled by default. That can lead to 49 | # small errors in the object coordinates stored in the FITS header. If your camera is internet connected you can 50 | # enable the autoupdate here: 51 | #enable_IERS_autoupdate=yes 52 | 53 | ##################################### 54 | # The following settings are to help debugging. Don't change them unasked! 55 | # 56 | # set CameraAdjustments=no to simulate an unknown camera 57 | CameraAdjustments=yes 58 | 59 | # set IgnoreRawModes=yes to simulate a camera without raw modes 60 | IgnoreRawModes=no 61 | 62 | # add button that prints all snooped values as info in log 63 | PrintSnoopedValuesButton=no 64 | 65 | # write information about exposed frame to log 66 | log_FrameInformation=no 67 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/SnoopingManager.py: -------------------------------------------------------------------------------- 1 | """ 2 | Snooping manager 3 | """ 4 | 5 | 6 | class SnoopingManager: 7 | def __init__(self, parent, to_server_func, logger): 8 | self.logger = logger 9 | self.to_server = to_server_func 10 | self.parent = parent 11 | # snooped values: dict(device->dict(name->dict(elements))) 12 | self.snoopedValues = dict() 13 | # kind->device table 14 | self.DevicesOfKind = dict() 15 | 16 | def start_Snooping(self, kind: str, device: str, names: list): 17 | """start snooping of a different driver 18 | 19 | Args: 20 | kind: type/kind of driver (mount, focusser, ...) 21 | device: device name to snoop 22 | names: vector names to snoop 23 | """ 24 | if kind in self.DevicesOfKind: 25 | if device != self.DevicesOfKind[kind]: 26 | # snoop now a different device -> stop snooping of old device 27 | self.stop_Snooping(kind) 28 | self.DevicesOfKind[kind] = device 29 | if device not in self.snoopedValues: 30 | self.snoopedValues[device] = dict() 31 | for name in names: 32 | if name not in self.snoopedValues[device]: 33 | self.snoopedValues[device][name] = dict() 34 | # send request to server 35 | xml = f'' 36 | self.to_server(xml) 37 | 38 | def stop_Snooping(self, kind: str): 39 | """stop snooping for given driver kind/type 40 | """ 41 | if kind in self.DevicesOfKind: 42 | device = self.DevicesOfKind[kind] 43 | del self.snoopedValues[device] 44 | del self.DevicesOfKind[kind] 45 | 46 | def catching(self, device: str, name: str, values: dict): 47 | """catch values from a snooped device 48 | 49 | Args: 50 | device: device which is snooped (or not) 51 | name: vector name 52 | values: dict(element->value) 53 | """ 54 | if device in self.snoopedValues: 55 | if name in self.snoopedValues[device]: 56 | self.snoopedValues[device][name] = values 57 | self.logger.debug(f'snooped "{device}" - "{name}": {values}') 58 | if ("DO_SNOOPING" in self.parent.knownVectors) and ("SNOOP" in self.parent.knownVectors["DO_SNOOPING"].get_OnSwitches()): 59 | if name in self.parent.knownVectors: 60 | self.parent.knownVectors[name].set_byClient(values) 61 | 62 | def get_Elements(self, kind: str, name: str): 63 | """get elements of snooped vector with given kind 64 | 65 | Args: 66 | kind: kind/type of snooped device (mount, focusser, ...) 67 | name: vector name 68 | 69 | Returns: 70 | dict of vector elements and their values (strings!) 71 | empty dict if not snooped or nothing received yet 72 | """ 73 | if kind in self.DevicesOfKind: 74 | device = self.DevicesOfKind[kind] 75 | return self.snoopedValues[device].get(name, dict()) 76 | else: 77 | return dict() 78 | 79 | def __str__(self): 80 | """make string representation of snooped values 81 | """ 82 | snooped = [] 83 | for device, deviceProps in self.snoopedValues.items(): 84 | for name, elements in deviceProps.items(): 85 | snooped.append(f'"{device}" - "{name}": {elements}') 86 | return "\n".join(snooped) 87 | 88 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 3.1.0 2 | - fixed README: changed 'libcamera' to 'rpicam' 3 | - using now new "FrameWallClock" key in the frame metadata dictionary to set "DATE-END" 4 | - fixed camera enumeration for newer versions of pylibcamera2 5 | - changed log message about missing configuration file from INFO to DEBUG 6 | 7 | 3.0.0 8 | - implemented image cropping (experimental, may not work for binning > 1) 9 | - replaced vector "TELESCOPE_INFO" by "SCOPE_INFO" and removed "CAMERA_LENS" to become compatible with new INDI protocol 10 | (fixes snooping of telescope aperture and focal length) 11 | - additional entry in FITS header: digital gain DGAIN 12 | - fix crash when no cameras are connected 13 | 14 | 2.9.0 15 | - implemented "RAW Mono" image format: a mono image is calculated from a Bayer pattern RAW image by summing up 16 | 2x2 pixel without reducing image size --> result is a mono image with RAW image size and 2 bits more than the 17 | RAW image has (original Mono format is based on ISP processed images and has 8 bits per pixel only) 18 | 19 | 2.8.1 20 | - fixed handshake when storing exposures remotely or on both sides 21 | 22 | 2.8.0 23 | - providing more binary names: indi_pylibcamera, indi_pylibcamera2, indi_pylibcamera3, ... indi_pylibcamera5 24 | this allows to run up to 5 driver instances for different cameras: 25 | * separate INI and CONFIG files for the driver instances 26 | * default camera device can be set in INI 27 | * changed code to kill existing driver instances 28 | - fixed bug in finding the INI files in installation path 29 | - updated README.md with description of the new features 30 | 31 | 2.7.0 32 | - implemented Mono frame 33 | - scaled RGB and Mono frames have now binning-factor in FITS header 34 | - minor code cleanup and optimization 35 | - minimized FITS metadata to avoid trouble with plate solver 36 | - moved folders CamerInfos and testpattern out of Python library 37 | 38 | 2.6.5 39 | - running old driver gets killed when started with `python3` and `python` 40 | - fixed typo in label "Disconnect" 41 | 42 | 2.6.4 43 | - fixed maximum exposure time for cameras reporting max < min (for instance for IMX296) 44 | 45 | 2.6.3 46 | - fixed installation issues 47 | 48 | 2.6.2 49 | - fixed ROWORDER attribute in FITS files (KStars/EKOS ignores this but some postprocessing tools need this fix to 50 | have the Bayer pattern in the correct order) 51 | - more details in install_requires of the wheel 52 | - adapted install instructions in README.md to meet newer OS versions (installation in virtual environment) 53 | - removed indi_pylibcamera_postinstall from installation: does not work from virtual environment 54 | 55 | 2.6.0 56 | - support for monochrome cameras 57 | - support for new raw and RGB frame formats (including monochrome) 58 | - disable astropy to download the latest IERS-A table from internet to avoid errors during observation session 59 | - better stability of exposure loop 60 | 61 | 2.5.0 62 | - fixed changed data alignment in Pi 5 raw images 63 | 64 | 2.4.0 65 | - added INI switch "force_Restart" and implemented camera restart (solves crashes for IMX290 and IMX519) 66 | - forwarding log messages to client 67 | 68 | 2.3.0 69 | - update FITS header formatting and timestamps 70 | - use lxml for construction of indi_pylibcamera.xml 71 | - renamed SwitchVector FRAME_TYPE(FRAMETYPE_RAW, FRAMETYPE_PROC) to CCD_CAPTURE_FORMAT(INDI_RAW, INDI_RGB) 72 | to better support AstroDMX 73 | - removed "setSitchVector CCD_ABORT_EXPOSURE" after each exposure start (that did not allow CCDciel to run 74 | exposures in loop) 75 | - reworked handling of CCD_ABORT_EXPOSURE 76 | - fixed Fast Exposure 77 | 78 | 2.2.0 79 | - fixed Bayer pattern order for HQ camera (pycamera2 or libcamera have change they way they report Bayer pattern order), 80 | BAYERPAT in FITS does not depend on "Rotation" property anymore (force_Rotation has no effect anymore), 81 | but Bayer pattern can now be forced with force_BayerOrder 82 | - sorting raw modes by size and bit depth to make the mode with most pixel and most bits/pixel the default 83 | - saving and loading configurations 84 | - bug fix: after aborting an exposure the camera stayed busy and EKOS field solver could not start a new exposure 85 | 86 | 2.1.0 87 | - fixed division by 0 when focal length equals 0 88 | - improved driver exit when indiserver is closed (to avoid driver continue to run) 89 | - more transparent implementation of snooping; snooped values are now also available as driver parameter 90 | (in tab "Snooping") 91 | - removed empty line and XML declaration from driver XML 92 | - added driver vectors for camera controls (allows to enable automatic exposure control and AWB) 93 | - added uninstall instructions in README.md 94 | - minor improvements 95 | 96 | 2.0.0 97 | - packaged for pip installation 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # indi_pylibcamera 2 | This project implements a Raspberry Pi camera driver for INDI (https://indilib.org/). 3 | 4 | Raspberry Pi cameras allow the amateur astronomer to make astonishing pictures with small budget. Especially the 5 | Raspberry Pi HQ camera can compete with expensive astro cameras. 6 | 7 | The driver is based on the new camera framework "libcamera" (https://github.com/raspberrypi/libcamera) which is 8 | already part of many Raspberry Pi operating systems. The driver can run on a Raspberry Pi Zero with 9 | HQ camera connected. But a more capable Raspberry Pi is recommended. 10 | 11 | The `indi_pylibcamera` may support all cameras supported by "libcamera". But not all cameras will provide image data 12 | in the required formats (raw Bayer or at least RGB). So it is not guaranteed that the driver will work with all 13 | cameras you can connect to a Raspberry Pi. 14 | 15 | Since version 2.8 the driver supports more than one camera connected to the Pi. 16 | 17 | The `indi_pylibcamera` is one layer in a stack of software: 18 | ``` 19 | INDI client (for instance KStars, PHD2, CCDciel, ...) 20 | --> INDI server 21 | --> indi_pylibcamera 22 | --> picamera2 23 | --> libcamera library 24 | --> kernel driver 25 | ``` 26 | It can not work when the versions of `libcamera` and `picamera2` are too old (both are in a dynamic development). 27 | And it can not work when the rpicam-tools (like `rpicam-hello` and `rpicam-still`) have issues with your 28 | camera. 29 | 30 | ## Requirements and installation 31 | Some packages need to be installed with `apt`: 32 | - `libcamera` and `rpicam-apps` (if not already installed). You can test libcamera and the support 33 | for your camera with: 34 | ```commandline 35 | rpicam-hello --list-cameras 36 | ``` 37 | You must be able to make RAW pictures in all modes. For instance `rpicam-hello` shows for the HQ camera: 38 | ``` 39 | 0 : imx477 [4056x3040] (/base/soc/i2c0mux/i2c@1/imx477@1a) 40 | Modes: 'SRGGB10_CSI2P' : 1332x990 [120.05 fps - (696, 528)/2664x1980 crop] 41 | 'SRGGB12_CSI2P' : 2028x1080 [50.03 fps - (0, 440)/4056x2160 crop] 42 | 2028x1520 [40.01 fps - (0, 0)/4056x3040 crop] 43 | 4056x3040 [10.00 fps - (0, 0)/4056x3040 crop] 44 | ``` 45 | and you must be able to run these commands without errors: 46 | ```commandline 47 | rpicam-still -r --mode 1332:990 --shutter 100000 --gain 1 --awbgains 1,1 --immediate -o test.jpg 48 | rpicam-still -r --mode 2028:1080 --shutter 100000 --gain 1 --awbgains 1,1 --immediate -o test.jpg 49 | rpicam-still -r --mode 2028:1520 --shutter 100000 --gain 1 --awbgains 1,1 --immediate -o test.jpg 50 | rpicam-still -r --mode 4056:3040 --shutter 100000 --gain 1 --awbgains 1,1 --immediate -o test.jpg 51 | ``` 52 | Something with your libcamera or kernel driver installation will be wrong if this does not work. 53 | - Install INDI core library. If there is no pre-compiled package for your hardware you will need to compile it 54 | by yourself. Instructions can be found here: https://github.com/indilib/indi. 55 | The scripts on https://gitea.nouspiro.space/nou/astro-soft-build automate compilation and installation. 56 | A Raspberry Pi Zero does not have enough RAM to compile with 4 threads in parallel: you need to do `make -j1` instead of `make -j4`. 57 | Finally, after installation, you should to have a working INDI server: `indiserver -v indi_simulator_telescope` 58 | - Some Python packages require matching versions of system libraries. They must be installed with `apt`: 59 | ```commandline 60 | sudo apt install python3-pip rpicam-apps python3-picamera2 python3-lxml python3-astropy python3-numpy python3-venv 61 | ``` 62 | 63 | The Raspberry Pi OS "Bullseye" still allowed to install system wide with `sudo pip install indi_pylibcamera`. 64 | Since "Bookworm" a virtual environment is required to install non-system Python packages. Trying to install 65 | `indi_pylibcamera` without a virtual environment will fail with `error: externally-managed-environment`. 66 | 67 | Run the following on a command lines to install `indi_pylibcamera`in a virtual environment called `venv_indi_pylibcamera` 68 | (you can name the virtual environment as you want): 69 | ```commandline 70 | python3 -m venv --system-site-packages ~/venv_indi_pylibcamera 71 | source ~/venv_indi_pylibcamera/bin/activate 72 | pip install --upgrade pip 73 | pip install indi_pylibcamera 74 | ``` 75 | 76 | It is highly recommended to make your own copy of the INI files: 77 | ```commandline 78 | mkdir ~/.indi_pylibcamera 79 | cp ~/venv_indi_pylibcamera/lib/python3.11/site-packages/indi_pylibcamera/*.ini ~/.indi_pylibcamera 80 | ``` 81 | Maybe you will need to adjust the Python version number in the source path. 82 | 83 | Please see section [Global Configuration](#Global-Configuration) for custimizing the INI settings. 84 | 85 | 86 | ## Some hints when you get trouble 87 | The Python packages `picamera2`, `numpy`, and ` astropy` MUST be installed with `sudo apt install`. 88 | You MUST NOT update them with `pip`. When you get errors related to these packages you can: 89 | 1. Check directory `~/.local/lib/python3.9/site-packages` if it contains one of these packages. If yes delete them! 90 | 2. Check if `pip list` shows different version numbers than `apt list` for these packages: 91 | ```commandline 92 | pip list | grep numpy 93 | apt list | grep numpy 94 | 95 | pip list | grep astropy 96 | apt list | grep astropy 97 | 98 | pip list | grep picamera2 99 | apt list | grep picamera2 100 | ``` 101 | If you see different versions for a package remove it with `pip uninstall` and reinstall it with 102 | `sudo apt reinstall`. 103 | 3. Remove and recreate the virtual environment. 104 | 105 | ## Uninstall 106 | For uninstalling the driver do: 107 | ```commandline 108 | sudo rm -f /usr/share/indi/indi_pylibcamera.xml 109 | rm -rf ~/venv_indi_pylibcamera 110 | ``` 111 | 112 | ## Running 113 | At the moment there is no support to start the driver from the EKOS profile editor. 114 | The driver and the INDI server must be started in shell with activated virtual environment: 115 | ```commandline 116 | source ~/venv_indi_pylibcamera/bin/activate 117 | ``` 118 | 119 | In the same shell you can start the INDI server with `indiserver -v indi_pylibcamera`. You can start up to 5 instances of the driver to operate up to 5 cameras: 120 | ```commandline 121 | indiserver -v indi_pylibcamera indi_pylibcamera2 indi_pylibcamera3 indi_pylibcamera4 indi_pylibcamera5 122 | ``` 123 | 124 | When the server is running 125 | you can connect to the server from another computer with an INDI client (for instance KStars/EKOS). The camera name 126 | is the one you configure in the global configuration (see below). 127 | 128 | I recommend you to make a wrapper script to activate the environment and start the driver. 129 | 130 | ## Global Configuration 131 | The driver uses a hierarchy of configuration files to set global parameter. These configuration files are called 132 | - `indi_pylibcamera.ini` (for driver instance `indi_pylibcamera`) 133 | - `indi_pylibcamera2.ini` (for driver instance `indi_pylibcamera2`) 134 | - `indi_pylibcamera3.ini` (for driver instance `indi_pylibcamera3`) 135 | - `indi_pylibcamera4.ini` (for driver instance `indi_pylibcamera4`) 136 | - `indi_pylibcamera5.ini` (for driver instance `indi_pylibcamera5`) 137 | 138 | These INI files are searched in the following order: 139 | 1. in the program installation directory (`~/venv_indi_pylibcamera/site_packages`) 140 | 2. pathname stored in environment variable `$INDI_PYLIBCAMERA_CONFIG_PATH` 141 | 3. `$HOME/.indi_pylibcamera` 142 | 4. in the directory where you started the INDI server 143 | The last INI file it finds will be used. This gives an INI file in the starting directory (item 4) the highest and in the installation directory the lowest precedence. 144 | 145 | I recommend to copy the INI files from the installation directory to `$HOME/.indi_pylibcamera` and to adjust the contents to your needs. 146 | 147 | The configuration file must have the section `[driver]`. The most important keys are: 148 | - `DeviceName` (string): INDI name of the device. This allows to distinguish indi_pylibcamera devices in your setup. 149 | For instance you can have one Raspberry Pi with HQ camera as main camera for taking photos and a second Raspberry Pi with 150 | a V1 camera for auto guiding. The INI files in the installation directors set the `DeviceName` to "pylibcamera Main", "pylibcamera2 Guide", "pylibcamera3", "pylibcamera4" and "pylibcamera5" for the 5 driver instances. 151 | - `SelectCameraDevice` (number): The driver can connect to different camera devices. Your client software will show 152 | you a list of available devices (for instance: "imx477, Num0, Loc2" or "imx477, Num1, Loc2") and it may have the 153 | option to automatically connect the camera before you get the chance to select one. The `SelectCameraDevice` 154 | setting determines, which camera device in the list is initially selected. Counting starts with 0. 155 | - `SendTimeStamps` (`yes`, `no`, `on`, `off`, `true`, `false`, `1`, `0`): Add a timestamp to the messages send from 156 | the device to the client. Such timestamps are needed in very seldom cases only, and usually it is okay to set this 157 | to `no`. If you really need timestamps make sure that the system clock is correct. 158 | - `force_UnitCellSize_X`, `force_UnitCellSize_Y` and `force_Rotation`: Some cameras are not fully supported by 159 | libcamera and do not provide all needed information. The configuration file allows to force pixel size and Bayer 160 | pattern rotation for such cameras. 161 | - `LoggingLevel`: The driver has buttons to set the logging level. But sometimes you need a higher logging level right 162 | at the beginning of the driver initialization. This can be done here in the INI file. 163 | - `DoSnooping`: The INDI protocol allows a driver to ask other drivers for information. This is called "snooping". The 164 | indi_pylibcamera driver uses this feature to get observer location, telescope information and telescope direction 165 | from the mount driver. It writes these information as metadata in the FITS images. This function got newly implemented 166 | and may make trouble in some setups. With the `DoSnooping` you can disable this function. 167 | - `force_Restart` (`yes`, `no`, `auto`): Some cameras crash after the first exposure. Restarting the camera before 168 | every frame exposure can solve this issue. Valid values of this switch are: 169 | * `no`: Do not restart if not needed to reconfigure camera. 170 | * `yes`: Always restart. Can lead to longer time between frames. 171 | * `auto`: Automatically choose based on list of known critical cameras. 172 | 173 | Default (if not otherwise set in INI file) is `auto`. 174 | - `enable_IERS_autoupdate` (`yes`, `no`): Allows the `astropy` library to update the IERS-A table from internet. 175 | By default this is disabled to avoid errors when the camera is not connected to internet. 176 | - `extended_Metadata` (`yes`, `no`, `on`, `off`, `true`, `false`, `1`, `0`, default: `false`): Adds more metadata to the FITS image. For 177 | instance it stores `SCALE` (angle of sky projected to pixel) and `XPIXSZ`/`YPIXSZ` (binned pixel size). When disabled 178 | the pixel sizes `PIXSIZE1`/`PIXSIZE2` get adjusted to the binning. That makes the images look like from a camera 179 | without binning and avoids many issues with plate solvers. 180 | 181 | There are more settings, mostly to support debugging. 182 | 183 | An example for a configuration file can be found in this repository. 184 | 185 | ## Saving a Configuration 186 | The driver allows you to save up to 6 different configurations. The "Options" tab has 3 controls for that: 187 | - One to select which of the 6 configurations you want to save, load or delete with control "Configs". 188 | - "Config Name" allows you to give the configuration a name. This is optional. It only helps you to remember 189 | what this configuration is made for. 190 | - The buttons in "Configuration" trigger the actions: 191 | - "Load" loads and applies the configuration. 192 | - "Save" stores the configuration. 193 | - "Default" restores the driver defaults. It does not overwrite the stored configuration. 194 | - "Purge" removes the stored configuration. 195 | 196 | When you try to load a non-existing configuration no settings will be changed. 197 | 198 | Many clients load "Config #1" automatically. If you do not want this you must purge "Config #1". 199 | 200 | Not all driver settings will be stored. For instance all settings which trigger an action (like "Connect", 201 | "Expose" and "Abort") will not be stored and load. Also, "Scope Location" (your place on earth), "Eq. Coordinates" 202 | (the scope pointing coordinates) and "Pier Side" will not be stored and loaded because these will typically set 203 | by snooping the mount driver. Telescope focal length and aperture are stored but get overwritten immediately by 204 | client (EKOS) when snooping. Generally all settings coming from client (EKOS) will overwrite settings you loaded 205 | previously from a configuration file. 206 | 207 | To save and load configurations you must be connected to a camera. The configuration will only be valid 208 | for this particular camera. It is not recommended to load a configuration which was saved for a different type 209 | of camera. 210 | 211 | Configurations get stored in `~/.indi_pylibcamera/CONFIG*.json`. Each driver instance has its own configuration files. 212 | 213 | ## Frametypes 214 | The driver provides these image frame types (when supported by the camera hardware): 215 | - `Raw` is the raw signal coming from the pixel converted to digital. Most cameras have an analog amplifier 216 | (configurable with `Gain`) between the pixel and the A/D converter. There is no software processing of the data. 217 | Typically, these pixel data have higher resolution but suffer from offset and gain errors. Furthermore, the pixel of 218 | color cameras have own color filter (called Bayer pattern) and do not output RGB data directly. Such raw images need 219 | post-processing. Astro-photographs like raw images because they allow much better image optimizations. The frame size 220 | of raw images is determined by the modes implemented in the camera hardware. 221 | - `RAW Mono` is a mono image calculated from the Bayer pattern RAW image by summing up 222 | 2x2 pixel without reducing image size. The result is a monochrome image with RAW image size and 2 bits more depth 223 | than the RAW image has. For comparison: the `Mono` format below is based on ISP processed images and has 224 | 8 bits per pixel only! 225 | - `RGB` are images post-processed by the Image Signal Processor (ISP). The ISP corrects for offset and gain, 226 | calculates the colors and can adjust exposure time and wide balance automatically. Drawback is the lower dynamic 227 | (lower bit width) and additional systematic "noise" due to rounding errors. Frame size can be chosen freely because 228 | the image scaling is done by software in the ISP. 229 | - `Mono` is a special case of the `RGB` images, exposed with saturation=0 and transmitted with only one channel per 230 | pixel. 231 | 232 | ## Special handling for some cameras 233 | The driver is made as generic as possible by using the camera information provided by libcamera. For instance the raw 234 | modes and frame sizes selectable in the driver are coming from libcamera. Unfortunately some important information 235 | is not provided by libcamera: 236 | * Some cameras have columns or rows filled with 0 or "garbage". These can disturb postprocessing of frames. 237 | For instance an automated color/brightness adjustment can get confused by these 0s. 238 | * Libcamera creates some raw modes by cropping, and others by binning (or subsampling, this is not sure) of frames. 239 | Binning (and subsampling) influences the viewing angle of the pixel. An INDI CCD driver must provide the used binning 240 | to allow client software to calculate the image viewing angle. 241 | 242 | To work around this the driver makes a special handling for the cameras listed below. Due to the removing of 243 | zero-filled columns/rows the image frame size will be smaller than stated on the raw mode name. 244 | 245 | ### IMX477 (Raspberry Pi HQ camera) 246 | Libcamera provides 4 raw modes for this camera, some made by binning (or subsampling) and all with 0-filled columns: 247 | * **4056x3040 BGGR 12bit:** provided frame size 4064x3040, no binning, has 8 zero-filled columns on its right, 248 | final image size is 4056x3040 249 | * **2028x1520 BGGR 12bit:** provided frame size 2032x1520, 2x2 binning, has 6 zero-filled columns on its right 250 | (8 get removed to avoid 1/2 Bayer pattern), final image size is 2024x1520 251 | * **2028x1080 BGGR 12bit:** provided frame size 2032x1080, 2x2 binning, has 6 zero-filled columns on its right 252 | (8 get removed to avoid 1/2 Bayer pattern), final image size is 2024x1080 253 | * **1332x990 BGGR 10bit:** provided frame size 1344x990, 2x2 binning, has 12 zero-filled columns on its right, 254 | final image size is 1332x990 255 | 256 | Maximum exposure time is > 5 minutes. 257 | 258 | ### OV5647 (Raspberry Pi V1 camera) 259 | This camera does not add zero-filled columns. But libcamera uses 3 binning modes. Maximum exposure time is 1 sec. 260 | * **2592x1944 GBRG 10bit:** provided frame size 2592x1944, no binning, no garbage columns, final image size is 2592x1944 261 | * **1920x1080 GBRG 10bit:** provided frame size 1920x1080, no binning, no garbage columns, final image size is 1920x1080 262 | * **1296x972 GBRG 10bit:** provided frame size 1296x972, 2x2 binning, no garbage columns, final image size is 1296x972 263 | * **640x480 GBRG 10bit:** provided frame size 640x480, 4x4 binning, no garbage columns, final image size is 640x480 264 | 265 | ### IMX708 (Raspberry Pi Module 3 camera) 266 | This camera has auto-focus capabilities which are not supported by this driver. Maximum exposure time is 1.7 sec. 267 | * **4608x2592 BGGR 10bit:** provided frame size 4608x2592, no binning, no garbage columns, final image size is 4608x2592 268 | * **2304x1296 BGGR 10bit:** provided frame size 2304x1296, 2x2 binning, no garbage columns, final image size is 2304x1296 269 | * **1536x864 BGGR 10bit:** provided frame size 1536x864, 2x2 binning, no garbage columns, final image size is 1536x864 270 | 271 | ## When you need support for a different camera 272 | There are many cameras you can connect to a Raspberry Pi. We can not test the driver with all of them. But we can try 273 | to support. For that we will need more information about your camera. Please run: 274 | 275 | ```commandline 276 | rpicam-hello --list-cameras 277 | 278 | indi_pylibcamera_print_camera_information > MyCam.txt 279 | ``` 280 | 281 | and send the generated "MyCam.txt" file. 282 | 283 | Furthermore, send one raw image for each available raw mode. Make pictures of a terrestrial object with red, green and 284 | blue areas. Do not change camera position between taking these pictures. It must be possible to measure and compare 285 | object dimensions. 286 | 287 | ## When you see strange behavior or errors 288 | In case you have trouble or you see unexpected behavior it will help debugging when you give more information about 289 | your system and camera. Please run: 290 | ```commandline 291 | cat /etc/os-release 292 | 293 | uname -a 294 | 295 | apt list --installed | grep numpy 296 | 297 | apt list --installed | grep astropy 298 | 299 | apt list --installed | grep libcamera 300 | 301 | apt list --installed | grep picamera 302 | 303 | rpicam-hello --list-cameras 304 | 305 | indi_pylibcamera_print_camera_information 306 | ``` 307 | 308 | and send the outputs in your issue report. 309 | 310 | Please also try to get raw images with `rpicam-still`: 311 | ```commandline 312 | rpicam-still -r -o test.jpg --shutter 1000000 --gain 1 --awbgains 1,1 --immediate 313 | ``` 314 | 315 | ## Snooping 316 | The `indi_pylibcamera` driver uses snooping to get information from the mount driver. This information is used to add 317 | more metadata to the FITS images, similar to this: 318 | ``` 319 | FOCALLEN= 2.000E+03 / Focal Length (mm) 320 | APTDIA = 2.000E+02 / Telescope diameter (mm) 321 | SCALE = 1.598825E-01 / arcsecs per pixel 322 | SITELAT = 5.105000E+01 / Latitude of the imaging site in degrees 323 | SITELONG= 1.375000E+01 / Longitude of the imaging site in degrees 324 | AIRMASS = 1.643007E+00 / Airmass 325 | OBJCTAZ = 1.121091E+02 / Azimuth of center of image in Degrees 326 | OBJCTALT= 3.744145E+01 / Altitude of center of image in Degrees 327 | OBJCTRA = ' 4 36 07.37' / Object J2000 RA in Hours 328 | OBJCTDEC= '16 30 26.02' / Object J2000 DEC in Degrees 329 | RA = 6.903072E+01 / Object J2000 RA in Degrees 330 | DEC = 1.650723E+01 / Object J2000 DEC in Degrees 331 | PIERSIDE= 'WEST ' / West, looking East 332 | EQUINOX = 2000 / Equinox 333 | DATE-OBS= '2023-04-05T11:27:53.655' / UTC start date of observation 334 | ``` 335 | Snooping is configured on the "Snooping" tab of the driver. Here you can set the mount driver (ECOS should do this for 336 | you automatically). Furthermore you can set which lenses your camera uses (main or guide scope). When snooping is 337 | enabled the telescope location, sky coordinates and pier side should update automatically. With the buttons 338 | "Do snooping" you can stop the updates. If you get trouble you can disable snooping in the INI file right from the 339 | driver start. 340 | 341 | A correct system time on you Raspberry Pi is absolutely needed for the calculation of the metadata. The Raspberry Pi 342 | does not have a battery powered realtime clock. It adjusts its system time from a time-server in the internet. If your 343 | Pi does not have internet access you will need to take care for setting the date and time. For instance, you can 344 | install a realtime clock or a GPS hardware. You can also copy date and time from one Linux computer (or Raspberry Pi) 345 | to another with: 346 | ```commandline 347 | ssh -t YourUserName@YourPiName sudo date --set=`date -Iseconds` 348 | ``` 349 | The driver uses "astropy" (https://www.astropy.org/) for coordinate transformations. When processing of the first image 350 | you make the "astropy" library needs a few seconds for initialization. This will not happen anymore for the next images. 351 | 352 | ## Camera controls 353 | The driver tab "Camera controls" allows you to change low level camera settings. For instance you can enable the 354 | automatic exposure control (AeEnable) and the automatic white balance (AwbEnable) to get processed pictures in good 355 | light conditions. 356 | A detailed description of the camera controls can be found in appendix C of the `picamera2` manual 357 | (https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf). 358 | 359 | Please be aware that most of the camera controls affect processed (RGB) pictures only. Do not use automatic exposure 360 | control or AWB when you plan to stack images. 361 | 362 | ## Client Software 363 | 364 | ### KStars/EKOS 365 | The driver is developed and tested with KStars/EKOS. 366 | 367 | ### CCDciel 368 | Since version 2.3.0 the driver works with CCDciel. But you need to stop the preview loop before you start image 369 | captures, otherwise CCDciel will tell you that it can not start exposures. The same happens when using 370 | indi_simulator_ccd as camera driver. 371 | 372 | ## Known Limitations 373 | - The maximum exposure time of the V1 camera is about 1 second. This limitation is caused by libcamera and the kernel 374 | driver. The indi_pylibcamera can not work around this. 375 | - Libcamera reports a maximum exposure time for HQ camera of 694.4 seconds. But when trying an exposure of 690 seconds 376 | the capture function already returns after 40 seconds. Likely this maximum exposure time is wrong. A test with 600 seconds 377 | exposure time was successful. 378 | - Libcamera reports a higher maximum value for analogue gain than expected. The analogue gain is implemented by hardware 379 | and has therefore well-defined restrictions. It is not clear if the reported higher maximum analogue gain is correct. 380 | 381 | ## Credits 382 | Many thanks to all who helped to improve this software. Contributions came from: 383 | - Simon Šander 384 | - Aaron W Morris 385 | - Caden Gobat 386 | - anjok 387 | 388 | I hope I did not forget someone. If so please do not be angry and tell me. 389 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/indidevice.py: -------------------------------------------------------------------------------- 1 | """ 2 | implementation of an INDI device 3 | based on INDI protocol v1.7 4 | 5 | 6 | not supported: 7 | - Light 8 | - LightVector 9 | """ 10 | 11 | from lxml import etree 12 | import sys 13 | import os 14 | import logging 15 | import base64 16 | import zlib 17 | import threading 18 | import fcntl 19 | import datetime 20 | 21 | from . import SnoopingManager 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | # helping functions 26 | 27 | def get_TimeStamp(): 28 | """return present system time formated as INDI timestamp 29 | """ 30 | return datetime.datetime.utcnow().isoformat(timespec="seconds") 31 | 32 | 33 | # enumerations 34 | 35 | class IVectorState: 36 | """INDI property states 37 | """ 38 | IDLE = "Idle" 39 | OK = "Ok" 40 | BUSY = "Busy" 41 | ALERT = "Alert" 42 | 43 | 44 | class IPermission: 45 | """INDI property permissions 46 | """ 47 | RO = "ro" 48 | WO = "wo" 49 | RW = "rw" 50 | 51 | 52 | class ISwitchRule: 53 | """INDI switch rules 54 | """ 55 | ONEOFMANY = "OneOfMany" 56 | ATMOST1 = "AtMostOne" 57 | NOFMANY = "AnyOfMany" 58 | 59 | 60 | class ISwitchState: 61 | """INDI switch states 62 | """ 63 | OFF = "Off" 64 | ON = "On" 65 | 66 | 67 | # sending messages to client is done by writing stdout 68 | 69 | class UnblockTTY: 70 | """configure stdout for unblocking write 71 | """ 72 | 73 | # shameless copy from https://stackoverflow.com/questions/67351928/getting-a-blockingioerror-when-printing-or-writting-to-stdout 74 | def __enter__(self): 75 | self.fd = sys.stdout.fileno() 76 | self.flags_save = fcntl.fcntl(self.fd, fcntl.F_GETFL) 77 | flags = self.flags_save & ~os.O_NONBLOCK 78 | fcntl.fcntl(self.fd, fcntl.F_SETFL, flags) 79 | 80 | def __exit__(self, *args): 81 | fcntl.fcntl(self.fd, fcntl.F_SETFL, self.flags_save) 82 | 83 | 84 | ToServerLock = threading.Lock() # need serialized output of the different threads! 85 | 86 | 87 | def to_server(msg: str): 88 | """send message to client 89 | """ 90 | with ToServerLock: 91 | with UnblockTTY(): 92 | sys.stdout.write(msg) 93 | sys.stdout.write("\n") 94 | sys.stdout.flush() 95 | 96 | 97 | class IProperty: 98 | """INDI property 99 | 100 | Base class for Text, Number, Switch and Blob properties. 101 | """ 102 | 103 | def __init__(self, name: str, label: str = None, value=None): 104 | """constructor 105 | 106 | Args: 107 | name: property name 108 | label: label shown in client GUI 109 | value: property value 110 | """ 111 | self._propertyType = "NotSet" 112 | self.name = name 113 | if label: 114 | self.label = label 115 | else: 116 | self.label = name 117 | self.value = value 118 | 119 | def __str__(self) -> str: 120 | return f"" 121 | 122 | def __repr__(self) -> str: 123 | return self.__str__() 124 | 125 | def get_oneProperty(self) -> str: 126 | """return XML for "oneNumber", "one"Text", "oneSwitch", "oneBLOB" messages 127 | """ 128 | return f'{self.value}' 129 | 130 | def set_byClient(self, value: str) -> str: 131 | """called when value gets set by client 132 | 133 | Overload this when actions are required. 134 | 135 | Args: 136 | value: value to set 137 | 138 | Returns: 139 | error message if failed or empty string if okay 140 | """ 141 | errmsg = f'setting property {self.name} not implemented' 142 | logger.error(errmsg) 143 | return errmsg 144 | 145 | 146 | class IVector: 147 | """INDI vector 148 | 149 | Base class for Text, Number, Switch and Blob vectors. 150 | """ 151 | 152 | def __init__( 153 | self, 154 | device: str, name: str, elements: list = [], 155 | label: str = None, group: str = "", 156 | state: str = IVectorState.IDLE, perm: str = IPermission.RW, 157 | timeout: int = 60, timestamp: bool = False, message: str = None, 158 | is_storable: bool = True, 159 | ): 160 | """constructor 161 | 162 | Args: 163 | device: device name 164 | name: vector name 165 | elements: list of INDI elements which build the vector 166 | label: label shown in client GUI 167 | group: group shown in client GUI 168 | state: vector state 169 | perm: vector permission 170 | timeout: timeout 171 | timestamp: send messages with (True) or without (False) timestamp 172 | message: message send to client 173 | is_storable: can be saved 174 | """ 175 | self._vectorType = "NotSet" 176 | self.device = device 177 | self.name = name 178 | self.elements = elements 179 | self.driver_default = {element.name: element.value for element in self.elements} 180 | if label: 181 | self.label = label 182 | else: 183 | self.label = name 184 | self.group = group 185 | self.state = state 186 | self.perm = perm 187 | self.timeout = timeout 188 | self.timestamp = timestamp 189 | self.message = message 190 | self.is_storable = is_storable 191 | 192 | def __str__(self) -> str: 193 | return f"" 194 | 195 | def __repr__(self) -> str: 196 | return self.__str__() 197 | 198 | def __len__(self) -> int: 199 | """returns number of elements 200 | """ 201 | return len(self.elements) 202 | 203 | def __add__(self, val: IProperty) -> list: 204 | """add an element 205 | 206 | Args: 207 | val: element (INDI property) to add 208 | """ 209 | self.elements.append(val) 210 | return self.elements 211 | 212 | def __getitem__(self, name: str) -> IProperty: 213 | """get named element 214 | 215 | Args: 216 | name: name of element to get 217 | """ 218 | for element in self.elements: 219 | if element.name == name: 220 | return element 221 | raise KeyError(f"{name} not in {self.__str__()}") 222 | 223 | def __setitem__(self, name, val): 224 | """set value of named element 225 | 226 | This does NOT inform the client about a value change! 227 | 228 | Args: 229 | name: name of element to set 230 | val: value to set 231 | """ 232 | for element in self.elements: 233 | if element.name == name: 234 | element.value = val 235 | return 236 | raise KeyError(f"{name} not in {self.__str__()}") 237 | 238 | def __iter__(self): 239 | """element iterator 240 | """ 241 | for element in self.elements: 242 | yield element 243 | 244 | def get_defVector(self) -> str: 245 | """return XML message for "defTextVector", "defNumberVector", "defSwitchVector" or "defBLOBVector" 246 | """ 247 | xml = f'' 262 | return xml 263 | 264 | def send_defVector(self, device: str = None): 265 | """tell client about existence of this vector 266 | 267 | Args: 268 | device: device name 269 | """ 270 | if (device is None) or (device == self.device): 271 | logger.debug(f'send_defVector: {self.get_defVector()}') 272 | to_server(self.get_defVector()) 273 | 274 | def get_delVector(self, msg: str = None) -> str: 275 | """tell client to delete property vector 276 | 277 | Args: 278 | msg: message to send with delProperty 279 | """ 280 | xml = f" str: 293 | """return XML for "set" message (to tell client about new vector data) 294 | """ 295 | xml = f'' 307 | return xml 308 | 309 | def send_setVector(self): 310 | """tell client about vector data 311 | """ 312 | logger.debug(f'send_setVector: {self.get_setVector()[:100]}') 313 | to_server(self.get_setVector()) 314 | 315 | def set_byClient(self, values: dict): 316 | """called when vector gets set by client 317 | 318 | Overload this when actions are required. 319 | 320 | Args: 321 | values: dict(propertyName: value) of values to set 322 | """ 323 | errmsgs = [] 324 | for propName, value in values.items(): 325 | errmsg = self[propName].set_byClient(value) 326 | if len(errmsg) > 0: 327 | errmsgs.append(errmsg) 328 | # send updated property values 329 | if len(errmsgs) > 0: 330 | self.state = IVectorState.ALERT 331 | self.message = "; ".join(errmsgs) 332 | else: 333 | self.state = IVectorState.OK 334 | self.send_setVector() 335 | self.message = "" 336 | 337 | def save(self): 338 | """return Vector state 339 | 340 | Returns: 341 | None if Vector is not savable 342 | dict with Vector state 343 | """ 344 | state = None 345 | if self.is_storable: 346 | state = dict() 347 | state["name"] = self.name 348 | state["values"] = {element.name: element.value for element in self.elements} 349 | return state 350 | 351 | def restore_DriverDefault(self): 352 | """restore driver defaults for savable vector 353 | """ 354 | if self.is_storable: 355 | self.set_byClient(self.driver_default) 356 | 357 | 358 | class IText(IProperty): 359 | """INDI Text property 360 | """ 361 | 362 | def __init__(self, name: str, label: str = None, value: str = ""): 363 | super().__init__(name=name, label=label, value=value) 364 | self._propertyType = "Text" 365 | 366 | def set_byClient(self, value: str) -> str: 367 | """called when value gets set by client 368 | 369 | Args: 370 | value: value to set 371 | 372 | Returns: 373 | error message if failed or empty string if okay 374 | """ 375 | self.value = value 376 | return "" 377 | 378 | def get_defProperty(self) -> str: 379 | """return XML for defText message 380 | """ 381 | return f'{self.value}' 382 | 383 | 384 | class ITextVector(IVector): 385 | """INDI Text vector 386 | """ 387 | 388 | def __init__( 389 | self, 390 | device: str, name: str, elements: list = [], 391 | label: str = None, group: str = "", 392 | state: str = IVectorState.IDLE, perm: str = IPermission.RW, 393 | timeout: int = 60, timestamp: bool = False, message: str = None, 394 | is_storable: bool = True, 395 | ): 396 | super().__init__( 397 | device=device, name=name, elements=elements, label=label, group=group, 398 | state=state, perm=perm, timeout=timeout, timestamp=timestamp, message=message, is_storable=is_storable, 399 | ) 400 | self._vectorType = "TextVector" 401 | 402 | 403 | class INumber(IProperty): 404 | """INDI Number property 405 | """ 406 | 407 | def __init__( 408 | self, name: str, value: float, min: float, max: float, step: float = 0, 409 | label: str = None, format: str = "%f" 410 | ): 411 | super().__init__(name=name, label=label, value=value) 412 | self._propertyType = "Number" 413 | self.min = min 414 | self.max = max 415 | self.step = step 416 | self.format = format 417 | 418 | def set_byClient(self, value: str) -> str: 419 | """called when value gets set by client 420 | 421 | Args: 422 | value: value to set 423 | 424 | Returns: 425 | error message if failed or empty string if okay 426 | """ 427 | self.value = min(max(float(value), self.min), self.max) 428 | return "" 429 | 430 | def get_defProperty(self) -> str: 431 | """return XML for defNumber message 432 | """ 433 | xml = f'{self.value}' 435 | return xml 436 | 437 | 438 | class INumberVector(IVector): 439 | """INDI Number vector 440 | """ 441 | 442 | def __init__( 443 | self, 444 | device: str, name: str, elements: list = [], 445 | label: str = None, group: str = "", 446 | state: str = IVectorState.IDLE, perm: str = IPermission.RW, 447 | timeout: int = 60, timestamp: bool = False, message: str = None, 448 | is_storable: bool = True, 449 | ): 450 | super().__init__( 451 | device=device, name=name, elements=elements, label=label, group=group, 452 | state=state, perm=perm, timeout=timeout, timestamp=timestamp, message=message, is_storable=is_storable, 453 | ) 454 | self._vectorType = "NumberVector" 455 | 456 | 457 | class ISwitch(IProperty): 458 | """INDI Switch property 459 | """ 460 | 461 | def __init__(self, name: str, label: str = None, value: str = ISwitchState.OFF): 462 | super().__init__(name=name, label=label, value=value) 463 | self._propertyType = "Switch" 464 | 465 | def set_byClient(self, value: str) -> str: 466 | """called when value gets set by client 467 | 468 | Args: 469 | value: value to set 470 | 471 | Returns: 472 | error message if failed or empty string if okay 473 | """ 474 | self.value = value 475 | return "" 476 | 477 | def get_defProperty(self) -> str: 478 | """return XML for defSwitch message 479 | """ 480 | return f'{self.value}' 481 | 482 | 483 | class ISwitchVector(IVector): 484 | """INDI Switch vector 485 | """ 486 | 487 | def __init__( 488 | self, 489 | device: str, name: str, elements: list = [], 490 | label: str = None, group: str = "", 491 | state: str = IVectorState.IDLE, perm: str = IPermission.RW, 492 | rule: str = ISwitchRule.ONEOFMANY, 493 | timeout: int = 60, timestamp: bool = False, message: str = None, 494 | is_storable: bool = True, 495 | ): 496 | super().__init__( 497 | device=device, name=name, elements=elements, label=label, group=group, 498 | state=state, perm=perm, timeout=timeout, timestamp=timestamp, message=message, is_storable=is_storable, 499 | ) 500 | self._vectorType = "SwitchVector" 501 | self.rule = rule 502 | 503 | def get_OnSwitches(self) -> list: 504 | """return list of element names which are On 505 | """ 506 | OnSwitches = [] 507 | for element in self.elements: 508 | if element.value == ISwitchState.ON: 509 | OnSwitches.append(element.name) 510 | return OnSwitches 511 | 512 | def get_OnSwitchesLabels(self) -> list: 513 | """return list of element labels which are On 514 | """ 515 | OnSwitches = [] 516 | for element in self.elements: 517 | if element.value == ISwitchState.ON: 518 | OnSwitches.append(element.label) 519 | return OnSwitches 520 | 521 | def get_OnSwitchesIdxs(self) -> list: 522 | """return list of element indices which are On 523 | """ 524 | OnSwitchesIdxs = [] 525 | for Idx, element in enumerate(self.elements): 526 | if element.value == ISwitchState.ON: 527 | OnSwitchesIdxs.append(Idx) 528 | return OnSwitchesIdxs 529 | 530 | def update_SwitchStates(self, values: dict) -> str: 531 | """update switch states according to values and switch rules 532 | 533 | Args: 534 | values: dict(SwitchName: value) of switch values 535 | 536 | Returns: 537 | error message if any 538 | """ 539 | errmsgs = [] 540 | if self.rule == ISwitchRule.NOFMANY: 541 | for propName, value in values.items(): 542 | errmsg = self[propName].set_byClient(value) 543 | if len(errmsg) > 0: 544 | errmsgs.append(errmsg) 545 | elif (self.rule == ISwitchRule.ATMOST1) or (self.rule == ISwitchRule.ONEOFMANY): 546 | for propName, value in values.items(): 547 | if value == ISwitchState.ON: 548 | # all others must be OFF 549 | for element in self.elements: 550 | element.value = ISwitchState.OFF 551 | errmsg = self[propName].set_byClient(value) 552 | if len(errmsg) > 0: 553 | errmsgs.append(errmsg) 554 | else: 555 | raise NotImplementedError(f'unknown switch rule "{self.rule}"') 556 | message = "; ".join(errmsgs) 557 | return message 558 | 559 | def set_byClient(self, values: dict): 560 | """called when vector gets set by client 561 | Special implementation for ISwitchVector to follow switch rules. 562 | 563 | Overload this when actions are required. 564 | 565 | Args: 566 | values: dict(propertyName: value) of values to set 567 | """ 568 | self.message = self.update_SwitchStates(values=values) 569 | # send updated property values 570 | if len(self.message) > 0: 571 | self.state = IVectorState.ALERT 572 | else: 573 | self.state = IVectorState.OK 574 | self.send_setVector() 575 | self.message = "" 576 | 577 | 578 | class IBlob(IProperty): 579 | """INDI BLOB property 580 | """ 581 | 582 | def __init__(self, name: str, label: str = None): 583 | super().__init__(name=name, label=label) 584 | self._propertyType = "BLOB" 585 | self.size = 0 586 | self.format = "not set" 587 | self.data = b'' 588 | self.enabled = "Only" 589 | 590 | def set_data(self, data: bytes, format: str = ".fits", compress: bool = False): 591 | """set BLOB data 592 | 593 | Args: 594 | data: data bytes 595 | format: data format 596 | compress: do ZIP compression (True/False) 597 | """ 598 | self.size = len(data) 599 | if compress: 600 | self.data = zlib.compress(data) 601 | self.format = format + ".z" 602 | else: 603 | self.data = data 604 | self.format = format 605 | 606 | def get_defProperty(self) -> str: 607 | """return XML for defBLOB message 608 | """ 609 | xml = f'' 610 | return xml 611 | 612 | def get_oneProperty(self) -> str: 613 | """return XML for oneBLOB message 614 | """ 615 | xml = "" 616 | if self.enabled in ["Also", "Only"]: 617 | xml += f'' 618 | xml += base64.b64encode(self.data).decode() 619 | xml += '' 620 | return xml 621 | 622 | 623 | class IBlobVector(IVector): 624 | """INDI BLOB vector 625 | """ 626 | 627 | def __init__( 628 | self, 629 | device: str, name: str, elements: list = [], 630 | label: str = None, group: str = "", 631 | state: str = IVectorState.IDLE, perm: str = IPermission.RO, 632 | timeout: int = 60, timestamp: bool = False, message: str = None, 633 | is_storable: bool = True, 634 | ): 635 | super().__init__( 636 | device=device, name=name, elements=elements, label=label, group=group, 637 | state=state, perm=perm, timeout=timeout, timestamp=timestamp, message=message, is_storable=is_storable, 638 | ) 639 | self._vectorType = "BLOBVector" 640 | 641 | def send_setVector(self): 642 | """tell client about vector data, special version for IBlobVector to avoid double calculation of setVector 643 | """ 644 | # logger.debug(f'send_setVector: {self.get_setVector()[:100]}') # this takes too long! 645 | to_server(self.get_setVector()) 646 | 647 | 648 | class IVectorList: 649 | """list of vectors 650 | """ 651 | 652 | def __init__(self, elements: list = [], name="IVectorList"): 653 | self.elements = elements 654 | self.name = name 655 | 656 | def __str__(self): 657 | return f"" 658 | 659 | def __repr__(self): 660 | return self.__str__() 661 | 662 | def __len__(self) -> int: 663 | return len(self.elements) 664 | 665 | def __add__(self, val: IVector) -> list: 666 | self.elements.append(val) 667 | return self.elements 668 | 669 | def __getitem__(self, name: str) -> IVector: 670 | for element in self.elements: 671 | if element.name == name: 672 | return element 673 | raise ValueError(f'vector list {self.name} has no vector {name}!') 674 | 675 | def __iter__(self): 676 | for element in self.elements: 677 | yield element 678 | 679 | def __contains__(self, name): 680 | for element in self.elements: 681 | if element.name == name: 682 | return True 683 | return False 684 | 685 | def pop(self, name: str) -> IVector: 686 | """return and remove named vector 687 | """ 688 | for i in range(len(self.elements)): 689 | if self.elements[i].name == name: 690 | return self.elements.pop(i) 691 | raise ValueError(f'vector list {self.name} has no vector {name}!') 692 | 693 | def send_defVectors(self, device: str = None): 694 | """send def messages for al vectors 695 | """ 696 | for element in self.elements: 697 | element.send_defVector(device=device) 698 | 699 | def send_delVectors(self): 700 | """send del message for all vectors 701 | """ 702 | for element in self.elements: 703 | element.send_delVector() 704 | 705 | def checkin(self, vector: IVector, send_defVector: bool = False): 706 | """add vector to list 707 | 708 | Args: 709 | vector: vector to add 710 | send_defVector: send def message to client (True/False) 711 | """ 712 | if send_defVector: 713 | vector.send_defVector() 714 | self.elements.append(vector) 715 | 716 | def checkout(self, name: str): 717 | """remove named vector and send del message to client 718 | """ 719 | self.pop(name).send_delVector() 720 | 721 | 722 | class indiMessageHandler(logging.StreamHandler): 723 | """logging message handler for INDI 724 | 725 | allows sending of log messages to client 726 | """ 727 | def __init__(self, device, timestamp=False): 728 | super().__init__() 729 | self.device = device 730 | self.timestamp = timestamp 731 | 732 | def emit(self, record): 733 | msg = self.format(record) 734 | # use etree here to get correct encoding of special characters in msg 735 | attribs = {"device": self.device, "message": msg} 736 | if self.timestamp: 737 | attribs['timestamp'] = get_TimeStamp() 738 | et = etree.ElementTree(etree.Element("message", attribs)) 739 | to_server(etree.tostring(et, xml_declaration=True).decode("latin")) 740 | #print(f'DBG MessageHandler: {etree.tostring(et, xml_declaration=True).decode("latin")}', file=sys.stderr) 741 | 742 | 743 | def handle_exception(exc_type, exc_value, exc_traceback): 744 | """logging of uncaught exceptions 745 | """ 746 | if issubclass(exc_type, KeyboardInterrupt): 747 | sys.__excepthook__(exc_type, exc_value, exc_traceback) 748 | return 749 | logger.error("Uncaught exception!", exc_info=(exc_type, exc_value, exc_traceback)) 750 | 751 | 752 | def enable_Logging(device, timestamp=False): 753 | """enable logging to client 754 | """ 755 | global logger 756 | logger.setLevel(logging.INFO) 757 | # console handler 758 | ch = logging.StreamHandler() 759 | #ch.setLevel(logging.INFO) 760 | formatter = logging.Formatter('%(name)s-%(levelname)s- %(message)s') 761 | ch.setFormatter(formatter) 762 | # INDI message handler 763 | ih = indiMessageHandler(device=device, timestamp=timestamp) 764 | ih.setFormatter(logging.Formatter('[%(levelname)s] %(message)s')) 765 | # add the handlers to logger 766 | logger.addHandler(ch) 767 | logger.addHandler(ih) 768 | # log uncought exceptions and forward them to client 769 | sys.excepthook = handle_exception 770 | 771 | 772 | class indidevice: 773 | """general INDI device 774 | """ 775 | 776 | def __init__(self, device: str): 777 | """constructor 778 | 779 | Args: 780 | device: device name as shown in client GUI 781 | """ 782 | self.device = device 783 | self.running = True 784 | self.knownVectors = IVectorList(name="knownVectors") 785 | # lock for device parameter 786 | self.knownVectorsLock = threading.Lock() 787 | # snooping 788 | self.SnoopingManager = SnoopingManager.SnoopingManager(parent=self, to_server_func=to_server, logger=logger) 789 | 790 | def send_Message(self, message: str, severity: str = "INFO", timestamp: bool = False): 791 | """send message to client 792 | 793 | Args: 794 | message: message text 795 | severity: message type, one of "DEBUG", "INFO", "WARN", "INFO" 796 | timestamp: send timestamp 797 | """ 798 | xml = f'' 802 | to_server(xml) 803 | 804 | def on_getProperties(self, device=None): 805 | """action to be done after receiving getProperties request 806 | """ 807 | self.knownVectors.send_defVectors(device=device) 808 | 809 | def message_loop(self): 810 | """message loop: read stdin, parse as xml, update vectors and send response to stdout 811 | """ 812 | inp = "" 813 | while self.running: 814 | 815 | new_inp = sys.stdin.readline() 816 | # detect termination of indiserver 817 | if len(new_inp) == 0: 818 | return 819 | inp += new_inp 820 | 821 | # maybe XML is complete 822 | try: 823 | xml = etree.fromstring(inp) 824 | inp = "" 825 | except etree.XMLSyntaxError as error: 826 | #logger.debug(f"XML not complete ({error}): {inp}") # creates too many log messages! 827 | continue 828 | 829 | logger.debug(f'Parsed data from client:\n{etree.tostring(xml, pretty_print=True).decode()}') 830 | logger.debug("End client data") 831 | 832 | device = xml.attrib.get('device', None) 833 | if xml.tag == "getProperties": 834 | self.on_getProperties(device) 835 | elif (device is None) or (device == self.device): 836 | if xml.tag in ["newNumberVector", "newTextVector", "newSwitchVector"]: 837 | vectorName = xml.attrib["name"] 838 | values = {ele.attrib["name"]: (ele.text.strip() if type(ele.text) is str else "") for ele in xml} 839 | try: 840 | vector = self.knownVectors[vectorName] 841 | except ValueError as e: 842 | logger.error(f'unknown vector name {vectorName}') 843 | else: 844 | logger.debug(f"calling {vector} set_byClient") 845 | with self.knownVectorsLock: 846 | vector.set_byClient(values) 847 | else: 848 | logger.error( 849 | f'could not interpret client request: {etree.tostring(xml, pretty_print=True).decode()}') 850 | else: 851 | # can be a snooped device 852 | if xml.tag in ["setNumberVector", "setTextVector", "setSwitchVector", "defNumberVector", 853 | "defTextVector", "defSwitchVector"]: 854 | vectorName = xml.attrib["name"] 855 | values = {ele.attrib["name"]: (ele.text.strip() if type(ele.text) is str else "") for ele in xml} 856 | with self.knownVectorsLock: 857 | self.SnoopingManager.catching(device=device, name=vectorName, values=values) 858 | elif xml.tag == "delProperty": 859 | # snooped device got closed 860 | pass 861 | else: 862 | logger.error( 863 | f'could not interpret client request: {etree.tostring(xml, pretty_print=True).decode()}') 864 | 865 | def checkin(self, vector: IVector, send_defVector: bool = False): 866 | """add vector to knownVectors list 867 | 868 | Args: 869 | vector: vector to add 870 | send_defVector: send def message to client (True/False) 871 | """ 872 | self.knownVectors.checkin(vector, send_defVector=send_defVector) 873 | 874 | def checkout(self, name: str): 875 | """remove named vector from knownVectors list and send del message to client 876 | """ 877 | self.knownVectors.checkout(name) 878 | 879 | def setVector(self, name: str, element: str, value=None, state: str = None, send: bool = True): 880 | """update vector value and/or state 881 | 882 | Args: 883 | name: vector name 884 | element: element name in vector 885 | value: new element value or None if unchanged 886 | state: vector state or None if unchanged 887 | send: send update to server 888 | """ 889 | v = self.knownVectors[name] 890 | if value is not None: 891 | v[element] = value 892 | if state is not None: 893 | v.state = state 894 | if send: 895 | v.send_setVector() 896 | 897 | def run(self): 898 | """start device 899 | """ 900 | self.message_loop() 901 | 902 | def start_Snooping(self, kind: str, device: str, names: list): 903 | """start snooping of a different driver 904 | 905 | Args: 906 | kind: type/kind of driver (mount, focusser, ...) 907 | device: device name to snoop 908 | names: vector names to snoop 909 | """ 910 | self.SnoopingManager.start_Snooping(kind=kind, device=device, names=names) 911 | 912 | def stop_Snooping(self, kind: str): 913 | """stop snooping for given driver kind/type 914 | """ 915 | self.SnoopingManager.stop_Snooping(kind=kind) 916 | -------------------------------------------------------------------------------- /src/indi_pylibcamera/CameraControl.py: -------------------------------------------------------------------------------- 1 | """ 2 | indi_pylibcamera: CameraControl class 3 | """ 4 | import os.path 5 | import numpy as np 6 | import io 7 | import re 8 | import threading 9 | import time 10 | import datetime 11 | 12 | from astropy.io import fits 13 | import astropy.coordinates 14 | import astropy.units 15 | import astropy.utils.iers 16 | 17 | from picamera2 import Picamera2 18 | from libcamera import controls, Rectangle 19 | 20 | 21 | from .indidevice import * 22 | 23 | 24 | class CameraSettings: 25 | """exposure settings 26 | """ 27 | 28 | def __init__(self): 29 | self.ExposureTime = None 30 | self.DoFastExposure = None 31 | self.DoRaw = None 32 | self.DoRgbMono = None 33 | self.ProcSize = None 34 | self.RawMode = None 35 | self.Binning = None 36 | self.camera_controls = None 37 | 38 | def update(self, ExposureTime, knownVectors, advertised_camera_controls, has_RawModes): 39 | self.ExposureTime = ExposureTime 40 | self.DoFastExposure = knownVectors["CCD_FAST_TOGGLE"]["INDI_ENABLED"].value == ISwitchState.ON 41 | self.DoRaw = knownVectors["CCD_CAPTURE_FORMAT"].get_OnSwitches()[0] in ["INDI_RAW", "RAW_MONO"] 42 | self.DoRgbMono = knownVectors["CCD_CAPTURE_FORMAT"]["INDI_MONO"].value == ISwitchState.ON if has_RawModes else False 43 | self.ProcSize = ( 44 | int(knownVectors["CCD_PROCFRAME"]["WIDTH"].value), 45 | int(knownVectors["CCD_PROCFRAME"]["HEIGHT"].value) 46 | ) 47 | self.RawMode = knownVectors["RAW_FORMAT"].get_SelectedRawMode() if has_RawModes else None 48 | self.Binning = (int(knownVectors["CCD_BINNING"]["HOR_BIN"].value), int(knownVectors["CCD_BINNING"]["VER_BIN"].value) 49 | ) 50 | self.camera_controls = { 51 | "ExposureTime": int(ExposureTime * 1e6), 52 | "AnalogueGain": knownVectors["CCD_GAIN"]["GAIN"].value, 53 | } 54 | if "AeEnable" in advertised_camera_controls: 55 | self.camera_controls["AeEnable"] = knownVectors["CAMCTRL_AEENABLE"]["INDI_ENABLED"].value == ISwitchState.ON 56 | if "AeConstraintMode" in advertised_camera_controls: 57 | self.camera_controls["AeConstraintMode"] = { 58 | "NORMAL": controls.AeConstraintModeEnum.Normal, 59 | "HIGHLIGHT": controls.AeConstraintModeEnum.Highlight, 60 | "SHADOWS": controls.AeConstraintModeEnum.Shadows, 61 | "CUSTOM": controls.AeConstraintModeEnum.Custom, 62 | }[knownVectors["CAMCTRL_AECONSTRAINTMODE"].get_OnSwitches()[0]] 63 | if "AeExposureMode" in advertised_camera_controls: 64 | self.camera_controls["AeExposureMode"] = { 65 | "NORMAL": controls.AeExposureModeEnum.Normal, 66 | "SHORT": controls.AeExposureModeEnum.Short, 67 | "LONG": controls.AeExposureModeEnum.Long, 68 | "CUSTOM": controls.AeExposureModeEnum.Custom, 69 | }[knownVectors["CAMCTRL_AEEXPOSUREMODE"].get_OnSwitches()[0]] 70 | if "AeMeteringMode" in advertised_camera_controls: 71 | self.camera_controls["AeMeteringMode"] = { 72 | "CENTREWEIGHTED": controls.AeMeteringModeEnum.CentreWeighted, 73 | "SPOT": controls.AeMeteringModeEnum.Spot, 74 | "MATRIX": controls.AeMeteringModeEnum.Matrix, 75 | "CUSTOM": controls.AeMeteringModeEnum.Custom, 76 | }[knownVectors["CAMCTRL_AEMETERINGMODE"].get_OnSwitches()[0]] 77 | if "AfMode" in advertised_camera_controls: 78 | self.camera_controls["AfMode"] = { 79 | "MANUAL": controls.AfModeEnum.Manual, 80 | "AUTO": controls.AfModeEnum.Auto, 81 | "CONTINUOUS": controls.AfModeEnum.Continuous, 82 | }[knownVectors["CAMCTRL_AFMODE"].get_OnSwitches()[0]] 83 | if "AfMetering" in advertised_camera_controls: 84 | self.camera_controls["AfMetering"] = { 85 | "AUTO": controls.AfMeteringEnum.Auto, 86 | "WINDOWS": controls.AfMeteringEnum.Windows, 87 | }[knownVectors["CAMCTRL_AFMETERING"].get_OnSwitches()[0]] 88 | if "AfPause" in advertised_camera_controls: 89 | self.camera_controls["AfPause"] = { 90 | "DEFERRED": controls.AfPauseEnum.Deferred, 91 | "IMMEDIATE": controls.AfPauseEnum.Immediate, 92 | "RESUME": controls.AfPauseEnum.Resume, 93 | }[knownVectors["CAMCTRL_AFPAUSE"].get_OnSwitches()[0]] 94 | if "AfRange" in advertised_camera_controls: 95 | self.camera_controls["AfRange"] = { 96 | "NORMAL": controls.AfRangeEnum.Normal, 97 | "MACRO": controls.AfRangeEnum.Macro, 98 | "FULL": controls.AfRangeEnum.Full, 99 | }[knownVectors["CAMCTRL_AFRANGE"].get_OnSwitches()[0]] 100 | if "AfSpeed" in advertised_camera_controls: 101 | self.camera_controls["AfSpeed"] = { 102 | "NORMAL": controls.AfSpeedEnum.Normal, 103 | "FAST": controls.AfSpeedEnum.Fast, 104 | }[knownVectors["CAMCTRL_AFSPEED"].get_OnSwitches()[0]] 105 | if "AfTrigger " in advertised_camera_controls: 106 | self.camera_controls["AfTrigger"] = { 107 | "START": controls.AfTriggerEnum.Start, 108 | "CANCEL": controls.AfTriggerEnum.Cancel, 109 | }[knownVectors["CAMCTRL_AFTRIGGER"].get_OnSwitches()[0]] 110 | if "AwbEnable" in advertised_camera_controls: 111 | self.camera_controls["AwbEnable"] = knownVectors["CAMCTRL_AWBENABLE"]["INDI_ENABLED"].value == ISwitchState.ON 112 | if "AwbMode" in advertised_camera_controls: 113 | self.camera_controls["AwbMode"] = { 114 | "AUTO": controls.AwbModeEnum.Auto, 115 | "TUNGSTEN": controls.AwbModeEnum.Tungsten, 116 | "FLUORESCENT": controls.AwbModeEnum.Fluorescent, 117 | "INDOOR": controls.AwbModeEnum.Indoor, 118 | "DAYLIGHT": controls.AwbModeEnum.Daylight, 119 | "CLOUDY": controls.AwbModeEnum.Cloudy, 120 | "CUSTOM": controls.AwbModeEnum.Custom, 121 | }[knownVectors["CAMCTRL_AWBMODE"].get_OnSwitches()[0]] 122 | if "Brightness" in advertised_camera_controls: 123 | self.camera_controls["Brightness"] = knownVectors["CAMCTRL_BRIGHTNESS"]["BRIGHTNESS"].value 124 | if "ColourGains" in advertised_camera_controls: 125 | if not self.camera_controls["AwbEnable"]: 126 | self.camera_controls["ColourGains"] = ( 127 | knownVectors["CAMCTRL_COLOURGAINS"]["REDGAIN"].value, 128 | knownVectors["CAMCTRL_COLOURGAINS"]["BLUEGAIN"].value, 129 | ) 130 | if "Contrast" in advertised_camera_controls: 131 | self.camera_controls["Contrast"] = knownVectors["CAMCTRL_CONTRAST"]["CONTRAST"].value 132 | if "ExposureValue" in advertised_camera_controls: 133 | self.camera_controls["ExposureValue"] = knownVectors["CAMCTRL_EXPOSUREVALUE"]["EXPOSUREVALUE"].value 134 | if "NoiseReductionMode" in advertised_camera_controls: 135 | self.camera_controls["NoiseReductionMode"] = { 136 | "OFF": controls.draft.NoiseReductionModeEnum.Off, 137 | "FAST": controls.draft.NoiseReductionModeEnum.Fast, 138 | "HIGHQUALITY": controls.draft.NoiseReductionModeEnum.HighQuality, 139 | }[knownVectors["CAMCTRL_NOISEREDUCTIONMODE"].get_OnSwitches()[0]] 140 | if "Saturation" in advertised_camera_controls: 141 | if self.DoRgbMono: 142 | # mono exposures are a special case of RGB with saturation=0 143 | self.camera_controls["Saturation"] = 0.0 144 | else: 145 | self.camera_controls["Saturation"] = knownVectors["CAMCTRL_SATURATION"]["SATURATION"].value 146 | if "Sharpness" in advertised_camera_controls: 147 | self.camera_controls["Sharpness"] = knownVectors["CAMCTRL_SHARPNESS"]["SHARPNESS"].value 148 | 149 | def get_controls(self): 150 | return self.camera_controls 151 | 152 | def is_RestartNeeded(self, NewCameraSettings): 153 | """would using NewCameraSettings need a camera restart? 154 | """ 155 | is_RestartNeeded = ( 156 | self.is_ReconfigurationNeeded(NewCameraSettings) 157 | or (self.camera_controls != NewCameraSettings.camera_controls) 158 | ) 159 | return is_RestartNeeded 160 | 161 | def is_ReconfigurationNeeded(self, NewCameraSettings): 162 | """would using NewCameraSettings need a camera reconfiguration? 163 | """ 164 | is_ReconfigurationNeeded = ( 165 | (self.DoFastExposure != NewCameraSettings.DoFastExposure) 166 | or (self.DoRaw != NewCameraSettings.DoRaw) 167 | or (self.ProcSize != NewCameraSettings.ProcSize) 168 | or (self.RawMode != NewCameraSettings.RawMode) 169 | ) 170 | return is_ReconfigurationNeeded 171 | 172 | def __str__(self): 173 | return f'CameraSettings: FastExposure={self.DoFastExposure}, DoRaw={self.DoRaw}, ProcSize={self.ProcSize}, ' \ 174 | f'RawMode={self.RawMode}, CameraControls={self.camera_controls}' 175 | 176 | def __repr__(self): 177 | return str(self) 178 | 179 | 180 | def getLocalFileName(dir: str = ".", prefix: str = "Image_XXX", suffix: str = ".fits"): 181 | """make image name for local storage 182 | 183 | Valid placeholder in prefix are: 184 | _XXX: 3 digit image count 185 | _ISO8601: local time 186 | 187 | Args: 188 | dir: local directory, will be created if not existing 189 | prefix: file name prefix with placeholders 190 | suffix: file name suffix 191 | 192 | Returns: 193 | path and file name with placeholders dissolved 194 | """ 195 | os.makedirs(dir, exist_ok=True) 196 | # replace ISO8601 placeholder in prefix with current time 197 | now = datetime.datetime.now().strftime("%Y-%m-%dT%H-%M-%S") 198 | prefix_now = prefix.replace("_ISO8601", f"_{now}") 199 | # find largest existing image index 200 | maxidx = 0 201 | patternstring = prefix_now.replace("_XXX", "_(?P\d{3})", 1) + suffix 202 | patternstring = patternstring.replace(".", "\.") 203 | pattern = re.compile(patternstring) 204 | for fn in os.listdir(dir): 205 | match = pattern.fullmatch(fn) 206 | if match: 207 | if "Idx" in match.groupdict(): 208 | idx = int(match.group("Idx")) 209 | maxidx = max(maxidx, idx) 210 | # 211 | maxidx += 1 212 | filename = prefix_now.replace("_XXX",f"_{maxidx:03d}", 1) + suffix 213 | return os.path.join(dir, filename) 214 | 215 | 216 | class CameraControl: 217 | """camera control and exposure thread 218 | """ 219 | 220 | def __init__(self, parent, config): 221 | self.parent = parent 222 | self.config = config 223 | # From time to time astropy downloads the latest IERS-A table from internet. 224 | # If offline this will raise an error. Here we disable the auto update. 225 | if self.config.getboolean("driver", "enable_IERS_autoupdate", fallback=True): 226 | astropy.utils.iers.conf.auto_max_age = None 227 | astropy.utils.iers.conf.iers_degraded_accuracy = "ignore" 228 | # 229 | self.do_CameraAdjustments = self.config.getboolean("driver", "CameraAdjustments", fallback=True) 230 | self.IgnoreRawModes = self.config.getboolean("driver", "IgnoreRawModes", fallback=False) 231 | # reset states 232 | self.picam2 = None 233 | self.present_CameraSettings = CameraSettings() 234 | self.CamProps = dict() 235 | self.RawModes = [] 236 | self.min_ExposureTime = None 237 | self.max_ExposureTime = None 238 | self.min_AnalogueGain = None 239 | self.max_AnalogueGain = None 240 | self.camera_controls = dict() 241 | self.needs_Restarts = False 242 | # exposure loop control 243 | self.ExposureTime = 0.0 244 | self.Sig_Do = threading.Event() # do an action 245 | self.Sig_ActionExpose = threading.Event() # single or fast exposure 246 | self.Sig_ActionExit = threading.Event() # exit exposure loop 247 | self.Sig_ActionAbort = threading.Event() # abort running exposure 248 | self.Sig_CaptureDone = threading.Event() 249 | # exposure loop in separate thread 250 | self.Sig_ActionExit.clear() 251 | self.Sig_ActionExpose.clear() 252 | self.Sig_Do.clear() 253 | self.Sig_ActionAbort.clear() 254 | self.ExposureThread = None 255 | 256 | 257 | def closeCamera(self): 258 | """close camera 259 | """ 260 | logger.info('closing camera') 261 | # stop exposure loop 262 | if self.ExposureThread is not None: 263 | if self.ExposureThread.is_alive(): 264 | self.Sig_ActionExit.set() 265 | self.Sig_Do.set() 266 | self.ExposureThread.join() # wait until exposure loop exits 267 | # close picam2 268 | if self.picam2 is not None: 269 | if self.picam2.started: 270 | self.picam2.stop_() 271 | self.picam2.close() 272 | # reset states 273 | self.picam2 = None 274 | self.present_CameraSettings = CameraSettings() 275 | self.CamProps = dict() 276 | self.RawModes = [] 277 | self.min_ExposureTime = None 278 | self.max_ExposureTime = None 279 | self.min_AnalogueGain = None 280 | self.max_AnalogueGain = None 281 | self.camera_controls = dict() 282 | 283 | 284 | def getRawCameraModes(self): 285 | """get list of usable raw camera modes 286 | """ 287 | sensor_modes = self.picam2.sensor_modes 288 | raw_modes = [] 289 | for sensor_mode in sensor_modes: 290 | # sensor_mode is dict 291 | # it must have key "format" (usually a packed data format) and can have 292 | # "unpacked" (unpacked data format) 293 | if "unpacked" not in sensor_mode.keys(): 294 | sensor_format = sensor_mode["format"] 295 | else: 296 | sensor_format = sensor_mode["unpacked"] 297 | # packed data formats are not supported 298 | if sensor_format.endswith("_CSI2P"): 299 | logger.warning(f'raw mode not supported: {sensor_mode}') 300 | continue 301 | # only monochrome and Bayer pattern formats are supported 302 | is_monochrome = re.match("R[0-9]+", sensor_format) 303 | is_bayer = re.match("S[RGB]{4}[0-9]+", sensor_format) 304 | if not (is_monochrome or is_bayer): 305 | logger.warning(f'raw mode not supported: {sensor_mode}') 306 | continue 307 | # 308 | size = sensor_mode["size"] 309 | # adjustments for cameras: 310 | # * zero- or garbage-filled columns 311 | # * raw modes with binning or subsampling 312 | true_size = size 313 | binning = (1, 1) 314 | if self.do_CameraAdjustments: 315 | if self.CamProps["Model"] == 'imx477': 316 | if size == (1332, 990): 317 | true_size = (1332, 990) 318 | binning = (2, 2) 319 | elif size == (2028, 1080): 320 | true_size = (2024, 1080) 321 | binning = (2, 2) 322 | elif size == (2028, 1520): 323 | true_size = (2024, 1520) 324 | binning = (2, 2) 325 | elif size == (4056, 3040): 326 | true_size = (4056, 3040) 327 | else: 328 | logger.warning(f'Unsupported frame size {size} for imx477!') 329 | elif self.CamProps["Model"] == 'ov5647': 330 | if size == (640, 480): 331 | binning = (4, 4) 332 | elif size == (1296, 972): 333 | binning = (2, 2) 334 | elif size == (1920, 1080): 335 | pass 336 | elif size == (2592, 1944): 337 | pass 338 | else: 339 | logger.warning(f'Unsupported frame size {size} for ov5647!') 340 | elif self.CamProps["Model"].startswith("imx708"): 341 | if size == (1536, 864): 342 | binning = (2, 2) 343 | elif size == (2304, 1296): 344 | binning = (2, 2) 345 | elif size == (4608, 2592): 346 | pass 347 | else: 348 | logger.warning(f'Unsupported frame size {size} for imx708!') 349 | # add to list of raw formats 350 | raw_mode = { 351 | "label": f'{size[0]}x{size[1]} {sensor_format[1:5] if is_bayer else "mono"} {sensor_mode["bit_depth"]}bit', 352 | "size": size, 353 | "true_size": true_size, 354 | "camera_format": sensor_format, 355 | "bit_depth": sensor_mode["bit_depth"], 356 | "binning": binning, 357 | } 358 | raw_modes.append(raw_mode) 359 | # sort list of raw formats by size and bit depth in descending order 360 | raw_modes.sort(key=lambda k: k["size"][0] * k["size"][1] * 100 + k["bit_depth"], reverse=True) 361 | return raw_modes 362 | 363 | def openCamera(self, idx: int): 364 | """open camera with given index idx 365 | """ 366 | logger.info("opening camera") 367 | self.picam2 = Picamera2(idx) 368 | # read camera properties 369 | self.CamProps = self.picam2.camera_properties 370 | logger.info(f'camera properties: {self.CamProps}') 371 | # force properties with values from config file 372 | if "UnitCellSize" not in self.CamProps: 373 | logger.warning("Camera properties do not have UnitCellSize value. Need to force from config file!") 374 | self.CamProps["UnitCellSize"] = ( 375 | self.config.getint( 376 | "driver", "force_UnitCellSize_X", 377 | fallback=self.CamProps["UnitCellSize"][0] if "UnitCellSize" in self.CamProps else 1000 378 | ), 379 | self.config.getint( 380 | "driver", "force_UnitCellSize_Y", 381 | fallback=self.CamProps["UnitCellSize"][1] if "UnitCellSize" in self.CamProps else 1000 382 | ) 383 | ) 384 | # newer libcamera version return a libcamera.Rectangle here! 385 | if type(self.CamProps["PixelArrayActiveAreas"][0]) is Rectangle: 386 | Rect = self.CamProps["PixelArrayActiveAreas"][0] 387 | self.CamProps["PixelArrayActiveAreas"] = (Rect.x, Rect.y, Rect.width, Rect.height) 388 | # raw modes 389 | self.RawModes = self.getRawCameraModes() 390 | if self.IgnoreRawModes: 391 | self.RawModes = [] 392 | # camera controls 393 | self.camera_controls = self.picam2.camera_controls 394 | # gain and exposure time range 395 | self.min_ExposureTime, self.max_ExposureTime, default_exp = self.camera_controls["ExposureTime"] 396 | self.min_AnalogueGain, self.max_AnalogueGain, default_again = self.camera_controls["AnalogueGain"] 397 | # workaround for cameras reporting max_ExposureTime=0 (IMX296) 398 | self.max_ExposureTime = self.max_ExposureTime if self.min_ExposureTime < self.max_ExposureTime else 1000.0e6 399 | self.max_AnalogueGain = self.max_AnalogueGain if self.min_AnalogueGain < self.max_AnalogueGain else 1000.0 400 | # INI switch to force camera restarts 401 | force_Restart = self.config.get("driver", "force_Restart", fallback="auto").lower() 402 | if force_Restart == "yes": 403 | logger.info("INI setting forces camera restart") 404 | self.needs_Restarts = True 405 | elif force_Restart == "no": 406 | logger.info("INI setting for camera restarts as needed") 407 | self.needs_Restarts = False 408 | else: 409 | if force_Restart != "auto": 410 | logger.warning(f'unknown INI value for camera restart: force_Restart={force_Restart}') 411 | self.needs_Restarts = self.CamProps["Model"] in ["imx290", "imx519"] 412 | # start exposure loop 413 | self.Sig_ActionExit.clear() 414 | self.Sig_ActionExpose.clear() 415 | self.Sig_Do.clear() 416 | self.ExposureThread = threading.Thread(target=self.__ExposureLoop) 417 | self.ExposureThread.start() 418 | 419 | def getProp(self, name): 420 | """return camera properties 421 | """ 422 | return self.CamProps[name] 423 | 424 | def snooped_FitsHeader(self, binnedCellSize_nm): 425 | """created FITS header data from snooped data 426 | 427 | Example: 428 | FOCALLEN= 2.000E+03 / Focal Length (mm) 429 | APTDIA = 2.000E+02 / Telescope diameter (mm) 430 | ROTATANG= 0.000E+00 / Rotator angle in degrees 431 | SCALE = 1.598825E-01 / arcsecs per pixel 432 | SITELAT = 5.105000E+01 / Latitude of the imaging site in degrees 433 | SITELONG= 1.375000E+01 / Longitude of the imaging site in degrees 434 | AIRMASS = 1.643007E+00 / Airmass 435 | OBJCTAZ = 1.121091E+02 / Azimuth of center of image in Degrees 436 | OBJCTALT= 3.744145E+01 / Altitude of center of image in Degrees 437 | OBJCTRA = ' 4 36 07.37' / Object J2000 RA in Hours 438 | OBJCTDEC= '16 30 26.02' / Object J2000 DEC in Degrees 439 | RA = 6.903072E+01 / Object J2000 RA in Degrees 440 | DEC = 1.650723E+01 / Object J2000 DEC in Degrees 441 | PIERSIDE= 'WEST ' / West, looking East 442 | EQUINOX = 2000 / Equinox 443 | DATE-OBS= '2023-04-05T11:27:53.655' / UTC start date of observation 444 | """ 445 | FitsHeader = {} 446 | #### FOCALLEN, APTDIA #### 447 | Aperture = self.parent.knownVectors["SCOPE_INFO"]["APERTURE"].value 448 | FocalLength = self.parent.knownVectors["SCOPE_INFO"]["FOCAL_LENGTH"].value 449 | FitsHeader.update({ 450 | "FOCALLEN": (FocalLength, "[mm] Focal Length"), 451 | "APTDIA": (Aperture, "[mm] Telescope aperture/diameter") 452 | }) 453 | #### SCALE #### 454 | if self.config.getboolean("driver", "extended_Metadata", fallback=False): 455 | # some telescope driver do not provide FOCAL_LENGTH and some capture software overwrite 456 | # FOCALLEN without recalculating SCALE --> trouble with plate solver 457 | if FocalLength > 0: 458 | FitsHeader["SCALE"] = ( 459 | 0.206265 * binnedCellSize_nm / FocalLength, 460 | "[arcsec/px] image scale" 461 | ) 462 | #### SITELAT, SITELONG #### 463 | Lat = self.parent.knownVectors["GEOGRAPHIC_COORD"]["LAT"].value 464 | Long = self.parent.knownVectors["GEOGRAPHIC_COORD"]["LONG"].value 465 | Height = self.parent.knownVectors["GEOGRAPHIC_COORD"]["ELEV"].value 466 | FitsHeader.update({ 467 | "SITELAT": (Lat, "[deg] Latitude of the imaging site"), 468 | "SITELONG": (Long, "[deg] Longitude of the imaging site") 469 | }) 470 | #### 471 | # TODO: "EQUATORIAL_COORD" (J2000 coordinates from mount) are not used! 472 | if False: 473 | J2000RA = self.parent.knownVectors["EQUATORIAL_COORD"]["RA"].value 474 | J2000DEC = self.parent.knownVectors["EQUATORIAL_COORD"]["DEC"].value 475 | FitsHeader.update({ 476 | #("OBJCTRA", J2000.ra.to_string(unit=astropy.units.hour).replace("h", " ").replace("m", " ").replace("s", " "), 477 | # "Object J2000 RA in Hours"), 478 | #("OBJCTDEC", J2000.dec.to_string(unit=astropy.units.deg).replace("d", " ").replace("m", " ").replace("s", " "), 479 | # "Object J2000 DEC in Degrees"), 480 | "RA": (J2000RA, "[deg] Object J2000 RA"), 481 | "DEC": (J2000DEC, "[deg] Object J2000 DEC") 482 | }) 483 | # TODO: What about AIRMASS, OBJCTAZ and OBJCTALT? 484 | #### AIRMASS, OBJCTAZ, OBJCTALT, OBJCTRA, OBJCTDEC, RA, DEC #### 485 | RA = self.parent.knownVectors["EQUATORIAL_EOD_COORD"]["RA"].value 486 | DEC = self.parent.knownVectors["EQUATORIAL_EOD_COORD"]["DEC"].value 487 | ObsLoc = astropy.coordinates.EarthLocation( 488 | lon=Long * astropy.units.deg, lat=Lat * astropy.units.deg, height=Height * astropy.units.meter 489 | ) 490 | c = astropy.coordinates.SkyCoord(ra=RA * astropy.units.hourangle, dec=DEC * astropy.units.deg) 491 | cAltAz = c.transform_to(astropy.coordinates.AltAz(obstime=astropy.time.Time.now(), location=ObsLoc)) 492 | J2000 = cAltAz.transform_to(astropy.coordinates.ICRS()) 493 | FitsHeader.update({ 494 | "AIRMASS" : (float(cAltAz.secz), "Airmass"), 495 | "OBJCTAZ" : (float(cAltAz.az/astropy.units.deg), "[deg] Azimuth of center of image"), 496 | "OBJCTALT": (float(cAltAz.alt/astropy.units.deg), "[deg] Altitude of center of image"), 497 | "OBJCTRA" : (J2000.ra.to_string(unit=astropy.units.hour).replace("h", " ").replace("m", " ").replace("s", " "), "[HMS] Object J2000 RA"), 498 | "OBJCTDEC": (J2000.dec.to_string(unit=astropy.units.deg).replace("d", " ").replace("m", " ").replace("s", " "), "[DMS] Object J2000 DEC"), 499 | "RA" : (float(J2000.ra.degree), "[deg] Object J2000 RA"), 500 | "DEC" : (float(J2000.dec.degree), "[deg] Object J2000 DEC"), 501 | "EQUINOX" : (2000.0, "[yr] Equinox") 502 | }) 503 | #### PIERSIDE #### 504 | if self.parent.knownVectors["TELESCOPE_PIER_SIDE"]["PIER_WEST"].value == ISwitchState.ON: 505 | FitsHeader["PIERSIDE"] = ("WEST", "West, looking East") 506 | else: 507 | FitsHeader["PIERSIDE"] = ("EAST", "East, looking West") 508 | 509 | logger.debug("Finished collecting snooped data.") 510 | #### 511 | return FitsHeader 512 | 513 | def createRawFits(self, array, metadata): 514 | """ 515 | creates raw image in FITS format 516 | 517 | Args: 518 | array: image data 519 | metadata: image metadata 520 | 521 | Returns: 522 | FITS HDUL 523 | """ 524 | format = self.picam2.camera_configuration()["raw"]["format"] 525 | self.log_FrameInformation(array=array, metadata=metadata, format=format) 526 | # we expect uncompressed format here 527 | if format.count("_") > 0: 528 | raise NotImplementedError(f'got unsupported raw image format {format}') 529 | # Bayer or mono format 530 | if format[0] == "S": 531 | # Bayer pattern format 532 | BayerPattern = format[1:5] 533 | BayerPattern = self.parent.config.get("driver", "force_BayerOrder", fallback=BayerPattern) 534 | bit_depth = int(format[5:]) 535 | elif format[0] == "R": 536 | # mono camera 537 | BayerPattern = None 538 | bit_depth = int(format[1:]) 539 | else: 540 | raise NotImplementedError(f'got unsupported raw image format {format}') 541 | # convert to 16 bit if needed 542 | if bit_depth > 8: 543 | array = array.view(np.uint16) 544 | else: 545 | array = array.view(np.uint8) 546 | # calculate RAW Mono if needed 547 | with self.parent.knownVectorsLock: 548 | is_RawMono = self.parent.knownVectors["CCD_CAPTURE_FORMAT"].get_OnSwitches()[0] == "RAW_MONO" 549 | if is_RawMono: 550 | if bit_depth < 16: 551 | # old libcamera data format (right aligned) 552 | bit_depth += 2 553 | array = np.pad(array, pad_width=1, mode="constant", constant_values=0) 554 | else: 555 | # new libcamera data format: 16b left aligned 556 | array = np.pad(array, pad_width=1, mode="constant", constant_values=0) >> 2 557 | array = array[1:-1, 1:-1] + array[2:, 1:-1] + array[1:-1, 2:] + array[2:, 2:] 558 | BayerPattern = None 559 | # remove 0- or garbage-filled columns 560 | true_size = self.present_CameraSettings.RawMode["true_size"] 561 | array = array[0:true_size[1], 0:true_size[0]] 562 | # crop 563 | with self.parent.knownVectorsLock: 564 | array = self.parent.knownVectors["CCD_FRAME"].crop( 565 | array=array, arrayType="mono" if BayerPattern is None else "bayer" 566 | ) 567 | # left adjust if needed 568 | if bit_depth > 8: 569 | bit_pix = 16 570 | array *= 2 ** (bit_pix - bit_depth) 571 | else: 572 | bit_pix = 8 573 | array *= 2 ** (bit_pix - bit_depth) 574 | # convert to FITS 575 | hdu = fits.PrimaryHDU(array) 576 | # avoid access conflicts to knownVectors 577 | with self.parent.knownVectorsLock: 578 | # determine frame type 579 | FrameType = self.parent.knownVectors["CCD_FRAME_TYPE"].get_OnSwitchesLabels()[0] 580 | # FITS header and metadata 581 | FitsHeader = { 582 | "BZERO": (2 ** (bit_pix - 1), "offset data range"), 583 | "BSCALE": (1, "default scaling factor"), 584 | "ROWORDER": ("TOP-DOWN", "Row order"), 585 | "INSTRUME": (self.parent.device, "CCD Name"), 586 | "TELESCOP": (self.parent.knownVectors["ACTIVE_DEVICES"]["ACTIVE_TELESCOPE"].value, "Telescope name"), 587 | **self.parent.knownVectors["FITS_HEADER"].FitsHeader, 588 | "EXPTIME": (metadata["ExposureTime"]/1e6, "[s] Total Exposure Time"), 589 | "CCD-TEMP": (metadata.get('SensorTemperature', 0), "[degC] CCD Temperature"), 590 | "FRAME": (FrameType, "Frame Type"), 591 | "IMAGETYP": (FrameType+" Frame", "Frame Type"), 592 | **self.snooped_FitsHeader(binnedCellSize_nm = self.getProp("UnitCellSize")[0] * self.present_CameraSettings.Binning[0]), 593 | "DATE-END": (datetime.datetime.utcfromtimestamp(metadata.get("FrameWallClock", time.time()*1e9)/1e9).isoformat(timespec="milliseconds"), 594 | "UTC time at end of observation"), 595 | "GAIN": (metadata.get("AnalogueGain", 0.0), "Gain"), 596 | "DGAIN": (metadata.get("DigitalGain", 0.0), "Digital Gain"), 597 | } 598 | if self.config.getboolean("driver", "extended_Metadata", fallback=False): 599 | # This is very detailed information about the camera binning. But some plate solver ignore this and get 600 | # trouble with a wrong field of view. 601 | FitsHeader.update({ 602 | "PIXSIZE1": (self.getProp("UnitCellSize")[0] / 1e3, "[um] Pixel Size 1"), 603 | "PIXSIZE2": (self.getProp("UnitCellSize")[1] / 1e3, "[um] Pixel Size 2"), 604 | "XBINNING": (self.present_CameraSettings.Binning[0], "Binning factor in width"), 605 | "YBINNING": (self.present_CameraSettings.Binning[1], "Binning factor in height"), 606 | "XPIXSZ": (self.getProp("UnitCellSize")[0] / 1e3 * self.present_CameraSettings.Binning[0], 607 | "[um] X binned pixel size"), 608 | "YPIXSZ": (self.getProp("UnitCellSize")[1] / 1e3 * self.present_CameraSettings.Binning[1], 609 | "[um] Y binned pixel size"), 610 | }) 611 | else: 612 | # Pretend to be a camera without binning to avoid trouble with plate solver. 613 | FitsHeader.update({ 614 | "PIXSIZE1": (self.getProp("UnitCellSize")[0] / 1e3 * self.present_CameraSettings.Binning[0], 615 | "[um] Pixel Size 1"), 616 | "PIXSIZE2": (self.getProp("UnitCellSize")[1] / 1e3 * self.present_CameraSettings.Binning[1], 617 | "[um] Pixel Size 2"), 618 | "XBINNING": (1, "Binning factor in width"), 619 | "YBINNING": (1, "Binning factor in height"), 620 | "XPIXSZ": (self.getProp("UnitCellSize")[0] / 1e3 * self.present_CameraSettings.Binning[0], 621 | "[um] X binned pixel size"), 622 | "YPIXSZ": (self.getProp("UnitCellSize")[1] / 1e3 * self.present_CameraSettings.Binning[1], 623 | "[um] Y binned pixel size"), 624 | }) 625 | if BayerPattern is not None: 626 | FitsHeader.update({ 627 | "XBAYROFF": (0, "[px] X offset of Bayer array"), 628 | "YBAYROFF": (0, "[px] Y offset of Bayer array"), 629 | "BAYERPAT": (BayerPattern, "Bayer color pattern"), 630 | }) 631 | if "SensorBlackLevels" in metadata: 632 | SensorBlackLevels = metadata["SensorBlackLevels"] 633 | if (len(SensorBlackLevels) == 4) and not is_RawMono: 634 | # according to picamera2 documentation: 635 | # "The black levels of the raw sensor image. This 636 | # control appears only in captured image 637 | # metadata and is read-only. One value is 638 | # reported for each of the four Bayer channels, 639 | # scaled up as if the full pixel range were 16 bits 640 | # (so 4096 represents a black level of 16 in 10- 641 | # bit raw data)." 642 | # When image data is stored as 16bit it is not needed to scale SensorBlackLevels again. 643 | # But when we store image with 8bit/pixel we need to divide by 2**8. 644 | SensorBlackLevelScaling = 2 ** (bit_pix - 16) 645 | FitsHeader.update({ 646 | "OFFSET_0": (SensorBlackLevels[0] * SensorBlackLevelScaling, "[DN] Sensor Black Level 0"), 647 | "OFFSET_1": (SensorBlackLevels[1] * SensorBlackLevelScaling, "[DN] Sensor Black Level 1"), 648 | "OFFSET_2": (SensorBlackLevels[2] * SensorBlackLevelScaling, "[DN] Sensor Black Level 2"), 649 | "OFFSET_3": (SensorBlackLevels[3] * SensorBlackLevelScaling, "[DN] Sensor Black Level 3"), 650 | }) 651 | for kw, value_comment in FitsHeader.items(): 652 | hdu.header[kw] = value_comment # astropy appropriately sets value and comment from tuple 653 | hdu.header.set("DATE-OBS", (datetime.datetime.fromisoformat(hdu.header["DATE-END"])-datetime.timedelta(seconds=hdu.header["EXPTIME"])).isoformat(timespec="milliseconds"), 654 | "UTC time of observation start", before="DATE-END") 655 | hdul = fits.HDUList([hdu]) 656 | return hdul 657 | 658 | def createRgbFits(self, array, metadata): 659 | """creates RGB and monochrome FITS image from RGB frame 660 | 661 | Args: 662 | array: data array 663 | metadata: metadata 664 | """ 665 | format = self.picam2.camera_configuration()["main"]["format"] 666 | self.log_FrameInformation(array=array, metadata=metadata, format=format) 667 | # first dimension must be the color channels of one pixel 668 | array = array.transpose([2, 0, 1]) 669 | if format == "BGR888": 670 | # each pixel is laid out as [R, G, B] 671 | pass 672 | elif format == "RGB888": 673 | # each pixel is laid out as [B, G, R] 674 | array = array[[2, 1, 0], :, :] 675 | elif format == "XBGR8888": 676 | # each pixel is laid out as [R, G, B, A] with A = 255 677 | array = array[[0, 1, 2], :, :] 678 | elif format == "XRGB8888": 679 | # each pixel is laid out as [B, G, R, A] with A = 255 680 | array = array[[2, 1, 0], :, :] 681 | else: 682 | raise NotImplementedError(f'got unsupported RGB image format {format}') 683 | #self.log_FrameInformation(array=array, metadata=metadata, is_raw=False) 684 | if self.present_CameraSettings.DoRgbMono: 685 | # monochrome frames are a special case of RGB: exposed with saturation=0, transmitted is R channel only 686 | array = array[0, :, :] 687 | # crop 688 | with self.parent.knownVectorsLock: 689 | array = self.parent.knownVectors["CCD_FRAME"].crop( 690 | array=array, arrayType="mono" if self.present_CameraSettings.DoRgbMono else "rgb" 691 | ) 692 | # convert to FITS 693 | hdu = fits.PrimaryHDU(array) 694 | # The image scaling in the ISP works like a software-binning. 695 | # When aspect ratio of the scaled image differs from the pixel array the ISP ignores rows (columns) on 696 | # both sides of the pixel array to select the field of view. 697 | ArraySize = self.getProp("PixelArraySize") 698 | FrameSize = self.picam2.camera_configuration()["main"]["size"] 699 | SoftwareBinning = ArraySize[1] / FrameSize[1] if (ArraySize[0] / ArraySize[1]) > (FrameSize[0] / FrameSize[1]) \ 700 | else ArraySize[0] / FrameSize[0] 701 | # avoid access conflicts to knownVectors 702 | with self.parent.knownVectorsLock: 703 | # determine frame type 704 | FrameType = self.parent.knownVectors["CCD_FRAME_TYPE"].get_OnSwitchesLabels()[0] 705 | # FITS header and metadata 706 | FitsHeader = { 707 | # "CTYPE3": 'RGB', # Is that needed to make it a RGB image? 708 | "BZERO": (0, "offset data range"), 709 | "BSCALE": (1, "default scaling factor"), 710 | "DATAMAX": 255, 711 | "DATAMIN": 0, 712 | #"ROWORDER": ("TOP-DOWN", "Row Order"), 713 | "INSTRUME": (self.parent.device, "CCD Name"), 714 | "TELESCOP": (self.parent.knownVectors["ACTIVE_DEVICES"]["ACTIVE_TELESCOPE"].value, "Telescope name"), 715 | **self.parent.knownVectors["FITS_HEADER"].FitsHeader, 716 | "EXPTIME": (metadata["ExposureTime"]/1e6, "[s] Total Exposure Time"), 717 | "CCD-TEMP": (metadata.get('SensorTemperature', 0), "[degC] CCD Temperature"), 718 | "FRAME": (FrameType, "Frame Type"), 719 | "IMAGETYP": (FrameType+" Frame", "Frame Type"), 720 | **self.snooped_FitsHeader(binnedCellSize_nm = self.getProp("UnitCellSize")[0] * SoftwareBinning), 721 | "DATE-END": (datetime.datetime.utcfromtimestamp(metadata.get("FrameWallClock", time.time()*1e9)/1e9).isoformat(timespec="milliseconds"), 722 | "UTC time at end of observation"), 723 | # more info from camera 724 | "GAIN": (metadata.get("AnalogueGain", 0.0), "Analog gain setting"), 725 | "DGAIN": (metadata.get("DigitalGain", 0.0), "Digital Gain"), 726 | } 727 | if self.config.getboolean("driver", "extended_Metadata", fallback=False): 728 | # This is very detailed information about the camera binning. But some plate solver ignore this and get 729 | # trouble with a wrong field of view. 730 | FitsHeader.update({ 731 | "PIXSIZE1": (self.getProp("UnitCellSize")[0] / 1e3, "[um] Pixel Size 1"), 732 | "PIXSIZE2": (self.getProp("UnitCellSize")[1] / 1e3, "[um] Pixel Size 2"), 733 | "XBINNING": (SoftwareBinning, "Binning factor in width"), 734 | "YBINNING": (SoftwareBinning, "Binning factor in height"), 735 | "XPIXSZ": (self.getProp("UnitCellSize")[0] / 1e3 * SoftwareBinning, "[um] X binned pixel size"), 736 | "YPIXSZ": (self.getProp("UnitCellSize")[1] / 1e3 * SoftwareBinning, "[um] Y binned pixel size"), 737 | }) 738 | else: 739 | # Pretend to be a camera without binning to avoid trouble with plate solver. 740 | FitsHeader.update({ 741 | "PIXSIZE1": (self.getProp("UnitCellSize")[0] / 1e3 * SoftwareBinning, "[um] Pixel Size 1"), 742 | "PIXSIZE2": (self.getProp("UnitCellSize")[1] / 1e3 * SoftwareBinning, "[um] Pixel Size 2"), 743 | "XBINNING": (1, "Binning factor in width"), 744 | "YBINNING": (1, "Binning factor in height"), 745 | "XPIXSZ": (self.getProp("UnitCellSize")[0] / 1e3 * SoftwareBinning, "[um] X binned pixel size"), 746 | "YPIXSZ": (self.getProp("UnitCellSize")[1] / 1e3 * SoftwareBinning, "[um] Y binned pixel size"), 747 | 748 | }) 749 | for kw, value_comment in FitsHeader.items(): 750 | hdu.header[kw] = value_comment 751 | hdu.header.set("DATE-OBS", (datetime.datetime.fromisoformat(hdu.header["DATE-END"])-datetime.timedelta(seconds=hdu.header["EXPTIME"])).isoformat(timespec="milliseconds"), 752 | "UTC time of observation start", before="DATE-END") 753 | hdul = fits.HDUList([hdu]) 754 | return hdul 755 | 756 | def log_FrameInformation(self, array, metadata, format): 757 | """write frame information to log 758 | 759 | Args: 760 | array: raw frame data 761 | metadata: frame metadata 762 | format: format string 763 | """ 764 | if self.config.getboolean("driver", "log_FrameInformation", fallback=False): 765 | if array.ndim == 2: 766 | arr = array.view(np.uint16) 767 | BitUsages = list() 768 | for b in range(15, -1, -1): 769 | BitSlice = (arr & (1 << b)) != 0 770 | BitUsage = BitSlice.sum() / arr.size 771 | BitUsages.append(BitUsage) 772 | BitUsages = [f'{bu:.1e}' for bu in BitUsages] 773 | logger.info(f'Frame format: {format}, shape: {array.shape} {array.dtype}, bit usages: (MSB) {" ".join(BitUsages)} (LSB)') 774 | else: 775 | logger.info(f'Frame format: {format}, shape: {array.shape} {array.dtype}') 776 | logger.info(f'Frame metadata: {metadata}') 777 | 778 | def __ExposureLoop(self): 779 | """exposure loop 780 | 781 | Made to run in a separate thread. 782 | 783 | typical communications between client and device: 784 | start single exposure: 785 | new CCD_EXPOSURE_VALUE 1 786 | set CCD_EXPOSURE_VALUE 1 Busy 787 | set CCD_EXPOSURE_VALUE 0.1 Busy 788 | set CCD_EXPOSURE_VALUE 0 Busy 789 | set CCD1 blob Ok 790 | set CCD_EXPOSURE_VALUE 0 Ok 791 | start Fast Exposure: 792 | new CCD_FAST_COUNT 100000 793 | set CCD_FAST_COUNT 100000 Ok 794 | new CCD_EXPOSURE_VALUE 1 795 | set CCD_EXPOSURE_VALUE 1 Busy 796 | set CCD_EXPOSURE_VALUE 0.1 Busy 797 | set CCD_EXPOSURE_VALUE 0 Busy 798 | set CCD_FAST_COUNT 99999 Busy 799 | set CCD_EXPOSURE_VALUE 0 Busy 800 | set CCD1 blob 801 | set CCD_EXPOSURE_VALUE 0 Ok 802 | set CCD_EXPOSURE_VALUE 0 Busy 803 | set CCD_FAST_COUNT 99998 Busy 804 | set CCD_EXPOSURE_VALUE 0 Busy 805 | set CCD1 blob 806 | abort: 807 | new CCD_ABORT_EXPOSURE On 808 | set CCD_FAST_COUNT 1, Idle 809 | set CCD_ABORT_EXPOSURE Off, Ok 810 | """ 811 | while True: 812 | with self.parent.knownVectorsLock: 813 | DoFastExposure = self.parent.knownVectors["CCD_FAST_TOGGLE"]["INDI_ENABLED"].value == ISwitchState.ON 814 | FastCount_Frames = self.parent.knownVectors["CCD_FAST_COUNT"]["FRAMES"].value 815 | if not DoFastExposure or (FastCount_Frames < 1): 816 | # prepare for next exposure 817 | if FastCount_Frames < 1: 818 | self.parent.setVector("CCD_FAST_COUNT", "FRAMES", value=1, state=IVectorState.OK) 819 | # wait for next action 820 | self.Sig_ActionAbort.clear() 821 | self.Sig_Do.wait() 822 | self.Sig_Do.clear() 823 | if self.Sig_ActionExpose.is_set(): 824 | self.Sig_ActionExpose.clear() 825 | if self.Sig_ActionExit.is_set(): 826 | # exit exposure loop 827 | self.picam2.stop_() 828 | self.parent.setVector("CCD_EXPOSURE", "CCD_EXPOSURE_VALUE", value=0, state=IVectorState.OK) 829 | return 830 | # picam2 needs to be open! 831 | if self.picam2 is None: 832 | raise RuntimeError("trying to make an exposure without camera opened") 833 | # get new camera settings for exposure 834 | has_RawModes = len(self.RawModes) > 0 835 | NewCameraSettings = CameraSettings() 836 | with self.parent.knownVectorsLock: 837 | NewCameraSettings.update( 838 | ExposureTime=self.ExposureTime, 839 | knownVectors=self.parent.knownVectors, 840 | advertised_camera_controls=self.camera_controls, 841 | has_RawModes=has_RawModes, 842 | ) 843 | logger.info(f'exposure settings: {NewCameraSettings}') 844 | # need a camera stop/start when something has changed on exposure controls 845 | IsRestartNeeded = self.present_CameraSettings.is_RestartNeeded(NewCameraSettings) or self.needs_Restarts 846 | if self.picam2.started and IsRestartNeeded: 847 | logger.info(f'stopping camera for deeper reconfiguration') 848 | self.picam2.stop_() 849 | # RawMode, Raw/Processed or processed frame size has changed: need to reset grop settings 850 | if self.present_CameraSettings.is_ReconfigurationNeeded(NewCameraSettings): 851 | with self.parent.knownVectorsLock: 852 | self.parent.knownVectors["CCD_FRAME"].reset() 853 | # change of DoFastExposure needs a configuration change 854 | if self.present_CameraSettings.is_ReconfigurationNeeded(NewCameraSettings) or self.needs_Restarts: 855 | logger.info(f'reconfiguring camera') 856 | # need a new camera configuration 857 | config = self.picam2.create_still_configuration( 858 | queue=NewCameraSettings.DoFastExposure, 859 | buffer_count=2 # 2 if NewCameraSettings.DoFastExposure else 1 # need at least 2 buffer for queueing 860 | ) 861 | if NewCameraSettings.DoRaw: 862 | # we do not need the main stream and configure it to smaller size to save memory 863 | config["main"]["size"] = (240, 190) 864 | # configure raw stream 865 | config["raw"] = {"size": NewCameraSettings.RawMode["size"], "format": NewCameraSettings.RawMode["camera_format"]} 866 | # libcamera internal binning does not change sensor array mechanical dimensions! 867 | #self.parent.setVector("CCD_FRAME", "WIDTH", value=NewCameraSettings.RawMode["size"][0], send=False) 868 | #self.parent.setVector("CCD_FRAME", "HEIGHT", value=NewCameraSettings.RawMode["size"][1]) 869 | else: 870 | config["main"]["size"] = NewCameraSettings.ProcSize 871 | config["main"]["format"] = "BGR888" 872 | # software image scaling does not change sensor array mechanical dimensions! 873 | #self.parent.setVector("CCD_FRAME", "WIDTH", value=NewCameraSettings.ProcSize[0], send=False) 874 | #self.parent.setVector("CCD_FRAME", "HEIGHT", value=NewCameraSettings.ProcSize[1]) 875 | # optimize (align) configuration: small changes to some main stream configurations 876 | # (for instance: size) will fit better to hardware 877 | self.picam2.align_configuration(config) 878 | # set still configuration 879 | self.picam2.configure(config) 880 | # changing exposure time or analogue gain needs a restart 881 | if IsRestartNeeded: 882 | # change camera controls 883 | self.picam2.set_controls(NewCameraSettings.get_controls()) 884 | # start camera if not already running in Fast Exposure mode 885 | if not self.picam2.started: 886 | self.picam2.start() 887 | logger.debug(f'camera started') 888 | # camera runs now with new parameter 889 | self.present_CameraSettings = NewCameraSettings 890 | # last chance to exit or abort before doing exposure 891 | if self.Sig_ActionExit.is_set(): 892 | # exit exposure loop 893 | self.picam2.stop_() 894 | self.parent.setVector("CCD_EXPOSURE", "CCD_EXPOSURE_VALUE", value=0, state=IVectorState.OK) 895 | return 896 | if self.Sig_ActionAbort.is_set(): 897 | self.Sig_ActionAbort.clear() 898 | else: 899 | # get (non-blocking!) frame and meta data 900 | self.Sig_CaptureDone.clear() 901 | ExpectedEndOfExposure = time.time() + self.present_CameraSettings.ExposureTime 902 | job = self.picam2.capture_arrays( 903 | ["raw" if self.present_CameraSettings.DoRaw else "main"], 904 | wait=False, signal_function=self.on_CaptureFinished, 905 | ) 906 | with self.parent.knownVectorsLock: 907 | PollingPeriod_s = self.parent.knownVectors["POLLING_PERIOD"]["PERIOD_MS"].value / 1e3 908 | Abort = False 909 | while ExpectedEndOfExposure - time.time() > PollingPeriod_s: 910 | # exposure count down 911 | self.parent.setVector( 912 | "CCD_EXPOSURE", "CCD_EXPOSURE_VALUE", value=ExpectedEndOfExposure - time.time(), 913 | state=IVectorState.BUSY 914 | ) 915 | # allow to close camera 916 | if self.Sig_ActionExit.is_set(): 917 | # exit exposure loop 918 | self.picam2.stop_() 919 | self.parent.setVector("CCD_EXPOSURE", "CCD_EXPOSURE_VALUE", value=0, state=IVectorState.OK) 920 | return 921 | # allow to abort exposure 922 | Abort = self.Sig_ActionAbort.is_set() 923 | if Abort: 924 | self.picam2.stop_() # stop exposure immediately 925 | self.Sig_ActionAbort.clear() 926 | break 927 | # allow exposure to finish earlier than expected (for instance when in fast exposure mode) 928 | if self.Sig_CaptureDone.is_set(): 929 | break 930 | time.sleep(PollingPeriod_s) 931 | # get frame and its metadata 932 | if not Abort: 933 | (array, ), metadata = self.picam2.wait(job) 934 | logger.info('got exposed frame') 935 | # at least HQ camera reports CCD temperature in meta data 936 | self.parent.setVector("CCD_TEMPERATURE", "CCD_TEMPERATURE_VALUE", 937 | value=metadata.get('SensorTemperature', 0)) 938 | # inform client about progress 939 | self.parent.setVector("CCD_EXPOSURE", "CCD_EXPOSURE_VALUE", value=0, state=IVectorState.BUSY) 940 | # last chance to exit or abort before sending blob 941 | if self.Sig_ActionExit.is_set(): 942 | # exit exposure loop 943 | self.picam2.stop_() 944 | self.parent.setVector("CCD_EXPOSURE", "CCD_EXPOSURE_VALUE", value=0, state=IVectorState.OK) 945 | return 946 | if self.Sig_ActionAbort.is_set(): 947 | self.Sig_ActionAbort.clear() 948 | Abort = True 949 | with self.parent.knownVectorsLock: 950 | DoFastExposure = self.parent.knownVectors["CCD_FAST_TOGGLE"]["INDI_ENABLED"].value == ISwitchState.ON 951 | FastCount_Frames = self.parent.knownVectors["CCD_FAST_COUNT"]["FRAMES"].value 952 | if not DoFastExposure: 953 | # in normal exposure mode the camera needs to be started with exposure command 954 | self.picam2.stop() 955 | if not Abort: 956 | if DoFastExposure: 957 | FastCount_Frames -= 1 958 | self.parent.setVector("CCD_FAST_COUNT", "FRAMES", value=FastCount_Frames, state=IVectorState.BUSY) 959 | # create FITS images 960 | if self.present_CameraSettings.DoRaw: 961 | hdul = self.createRawFits(array=array, metadata=metadata) 962 | else: 963 | # RGB and Mono 964 | hdul = self.createRgbFits(array=array, metadata=metadata) 965 | bstream = io.BytesIO() 966 | hdul.writeto(bstream) 967 | # free up some memory 968 | del hdul 969 | del array 970 | # save and/or transmit frame 971 | size = bstream.tell() 972 | # what to do with image 973 | with self.parent.knownVectorsLock: 974 | tv = self.parent.knownVectors["UPLOAD_SETTINGS"] 975 | upload_dir = tv["UPLOAD_DIR"].value 976 | upload_prefix = tv["UPLOAD_PREFIX"].value 977 | upload_mode = self.parent.knownVectors["UPLOAD_MODE"].get_OnSwitches() 978 | if upload_mode[0] in ["UPLOAD_LOCAL", "UPLOAD_BOTH"]: 979 | # requested to save locally 980 | local_filename = getLocalFileName(dir=upload_dir, prefix=upload_prefix, suffix=".fits") 981 | bstream.seek(0) 982 | logger.info(f"saving image to file {local_filename}") 983 | with open(local_filename, 'wb') as fh: 984 | fh.write(bstream.getbuffer()) 985 | self.parent.setVector("CCD_FILE_PATH", "FILE_PATH", value=local_filename, state=IVectorState.OK) 986 | if upload_mode[0] in ["UPLOAD_CLIENT", "UPLOAD_BOTH"]: 987 | # send blob to client 988 | bstream.seek(0) 989 | # make BLOB 990 | logger.info(f"preparing frame as BLOB: {size} bytes") 991 | bv = self.parent.knownVectors["CCD1"] 992 | compress = self.parent.knownVectors["CCD_COMPRESSION"]["CCD_COMPRESS"].value == ISwitchState.ON 993 | bv["CCD1"].set_data(data=bstream.getbuffer(), format=".fits", compress=compress) 994 | logger.info(f"sending BLOB") 995 | bv.send_setVector() 996 | # tell client that we finished exposure 997 | if DoFastExposure: 998 | if FastCount_Frames == 0: 999 | self.parent.setVector("CCD_FAST_COUNT", "FRAMES", value=0, state=IVectorState.OK) 1000 | self.parent.setVector("CCD_EXPOSURE", "CCD_EXPOSURE_VALUE", value=0, state=IVectorState.OK) 1001 | else: 1002 | self.parent.setVector("CCD_EXPOSURE", "CCD_EXPOSURE_VALUE", value=0, state=IVectorState.OK) 1003 | 1004 | def on_CaptureFinished(self, Job): 1005 | """callback function for capture done 1006 | """ 1007 | self.Sig_CaptureDone.set() 1008 | 1009 | def startExposure(self, exposuretime): 1010 | """start a single or fast exposure 1011 | 1012 | Args: 1013 | exposuretime: exposure time (seconds) 1014 | """ 1015 | if not self.ExposureThread.is_alive(): 1016 | raise RuntimeError("Try ro start exposure without having exposure loop running!") 1017 | self.ExposureTime = exposuretime 1018 | self.Sig_ActionExpose.set() 1019 | self.Sig_ActionAbort.clear() 1020 | self.Sig_ActionExit.clear() 1021 | self.Sig_Do.set() 1022 | 1023 | def abortExposure(self): 1024 | self.Sig_ActionExpose.clear() 1025 | self.Sig_ActionAbort.set() 1026 | --------------------------------------------------------------------------------