diff --git a/python/PiFinder/state.py b/python/PiFinder/state.py index 535c8f02f..80b190d40 100644 --- a/python/PiFinder/state.py +++ b/python/PiFinder/state.py @@ -200,6 +200,48 @@ def from_json(cls, json_str): return cls.from_dict(data) +@dataclass +class SQM: + """ + Represents the sky brightness (SQM) value and its source. + """ + + value: float = 20.15 # Standard value set to 20.15 + source: str = "None" + last_update: Optional[str] = None + + def __str__(self): + return ( + f"SQM(value={self.value:.2f}, " + f"source={self.source}, " + f"last_update={self.last_update})" + ) + + def reset(self): + self.value = 0.0 + self.source = "None" + self.last_update = None + + def to_dict(self): + """Convert the SQM object to a dictionary.""" + return asdict(self) + + def to_json(self): + """Convert the SQM object to a JSON string.""" + return json.dumps(self.to_dict()) + + @classmethod + def from_dict(cls, data): + """Create a SQM object from a dictionary.""" + return cls(**data) + + @classmethod + def from_json(cls, json_str): + """Create a SQM object from a JSON string.""" + data = json.loads(json_str) + return cls.from_dict(data) + + class SharedStateObj: def __init__(self): self.__power_state = 1 @@ -215,6 +257,7 @@ def __init__(self): self.__sats = None self.__imu = None self.__location: Location = Location() + self.__sqm: SQM = SQM() self.__datetime = None self.__datetime_time = None self.__screen = None @@ -298,6 +341,13 @@ def set_location(self, v): v.timezone = self.__tz_finder.timezone_at(lat=v.lat, lng=v.lon) self.__location = v + def sqm(self): + """Return the current SQM object""" + return self.__sqm + + def set_sqm(self, sqm: SQM): + self.__sqm = sqm + def last_image_metadata(self): return self.__last_image_metadata @@ -355,6 +405,12 @@ def ui_state(self): def set_ui_state(self, v): self.__ui_state = v + def get_sky_brightness(self): + """ + Returns the current sky brightness (SQM) value from the shared state. + """ + return self.__sqm.value + def __repr__(self): # A simple representation showing key attributes (adjust as needed) return ( diff --git a/python/PiFinder/ui/menu_manager.py b/python/PiFinder/ui/menu_manager.py index 7b5e88b08..14bbdd65e 100644 --- a/python/PiFinder/ui/menu_manager.py +++ b/python/PiFinder/ui/menu_manager.py @@ -5,6 +5,7 @@ from PiFinder import utils from PiFinder.ui.base import UIModule from PiFinder.ui import menu_structure +from PiFinder.ui.sqmentry import UISqmEntry from PiFinder.ui.object_details import UIObjectDetails from PiFinder.displays import DisplayBase from PiFinder.ui.text_menu import UITextMenu @@ -13,6 +14,7 @@ MarkingMenuOption, render_marking_menu, ) +from PiFinder.ui.textentry import UITextEntry def collect_preloads() -> list[dict]: @@ -104,6 +106,19 @@ def dyn_menu_equipment(cfg): equipment_menu_item["items"] = [telescope_menu, eyepiece_menu] +def dyn_menu_sqm(shared_state): + """ + Adds a submenu to the SQM page to manually set the SQM value + """ + sqm_menu_item = find_menu_by_label("sqm") + sqm_menu = { + "name": _("SQM Value"), + "class": UISqmEntry, + "label": "set_sqm", + } + sqm_menu_item["items"] = [sqm_menu] + + class MenuManager: def __init__( self, @@ -142,6 +157,8 @@ def __init__( self.ss_count = 0 dyn_menu_equipment(self.config_object) + dyn_menu_sqm(shared_state) + self.preload_modules() def screengrab(self): self.ss_count += 1 diff --git a/python/PiFinder/ui/menu_structure.py b/python/PiFinder/ui/menu_structure.py index f9af6d790..57b716169 100644 --- a/python/PiFinder/ui/menu_structure.py +++ b/python/PiFinder/ui/menu_structure.py @@ -1,11 +1,13 @@ from typing import Any from PiFinder.ui.timeentry import UITimeEntry +from PiFinder.ui.sqmentry import UISqmEntry from PiFinder.ui.text_menu import UITextMenu from PiFinder.ui.object_list import UIObjectList from PiFinder.ui.status import UIStatus from PiFinder.ui.console import UIConsole from PiFinder.ui.software import UISoftware from PiFinder.ui.gpsstatus import UIGPSStatus +from PiFinder.ui.sqm import UIsqm from PiFinder.ui.chart import UIChart from PiFinder.ui.align import UIAlign from PiFinder.ui.textentry import UITextEntry @@ -1036,6 +1038,7 @@ def _(key: str) -> Any: "items": [ {"name": _("Status"), "class": UIStatus}, {"name": _("Equipment"), "class": UIEquipment, "label": "equipment"}, + {"name": _("SQM"), "class": UIsqm, "label": "sqm"}, { "name": _("Place & Time"), "class": UITextMenu, diff --git a/python/PiFinder/ui/object_details.py b/python/PiFinder/ui/object_details.py index 9dba9d4c5..2e9a2a9e4 100644 --- a/python/PiFinder/ui/object_details.py +++ b/python/PiFinder/ui/object_details.py @@ -6,6 +6,8 @@ """ +from pydeepskylog.exceptions import InvalidParameterError + from PiFinder import cat_images from PiFinder.ui.marking_menus import MarkingMenuOption, MarkingMenu from PiFinder.obj_types import OBJ_TYPES @@ -25,6 +27,7 @@ from PiFinder.db.observations_db import ObservationsDatabase import numpy as np import time +import pydeepskylog as pds # Constants for display modes @@ -46,6 +49,7 @@ class UIObjectDetails(UIModule): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.contrast = None self.screen_direction = self.config_object.get_option("screen_direction") self.mount_type = self.config_object.get_option("mount_type") self.object = self.item_definition["object"] @@ -68,7 +72,7 @@ def __init__(self, *args, **kwargs): ), ) - # Used for displaying obsevation counts + # Used for displaying observation counts self.observations_db = ObservationsDatabase() self.simpleTextLayout = functools.partial( @@ -117,8 +121,15 @@ def _layout_designator(self): designator_color = 255 if not self.object.last_filtered_result: designator_color = 128 + + # layout the name - contrast reserve line + space_calculator = SpaceCalculatorFixed(14) + + _, typeconst = space_calculator.calculate_spaces( + self.object.display_name, self.contrast + ) return self.simpleTextLayout( - self.object.display_name, + typeconst, font=self.fonts.large, color=self.colors.get(designator_color), ) @@ -197,6 +208,82 @@ def update_object_info(self): scrollspeed=self._get_scrollspeed_config(), ) + # Get the SQM from the shared state + sqm = self.shared_state.get_sky_brightness() + + # Check if a telescope and eyepiece are set + if ( + self.config_object.equipment.active_eyepiece is None + or self.config_object.equipment.active_eyepiece is None + ): + self.contrast = "" + else: + # Calculate contrast reserve. The object diameters are given in arc seconds. + magnification = self.config_object.equipment.calc_magnification( + self.config_object.equipment.active_telescope, + self.config_object.equipment.active_eyepiece, + ) + if self.object.mag_str == "-": + self.contrast = "" + else: + try: + if self.object.size: + # Check if the size contains 'x' + if "x" in self.object.size: + diameter1, diameter2 = map( + float, self.object.size.split("x") + ) + diameter1 = ( + diameter1 * 60.0 + ) # Convert arc seconds to arc minutes + diameter2 = diameter2 * 60.0 + elif "'" in self.object.size: + # Convert arc minutes to arc seconds + diameter1 = float(self.object.size.replace("'", "")) * 60.0 + diameter2 = diameter1 + else: + diameter1 = diameter2 = float(self.object.size) * 60.0 + else: + diameter1 = diameter2 = None + + self.contrast = pds.contrast_reserve( + sqm=sqm, + telescope_diameter=self.config_object.equipment.active_telescope.aperture_mm, + magnification=magnification, + surf_brightness=None, + magnitude=float(self.object.mag_str), + object_diameter1=diameter1, + object_diameter2=diameter2, + ) + except InvalidParameterError as e: + print(f"Error calculating contrast reserve: {e}") + self.contrast = "" + if self.contrast is not None and self.contrast != "": + self.contrast = f"{self.contrast: .1f}" + else: + self.contrast = "" + + # Add contrast reserve line to details with interpretation + if self.contrast: + contrast_val = float(self.contrast) + if contrast_val < -0.2: + contrast_str = f"Object is not visible" + elif -0.2 <= contrast_val < 0.1: + contrast_str = f"Questionable detection" + elif 0.1 <= contrast_val < 0.35: + contrast_str = f"Difficult to see" + elif 0.35 <= contrast_val < 0.5: + contrast_str = f"Quite difficult to see" + elif 0.5 <= contrast_val < 1.0: + contrast_str = f"Easy to see" + elif contrast_val >= 1.0: + contrast_str = f"Very easy to see" + else: + contrast_str = f"" + self.texts["contrast_reserve"] = self.ScrollTextLayout( + contrast_str, font=self.fonts.base, color=self.colors.get(255), scrollspeed=self._get_scrollspeed_config(), + ) + # NGC description.... logs = self.observations_db.get_logs_for_object(self.object) desc = "" @@ -228,6 +315,7 @@ def update_object_info(self): magnification=magnification, ) + def active(self): self.activation_time = time.time() @@ -368,7 +456,7 @@ def update(self, force=True): if self.object_display_mode == DM_DESC or self.object_display_mode == DM_LOCATE: # catalog and entry field i.e. NGC-311 self.refresh_designator() - desc_available_lines = 4 + desc_available_lines = 3 desig = self.texts["designator"] desig.draw((0, 20)) @@ -409,6 +497,14 @@ def update(self, force=True): else: desc_available_lines += 1 # extra lines for description + contrast = self.texts.get("contrast_reserve") + + if contrast and contrast.text.strip(): + contrast.draw((0, posy)) + posy += 11 + else: + desc_available_lines +=1 + # Remaining lines with object description desc = self.texts.get("desc") if desc: diff --git a/python/PiFinder/ui/sqm.py b/python/PiFinder/ui/sqm.py new file mode 100644 index 000000000..fc9d65779 --- /dev/null +++ b/python/PiFinder/ui/sqm.py @@ -0,0 +1,67 @@ +from PiFinder.ui.base import UIModule +from PiFinder.state_utils import sleep_for_framerate +from PiFinder.ui.marking_menus import MarkingMenu, MarkingMenuOption +from PiFinder.ui.textentry import UITextEntry +from PiFinder import config + +class UIsqm(UIModule): + """ + Displays current SQM value and provides entry to manually set SQM value + """ + __title__ = "SQM" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.menu_index = 0 + self.marking_menu = MarkingMenu( + left=MarkingMenuOption(), + right=MarkingMenuOption(), + down=MarkingMenuOption(), + ) + + def update(self, force=False): + sleep_for_framerate(self.shared_state) + self.clear_screen() + sqm_value = self.shared_state.get_sky_brightness() + self.draw.text((10, 20), f"Current SQM: {sqm_value}", font=self.fonts.large.font, fill=self.colors.get(128)) + if sqm_value is not None: + self.draw.text((10, 40), f" {sqm_value}", font=self.fonts.large.font, fill=self.colors.get(128)) + else: + self.draw.text((10, 40), "No SQM value set", font=self.fonts.small.font, fill=self.colors.get(128)) + + self.draw.text( + (10, 80), + _("Manually..."), + font=self.fonts.large.font, + fill=self.colors.get(192), + ) + if self.menu_index == 0: + self.draw_menu_pointer(80) + + def key_down(self): + self.menu_index += 1 + if self.menu_index > 1: + self.menu_index = 1 + + def key_up(self): + self.menu_index -= 1 + if self.menu_index < 0: + self.menu_index = 0 + + def key_right(self): + if self.menu_index == 0: + self.jump_to_label("set_sqm") + + def draw_menu_pointer(self, horiz_position: int): + self.draw.text( + (2, horiz_position), + self._RIGHT_ARROW, + font=self.fonts.large.font, + fill=self.colors.get(255), + ) + + def active(self): + """ + Called when a module becomes active + i.e. foreground controlling display + """ diff --git a/python/PiFinder/ui/sqmentry.py b/python/PiFinder/ui/sqmentry.py new file mode 100644 index 000000000..a34a662fc --- /dev/null +++ b/python/PiFinder/ui/sqmentry.py @@ -0,0 +1,203 @@ +from PIL import Image, ImageDraw + +from PiFinder.state import SQM +from PiFinder.ui.base import UIModule +import math + +class UISqmEntry(UIModule): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Initialize two empty boxes + self.boxes = ["", ""] + self.current_box = 0 # Start with decimals + if self.shared_state: + # Get the current sky brightness from shared state + decimal_part, integer_part = math.modf(self.shared_state.get_sky_brightness()) + # Convert to string with 2 decimal places + decimal_part = f"{int(decimal_part * 100):02d}" # Convert + integer_part = f"{int(integer_part):02d}" # Convert to string with leading zeros + else: + # Default to 0.00 if no shared state is available + decimal_part, integer_part = "", "" + self.placeholders = [ + integer_part, + decimal_part, + ] + + # Screen setup + self.width = 128 + self.height = 128 + self.red = self.colors.get(255) + self.black = self.colors.get(0) + self.half_red = self.colors.get(128) + self.screen = Image.new("RGB", (self.width, self.height), "black") + self.draw = ImageDraw.Draw(self.screen) + self.bold = self.fonts.bold + + # Layout constants - updated to center the boxes + self.text_y = 25 + self.box_width = 25 + self.box_height = 20 + self.box_spacing = 15 + + # Calculate start_x to center the boxes on screen + total_width = (2 * self.box_width) + self.box_spacing + self.start_x = (self.width - total_width) // 2 + + def draw_boxes(self): + # Draw the two boxes with a decimal point between them + for i in range(2): + x = self.start_x + i * (self.box_width + self.box_spacing) + + # Draw box outline - highlight current box with a brighter outline + outline_color = self.red if i == self.current_box else self.half_red + outline_width = 2 if i == self.current_box else 1 + + self.draw.rectangle( + [x, self.text_y, x + self.box_width, self.text_y + self.box_height], + outline=outline_color, + width=outline_width, + ) + + # Draw text or placeholder + text = self.boxes[i] + if not text and i != self.current_box: + # Show placeholder if box is empty and not selected + text = self.placeholders[i] + color = self.colors.get(180) # Brighter color for placeholder + else: + color = self.red + + # Center text in box + text_width = self.bold.font.getbbox(text if text else "00")[2] + text_x = x + (self.box_width - text_width) // 2 + text_y = self.text_y + 2 + + self.draw.text((text_x, text_y), text, font=self.bold.font, fill=color) + + # Draw colon after first two boxes + if i < 1: + colon_x = x + self.box_width + self.box_spacing // 2 - 2 + self.draw.text( + (colon_x, self.text_y + 2), ".", font=self.bold.font, fill=self.red + ) + + # Draw cursor in current box if empty + if not self.boxes[self.current_box]: + x = self.start_x + self.current_box * (self.box_width + self.box_spacing) + cursor_x = x + 2 + self.draw.rectangle( + [ + cursor_x, + self.text_y + 2, + cursor_x + 8, + self.text_y + self.box_height - 2, + ], + fill=self.red, + ) + + def draw_separator(self, start_y): + # Draw a separator line before the legend + self.draw.line( + [(10, start_y), (self.width - 10, start_y)], fill=self.half_red, width=1 + ) + return start_y + 5 # Return the Y position after the separator + + def draw_legend(self, start_y): + legend_y = start_y + # Still using full red for better visibility but smaller font + legend_color = self.red + + self.draw.text( + (10, legend_y), + _(" Next box"), # Right + font=self.fonts.base.font, # Using base font + fill=legend_color, + ) + legend_y += 12 # Standard spacing + self.draw.text( + (10, legend_y), + _(" Done"), # Left + font=self.fonts.base.font, + fill=legend_color, + ) + legend_y += 12 # Standard spacing + self.draw.text( + (10, legend_y), + _("󰍴 Delete/Previous"), # minus + font=self.fonts.base.font, + fill=legend_color, + ) + + def validate_box(self, box_index, value): + """Validate the entered value for the given box""" + if not value: + return True + try: + num = int(value) + if box_index == 0: + return 14 <= num <= 22 + else: + return 0 <= num <= 99 + except ValueError: + return False + + def key_number(self, number): + current = self.boxes[self.current_box] + new_value = current + str(number) + + # Only allow 2 digits per box + if len(new_value) > 2: + return + + # Validate the new value + self.boxes[self.current_box] = new_value + # Auto-advance to next box if we have 2 digits + if len(new_value) == 2: + self.current_box = (self.current_box + 1) % 2 + + def key_minus(self): + """Delete last digit in current box or move to previous box if empty""" + if self.boxes[self.current_box]: + # Delete the last digit + self.boxes[self.current_box] = self.boxes[self.current_box][:-1] + else: + # If current box is empty, move to previous box + self.current_box = (self.current_box - 1) % 2 + + def key_right(self): + """Move to next box""" + self.current_box = (self.current_box + 1) % 2 + return False + + def inactive(self): + """Called when the module is no longer the active module""" + if not self.validate_box(0, self.boxes[0]) or not self.validate_box(1, self.boxes[1]): + # If any box has invalid value, do nothing + return + + if not self.boxes[0] or not self.boxes[1]: + # If both boxes are empty, do nothing + return + + # Create the SQM value from the boxes + sqm = float(self.boxes[1]) / 100.0 + float(self.boxes[0]) + + # Put the sqm value in a SQM object + sqm = SQM(sqm, "Manual") + self.shared_state.set_sqm(sqm) + + def update(self, force=False): + self.draw.rectangle((0, 0, 128, 128), fill=self.black) + + self.draw_boxes() + + # Draw additional elements with proper positioning + separator_y = self.draw_separator(65 + 15) + self.draw_legend(separator_y) + self.draw_legend(separator_y) + + if self.shared_state: + self.shared_state.set_screen(self.screen) + return self.screen_update() diff --git a/python/requirements.txt b/python/requirements.txt index 5f92091ab..dc2a4b35c 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -11,7 +11,7 @@ luma.lcd==2.11.0 pillow==10.4.0 numpy==1.26.2 pandas==1.5.3 -pydeepskylog==1.3.2 +pydeepskylog==1.6 pyjwt==2.8.0 python-libinput==0.3.0a0 pytz==2022.7.1