"""Serializa un Project completo a un archivo .vmsproj (SQLite).""" from __future__ import annotations import json import sqlite3 from pathlib import Path from typing import Final from vmssailor.core.persistence.migrations import ( migrate_to_latest, stamp_schema_version, ) from vmssailor.core.project import Project from vmssailor.version import VMSPROJ_SCHEMA_VERSION _SCHEMA_FILE: Final[Path] = Path(__file__).with_name("schema.sql") def save_project(project: Project, path: str | Path) -> Path: """Escribe `project` a un archivo .vmsproj. Si el archivo existe, se sobreescribe. Devuelve la `Path` final. Garantiza: - Schema aplicado y stampado con VMSPROJ_SCHEMA_VERSION. - Transacción atómica: si algo falla, el archivo destino no se altera. """ project.touch() final = Path(path) final.parent.mkdir(parents=True, exist_ok=True) tmp = final.with_suffix(final.suffix + ".tmp") if tmp.exists(): tmp.unlink() schema_sql = _SCHEMA_FILE.read_text(encoding="utf-8") conn = sqlite3.connect(str(tmp)) try: conn.executescript(schema_sql) migrate_to_latest(conn) stamp_schema_version(conn, VMSPROJ_SCHEMA_VERSION) with conn: _write_meta(conn) _write_project(conn, project) _write_vessel(conn, project) _write_equipment(conn, project) _write_topology(conn, project) _write_tags(conn, project) _write_permissives(conn, project) conn.close() except Exception: conn.close() if tmp.exists(): tmp.unlink() raise # Rename atómico (en Windows reemplaza si existe) if final.exists(): final.unlink() tmp.rename(final) return final # --- Writers internos por tabla -------------------------------------------- def _write_meta(conn: sqlite3.Connection) -> None: from vmssailor.version import __version__ rows = [ ("vmssailor_version", __version__), ("vmsproj_schema_version", str(VMSPROJ_SCHEMA_VERSION)), ("file_format", "vmsproj"), ] conn.executemany( "INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)", rows, ) def _write_project(conn: sqlite3.Connection, project: Project) -> None: systems_json = json.dumps([s.value for s in project.systems_enabled]) conn.execute( """ INSERT OR REPLACE INTO project (id, name, customer, notes, systems_enabled_json, created_at, updated_at, vmssailor_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( project.id, project.name, project.customer, project.notes, systems_json, project.created_at.isoformat(), project.updated_at.isoformat(), project.vmssailor_version, ), ) def _write_vessel(conn: sqlite3.Connection, project: Project) -> None: v = project.vessel conn.execute( """ INSERT OR REPLACE INTO vessel (id, name, type, subtype, length_overall_m, beam_max_m, draft_m, displacement_kg, silhouette_svg, description, data_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( v.id, v.name, v.type.value, v.subtype.value, v.length_overall_m, v.beam_max_m, v.draft_m, v.displacement_kg, v.silhouette_svg, v.description, v.data_source, ), ) # Borrar y reinsertar decks + bulkheads de este vessel conn.execute("DELETE FROM deck WHERE vessel_id = ?", (v.id,)) for d in v.decks: conn.execute( """ INSERT INTO deck (vessel_id, id, name, z_bl_bottom, z_bl_top, polygon_xy_json) VALUES (?, ?, ?, ?, ?, ?) """, ( v.id, d.id, d.name, d.z_bl_bottom, d.z_bl_top, json.dumps(d.polygon_xy), ), ) conn.execute("DELETE FROM bulkhead WHERE vessel_id = ?", (v.id,)) for b in v.bulkheads: conn.execute( "INSERT INTO bulkhead (vessel_id, id, name, x_pp, description) VALUES (?, ?, ?, ?, ?)", (v.id, b.id, b.name, b.x_pp, b.description), ) def _write_equipment(conn: sqlite3.Connection, project: Project) -> None: conn.execute("DELETE FROM equipment") for e in project.equipment: conn.execute( """ INSERT INTO equipment (id, model_ref, tag_prefix, display_name, x_pp, y_cl, z_bl, deck_id, system_id, description, installed) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( e.id, e.model_ref, e.tag_prefix, e.display_name, e.location.x_pp, e.location.y_cl, e.location.z_bl, e.deck_id, e.system_id.value, e.description, 1 if e.installed else 0, ), ) def _write_topology(conn: sqlite3.Connection, project: Project) -> None: conn.execute("DELETE FROM card_instance") conn.execute("DELETE FROM bus") for b in project.topology.buses: conn.execute( """ INSERT INTO bus (id, name, protocol, physical_port, baud_rate, parity, stop_bits, termination, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( b.id, b.name, b.protocol.value, b.physical_port, b.baud_rate, b.parity, b.stop_bits, 1 if b.termination else 0, b.description, ), ) for c in project.topology.cards: x = c.location.x_pp if c.location else None y = c.location.y_cl if c.location else None z = c.location.z_bl if c.location else None conn.execute( """ INSERT INTO card_instance (id, serial_number, slot_number, bus_id, bus_role, modbus_address, physical_location, x_pp, y_cl, z_bl, firmware_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( c.id, c.serial_number, c.slot_number, c.bus_id, c.bus_role.value, c.modbus_address, c.physical_location, x, y, z, c.firmware_version, ), ) def _write_tags(conn: sqlite3.Connection, project: Project) -> None: conn.execute("DELETE FROM alarm_config") conn.execute("DELETE FROM tag") for t in project.tags: b = t.physical_binding s = b.scaling if b is not None else None conn.execute( """ INSERT INTO tag ( id, equipment_id, description, unit_si, range_normal_min, range_normal_max, quality_default, controllable, control_mode, authority_required, protocol, address, historize, historize_period_s, binding_card_id, binding_channel_type, binding_channel_number, binding_signal_type, binding_filter, binding_filter_param, binding_update_rate_ms, binding_scaling_raw_min, binding_scaling_raw_max, binding_scaling_eng_min, binding_scaling_eng_max ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( t.id, t.equipment_id, t.description, t.unit_si.value, t.range_normal_min, t.range_normal_max, t.quality_default.value, 1 if t.controllable else 0, t.control_mode.value, t.authority_required.value, t.protocol.value, t.address, 1 if t.historize else 0, t.historize_period_s, b.card_id if b else None, b.channel_type.value if b else None, b.channel_number if b else None, b.signal_type.value if b else None, b.filter.value if b else "none", b.filter_param if b else None, b.update_rate_ms if b else 100, s.raw_min if s else None, s.raw_max if s else None, s.eng_min if s else None, s.eng_max if s else None, ), ) for a in t.alarms: conn.execute( """ INSERT INTO alarm_config (tag_id, id, threshold, operator, priority, hysteresis, delay_seconds, message, escalation_minutes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( t.id, a.id, a.threshold, a.operator, a.priority.value, a.hysteresis, a.delay_seconds, a.message, a.escalation_minutes, ), ) def _write_permissives(conn: sqlite3.Connection, project: Project) -> None: conn.execute("DELETE FROM permissive_condition") conn.execute("DELETE FROM permissive_rule") for r in project.permissive_rules: conn.execute( """ INSERT INTO permissive_rule (id, action_id, description, on_fail_message) VALUES (?, ?, ?, ?) """, (r.id, r.action_id, r.description, r.on_fail_message), ) for seq, c in enumerate(r.conditions): conn.execute( """ INSERT INTO permissive_condition (rule_id, seq, tag_ref, operator, threshold, threshold_low, threshold_high, severity, message_on_fail) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( r.id, seq, c.tag_ref, c.operator, c.threshold, c.threshold_low, c.threshold_high, c.severity, c.message_on_fail, ), )