diff --git a/__pycache__/app.cpython-314.pyc b/__pycache__/app.cpython-314.pyc index 392d712..12ae5d1 100644 Binary files a/__pycache__/app.cpython-314.pyc and b/__pycache__/app.cpython-314.pyc differ diff --git a/__pycache__/client_agent_v2.cpython-314.pyc b/__pycache__/client_agent_v2.cpython-314.pyc index 24a4075..4b5e453 100644 Binary files a/__pycache__/client_agent_v2.cpython-314.pyc and b/__pycache__/client_agent_v2.cpython-314.pyc differ diff --git a/app.py b/app.py index d7200c0..bab7a6e 100644 --- a/app.py +++ b/app.py @@ -2769,9 +2769,15 @@ def api_client_locations(): buildings = [row[0] for row in cur.fetchall()] cur.execute( """ - SELECT id, name, building, cabinet + SELECT id, name, building, floor, cabinet FROM cabinets ORDER BY building NULLS LAST, + CASE + WHEN NULLIF(REGEXP_REPLACE(COALESCE(floor, ''), '\\D', '', 'g'), '') IS NULL THEN 1 + ELSE 0 + END, + COALESCE(NULLIF(REGEXP_REPLACE(COALESCE(floor, ''), '\\D', '', 'g'), ''), '0')::INT, + floor NULLS LAST, CASE WHEN NULLIF(REGEXP_REPLACE(COALESCE(cabinet, name, ''), '\\D', '', 'g'), '') IS NULL THEN 1 ELSE 0 @@ -2786,7 +2792,8 @@ def api_client_locations(): "id": row[0], "name": row[1] or "", "building": row[2] or "", - "cabinet": row[3] or row[1] or "", + "floor": row[3] or "", + "cabinet": row[4] or row[1] or "", } for row in cur.fetchall() ] diff --git a/client_agent_v2.py b/client_agent_v2.py index 8e0e187..cdfe1ce 100644 --- a/client_agent_v2.py +++ b/client_agent_v2.py @@ -750,6 +750,8 @@ class ClientV2App: self.config = config self.locations = {"buildings": [], "cabinets": []} self.locations_loaded = False + self.cabinet_id_by_label = {} + self.saved_cabinet_id = str(config.get("cabinet_id") or "").strip() self.detected_payload = build_detected_payload() self.server_var = tk.StringVar(value=config.get("server", DEFAULT_SERVER)) @@ -796,13 +798,14 @@ class ClientV2App: self.root.title(CLIENT_DISPLAY_NAME) self.window_icon_image = None self.apply_window_icon() - self.root.geometry("1100x390") + self.root.geometry("1100x385") self.root.minsize(1000, 370) self.root.configure(bg=BG) self.content_window = None self.root.update_idletasks() self.build_ui() self.root.protocol("WM_DELETE_WINDOW", self.on_close) + self.root.bind("", self.on_window_unmap, add="+") self.root.after(1000, self.start_heartbeat) self.root.after(100, self.process_tray_actions) @@ -987,16 +990,16 @@ class ClientV2App: value_label = tk.Label( cell, textvariable=variable, - bg="#e6f4ea" if badge else PANEL_BG, + bg=PANEL_BG, fg=SUCCESS if badge else TEXT, font=FONT_BOLD if badge else FONT, anchor="w", justify="left", width=12 if badge else 0, - padx=3 if badge else 0, + padx=0, pady=0, - bd=1 if badge else 0, - relief="solid" if badge else "flat", + bd=0, + relief="flat", ) value_label.grid(row=0, column=1, sticky="w" if badge else "ew") return value_label @@ -1125,7 +1128,7 @@ class ClientV2App: if hasattr(self, "rustdesk_status_label"): self.rustdesk_status_label.configure( fg=("#17853b" if installed else "#c62828"), - bg=("#e6f4ea" if installed else "#fde8e8"), + bg=PANEL_BG, ) def on_device_type_changed(self, _event=None): @@ -1195,6 +1198,7 @@ class ClientV2App: def on_building_changed(self, _event=None): self.cabinet_var.set("") + self.saved_cabinet_id = "" self.refresh_cabinets() def refresh_cabinets(self): @@ -1208,19 +1212,52 @@ class ClientV2App: if not building or (item.get("building") or "") == building ] labels = [self.cabinet_label(item) for item in cabinets] + self.cabinet_id_by_label = { + self.cabinet_label(item): str(item.get("id") or "") + for item in cabinets + } self.cabinet_box.configure(values=labels) if self.cabinet_var.get() not in labels: - self.cabinet_var.set(labels[0] if labels else "") + selected_label = next( + ( + self.cabinet_label(item) + for item in cabinets + if str(item.get("id") or "") == self.saved_cabinet_id + ), + "", + ) + if not selected_label: + legacy_label = self.cabinet_var.get().strip() + legacy_id = legacy_label.split(" | ", 1)[0].strip() if " | " in legacy_label else "" + selected_label = next( + ( + self.cabinet_label(item) + for item in cabinets + if str(item.get("id") or "") == legacy_id + ), + "", + ) + self.cabinet_var.set(selected_label or (labels[0] if labels else "")) def cabinet_label(self, item): - name = item.get("cabinet") or item.get("name") or "" - return f"{item.get('id')} | {name}" + floor = str(item.get("floor") or "").strip() + cabinet = str(item.get("cabinet") or item.get("name") or "").strip() + floor = re.sub(r"^\s*этаж\s*", "", floor, flags=re.IGNORECASE) + floor = re.sub(r"\s*этаж\s*$", "", floor, flags=re.IGNORECASE) + floor = f"{floor} Этаж" if floor else "Без этажа" + cabinet = re.sub(r"^\s*(?:кабинет|cabinet)\s*", "", cabinet, flags=re.IGNORECASE) + cabinet = re.sub(r"\s*(?:кабинет|cabinet)\s*$", "", cabinet, flags=re.IGNORECASE) + cabinet = f"кабинет {cabinet}" if cabinet else "Без кабинета" + return f"{floor} | {cabinet}" def selected_cabinet_id(self): label = self.cabinet_var.get().strip() - if " | " not in label: - return "" - return label.split(" | ", 1)[0].strip() + cabinet_id = self.cabinet_id_by_label.get(label, "") + if cabinet_id: + return cabinet_id + if " | " in label and label.split(" | ", 1)[0].strip().isdigit(): + return label.split(" | ", 1)[0].strip() + return self.saved_cabinet_id def build_register_payload(self): payload = dict(self.detected_payload) @@ -1244,6 +1281,7 @@ class ClientV2App: return payload def save_manual_config(self): + self.saved_cabinet_id = self.selected_cabinet_id() self.config.update( { "server": normalize_server_url(self.server_var.get()), @@ -1254,6 +1292,7 @@ class ClientV2App: "serial_number": self.serial_var.get().strip(), "building": self.building_var.get().strip(), "cabinet_label": self.cabinet_var.get().strip(), + "cabinet_id": self.saved_cabinet_id, "position": self.position_var.get().strip(), "full_name": self.full_name_var.get().strip(), } @@ -1352,7 +1391,7 @@ class ClientV2App: def start_heartbeat(self): server = normalize_server_url(self.server_var.get()) token = self.token_var.get().strip() - inventory_number = self.inventory_var.get().strip() + inventory_number = self.bound_inventory_number or self.inventory_var.get().strip() if not server or not token or not inventory_number: self.stop_heartbeat() return @@ -1389,6 +1428,8 @@ class ClientV2App: if not icon_path.exists(): self.tray_available = False return False + ready_event = threading.Event() + startup_errors = [] try: image = Image.open(icon_path).convert("RGBA") menu = pystray.Menu( @@ -1408,13 +1449,36 @@ class ClientV2App: ), ) self.tray_icon = pystray.Icon(CLIENT_DISPLAY_NAME, image, CLIENT_DISPLAY_NAME, menu) - self.tray_thread = threading.Thread(target=self.tray_icon.run, daemon=True) + + def tray_setup(icon): + icon.visible = True + ready_event.set() + + def run_tray(): + try: + self.tray_icon.run(setup=tray_setup) + except Exception as error: + startup_errors.append(error) + ready_event.set() + + self.tray_thread = threading.Thread(target=run_tray, daemon=True) self.tray_thread.start() except Exception: self.tray_icon = None self.tray_thread = None self.tray_available = False return False + ready_event.wait(timeout=3) + if startup_errors or not ready_event.is_set() or not self.tray_thread.is_alive(): + if self.tray_icon is not None: + try: + self.tray_icon.stop() + except Exception: + pass + self.tray_icon = None + self.tray_thread = None + self.tray_available = False + return False self.tray_available = True return True @@ -1431,6 +1495,8 @@ class ClientV2App: elif action == "exit": self.shutdown_application() return + elif isinstance(action, tuple) and action[0] == "heartbeat_status": + self.status_var.set(action[1]) except queue.Empty: pass self.root.after(100, self.process_tray_actions) @@ -1444,12 +1510,36 @@ class ClientV2App: def hide_window(self): self.root.withdraw() + def on_window_unmap(self, _event=None): + if self.tray_available and not self.is_shutting_down: + self.root.after(50, self.hide_if_minimized) + + def hide_if_minimized(self): + if self.tray_available and not self.is_shutting_down and self.root.state() == "iconic": + self.hide_window() + def _heartbeat_loop(self, server, token, inventory_number, stop_event): + last_error = "" while not stop_event.is_set(): try: - send_client_test_heartbeat(server, token, inventory_number, is_online=True) - except Exception: - pass + response = send_client_test_heartbeat( + server, + token, + inventory_number, + is_online=True, + ) + if not response.get("ok") or not response.get("is_online"): + raise RuntimeError("Сервер не подтвердил статус online") + if last_error: + self.tray_actions.put(("heartbeat_status", "✓ Статус online восстановлен")) + last_error = "" + except Exception as error: + message = format_error(error) + if message != last_error: + self.tray_actions.put( + ("heartbeat_status", f"Ошибка отправки статуса online: {message}") + ) + last_error = message stop_event.wait(HEARTBEAT_INTERVAL_SECONDS) def on_close(self): @@ -1468,12 +1558,18 @@ class ClientV2App: if stop_event is not None: stop_event.set() if heartbeat_thread and heartbeat_thread.is_alive(): - heartbeat_thread.join(timeout=1) + heartbeat_thread.join(timeout=HEARTBEAT_TIMEOUT_SECONDS + 1) if context: try: - send_client_test_heartbeat(*context, is_online=False, timeout=2) - except Exception: - pass + response = send_client_test_heartbeat( + *context, + is_online=False, + timeout=HEARTBEAT_TIMEOUT_SECONDS, + ) + if not response.get("ok") or response.get("is_online") is not False: + raise RuntimeError("Сервер не подтвердил статус offline") + except Exception as error: + self.status_var.set(f"Ошибка отправки статуса offline: {format_error(error)}") self.heartbeat_stop_event = None self.heartbeat_thread = None self.heartbeat_context = None @@ -1504,9 +1600,10 @@ def run_gui(args): tray_started = app.start_tray() if args.startup and tray_started: app.hide_window() - elif args.startup: - app.status_var.set("Не удалось запустить значок в трее") - app.show_window() + elif not tray_started: + app.status_var.set("Трей недоступен: установите pystray и Pillow, затем пересоберите клиент") + if args.startup: + app.show_window() root.mainloop()