From 3207244fb5430bd27d608688859aa2b4f568e89e Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Tue, 6 Oct 2026 21:03:42 -0700 Subject: [PATCH 1/7] Fix the undefined names ruff reports (F821) #391 deleted the NSPanelRoomEntitiesPages component when the room editor moved to React, but ten htmx views still built one to render their response. Each raised NameError, so they returned 500 once they reached that line. Nothing in the templates, static JS or React code calls them any more, so remove them, their routes, and handle_entity_modal_result, whose only job was to dispatch to one of them: - partial_remove_entity_from_page_slot - partial_save_edit_entities_page - partial_move_entity - partial_move_entities_pages - partial_delete_entities_page - create_entities_page_in_room - create_or_update_{light,switch,button,scene}_entity - handle_entity_modal_result scenes_post looked lights up through a Light model that no longer exists, so POST /rest/scenes failed for any request with light states. LightState.light points at Entity, so look the light up there. Co-Authored-By: Claude Opus 5.5 --- docker/web/nspanelmanager/web/htmx.py | 397 -------------------------- docker/web/nspanelmanager/web/rest.py | 2 +- docker/web/nspanelmanager/web/urls.py | 6 - 3 files changed, 1 insertion(+), 404 deletions(-) diff --git a/docker/web/nspanelmanager/web/htmx.py b/docker/web/nspanelmanager/web/htmx.py index 7a4ceac5..30f18416 100644 --- a/docker/web/nspanelmanager/web/htmx.py +++ b/docker/web/nspanelmanager/web/htmx.py @@ -270,20 +270,6 @@ def relay_group_remove_relay(request, relay_binding_id): return response -@csrf_exempt -def handle_entity_modal_result(request): - if request.session["action"] == "ADD_SCENE_TO_NSPANEL_ENTITY_PAGE": - return create_or_update_scene_entity(request) - else: - return JsonResponse( - { - "status": "error", - "text": "Unknown action! Action: " + request.session["action"], - }, - status=500, - ) - - def partial_select_new_entity_item_list(request, action, action_args): # This is used in the last step of adding an entity to call the correct # "add function" so that it get added to the correct room, page and so on. @@ -454,35 +440,6 @@ def partial_entity_add_scene_entity(request): return render(request, "partial/select_entity/entity_add_or_edit_scene.html", data) -@csrf_exempt -def partial_remove_entity_from_page_slot(request, page_id, slot_id): - page = RoomEntitiesPage.objects.get(id=page_id) - - # Check for light in given slot - entities = page.entity_set.filter(room_view_position=slot_id).all() - if entities.count() > 0: - entities.delete() - send_mqttmanager_reload_command() - - entities = page.scene_set.filter(room_view_position=slot_id).all() - if entities.count() > 0: - entities.delete() - send_mqttmanager_reload_command() - - room_id = 0 - if page.room: - room_id = page.room.id - - entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get( - request=request, - view="edit_room", - room_id=room_id, - is_scenes_pages=page.is_scenes_page, - is_global_scenes_page=(page.room is None), - ) - - def get_entity_in_page_slot(page_id, slot_id): page = RoomEntitiesPage.objects.get(id=page_id) entities = Entity.objects.filter(entities_page=page, room_view_position=slot_id) @@ -496,98 +453,6 @@ def get_entity_in_page_slot(page_id, slot_id): return None -@csrf_exempt -def partial_move_entity(request): - existing_entity_in_slot = get_entity_in_page_slot(request.POST["page_id"], request.POST["slot_id"]) - new_entity_in_slot = None - if request.POST["new_entity_type"] == "scene": - new_entity_in_slot = Scene.objects.get(id=request.POST["new_entity_id"]) - else: - try: - new_entity_in_slot = Entity.objects.get(id=request.POST["new_entity_id"]) - except Exception as e: - return JsonResponse({"status": "error", "text": "Did not find existing entity to move!"}) - - if existing_entity_in_slot: - # Swap the existing entity place with the new entity to be put on that slot - existing_entity_in_slot.entities_page = new_entity_in_slot.entities_page - existing_entity_in_slot.room_view_position = new_entity_in_slot.room_view_position - existing_entity_in_slot.save() - - new_entity_in_slot.entities_page = RoomEntitiesPage.objects.get(id=request.POST["page_id"]) - new_entity_in_slot.room_view_position = request.POST["slot_id"] - new_entity_in_slot.save() - send_mqttmanager_reload_command() - - if new_entity_in_slot.room is None: - room_id = 0 - is_global_scenes_page = True - else: - room_id = new_entity_in_slot.room.id - is_global_scenes_page = False - - entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get( - request, - view="edit_room", - room_id=room_id, - is_scenes_pages=new_entity_in_slot.entities_page.is_scenes_page, - is_global_scenes_page=is_global_scenes_page, - ) - - -@csrf_exempt -def partial_move_entities_pages(request): - if "htmx_form_save_entities_pages_order_field" in request.POST: - json_data = json.loads(request.POST["htmx_form_save_entities_pages_order_field"]) - if "pages" in json_data: - if len(json_data["pages"]) > 0: - entity_page = RoomEntitiesPage.objects.get(id=json_data["pages"][0]) - is_global_scenes_pages = False - if entity_page.room is None: - room_id = 0 - is_global_scenes_pages = True - else: - room_id = entity_page.room.id - for index, page_id in enumerate(json_data["pages"]): - page = RoomEntitiesPage.objects.get(id=page_id) - page.display_order = index - page.save() - send_mqttmanager_reload_command() - entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get( - request, - view="edit_room", - room_id=room_id, - is_scenes_pages=entity_page.is_scenes_page, - is_global_scenes_page=is_global_scenes_pages, - ) - else: - return JsonResponse( - { - "status": "error", - "text": "'pages' field empty in request POST-data.", - }, - status=500, - ) - else: - return JsonResponse( - { - "status": "error", - "text": "'pages' field not available in JSON-data.", - }, - status=500, - ) - else: - return JsonResponse( - { - "status": "error", - "text": "'htmx_form_save_entities_pages_order_field' field not available in request POST-data.", - }, - status=500, - ) - - @csrf_exempt def partial_add_entity_to_entities_page_select_entity_type_react(request, entities_page_id, room_view_position): entities_page = RoomEntitiesPage.objects.get(id=entities_page_id) @@ -652,74 +517,6 @@ def partial_add_entity_to_entities_page_config_modal(request, entity_source): ) -@csrf_exempt -def partial_delete_entities_page(request, page_id): - page = RoomEntitiesPage.objects.get(id=page_id) - if page.room is None: - is_global_scenes_page = True - room_id = 0 - else: - is_global_scenes_page = False - room_id = page.room.id - page.delete() - - # Recalculate entity page order - for index, entity_page in enumerate( - RoomEntitiesPage.objects.filter(room=page.room, is_scenes_page=page.is_scenes_page).order_by("display_order"), - start=0, - ): - entity_page.display_order = index - entity_page.save() - - send_mqttmanager_reload_command() - - entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get( - request=request, - view="edit_room", - room_id=room_id, - is_scenes_pages=page.is_scenes_page, - is_global_scenes_page=is_global_scenes_page, - ) - - -def create_entities_page_in_room(request, room_id, page_type, is_scenes_page, is_global_scenes_page): - entity_page = RoomEntitiesPage() - entity_page.is_scenes_page = is_scenes_page == "True" - entity_page.is_global_scenes_page = is_global_scenes_page == "True" - if entity_page.is_global_scenes_page: - entity_page.room = None - entity_page.display_order = RoomEntitiesPage.objects.filter(room=None, is_scenes_page=is_scenes_page).count() - else: - room = Room.objects.get(id=room_id) - entity_page.room = room - entity_page.display_order = RoomEntitiesPage.objects.filter(room=room, is_scenes_page=is_scenes_page).count() - - if page_type == 4: - entity_page.page_type = 4 - entity_page.save() - send_mqttmanager_reload_command() - elif page_type == 8: - entity_page.page_type = 8 - entity_page.save() - send_mqttmanager_reload_command() - elif page_type == 12: - entity_page.page_type = 12 - entity_page.save() - send_mqttmanager_reload_command() - else: - print(f"ERROR! Unknown page type {page_type}") - # Return new partial HTMX update of all entities pages in this room - entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get( - request=request, - view="edit_room", - room_id=room_id, - is_scenes_pages=entity_page.is_scenes_page, - is_global_scenes_page=(entity_page.room == None), - ) - - @csrf_exempt def partial_reorder_rooms(request): if "htmx_form_save_rooms_order_field" in request.POST: @@ -772,200 +569,6 @@ def partial_select_new_outside_temperature_sensor(request): # When creating a new or updating an existing light entity this will take care of the actual creation/updating of the model # in the database. -def create_or_update_light_entity(request): - action_args = json.loads(request.session["action_args"]) # Loads arguments set when first starting process of adding/updating entity - - entity_data = { - "controller": request.session["entity_source"], - "home_assistant_name": "", - "openhab_name": "", - "openhab_control_mode": "", - "openhab_item_switch": "", - "openhab_item_dimmer": "", - "openhab_item_color_temp": "", - "openhab_item_rgb": "", - "can_dim": False, - "can_color_temperature": False, - "can_rgb": False, - "is_ceiling_light": False, - "controlled_by_nspanel_main_page": True, - } - if "entity_id" in action_args and int(action_args["entity_id"]) >= 0: - new_light = Entity.objects.get(id=int(action_args["entity_id"])) - entity_data = new_light.entity_data - else: - new_light = Entity() - new_light.entity_type = Entity.EntityType.LIGHT - - if entity_data["controller"] == "home_assistant": - entity_data["home_assistant_name"] = request.POST["home_assistant_item"] - new_light.friendly_name = request.POST["add_new_light_name"] - new_light.room = Room.objects.get(id=int(action_args["room_id"])) - new_light.entities_page = RoomEntitiesPage.objects.get(id=int(action_args["page_id"])) - new_light.room_view_position = int(action_args["page_slot"]) - - entity_data["controlled_by_nspanel_main_page"] = "controlled_by_nspanel_main_page" in request.POST - entity_data["is_ceiling_light"] = request.POST["light_type"] == "ceiling" - - if request.POST["light_control_mode"] == "dimmer": - entity_data["can_dim"] = True - entity_data["openhab_control_mode"] = "dimmer" - if entity_data["controller"] == "openhab": - entity_data["openhab_item_dimmer"] = request.POST["openhab_dimming_item"] - else: - entity_data["openhab_control_mode"] = "switch" - entity_data["can_dim"] = False - if entity_data["controller"] == "openhab": - entity_data["openhab_item_switch"] = request.POST["openhab_dimming_item"] - - if "color_temperature" in request.POST: - entity_data["can_color_temperature"] = True - if entity_data["controller"] == "openhab": - entity_data["openhab_item_color_temp"] = request.POST["openhab_color_temperature_item"] - else: - entity_data["can_color_temperature"] = False - entity_data["openhab_item_color_temp"] = "" - - if "rgb" in request.POST: - entity_data["can_rgb"] = True - if entity_data["controller"] == "openhab": - entity_data["openhab_item_rgb"] = request.POST["openhab_rgb_item"] - else: - entity_data["can_rgb"] = False - entity_data["openhab_item_rgb"] = "" - - new_light.entity_data = entity_data - new_light.save() - send_mqttmanager_reload_command() - - entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get( - request=request, - view="edit_room", - room_id=new_light.room.id, - is_scenes_pages=False, - is_global_scenes_page=False, - ) - - -def create_or_update_switch_entity(request): - action_args = json.loads(request.session["action_args"]) # Loads arguments set when first starting process of adding/updating entity - - entity_data = { - "controller": request.session["entity_source"], - "home_assistant_name": "", - "openhab_name": "", - "openhab_item_switch": "", - } - if "entity_id" in action_args and int(action_args["entity_id"]) >= 0: - new_switch = Entity.objects.get(id=int(action_args["entity_id"])) - entity_data = new_switch.entity_data - else: - new_switch = Entity() - new_switch.entity_type = Entity.EntityType.SWITCH - # Only set once, during initial creation: - new_switch.room = Room.objects.get(id=int(action_args["room_id"])) - new_switch.entities_page = RoomEntitiesPage.objects.get(id=int(action_args["page_id"])) - new_switch.room_view_position = int(action_args["page_slot"]) - if entity_data["controller"] == "home_assistant": - entity_data["home_assistant_name"] = request.POST["backend_name"] - elif entity_data["controller"] == "openhab": - entity_data["openhab_item_switch"] = request.POST["backend_name"] - - new_switch.friendly_name = request.POST["light_name"] - new_switch.entity_data = entity_data - new_switch.save() - send_mqttmanager_reload_command() - - entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get( - request=request, - view="edit_room", - room_id=new_switch.room.id, - is_scenes_pages=False, - is_global_scenes_page=False, - ) - - -def create_or_update_button_entity(request): - action_args = json.loads(request.session["action_args"]) # Loads arguments set when first starting process of adding/updating entity - - entity_data = { - "controller": request.session["entity_source"], - "home_assistant_name": "", - "mqtt_topic": "", # Used in case of controller = manual - "mqtt_payload": "", # Used in case of controller = manual - } - if "entity_id" in action_args and int(action_args["entity_id"]): - new_button = Entity.objects.get(id=int(action_args["entity_id"])) - entity_data = new_button.entity_data - else: - new_button = Entity() - new_button.entity_type = Entity.EntityType.BUTTON - # Only set once, during initial creation: - new_button.room = Room.objects.get(id=int(action_args["room_id"])) - new_button.entities_page = RoomEntitiesPage.objects.get(id=int(action_args["page_id"])) - new_button.room_view_position = int(action_args["page_slot"]) - - if entity_data["controller"] == "home_assistant": - entity_data["home_assistant_name"] = request.POST["backend_name"] - elif entity_data["controller"] == "nspm": - entity_data["mqtt_topic"] = request.POST["mqtt_topic"] - entity_data["mqtt_payload"] = request.POST["mqtt_payload"] - new_button.friendly_name = request.POST["light_name"] - new_button.entity_data = entity_data - new_button.save() - send_mqttmanager_reload_command() - - entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get( - request=request, - view="edit_room", - room_id=new_button.room.id, - is_scenes_pages=False, - is_global_scenes_page=False, - ) - - -def create_or_update_scene_entity(request): - action_args = json.loads(request.session["action_args"]) # Loads arguments set when first starting process of adding/updating entity - - if "entity_id" in action_args and int(action_args["entity_id"]) >= 0: - new_scene = Scene.objects.get(id=int(action_args["entity_id"])) - else: - new_scene = Scene() - if "room_id" in action_args: - new_scene.room = Room.objects.get(id=int(action_args["room_id"])) - else: - new_scene.room = None - new_scene.entities_page = RoomEntitiesPage.objects.get(id=int(action_args["page_id"])) - new_scene.room_view_position = int(action_args["page_slot"]) - new_scene.scene_type = request.session["entity_source"] - if new_scene.scene_type == "nspanelmanager": - pass # Do nothing, this is one of our own scenes. - elif new_scene.scene_type == "home_assistant": - new_scene.backend_name = request.POST["backend_name"] - elif new_scene.scene_type == "openhab": - new_scene.backend_name = request.POST["backend_name"] - - new_scene.friendly_name = request.POST["scene_name"] - new_scene.save() - send_mqttmanager_reload_command() - - room_id = 0 - if new_scene.room: - room_id = new_scene.room.id - - entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get( - request=request, - view="edit_room", - room_id=room_id, - is_scenes_pages=True, - is_global_scenes_page=(new_scene.room == None), - ) - - def show_messages(request): data = get_base_data(request) data["messages"] = Message.objects.all() diff --git a/docker/web/nspanelmanager/web/rest.py b/docker/web/nspanelmanager/web/rest.py index 6f3a7f1c..d394bdae 100644 --- a/docker/web/nspanelmanager/web/rest.py +++ b/docker/web/nspanelmanager/web/rest.py @@ -679,7 +679,7 @@ def scenes_post(request): scene.lightstate_set.all().delete() # Remove all old states # TODO: Convert light states to JSON fields in DB. for light_state in data["light_states"]: - light = Light.objects.filter(id=light_state["light_id"]).first() + light = Entity.objects.filter(id=light_state["light_id"], entity_type=Entity.EntityType.LIGHT).first() if light: new_state = LightState() new_state.light = light diff --git a/docker/web/nspanelmanager/web/urls.py b/docker/web/nspanelmanager/web/urls.py index 306874a7..4e956e55 100644 --- a/docker/web/nspanelmanager/web/urls.py +++ b/docker/web/nspanelmanager/web/urls.py @@ -100,7 +100,6 @@ ### HTMX "API" URLs ### ####################### path("htmx/unblock_nspanel/", htmx.unblock_nspanel, name="htmx_unblock_nspanel"), - path("htmx/partial/handle_entity_modal_result", htmx.handle_entity_modal_result, name="htmx_handle_entity_modal_result"), path("htmx/partial/select_new_entity_type_react//", htmx.partial_add_entity_to_entities_page_select_entity_type_react, name="htmx_partial_select_new_entity_type_react"), path("htmx/partial/select_new_entity_type//", htmx.partial_add_entity_to_entities_page_select_entity_type, name="htmx_partial_select_new_entity_type"), path("htmx/partial/select_new_entity_source//", htmx.partial_add_entity_to_entities_page_select_entity_source, name="htmx_partial_select_new_entity_source"), @@ -110,11 +109,6 @@ path("htmx/partial/add_light_entity_to_room/", htmx.partial_entity_add_light_entity, name="htmx_partial_add_light_entity"), path("htmx/partial/edit_light_entity_in_room/", htmx.partial_entity_edit_light_entity, name="htmx_partial_edit_light_entity"), path("htmx/partial/edit_scene_entity/", htmx.partial_entity_edit_scene_entity, name="htmx_partial_edit_scene_entity"), - path("htmx/partial/remove_entity_from_page_slot//", htmx.partial_remove_entity_from_page_slot, name="htmx_partial_remove_entity_from_page_slot"), - path("htmx/partial/move_entity", htmx.partial_move_entity, name="htmx_partial_move_entity"), - path("htmx/partial/reorder_entity_pages", htmx.partial_move_entities_pages, name="htmx_partial_move_entities_pages"), - path("htmx/partial/delete_entities_page/", htmx.partial_delete_entities_page, name="htmx_partial_delete_entities_page"), - path("htmx/partial/create_entities_page_in_room////", htmx.create_entities_page_in_room, name="htmx_create_entities_page_in_room"), path("htmx/partial/reorder_rooms", htmx.partial_reorder_rooms, name="htmx_partial_reorder_rooms"), path("htmx/partial/select_room_temperature_sensor_from_list//", htmx.select_room_temperature_sensor_from_list, name="htmx_partial_select_room_temperature_sensor_from_list"), path("htmx/partial/select_room_temperature_sensor_provider/", htmx.select_room_temperature_sensor_provider, name="htmx_partial_select_room_temperature_sensor_provider"), From c0143ae0aae4d0a448469f6b0743b179c3fddc3a Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Tue, 20 Oct 2026 18:21:36 -0700 Subject: [PATCH 2/7] Regenerate the firmware protobuf code Three .proto changes regenerated the MQTTManager C++ code but not the protobuf-c code in protobuf_firmware/ that the firmware builds against: - #385 added volume_step to NSPanelEntityState.MediaPlayer - #394 added the Sound message and NSPanelConfig.sounds - #405 added NSPanelConfig.tz_posix The protobuf check added later in this series caught all three. Generated with protoc 27.0 and protoc-gen-c 1.4.1, as CI does. Co-Authored-By: Claude Opus 5.5 --- protobuf_firmware/protobuf_nspanel.pb-c.c | 119 +++++++++++++++++- protobuf_firmware/protobuf_nspanel.pb-c.h | 51 +++++++- .../protobuf_nspanel_entity.pb-c.c | 19 ++- .../protobuf_nspanel_entity.pb-c.h | 6 +- 4 files changed, 187 insertions(+), 8 deletions(-) diff --git a/protobuf_firmware/protobuf_nspanel.pb-c.c b/protobuf_firmware/protobuf_nspanel.pb-c.c index 700dd92f..6e68e1d2 100644 --- a/protobuf_firmware/protobuf_nspanel.pb-c.c +++ b/protobuf_firmware/protobuf_nspanel.pb-c.c @@ -13,6 +13,12 @@ void nspanel_config__room_info__init static const NSPanelConfig__RoomInfo init_value = NSPANEL_CONFIG__ROOM_INFO__INIT; *message = init_value; } +void nspanel_config__sound__init + (NSPanelConfig__Sound *message) +{ + static const NSPanelConfig__Sound init_value = NSPANEL_CONFIG__SOUND__INIT; + *message = init_value; +} void nspanel_config__init (NSPanelConfig *message) { @@ -458,6 +464,57 @@ const ProtobufCMessageDescriptor nspanel_config__room_info__descriptor = (ProtobufCMessageInit) nspanel_config__room_info__init, NULL,NULL,NULL /* reserved[123] */ }; +static const ProtobufCFieldDescriptor nspanel_config__sound__field_descriptors[2] = +{ + { + "event", + 1, + PROTOBUF_C_LABEL_NONE, + PROTOBUF_C_TYPE_ENUM, + 0, /* quantifier_offset */ + offsetof(NSPanelConfig__Sound, event), + &nspanel_config__nspanel_sound_event__descriptor, + NULL, + 0, /* flags */ + 0,NULL,NULL /* reserved1,reserved2, etc */ + }, + { + "rtttl", + 2, + PROTOBUF_C_LABEL_NONE, + PROTOBUF_C_TYPE_STRING, + 0, /* quantifier_offset */ + offsetof(NSPanelConfig__Sound, rtttl), + NULL, + &protobuf_c_empty_string, + 0, /* flags */ + 0,NULL,NULL /* reserved1,reserved2, etc */ + }, +}; +static const unsigned nspanel_config__sound__field_indices_by_name[] = { + 0, /* field[0] = event */ + 1, /* field[1] = rtttl */ +}; +static const ProtobufCIntRange nspanel_config__sound__number_ranges[1 + 1] = +{ + { 1, 0 }, + { 0, 2 } +}; +const ProtobufCMessageDescriptor nspanel_config__sound__descriptor = +{ + PROTOBUF_C__MESSAGE_DESCRIPTOR_MAGIC, + "NSPanelConfig.Sound", + "Sound", + "NSPanelConfig__Sound", + "", + sizeof(NSPanelConfig__Sound), + 2, + nspanel_config__sound__field_descriptors, + nspanel_config__sound__field_indices_by_name, + 1, nspanel_config__sound__number_ranges, + (ProtobufCMessageInit) nspanel_config__sound__init, + NULL,NULL,NULL /* reserved[123] */ +}; static const ProtobufCEnumValue nspanel_config__nspanel_default_page__enum_values_by_number[3] = { { "HOME", "NSPANEL_CONFIG__NSPANEL_DEFAULT_PAGE__HOME", 0 }, @@ -556,7 +613,37 @@ const ProtobufCEnumDescriptor nspanel_config__nspanel_button_mode__descriptor = nspanel_config__nspanel_button_mode__value_ranges, NULL,NULL,NULL,NULL /* reserved[1234] */ }; -static const ProtobufCFieldDescriptor nspanel_config__field_descriptors[36] = +static const ProtobufCEnumValue nspanel_config__nspanel_sound_event__enum_values_by_number[3] = +{ + { "TOUCH", "NSPANEL_CONFIG__NSPANEL_SOUND_EVENT__TOUCH", 0 }, + { "SCENE_ACTIVATED", "NSPANEL_CONFIG__NSPANEL_SOUND_EVENT__SCENE_ACTIVATED", 1 }, + { "SCENE_SAVED", "NSPANEL_CONFIG__NSPANEL_SOUND_EVENT__SCENE_SAVED", 2 }, +}; +static const ProtobufCIntRange nspanel_config__nspanel_sound_event__value_ranges[] = { +{0, 0},{0, 3} +}; +static const ProtobufCEnumValueIndex nspanel_config__nspanel_sound_event__enum_values_by_name[3] = +{ + { "SCENE_ACTIVATED", 1 }, + { "SCENE_SAVED", 2 }, + { "TOUCH", 0 }, +}; +const ProtobufCEnumDescriptor nspanel_config__nspanel_sound_event__descriptor = +{ + PROTOBUF_C__ENUM_DESCRIPTOR_MAGIC, + "NSPanelConfig.NSPanelSoundEvent", + "NSPanelSoundEvent", + "NSPanelConfig__NSPanelSoundEvent", + "", + 3, + nspanel_config__nspanel_sound_event__enum_values_by_number, + 3, + nspanel_config__nspanel_sound_event__enum_values_by_name, + 1, + nspanel_config__nspanel_sound_event__value_ranges, + NULL,NULL,NULL,NULL /* reserved[1234] */ +}; +static const ProtobufCFieldDescriptor nspanel_config__field_descriptors[38] = { { "name", @@ -990,6 +1077,30 @@ static const ProtobufCFieldDescriptor nspanel_config__field_descriptors[36] = 0, /* flags */ 0,NULL,NULL /* reserved1,reserved2, etc */ }, + { + "sounds", + 45, + PROTOBUF_C_LABEL_REPEATED, + PROTOBUF_C_TYPE_MESSAGE, + offsetof(NSPanelConfig, n_sounds), + offsetof(NSPanelConfig, sounds), + &nspanel_config__sound__descriptor, + NULL, + 0, /* flags */ + 0,NULL,NULL /* reserved1,reserved2, etc */ + }, + { + "tz_posix", + 46, + PROTOBUF_C_LABEL_NONE, + PROTOBUF_C_TYPE_STRING, + 0, /* quantifier_offset */ + offsetof(NSPanelConfig, tz_posix), + NULL, + &protobuf_c_empty_string, + 0, /* flags */ + 0,NULL,NULL /* reserved1,reserved2, etc */ + }, }; static const unsigned nspanel_config__field_indices_by_name[] = { 32, /* field[32] = button1_lower_temperature */ @@ -1024,9 +1135,11 @@ static const unsigned nspanel_config__field_indices_by_name[] = { 10, /* field[10] = screensaver_mode */ 11, /* field[11] = show_screensaver_inside_temperature */ 12, /* field[12] = show_screensaver_outside_temperature */ + 36, /* field[36] = sounds */ 7, /* field[7] = special_mode_release_time */ 6, /* field[6] = special_mode_trigger_time */ 20, /* field[20] = temperature_calibration */ + 37, /* field[37] = tz_posix */ 14, /* field[14] = use_fahrenheit */ }; static const ProtobufCIntRange nspanel_config__number_ranges[5 + 1] = @@ -1036,7 +1149,7 @@ static const ProtobufCIntRange nspanel_config__number_ranges[5 + 1] = { 30, 23 }, { 35, 27 }, { 37, 28 }, - { 0, 36 } + { 0, 38 } }; const ProtobufCMessageDescriptor nspanel_config__descriptor = { @@ -1046,7 +1159,7 @@ const ProtobufCMessageDescriptor nspanel_config__descriptor = "NSPanelConfig", "", sizeof(NSPanelConfig), - 36, + 38, nspanel_config__field_descriptors, nspanel_config__field_indices_by_name, 5, nspanel_config__number_ranges, diff --git a/protobuf_firmware/protobuf_nspanel.pb-c.h b/protobuf_firmware/protobuf_nspanel.pb-c.h index 54158df8..9f2aaa12 100644 --- a/protobuf_firmware/protobuf_nspanel.pb-c.h +++ b/protobuf_firmware/protobuf_nspanel.pb-c.h @@ -17,6 +17,7 @@ PROTOBUF_C__BEGIN_DECLS typedef struct NSPanelConfig NSPanelConfig; typedef struct NSPanelConfig__RoomInfo NSPanelConfig__RoomInfo; +typedef struct NSPanelConfig__Sound NSPanelConfig__Sound; typedef struct NSPanelWarning NSPanelWarning; typedef struct NSPanelStatusReport NSPanelStatusReport; typedef struct NSPanelRoomEntitiesPage NSPanelRoomEntitiesPage; @@ -67,6 +68,25 @@ typedef enum _NSPanelConfig__NSPanelButtonMode { NSPANEL_CONFIG__NSPANEL_BUTTON_MODE__THERMOSTAT_COOL = 4 PROTOBUF_C__FORCE_ENUM_TO_BE_INT_SIZE(NSPANEL_CONFIG__NSPANEL_BUTTON_MODE) } NSPanelConfig__NSPanelButtonMode; +/* + * Sounds played by the panel buzzer, one entry per event. An event without an entry, or with an empty rtttl, is silent. + * Panels ignore events they do not know about. + */ +typedef enum _NSPanelConfig__NSPanelSoundEvent { + /* + * The screen was touched + */ + NSPANEL_CONFIG__NSPANEL_SOUND_EVENT__TOUCH = 0, + /* + * A scene was triggered from the panel. Played instead of the TOUCH sound for that touch. + */ + NSPANEL_CONFIG__NSPANEL_SOUND_EVENT__SCENE_ACTIVATED = 1, + /* + * A scene was saved from the panel + */ + NSPANEL_CONFIG__NSPANEL_SOUND_EVENT__SCENE_SAVED = 2 + PROTOBUF_C__FORCE_ENUM_TO_BE_INT_SIZE(NSPANEL_CONFIG__NSPANEL_SOUND_EVENT) +} NSPanelConfig__NSPanelSoundEvent; typedef enum _NSPanelStatusReport__State { NSPANEL_STATUS_REPORT__STATE__ONLINE = 0, NSPANEL_STATUS_REPORT__STATE__OFFLINE = 1, @@ -126,6 +146,20 @@ struct NSPanelConfig__RoomInfo , 0, 0,NULL, 0,NULL } +struct NSPanelConfig__Sound +{ + ProtobufCMessage base; + NSPanelConfig__NSPanelSoundEvent event; + /* + * Melody in RTTTL format, ex. "touch:d=32,o=6,b=190:g6,16p,32c7" + */ + char *rtttl; +}; +#define NSPANEL_CONFIG__SOUND__INIT \ + { PROTOBUF_C_MESSAGE_INIT (&nspanel_config__sound__descriptor) \ + , NSPANEL_CONFIG__NSPANEL_SOUND_EVENT__TOUCH, (char *)protobuf_c_empty_string } + + struct NSPanelConfig { ProtobufCMessage base; @@ -190,10 +224,17 @@ struct NSPanelConfig * At what temperature should relay2 turn off (heating) or on (cooling) when in thermostat mode. */ int32_t button2_upper_temperature; + size_t n_sounds; + NSPanelConfig__Sound **sounds; + /* + * POSIX TZ rule for the manager's timezone, ex. "CET-1CEST,M3.5.0,M10.5.0/3", for setenv("TZ", ...) and tzset(). + * Empty if the manager could not work it out, in which case show the time from the manager's status/time topic. + */ + char *tz_posix; }; #define NSPANEL_CONFIG__INIT \ { PROTOBUF_C_MESSAGE_INIT (&nspanel_config__descriptor) \ - , (char *)protobuf_c_empty_string, 0, NSPANEL_CONFIG__NSPANEL_DEFAULT_PAGE__HOME, 0, 0, 0, 0, 0, 0, 0, NSPANEL_CONFIG__NSPANEL_SCREENSAVER_MODE__WEATHER_WITH_BACKGROUND, 0, 0, 0, 0, 0, 0,NULL, 0, 0, 0, 0, NSPANEL_CONFIG__NSPANEL_BUTTON_MODE__DIRECT, NSPANEL_CONFIG__NSPANEL_BUTTON_MODE__DIRECT, 0,NULL, 0, 0, 0, 0,NULL, 0,NULL, 0, 0, (char *)protobuf_c_empty_string, 0, 0, 0, 0 } + , (char *)protobuf_c_empty_string, 0, NSPANEL_CONFIG__NSPANEL_DEFAULT_PAGE__HOME, 0, 0, 0, 0, 0, 0, 0, NSPANEL_CONFIG__NSPANEL_SCREENSAVER_MODE__WEATHER_WITH_BACKGROUND, 0, 0, 0, 0, 0, 0,NULL, 0, 0, 0, 0, NSPANEL_CONFIG__NSPANEL_BUTTON_MODE__DIRECT, NSPANEL_CONFIG__NSPANEL_BUTTON_MODE__DIRECT, 0,NULL, 0, 0, 0, 0,NULL, 0,NULL, 0, 0, (char *)protobuf_c_empty_string, 0, 0, 0, 0, 0,NULL, (char *)protobuf_c_empty_string } struct NSPanelWarning @@ -509,6 +550,9 @@ struct NSPanelMQTTManagerCommand /* NSPanelConfig__RoomInfo methods */ void nspanel_config__room_info__init (NSPanelConfig__RoomInfo *message); +/* NSPanelConfig__Sound methods */ +void nspanel_config__sound__init + (NSPanelConfig__Sound *message); /* NSPanelConfig methods */ void nspanel_config__init (NSPanelConfig *message); @@ -680,6 +724,9 @@ void nspanel_mqttmanager_command__free_unpacked typedef void (*NSPanelConfig__RoomInfo_Closure) (const NSPanelConfig__RoomInfo *message, void *closure_data); +typedef void (*NSPanelConfig__Sound_Closure) + (const NSPanelConfig__Sound *message, + void *closure_data); typedef void (*NSPanelConfig_Closure) (const NSPanelConfig *message, void *closure_data); @@ -743,9 +790,11 @@ typedef void (*NSPanelMQTTManagerCommand_Closure) extern const ProtobufCEnumDescriptor nspanel_warning_level__descriptor; extern const ProtobufCMessageDescriptor nspanel_config__descriptor; extern const ProtobufCMessageDescriptor nspanel_config__room_info__descriptor; +extern const ProtobufCMessageDescriptor nspanel_config__sound__descriptor; extern const ProtobufCEnumDescriptor nspanel_config__nspanel_default_page__descriptor; extern const ProtobufCEnumDescriptor nspanel_config__nspanel_screensaver_mode__descriptor; extern const ProtobufCEnumDescriptor nspanel_config__nspanel_button_mode__descriptor; +extern const ProtobufCEnumDescriptor nspanel_config__nspanel_sound_event__descriptor; extern const ProtobufCMessageDescriptor nspanel_warning__descriptor; extern const ProtobufCMessageDescriptor nspanel_status_report__descriptor; extern const ProtobufCEnumDescriptor nspanel_status_report__state__descriptor; diff --git a/protobuf_firmware/protobuf_nspanel_entity.pb-c.c b/protobuf_firmware/protobuf_nspanel_entity.pb-c.c index 4776cf1e..6b24a081 100644 --- a/protobuf_firmware/protobuf_nspanel_entity.pb-c.c +++ b/protobuf_firmware/protobuf_nspanel_entity.pb-c.c @@ -547,7 +547,7 @@ const ProtobufCEnumDescriptor nspanel_entity_state__media_player__playback_state nspanel_entity_state__media_player__playback_state__value_ranges, NULL,NULL,NULL,NULL /* reserved[1234] */ }; -static const ProtobufCFieldDescriptor nspanel_entity_state__media_player__field_descriptors[16] = +static const ProtobufCFieldDescriptor nspanel_entity_state__media_player__field_descriptors[17] = { { "media_player_id", @@ -741,6 +741,18 @@ static const ProtobufCFieldDescriptor nspanel_entity_state__media_player__field_ 0, /* flags */ 0,NULL,NULL /* reserved1,reserved2, etc */ }, + { + "volume_step", + 17, + PROTOBUF_C_LABEL_NONE, + PROTOBUF_C_TYPE_INT32, + 0, /* quantifier_offset */ + offsetof(NSPanelEntityState__MediaPlayer, volume_step), + NULL, + NULL, + 0, /* flags */ + 0,NULL,NULL /* reserved1,reserved2, etc */ + }, }; static const unsigned nspanel_entity_state__media_player__field_indices_by_name[] = { 15, /* field[15] = album_art_url */ @@ -759,11 +771,12 @@ static const unsigned nspanel_entity_state__media_player__field_indices_by_name[ 8, /* field[8] = source_volume */ 2, /* field[2] = state */ 5, /* field[5] = volume */ + 16, /* field[16] = volume_step */ }; static const ProtobufCIntRange nspanel_entity_state__media_player__number_ranges[1 + 1] = { { 1, 0 }, - { 0, 16 } + { 0, 17 } }; const ProtobufCMessageDescriptor nspanel_entity_state__media_player__descriptor = { @@ -773,7 +786,7 @@ const ProtobufCMessageDescriptor nspanel_entity_state__media_player__descriptor "NSPanelEntityState__MediaPlayer", "", sizeof(NSPanelEntityState__MediaPlayer), - 16, + 17, nspanel_entity_state__media_player__field_descriptors, nspanel_entity_state__media_player__field_indices_by_name, 1, nspanel_entity_state__media_player__number_ranges, diff --git a/protobuf_firmware/protobuf_nspanel_entity.pb-c.h b/protobuf_firmware/protobuf_nspanel_entity.pb-c.h index 4d2d1925..89eb5e34 100644 --- a/protobuf_firmware/protobuf_nspanel_entity.pb-c.h +++ b/protobuf_firmware/protobuf_nspanel_entity.pb-c.h @@ -138,10 +138,14 @@ struct NSPanelEntityState__MediaPlayer * the panel can compare it with the last URL to tell when to fetch again. */ char *album_art_url; + /* + * How much one volume up/down press changes the volume and source volume, in 1-100%. + */ + int32_t volume_step; }; #define NSPANEL_ENTITY_STATE__MEDIA_PLAYER__INIT \ { PROTOBUF_C_MESSAGE_INIT (&nspanel_entity_state__media_player__descriptor) \ - , 0, (char *)protobuf_c_empty_string, NSPANEL_ENTITY_STATE__MEDIA_PLAYER__PLAYBACK_STATE__UNKNOWN, (char *)protobuf_c_empty_string, (char *)protobuf_c_empty_string, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, (char *)protobuf_c_empty_string } + , 0, (char *)protobuf_c_empty_string, NSPANEL_ENTITY_STATE__MEDIA_PLAYER__PLAYBACK_STATE__UNKNOWN, (char *)protobuf_c_empty_string, (char *)protobuf_c_empty_string, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, (char *)protobuf_c_empty_string, 0 } typedef enum { From 2b746537ea661525c9963a5ea0f3adb02ad38617 Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Tue, 20 Oct 2026 18:21:36 -0700 Subject: [PATCH 3/7] Add a Django checks workflow: ruff, system checks and migrations docker/ruff.toml never chose which rules to run, so it got whatever the installed ruff enabled by default. ruff 0.8 reports 102 findings on docker/web; 0.16 reports 394, mostly from rules the project never chose. List the long-standing defaults (E4, E7, E9, F) explicitly, and exclude the generated migrations and protobuf modules. The "Django checks" workflow has two jobs: - ruff, pinned, failing on undefined names and syntax errors (F821, E9). Those found the eleven broken call sites fixed in the previous commit. The remaining findings, mostly unused imports, need a cleanup pass before all of F can be enforced. - Django's system checks and makemigrations --check, so a model change without its migration fails on the pull request rather than reaching installs whose database would no longer match the code. Both pass today. settings.py keeps its secret key and database under /data, so the job creates it first. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/django-checks.yml | 66 +++++++++++++++++++++++++++++ docker/ruff.toml | 5 +++ 2 files changed, 71 insertions(+) create mode 100644 .github/workflows/django-checks.yml diff --git a/.github/workflows/django-checks.yml b/.github/workflows/django-checks.yml new file mode 100644 index 00000000..76443d07 --- /dev/null +++ b/.github/workflows/django-checks.yml @@ -0,0 +1,66 @@ +name: "Django checks" + +on: + pull_request: + paths: + - "docker/web/**" + - "docker/ruff.toml" + - ".github/workflows/django-checks.yml" + push: + branches: + - "main" + - "devel" + - "beta" + paths: + - "docker/web/**" + - "docker/ruff.toml" + - ".github/workflows/django-checks.yml" + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # Pinned: ruff's default rules and its findings change between releases. + - name: Install ruff + run: pip install ruff==0.16.10 + + # Only undefined names and syntax errors fail the build for now. The rest of + # docker/ruff.toml's rules still report findings (unused imports and the like) + # that need a cleanup pass before they can be enforced. + - name: Check for undefined names and syntax errors + working-directory: docker + run: ruff check web/ --select F821,E9 --output-format github + + migrations: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" # Matches the python:3.12 base image in docker/Dockerfile + cache: pip + cache-dependency-path: docker/web/requirements.txt + + - name: Install dependencies + run: pip install -r docker/web/requirements.txt + + # settings.py keeps the secret key and database in /data, as in the container. + - name: Create the data directory + run: sudo install -d -o "$USER" /data + + - name: Run Django system checks + working-directory: docker/web/nspanelmanager + run: python manage.py check + + # Fails if a model change has no migration, which would leave existing installs + # with a database that doesn't match the code after upgrading. + - name: Check for missing migrations + working-directory: docker/web/nspanelmanager + run: python manage.py makemigrations --check --dry-run diff --git a/docker/ruff.toml b/docker/ruff.toml index b0e205b5..ec86d4e2 100644 --- a/docker/ruff.toml +++ b/docker/ruff.toml @@ -1,7 +1,12 @@ # Allow lines to be as long as 120. line-length = 320 +# Migrations and protobuf modules are generated. +extend-exclude = ["web/nspanelmanager/web/migrations", "web/nspanelmanager/web/protobuf"] [lint] +# Ruff's long-standing defaults, listed explicitly so that a newer ruff +# doesn't enable more rules on its own. +select = ["E4", "E7", "E9", "F"] # Avoid enforcing line-length violations (`E501`) ignore = ["E501"] From b0a18c7de0350966b11c30f79ea5f8e89704d313 Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Tue, 6 Oct 2026 21:06:13 -0700 Subject: [PATCH 4/7] Build the Docker image on pull requests The image was only ever built by release.yml, so a broken Dockerfile, Python dependency or MQTTManager cross-compile surfaced when a release was published. Add a "Docker build" workflow that builds the image without pushing it: amd64 and aarch64 on pull requests and pushes, and also armv7, armhf and i386 weekly and on manual runs. The release build compiles every Conan dependency from source, which takes about an hour. Make the Conan package cache a BuildKit cache mount so it can be kept between builds, and save it with actions/cache through buildkit-cache-dance, keyed per architecture on conanfile.py and install_build_tools.sh. Release and local builds behave as before on a cold cache; local rebuilds now reuse the packages. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docker-build.yml | 91 ++++++++++++++++++++++++++++++ docker/Dockerfile | 6 +- 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docker-build.yml diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 00000000..7fce7a67 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,91 @@ +name: "Docker build" + +# Builds the release image without pushing it, so a broken Dockerfile, dependency or cross-compile +# shows up on the pull request instead of when a release is published. Pull requests and pushes build +# amd64 and aarch64; the weekly run and manual runs also build the 32-bit platforms. + +on: + pull_request: + paths: + - "docker/**" + - ".github/workflows/docker-build.yml" + push: + branches: + - "main" + - "devel" + - "beta" + paths: + - "docker/**" + - ".github/workflows/docker-build.yml" + schedule: + - cron: "0 4 * * 1" + workflow_dispatch: + +# A new push to a pull request supersedes its running build, which can take an hour. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: Build ${{ matrix.arch }} + runs-on: ubuntu-latest + # Building the Conan dependencies from source takes about an hour when the cache is cold. + timeout-minutes: 150 + strategy: + fail-fast: false + matrix: + # Same architectures as release.yml and docker/config.yaml. + arch: ${{ fromJSON((github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '["amd64", "aarch64", "armv7", "armhf", "i386"]' || '["amd64", "aarch64"]') }} + steps: + - uses: actions/checkout@v4 + + - name: Map architecture to Docker platform + id: platform + run: | + case "${{ matrix.arch }}" in + amd64) echo "platform=linux/amd64" >> "$GITHUB_OUTPUT" ;; + aarch64) echo "platform=linux/arm64" >> "$GITHUB_OUTPUT" ;; + armv7) echo "platform=linux/arm/v7" >> "$GITHUB_OUTPUT" ;; + armhf) echo "platform=linux/arm/v6" >> "$GITHUB_OUTPUT" ;; + i386) echo "platform=linux/386" >> "$GITHUB_OUTPUT" ;; + *) echo "Unknown architecture ${{ matrix.arch }}"; exit 1 ;; + esac + + # MQTTManager is cross-compiled on the runner's platform, but the final stage runs the + # target platform's apt-get and pip, which needs emulation. + - uses: docker/setup-qemu-action@v3 + + - uses: docker/setup-buildx-action@v3 + id: buildx + + - name: Cache Conan packages + id: conan-cache + uses: actions/cache@v4 + with: + path: conan-cache + key: docker-conan-${{ matrix.arch }}-${{ hashFiles('docker/MQTTManager/conanfile.py', 'docker/build_scripts/install_build_tools.sh') }} + restore-keys: docker-conan-${{ matrix.arch }}- + + # Moves the cached directory into the Dockerfile's Conan cache mount before the build, + # and back out afterwards so actions/cache can save it. + - name: Restore Conan cache mount + uses: reproducible-containers/buildkit-cache-dance@v3 + with: + builder: ${{ steps.buildx.outputs.name }} + cache-map: | + { + "conan-cache": { + "target": "/MQTTManager/conan_cache", + "id": "nspm-conan", + "sharing": "locked" + } + } + skip-extraction: ${{ steps.conan-cache.outputs.cache-hit }} + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: docker + platforms: ${{ steps.platform.outputs.platform }} + push: false diff --git a/docker/Dockerfile b/docker/Dockerfile index 94bd5a14..a8397fa1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -29,7 +29,11 @@ COPY MQTTManager/ /MQTTManager/ # Only build MQTTManager during Docker build if is is not a devel mode. RUN if [ "$IS_DEVEL" != "yes" ]; then bash /build_scripts/install_build_tools.sh "${IS_DEVEL}"; fi -RUN if [ -z "$no_mqttmanager_build" ]; then /bin/bash /MQTTManager/compile_mqttmanager.sh --target-platform "$TARGETPLATFORM" --strip; else echo "Not building MQTTManager."; fi +# The Conan package cache (core.cache:storage_path in install_build_tools.sh) is a BuildKit cache +# mount, so rebuilds reuse the dependencies instead of compiling them from source again, and CI can +# save it between runs. Only /MQTTManager/build is copied out of this stage. +RUN --mount=type=cache,id=nspm-conan,target=/MQTTManager/conan_cache,sharing=locked \ + if [ -z "$no_mqttmanager_build" ]; then /bin/bash /MQTTManager/compile_mqttmanager.sh --target-platform "$TARGETPLATFORM" --strip; else echo "Not building MQTTManager."; fi FROM python:3.12.5-bookworm ARG no_mqttmanager_build From 16b73d23b7e4d51edc56fe36d88370b9240a1f4c Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Tue, 6 Oct 2026 21:06:31 -0700 Subject: [PATCH 5/7] Choose which Web UI to build with a WEB_UI_REF build argument The ui-builder stage always cloned NSPanelManager-Web-UI's default branch. Add a WEB_UI_REF build argument that takes a branch, tag or full commit hash, so a build can use the Web UI's devel branch or a fixed commit. It defaults to main, so releases and existing builds are unchanged. Co-Authored-By: Claude Opus 5.5 --- docker/Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a8397fa1..15d377b0 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,12 +1,14 @@ FROM --platform=$BUILDPLATFORM node:20-alpine AS ui-builder ARG BUILDTIME +# Branch, tag or full commit hash of NSPanelManager-Web-UI to build, e.g. --build-arg WEB_UI_REF=devel. +ARG WEB_UI_REF=main WORKDIR /ui-app # Install git to clone your frontend repository RUN apk add --no-cache git # Clone the UI repo -RUN echo "Building UI at $BUILDTIME" && git clone https://github.com/NSPManager/NSPanelManager-Web-UI.git . # Use the echo to force a rebuild of this step. +RUN echo "Building UI at $BUILDTIME" && git clone https://github.com/NSPManager/NSPanelManager-Web-UI.git . && git fetch origin "$WEB_UI_REF" && git checkout --detach FETCH_HEAD # Use the echo to force a rebuild of this step. # Install dependencies and build RUN npm install From 1201c0303b110eb5a65c90b5c8f3ef4ef92de6d9 Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Tue, 20 Oct 2026 18:21:36 -0700 Subject: [PATCH 6/7] Check the generated protobuf code is up to date in CI The C++, Python and firmware C code generated from docker/protobuf is committed, so a .proto change can be merged without regenerating it, or regenerated by only one side. Add a "Protobuf" workflow that regenerates it and fails if the result differs from what is committed. It also checks that protoc (install_protobuf.sh), the Conan protobuf package and the Python protobuf package are the same release. The generated C++ does not compile against a different runtime, and the generated Python does not import with one. All three are 5.27.0 today. Regenerating used to need the dev container, which takes a full Conan build to create. docker/protobuf/regenerate.sh runs compile_all.sh in a plain Ubuntu 24.04 container with protoc 27.0 and protoc-gen-c 1.4.1, and needs nothing but Docker. The workflow runs that same script, so a failing check is fixed by running it and committing the result, and the error message says so. compile_all.sh's output directories can now be set from the environment, with the same defaults. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/protobuf.yml | 60 ++++++++++++++++++++++++++++++++++ docker/protobuf/compile_all.sh | 7 ++-- docker/protobuf/regenerate.sh | 32 ++++++++++++++++++ 3 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/protobuf.yml create mode 100755 docker/protobuf/regenerate.sh diff --git a/.github/workflows/protobuf.yml b/.github/workflows/protobuf.yml new file mode 100644 index 00000000..611131df --- /dev/null +++ b/.github/workflows/protobuf.yml @@ -0,0 +1,60 @@ +name: "Protobuf" + +# The code generated from docker/protobuf/*.proto is committed. Check that it matches the .proto +# files, and that protoc and both protobuf runtimes are on the same release: the generated C++ +# refuses to compile, and the generated Python refuses to import, against a different version. + +on: + pull_request: + paths: + - "docker/protobuf/**" + - "docker/MQTTManager/include/protobuf/**" + - "docker/MQTTManager/conanfile.py" + - "docker/web/nspanelmanager/web/protobuf/**" + - "docker/web/requirements.txt" + - "protobuf_firmware/**" + - ".github/workflows/protobuf.yml" + push: + branches: + - "main" + - "devel" + - "beta" + paths: + - "docker/protobuf/**" + - "docker/MQTTManager/include/protobuf/**" + - "docker/MQTTManager/conanfile.py" + - "docker/web/nspanelmanager/web/protobuf/**" + - "docker/web/requirements.txt" + - "protobuf_firmware/**" + - ".github/workflows/protobuf.yml" + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check protobuf versions match + run: | + protoc_version=$(sed -nE 's/^REMOTE_FILE="protoc-([0-9.]+)-.*/\1/p' docker/protobuf/install_protobuf.sh) + conan_version=$(sed -nE 's/.*"protobuf\/([0-9.]+)".*/\1/p' docker/MQTTManager/conanfile.py) + python_version=$(sed -nE 's/^protobuf==([0-9.]+).*/\1/p' docker/web/requirements.txt) + echo "protoc $protoc_version, Conan protobuf $conan_version, Python protobuf $python_version" + # protoc 27.0 is the protoc of the 5.27.0 C++ and Python runtimes. + if [ -z "$protoc_version" ] || [ "5.$protoc_version" != "$conan_version" ] || [ "$conan_version" != "$python_version" ]; then + echo "::error::protoc ($protoc_version in install_protobuf.sh), Conan protobuf ($conan_version in conanfile.py) and Python protobuf ($python_version in requirements.txt) must be the same release" + exit 1 + fi + + # The same script contributors run, so a failure here is fixed by running it locally. + - name: Regenerate + run: bash docker/protobuf/regenerate.sh + + - name: Check generated code is up to date + run: | + git add --intent-to-add --all + if ! git diff --exit-code --stat; then + git diff + echo "::error::Generated protobuf code is out of date. Run docker/protobuf/regenerate.sh (needs only Docker) and commit the result." + exit 1 + fi diff --git a/docker/protobuf/compile_all.sh b/docker/protobuf/compile_all.sh index 3d49134c..4decf61a 100755 --- a/docker/protobuf/compile_all.sh +++ b/docker/protobuf/compile_all.sh @@ -1,7 +1,8 @@ #!/bin/bash -DEST_DIR_MQTTMANAGER=/MQTTManager/include/protobuf/ -DEST_DIR_DJANGO=/usr/src/app/nspanelmanager/web/protobuf/ -DEST_DIR_FIRMWARE=/full_git/protobuf_firmware/ +# Output directories default to their paths in the dev container; CI overrides them. +DEST_DIR_MQTTMANAGER=${DEST_DIR_MQTTMANAGER:-/MQTTManager/include/protobuf/} +DEST_DIR_DJANGO=${DEST_DIR_DJANGO:-/usr/src/app/nspanelmanager/web/protobuf/} +DEST_DIR_FIRMWARE=${DEST_DIR_FIRMWARE:-/full_git/protobuf_firmware/} SRC_FILES=('protobuf_mqttmanager.proto' 'protobuf_general.proto' 'protobuf_formats.proto') for SRC_FILE in ${SRC_FILES[@]}; do diff --git a/docker/protobuf/regenerate.sh b/docker/protobuf/regenerate.sh new file mode 100755 index 00000000..9da487b7 --- /dev/null +++ b/docker/protobuf/regenerate.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Regenerate the committed C++, Python and firmware C protobuf code from the .proto files in this +# directory. Runs compile_all.sh in a throwaway container, so the only requirement is Docker. CI's +# protobuf check runs this same script, so its output is exactly what CI expects. +# +# docker/protobuf/regenerate.sh +set -euo pipefail + +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) + +# Ubuntu 24.04's protobuf-c-compiler is 1.4.1; install_protobuf.sh installs protoc 27.0. +docker run --rm \ + -v "$REPO_ROOT":/repo \ + -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \ + -w /repo/docker/protobuf \ + ubuntu:24.04 \ + bash -euo pipefail -c ' + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ + protobuf-c-compiler curl unzip ca-certificates >/dev/null + (cd /tmp && bash /repo/docker/protobuf/install_protobuf.sh >/dev/null) + protoc --version + # -e: compile_all.sh carries on past a failed protoc, which would leave the old files in place. + DEST_DIR_MQTTMANAGER=/repo/docker/MQTTManager/include/protobuf/ \ + DEST_DIR_DJANGO=/repo/docker/web/nspanelmanager/web/protobuf/ \ + DEST_DIR_FIRMWARE=/repo/protobuf_firmware/ \ + bash -e compile_all.sh + chown -R "$HOST_UID:$HOST_GID" \ + /repo/docker/MQTTManager/include/protobuf \ + /repo/docker/web/nspanelmanager/web/protobuf \ + /repo/protobuf_firmware + ' From 1832b2f35083afb9f50a4bf8df14e89cdc6cdf7e Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Tue, 6 Oct 2026 21:10:30 -0700 Subject: [PATCH 7/7] Type-check and build the React components in CI The React components' build output (dist/ and templates/scripts.html) is committed and served as-is, so a change to src/ that isn't rebuilt, or doesn't type-check, ships unnoticed. Add a "React components" workflow that installs from package-lock.json, runs the build (tsc -b, then vite build), and fails if the output differs from what is committed. The committed build reproduces exactly today. ESLint runs but does not fail the job yet: the existing code has 30 errors, mostly no-explicit-any and react-refresh/only-export-components. package-lock.json is used rather than bun.lock because only the former matches package.json: bun.lock predates the runtime dependencies added since May, and bun install --frozen-lockfile fails on it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/react-components.yml | 56 ++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/react-components.yml diff --git a/.github/workflows/react-components.yml b/.github/workflows/react-components.yml new file mode 100644 index 00000000..dd154021 --- /dev/null +++ b/.github/workflows/react-components.yml @@ -0,0 +1,56 @@ +name: "React components" + +# The React components in docker/web/nspanelmanager/web/react-components are built into dist/ and +# templates/scripts.html, which are committed and served as-is: the Docker image does not rebuild +# them. Check that they type-check, build, and match what is committed. + +on: + pull_request: + paths: + - "docker/web/nspanelmanager/web/react-components/**" + - ".github/workflows/react-components.yml" + push: + branches: + - "main" + - "devel" + - "beta" + paths: + - "docker/web/nspanelmanager/web/react-components/**" + - ".github/workflows/react-components.yml" + +defaults: + run: + working-directory: docker/web/nspanelmanager/web/react-components + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: docker/web/nspanelmanager/web/react-components/package-lock.json + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + # "build" runs tsc -b before vite build, so this is also the type check. + - name: Type-check and build + run: npm run build + + - name: Check the committed build is up to date + run: | + git add --intent-to-add --all . + if ! git diff --exit-code --stat -- .; then + echo "::error::dist/ or templates/scripts.html is out of date. Run the build in react-components and commit the result." + exit 1 + fi + + # Reported but not enforced yet: the existing code has 30 errors, mostly no-explicit-any and + # react-refresh/only-export-components. + - name: Lint + continue-on-error: true + run: npx eslint .