├── 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''
259 | for element in self.elements:
260 | xml += element.get_defProperty()
261 | 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""
284 | return xml
285 |
286 | def send_delVector(self):
287 | """tell client to remove this vector
288 | """
289 | logger.debug(f'send_delVector: {self.get_delVector()}')
290 | to_server(self.get_delVector())
291 |
292 | def get_setVector(self) -> str:
293 | """return XML for "set" message (to tell client about new vector data)
294 | """
295 | xml = f''
304 | for element in self.elements:
305 | xml += element.get_oneProperty()
306 | 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 |
--------------------------------------------------------------------------------