sprint-0: fundaciones VMS-Sailor

Sprint 0 completo del producto VMS-Sailor (Vessel Management System
integrado para buques 30-40m). Brief de referencia en
VMS_Sailor_v2_Parte_*.md (intacto).

Core (vmssailor.core, 95.17% coverage, 99 tests verde):
- ShipCoord: sistema naval x_pp/y_cl/z_bl frozen
- Vessel, Deck, Bulkhead
- Equipment, EquipmentModel, Sensor, EquipmentSpec
- Tag, AlarmConfig, TagBinding, Scaling
- CardInstance, Bus, Topology con validacion 21 puntos I/O AR-NMEA-IO-v1.0
- Alarm, PermissiveRule, Condition
- Project agregado raiz con validacion cross-entity
- Persistencia portable .vmsproj (SQLite) con roundtrip verificable

Biblioteca curada seed (vmssailor.library):
- systems_catalog.json completo (catalogo maestro Parte 1 sec 7)
- 2 vessels: Sunseeker 76, Ferretti 850
- 2 motores: MTU 12V 2000 M96, Volvo D13-900
- 1 genset: Northern Lights M65C13
- yacht_motor_planeo.yaml (reglas heuristicas)
- TODO marcado data_source=seed_estimate - requiere validacion datasheets

Tools:
- vms-validate-library: CLI valida biblioteca completa
- vms-generate-test-project: CLI demo + verificacion roundtrip persistencia

Design System + 8 mockups HTML estaticos:
- docs/design_system.md (paleta Deep Ocean, gradientes, typography, motion)
- docs/brand/ (logo + variantes SVG)
- docs/mockups/splash, studio_main, runtime_overview,
  runtime_mimic_fuel (P&ID animado), runtime_alarms, runtime_trim (panel
  estrella con horizonte artificial), mobile_overview, mobile_trim
- docs/mockups/index.html (galeria)

Firmware (Sprint 12+ implementacion):
- firmware/ar_nmea_io_v1/src/config/pinout.h con macros GPIO

Decisiones autonomas documentadas en docs/decisions_sprint0.md.

Stack: Python 3.11 + uv + Pydantic v2 + SQLite stdlib + hatchling +
pytest 9 + ruff + mypy. Sin PySide6, FastAPI, Flutter ni firmware
funcional (entran en sprints siguientes).

Criterio de aceptacion Sprint 0: cumplido.
- uv sync: OK
- pytest: 99/99 verde
- cov vmssailor.core: 95.17% (objetivo >=80%)
- ruff: clean
- vms-validate-library: OK
- vms-generate-test-project: INTEGRIDAD OK

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 07:26:06 -04:00
commit deb04c9315
96 changed files with 15335 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
"""Persistencia portable de Project a SQLite (.vmsproj).
Un .vmsproj es un archivo SQLite único que contiene toda la configuración
del proyecto. Es **portable**: se puede copiar entre máquinas, abrir con
cualquier visor SQLite, y reconstruir el `Project` en memoria via roundtrip.
API pública:
- `save_project(project, path)` — escribe el Project a disco
- `load_project(path)` — reconstruye el Project desde disco
El roundtrip está garantizado: `load_project(save_project(p)) == p`.
"""
from vmssailor.core.persistence.vmsproj_reader import load_project
from vmssailor.core.persistence.vmsproj_writer import save_project
__all__ = ["load_project", "save_project"]
+46
View File
@@ -0,0 +1,46 @@
"""Migraciones de schema entre versiones de .vmsproj.
Sprint 0 sólo tiene v1. Cuando agreguemos columnas o tablas en sprints
futuros, agregamos funciones `_migrate_v1_to_v2(conn)`, etc.
"""
from __future__ import annotations
import sqlite3
from datetime import UTC, datetime
from vmssailor.version import VMSPROJ_SCHEMA_VERSION
def current_schema_version(conn: sqlite3.Connection) -> int:
"""Devuelve la versión activa en este .vmsproj, o 0 si no hay tabla."""
row = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'"
).fetchone()
if not row:
return 0
row = conn.execute(
"SELECT MAX(version) FROM schema_version"
).fetchone()
return int(row[0]) if row and row[0] is not None else 0
def stamp_schema_version(conn: sqlite3.Connection, version: int) -> None:
"""Registra que el schema fue aplicado/migrado a `version`."""
ts = datetime.now(UTC).isoformat()
conn.execute(
"INSERT OR REPLACE INTO schema_version(version, applied_at) VALUES (?, ?)",
(version, ts),
)
def migrate_to_latest(conn: sqlite3.Connection) -> None:
"""Migra el schema desde su versión actual hasta `VMSPROJ_SCHEMA_VERSION`.
En Sprint 0 sólo aplicamos el schema inicial v1.
"""
current = current_schema_version(conn)
if current >= VMSPROJ_SCHEMA_VERSION:
return
# Sprint 0: una sola versión, no hay migraciones intermedias.
stamp_schema_version(conn, VMSPROJ_SCHEMA_VERSION)
+186
View File
@@ -0,0 +1,186 @@
-- VMS-Sailor .vmsproj schema v1
-- SQLite portable. Cada proyecto = 1 archivo. Sprint 0.
PRAGMA foreign_keys = ON;
-- ----- Meta del archivo -----------------------------------------------------
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- ----- Project (singleton) --------------------------------------------------
CREATE TABLE IF NOT EXISTS project (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
customer TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
systems_enabled_json TEXT NOT NULL, -- JSON array of SystemId values
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
vmssailor_version TEXT NOT NULL
);
-- ----- Vessel ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS vessel (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
subtype TEXT NOT NULL,
length_overall_m REAL NOT NULL,
beam_max_m REAL NOT NULL,
draft_m REAL NOT NULL,
displacement_kg REAL,
silhouette_svg TEXT,
description TEXT NOT NULL DEFAULT '',
data_source TEXT NOT NULL DEFAULT 'user_input'
);
CREATE TABLE IF NOT EXISTS deck (
vessel_id TEXT NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL,
z_bl_bottom REAL NOT NULL,
z_bl_top REAL NOT NULL,
polygon_xy_json TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY (vessel_id, id),
FOREIGN KEY (vessel_id) REFERENCES vessel(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS bulkhead (
vessel_id TEXT NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL,
x_pp REAL NOT NULL,
description TEXT NOT NULL DEFAULT '',
PRIMARY KEY (vessel_id, id),
FOREIGN KEY (vessel_id) REFERENCES vessel(id) ON DELETE CASCADE
);
-- ----- Equipment ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS equipment (
id TEXT PRIMARY KEY,
model_ref TEXT NOT NULL,
tag_prefix TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
x_pp REAL NOT NULL,
y_cl REAL NOT NULL,
z_bl REAL NOT NULL,
deck_id TEXT,
system_id TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
installed INTEGER NOT NULL DEFAULT 1
);
-- ----- Bus / CardInstance / Topology ---------------------------------------
CREATE TABLE IF NOT EXISTS bus (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
protocol TEXT NOT NULL,
physical_port TEXT NOT NULL,
baud_rate INTEGER NOT NULL DEFAULT 115200,
parity TEXT NOT NULL DEFAULT 'N',
stop_bits INTEGER NOT NULL DEFAULT 1,
termination INTEGER NOT NULL DEFAULT 1,
description TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS card_instance (
id TEXT PRIMARY KEY,
serial_number TEXT NOT NULL DEFAULT '',
slot_number INTEGER NOT NULL,
bus_id TEXT NOT NULL,
bus_role TEXT NOT NULL,
modbus_address INTEGER,
physical_location TEXT NOT NULL DEFAULT '',
x_pp REAL,
y_cl REAL,
z_bl REAL,
firmware_version TEXT NOT NULL DEFAULT '0.0.0',
FOREIGN KEY (bus_id) REFERENCES bus(id) ON DELETE CASCADE
);
-- ----- Tag + binding + alarms ----------------------------------------------
CREATE TABLE IF NOT EXISTS tag (
id TEXT PRIMARY KEY,
equipment_id TEXT,
description TEXT NOT NULL DEFAULT '',
unit_si TEXT NOT NULL DEFAULT 'none',
range_normal_min REAL,
range_normal_max REAL,
quality_default TEXT NOT NULL DEFAULT 'good',
controllable INTEGER NOT NULL DEFAULT 0,
control_mode TEXT NOT NULL DEFAULT 'monitor',
authority_required TEXT NOT NULL DEFAULT 'either',
protocol TEXT NOT NULL DEFAULT 'modbus_rtu',
address INTEGER,
historize INTEGER NOT NULL DEFAULT 1,
historize_period_s REAL NOT NULL DEFAULT 1.0,
-- Physical binding inlined (1:1 with tag, optional)
binding_card_id TEXT,
binding_channel_type TEXT,
binding_channel_number INTEGER,
binding_signal_type TEXT,
binding_filter TEXT NOT NULL DEFAULT 'none',
binding_filter_param REAL,
binding_update_rate_ms INTEGER NOT NULL DEFAULT 100,
binding_scaling_raw_min REAL,
binding_scaling_raw_max REAL,
binding_scaling_eng_min REAL,
binding_scaling_eng_max REAL
);
CREATE TABLE IF NOT EXISTS alarm_config (
tag_id TEXT NOT NULL,
id TEXT NOT NULL,
threshold REAL NOT NULL,
operator TEXT NOT NULL,
priority TEXT NOT NULL,
hysteresis REAL NOT NULL DEFAULT 0.0,
delay_seconds REAL NOT NULL DEFAULT 0.0,
message TEXT NOT NULL DEFAULT '',
escalation_minutes INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (tag_id, id),
FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE
);
-- ----- Permissive rules + conditions ---------------------------------------
CREATE TABLE IF NOT EXISTS permissive_rule (
id TEXT PRIMARY KEY,
action_id TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
on_fail_message TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS permissive_condition (
rule_id TEXT NOT NULL,
seq INTEGER NOT NULL,
tag_ref TEXT NOT NULL,
operator TEXT NOT NULL,
threshold REAL,
threshold_low REAL,
threshold_high REAL,
severity TEXT NOT NULL DEFAULT 'fail',
message_on_fail TEXT NOT NULL DEFAULT '',
PRIMARY KEY (rule_id, seq),
FOREIGN KEY (rule_id) REFERENCES permissive_rule(id) ON DELETE CASCADE
);
-- ----- Índices auxiliares --------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_equipment_system ON equipment(system_id);
CREATE INDEX IF NOT EXISTS idx_tag_equipment ON tag(equipment_id);
CREATE INDEX IF NOT EXISTS idx_tag_protocol ON tag(protocol);
CREATE INDEX IF NOT EXISTS idx_card_bus ON card_instance(bus_id);
@@ -0,0 +1,365 @@
"""Reconstruye un Project desde un archivo .vmsproj (SQLite)."""
from __future__ import annotations
import json
import sqlite3
from datetime import datetime
from pathlib import Path
from vmssailor.core.alarm import Alarm # noqa: F401 (re-export consistency)
from vmssailor.core.card import Bus, CardInstance, Topology
from vmssailor.core.coords import ShipCoord
from vmssailor.core.enums import (
AlarmPriority,
AuthorityRequired,
BusRole,
ChannelType,
ControlMode,
FilterType,
Protocol,
Quality,
SignalType,
SystemId,
UnitSI,
VesselSubtype,
VesselType,
)
from vmssailor.core.equipment import Equipment
from vmssailor.core.permissive import Condition, PermissiveRule
from vmssailor.core.persistence.migrations import current_schema_version
from vmssailor.core.project import Project
from vmssailor.core.tag import AlarmConfig, Scaling, Tag, TagBinding
from vmssailor.core.vessel import Bulkhead, Deck, Vessel
from vmssailor.version import VMSPROJ_SCHEMA_VERSION
class VmsProjError(Exception):
"""Error al cargar un .vmsproj."""
def load_project(path: str | Path) -> Project:
"""Reconstruye un `Project` desde un archivo `.vmsproj`.
Garantiza: `load_project(save_project(p))` produce un Project
equivalente campo por campo.
"""
p = Path(path)
if not p.exists():
raise VmsProjError(f"Archivo .vmsproj no encontrado: {p}")
conn = sqlite3.connect(str(p))
conn.row_factory = sqlite3.Row
try:
ver = current_schema_version(conn)
if ver == 0:
raise VmsProjError(
f"Archivo {p} no tiene tabla schema_version — probablemente no es .vmsproj válido."
)
if ver > VMSPROJ_SCHEMA_VERSION:
raise VmsProjError(
f"Schema version {ver} es más nueva que la soportada por esta versión "
f"de vmssailor ({VMSPROJ_SCHEMA_VERSION}). Actualizar el código."
)
vessel = _read_vessel(conn)
equipment = _read_equipment(conn)
topology = _read_topology(conn)
tags = _read_tags(conn)
rules = _read_permissive_rules(conn)
project = _read_project(conn, vessel, equipment, topology, tags, rules)
finally:
conn.close()
return project
# --- Readers internos por tabla --------------------------------------------
def _read_project(
conn: sqlite3.Connection,
vessel: Vessel,
equipment: list[Equipment],
topology: Topology,
tags: list[Tag],
rules: list[PermissiveRule],
) -> Project:
row = conn.execute(
"""
SELECT id, name, customer, notes, systems_enabled_json,
created_at, updated_at, vmssailor_version
FROM project
LIMIT 1
"""
).fetchone()
if row is None:
raise VmsProjError("Tabla 'project' vacía.")
systems_raw = json.loads(row["systems_enabled_json"])
systems = [SystemId(s) for s in systems_raw]
return Project(
id=row["id"],
name=row["name"],
customer=row["customer"] or "",
notes=row["notes"] or "",
systems_enabled=systems,
vessel=vessel,
equipment=equipment,
topology=topology,
tags=tags,
permissive_rules=rules,
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
vmssailor_version=row["vmssailor_version"],
)
def _read_vessel(conn: sqlite3.Connection) -> Vessel:
row = conn.execute(
"""
SELECT id, name, type, subtype, length_overall_m, beam_max_m, draft_m,
displacement_kg, silhouette_svg, description, data_source
FROM vessel
LIMIT 1
"""
).fetchone()
if row is None:
raise VmsProjError("Tabla 'vessel' vacía.")
decks_rows = conn.execute(
"SELECT id, name, z_bl_bottom, z_bl_top, polygon_xy_json FROM deck "
"WHERE vessel_id = ? ORDER BY rowid",
(row["id"],),
).fetchall()
decks = [
Deck(
id=d["id"],
name=d["name"],
z_bl_bottom=d["z_bl_bottom"],
z_bl_top=d["z_bl_top"],
polygon_xy=[tuple(p) for p in json.loads(d["polygon_xy_json"])],
)
for d in decks_rows
]
bulks_rows = conn.execute(
"SELECT id, name, x_pp, description FROM bulkhead WHERE vessel_id = ? ORDER BY rowid",
(row["id"],),
).fetchall()
bulkheads = [
Bulkhead(id=b["id"], name=b["name"], x_pp=b["x_pp"], description=b["description"] or "")
for b in bulks_rows
]
return Vessel(
id=row["id"],
name=row["name"],
type=VesselType(row["type"]),
subtype=VesselSubtype(row["subtype"]),
length_overall_m=row["length_overall_m"],
beam_max_m=row["beam_max_m"],
draft_m=row["draft_m"],
displacement_kg=row["displacement_kg"],
silhouette_svg=row["silhouette_svg"],
description=row["description"] or "",
data_source=row["data_source"] or "user_input",
decks=decks,
bulkheads=bulkheads,
)
def _read_equipment(conn: sqlite3.Connection) -> list[Equipment]:
rows = conn.execute(
"""
SELECT id, model_ref, tag_prefix, display_name,
x_pp, y_cl, z_bl, deck_id, system_id, description, installed
FROM equipment
ORDER BY rowid
"""
).fetchall()
return [
Equipment(
id=r["id"],
model_ref=r["model_ref"],
tag_prefix=r["tag_prefix"],
display_name=r["display_name"],
location=ShipCoord(x_pp=r["x_pp"], y_cl=r["y_cl"], z_bl=r["z_bl"]),
deck_id=r["deck_id"],
system_id=SystemId(r["system_id"]),
description=r["description"] or "",
installed=bool(r["installed"]),
)
for r in rows
]
def _read_topology(conn: sqlite3.Connection) -> Topology:
bus_rows = conn.execute(
"""
SELECT id, name, protocol, physical_port, baud_rate, parity, stop_bits,
termination, description
FROM bus ORDER BY rowid
"""
).fetchall()
buses = [
Bus(
id=b["id"],
name=b["name"],
protocol=Protocol(b["protocol"]),
physical_port=b["physical_port"],
baud_rate=b["baud_rate"],
parity=b["parity"],
stop_bits=b["stop_bits"],
termination=bool(b["termination"]),
description=b["description"] or "",
)
for b in bus_rows
]
card_rows = conn.execute(
"""
SELECT id, serial_number, slot_number, bus_id, bus_role, modbus_address,
physical_location, x_pp, y_cl, z_bl, firmware_version
FROM card_instance
ORDER BY rowid
"""
).fetchall()
cards: list[CardInstance] = []
for r in card_rows:
loc = None
if r["x_pp"] is not None and r["y_cl"] is not None and r["z_bl"] is not None:
loc = ShipCoord(x_pp=r["x_pp"], y_cl=r["y_cl"], z_bl=r["z_bl"])
cards.append(
CardInstance(
id=r["id"],
serial_number=r["serial_number"] or "",
slot_number=r["slot_number"],
bus_id=r["bus_id"],
bus_role=BusRole(r["bus_role"]),
modbus_address=r["modbus_address"],
physical_location=r["physical_location"] or "",
location=loc,
firmware_version=r["firmware_version"] or "0.0.0",
)
)
return Topology(buses=buses, cards=cards)
def _read_tags(conn: sqlite3.Connection) -> list[Tag]:
rows = conn.execute(
"""
SELECT
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
FROM tag ORDER BY rowid
"""
).fetchall()
tags: list[Tag] = []
for r in rows:
binding = None
if r["binding_card_id"]:
scaling = None
if r["binding_scaling_raw_min"] is not None:
scaling = Scaling(
raw_min=r["binding_scaling_raw_min"],
raw_max=r["binding_scaling_raw_max"],
eng_min=r["binding_scaling_eng_min"],
eng_max=r["binding_scaling_eng_max"],
)
binding = TagBinding(
card_id=r["binding_card_id"],
channel_type=ChannelType(r["binding_channel_type"]),
channel_number=r["binding_channel_number"],
signal_type=SignalType(r["binding_signal_type"]),
filter=FilterType(r["binding_filter"] or "none"),
filter_param=r["binding_filter_param"],
update_rate_ms=r["binding_update_rate_ms"],
scaling=scaling,
)
alarm_rows = conn.execute(
"""
SELECT id, threshold, operator, priority, hysteresis,
delay_seconds, message, escalation_minutes
FROM alarm_config WHERE tag_id = ? ORDER BY rowid
""",
(r["id"],),
).fetchall()
alarms = [
AlarmConfig(
id=a["id"],
threshold=a["threshold"],
operator=a["operator"],
priority=AlarmPriority(a["priority"]),
hysteresis=a["hysteresis"],
delay_seconds=a["delay_seconds"],
message=a["message"] or "",
escalation_minutes=a["escalation_minutes"],
)
for a in alarm_rows
]
tags.append(
Tag(
id=r["id"],
equipment_id=r["equipment_id"],
description=r["description"] or "",
unit_si=UnitSI(r["unit_si"]),
range_normal_min=r["range_normal_min"],
range_normal_max=r["range_normal_max"],
quality_default=Quality(r["quality_default"]),
alarms=alarms,
controllable=bool(r["controllable"]),
control_mode=ControlMode(r["control_mode"]),
authority_required=AuthorityRequired(r["authority_required"]),
protocol=Protocol(r["protocol"]),
address=r["address"],
physical_binding=binding,
historize=bool(r["historize"]),
historize_period_s=r["historize_period_s"],
)
)
return tags
def _read_permissive_rules(conn: sqlite3.Connection) -> list[PermissiveRule]:
rows = conn.execute(
"SELECT id, action_id, description, on_fail_message FROM permissive_rule ORDER BY rowid"
).fetchall()
out: list[PermissiveRule] = []
for r in rows:
cond_rows = conn.execute(
"""
SELECT seq, tag_ref, operator, threshold, threshold_low,
threshold_high, severity, message_on_fail
FROM permissive_condition WHERE rule_id = ? ORDER BY seq
""",
(r["id"],),
).fetchall()
conds = [
Condition(
tag_ref=c["tag_ref"],
operator=c["operator"],
threshold=c["threshold"],
threshold_low=c["threshold_low"],
threshold_high=c["threshold_high"],
severity=c["severity"],
message_on_fail=c["message_on_fail"] or "",
)
for c in cond_rows
]
out.append(
PermissiveRule(
id=r["id"],
action_id=r["action_id"],
description=r["description"] or "",
on_fail_message=r["on_fail_message"] or "",
conditions=conds,
)
)
return out
@@ -0,0 +1,335 @@
"""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,
),
)