feat: AR-ElecArrangement initial commit — Python FastAPI + uvicorn (LAN desktop app, packaged as .exe via PyInstaller)
This commit is contained in:
+147
@@ -0,0 +1,147 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# AR-Autopilot — .gitignore
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Python
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
# Exception: Flutter display app source is in display/lib/ — track it
|
||||||
|
!display/lib/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.env
|
||||||
|
|
||||||
|
# pytest / coverage / mypy / ruff
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Jupyter
|
||||||
|
.ipynb_checkpoints/
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Flutter / Dart (display app)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
display/.dart_tool/
|
||||||
|
display/.flutter-plugins
|
||||||
|
display/.flutter-plugins-dependencies
|
||||||
|
display/.packages
|
||||||
|
display/.pub-cache/
|
||||||
|
display/.pub/
|
||||||
|
display/build/
|
||||||
|
display/**/build/
|
||||||
|
display/**/.dart_tool/
|
||||||
|
display/**/.idea/
|
||||||
|
display/**/*.iml
|
||||||
|
display/ios/Pods/
|
||||||
|
display/ios/.symlinks/
|
||||||
|
display/android/.gradle/
|
||||||
|
display/android/local.properties
|
||||||
|
display/android/captures/
|
||||||
|
display/android/gradlew
|
||||||
|
display/android/gradlew.bat
|
||||||
|
display/android/gradle-wrapper.jar
|
||||||
|
display/windows/flutter/ephemeral/
|
||||||
|
display/linux/flutter/ephemeral/
|
||||||
|
display/macos/Flutter/ephemeral/
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# PlatformIO / ESP32 firmware
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
firmware/**/.pio/
|
||||||
|
firmware/**/.pioenvs/
|
||||||
|
firmware/**/.piolibdeps/
|
||||||
|
firmware/**/.vscode/
|
||||||
|
firmware/**/.clang_complete
|
||||||
|
firmware/**/.gcc-flags.json
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# IDEs / Editors
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.project
|
||||||
|
.pydevproject
|
||||||
|
.settings/
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# OS files
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Build artifacts / installers
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
installer/output/
|
||||||
|
installer/build/
|
||||||
|
*.msi
|
||||||
|
*.exe
|
||||||
|
*.appack
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Local config / secrets
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
*.local.yaml
|
||||||
|
*.local.json
|
||||||
|
secrets/
|
||||||
|
.env.local
|
||||||
|
.secrets/
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Examples output
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
examples/output/
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Logs
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Claude Code local settings + worktrees (personal — not committed)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
.claude/settings.local.json
|
||||||
|
.claude/worktrees/
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# ── Python ───────────────────────────────────────────────────────────────────
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# ── Environments ─────────────────────────────────────────────────────────────
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
|
||||||
|
# ── PyInstaller ──────────────────────────────────────────────────────────────
|
||||||
|
*.spec
|
||||||
|
!arelec.spec # keep the canonical spec checked in
|
||||||
|
|
||||||
|
# ── IDE / Editor ─────────────────────────────────────────────────────────────
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# ── Frontend (Vue/Vite/Node) ─────────────────────────────────────────────────
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
frontend/coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# ── Project files (user data) ────────────────────────────────────────────────
|
||||||
|
*.area
|
||||||
|
!sample_projects/*.area
|
||||||
|
projects/
|
||||||
|
|
||||||
|
# ── Logs ─────────────────────────────────────────────────────────────────────
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# ── OS / temp ────────────────────────────────────────────────────────────────
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
*~
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
AR-ElecArrangement
|
||||||
|
Copyright © 2026 Alvaro Enrique Romero Donado. Todos los derechos reservados.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Este software es propiedad exclusiva de su autor. Ningún derecho de uso,
|
||||||
|
copia, modificación, distribución, ingeniería inversa o redistribución se
|
||||||
|
concede sin licencia escrita expresa del titular.
|
||||||
|
|
||||||
|
Para licenciamiento contactar: alro65@gmail.com
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
This software is the exclusive property of its author. No right of use,
|
||||||
|
copy, modification, distribution, reverse engineering, or redistribution
|
||||||
|
is granted without express written license from the holder.
|
||||||
|
|
||||||
|
For licensing inquiries: alro65@gmail.com
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
AR-Autopilot
|
||||||
|
Copyright (c) 2026 Alvaro Romero. All Rights Reserved.
|
||||||
|
|
||||||
|
PROPRIETARY AND CONFIDENTIAL — NOT FOR REDISTRIBUTION
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
This software, including but not limited to its source code, firmware,
|
||||||
|
configuration files, default tuning parameters, schematics, documentation,
|
||||||
|
and any accompanying assets (collectively, the "Software"), is the exclusive
|
||||||
|
property of Alvaro Romero ("the Author") and is protected by international
|
||||||
|
copyright law and treaty provisions.
|
||||||
|
|
||||||
|
PERMITTED USE
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
No license, express or implied, is granted to any person or entity to:
|
||||||
|
|
||||||
|
(a) use, copy, modify, merge, publish, distribute, sublicense, or sell
|
||||||
|
copies of the Software, in whole or in part;
|
||||||
|
|
||||||
|
(b) reverse-engineer, decompile, disassemble, or otherwise attempt to
|
||||||
|
derive the source code from compiled binaries or firmware images;
|
||||||
|
|
||||||
|
(c) extract, reuse, or redistribute the default PID tuning parameters,
|
||||||
|
gain schedules, actuator profiles, vessel profiles, or any other
|
||||||
|
proprietary parameter set bundled with the Software, which constitute
|
||||||
|
trade secrets of the Author;
|
||||||
|
|
||||||
|
(d) use the Software, or any derivative thereof, for the development of
|
||||||
|
competing autopilot, dynamic positioning, or vessel control products;
|
||||||
|
|
||||||
|
except under the terms of a separate written commercial license agreement
|
||||||
|
signed by the Author.
|
||||||
|
|
||||||
|
COMMERCIAL LICENSING
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Commercial deployment of the Software on board a vessel requires a per-vessel
|
||||||
|
license bound to the unique hardware identifier (HWID) of the installation,
|
||||||
|
issued by the Author. Contact the Author for licensing inquiries.
|
||||||
|
|
||||||
|
SAFETY-CRITICAL DISCLAIMER
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
THE SOFTWARE CONTROLS VESSEL STEERING SYSTEMS AND IS SAFETY-CRITICAL.
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES, INJURY, LOSS OF LIFE, LOSS OF
|
||||||
|
VESSEL, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||||
|
OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||||
|
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
Installation, commissioning, and operation of the Software must comply with
|
||||||
|
the relevant maritime regulations and standards applicable to the vessel and
|
||||||
|
its area of operation, including but not limited to ISO 11674, ISO 16329, and
|
||||||
|
IMO MSC.64(67).
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
For licensing inquiries: alro65@gmail.com
|
||||||
|
================================================================================
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# AR-ElecArrangement
|
||||||
|
|
||||||
|
Aplicación para el diseño completo de la instalación eléctrica de un buque
|
||||||
|
desde la silueta hasta el plano de arrangement firmable.
|
||||||
|
|
||||||
|
Familia AR ShipDesign. Cubre yates a motor, yates a vela, pesqueros, lanchas
|
||||||
|
de pasaje y embarcaciones de trabajo de hasta ~50 m.
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
Servidor Windows + clientes web. Un solo `.exe` empaca el backend FastAPI y
|
||||||
|
el frontend estático. El PC del usuario es el servidor; iPad, Android u otro
|
||||||
|
PC se conectan por navegador a `http://<server-ip>:5505`.
|
||||||
|
|
||||||
|
```
|
||||||
|
.exe (Windows)
|
||||||
|
├─ Backend Python (FastAPI, cálculo eléctrico, exports)
|
||||||
|
├─ Frontend web (HTML + Vue 3 + Konva.js)
|
||||||
|
└─ Auto-abre browser a http://localhost:5505
|
||||||
|
|
||||||
|
Tablets / otros PC → http://<server-ip>:5505 (LAN, sin Internet)
|
||||||
|
```
|
||||||
|
|
||||||
|
Sin telemetría. Offline absoluto. Catálogos editables por el usuario.
|
||||||
|
|
||||||
|
## Normativas soportadas
|
||||||
|
|
||||||
|
- ABYC E-11 (small craft USA)
|
||||||
|
- IEC 60092 (mercante / clase)
|
||||||
|
- NMEA 2000 y NMEA 0183
|
||||||
|
- IEEE 45 (buques grandes)
|
||||||
|
- ISO 10133 (DC small craft) e ISO 13297 (AC small craft)
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Capa | Tecnología |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Python 3.11 + FastAPI + uvicorn |
|
||||||
|
| Frontend | Vue 3 + Konva.js |
|
||||||
|
| Cálculo | numpy, scipy, pandas, networkx |
|
||||||
|
| Exports | ezdxf (DXF), reportlab (PDF), openpyxl (Excel BOM) |
|
||||||
|
| Persistencia | `.area` (ZIP con JSON + assets) |
|
||||||
|
| Empaquetado | PyInstaller |
|
||||||
|
|
||||||
|
## Desarrollo local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\activate
|
||||||
|
pip install -r backend/requirements.txt
|
||||||
|
python -m uvicorn backend.main:app --reload --port 5505
|
||||||
|
|
||||||
|
# Frontend (dev)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173 con proxy al backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Empaquetado .exe
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pyinstaller arelec.spec
|
||||||
|
# dist/AR-ElecArrangement/AR-ElecArrangement.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
Propietaria. Copyright © 2026 Alvaro Enrique Romero Donado. Ver `LICENSE.txt`.
|
||||||
|
Para licenciamiento contactar: alro65@gmail.com
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# AR-Autopilot
|
||||||
|
|
||||||
|
Professional marine autopilot for vessels in the 30-40 m range (motor yachts, motor sailboats, fishing vessels, small ferries, coastal patrol boats).
|
||||||
|
|
||||||
|
Part of the **AR Suite** alongside AR-ECDIS, VMS-Sailor, AR-ShipDesign, AR-ElecArrangement, and AR-StabCol. Sold standalone or bundled with AR-ECDIS.
|
||||||
|
|
||||||
|
> **NOT** Dynamic Positioning. **NOT** joystick docking. This is a classic heading-and-track autopilot with intelligent drift compensation, controlling rudder actuators (hydraulic or electric).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Sprint 0 — Foundations (in progress).**
|
||||||
|
|
||||||
|
This sprint delivers the repository structure, core data model, seed library, and a passing test suite. No functional firmware, Studio GUI, or display yet — those start in Sprint 1.
|
||||||
|
|
||||||
|
See [`docs/AR_Autopilot_brief.md`](docs/AR_Autopilot_brief.md) for the complete project brief, scope, and roadmap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Component | Tech | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| **Studio** (`arautopilot/studio/`) | Python 3.11 + PySide6 | Project configurator (integrator-side, not shipped to customers). Generates per-vessel `.appack` packages |
|
||||||
|
| **Firmware** (`firmware/ar_autopilot_v1/`) | C++ on ESP32 via PlatformIO | Real-time PID control, NMEA 2000 + Modbus, safety logic. Runs on the AR-NMEA-IO v1.0 board (shared with VMS-Sailor) |
|
||||||
|
| **Display** (`display/`) | Flutter Desktop (Win + Linux) | Dedicated bridge cockpit-feel touch display with rotary knob input |
|
||||||
|
| **Core models** (`arautopilot/core/`) | Pydantic v2 | Shared data model (vessel config, PID config, actuator config, alarms, modes, knob state) |
|
||||||
|
| **Library** (`arautopilot/library/`) | YAML + JSON | Curated seed: actuator profiles, default tunings per vessel type |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python **3.11** or newer
|
||||||
|
- Git
|
||||||
|
- (Later sprints) PlatformIO, Flutter SDK, WiX Toolset
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start (Sprint 0)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Create venv and install
|
||||||
|
python -m venv .venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
python -m pip install -U pip
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Run the Sprint 0 demo (creates, saves, reloads a project config)
|
||||||
|
python examples/sprint0_demo.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
AR-Autopilot/
|
||||||
|
├── arautopilot/ # Python package (core models, library, studio stubs, tests)
|
||||||
|
├── firmware/ # ESP32 firmware (Sprint 1+; only pinout.h in Sprint 0)
|
||||||
|
├── display/ # Flutter dedicated display (Sprint 4+)
|
||||||
|
├── examples/ # Runnable demos
|
||||||
|
├── docs/ # Brief + per-sprint design docs
|
||||||
|
├── installer/ # WiX MSI scripts (later)
|
||||||
|
└── tools/ # Helper scripts (later)
|
||||||
|
```
|
||||||
|
|
||||||
|
See [`docs/architecture.md`](docs/architecture.md) for a one-page architecture overview.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sprint roadmap
|
||||||
|
|
||||||
|
| Sprint | Focus |
|
||||||
|
|---|---|
|
||||||
|
| **0** | Foundations: repo structure, core data model, seed library, tests |
|
||||||
|
| 1 | Firmware base (I/O, Modbus, NMEA 2000 read, STANDBY mode) |
|
||||||
|
| 2 | PID inner loop (rudder position control) |
|
||||||
|
| 3 | PID outer loop + Heading Hold (with ROT feed-forward & gain scheduling) |
|
||||||
|
| 4 | Studio + basic dedicated display |
|
||||||
|
| 5 | True Course + Track Keeping (smooth XTE correction) |
|
||||||
|
| 6 | Safety, alarms, NMEA 2000 publish, VMS alarm consumption |
|
||||||
|
| 7 | Knob + commissioning + offline auto-tuning |
|
||||||
|
| 8 | EKF + adaptive tuning + telemetry + VPN |
|
||||||
|
| 9 | Hardening + integrated testing |
|
||||||
|
| 10+ | Phase 2 (wind modes for sailboats) and beyond |
|
||||||
|
|
||||||
|
Full detail in the brief.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Proprietary. All rights reserved. See [`LICENSE.txt`](LICENSE.txt).
|
||||||
|
|
||||||
|
Commercial deployment requires a per-vessel license bound to the installation HWID. Contact <alro65@gmail.com> for licensing.
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
PyInstaller spec para AR-ElecArrangement.
|
||||||
|
|
||||||
|
Empaca:
|
||||||
|
- Backend Python (backend/main.py como entrypoint)
|
||||||
|
- Frontend static (frontend/dist/ como recurso bundled)
|
||||||
|
- Data (data/ con catálogos JSON)
|
||||||
|
|
||||||
|
Resultado: dist/AR-ElecArrangement/AR-ElecArrangement.exe
|
||||||
|
Cuando se ejecuta, inicia el server en 0.0.0.0:5505 y abre el browser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PyInstaller.utils.hooks import collect_data_files
|
||||||
|
|
||||||
|
block_cipher = None
|
||||||
|
|
||||||
|
# Recursos que el .exe debe llevar dentro de sys._MEIPASS
|
||||||
|
datas = [
|
||||||
|
('frontend/dist', 'frontend/dist'),
|
||||||
|
('data', 'data'),
|
||||||
|
]
|
||||||
|
# Catálogos (futuro): garantiza que cualquier JSON de data/ se incluya
|
||||||
|
# automáticamente cuando agreguemos más.
|
||||||
|
|
||||||
|
# Dependencias que PyInstaller a veces no detecta solo (uvicorn loaders)
|
||||||
|
hiddenimports = [
|
||||||
|
'uvicorn.logging',
|
||||||
|
'uvicorn.loops',
|
||||||
|
'uvicorn.loops.auto',
|
||||||
|
'uvicorn.protocols',
|
||||||
|
'uvicorn.protocols.http',
|
||||||
|
'uvicorn.protocols.http.auto',
|
||||||
|
'uvicorn.protocols.websockets',
|
||||||
|
'uvicorn.protocols.websockets.auto',
|
||||||
|
'uvicorn.lifespan',
|
||||||
|
'uvicorn.lifespan.on',
|
||||||
|
]
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['backend/main.py'],
|
||||||
|
pathex=['backend'],
|
||||||
|
binaries=[],
|
||||||
|
datas=datas,
|
||||||
|
hiddenimports=hiddenimports,
|
||||||
|
hookspath=[],
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=['tkinter', 'matplotlib', 'PySide6', 'PyQt5', 'PyQt6'],
|
||||||
|
win_no_prefer_redirects=False,
|
||||||
|
win_private_assemblies=False,
|
||||||
|
cipher=block_cipher,
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
[],
|
||||||
|
exclude_binaries=True,
|
||||||
|
name='AR-ElecArrangement',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
console=True, # mantiene ventana CMD visible — útil para ver logs
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
icon=None, # TODO Sprint 14: agregar icon.ico
|
||||||
|
)
|
||||||
|
coll = COLLECT(
|
||||||
|
exe,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
name='AR-ElecArrangement',
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
AR-ElecArrangement — backend package.
|
||||||
|
|
||||||
|
Aplicación servidor para diseño eléctrico de buques. El cliente (browser)
|
||||||
|
consume la API REST + WebSocket expuesta por ``arelec.main``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
__author__ = "Alvaro Enrique Romero Donado"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Modelo de datos del proyecto: ship, decks, zones, appliances, panels, cables…"""
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""
|
||||||
|
Raíz del proyecto y serialización al formato ``.area``.
|
||||||
|
|
||||||
|
Un ``.area`` es un archivo ZIP que contiene:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
project.json — metadata del proyecto y modelo serializado
|
||||||
|
assets/ — recursos (íconos custom, capturas, fotografías)
|
||||||
|
|
||||||
|
El formato JSON usa el modelo declarado por el resto de ``arelec.core`` y
|
||||||
|
está versionado por el campo ``schema_version``. Los .area producidos por
|
||||||
|
una versión del software pueden ser leídos por versiones posteriores
|
||||||
|
(migraciones aditivas), nunca por anteriores.
|
||||||
|
|
||||||
|
En Sprint 0 el modelo es mínimo (solo metadata) — los sub-modelos (ship,
|
||||||
|
decks, appliances, panels, cables…) se agregan en sprints siguientes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProjectMetadata:
|
||||||
|
"""Datos administrativos del proyecto."""
|
||||||
|
|
||||||
|
name: str = "Nuevo proyecto"
|
||||||
|
author: str = ""
|
||||||
|
company: str = ""
|
||||||
|
notes: str = ""
|
||||||
|
schema_version: int = SCHEMA_VERSION
|
||||||
|
created_at: str = field(
|
||||||
|
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||||
|
)
|
||||||
|
modified_at: str = field(
|
||||||
|
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Project:
|
||||||
|
"""
|
||||||
|
Raíz del modelo de datos. Sprint 0 contiene solo metadata; los sub-modelos
|
||||||
|
(ship, decks, appliances, panels, cable_runs, batteries, etc.) se acoplan
|
||||||
|
a esta clase en sprints siguientes.
|
||||||
|
|
||||||
|
La instancia se serializa íntegra como ``project.json`` dentro del ZIP
|
||||||
|
``.area``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
metadata: ProjectMetadata = field(default_factory=ProjectMetadata)
|
||||||
|
# Sub-modelos futuros se irán agregando aquí:
|
||||||
|
# ship: Ship | None = None
|
||||||
|
# decks: list[Deck] = field(default_factory=list)
|
||||||
|
# appliances: list[Appliance] = field(default_factory=list)
|
||||||
|
# batteries: list[BatteryBank] = field(default_factory=list)
|
||||||
|
# panels: list[Panel] = field(default_factory=list)
|
||||||
|
# cable_runs: list[CableRun] = field(default_factory=list)
|
||||||
|
|
||||||
|
# ── Serialization ───────────────────────────────────────────────────────
|
||||||
|
def to_json(self) -> dict[str, Any]:
|
||||||
|
"""Convertir a dict serializable (JSON-safe)."""
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, data: dict[str, Any]) -> "Project":
|
||||||
|
"""Reconstruir desde dict cargado de ``project.json``."""
|
||||||
|
meta_dict = data.get("metadata", {})
|
||||||
|
return cls(metadata=ProjectMetadata(**meta_dict))
|
||||||
|
|
||||||
|
# ── .area I/O ───────────────────────────────────────────────────────────
|
||||||
|
def save(self, path: str | Path) -> None:
|
||||||
|
"""
|
||||||
|
Persistir el proyecto a ``path`` (extensión ``.area`` sugerida).
|
||||||
|
|
||||||
|
Toca ``metadata.modified_at`` con la hora UTC actual antes de
|
||||||
|
serializar.
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
self.metadata.modified_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
payload = json.dumps(self.to_json(), indent=2, ensure_ascii=False)
|
||||||
|
# ``ZIP_DEFLATED`` para que los .area no crezcan absurdo en proyectos
|
||||||
|
# grandes — el contenido es texto y comprime ~10×.
|
||||||
|
with zipfile.ZipFile(path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr("project.json", payload)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: str | Path) -> "Project":
|
||||||
|
"""
|
||||||
|
Cargar un proyecto desde ``path``. Lanza:
|
||||||
|
|
||||||
|
- ``FileNotFoundError`` si el archivo no existe
|
||||||
|
- ``zipfile.BadZipFile`` si el archivo no es ZIP válido
|
||||||
|
- ``ValueError`` si ``schema_version`` es mayor que ``SCHEMA_VERSION``
|
||||||
|
(el .area fue creado por una versión más nueva del software)
|
||||||
|
- ``KeyError`` si falta ``project.json`` en el ZIP
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
with zipfile.ZipFile(path, mode="r") as zf:
|
||||||
|
with zf.open("project.json") as f:
|
||||||
|
data = json.loads(f.read().decode("utf-8"))
|
||||||
|
|
||||||
|
version = data.get("metadata", {}).get("schema_version", 1)
|
||||||
|
if version > SCHEMA_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
f"El proyecto requiere schema_version={version}, "
|
||||||
|
f"este software soporta hasta {SCHEMA_VERSION}. "
|
||||||
|
f"Actualiza AR-ElecArrangement."
|
||||||
|
)
|
||||||
|
return cls.from_json(data)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""
|
||||||
|
Conversiones SI ↔ imperial usadas en la UI.
|
||||||
|
|
||||||
|
REGLA INTERNA DEL PROYECTO: todo el modelo de datos y todos los cálculos
|
||||||
|
trabajan en SI (m, kg, A, V, W). Las conversiones a unidades imperiales
|
||||||
|
(pies, AWG, BTU/h) ocurren ÚNICAMENTE en el borde UI cuando el usuario lo
|
||||||
|
pide. Persistir AWG o pies en .area está prohibido.
|
||||||
|
|
||||||
|
Tablas
|
||||||
|
------
|
||||||
|
- AWG ↔ mm²: ABYC E-11 Tabla VI / IEC 60228 (relación logarítmica + valores
|
||||||
|
tabulados estándar).
|
||||||
|
- ft ↔ m: 1 ft = 0.3048 m exacto (NIST).
|
||||||
|
- lb ↔ kg: 1 lb = 0.45359237 kg exacto (NIST).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# ── Lengths ─────────────────────────────────────────────────────────────────
|
||||||
|
M_PER_FT: float = 0.3048
|
||||||
|
FT_PER_M: float = 1.0 / M_PER_FT
|
||||||
|
|
||||||
|
|
||||||
|
def m_to_ft(m: float) -> float:
|
||||||
|
return m * FT_PER_M
|
||||||
|
|
||||||
|
|
||||||
|
def ft_to_m(ft: float) -> float:
|
||||||
|
return ft * M_PER_FT
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mass ────────────────────────────────────────────────────────────────────
|
||||||
|
KG_PER_LB: float = 0.45359237
|
||||||
|
LB_PER_KG: float = 1.0 / KG_PER_LB
|
||||||
|
|
||||||
|
|
||||||
|
def kg_to_lb(kg: float) -> float:
|
||||||
|
return kg * LB_PER_KG
|
||||||
|
|
||||||
|
|
||||||
|
def lb_to_kg(lb: float) -> float:
|
||||||
|
return lb * KG_PER_LB
|
||||||
|
|
||||||
|
|
||||||
|
# ── Wire calibre — AWG ↔ mm² ────────────────────────────────────────────────
|
||||||
|
# Tabla estándar de conductores (no se usa la fórmula logarítmica analítica
|
||||||
|
# porque los valores comerciales están redondeados).
|
||||||
|
# Cobertura: AWG 24 a 4/0 y mm² 0.2 a 120 — rango típico marino.
|
||||||
|
AWG_TO_MM2: dict[str, float] = {
|
||||||
|
"24": 0.205,
|
||||||
|
"22": 0.324,
|
||||||
|
"20": 0.519,
|
||||||
|
"18": 0.823,
|
||||||
|
"16": 1.31,
|
||||||
|
"14": 2.08,
|
||||||
|
"12": 3.31,
|
||||||
|
"10": 5.26,
|
||||||
|
"8": 8.37,
|
||||||
|
"6": 13.3,
|
||||||
|
"4": 21.2,
|
||||||
|
"2": 33.6,
|
||||||
|
"1": 42.4,
|
||||||
|
"1/0": 53.5,
|
||||||
|
"2/0": 67.4,
|
||||||
|
"3/0": 85.0,
|
||||||
|
"4/0": 107.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Lista de mm² comerciales IEC para la conversión inversa.
|
||||||
|
COMMERCIAL_MM2: tuple[float, ...] = (
|
||||||
|
0.5, 0.75, 1.0, 1.5, 2.5, 4.0, 6.0, 10.0, 16.0, 25.0, 35.0,
|
||||||
|
50.0, 70.0, 95.0, 120.0, 150.0, 185.0, 240.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def awg_to_mm2(awg: str) -> float:
|
||||||
|
"""Convertir designación AWG (p.ej. ``"10"``, ``"1/0"``) a mm²."""
|
||||||
|
if awg not in AWG_TO_MM2:
|
||||||
|
raise ValueError(f"AWG no soportado: {awg!r}. Válidos: {list(AWG_TO_MM2)}")
|
||||||
|
return AWG_TO_MM2[awg]
|
||||||
|
|
||||||
|
|
||||||
|
def mm2_to_awg(mm2: float) -> str:
|
||||||
|
"""Mapear área (mm²) al AWG cuya área es ≥ la pedida (criterio conservador)."""
|
||||||
|
if mm2 <= 0:
|
||||||
|
raise ValueError(f"mm² debe ser > 0, recibido: {mm2}")
|
||||||
|
# AWG_TO_MM2 está ordenado de menor a mayor área
|
||||||
|
for awg, area in AWG_TO_MM2.items():
|
||||||
|
if area >= mm2:
|
||||||
|
return awg
|
||||||
|
raise ValueError(f"mm² {mm2} excede el AWG máximo soportado (4/0 = 107 mm²)")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Power / energy ──────────────────────────────────────────────────────────
|
||||||
|
def hp_to_kw(hp: float) -> float:
|
||||||
|
"""Caballos métricos (CV / PS) a kW: 1 hp métrico = 0.7355 kW."""
|
||||||
|
return hp * 0.7355
|
||||||
|
|
||||||
|
|
||||||
|
def kw_to_hp(kw: float) -> float:
|
||||||
|
return kw / 0.7355
|
||||||
|
|
||||||
|
|
||||||
|
def btu_per_h_to_kw(btu_h: float) -> float:
|
||||||
|
"""BTU/h a kW (capacidad frigorífica de A/C)."""
|
||||||
|
return btu_h * 0.000293071
|
||||||
|
|
||||||
|
|
||||||
|
def kw_to_btu_per_h(kw: float) -> float:
|
||||||
|
return kw / 0.000293071
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
"""
|
||||||
|
AR-ElecArrangement — entrypoint del servidor.
|
||||||
|
|
||||||
|
Lanza FastAPI/uvicorn en ``0.0.0.0:5505`` (accesible desde la LAN para que
|
||||||
|
los tablets se conecten) y sirve el frontend compilado de ``frontend/dist/``.
|
||||||
|
|
||||||
|
Cuando se ejecuta como ``python -m backend.main`` o desde el ``.exe`` empacado
|
||||||
|
por PyInstaller, después de iniciar el server espera 1 s y abre el browser
|
||||||
|
predeterminado apuntando a ``http://localhost:5505``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import webbrowser
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, HTTPException, UploadFile, File
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from arelec import __version__
|
||||||
|
from arelec.core.project import Project
|
||||||
|
|
||||||
|
# ── Paths ────────────────────────────────────────────────────────────────────
|
||||||
|
# En desarrollo: __file__ = backend/main.py → BACKEND_DIR = backend/
|
||||||
|
# Empacado: __file__ está dentro de _MEIPASS → BACKEND_DIR = sys._MEIPASS/backend
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
BASE_DIR = Path(sys._MEIPASS) # type: ignore[attr-defined]
|
||||||
|
else:
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
FRONTEND_DIST = BASE_DIR / "frontend" / "dist"
|
||||||
|
DATA_DIR = BASE_DIR / "data"
|
||||||
|
|
||||||
|
HOST = "0.0.0.0"
|
||||||
|
PORT = 5505
|
||||||
|
|
||||||
|
|
||||||
|
# ── FastAPI app ──────────────────────────────────────────────────────────────
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Hook de arranque/apagado del server."""
|
||||||
|
print(f"[arelec] Servidor iniciado en http://localhost:{PORT} (v{__version__})")
|
||||||
|
print(f"[arelec] Tablets en LAN: http://<ip-de-este-pc>:{PORT}")
|
||||||
|
yield
|
||||||
|
print("[arelec] Servidor detenido.")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="AR-ElecArrangement",
|
||||||
|
version=__version__,
|
||||||
|
description="Diseño eléctrico de buques — backend",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Acceso LAN sin restricciones (la app está pensada para LAN privada del usuario,
|
||||||
|
# no para Internet pública). Si en el futuro se ofrece cloud, restringir aquí.
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||||||
|
|
||||||
|
|
||||||
|
# ── API ──────────────────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health() -> dict[str, str]:
|
||||||
|
"""Heartbeat para clientes que verifican que el server está vivo."""
|
||||||
|
return {"status": "ok", "version": __version__}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Project I/O (Sprint 0 — sólo metadata, expandirá en Sprint 1+) ──────────
|
||||||
|
@app.post("/api/project/new")
|
||||||
|
def project_new() -> dict[str, Any]:
|
||||||
|
"""Crear un proyecto vacío y devolver su JSON serializado."""
|
||||||
|
return Project().to_json()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/project/open")
|
||||||
|
async def project_open(file: UploadFile = File(...)) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Abrir un archivo ``.area`` subido por el cliente. Lo guarda en un tempfile,
|
||||||
|
lo carga vía ``Project.load``, devuelve el JSON serializado.
|
||||||
|
"""
|
||||||
|
if not (file.filename or "").lower().endswith(".area"):
|
||||||
|
raise HTTPException(400, "El archivo debe tener extensión .area")
|
||||||
|
data = await file.read()
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(400, "Archivo vacío")
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".area", delete=False) as tmp:
|
||||||
|
tmp.write(data)
|
||||||
|
tmp_path = Path(tmp.name)
|
||||||
|
try:
|
||||||
|
proj = Project.load(tmp_path)
|
||||||
|
return proj.to_json()
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
# No exponemos str(e) al cliente para evitar filtrar rutas internas
|
||||||
|
# o detalles de implementación. El tipo de error es suficiente.
|
||||||
|
raise HTTPException(400, "No es un .area válido o el archivo está dañado.") from e
|
||||||
|
finally:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
_WINDOWS_RESERVED_RE = re.compile(
|
||||||
|
r"^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(name: str) -> str:
|
||||||
|
"""Sanitiza el nombre del proyecto para uso seguro como nombre de archivo.
|
||||||
|
|
||||||
|
- Elimina caracteres no permitidos en Windows/POSIX (``/``, ``\\``, ``:``,
|
||||||
|
``*``, ``?``, ``"``, ``<``, ``>``, ``|``, bytes nulos).
|
||||||
|
- Colapsa espacios/guiones bajos múltiples consecutivos.
|
||||||
|
- Trunca a 200 caracteres para evitar rutas demasiado largas.
|
||||||
|
- Sustituye cadena vacía o nombres reservados de Windows por ``proyecto``.
|
||||||
|
"""
|
||||||
|
safe = re.sub(r'[\\/:*?"<>|\x00]', "_", name)
|
||||||
|
safe = re.sub(r"[_\s]{2,}", "_", safe).strip("_").strip()
|
||||||
|
safe = safe[:200]
|
||||||
|
if not safe or _WINDOWS_RESERVED_RE.match(safe):
|
||||||
|
safe = "proyecto"
|
||||||
|
return safe
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/project/save")
|
||||||
|
async def project_save(payload: dict[str, Any]) -> FileResponse:
|
||||||
|
"""
|
||||||
|
Recibe un proyecto en JSON, lo serializa a ``.area`` y lo devuelve para
|
||||||
|
descarga directa por el browser.
|
||||||
|
"""
|
||||||
|
proj = Project.from_json(payload)
|
||||||
|
out_dir = Path(tempfile.mkdtemp(prefix="arelec_save_"))
|
||||||
|
out_path = out_dir / (_safe_filename(proj.metadata.name) + ".area")
|
||||||
|
proj.save(out_path)
|
||||||
|
return FileResponse(
|
||||||
|
path=out_path,
|
||||||
|
media_type="application/zip",
|
||||||
|
filename=out_path.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Frontend (montaje al final para que /api/* tenga precedencia) ────────────
|
||||||
|
if FRONTEND_DIST.exists():
|
||||||
|
app.mount("/", StaticFiles(directory=str(FRONTEND_DIST), html=True), name="frontend")
|
||||||
|
else:
|
||||||
|
@app.get("/")
|
||||||
|
def _no_frontend_msg() -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"warning": "frontend/dist no encontrado",
|
||||||
|
"hint": "Compilar frontend: cd frontend && npm install && npm run build",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auto-launch del browser cuando corre como .exe ───────────────────────────
|
||||||
|
def _open_browser_when_ready() -> None:
|
||||||
|
"""Espera 1 s y abre el browser default. Llamado solo desde .exe / __main__."""
|
||||||
|
time.sleep(1.0)
|
||||||
|
url = f"http://localhost:{PORT}"
|
||||||
|
try:
|
||||||
|
webbrowser.open(url)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f"[arelec] No se pudo abrir el browser automáticamente: {e}")
|
||||||
|
print(f"[arelec] Abre manualmente: {url}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Entrypoint cuando se corre como ``python -m backend.main`` o ``.exe``."""
|
||||||
|
# Solo abrir browser cuando no estamos en modo --reload (dev)
|
||||||
|
if os.environ.get("ARELEC_NO_BROWSER") != "1":
|
||||||
|
threading.Thread(target=_open_browser_when_ready, daemon=True).start()
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"backend.main:app" if not getattr(sys, "frozen", False) else app,
|
||||||
|
host=HOST,
|
||||||
|
port=PORT,
|
||||||
|
log_level="info",
|
||||||
|
reload=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
|
||||||
|
# ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
pytest>=8.3
|
||||||
|
pytest-asyncio>=0.25
|
||||||
|
httpx>=0.28
|
||||||
|
|
||||||
|
# ── Linting / formato ────────────────────────────────────────────────────────
|
||||||
|
ruff>=0.8
|
||||||
|
mypy>=1.13
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# ── Server ───────────────────────────────────────────────────────────────────
|
||||||
|
# Versiones mínimas conocidas-buenas; sin pin estricto para que pip resuelva
|
||||||
|
# la última disponible compatible con la versión de Python instalada.
|
||||||
|
# Probado contra Python 3.11–3.14.
|
||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.34
|
||||||
|
pydantic>=2.11 # Python 3.14 wheels disponibles desde 2.11
|
||||||
|
websockets>=13.1
|
||||||
|
python-multipart>=0.0.20 # uploads en /api/project/open
|
||||||
|
|
||||||
|
# ── Cálculo (Sprint 4+) ──────────────────────────────────────────────────────
|
||||||
|
# numpy y pandas se agregan cuando arranquen los motores de cálculo.
|
||||||
|
# scipy se agrega cuando salga rueda para Python 3.14 (ETA: ya en pre-release).
|
||||||
|
# networkx se agrega en Sprint 9 (routing automático).
|
||||||
|
|
||||||
|
# ── Exports (Sprint 10) ──────────────────────────────────────────────────────
|
||||||
|
# ezdxf, reportlab, openpyxl entran cuando empecemos exports DXF/PDF/Excel.
|
||||||
|
|
||||||
|
# ── Empaquetado .exe (Sprint 14) ─────────────────────────────────────────────
|
||||||
|
# pyinstaller se agrega al cierre cuando empacamos el .exe final.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
Sprint 0 — sanity: un Project recién creado se guarda y se carga sin pérdida.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arelec.core.project import Project, ProjectMetadata, SCHEMA_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_default_metadata() -> None:
|
||||||
|
p = Project()
|
||||||
|
assert p.metadata.name == "Nuevo proyecto"
|
||||||
|
assert p.metadata.schema_version == SCHEMA_VERSION
|
||||||
|
assert p.metadata.created_at != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_load_roundtrip(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "demo.area"
|
||||||
|
original = Project(metadata=ProjectMetadata(
|
||||||
|
name="Yate Demo",
|
||||||
|
author="ARD",
|
||||||
|
company="AR ShipDesign",
|
||||||
|
notes="Sprint 0 test",
|
||||||
|
))
|
||||||
|
original.save(path)
|
||||||
|
assert path.exists()
|
||||||
|
assert path.stat().st_size > 0
|
||||||
|
|
||||||
|
loaded = Project.load(path)
|
||||||
|
assert loaded.metadata.name == "Yate Demo"
|
||||||
|
assert loaded.metadata.author == "ARD"
|
||||||
|
assert loaded.metadata.company == "AR ShipDesign"
|
||||||
|
assert loaded.metadata.notes == "Sprint 0 test"
|
||||||
|
assert loaded.metadata.schema_version == SCHEMA_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_touches_modified_at(tmp_path: Path) -> None:
|
||||||
|
p = Project()
|
||||||
|
original_modified = p.metadata.modified_at
|
||||||
|
p.save(tmp_path / "t.area")
|
||||||
|
# Save siempre actualiza modified_at (aunque sea el mismo segundo, queda con
|
||||||
|
# nueva timestamp ISO — comparamos que fue tocado).
|
||||||
|
assert p.metadata.modified_at >= original_modified
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_rejects_future_schema(tmp_path: Path) -> None:
|
||||||
|
"""Un .area creado por una versión más nueva debe rechazarse limpio."""
|
||||||
|
path = tmp_path / "future.area"
|
||||||
|
p = Project(metadata=ProjectMetadata(schema_version=SCHEMA_VERSION + 99))
|
||||||
|
p.save(path)
|
||||||
|
with pytest.raises(ValueError, match="schema_version"):
|
||||||
|
Project.load(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_rejects_missing_project_json(tmp_path: Path) -> None:
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
path = tmp_path / "empty.area"
|
||||||
|
with zipfile.ZipFile(path, mode="w") as zf:
|
||||||
|
zf.writestr("readme.txt", "no project.json")
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
Project.load(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_rejects_non_zip(tmp_path: Path) -> None:
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
path = tmp_path / "garbage.area"
|
||||||
|
path.write_bytes(b"this is not a zip file")
|
||||||
|
with pytest.raises(zipfile.BadZipFile):
|
||||||
|
Project.load(path)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
Sprint 0 — sanity: conversiones SI ↔ imperial round-trip y casos límite.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arelec.core.units import (
|
||||||
|
AWG_TO_MM2,
|
||||||
|
awg_to_mm2,
|
||||||
|
ft_to_m,
|
||||||
|
kg_to_lb,
|
||||||
|
kw_to_hp,
|
||||||
|
lb_to_kg,
|
||||||
|
m_to_ft,
|
||||||
|
mm2_to_awg,
|
||||||
|
hp_to_kw,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_length_roundtrip() -> None:
|
||||||
|
assert m_to_ft(1.0) == pytest.approx(3.28084, rel=1e-4)
|
||||||
|
assert ft_to_m(m_to_ft(42.0)) == pytest.approx(42.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mass_roundtrip() -> None:
|
||||||
|
assert kg_to_lb(1.0) == pytest.approx(2.20462, rel=1e-4)
|
||||||
|
assert lb_to_kg(kg_to_lb(100.0)) == pytest.approx(100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_power_roundtrip() -> None:
|
||||||
|
assert hp_to_kw(1.0) == pytest.approx(0.7355)
|
||||||
|
assert kw_to_hp(hp_to_kw(150.0)) == pytest.approx(150.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_awg_lookup_known_values() -> None:
|
||||||
|
# ABYC E-11 tabla VI — valores publicados estándar
|
||||||
|
assert awg_to_mm2("10") == 5.26
|
||||||
|
assert awg_to_mm2("4/0") == 107.0
|
||||||
|
assert awg_to_mm2("14") == 2.08
|
||||||
|
|
||||||
|
|
||||||
|
def test_mm2_to_awg_picks_next_larger() -> None:
|
||||||
|
# 5.0 mm² no es exactamente AWG 10 (5.26), pero AWG 10 cubre el cable.
|
||||||
|
# Criterio conservador: elegir AWG cuya área es ≥ pedida.
|
||||||
|
assert mm2_to_awg(5.0) == "10"
|
||||||
|
assert mm2_to_awg(5.26) == "10"
|
||||||
|
assert mm2_to_awg(2.0) == "14" # 2.08
|
||||||
|
assert mm2_to_awg(100.0) == "4/0" # 107
|
||||||
|
|
||||||
|
|
||||||
|
def test_mm2_to_awg_rejects_invalid() -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
mm2_to_awg(0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
mm2_to_awg(-1.0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
mm2_to_awg(500.0) # más grande que 4/0
|
||||||
|
|
||||||
|
|
||||||
|
def test_awg_table_monotonic() -> None:
|
||||||
|
"""La tabla AWG debe ir de menor a mayor área (asumido por mm2_to_awg)."""
|
||||||
|
areas = list(AWG_TO_MM2.values())
|
||||||
|
assert areas == sorted(areas)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
+41
@@ -0,0 +1,41 @@
|
|||||||
|
@echo off
|
||||||
|
title AR-ElecArrangement — Instalación de dependencias
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo =====================================================
|
||||||
|
echo AR-ElecArrangement — Setup del entorno Python
|
||||||
|
echo =====================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: 1. Crear venv si no existe
|
||||||
|
if not exist "%~dp0venv\Scripts\python.exe" (
|
||||||
|
echo [1/3] Creando entorno virtual...
|
||||||
|
python -m venv venv
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ERROR: no se pudo crear venv. Verifica que Python 3.11+ esté instalado.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo [1/3] Entorno virtual ya existe, salto.
|
||||||
|
)
|
||||||
|
|
||||||
|
:: 2. Actualizar pip
|
||||||
|
echo [2/3] Actualizando pip...
|
||||||
|
"%~dp0venv\Scripts\python.exe" -m pip install --upgrade pip >nul
|
||||||
|
|
||||||
|
:: 3. Instalar dependencias
|
||||||
|
echo [3/3] Instalando dependencias (esto tarda 1-2 minutos la primera vez)...
|
||||||
|
"%~dp0venv\Scripts\python.exe" -m pip install -r backend\requirements-dev.txt
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ERROR durante pip install.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo =====================================================
|
||||||
|
echo Instalación lista. Arranca con: start.bat
|
||||||
|
echo =====================================================
|
||||||
|
pause
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "ar-elecarrangement"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Diseño eléctrico de buques — servidor + clientes web"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = { text = "Proprietary" }
|
||||||
|
authors = [
|
||||||
|
{ name = "Alvaro Enrique Romero Donado", email = "alro65@gmail.com" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "N", "W", "B", "UP", "PL", "RUF"]
|
||||||
|
ignore = ["PLR0913"] # too many arguments — common in marine engineering APIs
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["backend/tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
strict = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "arautopilot"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "AR-Autopilot — Professional marine autopilot for 30-40 m vessels (Studio + firmware + display)"
|
||||||
|
readme = "README.md"
|
||||||
|
license = { file = "LICENSE.txt" }
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
authors = [{ name = "Alvaro Romero", email = "alro65@gmail.com" }]
|
||||||
|
keywords = ["marine", "autopilot", "pid", "nmea2000", "esp32", "vessel-control"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 2 - Pre-Alpha",
|
||||||
|
"Intended Audience :: Manufacturing",
|
||||||
|
"License :: Other/Proprietary License",
|
||||||
|
"Operating System :: Microsoft :: Windows",
|
||||||
|
"Operating System :: POSIX :: Linux",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Topic :: Scientific/Engineering",
|
||||||
|
"Topic :: System :: Hardware",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Runtime dependencies — kept intentionally minimal for Sprint 0.
|
||||||
|
# GUI (PySide6), Modbus (pymodbus), serial, etc. are added in later sprints.
|
||||||
|
dependencies = [
|
||||||
|
"pydantic>=2.6,<3.0",
|
||||||
|
"pyyaml>=6.0",
|
||||||
|
"python-dateutil>=2.8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-cov>=4.1",
|
||||||
|
"ruff>=0.4",
|
||||||
|
"mypy>=1.10",
|
||||||
|
"types-PyYAML",
|
||||||
|
"types-python-dateutil",
|
||||||
|
]
|
||||||
|
# Studio GUI -- Sprint 2.5+. Heavy (~80 MB), kept optional so the core can
|
||||||
|
# be installed in lean environments (CI, headless test bench).
|
||||||
|
# PySide6 >= 6.6 includes QtSerialPort on all platforms — no extra dep needed.
|
||||||
|
studio = [
|
||||||
|
"PySide6>=6.6",
|
||||||
|
"pyserial>=3.5",
|
||||||
|
"platformio>=6.1",
|
||||||
|
]
|
||||||
|
# Installer tooling — required on the developer's build machine.
|
||||||
|
installer = [
|
||||||
|
"requests>=2.31",
|
||||||
|
]
|
||||||
|
# License server — deploy to arelectronics.com VPS.
|
||||||
|
license-server = [
|
||||||
|
"fastapi>=0.111",
|
||||||
|
"uvicorn[standard]>=0.29",
|
||||||
|
"sqlalchemy>=2.0",
|
||||||
|
"pydantic>=2.7",
|
||||||
|
"python-dotenv>=1.0",
|
||||||
|
]
|
||||||
|
# AR Display Manager — multi-monitor app switcher for the Integrated Bridge System.
|
||||||
|
# Same PySide6 dep as the Studio; listed separately so it can be installed standalone.
|
||||||
|
display-manager = [
|
||||||
|
"PySide6>=6.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/alro65/AR-Autopilot"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["arautopilot*"]
|
||||||
|
exclude = ["arautopilot.tests*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
"arautopilot.library" = [
|
||||||
|
"actuators/*.json",
|
||||||
|
"default_tunings/*.yaml",
|
||||||
|
"vessel_profiles/*.yaml",
|
||||||
|
"_schemas/*.json",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# pytest
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
minversion = "8.0"
|
||||||
|
testpaths = ["arautopilot/tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_classes = ["Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
addopts = [
|
||||||
|
"-ra",
|
||||||
|
"--strict-markers",
|
||||||
|
"--strict-config",
|
||||||
|
"--showlocals",
|
||||||
|
]
|
||||||
|
markers = [
|
||||||
|
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||||
|
"integration: marks integration tests",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Coverage
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["arautopilot"]
|
||||||
|
omit = [
|
||||||
|
"arautopilot/tests/*",
|
||||||
|
"arautopilot/studio/*", # GUI not in scope for Sprint 0
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Ruff (linting + formatting)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
extend-exclude = [
|
||||||
|
"firmware",
|
||||||
|
"display",
|
||||||
|
"installer",
|
||||||
|
".venv",
|
||||||
|
"build",
|
||||||
|
"dist",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"W", # pycodestyle warnings
|
||||||
|
"F", # pyflakes
|
||||||
|
"I", # isort
|
||||||
|
"B", # flake8-bugbear
|
||||||
|
"C4", # flake8-comprehensions
|
||||||
|
"UP", # pyupgrade
|
||||||
|
"N", # pep8-naming
|
||||||
|
"SIM", # flake8-simplify
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"E501", # line too long (handled by formatter)
|
||||||
|
"B008", # function calls in default args (common with pydantic Field)
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"arautopilot/tests/*" = ["N802", "N803"] # test names
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# mypy
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
strict = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
plugins = ["pydantic.mypy"]
|
||||||
|
exclude = [
|
||||||
|
"build/",
|
||||||
|
"dist/",
|
||||||
|
"firmware/",
|
||||||
|
"display/",
|
||||||
|
"installer/",
|
||||||
|
"arautopilot/studio/", # GUI stubs, not in scope for Sprint 0
|
||||||
|
]
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "arautopilot.tests.*"
|
||||||
|
disallow_untyped_defs = false
|
||||||
|
|
||||||
|
[tool.pydantic-mypy]
|
||||||
|
init_forbid_extra = true
|
||||||
|
init_typed = true
|
||||||
|
warn_required_dynamic_aliases = true
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
@echo off
|
||||||
|
title AR-ElecArrangement Server
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
:: Matar proceso previo en el puerto 5505 (si quedó colgado de una corrida anterior)
|
||||||
|
for /f "tokens=5" %%a in ('netstat -ano 2^>nul ^| findstr ":5505 " ^| findstr "LISTENING"') do (
|
||||||
|
taskkill /F /PID %%a >nul 2>&1
|
||||||
|
)
|
||||||
|
timeout /t 1 /nobreak >nul
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo =====================================================
|
||||||
|
echo AR-ElecArrangement
|
||||||
|
echo Servidor: http://localhost:5505
|
||||||
|
echo LAN: http://%COMPUTERNAME%:5505
|
||||||
|
echo =====================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: Usar venv si existe, si no buscar python en el PATH
|
||||||
|
if exist "%~dp0venv\Scripts\python.exe" (
|
||||||
|
set "PY=%~dp0venv\Scripts\python.exe"
|
||||||
|
) else (
|
||||||
|
set "PY=python"
|
||||||
|
)
|
||||||
|
|
||||||
|
:: PYTHONPATH para que ``backend.main`` resuelva ``arelec`` del subpaquete
|
||||||
|
set "PYTHONPATH=%~dp0backend"
|
||||||
|
|
||||||
|
:loop
|
||||||
|
"%PY%" -m backend.main
|
||||||
|
echo.
|
||||||
|
echo Servidor detenido. Reiniciando en 3 segundos... (Ctrl+C para salir)
|
||||||
|
timeout /t 3 /nobreak >nul
|
||||||
|
goto loop
|
||||||
Reference in New Issue
Block a user