Merge branch 'main' into sinexcel_multiinveters_configurtaion

This commit is contained in:
Yinyin Liu 2026-04-28 16:07:08 +02:00
commit 2889d4c281
68 changed files with 11093 additions and 3745 deletions

2
.gitignore vendored
View File

@ -5,3 +5,5 @@
**/.idea/
**/.env
.claude/
**/__pycache__/
*.pyc

View File

@ -0,0 +1,14 @@
[Unit]
Description=ModbusTcp Service
After=network.target
[Service]
ExecStart=/home/ie-entwicklung/salimax/ModbusTCP/dist/modbus_tcp_server
Restart=always
User=ie-entwicklung
Group=nogroup
StandardOutput=append:/var/log/ModbusTCPService.log
StandardError=inherit
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,14 @@
[Unit]
Description=ModbusTcp Service
After=network.target
[Service]
ExecStart=/home/inesco/SodiStoreHome/ModbusTCP/dist/modbus_tcp_server
Restart=always
User=inesco
Group=nogroup
StandardOutput=append:/var/log/ModbusTCPService.log
StandardError=inherit
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,64 @@
#!/bin/bash
set -e
# --- Sodistore Home ---
REMOTE_USER="inesco"
REMOTE_HOST="10.2.5.33"
HOME_PATH="/home/inesco/SodiStoreHome"
REMOTE_BASE="/home/inesco/SodiStoreHome/ModbusTCP"
# --- Sodistore Max ---
# REMOTE_USER="ie-entwicklung"
# REMOTE_HOST="10.2.5.25"
# HOME_PATH="/home/ie-entwicklung/salimax"
# REMOTE_BASE="/home/ie-entwicklung/salimax/ModbusTCP"
REMOTE_DIST="$REMOTE_BASE/dist"
SERVICE_NAME="ModbusTCP.service"
PYTHON_FILE="modbus_tcp_server.py"
SERVICE_FILE="ModbusTCP.service"
BINARY_NAME="modbus_tcp_server"
# SUDO_PASSWORD="Salimax4x25"
# echo "==> Preparing Python virtual environment..."
# cd "$HOME_PATH"
# python3 -m venv venv
# source venv/bin/activate
# pip install watchdog
# pip install pymodbus==2.5.3
# pip install pyinstaller
# deactivate
# echo "==> Copying source file to remote..."
scp "$PYTHON_FILE" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_BASE/"
# scp "$SERVICE_FILE" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_BASE/"
echo "==> Building Python binary on remote..."
ssh "$REMOTE_USER@$REMOTE_HOST" "
cd $HOME_PATH && \
source venv/bin/activate && \
cd ModbusTCP && \
pyinstaller --onefile $PYTHON_FILE
"
echo "==> Cleaning up temporary build artifacts on remote..."
ssh "$REMOTE_USER@$REMOTE_HOST" "
cd $REMOTE_BASE && \
rm -f $PYTHON_FILE && \
rm -rf build __pycache__ *.spec
# "
echo "==> Setting up systemd service on remote..."
ssh "$REMOTE_USER@$REMOTE_HOST" "
echo '$SUDO_PASSWORD' | sudo -S cp '$REMOTE_BASE/ModbusTCP.service' /etc/systemd/system/"
echo "==> Restarting systemd service on remote..."
ssh "$REMOTE_USER@$REMOTE_HOST" "
echo '$SUDO_PASSWORD' | sudo -S setcap 'cap_net_bind_service=+ep' '$REMOTE_BASE/dist/modbus_tcp_server' && \
echo '$SUDO_PASSWORD' | sudo -S systemctl daemon-reload && \
echo '$SUDO_PASSWORD' | sudo -S systemctl enable $SERVICE_NAME && \
echo '$SUDO_PASSWORD' | sudo -S systemctl stop $SERVICE_NAME && \
echo '$SUDO_PASSWORD' | sudo -S systemctl start $SERVICE_NAME && \
echo '$SUDO_PASSWORD' | sudo -S systemctl status $SERVICE_NAME --no-pager -l
"
echo "Deployment to $REMOTE_HOST completed successfully!"

BIN
ModbusTCP/dist/modbus_tcp_server vendored Executable file

Binary file not shown.

0
ModbusTCP/kaco/Setting Normal file
View File

View File

@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""
Modbus TCP client for KACO/Inesco SodistoreGrid battery storage system.
Reads system, battery, grid meter, and inverter registers.
Writes to inverter control registers to test server handling.
Register Map based on: inesco_Modbus_Register_Map_SodistoreGird.pdf
- Base address = 1
- Combines 32-bit low/high registers (low word first)
- Applies scaling factors (0.1, 0.01, etc.)
"""
from pymodbus.client.sync import ModbusTcpClient
import datetime
INVALID_16BIT = 0xFFFF
INVALID_32BIT = 0xFFFFFFFF
# --------------------------
# KACO/Inesco Register Map
# --------------------------
REGISTER_MAP = {
# ---- Read Only ----
# System Data (30001-30003)
30001: {"name": "Protocol Version", "type": "INT16", "scale": 0.1, "unit": "version"},
30002: {"name": "System Timestamp (Low)", "type": "UINT32", "scale": 1, "unit": "unix_ts"}, # 30002+30003
# Battery Data (31002-31010)
31002: {"name": "Avg Battery Voltage", "type": "INT16", "scale": 0.1, "unit": "V"},
31003: {"name": "Sum Battery Current", "type": "INT32", "scale": 0.1, "unit": "A"}, # 31003+31004 (Pos: Charge, Neg: Discharge)
31005: {"name": "Avg SOC", "type": "UINT16", "scale": 0.01, "unit": "%"},
31006: {"name": "Sum Battery Power", "type": "INT32", "scale": 0.1, "unit": "W"}, # 31006+31007 (Pos: Charge, Neg: Discharge)
31010: {"name": "Avg SOH", "type": "UINT16", "scale": 0.01, "unit": "%"},
# Grid Meter Data (33000-33011)
33000: {"name": "Grid Active Power", "type": "INT32", "scale": 0.1, "unit": "W"}, # 33000+33001
33002: {"name": "Grid Frequency", "type": "UINT16", "scale": 0.1, "unit": "Hz"},
33003: {"name": "Grid Voltage U1", "type": "UINT16", "scale": 0.1, "unit": "V"}, # Single register (PDF typo: listed as UINT32)
33004: {"name": "Grid Voltage U2", "type": "UINT16", "scale": 0.1, "unit": "V"}, # Single register (PDF typo: listed as UINT32)
33005: {"name": "Grid Voltage U3", "type": "UINT16", "scale": 0.1, "unit": "V"}, # Single register (PDF typo: listed as UINT32)
33006: {"name": "Grid Current I1", "type": "INT32", "scale": 0.1, "unit": "A"}, # 33006+33007
33008: {"name": "Grid Current I2", "type": "INT32", "scale": 0.1, "unit": "A"}, # 33008+33009
33010: {"name": "Grid Current I3", "type": "INT32", "scale": 0.1, "unit": "A"}, # 33010+33011
# Inverter Data - Read Only (34001-34005)
34001: {"name": "Inverter Power", "type": "UINT16", "scale": 1, "unit": "W"},
34002: {"name": "Max Charge Current (RO)", "type": "UINT16", "scale": 1, "unit": "A"},
34003: {"name": "Max Discharge Current (RO)", "type": "UINT16", "scale": 1, "unit": "A"},
34004: {"name": "Max Charge Voltage (RO)", "type": "UINT16", "scale": 1, "unit": "V"},
34005: {"name": "Min Discharge Voltage (RO)", "type": "UINT16", "scale": 1, "unit": "V"},
# ---- Write Registers (40001-40005) ----
40001: {"name": "Inverter Power Percentage", "type": "UINT16", "scale": 1, "unit": "%"},
40002: {"name": "Max Charge Current", "type": "UINT16", "scale": 1, "unit": "A"},
40003: {"name": "Max Discharge Current", "type": "UINT16", "scale": 1, "unit": "A"},
40004: {"name": "Max Charge Voltage", "type": "UINT16", "scale": 1, "unit": "V"},
40005: {"name": "Min Discharge Voltage", "type": "UINT16", "scale": 1, "unit": "V"},
}
# --------------------------
# Decode helpers
# --------------------------
def decode_signed_16bit(val):
if val is None:
return None
if val == 0x8000:
return None
if not 0 <= val <= 0xFFFF:
return None
return val - 0x10000 if val >= 0x8000 else val
def decode_unsigned_16bit(val):
if val is None:
return None
if val == INVALID_16BIT:
return None
if not 0 <= val <= 0xFFFF:
return None
return val
def decode_signed_32bit(low, high):
if low is None or high is None:
return None
combined = (high << 16) | low
if combined == INVALID_32BIT:
return None
if combined >= 0x80000000:
combined -= 0x100000000
return combined
def decode_unsigned_32bit(low, high):
if low is None or high is None:
return None
combined = (high << 16) | low
if combined == INVALID_32BIT:
return None
return combined
def scale(val, factor):
return round(val * factor, 2) if val is not None else "Missing/Invalid"
def timestamp_str_from_words(low, high):
ts = decode_unsigned_32bit(low, high)
if ts is None:
return "Missing/Invalid"
try:
dt = datetime.datetime.fromtimestamp(ts)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return f"Invalid timestamp ({ts})"
# --------------------------
# Modbus read helper (1-based -> pymodbus 0-based)
# --------------------------
def read_modbus_data(client, start_address, count):
try:
result = client.read_holding_registers(start_address - 1, count, unit=1)
if result.isError():
print(f"Error reading data from address {start_address}")
return None
return result.registers
except Exception as e:
print(f"Error reading from Modbus server: {e}")
return None
# --------------------------
# Generic block reader/printer
# --------------------------
def read_and_print_block(client, title, start_addr, end_addr):
"""
Reads [start_addr..end_addr] inclusive and prints values using REGISTER_MAP.
Handles INT32/UINT32 by consuming (low+high).
"""
count = end_addr - start_addr + 1
regs = read_modbus_data(client, start_addr, count)
if not regs:
print(f"\n--- {title} ---")
print("No data available.")
return
print(f"\n--- {title} ({start_addr}..{end_addr}) ---")
i = 0
while i < count:
addr = start_addr + i
info = REGISTER_MAP.get(addr)
# If not mapped, just skip printing (still consumes 1 word)
if info is None:
i += 1
continue
raw = regs[i]
dtype = info["type"]
factor = info.get("scale", 1)
unit = info.get("unit", "")
if dtype in ("INT32", "UINT32"):
if i + 1 >= count:
print(f"{info['name']} ({addr}+{addr+1}): Missing/Invalid (no high word)")
i += 1
continue
low = regs[i]
high = regs[i + 1]
if dtype == "INT32":
decoded = decode_signed_32bit(low, high)
else:
decoded = decode_unsigned_32bit(low, high)
# Special pretty-print for timestamp at 30002+30003
if addr == 30002 and dtype == "UINT32":
print(f"System Timestamp (30002+30003): {timestamp_str_from_words(low, high)}")
else:
scaled_val = scale(decoded, factor)
print(f"{info['name']} ({addr}+{addr+1}): {scaled_val} {unit}")
i += 2
continue
if dtype == "INT16":
decoded = decode_signed_16bit(raw)
elif dtype == "UINT16":
decoded = decode_unsigned_16bit(raw)
else:
decoded = raw
scaled_val = scale(decoded, factor)
print(f"{info['name']} ({addr}): {scaled_val} {unit}")
i += 1
# --------------------------
# Write helpers (1-based -> pymodbus 0-based)
# --------------------------
def to_u16(val):
return val & 0xFFFF
def write_u16(client, addr_1based, value):
try:
res = client.write_register(addr_1based - 1, to_u16(value), unit=1)
ok = not res.isError()
print(f"WRITE UINT16 {addr_1based} = {value} -> {'OK' if ok else 'FAIL'}")
return ok
except Exception as e:
print(f"WRITE UINT16 {addr_1based} exception: {e}")
return False
def write_test_kaco(client):
"""
Writes to KACO inverter control registers (40001-40005) to test server behavior.
Values are chosen as safe examples - adjust to your actual system limits.
"""
print("\n=== WRITE TEST (KACO INVERTER CONTROL REGISTERS) ===")
# 40001: Inverter Power Percentage (0-100%)
write_u16(client, 40001, 80) # Set to 80% of inverter capacity
# 40002: Max Charge Current (Amps)
write_u16(client, 40002, 50) # 50A max charge
# 40003: Max Discharge Current (Amps)
write_u16(client, 40003, 50) # 50A max discharge
# 40004: Max Charge Voltage (Volts)
write_u16(client, 40004, 580) # 580V max charge voltage
# 40005: Min Discharge Voltage (Volts)
write_u16(client, 40005, 420) # 420V min discharge voltage
def main():
client = ModbusTcpClient("localhost", port=502)
if not client.connect():
print("Failed to connect to Modbus server.")
print("Make sure modbus_tcp_server_kaco.py is running on port 502.")
return
try:
print("=" * 70)
print("KACO/Inesco SodistoreGrid - Modbus TCP Client Test")
print("=" * 70)
# ---- Read-only blocks ----
read_and_print_block(client, "System Data", 30001, 30003)
read_and_print_block(client, "Battery Data", 31002, 31010)
read_and_print_block(client, "Grid Meter Data", 33000, 33011)
read_and_print_block(client, "Inverter Data (Read-Only)", 34001, 34005)
# ---- Write tests ----
write_test_kaco(client)
# ---- Re-read write registers to verify ----
read_and_print_block(client, "Inverter Control (After Write)", 40001, 40005)
print("\n" + "=" * 70)
print("Test completed successfully!")
print("=" * 70)
finally:
client.close()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,32 @@
{
"30001": 100,
"30002": 36343,
"30003": 26755,
"31002": 5200,
"31003": 125,
"31004": 0,
"31005": 8542,
"31006": 6500,
"31007": 0,
"31010": 9850,
"33000": 32000,
"33001": 0,
"33002": 500,
"33003": 2300,
"33004": 2310,
"33005": 2290,
"33006": 140,
"33007": 0,
"33008": 138,
"33009": 0,
"33010": 142,
"33011": 0,
"34001": 3000,
"34002": 100,
"34003": 100,
"34004": 580,
"34005": 420
}

View File

@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""
Modbus TCP Server for KACO/Inesco SodistoreGrid System
Adapted for the Inesco Modbus Register Map (Base address = 1)
Register Map Overview:
- System Data (Read): 30001-30003 (Protocol Version, System Timestamp)
- Battery Data (Read): 31002-31010 (Voltage, Current, SOC, Power, SOH)
- Grid Meter (Read): 33000-33009 (Active Power, Frequency, Voltages, Currents)
- Inverter Data (Read): 34001-34005 (Power, Max Charge/Discharge Current, Voltages)
- Inverter Control (Write): 40001-40005 (Power Percentage, Max Charge/Discharge Current, Voltages)
"""
import os
import sys
import json
import logging
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from pymodbus.server.sync import ModbusTcpServer
from pymodbus.datastore import ModbusSlaveContext, ModbusServerContext
from pymodbus.datastore import ModbusSequentialDataBlock
from threading import Lock
from pathlib import Path
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(message)s")
INVALID_16BIT_VALUE = 0xFFFF
class ModbusDataStore(ModbusSequentialDataBlock):
def __init__(self, json_file, config_json):
self.json_file = json_file
self.config_json = config_json
self.start_address = 30001
self.end_address = 40005 # Updated for KACO register map (highest write register)
self.values = [INVALID_16BIT_VALUE] * (self.end_address - self.start_address + 1)
self.lock = Lock()
self.load_and_set_values()
super().__init__(self.start_address, self.values)
def load_data_from_json(self):
logging.debug(f"Loading data from JSON file: {self.json_file}")
try:
with open(self.json_file, 'r') as file:
data = json.load(file)
logging.debug("Data loaded successfully.")
except Exception as e:
logging.error(f"Error loading JSON file: {e}")
raise e
return data
def create_values(self, data_dict):
size = self.end_address - self.start_address + 1
values = [INVALID_16BIT_VALUE] * size # full range
for key, val in data_dict.items():
addr = int(key)
if self.start_address <= addr <= self.end_address:
index = addr - self.start_address
values[index] = val
else:
logging.warning(f"Address {addr} outside range {self.start_address}-{self.end_address}, skipping.")
logging.info(f"Loaded {len(values)} registers from JSON (full range).")
return values
def load_and_set_values(self):
with self.lock:
self.data_dict = self.load_data_from_json()
self.values = self.create_values(self.data_dict)
def getValues(self, address, count=1):
with self.lock:
values = super().getValues(address, count)
logging.debug(f"Reading values from address {address}, count {count}: {values}")
return values
def reload_json(self):
logging.info(f"Reloading JSON data from file: {self.json_file}")
try:
self.load_and_set_values()
self.update_data_store()
except Exception as e:
logging.error(f"Failed to reload data: {e}")
def update_data_store(self):
with self.lock:
for index, value in enumerate(self.values):
super().setValues(self.start_address + index, [value])
def setValues(self, address, values):
"""
Handle Modbus writes and update only relevant entries in config.json
KACO/Inesco Write Registers (Base address = 1):
40001: Inverter Power Percentage (UINT16, 1[%]) - Percentage of inverter capacity [0,100]
40002: Max Charge Current (UINT16, 1[A]) - Consider all batteries' capacity
40003: Max Discharge Current (UINT16, 1[A]) - Consider all batteries' capacity
40004: Max Charge Voltage (UINT16, 1[V]) - Consider all batteries' capacity
40005: Min Discharge Voltage (UINT16, 1[V]) - Consider all batteries' capacity
"""
with self.lock:
super().setValues(address, values)
logging.debug(f"Received Modbus write: address {address}, values {values}")
# KACO/Inesco Register Map - Write Registers
WRITE_REGISTERS = {
40001: {"key": "InverterPowerPercentage", "type": "UINT16", "scale": 1, "unit": "%"},
40002: {"key": "MaxChargeCurrent", "type": "UINT16", "scale": 1, "unit": "A"},
40003: {"key": "MaxDischargeCurrent", "type": "UINT16", "scale": 1, "unit": "A"},
40004: {"key": "MaxChargeVoltage", "type": "UINT16", "scale": 1, "unit": "V"},
40005: {"key": "MinDischargeVoltage", "type": "UINT16", "scale": 1, "unit": "V"},
}
def combine_words(low, high, signed=False):
val = (high << 16) | low
if signed and val & 0x80000000:
val -= 0x100000000
return val
updates = {}
i = 0
while i < len(values):
reg_address = address + i
reg_info = WRITE_REGISTERS.get(reg_address)
if reg_info:
key = reg_info["key"]
dtype = reg_info["type"]
scale = reg_info.get("scale", 1)
if dtype in ("UINT16", "INT16"):
raw = values[i]
val = raw * scale
if dtype == "INT16" and raw & 0x8000:
val = raw - 0x10000
updates[key] = val
logging.info(f"Register {reg_address} ({key}): {val} {reg_info.get('unit', '')}")
i += 1
elif dtype == "INT32" and i + 1 < len(values):
low = values[i]
high = values[i + 1]
val = combine_words(low, high, signed=True) * scale
updates[key] = val
logging.info(f"Register {reg_address} ({key}): {val} {reg_info.get('unit', '')}")
i += 2
else:
logging.warning(f"Incomplete write for {reg_address}")
i += 1
else:
i += 1
if updates:
try:
if os.path.exists(self.config_json):
with open(self.config_json, "r") as f:
config_data = json.load(f)
else:
config_data = {}
# Update only the relevant keys, keep rest unchanged
for k, v in updates.items():
config_data[k] = v
with open(self.config_json, "w") as f:
json.dump(config_data, f, indent=4)
logging.info(f"Updated {self.config_json} keys: {list(updates.keys())}")
except Exception as e:
logging.error(f"Failed to update {self.config_json}: {e}")
class JSONFileChangeHandler(FileSystemEventHandler):
def __init__(self, store, json_file):
self.store = store
self.json_file = os.path.abspath(json_file)
def on_modified(self, event):
if os.path.abspath(event.src_path) == self.json_file:
logging.info(f"File {self.json_file} changed, reloading...")
self.store.reload_json()
def create_server(data_json, config_json):
data_json = os.path.abspath(data_json)
config_json = os.path.abspath(config_json)
logging.info(f"Starting KACO/Inesco Modbus TCP Server")
logging.info(f"Data JSON (read-only telemetry): {data_json}")
logging.info(f"Config JSON (writable parameters): {config_json}")
store = ModbusDataStore(data_json, config_json)
slave = ModbusSlaveContext(
hr=store,
ir=store,
)
context = ModbusServerContext(slaves=slave, single=True)
# file change observer for telemetry JSON
event_handler = JSONFileChangeHandler(store, data_json)
observer = Observer()
observer.schedule(event_handler, path=os.path.dirname(data_json), recursive=False)
observer.start()
server = ModbusTcpServer(context, address=("0.0.0.0", 502))
logging.info("Modbus TCP Server running on 0.0.0.0:502")
try:
server.serve_forever()
except Exception as e:
logging.error(f"Error running the Modbus server: {e}")
finally:
observer.stop()
observer.join()
if __name__ == "__main__":
# KACO/Inesco System Paths
# Update these paths according to your deployment
# Option 1: Absolute paths (uncomment to use)
# json_path = "/home/ie-entwicklung/kaco/ModbusTCP/modbus_tcp_data.json"
# config_path = "/home/ie-entwicklung/kaco/config.json"
# Option 2: Relative to script/binary location (recommended for portability)
# Works for both Python script and PyInstaller binary
# Script location: /path/to/product/ModbusTCP/modbus_tcp_server.py
# Binary location: /path/to/product/ModbusTCP/dist/modbus_tcp_server
# JSON location: /path/to/product/ModbusTCP/modbus_tcp_data.json
# Config location: /path/to/product/config.json
# Detect if running as PyInstaller binary or Python script
if getattr(sys, 'frozen', False):
# Running as PyInstaller binary: use sys.executable path
# Binary is in: /path/to/product/ModbusTCP/dist/modbus_tcp_server
# Need to go up one level to get to ModbusTCP folder
current_file_dir = Path(sys.executable).resolve().parent.parent
else:
# Running as Python script: use __file__ path
current_file_dir = Path(__file__).resolve().parent
json_path = current_file_dir / "modbus_tcp_data.json"
config_path = current_file_dir.parent / "config.json"
# Convert Path objects to strings
json_path = str(json_path)
config_path = str(config_path)
if not os.path.isfile(json_path):
logging.error(f"Modbus data JSON file not found: {json_path}. Server cannot start.")
sys.exit(1)
if not os.path.isfile(config_path):
logging.error(f"Config JSON file not found: {config_path}. Server cannot start.")
sys.exit(1)
create_server(json_path, config_path)

View File

@ -0,0 +1,302 @@
#!/usr/bin/env python3
"""
Modbus TCP client for reading system, battery, PV, grid, inverter and configuration registers,
and writing all writable registers to test server handling.
- Combines 32-bit low/high registers (low word first)
- Applies scaling (multiply by scale factor)
- Prints human-readable timestamp from 30002+30003
"""
from pymodbus.client.sync import ModbusTcpClient
import datetime
INVALID_16BIT = 0xFFFF
INVALID_32BIT = 0xFFFFFFFF
# --------------------------
# Register Map (from PDF)
# --------------------------
REGISTER_MAP = {
# ---- Read Only ----
# System Data
30001: {"name": "Protocol Version", "type": "INT16", "scale": 0.1},
30002: {"name": "System Timestamp", "type": "UINT32", "scale": 1}, # 30002+30003
30004: {"name": "Operating Priority", "type": "UINT16", "scale": 1}, # 0/1/2
# Battery Data
31000: {"name": "Battery Count", "type": "UINT16", "scale": 1},
31001: {"name": "Battery Operating Status", "type": "UINT16", "scale": 1},
31002: {"name": "Avg Battery Voltage", "type": "INT16", "scale": 0.1},
31003: {"name": "Sum Battery Current", "type": "INT32", "scale": 0.1}, # 31003+31004
31005: {"name": "Avg SOC", "type": "UINT16", "scale": 0.01},
31006: {"name": "Sum Battery Power", "type": "INT32", "scale": 0.1}, # 31006+31007
31008: {"name": "Min SOC", "type": "UINT16", "scale": 0.01},
31009: {"name": "Max SOC", "type": "UINT16", "scale": 0.01},
31010: {"name": "Avg SOH", "type": "UINT16", "scale": 0.01},
31011: {"name": "Avg Battery Ambient Temperature", "type": "INT16", "scale": 0.01},
31012: {"name": "Max Charge Current", "type": "UINT16", "scale": 0.1},
31013: {"name": "Max Discharge Current", "type": "UINT16", "scale": 0.1},
31014: {"name": "Max Charge Voltage", "type": "UINT16", "scale": 0.1},
# PV Data
32000: {"name": "Sum PV Power", "type": "UINT32", "scale": 0.1}, # 32000+32001
# Grid Data
33000: {"name": "Grid Power", "type": "INT32", "scale": 0.1}, # 33000+33001
33002: {"name": "Grid Frequency", "type": "UINT16", "scale": 0.1},
# Inverter Data (PDF seems to have a duplicated 34000; this is the consistent assumption)
34000: {"name": "System Operating Mode", "type": "UINT16", "scale": 1},
34001: {"name": "Inverter Power", "type": "INT32", "scale": 0.1}, # 34001+34002
34003: {"name": "Inverter Device Type", "type": "UINT16", "scale": 1},
# Configuration Data (read)
35000: {"name": "Grid Setpoint (config/read)", "type": "INT32", "scale": 0.1}, # 35000+35001
35002: {"name": "Enable Grid Export (config/read)", "type": "UINT16", "scale": 1},
35003: {"name": "Grid Export Percentage (config/read)", "type": "INT16", "scale": 1},
# ---- Write (RW) ----
40001: {"name": "Write Operating Priority", "type": "UINT16", "scale": 1},
40002: {"name": "Write Inverter Power %", "type": "UINT16", "scale": 1},
41000: {"name": "Write Min SOC %", "type": "UINT16", "scale": 1},
41001: {"name": "Write Max SOC %", "type": "UINT16", "scale": 1},
41002: {"name": "Write Max Charge Current A", "type": "UINT16", "scale": 1},
41003: {"name": "Write Max Discharge Current A", "type": "UINT16", "scale": 1},
41004: {"name": "Write Max Charge Voltage V", "type": "UINT16", "scale": 1},
# PV write exists in map as "PV 43000" but no details (not available now)
43000: {"name": "PV Write Placeholder", "type": "UINT16", "scale": 1},
# Grid Write
44000: {"name": "Write Grid Power Setpoint W", "type": "INT32", "scale": 1}, # 44000+44001
44002: {"name": "Write Enable Grid Export", "type": "UINT16", "scale": 1},
}
# --------------------------
# Decode helpers
# --------------------------
def decode_signed_16bit(val):
if val is None:
return None
if val == 0x8000:
return None
if not 0 <= val <= 0xFFFF:
return None
return val - 0x10000 if val >= 0x8000 else val
def decode_unsigned_16bit(val):
if val is None:
return None
if val == INVALID_16BIT:
return None
if not 0 <= val <= 0xFFFF:
return None
return val
def decode_signed_32bit(low, high):
if low is None or high is None:
return None
combined = (high << 16) | low
if combined == INVALID_32BIT:
return None
if combined >= 0x80000000:
combined -= 0x100000000
return combined
def decode_unsigned_32bit(low, high):
if low is None or high is None:
return None
combined = (high << 16) | low
if combined == INVALID_32BIT:
return None
return combined
def scale(val, factor):
return round(val * factor, 2) if val is not None else "Missing/Invalid"
def timestamp_str_from_words(low, high):
ts = decode_unsigned_32bit(low, high)
if ts is None:
return "Missing/Invalid"
try:
dt = datetime.datetime.fromtimestamp(ts)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return f"Invalid timestamp ({ts})"
# --------------------------
# Modbus read helper (1-based -> pymodbus 0-based)
# --------------------------
def read_modbus_data(client, start_address, count):
try:
result = client.read_holding_registers(start_address - 1, count, unit=1)
if result.isError():
print(f"Error reading data from address {start_address}")
return None
return result.registers
except Exception as e:
print(f"Error reading from Modbus server: {e}")
return None
# --------------------------
# Generic block reader/printer
# --------------------------
def read_and_print_block(client, title, start_addr, end_addr):
"""
Reads [start_addr..end_addr] inclusive and prints values using REGISTER_MAP.
Handles INT32/UINT32 by consuming (low+high).
"""
count = end_addr - start_addr + 1
regs = read_modbus_data(client, start_addr, count)
if not regs:
print(f"\n--- {title} ---")
print("No data available.")
return
print(f"\n--- {title} ({start_addr}..{end_addr}) ---")
i = 0
while i < count:
addr = start_addr + i
info = REGISTER_MAP.get(addr)
# If not mapped, just skip printing (still consumes 1 word)
if info is None:
i += 1
continue
raw = regs[i]
dtype = info["type"]
factor = info.get("scale", 1)
if dtype in ("INT32", "UINT32"):
if i + 1 >= count:
print(f"{info['name']} ({addr}+{addr+1}): Missing/Invalid (no high word)")
i += 1
continue
low = regs[i]
high = regs[i + 1]
if dtype == "INT32":
decoded = decode_signed_32bit(low, high)
else:
decoded = decode_unsigned_32bit(low, high)
# Special pretty-print for timestamp at 30002+30003
if addr == 30002 and dtype == "UINT32":
print(f"System Timestamp (30002+30003): {timestamp_str_from_words(low, high)}")
else:
print(f"{info['name']} ({addr}+{addr+1}): {scale(decoded, factor)}")
i += 2
continue
if dtype == "INT16":
decoded = decode_signed_16bit(raw)
elif dtype == "UINT16":
decoded = decode_unsigned_16bit(raw)
else:
decoded = raw
print(f"{info['name']} ({addr}): {scale(decoded, factor)}")
i += 1
# --------------------------
# Write helpers (1-based -> pymodbus 0-based)
# --------------------------
def to_u16(val):
return val & 0xFFFF
def split_int32(value_signed):
"""
Returns (low_word, high_word) for signed int32, low word first.
"""
v = int(value_signed)
if v < 0:
v = (1 << 32) + v
low = v & 0xFFFF
high = (v >> 16) & 0xFFFF
return low, high
def write_u16(client, addr_1based, value):
try:
res = client.write_register(addr_1based - 1, to_u16(value), unit=1)
ok = not res.isError()
print(f"WRITE UINT16 {addr_1based} = {value} -> {'OK' if ok else 'FAIL'}")
return ok
except Exception as e:
print(f"WRITE UINT16 {addr_1based} exception: {e}")
return False
def write_int32(client, addr_low_1based, value_signed):
low, high = split_int32(value_signed)
try:
res = client.write_registers(addr_low_1based - 1, [low, high], unit=1)
ok = not res.isError()
print(f"WRITE INT32 {addr_low_1based}+{addr_low_1based+1} = {value_signed} -> {'OK' if ok else 'FAIL'} (low={low}, high={high})")
return ok
except Exception as e:
print(f"WRITE INT32 {addr_low_1based} exception: {e}")
return False
def write_test_all(client):
"""
Writes all RW registers from the PDF to test server behavior.
(Values chosen to be safe-ish examples; adjust to your device limits.)
"""
print("\n=== WRITE TEST (ALL RW REGISTERS) ===")
# Inverter (write)
write_u16(client, 40001, 1) # Operating Priority example
write_u16(client, 40002, 30) # Inverter Power % example (0..100)
# Battery (write)
write_u16(client, 41000, 20) # Min SOC %
write_u16(client, 41001, 90) # Max SOC %
write_u16(client, 41002, 50) # Max Charge Current A
write_u16(client, 41003, 50) # Max Discharge Current A
write_u16(client, 41004, 520) # Max Charge Voltage V
# PV (write) - unclear/greyed out in PDF, still try if server supports it
write_u16(client, 43000, 0)
# Grid (write)
write_int32(client, 44000, 10) # Grid Power Setpoint W (positive/negative allowed)
write_u16(client, 44002, 1) # Enable Grid Export (0/1)
write_int32(client, 44003, 25) # Grid Export Percentage % (-100..100)
def main():
client = ModbusTcpClient("localhost", port=502)
if not client.connect():
print("Failed to connect to Modbus server.")
return
try:
# ---- Read-only blocks ----
read_and_print_block(client, "System Data", 30001, 30004)
read_and_print_block(client, "Battery Data", 31000, 31014)
read_and_print_block(client, "PV Data", 32000, 32001)
read_and_print_block(client, "Grid Data", 33000, 33002)
# Inverter Data block (see note in header)
read_and_print_block(client, "Inverter Data", 34000, 34003)
# Configuration Data (read)
# Note: Grid Export Percentage is INT32 starting at 35003 -> needs 35004 as high word.
# If your server doesn't provide 35004, you'll see Missing/Invalid.
read_and_print_block(client, "Configuration Data", 35000, 35004)
# ---- Write tests ----
write_test_all(client)
# Re-read key RW registers after write
read_and_print_block(client, "Post-write Inverter RW", 40001, 40002)
read_and_print_block(client, "Post-write Battery RW", 41000, 41004)
read_and_print_block(client, "Post-write Grid RW", 44000, 44004)
finally:
client.close()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,27 @@
{
"30001": 2020,
"30002": 36343,
"30003": 26755,
"30004": 6,
"31000": 2,
"31001": 64516,
"31002": 65535,
"31003": 160,
"31004": 0,
"31005": 3220,
"31006": 0,
"31007": 170,
"31008": 0,
"31009": 2810,
"31010": 0,
"31011": 42764,
"31012": 1,
"31013": 42764,
"31014": 1,
"31015": 570,
"31016": 65516,
"31017": 65535,
"31018": 9600,
"31019": 5000,
"31021": 0
}

243
ModbusTCP/modbus_tcp_server.py Executable file
View File

@ -0,0 +1,243 @@
#!/usr/bin/env python3
import os
import sys
import json
import logging
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from pymodbus.server.sync import ModbusTcpServer
from pymodbus.datastore import ModbusSlaveContext, ModbusServerContext
from pymodbus.datastore import ModbusSequentialDataBlock
from threading import Lock
from pathlib import Path
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(message)s")
INVALID_16BIT_VALUE = 0xFFFF
class ModbusDataStore(ModbusSequentialDataBlock):
def __init__(self, json_file,config_json):
self.json_file = json_file
self.config_json = config_json
self.start_address = 30001
self.end_address = 44004 # Need to update here when there are more registers!!!!
self.values = [INVALID_16BIT_VALUE] * (self.end_address - self.start_address + 1)
self.lock = Lock()
self.load_and_set_values()
super().__init__(self.start_address, self.values)
def load_data_from_json(self):
logging.debug(f"Loading data from JSON file: {self.json_file}")
try:
with open(self.json_file, 'r') as file:
data = json.load(file)
logging.debug("Data loaded successfully.")
except Exception as e:
logging.error(f"Error loading JSON file: {e}")
raise e
return data
def create_values(self, data_dict):
size = self.end_address - self.start_address + 1
values = [INVALID_16BIT_VALUE] * size # full range
for key, val in data_dict.items():
addr = int(key)
if self.start_address <= addr <= self.end_address:
index = addr - self.start_address
values[index] = val
else:
logging.warning(f"Address {addr} outside range {self.start_address}-{self.end_address}, skipping.")
logging.info(f"Loaded {len(values)} registers from JSON (full range).")
return values
def load_and_set_values(self):
with self.lock:
self.data_dict = self.load_data_from_json()
self.values = self.create_values(self.data_dict)
def getValues(self, address, count=1):
with self.lock:
values = super().getValues(address, count)
logging.debug(f"Reading values from address {address}, count {count}: {values}")
return values
def reload_json(self):
logging.info(f"Reloading JSON data from file: {self.json_file}")
try:
self.load_and_set_values()
self.update_data_store()
except Exception as e:
logging.error(f"Failed to reload data: {e}")
def update_data_store(self):
with self.lock:
for index, value in enumerate(self.values):
super().setValues(self.start_address + index, [value])
def setValues(self, address, values):
"""Handle Modbus writes and update only relevant entries in config.json"""
with self.lock:
super().setValues(address, values)
logging.debug(f"Received Modbus write: address {address}, values {values}")
WRITE_REGISTERS = {
# Correct 1-based addresses matching SodistoreHome PDF
40001: {"key": "OperatingPriority", "type": "UINT16", "scale": 1},
40002: {"key": "InverterPower", "type": "UINT16", "scale": 1},
41000: {"key": "MinSoc", "type": "UINT16", "scale": 1},
41001: {"key": "MaxSoc", "type": "UINT16", "scale": 1},
41002: {"key": "MaximumChargingCurrent", "type": "UINT16", "scale": 1},
41003: {"key": "MaximumDischargingCurrent", "type": "UINT16", "scale": 1},
41004: {"key": "MaxChargeVoltage", "type": "UINT16", "scale": 1},
44000: {"key": "GridSetPoint", "type": "INT32", "scale": 1}, # 44000+44001
44002: {"key": "EnableGridExport", "type": "UINT16", "scale": 1},
44003: {"key": "GridExportPercentage", "type": "INT16", "scale": 1},
}
def combine_words(low, high, signed=False):
val = (high << 16) | low
if signed and val & 0x80000000:
val -= 0x100000000
return val
updates = {}
i = 0
while i < len(values):
reg_address = address + i
reg_info = WRITE_REGISTERS.get(reg_address)
if reg_info:
key = reg_info["key"]
dtype = reg_info["type"]
scale = reg_info.get("scale", 1)
if dtype in ("UINT16", "INT16"):
raw = values[i]
val = raw * scale
if dtype == "INT16" and raw & 0x8000:
val = raw - 0x10000
updates[key] = val
i += 1
elif dtype == "INT32" and i + 1 < len(values):
low = values[i]
high = values[i + 1]
val = combine_words(low, high, signed=True) * scale
updates[key] = val
i += 2
else:
logging.warning(f"Incomplete write for {reg_address}")
i += 1
else:
i += 1
if updates:
try:
if os.path.exists(self.config_json):
with open(self.config_json, "r") as f:
config_data = json.load(f)
else:
config_data = {}
# Update only the relevant keys, keep rest unchanged
for k, v in updates.items():
config_data[k] = v
with open(self.config_json, "w") as f:
json.dump(config_data, f, indent=4)
logging.info(f"Updated {self.config_json} keys: {list(updates.keys())}")
except Exception as e:
logging.error(f"Failed to update {self.config_json}: {e}")
class JSONFileChangeHandler(FileSystemEventHandler):
def __init__(self, store, json_file):
self.store = store
self.json_file = os.path.abspath(json_file)
def on_modified(self, event):
if os.path.abspath(event.src_path) == self.json_file:
logging.info(f"File {self.json_file} changed, reloading...")
self.store.reload_json()
def create_server(data_json,config_json):
data_json = os.path.abspath(data_json)
config_json = os.path.abspath(config_json)
logging.info(f"Starting Modbus server with data JSON: {data_json}")
logging.info(f"Modbus writable config file: {config_json}")
store = ModbusDataStore(data_json, config_json)
slave = ModbusSlaveContext(
hr=store,
ir=store,
)
context = ModbusServerContext(slaves=slave, single=True)
# file change observer for telemetry JSON remains the same
event_handler = JSONFileChangeHandler(store, data_json)
observer = Observer()
observer.schedule(event_handler, path=os.path.dirname(data_json), recursive=False)
observer.start()
server = ModbusTcpServer(context, address=("0.0.0.0", 502))
logging.info("Modbus TCP Server running on port:502")
try:
server.serve_forever()
except Exception as e:
logging.error(f"Error running the Modbus server: {e}")
finally:
observer.stop()
observer.join()
if __name__ == "__main__":
# Option 1: Absolute paths (uncomment to use specific deployment)
# SodistoreHome
# json_path = "/home/inesco/SodiStoreHome/ModbusTCP/modbus_tcp_data.json"
# config_path = "/home/inesco/SodiStoreHome/config.json"
# SodistoreMax
# json_path = "/home/ie-entwicklung/salimax/ModbusTCP/modbus_tcp_data.json"
# config_path = "/home/ie-entwicklung/salimax/config.json"
# Option 2: Relative to script/binary location (recommended for portability)
# Works for both Python script and PyInstaller binary
# Script location: /path/to/product/ModbusTCP/modbus_tcp_server.py
# Binary location: /path/to/product/ModbusTCP/dist/modbus_tcp_server
# JSON location: /path/to/product/ModbusTCP/modbus_tcp_data.json
# Config location: /path/to/product/config.json
# Detect if running as PyInstaller binary or Python script
if getattr(sys, 'frozen', False):
# Running as PyInstaller binary: use sys.executable path
# Binary is in: /path/to/product/ModbusTCP/dist/modbus_tcp_server
# Need to go up one level to get to ModbusTCP folder
current_file_dir = Path(sys.executable).resolve().parent.parent
else:
# Running as Python script: use __file__ path
current_file_dir = Path(__file__).resolve().parent
json_path = current_file_dir / "modbus_tcp_data.json"
config_path = current_file_dir.parent / "config.json"
# Convert Path objects to strings
json_path = str(json_path)
config_path = str(config_path)
if not os.path.isfile(json_path):
logging.error(f"Modbus data JSON file not found: {json_path}. Server cannot start.")
sys.exit(1)
if not os.path.isfile(config_path):
logging.error(f"Config JSON file not found: {config_path}. Server cannot start.")
sys.exit(1)
create_server(json_path,config_path)

View File

@ -0,0 +1,38 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['modbus_tcp_server.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='modbus_tcp_server',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

View File

@ -806,7 +806,7 @@ public class Controller : ControllerBase
/// <summary>
/// Returns an AI-generated diagnosis for a single error/alarm description.
/// Responses are cached in memory — repeated calls for the same error code
/// do not hit Mistral again.
/// do not hit the AI provider again.
/// </summary>
[HttpGet(nameof(DiagnoseError))]
public async Task<ActionResult<DiagnosticResponse>> DiagnoseError(Int64 installationId, string errorDescription, Token authToken)
@ -839,8 +839,11 @@ public class Controller : ControllerBase
/// Remove this endpoint in production if not needed.
/// </summary>
[HttpGet(nameof(TestAlarmKnowledgeBase))]
public ActionResult TestAlarmKnowledgeBase()
public ActionResult TestAlarmKnowledgeBase(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var testCases = new[]
{
// Sinexcel alarms (keys match SinexcelRecord.Api.cs property names)
@ -855,7 +858,7 @@ public class Controller : ControllerBase
"BmsFault",
"OverTemperature",
"AFCI Fault",
// Unknown alarm (should return null - would call Mistral)
// Unknown alarm (should return null - would call AI)
"Some unknown alarm XYZ123"
};
@ -867,7 +870,7 @@ public class Controller : ControllerBase
{
Alarm = alarm,
FoundInKnowledgeBase = diagnosis != null,
Explanation = diagnosis?.Explanation ?? "NOT FOUND - Would call Mistral API",
Explanation = diagnosis?.Explanation ?? "NOT FOUND - Would call AI API",
CausesCount = diagnosis?.Causes.Count ?? 0,
NextStepsCount = diagnosis?.NextSteps.Count ?? 0
});
@ -878,45 +881,47 @@ public class Controller : ControllerBase
TestTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
TotalTests = testCases.Length,
FoundInKnowledgeBase = results.Count(r => ((dynamic)r).FoundInKnowledgeBase),
WouldCallMistral = results.Count(r => !((dynamic)r).FoundInKnowledgeBase),
WouldCallAi = results.Count(r => !((dynamic)r).FoundInKnowledgeBase),
Results = results
});
}
/// <summary>
/// Test endpoint for the full AI diagnostic flow (knowledge base + Mistral API).
/// No auth required. Remove before production.
/// Usage: GET /api/TestDiagnoseError?errorDescription=SomeAlarm
/// Test endpoint for the full AI diagnostic flow (knowledge base + AI API).
/// Admin-only. Usage: GET /api/TestDiagnoseError?errorDescription=SomeAlarm
/// </summary>
[HttpGet(nameof(TestDiagnoseError))]
public async Task<ActionResult> TestDiagnoseError(string errorDescription = "AbnormalGridVoltage", string language = "en")
public async Task<ActionResult> TestDiagnoseError(Token authToken, string errorDescription = "AbnormalGridVoltage", string language = "en")
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
// 1. Try static lookup (KB for English, pre-generated translations for others)
var staticResult = DiagnosticService.TryGetTranslation(errorDescription, language);
if (staticResult is not null)
{
return Ok(new
{
Source = "KnowledgeBase",
Alarm = errorDescription,
MistralEnabled = DiagnosticService.IsEnabled,
Source = "KnowledgeBase",
Alarm = errorDescription,
AiEnabled = DiagnosticService.IsEnabled,
staticResult.Explanation,
staticResult.Causes,
staticResult.NextSteps
});
}
// 2. If not found, try Mistral with the correct language
// 2. If not found, try AI with the correct language
if (!DiagnosticService.IsEnabled)
return Ok(new { Source = "None", Alarm = errorDescription, Message = "Not in knowledge base and Mistral API key not configured." });
return Ok(new { Source = "None", Alarm = errorDescription, Message = "Not in knowledge base and AI API key not configured." });
var aiResult = await DiagnosticService.TestCallMistralAsync(errorDescription, language);
if (aiResult is null)
return Ok(new { Source = "MistralFailed", Alarm = errorDescription, Message = "Mistral API call failed or returned empty." });
return Ok(new { Source = "AiFailed", Alarm = errorDescription, Message = "AI API call failed or returned empty." });
return Ok(new
{
Source = "MistralAI",
Source = "Ai",
Alarm = errorDescription,
aiResult.Explanation,
aiResult.Causes,
@ -2020,50 +2025,71 @@ public class Controller : ControllerBase
// ── Alarm Review Campaign ────────────────────────────────────────────────
[HttpPost(nameof(SendTestAlarmReview))]
public async Task<ActionResult> SendTestAlarmReview()
public async Task<ActionResult> SendTestAlarmReview(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
await AlarmReviewService.SendTestBatchAsync();
return Ok(new { message = "Test review email sent to liu@inesco.energy. Check your inbox." });
}
[HttpPost(nameof(StartAlarmReviewCampaign))]
public ActionResult StartAlarmReviewCampaign()
public ActionResult StartAlarmReviewCampaign(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
AlarmReviewService.StartCampaign();
return Ok(new { message = "Alarm review campaign started." });
}
[HttpPost(nameof(StopAlarmReviewCampaign))]
public ActionResult StopAlarmReviewCampaign()
public ActionResult StopAlarmReviewCampaign(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
AlarmReviewService.StopCampaign();
return Ok(new { message = "Campaign paused — progress preserved. Use ResumeAlarmReviewCampaign to restart timers." });
}
[HttpPost(nameof(ResumeAlarmReviewCampaign))]
public ActionResult ResumeAlarmReviewCampaign()
public ActionResult ResumeAlarmReviewCampaign(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
AlarmReviewService.ResumeCampaign();
return Ok(new { message = "Campaign resumed — timers restarted from existing progress." });
}
[HttpPost(nameof(ResetAlarmReviewCampaign))]
public ActionResult ResetAlarmReviewCampaign()
public ActionResult ResetAlarmReviewCampaign(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
AlarmReviewService.ResetCampaign();
return Ok(new { message = "Campaign fully reset — all progress deleted. Use StartAlarmReviewCampaign to begin again." });
}
[HttpGet(nameof(CorrectAlarm))]
public ActionResult CorrectAlarm(int batch, string key)
public ActionResult CorrectAlarm(Token authToken, int batch, string key)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var html = AlarmReviewService.GetCorrectionPage(batch, key);
return Content(html, "text/html");
}
[HttpPost(nameof(ApplyAlarmCorrection))]
public ActionResult ApplyAlarmCorrection([FromBody] AlarmCorrectionRequest req)
public ActionResult ApplyAlarmCorrection(Token authToken, [FromBody] AlarmCorrectionRequest req)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
if (req == null) return BadRequest();
var correction = new DiagnosticResponse
{
@ -2076,16 +2102,22 @@ public class Controller : ControllerBase
}
[HttpGet(nameof(ReviewAlarms))]
public ActionResult ReviewAlarms(int batch, string reviewer)
public ActionResult ReviewAlarms(Token authToken, int batch, string reviewer)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var html = AlarmReviewService.GetReviewPage(batch, reviewer);
if (html is null) return NotFound("Batch not found or reviewer not recognised.");
return Content(html, "text/html");
}
[HttpPost(nameof(SubmitAlarmReview))]
public async Task<ActionResult> SubmitAlarmReview(int batch, string? reviewer, [FromBody] List<ReviewFeedback>? feedbacks)
public async Task<ActionResult> SubmitAlarmReview(Token authToken, int batch, string? reviewer, [FromBody] List<ReviewFeedback>? feedbacks)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
// Batch 0 = test mode — run dry-run synthesis and return preview HTML (nothing is saved)
if (batch == 0)
{
@ -2099,14 +2131,20 @@ public class Controller : ControllerBase
}
[HttpGet(nameof(GetAlarmReviewStatus))]
public ActionResult GetAlarmReviewStatus()
public ActionResult GetAlarmReviewStatus(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
return Ok(AlarmReviewService.GetStatus());
}
[HttpGet(nameof(DownloadCheckedKnowledgeBase))]
public ActionResult DownloadCheckedKnowledgeBase()
public ActionResult DownloadCheckedKnowledgeBase(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var content = AlarmReviewService.GetCheckedFileContent();
if (content is null) return NotFound("AlarmKnowledgeBaseChecked.cs has not been generated yet.");
@ -2211,6 +2249,20 @@ public class Controller : ControllerBase
var existing = Db.GetTicketById(ticket.Id);
if (existing is null) return NotFound();
// Subject is creator-only. Non-creators submitting a different Subject
// (typically a stale client-side view during a concurrent edit) are
// silently coerced to the existing value rather than 403'd, so an
// unrelated update (status, assignee, ...) never fails on a stale subject.
if (existing.CreatedByUserId != user.Id)
{
ticket.Subject = existing.Subject;
}
else if (String.IsNullOrWhiteSpace(ticket.Subject))
{
return BadRequest("Subject is required.");
}
var subjectChanged = !String.Equals(ticket.Subject, existing.Subject);
// Enforce resolution when resolving
if (ticket.Status == (Int32)TicketStatus.Resolved
&& (String.IsNullOrWhiteSpace(ticket.RootCause) || String.IsNullOrWhiteSpace(ticket.Solution)))
@ -2218,6 +2270,8 @@ public class Controller : ControllerBase
return BadRequest("Root Cause and Solution are required to resolve a ticket.");
}
var oldSubject = existing.Subject;
ticket.CreatedAt = existing.CreatedAt;
ticket.CreatedByUserId = existing.CreatedByUserId;
ticket.UpdatedAt = DateTime.UtcNow;
@ -2314,7 +2368,22 @@ public class Controller : ControllerBase
});
}
return Db.Update(ticket) ? ticket : StatusCode(500, "Update failed.");
if (!Db.Update(ticket)) return StatusCode(500, "Update failed.");
if (subjectChanged)
{
Db.Create(new TicketTimelineEvent
{
TicketId = ticket.Id,
EventType = (Int32)TimelineEventType.SubjectChanged,
Description = $"Subject changed: \"{oldSubject}\" → \"{ticket.Subject}\".",
ActorType = (Int32)TimelineActorType.Human,
ActorId = user.Id,
CreatedAt = DateTime.UtcNow
});
}
return ticket;
}
[HttpDelete(nameof(DeleteTicket))]
@ -2406,6 +2475,40 @@ public class Controller : ControllerBase
return comment;
}
public class UpdateTicketCommentRequest
{
public Int64 Id { get; set; }
public String Body { get; set; } = "";
}
[HttpPost(nameof(UpdateTicketComment))]
public ActionResult<TicketComment> UpdateTicketComment([FromBody] UpdateTicketCommentRequest req, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var comment = Db.TicketComments.FirstOrDefault(c => c.Id == req.Id);
if (comment is null) return NotFound();
if (comment.AuthorType != (Int32)CommentAuthorType.Human) return Unauthorized();
if (comment.AuthorId != user.Id) return Unauthorized();
if (String.IsNullOrWhiteSpace(req.Body)) return BadRequest("Body required.");
comment.Body = req.Body;
comment.EditedAt = DateTime.UtcNow;
if (!Db.Update(comment)) return StatusCode(500, "Failed to update comment.");
var ticket = Db.GetTicketById(comment.TicketId);
if (ticket is not null)
{
ticket.UpdatedAt = DateTime.UtcNow;
Db.Update(ticket);
}
return comment;
}
[HttpGet(nameof(GetTicketDetail))]
public ActionResult<Object> GetTicketDetail(Int64 id, Token authToken)
{
@ -2544,16 +2647,18 @@ public class Controller : ControllerBase
private static readonly HashSet<String> AllowedMimeTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/jpeg", "image/png", "image/gif", "image/webp",
"application/pdf", "application/x-pdf"
"application/pdf", "application/x-pdf",
"video/mp4", "video/quicktime", "video/webm"
};
// Some browsers send generic MIME types — allow them if the file extension is valid
private static readonly HashSet<String> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf"
".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf",
".mp4", ".mov", ".webm"
};
private const Int64 MaxFileSizeBytes = 25 * 1024 * 1024; // 25 MB
private const Int64 MaxFileSizeBytes = 100 * 1024 * 1024; // 100 MB
private static S3Bucket DocumentBucket
{
@ -2565,13 +2670,15 @@ public class Controller : ControllerBase
}
[HttpPost(nameof(UploadDocument))]
[RequestSizeLimit(26_214_400)]
[RequestSizeLimit(104_857_600)]
public async Task<ActionResult<Document>> UploadDocument(
IFormFile file,
[FromQuery] Int32 scope,
[FromQuery] Int64? ticketId,
[FromQuery] Int64? ticketCommentId,
[FromQuery] Int64? installationId,
[FromQuery] Int64? checklistItemId,
[FromQuery] String? subtaskKey,
[FromQuery] Token authToken)
{
var user = Db.GetSession(authToken)?.User;
@ -2650,6 +2757,8 @@ public class Controller : ControllerBase
TicketId = ticketId,
TicketCommentId = ticketCommentId,
InstallationId = installationId,
ChecklistItemId = checklistItemId,
SubtaskKey = subtaskKey,
Scope = scope,
S3Key = s3Key,
OriginalName = safeFileName,
@ -2699,6 +2808,8 @@ public class Controller : ControllerBase
[FromQuery] Int64? ticketId,
[FromQuery] Int64? ticketCommentId,
[FromQuery] Int64? installationId,
[FromQuery] Int64? checklistItemId,
[FromQuery] String? subtaskKey,
[FromQuery] Token authToken)
{
var user = Db.GetSession(authToken)?.User;
@ -2710,6 +2821,12 @@ public class Controller : ControllerBase
if (ticketCommentId.HasValue)
return Ok(Db.GetDocumentsForComment(ticketCommentId.Value));
if (checklistItemId.HasValue)
{
if (user.UserType != 2) return Unauthorized();
return Ok(Db.GetDocumentsForChecklistItem(checklistItemId.Value, subtaskKey));
}
if (installationId.HasValue)
{
// Access control: admin can list all; others need installation access
@ -2721,7 +2838,7 @@ public class Controller : ControllerBase
return Ok(Db.GetDocumentsForInstallation(installationId.Value));
}
return BadRequest("Provide ticketId, ticketCommentId, or installationId.");
return BadRequest("Provide ticketId, ticketCommentId, installationId, or checklistItemId.");
}
[HttpDelete(nameof(DeleteDocument))]
@ -2748,4 +2865,114 @@ public class Controller : ControllerBase
return Ok();
}
// ── Checklist ───────────────────────────────────────────────────────
[HttpGet(nameof(GetChecklistForInstallation))]
public ActionResult<IEnumerable<ChecklistItem>> GetChecklistForInstallation(Int64 installationId, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var installation = Db.GetInstallationById(installationId);
if (installation is null) return NotFound("Installation not found.");
if (!Db.ChecklistExistsForInstallation(installationId))
{
foreach (var def in ChecklistStepDefinitions.Steps)
{
Db.Create(new ChecklistItem
{
InstallationId = installationId,
StepNumber = def.Number,
StepTitle = def.Title,
Subtasks = def.SubtasksJson,
});
}
}
return Ok(Db.GetChecklistForInstallation(installationId));
}
[HttpPut(nameof(UpdateChecklistItem))]
public ActionResult<ChecklistItem> UpdateChecklistItem(
Int64 checklistItemId,
Int32? status,
String? comments,
Int64? assigneeId,
Boolean clearAssignee,
String? doneAt,
String? subtasks,
Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var item = Db.GetChecklistItemById(checklistItemId);
if (item is null) return NotFound("Checklist item not found.");
if (status.HasValue) item.Status = status.Value;
if (comments is not null) item.Comments = comments;
if (clearAssignee) item.AssigneeId = null;
else if (assigneeId.HasValue) item.AssigneeId = assigneeId.Value;
if (doneAt is not null) item.DoneAt = String.IsNullOrWhiteSpace(doneAt) ? null : doneAt;
if (subtasks is not null) item.Subtasks = subtasks;
// Auto-fill DoneAt when status transitions to Done and no date provided
if (item.Status == (Int32)ChecklistStatus.Done && String.IsNullOrWhiteSpace(item.DoneAt))
item.DoneAt = DateTime.UtcNow.ToString("yyyy-MM-dd");
item.UpdatedAt = DateTime.UtcNow;
return Db.Update(item) ? item : StatusCode(500, "Update failed.");
}
[HttpPost(nameof(NotifyChecklistAssignee))]
public async Task<ActionResult> NotifyChecklistAssignee(Int64 checklistItemId, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var item = Db.GetChecklistItemById(checklistItemId);
if (item is null) return NotFound("Checklist item not found.");
if (item.AssigneeId is null) return BadRequest("No assignee set for this step.");
var assignee = Db.GetUserById(item.AssigneeId);
if (assignee is null) return BadRequest("Assignee user not found.");
var installation = Db.GetInstallationById(item.InstallationId);
if (installation is null) return NotFound("Installation not found.");
try
{
await assignee.SendChecklistAssignedEmail(item, installation, user.Name);
return Ok();
}
catch (Exception ex)
{
Console.WriteLine($"[Checklist] Failed to send assignee email: {ex}");
return StatusCode(500, "Failed to send notification email.");
}
}
[HttpGet(nameof(GetChecklistSummary))]
public ActionResult<IEnumerable<Object>> GetChecklistSummary(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var summaries = Db.ChecklistItems
.ToList()
.GroupBy(c => c.InstallationId)
.Select(g => new
{
installationId = g.Key,
done = g.Count(c => c.Status == (Int32)ChecklistStatus.Done),
total = g.Count()
})
.ToList();
return Ok(summaries);
}
}

View File

@ -0,0 +1,23 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
public enum ChecklistStatus { NotStarted = 0, InProgress = 1, Done = 2 }
public class ChecklistItem
{
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
[Indexed] public Int64 InstallationId { get; set; }
public Int32 StepNumber { get; set; }
public String StepTitle { get; set; } = "";
public Int32 Status { get; set; } = (Int32)ChecklistStatus.NotStarted;
public String Comments { get; set; } = "";
public Int64? AssigneeId { get; set; }
public String? DoneAt { get; set; }
public String? Subtasks { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,70 @@
namespace InnovEnergy.App.Backend.DataTypes;
public record ChecklistStepDefinition(Int32 Number, String Title, String? SubtasksJson);
public static class ChecklistStepDefinitions
{
private const String NoSubtasks = null!;
public static readonly IReadOnlyList<ChecklistStepDefinition> Steps = new List<ChecklistStepDefinition>
{
new( 1, "Order created, customer and partner info recorded in CRM", NoSubtasks),
new( 2, "Hardware assembled at Vebo", NoSubtasks),
new( 3, "Installation created on Monitor under correct product and folder",
"""
[
{"text":"checklistStep3Sub1","checked":false}
]
"""),
new( 4, "Gateway SD card configured, VPN and gateway name registered", NoSubtasks),
new( 5, "Information tab filled out (customer, serials, VPN)",
"""
[
{"text":"checklistStep5Sub1","checked":false},
{"text":"checklistStep5Sub2","checked":false},
{"text":"checklistStep5Sub3","checked":false},
{"text":"checklistStep5Sub4","checked":false},
{"text":"checklistStep5Sub5","checked":false},
{"text":"checklistStep5Sub6","checked":false}
]
"""),
new( 6, "Installation configured and tested electrically / hardware-wise at Vebo",
"""
[
{"text":"checklistStep6Sub1","checked":false},
{"text":"checklistStep6Sub2","checked":false},
{"text":"checklistStep6Sub3","checked":false},
{"text":"checklistStep6Sub4","checked":false}
]
"""),
new( 7, "Installation tested software-wise at Vebo",
"""
[
{"text":"checklistStep7Sub1","checked":false},
{"text":"checklistStep7Sub2","checked":false},
{"text":"checklistStep7Sub3","checked":false},
{"text":"checklistStep7Sub4","checked":false}
]
"""),
new( 8, "Installation delivered to customer site",
"""
[
{"text":"checklistStep8Sub1","checked":false}
]
"""),
new( 9, "Installation connected to grid", NoSubtasks),
new(10, "Hardware verified on site",
"""
[
{"text":"checklistStep10Sub1","checked":false},
{"text":"checklistStep10Sub2","checked":false}
]
"""),
new(11, "Software verified on site", NoSubtasks),
new(12, "Installation online on Monitor", NoSubtasks),
new(13, "Customer informed about Monitor account and reports", NoSubtasks),
new(14, "User account created with correct folders and access", NoSubtasks),
new(15, "Customer follow-up completed, feedback collected", NoSubtasks),
new(16, "Further issues tracked via Ticket system", NoSubtasks),
};
}

View File

@ -15,6 +15,8 @@ public class Document
[Indexed] public Int64? TicketId { get; set; }
[Indexed] public Int64? TicketCommentId { get; set; }
[Indexed] public Int64? InstallationId { get; set; }
[Indexed] public Int64? ChecklistItemId { get; set; }
public String? SubtaskKey { get; set; }
public Int32 Scope { get; set; } = (Int32)DocumentScope.TicketAttachment;
public String S3Key { get; set; } = "";

View File

@ -1,5 +1,6 @@
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.Relations;
using InnovEnergy.Lib.Mailer;
using InnovEnergy.Lib.Utils;
namespace InnovEnergy.App.Backend.DataTypes.Methods;
@ -172,4 +173,38 @@ public static class InstallationMethods
return true;
}
private const String SupportEmail = "support@inesco.energy";
private const String SupportName = "inesco energy Support Team";
public static Task SendAlarmNotificationToSupport(this Installation installation, Int32 prevStatus)
{
var productName = ProductName(installation.Product);
var fromStatus = StatusName(prevStatus);
var subject = $"[inesco energy] Alarm: {installation.Name}";
var body =
$"Installation \"{installation.Name}\" (ID {installation.Id}, {productName})\n" +
$"status changed from {fromStatus} to Alarm.\n\n" +
"Please check the Log tab on the Monitor to see detailed errors and warnings.\n";
return Mailer.Send(SupportName, SupportEmail, subject, body);
}
private static String StatusName(Int32 status) => status switch
{
-1 => "Offline",
0 => "Green",
1 => "Warning",
2 => "Alarm",
_ => "Unknown"
};
private static String ProductName(Int32 product) => product switch
{
2 => "Sodistore Home",
3 => "Sodistore Max",
4 => "Sodistore Grid",
5 => "Sodistore Pro",
_ => $"Product {product}"
};
}

View File

@ -518,4 +518,67 @@ public static class UserMethods
return user.SendEmail(subject, body);
}
public static Task SendChecklistAssignedEmail(
this User user,
ChecklistItem item,
Installation installation,
String notifiedByName)
{
var productPath = installation.Product switch
{
4 => "sodistoregrid_installations",
5 => "sodistorepro_installations",
_ => "sodiohome_installations"
};
var checklistLink = $"https://monitor.inesco.energy/{productPath}/installation/{installation.Id}/checklist";
var installationName = String.IsNullOrEmpty(installation.Name) ? $"#{installation.Id}" : installation.Name;
var commentsBlock = String.IsNullOrWhiteSpace(item.Comments) ? "" : item.Comments;
var (subject, body) = (user.Language ?? "en") switch
{
"de" => (
"inesco energy Sie wurden einem Checklisten-Schritt zugewiesen",
$"Sehr geehrte/r {user.Name},\n\n" +
$"{notifiedByName} hat Sie einem Schritt der Installations-Checkliste zugewiesen:\n\n" +
$"Installation: {installationName}\n" +
$"Schritt {item.StepNumber}: {item.StepTitle}\n\n" +
(commentsBlock.Length > 0 ? $"Kommentare:\n{commentsBlock}\n\n" : "") +
$"Checkliste öffnen: {checklistLink}\n\n" +
"Mit freundlichen Grüssen\ninesco energy Monitor"
),
"fr" => (
"inesco energy Une étape de checklist vous a été attribuée",
$"Cher/Chère {user.Name},\n\n" +
$"{notifiedByName} vous a attribué une étape de la checklist d'installation :\n\n" +
$"Installation : {installationName}\n" +
$"Étape {item.StepNumber} : {item.StepTitle}\n\n" +
(commentsBlock.Length > 0 ? $"Commentaires :\n{commentsBlock}\n\n" : "") +
$"Ouvrir la checklist : {checklistLink}\n\n" +
"Cordialement,\ninesco energy Monitor"
),
"it" => (
"inesco energy Le è stato assegnato un passo della checklist",
$"Gentile {user.Name},\n\n" +
$"{notifiedByName} le ha assegnato un passo della checklist di installazione:\n\n" +
$"Installazione: {installationName}\n" +
$"Passo {item.StepNumber}: {item.StepTitle}\n\n" +
(commentsBlock.Length > 0 ? $"Commenti:\n{commentsBlock}\n\n" : "") +
$"Aprire la checklist: {checklistLink}\n\n" +
"Cordiali saluti,\ninesco energy Monitor"
),
_ => (
"inesco energy You have been assigned to a checklist step",
$"Dear {user.Name},\n\n" +
$"{notifiedByName} has assigned you to an installation checklist step:\n\n" +
$"Installation: {installationName}\n" +
$"Step {item.StepNumber}: {item.StepTitle}\n\n" +
(commentsBlock.Length > 0 ? $"Comments:\n{commentsBlock}\n\n" : "") +
$"Open the checklist: {checklistLink}\n\n" +
"Best regards,\ninesco energy Monitor"
)
};
return user.SendEmail(subject, body);
}
}

View File

@ -13,6 +13,7 @@ public class TicketComment
public Int64? AuthorId { get; set; }
public String Body { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? EditedAt { get; set; }
[Ignore] public List<Int64> MentionedUserIds { get; set; } = new();
}

View File

@ -6,7 +6,7 @@ public enum TimelineEventType
{
Created = 0, StatusChanged = 1, Assigned = 2,
CommentAdded = 3, AiDiagnosisAttached = 4, Escalated = 5,
ResolutionAdded = 6
ResolutionAdded = 6, SubjectChanged = 7
}
public enum TimelineActorType { System = 0, AiAgent = 1, Human = 2 }

View File

@ -92,6 +92,9 @@ public static partial class Db
// Document storage
public static Boolean Create(Document document) => Insert(document);
// Checklist
public static Boolean Create(ChecklistItem item) => Insert(item);
public static void HandleAction(UserAction newAction)
{
//Find the total number of actions for this installation

View File

@ -42,6 +42,9 @@ public static partial class Db
// Document storage
public static TableQuery<Document> Documents => Connection.Table<Document>();
// Checklist
public static TableQuery<ChecklistItem> ChecklistItems => Connection.Table<ChecklistItem>();
public static void Init()
{
@ -83,6 +86,9 @@ public static partial class Db
// Document storage
Connection.CreateTable<Document>();
// Checklist
Connection.CreateTable<ChecklistItem>();
});
// One-time migration: normalize legacy long-form language values to ISO codes
@ -103,6 +109,51 @@ public static partial class Db
Connection.Execute("UPDATE User SET Name = 'inesco energy Master Admin' WHERE Name = 'InnovEnergy Master Admin'");
Connection.Execute("UPDATE User SET Name = 'inesco energy Master Admin' WHERE Name = 'inesco Energy Master Admin'");
// One-time migration: rewrite early-seeded English subtask text to translation keys so the
// frontend can localize them. Idempotent: rows already containing keys match nothing.
var subtaskTextToKey = new (String Old, String Key)[]
{
("Customer information (email, address)", "checklistStep5Sub1"),
("Installation information (external EMS, grid provider, data collection)", "checklistStep5Sub2"),
("Installation information (external EMS, network provider, data collection)", "checklistStep5Sub2"),
("Battery serial number", "checklistStep5Sub3"),
("Inverter serial number", "checklistStep5Sub4"),
("Data logger serial number", "checklistStep5Sub5"),
("VPN details", "checklistStep5Sub6"),
("Inverter firmware and configuration verified", "checklistStep6Sub1"),
("Battery firmware and configuration verified", "checklistStep6Sub2"),
("Internet for gateway configured", "checklistStep6Sub3"),
("Communication cable between gateway and inverter correct", "checklistStep6Sub4"),
("S3 bucket number and key credentials copied from Information tab into config.json","checklistStep7Sub1"),
("Product ID configured in config.json", "checklistStep7Sub2"),
("USB ID configured in config.json", "checklistStep7Sub3"),
("Inverter data reading from inverter tested", "checklistStep7Sub4")
};
foreach (var (oldText, key) in subtaskTextToKey)
{
Connection.Execute(
"UPDATE ChecklistItem SET Subtasks = REPLACE(Subtasks, ?, ?) WHERE Subtasks LIKE ?",
$"\"{oldText}\"", $"\"{key}\"", $"%\"{oldText}\"%");
}
// One-time backfill: step 3 originally had no subtasks; add the installation serial subtask
// to existing rows.
Connection.Execute(
"UPDATE ChecklistItem SET Subtasks = ? WHERE StepNumber = 3 AND (Subtasks IS NULL OR Subtasks = '')",
"[{\"text\":\"checklistStep3Sub1\",\"checked\":false}]");
// One-time backfill: step 8 originally had no subtasks; add the delivery-receipt subtask
// to existing rows so already-seeded installations pick up the new subtask after deploy.
Connection.Execute(
"UPDATE ChecklistItem SET Subtasks = ? WHERE StepNumber = 8 AND (Subtasks IS NULL OR Subtasks = '')",
"[{\"text\":\"checklistStep8Sub1\",\"checked\":false}]");
// One-time backfill: step 10 originally had no subtasks; add the two upload subtasks
// (installation protocol + time & material report) to existing rows.
Connection.Execute(
"UPDATE ChecklistItem SET Subtasks = ? WHERE StepNumber = 10 AND (Subtasks IS NULL OR Subtasks = '')",
"[{\"text\":\"checklistStep10Sub1\",\"checked\":false},{\"text\":\"checklistStep10Sub2\",\"checked\":false}]");
//UpdateKeys();
CleanupSessions().SupressAwaitWarning();
DeleteSnapshots().SupressAwaitWarning();
@ -150,6 +201,9 @@ public static partial class Db
// Document storage
fileConnection.CreateTable<Document>();
// Checklist
fileConnection.CreateTable<ChecklistItem>();
// Migrate new columns: set defaults for existing rows where NULL or empty
fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''");
fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL");

View File

@ -145,6 +145,9 @@ public static partial class Db
// Clean up installation-level documents
Documents.Delete(d => d.InstallationId == installation.Id);
// Clean up checklist items for this installation
ChecklistItems.Delete(c => c.InstallationId == installation.Id);
return Installations.Delete(i => i.Id == installation.Id) > 0;
}
}

View File

@ -233,4 +233,26 @@ public static partial class Db
.Where(d => d.InstallationId == installationId && d.Scope == (Int32)DocumentScope.InstallationDocument)
.OrderBy(d => d.CreatedAt)
.ToList();
public static List<Document> GetDocumentsForChecklistItem(Int64 checklistItemId, String? subtaskKey = null)
=> Documents
.Where(d => d.ChecklistItemId == checklistItemId
&& d.Scope == (Int32)DocumentScope.InstallationDocument
&& (subtaskKey == null || d.SubtaskKey == subtaskKey))
.OrderBy(d => d.CreatedAt)
.ToList();
// ── Checklist Queries ───────────────────────────────────────────────
public static List<ChecklistItem> GetChecklistForInstallation(Int64 installationId)
=> ChecklistItems
.Where(c => c.InstallationId == installationId)
.OrderBy(c => c.StepNumber)
.ToList();
public static Boolean ChecklistExistsForInstallation(Int64 installationId)
=> ChecklistItems.Any(c => c.InstallationId == installationId);
public static ChecklistItem? GetChecklistItemById(Int64 id)
=> ChecklistItems.FirstOrDefault(c => c.Id == id);
}

View File

@ -72,4 +72,8 @@ public static partial class Db
// Ticket system
public static Boolean Update(Ticket ticket) => Update(obj: ticket);
public static Boolean Update(TicketAiDiagnosis diagnosis) => Update(obj: diagnosis);
public static Boolean Update(TicketComment comment) => Update(obj: comment);
// Checklist
public static Boolean Update(ChecklistItem item) => Update(obj: item);
}

View File

@ -1,4 +1,4 @@
{
{
"AbnormalGridVoltage": {
"Explanation": "Der Wechselrichter hat festgestellt, dass die Netzspannung außerhalb des zulässigen Bereichs liegt. Das System benötigt manuellen Eingriff zur Wiederherstellung.",
"Causes": [
@ -28,15 +28,15 @@
]
},
"InvertedSequenceOfGridVoltage": {
"Explanation": "Die Phasenreihenfolge der dreiphasigen Netzspannung ist vertauscht. Dies ist ein Verdrahtungsproblem, das einen sicheren Betrieb verhindert.",
"Explanation": "Die Reihenfolge der drei Stromphasen vom Netz ist falsch angeschlossen. Das System kann nicht sicher arbeiten.",
"Causes": [
"Falsche Verdrahtung der Netzphasen während der Installation (L1, L2, L3 vertauscht)",
"Nachträgliche Verdrahtungsarbeiten ohne Überprüfung der Phasenfolge"
"Falsche Verdrahtung der Netzphasen (L1, L2, L3) bei der Installation",
"Nachträgliche Änderungen an der Verdrahtung ohne Prüfung der Phasenfolge"
],
"NextSteps": [
"Schalten Sie das gesamte System sicher aus, bevor Sie die Verdrahtung berühren",
"Vertauschen Sie zwei der drei Phasenleitungen an der Netzverbindung, um die Reihenfolge zu korrigieren",
"Schalten Sie das System wieder ein und überprüfen Sie, ob die Warnung behoben ist"
"Tauschen Sie zwei der drei Phasenleitungen an der Netzverbindung, um die Reihenfolge zu korrigieren",
"Schalten Sie das System wieder ein und prüfen Sie, ob die Warnung verschwunden ist"
]
},
"GridVoltagePhaseLoss": {
@ -135,7 +135,7 @@
]
},
"ExcessiveRadiatorTemperature": {
"Explanation": "Die Temperatur des Kühlkörpers (Radiator) des Wechselrichters ist zu hoch. Der Kühlkörper dient dazu, Wärme während des Betriebs abzuführen.",
"Explanation": "Der Kühlkörper des Wechselrichters ist zu heiß, weil die Wärme nicht richtig abgeführt wird.",
"Causes": [
"Verstopfte oder blockierte Lüftungsschlitze verhindern die Wärmeabfuhr",
"Ausfall des Kühlgebläses reduziert die Luftzirkulation",
@ -145,8 +145,7 @@
"NextSteps": [
"Reinigen Sie die Lüftungsschlitze und Staubfilter Staubansammlungen sind eine häufige Ursache",
"Überprüfen Sie, ob das Kühlgebläse läuft (Hören Sie auf Gebläsegeräusche während des Betriebs)",
"Reduzieren Sie die Last vorübergehend, um die Wärmeentwicklung zu verringern",
"Reparieren oder ersetzen Sie das Gebläse, falls es defekt ist, und starten Sie den Wechselrichter neu"
"Reduzieren Sie die Last vorübergehend, um die Wärmeentwicklung zu verringern"
]
},
"PcbOvertemperature": {
@ -541,13 +540,14 @@
"Battery2OverloadTimeout": {
"Explanation": "Batterie 2 läuft seit zu langer Zeit unter Überlast.",
"Causes": [
"Dauerhafte hohe Last, die die Entladerating von Batterie 2 überschreitet",
"Dauerhafte hohe Last, die die Entladeleistung von Batterie 2 überschreitet",
"Batterie 2 ist degradiert und kann weniger Leistung bereitstellen"
],
"NextSteps": [
"Den Gesamtstromverbrauch reduzieren",
"Prüfen, ob Batterie 2 für die Lastanforderungen richtig dimensioniert ist",
"Nach Lastreduzierung den Wechselrichter neu starten"
"Nach Lastreduzierung den Wechselrichter neu starten",
"Bitte überprüfen Sie die Einstellungen zur Batterie (z. B. Lade-/Entladestrom)"
]
},
"Battery2SoftStartFailure": {
@ -967,9 +967,9 @@
]
},
"Pv3ReverseConnection": {
"Explanation": "PV-String 3 ist mit vertauschter Polarität angeschlossen. Dies ist ein Verdrahtungsfehler, der vor dem Betrieb behoben werden muss.",
"Explanation": "PV-String 3 ist mit vertauschter Polarität angeschlossen. Dieser Verdrahtungsfehler muss vor dem Betrieb behoben werden.",
"Causes": [
"Positive und negative Kabel von PV-String 3 wurden während der Installation vertauscht",
"Positive und negative Kabel von PV-String 3 wurden womöglich während der Installation vertauscht",
"Falsche Kabelverbindung am DC-Eingang des Wechselrichters"
],
"NextSteps": [
@ -1247,12 +1247,13 @@
]
},
"InverterOverloadTimeout": {
"Explanation": "Der Wechselrichter war zu lange überlastet und hat sich abgeschaltet.",
"Explanation": "Der Wechselrichter war zu lange überlastet und hat sich automatisch abgeschaltet.",
"Causes": [
"Dauerhafte Überlastung, die die Kurzzeit-Überlastfähigkeit des Wechselrichters überschreitet",
"Der Wechselrichter ist für die tatsächliche Last zu klein dimensioniert"
],
"NextSteps": [
"Die sinnvolle Aufteilung der Lasten zwischen Notstrom- und normalem Hausnetz überprüfen.",
"Die angeschlossene Last dauerhaft reduzieren",
"Falls die Last notwendig ist, auf einen größeren Wechselrichter umsteigen",
"Die Ursache beheben und den Wechselrichter neu starten"
@ -1265,6 +1266,7 @@
"Ein neues leistungsstarkes Gerät wurde hinzugefügt, das die Systemleistung übersteigt"
],
"NextSteps": [
"Die sinnvolle Aufteilung der Lasten zwischen Notstrom- und normalem Hausnetz überprüfen.",
"Last reduzieren, indem nicht essentielle Geräte ausgeschaltet werden",
"Nutzung leistungsstarker Geräte staffeln und den Wechselrichter neu starten"
]
@ -1295,16 +1297,15 @@
]
},
"Dsp1ParameterSettingFault": {
"Explanation": "DSP 1 (digitaler Signalprozessor) hat eine falsche Parameterkonfiguration erkannt.",
"Explanation": "Der Wechselrichter hat eine falsche Einstellung in seinen internen Parametern erkannt.",
"Causes": [
"Ein oder mehrere Wechselrichterparameter sind außerhalb des zulässigen Bereichs eingestellt",
"Firmware-Korruption beeinflusst die Parameterspeicherung",
"Konfigurationsinkonsistenz nach einem Firmware-Update"
"Ein oder mehrere Parameter des Wechselrichters liegen außerhalb des erlaubten Bereichs.",
"Die Firmware ist beschädigt und beeinflusst die Speicherung der Einstellungen.",
"Nach einem Firmware-Update stimmen die Einstellungen nicht mehr überein."
],
"NextSteps": [
"Alle Wechselrichter-Parameter überprüfen und eventuell ungültige Werte korrigieren",
"Parameter auf Werkseinstellungen zurücksetzen, falls unsicher über die richtigen Werte",
"Die Ursache beheben und den Wechselrichter neu starten"
"Überprüfen Sie alle Parameter des Wechselrichters und korrigieren Sie ungültige Werte.",
"Setzen Sie die Parameter auf Werkseinstellungen zurück, falls Sie unsicher sind."
]
},
"Dsp2ParameterSettingFault": {
@ -1568,14 +1569,15 @@
]
},
"ReverseMeterConnection": {
"Explanation": "Der Stromzähler ist falsch installiert oder verdrahtet. Die Zählerstände (Import/Export) sind bis zur Korrektur ungenau.",
"Explanation": "Der Stromzähler ist falsch angeschlossen, sodass die Messwerte (Strombezug/Einspeisung) nicht stimmen.",
"Causes": [
"Der Stromwandler (CT) ist in die falsche Richtung installiert",
"Die L- und N-Leitungen des Zählers sind bei der Installation vertauscht"
"Der Stromwandler (CT) ist in die falsche Richtung eingebaut",
"Die L- und N-Leitungen des Zählers wurden vertauscht",
"Andere Messwandler in der Nähe stören mindestens 30 cm Abstand halten"
],
"NextSteps": [
"Verlassen Sie sich nicht auf die Zählerstände, bis die Korrektur erfolgt ist",
"Kontaktieren Sie Ihren Installateur oder einen qualifizierten Elektriker, um den Stromwandler oder die Zählerverkabelung zu korrigieren"
"Kontaktieren Sie Ihren Installateur oder einen qualifizierten Elektriker, um den Stromwandler oder die Zählerverkabelung zu prüfen"
]
},
"InverterSealPulse": {
@ -2121,7 +2123,7 @@
]
},
"LithiumBatteryOverload": {
"Explanation": "Der Überlastschutz der Lithiumbatterie wurde aktiviert die Last entnimmt mehr Strom, als die Batterie abgeben kann.",
"Explanation": "Der Überlastschutz der Batterie wurde aktiviert die Last entnimmt mehr Strom, als die Batterie abgeben kann.",
"Causes": [
"Die Gesamtlastleistung überschreitet die maximale Entladeleistung der Batterie",
"Hochstrom beim Einschalten großer Motoren oder Kompressoren übersteigt vorübergehend die Batteriegrenzen"
@ -2220,6 +2222,19 @@
"Reparieren oder entfernen Sie den überlastenden Verbraucher, bevor Sie den Wechselrichter neu starten"
]
},
"OffGridBusVoltageTooLow": {
"Explanation": "Die Gleichspannung im Inselbetrieb ist zu stark abgesunken, um einen stabilen Betrieb aufrechtzuerhalten.",
"Causes": [
"Batterieladestand zu niedrig",
"Zu hohe Last am Inselausgang",
"Defekt oder lockere Verbindung in der DC-Bus-Verdrahtung"
],
"NextSteps": [
"Last am Inselausgang reduzieren",
"Batterieladestand prüfen und ggf. aufladen",
"DC-Bus-Verdrahtung auf lockere Verbindungen oder Schäden überprüfen"
]
},
"OffGridOutputOverload": {
"Explanation": "Der netzunabhängige (EPS/Backup)-Ausgang ist überlastet — es wird mehr Strom angefordert, als der Wechselrichter im Backup-Modus liefern kann.",
"Causes": [
@ -2805,18 +2820,5 @@
"Messen Sie die tatsächliche DC-Spannung, bevor Sie wieder anschließen",
"Überprüfen Sie das String-Design und reduzieren Sie gegebenenfalls die Anzahl der Module in Reihe, um die Wechselrichter-Spannungsgrenzen einzuhalten"
]
},
"OffGridBusVoltageTooLow": {
"Explanation": "Die Gleichspannung im Inselbetrieb ist zu stark abgesunken, um einen stabilen Betrieb aufrechtzuerhalten.",
"Causes": [
"Batterieladestand zu niedrig",
"Zu hohe Last am Inselausgang",
"Defekt oder lockere Verbindung in der DC-Bus-Verdrahtung"
],
"NextSteps": [
"Last am Inselausgang reduzieren",
"Batterieladestand prüfen und ggf. aufladen",
"DC-Bus-Verdrahtung auf lockere Verbindungen oder Schäden überprüfen"
]
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -279,6 +279,10 @@ public static class AlarmReviewService
if (current.Synthesized)
{
// Campaign is fully reviewed → nothing left to do. Without this guard the
// recovery branch below re-fires the admin completion email every workday.
if (progress.Batches.Count * BatchSize >= AllAlarmKeys.Length) return;
// Next batch is sent immediately after synthesis — only act here as a safety net
// in case the server restarted before SendNextBatchAsync could run.
var nextAlreadySent = progress.Batches.Count > current.BatchNumber;

View File

@ -2,6 +2,7 @@ using System.Text;
using System.Text.Json;
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes;
using InnovEnergy.App.Backend.DataTypes.Methods;
using InnovEnergy.Lib.Utils;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
@ -186,6 +187,20 @@ public static class RabbitMqManager
Db.UpdateInstallationStatus(installationId, receivedStatusMessage.Status);
const int AlarmStatus = 2;
var isSodistore = installation.Product is 2 or 3 or 4 or 5;
if (isSodistore
&& prevStatus != AlarmStatus
&& receivedStatusMessage.Status == AlarmStatus)
{
var prev = prevStatus;
_ = Task.Run(async () =>
{
try { await installation.SendAlarmNotificationToSupport(prev); }
catch (Exception ex) { Console.WriteLine($"[AlarmNotify] failed for {installationId}: {ex.Message}"); }
});
}
//Console.WriteLine("----------------------------------------------");
//If the status has changed, update all the connected front-ends regarding this installation
if(prevStatus != receivedStatusMessage.Status && WebsocketManager.InstallationConnections[installationId].Connections.Count > 0)

View File

@ -4,18 +4,29 @@ generate_alarm_translations.py
Post-campaign script: reads AlarmTranslationsChecked.de.json (the reviewed and
AI-synthesized German content), translates into English, French, and Italian,
and writes:
and writes preview files for review BEFORE replacing the live translations:
Resources/AlarmTranslations.de.json replace with reviewed German
Resources/AlarmTranslations.en.json back-translated from German
Resources/AlarmTranslations.fr.json translated from German
Resources/AlarmTranslations.it.json translated from German
Services/AlarmKnowledgeBase.cs updated English source (keeps same structure)
Resources/AlarmTranslationsChecked.en.json NEW (back-translated from German)
Resources/AlarmTranslationsChecked.fr.json NEW (translated from German)
Resources/AlarmTranslationsChecked.it.json NEW (translated from German)
Services/AlarmKnowledgeBase.cs updated in-place (review via `git diff`)
Resources/AlarmTranslationsChecked.de.json is the INPUT and is not modified.
Live files Resources/AlarmTranslations.{de,fr,it}.json are NOT overwritten
review the *Checked* files, then manually copy them onto the live names when ready:
cp Resources/AlarmTranslationsChecked.de.json Resources/AlarmTranslations.de.json
cp Resources/AlarmTranslationsChecked.en.json Resources/AlarmTranslations.en.json
cp Resources/AlarmTranslationsChecked.fr.json Resources/AlarmTranslations.fr.json
cp Resources/AlarmTranslationsChecked.it.json Resources/AlarmTranslations.it.json
Run this AFTER the review campaign is complete:
export MISTRAL_API_KEY=your_key_here
cd csharp/App/Backend
python3 generate_alarm_translations.py
The script reads MISTRAL_API_KEY from the environment, falling back to the same
.env file the C# backend uses (csharp/App/Backend/.env). No `export` needed if
the .env file is in place.
"""
import json
@ -23,7 +34,6 @@ import os
import re
import sys
import time
import shutil
from typing import Optional
import requests
@ -245,18 +255,17 @@ def main():
print(f"ERROR: {CHECKED_FILE} not found. Run the review campaign first.")
sys.exit(1)
with open(CHECKED_FILE, "r", encoding="utf-8") as f:
# utf-8-sig strips the BOM that the C# AlarmReviewService writes via Encoding.UTF8
with open(CHECKED_FILE, "r", encoding="utf-8-sig") as f:
german_source = json.load(f)
alarm_keys = list(german_source.keys())
print(f"Loaded {len(alarm_keys)} alarms from {CHECKED_FILE}.")
# Step 1: copy reviewed German as the new de.json
de_out = os.path.join(RESOURCES_DIR, "AlarmTranslations.de.json")
shutil.copy(CHECKED_FILE, de_out)
print(f"\n✓ Copied reviewed German → {de_out}")
# The reviewed German JSON is already at AlarmTranslationsChecked.de.json — no copy needed.
# User will manually replace AlarmTranslations.de.json after reviewing all four Checked files.
# Step 2: translate to en, fr, it
# Translate to en, fr, it → write to AlarmTranslationsChecked.{lang}.json (preview names)
all_translations = {} # lang_code → {key → entry}
for lang_code, lang_name in TARGET_LANGUAGES.items():
print(f"\n── Translating to {lang_name} ({lang_code}) ──")
@ -296,7 +305,7 @@ def main():
time.sleep(1)
all_translations[lang_code] = translations
out_file = os.path.join(RESOURCES_DIR, f"AlarmTranslations.{lang_code}.json")
out_file = os.path.join(RESOURCES_DIR, f"AlarmTranslationsChecked.{lang_code}.json")
with open(out_file, "w", encoding="utf-8") as f:
json.dump(translations, f, ensure_ascii=False, indent=2)
print(f" ✓ Wrote {len(translations)} entries → {out_file}")
@ -312,8 +321,17 @@ def main():
else:
print(" Skipped — en.json not generated or AlarmKnowledgeBase.cs not found.")
print("\n✓ Done. Review the output files before deploying.")
print(" Next: cd csharp/App/Backend && dotnet build && ./deploy.sh")
print("\n✓ Done. Review these preview files before replacing the live ones:")
print(f" - {RESOURCES_DIR}/AlarmTranslationsChecked.de.json (reviewed German — input, unchanged)")
print(f" - {RESOURCES_DIR}/AlarmTranslationsChecked.en.json (new)")
print(f" - {RESOURCES_DIR}/AlarmTranslationsChecked.fr.json (new)")
print(f" - {RESOURCES_DIR}/AlarmTranslationsChecked.it.json (new)")
print(f" - {KNOWLEDGE_BASE} (overwritten — review with `git diff`)")
print("\nWhen satisfied:")
print(" for lang in de en fr it; do")
print(f" cp {RESOURCES_DIR}/AlarmTranslationsChecked.$lang.json {RESOURCES_DIR}/AlarmTranslations.$lang.json")
print(" done")
print(" dotnet build && ./deploy.sh")
if __name__ == "__main__":

View File

@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
patch_missing_alarms.py
Re-translates specific keys that failed during a previous run of
generate_alarm_translations.py (e.g. due to LLM JSON-format glitches),
then regenerates AlarmKnowledgeBase.cs with the now-complete English set.
Translates one key per API call to dodge the multi-key JSON formatting
issue that caused the original failures.
Edit MISSING below to set which keys to retry per language, then run:
cd csharp/App/Backend
python3 patch_missing_alarms.py
"""
import json
import os
import sys
from generate_alarm_translations import (
translate_batch,
parse_kb_key_sections,
write_knowledge_base_cs,
load_env_file,
CHECKED_FILE,
KNOWLEDGE_BASE,
RESOURCES_DIR,
TARGET_LANGUAGES,
)
# Keys that failed during the 2026-04-28 run.
# Update this dict if a new run produces different failures.
MISSING = {
"en": [
"DcBusOvervoltage",
"DcBusUndervoltage",
"DcBusVoltageUnbalance",
"BusSlowOvervoltage",
"HardwareBusOvervoltage",
],
"it": [
"NtcTemperatureSensorBroken",
"SyncSignalAbnormal",
"GridStartupConditionsNotMet",
"BatteryCommunicationFailure",
"BatteryDisconnected",
],
}
def main():
api_key = os.environ.get("MISTRAL_API_KEY", "").strip()
if not api_key:
script_dir = os.path.dirname(os.path.abspath(__file__))
api_key = load_env_file(os.path.join(script_dir, ".env")).get("MISTRAL_API_KEY", "").strip()
if not api_key:
print("ERROR: MISTRAL_API_KEY not found in environment or .env file.")
sys.exit(1)
print("MISTRAL_API_KEY loaded.")
with open(CHECKED_FILE, encoding="utf-8-sig") as f:
de = json.load(f)
en_translations = None
for lang_code, missing_keys in MISSING.items():
lang_name = TARGET_LANGUAGES[lang_code]
out_file = os.path.join(RESOURCES_DIR, f"AlarmTranslationsChecked.{lang_code}.json")
with open(out_file, encoding="utf-8") as f:
existing = json.load(f)
# Idempotent: only translate keys that are still genuinely missing from the JSON.
actually_missing = [
k for k in missing_keys
if k in de and (k not in existing or not existing[k].get("Explanation"))
]
if not actually_missing:
print(f"\n── {lang_name} ({lang_code}) already complete ({len(existing)} entries) — skipping translation ──")
else:
print(f"\n── Patching {lang_name} ({lang_code}) — {len(actually_missing)} keys ──")
translated = {}
for key in actually_missing:
print(f" {key}")
result = translate_batch(api_key, {key: de[key]}, lang_name)
if result and key in result:
r = result[key]
translated[key] = {
"Explanation": r.get("Explanation", ""),
"Causes": r.get("Causes", []),
"NextSteps": r.get("NextSteps", []),
}
snippet = r.get("Explanation", "")[:80]
print(f" OK: {snippet}{'...' if len(r.get('Explanation','')) > 80 else ''}")
else:
print(f" FAILED: {key}")
existing.update(translated)
with open(out_file, "w", encoding="utf-8") as f:
json.dump(existing, f, ensure_ascii=False, indent=2)
print(f" ✓ Wrote {len(existing)} total entries → {out_file}")
if lang_code == "en":
en_translations = existing
if en_translations is not None and os.path.exists(KNOWLEDGE_BASE):
print("\n── Regenerating AlarmKnowledgeBase.cs ──")
key_sections = parse_kb_key_sections(KNOWLEDGE_BASE)
write_knowledge_base_cs(KNOWLEDGE_BASE, en_translations, key_sections)
print("\n✓ Patch done.")
if __name__ == "__main__":
main()

View File

@ -26,5 +26,6 @@
"report": "report",
"installationTickets": "installationTickets",
"documents": "documents",
"checklist": "checklist",
"tickets": "/tickets/"
}

View File

@ -13,6 +13,8 @@ import DownloadIcon from '@mui/icons-material/Download';
import DeleteIcon from '@mui/icons-material/Delete';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import VideocamIcon from '@mui/icons-material/Videocam';
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
@ -48,8 +50,13 @@ function isImage(contentType: string): boolean {
return contentType.startsWith('image/');
}
function isVideo(contentType: string): boolean {
return contentType.startsWith('video/');
}
function getFileIcon(contentType: string) {
if (contentType === 'application/pdf') return <PictureAsPdfIcon fontSize="small" color="error" />;
if (isVideo(contentType)) return <VideocamIcon fontSize="small" color="primary" />;
return <InsertDriveFileIcon fontSize="small" />;
}
@ -63,7 +70,36 @@ function DocumentList({
const [documents, setDocuments] = useState<DocumentItem[]>([]);
const [loading, setLoading] = useState(true);
const [previews, setPreviews] = useState<Record<number, string>>({});
const [videoUrls, setVideoUrls] = useState<Record<number, string>>({});
const [loadingVideoIds, setLoadingVideoIds] = useState<Set<number>>(new Set());
const [expandedImage, setExpandedImage] = useState<string | null>(null);
const [expandedVideo, setExpandedVideo] = useState<{ url: string; contentType: string } | null>(null);
const loadVideoBlob = (doc: DocumentItem) => {
if (videoUrls[doc.id] || loadingVideoIds.has(doc.id)) return;
setLoadingVideoIds((prev) => {
const next = new Set(prev);
next.add(doc.id);
return next;
});
axiosConfig
.get('/DownloadDocument', {
params: { id: doc.id },
responseType: 'blob'
})
.then((res) => {
const url = window.URL.createObjectURL(new Blob([res.data], { type: doc.contentType }));
setVideoUrls((prev) => ({ ...prev, [doc.id]: url }));
})
.catch(() => {})
.finally(() => {
setLoadingVideoIds((prev) => {
const next = new Set(prev);
next.delete(doc.id);
return next;
});
});
};
const fetchDocuments = () => {
setLoading(true);
@ -100,17 +136,25 @@ function DocumentList({
});
}, [documents]);
// Clean up blob URLs on unmount
// Revoke superseded blob URLs as state changes, and on unmount.
// Empty deps would capture the initial {} and never revoke anything.
useEffect(() => {
return () => {
Object.values(previews).forEach((url) => window.URL.revokeObjectURL(url));
};
}, []);
}, [previews]);
useEffect(() => {
return () => {
Object.values(videoUrls).forEach((url) => window.URL.revokeObjectURL(url));
};
}, [videoUrls]);
const handleDownload = (doc: DocumentItem) => {
if (previews[doc.id]) {
const cached = previews[doc.id] || videoUrls[doc.id];
if (cached) {
const link = document.createElement('a');
link.href = previews[doc.id];
link.href = cached;
link.setAttribute('download', doc.originalName);
document.body.appendChild(link);
link.click();
@ -196,6 +240,53 @@ function DocumentList({
}}
/>
)}
{isVideo(doc.contentType) && (
videoUrls[doc.id] ? (
<Box
component="video"
controls
src={videoUrls[doc.id]}
onClick={() => setExpandedVideo({ url: videoUrls[doc.id], contentType: doc.contentType })}
sx={{
maxWidth: 240,
maxHeight: 160,
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
cursor: 'pointer',
backgroundColor: 'common.black'
}}
/>
) : (
<Box
onClick={() => loadVideoBlob(doc)}
sx={{
width: 240,
height: 135,
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
backgroundColor: 'action.hover',
cursor: loadingVideoIds.has(doc.id) ? 'progress' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 0.5,
'&:hover': { opacity: 0.85 }
}}
>
<PlayCircleOutlineIcon sx={{ fontSize: 48, color: 'text.secondary' }} />
<Typography variant="caption" color="text.secondary">
{loadingVideoIds.has(doc.id) ? (
<FormattedMessage id="videoLoading" defaultMessage="Loading…" />
) : (
<FormattedMessage id="videoClickToPlay" defaultMessage="Click to play" />
)}
</Typography>
</Box>
)
)}
</ListItem>
))}
</List>
@ -215,6 +306,23 @@ function DocumentList({
/>
)}
</Dialog>
{/* Full-size video preview dialog */}
<Dialog
open={!!expandedVideo}
onClose={() => setExpandedVideo(null)}
maxWidth="lg"
>
{expandedVideo && (
<Box
component="video"
controls
autoPlay
src={expandedVideo.url}
sx={{ maxWidth: '90vw', maxHeight: '90vh', backgroundColor: 'common.black' }}
/>
)}
</Dialog>
</Box>
);
}

View File

@ -7,7 +7,7 @@ import {
Typography
} from '@mui/material';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
const ALLOWED_TYPES = [
@ -15,9 +15,13 @@ const ALLOWED_TYPES = [
'image/png',
'image/gif',
'image/webp',
'application/pdf'
'application/pdf',
'video/mp4',
'video/quicktime',
'video/webm'
];
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
const MAX_FILE_SIZE_MB = MAX_FILE_SIZE / (1024 * 1024);
export interface UploadedDocument {
id: number;
@ -32,6 +36,8 @@ interface FileUploadButtonProps {
ticketId?: number;
ticketCommentId?: number;
installationId?: number;
checklistItemId?: number;
subtaskKey?: string;
onUploaded?: (doc: UploadedDocument) => void;
disabled?: boolean;
}
@ -41,9 +47,12 @@ function FileUploadButton({
ticketId,
ticketCommentId,
installationId,
checklistItemId,
subtaskKey,
onUploaded,
disabled = false
}: FileUploadButtonProps) {
const intl = useIntl();
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
@ -58,11 +67,21 @@ function FileUploadButton({
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!ALLOWED_TYPES.includes(file.type)) {
setError(`Invalid file type: ${file.name}`);
setError(
intl.formatMessage(
{ id: 'attachFileInvalidType', defaultMessage: 'Invalid file type: {name}' },
{ name: file.name }
)
);
return;
}
if (file.size > MAX_FILE_SIZE) {
setError(`File too large: ${file.name} (max 25 MB)`);
setError(
intl.formatMessage(
{ id: 'attachFileTooLarge', defaultMessage: 'File too large: {name} (max {limitMb} MB)' },
{ name: file.name, limitMb: MAX_FILE_SIZE_MB }
)
);
return;
}
validFiles.push(file);
@ -87,7 +106,7 @@ function FileUploadButton({
try {
const res = await axiosConfig.post('/UploadDocument', formData, {
params: { scope, ticketId, ticketCommentId, installationId },
params: { scope, ticketId, ticketCommentId, installationId, checklistItemId, subtaskKey },
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (e.total) setProgress(Math.round((e.loaded * 100) / e.total));

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import {
Container,
Grid,
@ -14,12 +14,16 @@ import {
import { JSONRecordData } from '../Log/graph.util';
import { Link, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import Button from '@mui/material/Button';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import { I_S3Credentials } from '../../../interfaces/S3Types';
import routes from '../../../Resources/routes.json';
import CircularProgress from '@mui/material/CircularProgress';
import { I_Installation } from 'src/interfaces/InstallationTypes';
import MainStatsSodioHome from './MainStatsSodioHome';
import {
ActiveCluster,
getActiveClusters
} from '../Information/installationSetupUtils';
interface BatteryViewSodioHomeProps {
values: JSONRecordData;
@ -36,42 +40,78 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
const currentLocation = useLocation();
const navigate = useNavigate();
const intl = useIntl();
const inverter = (props.values as any)?.InverterRecord;
const batteryClusterNumber = props.installation.batteryClusterNumber;
const batterySerialNumbers = props.installation.batterySerialNumbers;
const hasDevices = !!inverter?.Devices;
const sortedBatteryView = inverter
? Array.from({ length: batteryClusterNumber }, (_, i) => {
if (hasDevices) {
// Sinexcel: map across devices — 0→D1/B1, 1→D1/B2, 2→D2/B1, 3→D2/B2
const deviceId = String(Math.floor(i / 2) + 1);
const batteryIndex = (i % 2) + 1;
const device = inverter.Devices[deviceId];
const activeClusters: ActiveCluster[] = useMemo(() => {
const parsed = getActiveClusters(batterySerialNumbers || '');
if (parsed.length > 0) return parsed;
// Legacy/empty fallback: assume 2 clusters per inverter (all current Sinexcel
// presets), which matches the previous floor(i/2)+1 / (i%2)+1 mapping.
// For Growatt (batteryClusterNumber = 1) this collapses to a single row.
return Array.from({ length: batteryClusterNumber }, (_, i) => ({
invIdx: Math.floor(i / 2),
clIdx: i % 2,
flatIdx: i
}));
}, [batterySerialNumbers, batteryClusterNumber]);
const inverterCount = activeClusters.reduce(
(max, c) => Math.max(max, c.invIdx + 1),
0
);
const showInverterLabel = hasDevices && inverterCount > 1;
const sortedBatteryView = inverter
? activeClusters.map(({ invIdx, clIdx, flatIdx }) => {
const label = showInverterLabel
? intl.formatMessage(
{
id: 'batteryClusterInInverter',
defaultMessage: 'Battery Cluster {cl} in Inverter {inv}'
},
{ cl: clIdx + 1, inv: invIdx + 1 }
)
: intl.formatMessage(
{ id: 'batteryClusterN', defaultMessage: 'Battery Cluster {n}' },
{ n: clIdx + 1 }
);
if (hasDevices) {
// Sinexcel: Devices keyed by "1","2",... (1-based dict keys)
const device = inverter.Devices[String(invIdx + 1)];
const bi = clIdx + 1;
return {
BatteryId: String(flatIdx + 1),
label,
battery: {
Voltage: device?.[`Battery${bi}PackTotalVoltage`] ?? 0,
Current: device?.[`Battery${bi}PackTotalCurrent`] ?? 0,
Power: device?.[`Battery${bi}Power`] ?? 0,
Soc:
device?.[`Battery${bi}Soc`] ??
device?.[`Battery${bi}SocSecondvalue`] ??
0
}
};
}
// Growatt: flat Battery1, Battery2, ... on InverterRecord
const index = clIdx + 1;
return {
BatteryId: String(i + 1),
battery: {
Voltage: device?.[`Battery${batteryIndex}PackTotalVoltage`] ?? 0,
Current: device?.[`Battery${batteryIndex}PackTotalCurrent`] ?? 0,
Power: device?.[`Battery${batteryIndex}Power`] ?? 0,
Soc: device?.[`Battery${batteryIndex}Soc`] ?? device?.[`Battery${batteryIndex}SocSecondvalue`] ?? 0,
}
};
} else {
// Growatt: flat Battery1, Battery2, ...
const index = i + 1;
return {
BatteryId: String(index),
BatteryId: String(flatIdx + 1),
label,
battery: {
Voltage: inverter[`Battery${index}Voltage`] ?? 0,
Current: inverter[`Battery${index}Current`] ?? 0,
Power: inverter[`Battery${index}Power`] ?? 0,
Soc: inverter[`Battery${index}Soc`] ?? 0,
Soc: inverter[`Battery${index}Soc`] ?? 0
}
};
}
})
})
: [];
const [loading, setLoading] = useState(sortedBatteryView.length == 0);
@ -193,6 +233,8 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
s3Credentials={props.s3Credentials}
id={props.installationId}
batteryClusterNumber={props.installation.batteryClusterNumber}
activeClusters={activeClusters}
showInverterLabel={showInverterLabel}
></MainStatsSodioHome>
}
/>
@ -225,7 +267,7 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
</TableRow>
</TableHead>
<TableBody>
{sortedBatteryView.map(({ BatteryId, battery }) => (
{sortedBatteryView.map(({ BatteryId, label, battery }) => (
<TableRow
key={BatteryId}
style={{
@ -243,7 +285,7 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
style={{ color: 'black' }}
to={routes.detailed_view + BatteryId}
>*/}
{'Battery Cluster ' + BatteryId}
{label}
{/*</Link>*/}
</TableCell>
<TableCell

View File

@ -26,11 +26,14 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import CircularProgress from '@mui/material/CircularProgress';
import { useLocation, useNavigate } from 'react-router-dom';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { ActiveCluster } from '../Information/installationSetupUtils';
interface MainStatsSodioHomeProps {
s3Credentials: I_S3Credentials;
id: number;
batteryClusterNumber: number;
activeClusters?: ActiveCluster[];
showInverterLabel?: boolean;
}
function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
@ -115,7 +118,8 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
TimeSpan.fromDays(1)
),
UnixTime.fromTicks(new Date().getTime() / 1000),
props.batteryClusterNumber
props.batteryClusterNumber,
props.activeClusters
);
resultPromise
@ -146,9 +150,15 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
function generateSeries(chartData, category, color) {
const series = [];
const pathsToSearch = [];
for (let i = 0; i < props.batteryClusterNumber; i++) {
pathsToSearch.push('Node' + i);
const pathsToSearch: string[] = [];
if (props.activeClusters && props.activeClusters.length > 0) {
props.activeClusters.forEach((c) => {
pathsToSearch.push('Node' + c.flatIdx);
});
} else {
for (let i = 0; i < props.batteryClusterNumber; i++) {
pathsToSearch.push('Node' + i);
}
}
const total = pathsToSearch.length;
@ -207,7 +217,8 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
2,
UnixTime.fromTicks(startDate.unix()),
UnixTime.fromTicks(endDate.unix()),
props.batteryClusterNumber
props.batteryClusterNumber,
props.activeClusters
);
resultPromise
@ -270,7 +281,8 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
2,
UnixTime.fromTicks(startX).earlier(TimeSpan.fromHours(2)),
UnixTime.fromTicks(endX).earlier(TimeSpan.fromHours(2)),
props.batteryClusterNumber
props.batteryClusterNumber,
props.activeClusters
);
resultPromise

View File

@ -0,0 +1,321 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Box,
Checkbox,
Chip,
FormControl,
FormControlLabel,
IconButton,
MenuItem,
Select,
Stack,
TableCell,
TableRow,
TextField,
Tooltip,
Typography
} from '@mui/material';
import EmailIcon from '@mui/icons-material/Email';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { FormattedMessage, useIntl } from 'react-intl';
import {
ChecklistItem,
ChecklistStatus,
ChecklistSubtask,
parseSubtasks,
serializeSubtasks,
UPLOADABLE_SUBTASK_KEYS
} from 'src/interfaces/ChecklistTypes';
import { AdminUser } from 'src/interfaces/TicketTypes';
import SubtaskDocumentUpload from './SubtaskDocumentUpload';
type EmailIconState = 'idle' | 'loading' | 'success' | 'error';
interface Props {
item: ChecklistItem;
installationId: number;
adminUsers: AdminUser[];
onUpdate: (
id: number,
patch: Partial<{
status: number;
comments: string;
assigneeId: number | null;
doneAt: string | null;
subtasks: string;
}>
) => Promise<boolean>;
onNotify: (id: number) => Promise<boolean>;
}
const statusColors: Record<number, string> = {
[ChecklistStatus.NotStarted]: '#9e9e9e',
[ChecklistStatus.InProgress]: '#ed6c02',
[ChecklistStatus.Done]: '#2e7d32'
};
function ChecklistStepRow({ item, installationId, adminUsers, onUpdate, onNotify }: Props) {
const intl = useIntl();
const [comments, setComments] = useState(item.comments ?? '');
const [emailState, setEmailState] = useState<EmailIconState>('idle');
const [subtasksOpen, setSubtasksOpen] = useState(false);
const [subtasks, setSubtasks] = useState<ChecklistSubtask[]>(() =>
parseSubtasks(item.subtasks)
);
useEffect(() => {
setComments(item.comments ?? '');
}, [item.comments]);
useEffect(() => {
setSubtasks(parseSubtasks(item.subtasks));
}, [item.subtasks]);
const subtaskSummary = useMemo(() => {
if (subtasks.length === 0) return null;
const done = subtasks.filter((s) => s.checked).length;
return `${done}/${subtasks.length}`;
}, [subtasks]);
const handleStatusChange = async (value: number) => {
await onUpdate(item.id, { status: value });
};
const handleAssigneeChange = async (rawValue: string) => {
if (rawValue === '') {
await onUpdate(item.id, { assigneeId: null });
} else {
await onUpdate(item.id, { assigneeId: Number(rawValue) });
}
};
const handleDoneAtChange = async (value: string) => {
await onUpdate(item.id, { doneAt: value || null });
};
const handleCommentsBlur = async () => {
if (comments !== (item.comments ?? '')) {
await onUpdate(item.id, { comments });
}
};
const handleSubtaskToggle = async (index: number) => {
const updated = subtasks.map((s, i) =>
i === index ? { ...s, checked: !s.checked } : s
);
setSubtasks(updated);
await onUpdate(item.id, { subtasks: serializeSubtasks(updated) });
};
const handleNotifyClick = async () => {
if (!item.assigneeId || emailState === 'loading') return;
setEmailState('loading');
const ok = await onNotify(item.id);
setEmailState(ok ? 'success' : 'error');
setTimeout(() => setEmailState('idle'), 2500);
};
const renderEmailIcon = () => {
if (emailState === 'success') return <CheckIcon sx={{ color: '#2e7d32' }} />;
if (emailState === 'error') return <CloseIcon sx={{ color: '#d32f2f' }} />;
return <EmailIcon />;
};
const filteredAdmins = useMemo(
() =>
adminUsers.filter((u) => {
const name = (u.name ?? '').toLowerCase();
return (
!name.includes('inesco energy master admin') &&
!name.includes('paal myhre')
);
}),
[adminUsers]
);
return (
<>
<TableRow hover>
<TableCell sx={{ width: 48, verticalAlign: 'top' }}>
<Typography variant="body2" fontWeight={600}>
{item.stepNumber}
</Typography>
</TableCell>
<TableCell sx={{ verticalAlign: 'top', minWidth: 240 }}>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2">
{intl.formatMessage({
id: `checklistStep${item.stepNumber}`,
defaultMessage: item.stepTitle
})}
</Typography>
{subtasks.length > 0 && (
<>
<Chip
size="small"
label={subtaskSummary}
color={
subtasks.every((s) => s.checked) ? 'success' : 'default'
}
/>
<IconButton
size="small"
onClick={() => setSubtasksOpen((o) => !o)}
aria-label={intl.formatMessage({
id: 'checklistToggleSubtasks',
defaultMessage: 'Toggle subtasks'
})}
>
{subtasksOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</>
)}
</Stack>
</TableCell>
<TableCell sx={{ verticalAlign: 'top', width: 160 }}>
<FormControl size="small" fullWidth>
<Select
value={item.status}
onChange={(e) => handleStatusChange(Number(e.target.value))}
sx={{
'& .MuiSelect-select': {
color: statusColors[item.status] ?? 'inherit',
fontWeight: 600
}
}}
>
<MenuItem value={ChecklistStatus.NotStarted}>
<FormattedMessage id="checklistNotStarted" defaultMessage="Not Started" />
</MenuItem>
<MenuItem value={ChecklistStatus.InProgress}>
<FormattedMessage id="checklistInProgress" defaultMessage="In Progress" />
</MenuItem>
<MenuItem value={ChecklistStatus.Done}>
<FormattedMessage id="checklistDone" defaultMessage="Done" />
</MenuItem>
</Select>
</FormControl>
</TableCell>
<TableCell sx={{ verticalAlign: 'top', width: 260 }}>
<Stack direction="row" spacing={0.5} alignItems="center">
<FormControl size="small" sx={{ flex: 1 }}>
<Select
value={item.assigneeId ?? ''}
displayEmpty
onChange={(e) => handleAssigneeChange(String(e.target.value))}
>
<MenuItem value="">
<em>
<FormattedMessage
id="checklistNoAssignee"
defaultMessage="Unassigned"
/>
</em>
</MenuItem>
{filteredAdmins.map((u) => (
<MenuItem key={u.id} value={u.id}>
{u.name}
</MenuItem>
))}
</Select>
</FormControl>
<Tooltip
title={
!item.assigneeId
? intl.formatMessage({
id: 'checklistNotifyDisabledTooltip',
defaultMessage: 'Assign someone first to send a notification'
})
: intl.formatMessage({
id: 'checklistNotifyTooltip',
defaultMessage: 'Send email notification to assignee'
})
}
>
<span>
<IconButton
size="small"
disabled={!item.assigneeId || emailState === 'loading'}
onClick={handleNotifyClick}
>
{renderEmailIcon()}
</IconButton>
</span>
</Tooltip>
</Stack>
</TableCell>
<TableCell sx={{ verticalAlign: 'top', width: 160 }}>
<TextField
size="small"
type="date"
value={item.doneAt ?? ''}
onChange={(e) => handleDoneAtChange(e.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
/>
</TableCell>
<TableCell sx={{ verticalAlign: 'top', minWidth: 280 }}>
<TextField
size="small"
multiline
minRows={1}
maxRows={6}
value={comments}
onChange={(e) => setComments(e.target.value)}
onBlur={handleCommentsBlur}
fullWidth
placeholder={intl.formatMessage({
id: 'checklistCommentsPlaceholder',
defaultMessage: 'Notes, contact info, observations…'
})}
/>
</TableCell>
</TableRow>
{subtasks.length > 0 && subtasksOpen && (
<TableRow>
<TableCell colSpan={6} sx={{ backgroundColor: '#fafafa', py: 1 }}>
<Box pl={6} display="flex" flexDirection="column">
{subtasks.map((s, i) => (
<Box key={i}>
<FormControlLabel
control={
<Checkbox
size="small"
checked={s.checked}
onChange={() => handleSubtaskToggle(i)}
/>
}
label={
<Typography variant="body2" component="span">
{intl.formatMessage({ id: s.text, defaultMessage: s.text })}
</Typography>
}
sx={{ ml: 0 }}
/>
{UPLOADABLE_SUBTASK_KEYS.has(s.text) && (
<SubtaskDocumentUpload
installationId={installationId}
checklistItemId={item.id}
subtaskKey={s.text}
/>
)}
</Box>
))}
</Box>
</TableCell>
</TableRow>
)}
</>
);
}
export default ChecklistStepRow;

View File

@ -0,0 +1,265 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Box,
LinearProgress,
Snackbar,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
Paper
} from '@mui/material';
import { FormattedMessage, useIntl } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import {
ChecklistItem,
ChecklistStatus
} from 'src/interfaces/ChecklistTypes';
import { AdminUser } from 'src/interfaces/TicketTypes';
import ChecklistStepRow from './ChecklistStepRow';
interface Props {
installationId: number;
}
type ToastState = {
open: boolean;
severity: 'success' | 'error';
message: string;
};
function InstallationChecklistTab({ installationId }: Props) {
const intl = useIntl();
const [items, setItems] = useState<ChecklistItem[]>([]);
const [adminUsers, setAdminUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [toast, setToast] = useState<ToastState>({
open: false,
severity: 'success',
message: ''
});
const fetchItems = useCallback(() => {
setLoading(true);
axiosConfig
.get('/GetChecklistForInstallation', { params: { installationId } })
.then((res) => {
setItems(Array.isArray(res.data) ? res.data : []);
setError('');
})
.catch(() => setError('Failed to load checklist.'))
.finally(() => setLoading(false));
}, [installationId]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
useEffect(() => {
axiosConfig
.get('/GetAdminUsers')
.then((res) => {
if (Array.isArray(res.data)) setAdminUsers(res.data);
})
.catch(() => setAdminUsers([]));
}, []);
const progress = useMemo(() => {
const total = items.length;
const done = items.filter(
(i) => i.status === ChecklistStatus.Done
).length;
const percent = total === 0 ? 0 : Math.round((done / total) * 100);
return { total, done, percent };
}, [items]);
const handleUpdate = useCallback(
async (
id: number,
patch: Partial<{
status: number;
comments: string;
assigneeId: number | null;
doneAt: string | null;
subtasks: string;
}>
) => {
const params: Record<string, unknown> = { checklistItemId: id };
if (patch.status !== undefined) params.status = patch.status;
if (patch.comments !== undefined) params.comments = patch.comments;
if (patch.subtasks !== undefined) params.subtasks = patch.subtasks;
if ('assigneeId' in patch) {
if (patch.assigneeId === null) {
params.clearAssignee = true;
} else if (typeof patch.assigneeId === 'number') {
params.assigneeId = patch.assigneeId;
}
}
if ('doneAt' in patch) {
params.doneAt = patch.doneAt ?? '';
}
try {
const res = await axiosConfig.put('/UpdateChecklistItem', null, {
params
});
const updated = res.data as ChecklistItem;
setItems((prev) =>
prev.map((it) => (it.id === id ? updated : it))
);
return true;
} catch {
setToast({
open: true,
severity: 'error',
message: intl.formatMessage({
id: 'checklistSaveFailed',
defaultMessage: 'Failed to save change'
})
});
return false;
}
},
[intl]
);
const handleNotify = useCallback(
async (id: number) => {
const item = items.find((i) => i.id === id);
if (!item || !item.assigneeId) return false;
const assignee = adminUsers.find((u) => u.id === item.assigneeId);
const assigneeName = assignee?.name ?? '';
try {
await axiosConfig.post('/NotifyChecklistAssignee', null, {
params: { checklistItemId: id }
});
setToast({
open: true,
severity: 'success',
message: intl.formatMessage(
{
id: 'checklistEmailSent',
defaultMessage: 'Email sent to {name}'
},
{ name: assigneeName }
)
});
return true;
} catch {
setToast({
open: true,
severity: 'error',
message: intl.formatMessage({
id: 'checklistEmailFailed',
defaultMessage: 'Failed to send email — try again'
})
});
return false;
}
},
[items, adminUsers, intl]
);
if (loading) {
return (
<Box p={3}>
<LinearProgress />
</Box>
);
}
if (error) {
return (
<Box p={3}>
<Alert severity="error">{error}</Alert>
</Box>
);
}
return (
<Box p={2}>
<Box mb={2}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
<Typography variant="h4">
<FormattedMessage
id="checklistTitle"
defaultMessage="Steps to Bring Installation to Monitor"
/>
</Typography>
<Typography variant="body2" color="text.secondary">
<FormattedMessage
id="checklistProgress"
defaultMessage="Progress: {done}/{total} ({percent}%)"
values={progress}
/>
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={progress.percent}
sx={{ height: 8, borderRadius: 4 }}
/>
</Box>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>#</TableCell>
<TableCell>
<FormattedMessage id="checklistStep" defaultMessage="Step" />
</TableCell>
<TableCell>
<FormattedMessage id="checklistStatus" defaultMessage="Status" />
</TableCell>
<TableCell>
<FormattedMessage id="checklistAssignee" defaultMessage="Assignee" />
</TableCell>
<TableCell>
<FormattedMessage id="checklistDateDone" defaultMessage="Date Done" />
</TableCell>
<TableCell>
<FormattedMessage id="checklistComments" defaultMessage="Comments" />
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item) => (
<ChecklistStepRow
key={item.id}
item={item}
installationId={installationId}
adminUsers={adminUsers}
onUpdate={handleUpdate}
onNotify={handleNotify}
/>
))}
</TableBody>
</Table>
</TableContainer>
<Snackbar
open={toast.open}
autoHideDuration={4000}
onClose={() => setToast((t) => ({ ...t, open: false }))}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={toast.severity}
onClose={() => setToast((t) => ({ ...t, open: false }))}
>
{toast.message}
</Alert>
</Snackbar>
</Box>
);
}
export default InstallationChecklistTab;

View File

@ -0,0 +1,56 @@
import React from 'react';
import { Box, LinearProgress, Tooltip, Typography } from '@mui/material';
import { FormattedMessage, useIntl } from 'react-intl';
interface Props {
done: number | null;
total: number | null;
}
function phaseId(done: number, total: number): string {
if (total === 0) return 'checklistPhaseEmpty';
if (done >= total) return 'checklistPhaseComplete';
if (done <= 5) return 'checklistPhasePreparation';
if (done <= 12) return 'checklistPhaseOnSite';
return 'checklistPhaseHandover';
}
function SetupProgress({ done, total }: Props) {
const intl = useIntl();
if (done === null || total === null || total === 0) {
return (
<Typography variant="body2" color="text.secondary" sx={{ fontSize: 'small' }}>
--
</Typography>
);
}
const percent = Math.round((done / total) * 100);
const color = percent >= 100 ? 'success' : percent > 0 ? 'warning' : 'inherit';
const tooltip = intl.formatMessage({
id: phaseId(done, total),
defaultMessage: 'Progress'
});
return (
<Tooltip title={tooltip}>
<Box display="flex" alignItems="center" sx={{ minWidth: 120 }}>
<Box sx={{ width: 70, mr: 1 }}>
<LinearProgress
variant="determinate"
value={percent}
color={color === 'inherit' ? 'primary' : (color as any)}
sx={{ height: 6, borderRadius: 3 }}
/>
</Box>
<Typography variant="body2" sx={{ fontSize: 'small', whiteSpace: 'nowrap' }}>
{done}/{total}
</Typography>
</Box>
</Tooltip>
);
}
export default SetupProgress;

View File

@ -0,0 +1,124 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Box, IconButton, Stack, Typography } from '@mui/material';
import DownloadIcon from '@mui/icons-material/Download';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import FileUploadButton, {
UploadedDocument
} from 'src/components/FileUploadButton';
interface DocumentItem {
id: number;
originalName: string;
contentType: string;
sizeBytes: number;
createdAt: string;
}
interface Props {
installationId: number;
checklistItemId: number;
subtaskKey: string;
}
function fileIcon(contentType: string) {
if (contentType === 'application/pdf')
return <PictureAsPdfIcon fontSize="small" color="error" />;
return <InsertDriveFileIcon fontSize="small" />;
}
function SubtaskDocumentUpload({ installationId, checklistItemId, subtaskKey }: Props) {
const [docs, setDocs] = useState<DocumentItem[]>([]);
const [refreshKey, setRefreshKey] = useState(0);
const fetchDocs = useCallback(() => {
axiosConfig
.get('/GetDocuments', { params: { checklistItemId, subtaskKey } })
.then((res) => {
if (Array.isArray(res.data)) setDocs(res.data);
})
.catch(() => setDocs([]));
}, [checklistItemId, subtaskKey]);
useEffect(() => {
fetchDocs();
}, [fetchDocs, refreshKey]);
const handleDownload = (doc: DocumentItem) => {
axiosConfig
.get('/DownloadDocument', {
params: { id: doc.id },
responseType: 'blob'
})
.then((res) => {
const url = window.URL.createObjectURL(new Blob([res.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', doc.originalName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
})
.catch(() => {});
};
const handleUploaded = (_doc: UploadedDocument) => {
setRefreshKey((k) => k + 1);
};
return (
<Box mt={0.5} mb={1}>
{docs.length > 0 && (
<Stack spacing={0.25} mb={0.75}>
{docs.map((d) => (
<Stack
key={d.id}
direction="row"
alignItems="center"
spacing={1}
sx={{ pl: 4 }}
>
{fileIcon(d.contentType)}
<Typography variant="body2" sx={{ flex: 1 }} noWrap>
{d.originalName}
</Typography>
<IconButton
size="small"
onClick={() => handleDownload(d)}
aria-label="download"
>
<DownloadIcon fontSize="small" />
</IconButton>
</Stack>
))}
</Stack>
)}
<Box sx={{ pl: 4 }}>
<FileUploadButton
scope={1}
installationId={installationId}
checklistItemId={checklistItemId}
subtaskKey={subtaskKey}
onUploaded={handleUploaded}
/>
</Box>
{docs.length === 0 && (
<Typography
variant="caption"
color="text.secondary"
sx={{ pl: 4, display: 'block', mt: 0.5 }}
>
<FormattedMessage
id="checklistNoAttachments"
defaultMessage="No file attached yet."
/>
</Typography>
)}
</Box>
);
}
export default SubtaskDocumentUpload;

View File

@ -51,7 +51,7 @@ function DocumentsTab({ installationId }: DocumentsTabProps) {
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
<FormattedMessage
id="documentsHint"
defaultMessage="Accepted formats: JPEG, PNG, GIF, WebP, PDF. Maximum file size: 25 MB."
defaultMessage="Accepted formats: JPEG, PNG, GIF, WebP, PDF, MP4, MOV, WebM. Maximum file size: 100 MB."
/>
</Typography>
</CardContent>

View File

@ -115,6 +115,32 @@ export const computeFlatValues = (
};
};
export interface ActiveCluster {
invIdx: number;
clIdx: number;
flatIdx: number;
}
export const getActiveClusters = (raw: string): ActiveCluster[] => {
if (!raw || raw.trim() === '') return [];
if (!raw.includes('/') && !raw.includes('|')) return [];
const result: ActiveCluster[] = [];
let flatIdx = 0;
raw.split('/').forEach((invStr, invIdx) => {
invStr.split('|').forEach((clStr, clIdx) => {
const hasSn = clStr
.split(',')
.some((s) => s.trim() !== '');
if (hasSn) {
result.push({ invIdx, clIdx, flatIdx });
}
flatIdx += 1;
});
});
return result;
};
export const wouldLoseData = (
oldTree: BatterySnTree,
newPreset: PresetConfig

View File

@ -27,6 +27,14 @@ import { useLocation, useNavigate } from 'react-router-dom';
import routes from '../../../Resources/routes.json';
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone';
import { UserContext } from 'src/contexts/userContext';
import { UserType } from 'src/interfaces/UserTypes';
import axiosConfig from 'src/Resources/axiosConfig';
import {
CHECKLIST_ENABLED_PRODUCTS,
ChecklistSummary
} from 'src/interfaces/ChecklistTypes';
import SetupProgress from '../Checklist/SetupProgress';
interface FlatInstallationViewProps {
installations: I_Installation[];
@ -46,6 +54,28 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
const [searchTerm, setSearchTerm] = useState('');
const [sortByStatus, setSortByStatus] = useState('All Installations');
const [sortByAction, setSortByAction] = useState('All Installations');
const { currentUser } = useContext(UserContext);
const showChecklistColumn =
currentUser?.userType === UserType.admin &&
CHECKLIST_ENABLED_PRODUCTS.has(product ?? -1);
const [progressMap, setProgressMap] = useState<Map<number, ChecklistSummary>>(
new Map()
);
useEffect(() => {
if (!showChecklistColumn) return;
axiosConfig
.get('/GetChecklistSummary')
.then((res) => {
if (!Array.isArray(res.data)) return;
const map = new Map<number, ChecklistSummary>();
res.data.forEach((s: ChecklistSummary) => {
map.set(s.installationId, s);
});
setProgressMap(map);
})
.catch(() => setProgressMap(new Map()));
}, [showChecklistColumn, currentLocation.pathname]);
const HoverableTableRow = styled(TableRow)(({ theme }) => ({
cursor: 'pointer',
@ -96,8 +126,12 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
break;
}
// Sort by status (alarms first)
// Sort by status (alarms first); data-collection-disabled sinks below offline.
return filtered.sort((a, b) => {
const aDisabled = a.dataCollectionEnabled === false;
const bDisabled = b.dataCollectionEnabled === false;
if (aDisabled !== bDisabled) return aDisabled ? 1 : -1;
const a_status = a.status;
const b_status = b.status;
@ -311,6 +345,14 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
<TableCell>
<FormattedMessage id="status" defaultMessage="Status" />
</TableCell>
{showChecklistColumn && (
<TableCell>
<FormattedMessage
id="setupProgress"
defaultMessage="Setup Progress"
/>
</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
@ -457,6 +499,19 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
)}
</div>
</TableCell>
{showChecklistColumn && (
<TableCell>
{(() => {
const summary = progressMap.get(installation.id);
return (
<SetupProgress
done={summary?.done ?? 0}
total={summary?.total ?? 16}
/>
);
})()}
</TableCell>
)}
</TableRow>
);
})}

View File

@ -31,6 +31,8 @@ import Configuration from '../Configuration/Configuration';
import PvView from '../PvView/PvView';
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
import DocumentsTab from '../Documents/DocumentsTab';
import InstallationChecklistTab from '../Checklist/InstallationChecklistTab';
import { CHECKLIST_ENABLED_PRODUCTS } from 'src/interfaces/ChecklistTypes';
interface singleInstallationProps {
current_installation?: I_Installation;
@ -386,7 +388,8 @@ function Installation(props: singleInstallationProps) {
currentTab != 'history' &&
// currentTab != 'manage' &&
currentTab != 'log' &&
currentTab != 'installationTickets' && (
currentTab != 'installationTickets' &&
currentTab != 'checklist' && (
<Container
maxWidth="xl"
sx={{
@ -569,6 +572,18 @@ function Installation(props: singleInstallationProps) {
/>
)}
{currentUser.userType == UserType.admin &&
CHECKLIST_ENABLED_PRODUCTS.has(props.current_installation.product) && (
<Route
path={routes.checklist}
element={
<InstallationChecklistTab
installationId={props.current_installation.id}
/>
}
/>
)}
<Route
path={'*'}
element={<Navigate to={routes.live}></Navigate>}

View File

@ -34,7 +34,8 @@ function InstallationTabs(props: InstallationTabsProps) {
'history',
'pvview',
'installationTickets',
'documents'
'documents',
'checklist'
];
const [currentTab, setCurrentTab] = useState<string>(undefined);
@ -105,6 +106,10 @@ function InstallationTabs(props: InstallationTabsProps) {
// TODO: SodistoreGrid — PV View excluded for product 4, add back when data path is ready
const hidePvView = props.product === 4;
// Checklist is only shown for Sodistore Grid (product=4) in the Installations view.
// Salimax (0) / Salidomo (1) / SodistoreMax (3) use different onboarding flows.
const showChecklist = props.product === 4;
const singleInstallationTabs = (
currentUser.userType == UserType.admin
? [
@ -175,6 +180,10 @@ function InstallationTabs(props: InstallationTabsProps) {
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
},
{
value: 'checklist',
label: <FormattedMessage id="checklist" defaultMessage="Checklist" />
}
]
: currentUser.userType == UserType.partner
@ -229,7 +238,9 @@ function InstallationTabs(props: InstallationTabsProps) {
)
}
]
).filter((tab) => !(hidePvView && tab.value === 'pvview'));
)
.filter((tab) => !(hidePvView && tab.value === 'pvview'))
.filter((tab) => !(!showChecklist && tab.value === 'checklist'));
const tabs =
currentTab != 'list' &&
@ -316,6 +327,10 @@ function InstallationTabs(props: InstallationTabsProps) {
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
},
{
value: 'checklist',
label: <FormattedMessage id="checklist" defaultMessage="Checklist" />
}
]
: currentUser.userType == UserType.partner
@ -410,9 +425,10 @@ function InstallationTabs(props: InstallationTabsProps) {
];
// Filter out PV View for SodistoreGrid
const filteredTabs = hidePvView
const filteredTabs = (hidePvView
? tabs.filter((tab) => tab.value !== 'pvview')
: tabs;
: tabs
).filter((tab) => !(!showChecklist && tab.value === 'checklist'));
return installations.length > 1 ? (
<>

View File

@ -244,6 +244,8 @@ export const getChartOptions = (
const seriesName = w.config.series[seriesIndex].name;
if (seriesName === 'Battery SOC') {
return val.toFixed(2) + ' %';
} else if (seriesName === 'Battery Voltage') {
return val.toFixed(2) + ' (V)';
} else {
return (
formatPowerForGraph(val, chartInfo.magnitude).value.toFixed(

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useContext, useEffect, useMemo, useState } from 'react';
import {
Card,
CircularProgress,
@ -20,6 +20,14 @@ import routes from '../../../Resources/routes.json';
import CancelIcon from '@mui/icons-material/Cancel';
import BuildIcon from '@mui/icons-material/Build';
import { getDeviceTypeName } from '../Information/installationSetupUtils';
import { UserContext } from 'src/contexts/userContext';
import { UserType } from 'src/interfaces/UserTypes';
import axiosConfig from 'src/Resources/axiosConfig';
import {
CHECKLIST_ENABLED_PRODUCTS,
ChecklistSummary
} from 'src/interfaces/ChecklistTypes';
import SetupProgress from '../Checklist/SetupProgress';
interface FlatInstallationViewProps {
installations: I_Installation[];
@ -31,34 +39,66 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
const currentLocation = useLocation();
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
//
const sortedInstallations = [...props.installations].sort((a, b) => {
// Compare the status field of each installation and sort them based on the status.
//Installations with alarms go first
let a_status = a.status;
let b_status = b.status;
if (a_status > b_status) {
return -1;
}
if (a_status < b_status) {
return 1;
}
return 0;
});
const { currentUser } = useContext(UserContext);
const showChecklistColumn =
currentUser?.userType === UserType.admin &&
CHECKLIST_ENABLED_PRODUCTS.has(props.product ?? -1);
const isListViewPath =
currentLocation.pathname === baseRoute + 'list' ||
currentLocation.pathname === baseRoute + routes.list;
const [progressMap, setProgressMap] = useState<Map<number, ChecklistSummary>>(
new Map()
);
useEffect(() => {
if (!showChecklistColumn || !isListViewPath) return;
axiosConfig
.get('/GetChecklistSummary')
.then((res) => {
if (!Array.isArray(res.data)) return;
const map = new Map<number, ChecklistSummary>();
res.data.forEach((s: ChecklistSummary) => {
map.set(s.installationId, s);
});
setProgressMap(map);
})
.catch(() => setProgressMap(new Map()));
}, [showChecklistColumn, isListViewPath]);
const sortedInstallations = useMemo(() => {
return [...props.installations].sort((a, b) => {
// Data-collection-disabled installations sink below everything (even offline).
const aDisabled = a.dataCollectionEnabled === false;
const bDisabled = b.dataCollectionEnabled === false;
if (aDisabled !== bDisabled) return aDisabled ? 1 : -1;
// Then sort by status (alarms first)
const a_status = a.status;
const b_status = b.status;
if (a_status > b_status) return -1;
if (a_status < b_status) return 1;
return 0;
});
}, [props.installations]);
const handleSelectOneInstallation = (installationID: number): void => {
if (selectedInstallation != installationID) {
setSelectedInstallation(installationID);
setSelectedInstallation(-1);
const target = props.installations.find((i) => i.id === installationID);
const landingTab =
target?.dataCollectionEnabled === false ? routes.information : routes.live;
navigate(
baseRoute +
routes.list +
routes.installation +
`${installationID}` +
'/' +
routes.live,
landingTab,
{
replace: true
}
@ -77,18 +117,14 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
}
}));
const isListView = isListViewPath;
return (
<Grid container spacing={1} sx={{ marginTop: 0.1 }}>
<Grid
item
sx={{
display:
currentLocation.pathname ===
baseRoute + 'list' ||
currentLocation.pathname ===
baseRoute + routes.list
? 'block'
: 'none'
display: isListView ? 'block' : 'none'
}}
>
<Card>
@ -114,6 +150,14 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
<TableCell>
<FormattedMessage id="status" defaultMessage="Status" />
</TableCell>
{showChecklistColumn && (
<TableCell>
<FormattedMessage
id="setupProgress"
defaultMessage="Setup Progress"
/>
</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
@ -278,6 +322,19 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
)}
</div>
</TableCell>
{showChecklistColumn && (
<TableCell>
{(() => {
const summary = progressMap.get(installation.id);
return (
<SetupProgress
done={summary?.done ?? 0}
total={summary?.total ?? 16}
/>
);
})()}
</TableCell>
)}
</HoverableTableRow>
);
})}

View File

@ -30,6 +30,8 @@ import Overview from '../Overview/overview';
import WeeklyReport from './WeeklyReport';
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
import DocumentsTab from '../Documents/DocumentsTab';
import InstallationChecklistTab from '../Checklist/InstallationChecklistTab';
import { CHECKLIST_ENABLED_PRODUCTS } from 'src/interfaces/ChecklistTypes';
interface singleInstallationProps {
current_installation?: I_Installation;
@ -489,12 +491,15 @@ function SodioHomeInstallation(props: singleInstallationProps) {
</div>
</div>
{loading &&
!dataCollectionDisabled &&
currentTab != 'information' &&
// currentTab != 'manage' &&
currentTab != 'history' &&
currentTab != 'log' &&
currentTab != 'report' &&
currentTab != 'installationTickets' && (
currentTab != 'installationTickets' &&
currentTab != 'documents' &&
currentTab != 'checklist' && (
<Container
maxWidth="xl"
sx={{
@ -668,9 +673,21 @@ function SodioHomeInstallation(props: singleInstallationProps) {
/>
)}
{currentUser.userType == UserType.admin &&
CHECKLIST_ENABLED_PRODUCTS.has(props.current_installation.product) && (
<Route
path={routes.checklist}
element={
<InstallationChecklistTab
installationId={props.current_installation.id}
/>
}
/>
)}
<Route
path={'*'}
element={<Navigate to={routes.live}></Navigate>}
element={<Navigate to={dataCollectionDisabled ? routes.information : routes.live}></Navigate>}
/>
</Routes>
</Grid>

View File

@ -1,7 +1,15 @@
import React, { useMemo, useState } from 'react';
import { FormControl, Grid, InputAdornment, TextField } from '@mui/material';
import {
FormControl,
Grid,
InputAdornment,
InputLabel,
MenuItem,
Select,
TextField
} from '@mui/material';
import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone';
import { useIntl } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import { I_Installation } from '../../../interfaces/InstallationTypes';
import { Route, Routes, useLocation } from 'react-router-dom';
import routes from '../../../Resources/routes.json';
@ -16,9 +24,10 @@ interface installationSearchProps {
function InstallationSearch(props: installationSearchProps) {
const intl = useIntl();
const [searchTerm, setSearchTerm] = useState('');
const [sortByStatus, setSortByStatus] = useState('All Installations');
const [sortByAction, setSortByAction] = useState('All Installations');
const currentLocation = useLocation();
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
// const [filteredData, setFilteredData] = useState(props.installations);
const indexedData = useMemo(() => {
return props.installations.map((item) => ({
@ -30,56 +39,126 @@ function InstallationSearch(props: installationSearchProps) {
}, [props.installations]);
const filteredData = useMemo(() => {
return indexedData.filter(
let list = indexedData.filter(
(item) =>
item.nameLower.includes(searchTerm.toLowerCase()) ||
item.locationLower.includes(searchTerm.toLowerCase()) ||
item.regionLower.includes(searchTerm.toLowerCase())
);
}, [searchTerm, indexedData]);
switch (sortByStatus) {
case 'Installations With Alarm':
list = list.filter((i) => i.status === 2);
break;
case 'Installations with Warning':
list = list.filter((i) => i.status === 1);
break;
case 'Functional Installations':
list = list.filter((i) => i.status === 0);
break;
case 'Offline Installations':
list = list.filter((i) => i.status === -1);
break;
case 'Installations Without Data Collection':
list = list.filter((i) => i.dataCollectionEnabled === false);
break;
}
switch (sortByAction) {
case 'Installations With Action Flag':
list = list.filter((i) => i.testingMode === true);
break;
case 'Installations Without Action Flag':
list = list.filter((i) => i.testingMode === false);
break;
}
return list;
}, [searchTerm, indexedData, sortByStatus, sortByAction]);
const isListView =
currentLocation.pathname === baseRoute + 'list' ||
currentLocation.pathname === baseRoute + routes.list;
return (
<>
<Grid container>
<Grid
item
xs={12}
md={6}
sx={{
display:
currentLocation.pathname ===
baseRoute + 'list' ||
currentLocation.pathname ===
baseRoute + routes.list
? 'block'
: 'none'
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<FormControl variant="outlined">
<TextField
placeholder={intl.formatMessage({ id: 'search' })}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchTwoToneIcon />
</InputAdornment>
)
}}
/>
</FormControl>
</div>
{isListView && (
<Grid container>
<Grid item xs={12}>
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '16px',
width: '100%',
flexWrap: 'wrap'
}}
>
<FormControl variant="outlined" sx={{ width: 280 }}>
<TextField
placeholder={intl.formatMessage({ id: 'search' })}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchTwoToneIcon />
</InputAdornment>
)
}}
/>
</FormControl>
<FormControl sx={{ width: 280 }}>
<InputLabel>
<FormattedMessage id="sortByStatus" defaultMessage="Sort By Status" />
</InputLabel>
<Select
value={sortByStatus}
onChange={(e) => setSortByStatus(e.target.value)}
label={intl.formatMessage({ id: 'sortByStatus' })}
>
{[
'All Installations',
'Installations With Alarm',
'Installations with Warning',
'Functional Installations',
'Offline Installations',
'Installations Without Data Collection'
].map((type) => (
<MenuItem key={type} value={type}>
{type}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl sx={{ width: 280 }}>
<InputLabel>
<FormattedMessage id="sortByActionFlag" defaultMessage="Sort By Action Flag" />
</InputLabel>
<Select
value={sortByAction}
onChange={(e) => setSortByAction(e.target.value)}
label={intl.formatMessage({ id: 'sortByActionFlag' })}
>
{[
'All Installations',
'Installations With Action Flag',
'Installations Without Action Flag'
].map((type) => (
<MenuItem key={type} value={type}>
{type}
</MenuItem>
))}
</Select>
</FormControl>
</div>
</Grid>
</Grid>
</Grid>
)}
<FlatInstallationView installations={filteredData} product={props.product} />
<Routes>

View File

@ -53,7 +53,8 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
'configuration',
'report',
'installationTickets',
'documents'
'documents',
'checklist'
];
const [currentTab, setCurrentTab] = useState<string>(undefined);
@ -192,6 +193,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
},
{
value: 'checklist',
label: <FormattedMessage id="checklist" defaultMessage="Checklist" />
}
]
: currentUser.userType == UserType.partner
@ -279,7 +284,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
const dataCollectionDisabled =
currentInstallation?.dataCollectionEnabled === false
|| (installations.length === 1 && installations[0].dataCollectionEnabled === false);
const allowedWhenDisabled = ['list', 'tree', 'information', 'history', 'installationTickets', 'documents'];
const allowedWhenDisabled = ['list', 'tree', 'information', 'history', 'installationTickets', 'documents', 'checklist'];
const tabs = inInstallationView && currentUser.userType == UserType.admin
? [
@ -361,6 +366,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
},
{
value: 'checklist',
label: <FormattedMessage id="checklist" defaultMessage="Checklist" />
}
]
: inInstallationView && currentUser.userType == UserType.partner

View File

@ -0,0 +1,62 @@
import React from 'react';
import { Box, Button, Tooltip } from '@mui/material';
import FormatBoldIcon from '@mui/icons-material/FormatBold';
import { useIntl } from 'react-intl';
import { applyFormat, FormatKind } from './commentMarkdown';
interface CommentFormatToolbarProps {
textareaRef: React.RefObject<HTMLTextAreaElement | HTMLInputElement | null>;
value: string;
onChange: (next: string) => void;
disabled?: boolean;
}
function CommentFormatToolbar({
textareaRef,
value,
onChange,
disabled
}: CommentFormatToolbarProps) {
const intl = useIntl();
const handle = (kind: FormatKind) => () => {
applyFormat(textareaRef.current, value, kind, onChange);
};
const btnSx = { minWidth: 32, px: 1, py: 0.25, fontSize: 12, textTransform: 'none' as const };
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
<Tooltip title={intl.formatMessage({ id: 'commentFormatBold', defaultMessage: 'Bold' })}>
<span>
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('bold')} disabled={disabled}>
<FormatBoldIcon fontSize="inherit" />
</Button>
</span>
</Tooltip>
<Tooltip title={intl.formatMessage({ id: 'commentFormatH1', defaultMessage: 'Heading 1' })}>
<span>
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('h1')} disabled={disabled}>
H1
</Button>
</span>
</Tooltip>
<Tooltip title={intl.formatMessage({ id: 'commentFormatH2', defaultMessage: 'Heading 2' })}>
<span>
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('h2')} disabled={disabled}>
H2
</Button>
</span>
</Tooltip>
<Tooltip title={intl.formatMessage({ id: 'commentFormatH3', defaultMessage: 'Heading 3' })}>
<span>
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('h3')} disabled={disabled}>
H3
</Button>
</span>
</Tooltip>
</Box>
);
}
export default CommentFormatToolbar;

View File

@ -1,4 +1,5 @@
import React, { useRef, useState } from 'react';
import React, { useContext, useRef, useState } from 'react';
import { UserContext } from 'src/contexts/userContext';
import {
Avatar,
Box,
@ -24,6 +25,8 @@ import { FormattedMessage, useIntl } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
import DocumentList from 'src/components/DocumentList';
import CommentFormatToolbar from './CommentFormatToolbar';
import { renderCommentBody } from './commentMarkdown';
interface CommentThreadProps {
ticketId: number;
@ -39,8 +42,13 @@ function CommentThread({
adminUsers = []
}: CommentThreadProps) {
const intl = useIntl();
const userCtx = useContext(UserContext);
const currentUserId = userCtx?.currentUser?.id;
const [body, setBody] = useState('');
const [submitting, setSubmitting] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [editBody, setEditBody] = useState('');
const [savingEdit, setSavingEdit] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
@ -49,6 +57,7 @@ function CommentThread({
const [mentionedIds, setMentionedIds] = useState<number[]>([]);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const commentInputRef = useRef<HTMLInputElement | null>(null);
const editInputRef = useRef<HTMLInputElement | null>(null);
const MENTION_EXCLUDED_NAMES = ['inesco energy Master Admin'];
@ -108,8 +117,11 @@ function CommentThread({
}, 0);
};
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
const MAX_FILE_SIZE = 25 * 1024 * 1024;
const ALLOWED_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf',
'video/mp4', 'video/quicktime', 'video/webm'
];
const MAX_FILE_SIZE = 100 * 1024 * 1024;
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
@ -125,6 +137,32 @@ function CommentThread({
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
const startEdit = (comment: TicketComment) => {
setEditingId(comment.id);
setEditBody(comment.body);
};
const cancelEdit = () => {
setEditingId(null);
setEditBody('');
};
const saveEdit = async (commentId: number) => {
if (!editBody.trim()) return;
setSavingEdit(true);
try {
await axiosConfig.post('/UpdateTicketComment', {
id: commentId,
body: editBody
});
setEditingId(null);
setEditBody('');
onCommentAdded();
} finally {
setSavingEdit(false);
}
};
const handleSubmit = async () => {
if (!body.trim() && selectedFiles.length === 0) return;
setSubmitting(true);
@ -196,6 +234,8 @@ function CommentThread({
{sorted.map((comment) => {
const isAi = comment.authorType === CommentAuthorType.AiAgent;
const canEdit = !isAi && currentUserId != null && comment.authorId === currentUserId;
const isEditing = editingId === comment.id;
return (
<Box
key={comment.id}
@ -216,7 +256,7 @@ function CommentThread({
</Avatar>
<Box sx={{ flex: 1 }}>
<Box
sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}
sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flexWrap: 'wrap' }}
>
<Typography variant="subtitle2">
{isAi ? 'AI' : (adminUsers.find(u => u.id === comment.authorId)?.name ?? `User #${comment.authorId ?? '?'}`)}
@ -224,10 +264,64 @@ function CommentThread({
<Typography variant="caption" color="text.disabled">
{new Date(comment.createdAt).toLocaleString()}
</Typography>
{comment.editedAt && (
<Typography variant="caption" color="text.disabled" sx={{ fontStyle: 'italic' }}>
<FormattedMessage
id="commentEdited"
defaultMessage="(edited {time})"
values={{ time: new Date(comment.editedAt).toLocaleString() }}
/>
</Typography>
)}
{canEdit && !isEditing && (
<Button
size="small"
variant="text"
sx={{ minWidth: 0, p: 0, ml: 'auto', textTransform: 'none' }}
onClick={() => startEdit(comment)}
>
<FormattedMessage id="edit" defaultMessage="Edit" />
</Button>
)}
</Box>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{comment.body}
</Typography>
{isEditing ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 0.5 }}>
<CommentFormatToolbar
textareaRef={editInputRef}
value={editBody}
onChange={setEditBody}
disabled={savingEdit}
/>
<TextField
size="small"
fullWidth
multiline
minRows={2}
value={editBody}
onChange={(e) => setEditBody(e.target.value)}
inputRef={editInputRef}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button
size="small"
onClick={cancelEdit}
disabled={savingEdit}
>
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
<Button
size="small"
variant="contained"
onClick={() => saveEdit(comment.id)}
disabled={savingEdit || !editBody.trim()}
>
<FormattedMessage id="save" defaultMessage="Save" />
</Button>
</Box>
</Box>
) : (
renderCommentBody(comment.body)
)}
<DocumentList ticketCommentId={comment.id} refreshKey={refreshKey} />
</Box>
</Box>
@ -237,6 +331,20 @@ function CommentThread({
<Divider />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 1 }}>
<CommentFormatToolbar
textareaRef={commentInputRef}
value={body}
onChange={setBody}
disabled={submitting || uploading}
/>
<Typography variant="caption" color="text.disabled">
<FormattedMessage
id="commentMarkdownHint"
defaultMessage="Markdown: **bold**, #, ##, ###"
/>
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
size="small"
@ -277,7 +385,7 @@ function CommentThread({
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf"
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf,video/mp4,video/quicktime,video/webm"
multiple
style={{ display: 'none' }}
onChange={handleFileSelect}

View File

@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { UserContext } from 'src/contexts/userContext';
import { useNavigate, useParams } from 'react-router-dom';
import {
Alert,
@ -87,6 +88,12 @@ function TicketDetailPage() {
const [editingDescription, setEditingDescription] = useState(false);
const [savingDescription, setSavingDescription] = useState(false);
const [descriptionSaved, setDescriptionSaved] = useState(false);
const [subject, setSubject] = useState('');
const [editingSubject, setEditingSubject] = useState(false);
const [savingSubject, setSavingSubject] = useState(false);
const [subjectError, setSubjectError] = useState('');
const userCtx = useContext(UserContext);
const currentUser = userCtx?.currentUser;
const [docRefreshKey, setDocRefreshKey] = useState(0);
const [solveGateOpen, setSolveGateOpen] = useState(false);
const rootCauseRef = useRef<HTMLInputElement | null>(null);
@ -107,6 +114,7 @@ function TicketDetailPage() {
setRootCause(res.data.ticket.rootCause ?? '');
setSolution(res.data.ticket.solution ?? '');
setDescription(res.data.ticket.description ?? '');
setSubject(res.data.ticket.subject ?? '');
setEditCustomSub(res.data.ticket.customSubCategory ?? '');
setEditCustomCat(res.data.ticket.customCategory ?? '');
setError('');
@ -205,6 +213,25 @@ function TicketDetailPage() {
});
};
const handleSaveSubject = () => {
if (!detail) return;
const trimmed = subject.trim();
if (!trimmed) {
setSubjectError(intl.formatMessage({ id: 'subjectRequired', defaultMessage: 'Subject is required.' }));
return;
}
setSavingSubject(true);
setSubjectError('');
axiosConfig
.put('/UpdateTicket', { ...detail.ticket, subject: trimmed })
.then(() => {
fetchDetail();
setEditingSubject(false);
})
.catch(() => setSubjectError(intl.formatMessage({ id: 'failedToSaveSubject', defaultMessage: 'Failed to save subject.' })))
.finally(() => setSavingSubject(false));
};
const handleSaveDescription = () => {
if (!detail) return;
setSavingDescription(true);
@ -285,10 +312,66 @@ function TicketDetailPage() {
</Box>
<Box mb={3}>
<Typography variant="h3" gutterBottom>
#{ticket.id} {ticket.subject}
</Typography>
<Box display="flex" gap={1} alignItems="center">
{editingSubject ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 1 }}>
<Box display="flex" gap={1} alignItems="flex-start">
<Typography variant="h3" sx={{ whiteSpace: 'nowrap', pt: 0.5 }}>
#{ticket.id}
</Typography>
<TextField
fullWidth
autoFocus
value={subject}
onChange={(e) => {
setSubject(e.target.value);
setSubjectError('');
}}
error={!!subjectError}
helperText={subjectError}
/>
</Box>
<Box display="flex" justifyContent="flex-end" gap={1}>
<Button
size="small"
onClick={() => {
setEditingSubject(false);
setSubject(ticket.subject);
setSubjectError('');
}}
>
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
<Button
variant="contained"
size="small"
onClick={handleSaveSubject}
disabled={savingSubject}
>
<FormattedMessage id="save" defaultMessage="Save" />
</Button>
</Box>
</Box>
) : (
<Box display="flex" alignItems="center" gap={1} flexWrap="wrap">
<Typography variant="h3" gutterBottom sx={{ mb: 0 }}>
#{ticket.id} {ticket.subject}
</Typography>
{currentUser?.id === ticket.createdByUserId && (
<Button
size="small"
startIcon={<EditIcon />}
onClick={() => {
setSubject(ticket.subject);
setSubjectError('');
setEditingSubject(true);
}}
>
<FormattedMessage id="edit" defaultMessage="Edit" />
</Button>
)}
</Box>
)}
<Box display="flex" gap={1} alignItems="center" mt={1}>
<StatusChip status={ticket.status} size="medium" />
<Typography variant="body2" color="text.secondary">
{intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })} ·{' '}

View File

@ -20,7 +20,8 @@ const eventTypeKeys: Record<number, { id: string; defaultMessage: string }> = {
[TimelineEventType.CommentAdded]: { id: 'timelineCommentAdded', defaultMessage: 'Comment Added' },
[TimelineEventType.AiDiagnosisAttached]: { id: 'timelineAiDiagnosis', defaultMessage: 'AI Diagnosis' },
[TimelineEventType.Escalated]: { id: 'timelineEscalated', defaultMessage: 'Escalated' },
[TimelineEventType.ResolutionAdded]: { id: 'timelineResolutionAdded', defaultMessage: 'Resolution Added' }
[TimelineEventType.ResolutionAdded]: { id: 'timelineResolutionAdded', defaultMessage: 'Resolution Added' },
[TimelineEventType.SubjectChanged]: { id: 'timelineSubjectChanged', defaultMessage: 'Subject Changed' }
};
const eventTypeColors: Record<number, string> = {
@ -30,7 +31,8 @@ const eventTypeColors: Record<number, string> = {
[TimelineEventType.CommentAdded]: '#2e7d32',
[TimelineEventType.AiDiagnosisAttached]: '#0288d1',
[TimelineEventType.Escalated]: '#d32f2f',
[TimelineEventType.ResolutionAdded]: '#4caf50'
[TimelineEventType.ResolutionAdded]: '#4caf50',
[TimelineEventType.SubjectChanged]: '#7b1fa2'
};
interface TimelinePanelProps {

View File

@ -0,0 +1,85 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
export type FormatKind = 'bold' | 'h1' | 'h2' | 'h3';
function renderInline(text: string): React.ReactNode[] {
const parts = text.split(/\*\*(.+?)\*\*/g);
return parts.map((p, i) =>
i % 2 === 1 ? <strong key={i}>{p}</strong> : <React.Fragment key={i}>{p}</React.Fragment>
);
}
export function renderCommentBody(body: string): JSX.Element {
const lines = body.split('\n');
return (
<Box sx={{ '& > *': { mb: 0.5 } }}>
{lines.map((line, idx) => {
if (line.startsWith('### ')) {
return (
<Typography key={idx} variant="subtitle1" sx={{ fontWeight: 600, mt: 1 }}>
{renderInline(line.slice(4))}
</Typography>
);
}
if (line.startsWith('## ')) {
return (
<Typography key={idx} variant="h6" sx={{ mt: 1.5 }}>
{renderInline(line.slice(3))}
</Typography>
);
}
if (line.startsWith('# ')) {
return (
<Typography key={idx} variant="h5" sx={{ mt: 2 }}>
{renderInline(line.slice(2))}
</Typography>
);
}
return (
<Typography key={idx} variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{line ? renderInline(line) : '\u00A0'}
</Typography>
);
})}
</Box>
);
}
export function applyFormat(
el: HTMLTextAreaElement | HTMLInputElement | null,
value: string,
kind: FormatKind,
onChange: (next: string) => void
): void {
const start = el?.selectionStart ?? value.length;
const end = el?.selectionEnd ?? value.length;
if (kind === 'bold') {
const selected = value.slice(start, end);
const wrapped = `**${selected}**`;
const next = value.slice(0, start) + wrapped + value.slice(end);
onChange(next);
const caret = selected.length > 0 ? start + wrapped.length : start + 2;
requestAnimationFrame(() => {
el?.focus();
el?.setSelectionRange(caret, caret);
});
return;
}
const prefix = kind === 'h1' ? '# ' : kind === 'h2' ? '## ' : '### ';
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const nlAfter = value.indexOf('\n', start);
const lineEnd = nlAfter === -1 ? value.length : nlAfter;
const line = value.slice(lineStart, lineEnd);
const stripped = line.replace(/^#{1,3}\s/, '');
const newLine = prefix + stripped;
const next = value.slice(0, lineStart) + newLine + value.slice(lineEnd);
onChange(next);
const caret = lineStart + newLine.length;
requestAnimationFrame(() => {
el?.focus();
el?.setSelectionRange(caret, caret);
});
}

View File

@ -17,12 +17,16 @@ function InstallationTree() {
useContext(InstallationsContext);
const sortedInstallations = [...foldersAndInstallations].sort((a, b) => {
// Compare the status field of each installation and sort them based on the status.
//Installations with alarms go first
// Folders stay on top (existing behavior).
if (a.type == 'Folder') {
return -1;
}
// Data-collection-disabled installations sink below everything (even offline).
const aDisabled = (a as any).dataCollectionEnabled === false;
const bDisabled = (b as any).dataCollectionEnabled === false;
if (aDisabled !== bDisabled) return aDisabled ? 1 : -1;
// Then sort by status (alarms first).
let a_status = a.status;
let b_status = b.status;

View File

@ -81,11 +81,13 @@ export const transformInputToBatteryViewDataJson = async (
product: number,
start_time?: UnixTime,
end_time?: UnixTime,
batteryClusterNumber?: number
batteryClusterNumber?: number,
activeClusters?: Array<{ invIdx: number; clIdx: number; flatIdx: number }>
): Promise<{
chartData: BatteryDataInterface;
chartOverview: BatteryOverviewInterface;
}> => {
const useActive = !!activeClusters && activeClusters.length > 0;
const prefixes = ['', 'k', 'M', 'G', 'T'];
const MAX_NUMBER = 9999999;
const isSodioHome = product === 2 || product === 5;
@ -199,17 +201,34 @@ export const transformInputToBatteryViewDataJson = async (
const inv = (result as any)?.InverterRecord;
if (!inv) continue;
const numBatteries = batteryClusterNumber || 1;
// Iteration order: either active-cluster list (skips empty slots,
// preserves flat hardware indices) or a contiguous 0..N-1 fallback.
const iter = useActive
? activeClusters!.map((c) => ({
flatIdx: c.flatIdx,
invIdx: c.invIdx,
clIdx: c.clIdx
}))
: Array.from({ length: batteryClusterNumber || 1 }, (_, k) => ({
flatIdx: k,
invIdx: Math.floor(k / 2),
clIdx: k % 2
}));
const inverterCount = iter.reduce(
(max, c) => Math.max(max, c.invIdx + 1),
0
);
const showInverterLabel = !!inv?.Devices && inverterCount > 1;
let old_length = pathsToSave.length;
if (numBatteries > old_length) {
for (let b = old_length; b < numBatteries; b++) {
const nodeName = 'Node' + b;
if (!pathsToSave.includes(nodeName)) {
pathsToSave.push(nodeName);
}
iter.forEach((c) => {
const nodeName = 'Node' + c.flatIdx;
if (!pathsToSave.includes(nodeName)) {
pathsToSave.push(nodeName);
}
}
});
if (initialiation) {
initialiation = false;
@ -224,12 +243,15 @@ export const transformInputToBatteryViewDataJson = async (
});
}
if (numBatteries > old_length) {
if (pathsToSave.length > old_length) {
categories.forEach((category) => {
pathsToSave.forEach((path) => {
iter.forEach((c) => {
const path = 'Node' + c.flatIdx;
if (pathsToSave.indexOf(path) >= old_length) {
const displayIndex = pathsToSave.indexOf(path);
chartData[category].data[path] = { name: 'Battery Cluster ' + (displayIndex + 1), data: [] };
const name = showInverterLabel
? `Battery Cluster ${c.clIdx + 1} in Inverter ${c.invIdx + 1}`
: `Battery Cluster ${c.clIdx + 1}`;
chartData[category].data[path] = { name, data: [] };
}
});
});
@ -253,24 +275,23 @@ export const transformInputToBatteryViewDataJson = async (
Soh: 'Soh'
};
for (let j = 0; j < pathsToSave.length; j++) {
iter.forEach((c) => {
const path = 'Node' + c.flatIdx;
categories.forEach((category) => {
let value: number | undefined;
if (hasDevices) {
// Sinexcel: nested under Devices — 0→D1/B1, 1→D1/B2, 2→D2/B1, ...
const deviceId = String(Math.floor(j / 2) + 1);
const bi = (j % 2) + 1;
const device = inv.Devices[deviceId];
// Sinexcel: Devices keyed by "1","2",... (1-based dict keys)
const device = inv.Devices[String(c.invIdx + 1)];
const bi = c.clIdx + 1;
const fieldName = `Battery${bi}${categoryFieldMapSinexcel[category]}`;
value = device?.[fieldName];
// Fallback for Soc
if ((value === undefined || value === null) && category === 'Soc') {
value = device?.[`Battery${bi}SocSecondvalue`];
}
} else {
// Growatt: flat Battery1Soc, Battery2Voltage, ...
const batteryIndex = j + 1;
// Growatt: flat Battery1Soc, Battery2Voltage, ... on InverterRecord
const batteryIndex = c.clIdx + 1;
const fieldName = `Battery${batteryIndex}${categoryFieldMapGrowatt[category]}`;
value = inv[fieldName];
}
@ -282,13 +303,13 @@ export const transformInputToBatteryViewDataJson = async (
if (value > chartOverview[category].max) {
chartOverview[category].max = value;
}
chartData[category].data[pathsToSave[j]].data.push([
chartData[category].data[path].data.push([
adjustedTimestampArray[i],
value
]);
}
});
}
});
} else {
// SaliMax, Salidomo, SodistoreMax: existing logic
const battery_nodes =

View File

@ -0,0 +1,55 @@
export enum ChecklistStatus {
NotStarted = 0,
InProgress = 1,
Done = 2
}
export type ChecklistSubtask = {
text: string;
checked: boolean;
};
export type ChecklistItem = {
id: number;
installationId: number;
stepNumber: number;
stepTitle: string;
status: number;
comments: string;
assigneeId: number | null;
doneAt: string | null;
subtasks: string | null;
createdAt: string;
updatedAt: string;
};
export const CHECKLIST_ENABLED_PRODUCTS: ReadonlySet<number> = new Set([2, 4, 5]);
export const UPLOADABLE_SUBTASK_KEYS: ReadonlySet<string> = new Set([
'checklistStep8Sub1',
'checklistStep10Sub1',
'checklistStep10Sub2'
]);
export type ChecklistSummary = {
installationId: number;
done: number;
total: number;
};
export function parseSubtasks(raw: string | null | undefined): ChecklistSubtask[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.filter((x) => x && typeof x.text === 'string')
.map((x) => ({ text: String(x.text), checked: Boolean(x.checked) }));
} catch {
return [];
}
}
export function serializeSubtasks(subtasks: ChecklistSubtask[]): string {
return JSON.stringify(subtasks);
}

View File

@ -181,7 +181,8 @@ export enum TimelineEventType {
CommentAdded = 3,
AiDiagnosisAttached = 4,
Escalated = 5,
ResolutionAdded = 6
ResolutionAdded = 6,
SubjectChanged = 7
}
export type Ticket = {
@ -214,6 +215,7 @@ export type TicketComment = {
authorId: number | null;
body: string;
createdAt: string;
editedAt?: string | null;
};
export type TicketAiDiagnosis = {

View File

@ -13,7 +13,7 @@
"distributionPartner": "Vertriebspartner",
"inverterFirmwareVersion": "Wechselrichter-Firmware-Version",
"batteryFirmwareVersion": "Batterie-Firmware-Version",
"networkProvider": "Netzbetreiber",
"networkProvider": "Verteilnetzbetreiber",
"emailAddress": "E-Mail-Adresse",
"createNewFolder": "Neuer Ordner",
"createNewUser": "Neuer Benutzer",
@ -97,6 +97,8 @@
"selectModel": "Modell auswählen...",
"inverterN": "Wechselrichter {n}",
"clusterN": "Cluster {n}",
"batteryClusterN": "Batterie-Cluster {n}",
"batteryClusterInInverter": "Batterie-Cluster {cl} an Wechselrichter {inv}",
"clustersBatteriesSummary": "{filledClusters}/{totalClusters} Cluster, {filledBat}/{totalBat} Batterien",
"batteriesSummary": "{filled}/{total} Batterien",
"inverterNSerialNumber": "Wechselrichter {n} Seriennummer",
@ -590,6 +592,12 @@
"noDiagnosis": "Keine KI-Diagnose verfügbar.",
"comments": "Kommentare",
"noComments": "Noch keine Kommentare.",
"commentEdited": "(bearbeitet {time})",
"commentMarkdownHint": "Markdown: **fett**, #, ##, ###",
"commentFormatBold": "Fett",
"commentFormatH1": "Überschrift 1",
"commentFormatH2": "Überschrift 2",
"commentFormatH3": "Überschrift 3",
"addComment": "Hinzufügen",
"timeline": "Zeitverlauf",
"noTimelineEvents": "Noch keine Ereignisse.",
@ -638,6 +646,8 @@
"subCategory": "Unterkategorie",
"edit": "Bearbeiten",
"save": "Speichern",
"subjectRequired": "Betreff ist erforderlich.",
"failedToSaveSubject": "Betreff konnte nicht gespeichert werden.",
"descriptionSaved": "Beschreibung gespeichert.",
"subCatGeneral": "Allgemein",
"subCatOther": "Sonstiges",
@ -668,6 +678,7 @@
"timelineAiDiagnosis": "KI-Diagnose",
"timelineEscalated": "Eskaliert",
"timelineResolutionAdded": "Lösung hinzugefügt",
"timelineSubjectChanged": "Betreff geändert",
"timelineCreatedDesc": "Ticket erstellt von {name}.",
"timelineStatusChangedDesc": "Status geändert auf {status}.",
"timelineAssignedDesc": "Zugewiesen an {name}.",
@ -698,8 +709,12 @@
"sodistorepro": "Sodistore Pro",
"numberOfInverters": "Anzahl der Wechselrichter",
"documentsTab": "Dokumente",
"documentsHint": "Akzeptierte Formate: JPEG, PNG, GIF, WebP, PDF. Maximale Dateigrösse: 25 MB.",
"documentsHint": "Akzeptierte Formate: JPEG, PNG, GIF, WebP, PDF, MP4, MOV, WebM. Maximale Dateigrösse: 100 MB.",
"attachFiles": "Dateien anhängen",
"attachFileInvalidType": "Ungültiger Dateityp: {name}",
"attachFileTooLarge": "Datei zu gross: {name} (max. {limitMb} MB)",
"videoClickToPlay": "Zum Abspielen klicken",
"videoLoading": "Wird geladen…",
"attachments": "Anhänge",
"documents": "Dokumente",
"installationDocuments": "Installationsdokumente",
@ -708,5 +723,65 @@
"fileTooLarge": "Datei überschreitet die maximale Grösse von 25 MB.",
"invalidFileType": "Ungültiger Dateityp.",
"uploadFailed": "Hochladen fehlgeschlagen.",
"uploadSuccess": "Hochladen erfolgreich."
"uploadSuccess": "Hochladen erfolgreich.",
"checklist": "Checkliste",
"checklistTitle": "Schritte zur Anbindung der Installation an Monitor",
"checklistProgress": "Fortschritt: {done}/{total} ({percent}%)",
"checklistStep": "Schritt",
"checklistStatus": "Status",
"checklistAssignee": "Zuständig",
"checklistDateDone": "Erledigungsdatum",
"checklistComments": "Kommentare",
"checklistNotStarted": "Nicht gestartet",
"checklistInProgress": "In Bearbeitung",
"checklistDone": "Erledigt",
"checklistNoAssignee": "Nicht zugewiesen",
"checklistNotifyTooltip": "E-Mail-Benachrichtigung an Zuständige senden",
"checklistNotifyDisabledTooltip": "Weisen Sie zuerst jemanden zu, um eine Benachrichtigung zu senden",
"checklistToggleSubtasks": "Unteraufgaben umschalten",
"checklistCommentsPlaceholder": "Notizen, Kontaktinformationen, Beobachtungen…",
"checklistEmailSent": "E-Mail an {name} gesendet",
"checklistEmailFailed": "E-Mail-Versand fehlgeschlagen — bitte erneut versuchen",
"checklistSaveFailed": "Änderung konnte nicht gespeichert werden",
"checklistStep1": "Auftrag erstellt, Kunden- und Partnerinformationen im CRM erfasst",
"checklistStep2": "Hardware bei Vebo zusammengebaut",
"checklistStep3": "Installation auf Monitor unter korrektem Produkt und Ordner erstellt",
"checklistStep4": "Gateway-SD-Karte konfiguriert, VPN und Gateway-Name registriert",
"checklistStep5": "Informations-Tab ausgefüllt (Kunde, Seriennummern, VPN)",
"checklistStep6": "Installation bei Vebo elektrisch/hardwareseitig konfiguriert und getestet",
"checklistStep7": "Installation bei Vebo softwareseitig getestet",
"checklistStep8": "Installation am Kundenstandort ausgeliefert",
"checklistStep9": "Installation ans Netz angeschlossen",
"checklistStep10": "Hardware vor Ort verifiziert",
"checklistStep11": "Software vor Ort verifiziert",
"checklistStep12": "Installation online auf Monitor",
"checklistStep13": "Kunde über Monitor-Konto und Reports informiert",
"checklistStep14": "Benutzerkonto mit richtigen Ordnern und Zugriffen erstellt",
"checklistStep15": "Kundennachverfolgung abgeschlossen, Feedback eingeholt",
"checklistStep16": "Weitere Anliegen werden über das Ticket-System verfolgt",
"checklistStep3Sub1": "Installations-Seriennummer",
"checklistStep5Sub1": "Kundeninformationen (E-Mail, Adresse)",
"checklistStep5Sub2": "Installationsinformationen (externes EMS, Stromanbieter, Datenerfassung)",
"checklistStep5Sub3": "Batterie-Seriennummer",
"checklistStep5Sub4": "Wechselrichter-Seriennummer",
"checklistStep5Sub5": "Datenlogger-Seriennummer",
"checklistStep5Sub6": "VPN",
"checklistStep6Sub1": "Wechselrichter-Firmware und Konfiguration geprüft",
"checklistStep6Sub2": "Batterie-Firmware und Konfiguration geprüft",
"checklistStep6Sub3": "Internet für Gateway konfiguriert",
"checklistStep6Sub4": "Kommunikationskabel zwischen Gateway und Wechselrichter korrekt",
"checklistStep7Sub1": "S3-Bucket-Nummer und Schlüssel-Credentials aus Informations-Tab in config.json übernommen",
"checklistStep7Sub2": "Produkt-ID in config.json konfiguriert",
"checklistStep7Sub3": "USB-ID in config.json konfiguriert",
"checklistStep7Sub4": "Datenlesung vom Wechselrichter getestet",
"checklistStep8Sub1": "Lieferschein mit Kundenunterschrift erhalten und hochgeladen",
"checklistStep10Sub1": "Installationsprotokoll hochgeladen",
"checklistStep10Sub2": "Zeit- und Materialbericht in Monitoring hochgeladen",
"checklistNoAttachments": "Noch keine Datei angehängt.",
"setupProgress": "Setup-Fortschritt",
"checklistPhaseEmpty": "Nicht gestartet",
"checklistPhasePreparation": "Vorbereitung",
"checklistPhaseOnSite": "Vor Ort",
"checklistPhaseHandover": "Kundenübergabe",
"checklistPhaseComplete": "Abgeschlossen"
}

View File

@ -9,7 +9,7 @@
"distributionPartner": "Distribution Partner",
"inverterFirmwareVersion": "Inverter Firmware Version",
"batteryFirmwareVersion": "Battery Firmware Version",
"networkProvider": "Network Provider",
"networkProvider": "Grid Provider",
"emailAddress": "Email Address",
"customerName": "Customer name",
"english": "English",
@ -79,6 +79,8 @@
"selectModel": "Select model...",
"inverterN": "Inverter {n}",
"clusterN": "Cluster {n}",
"batteryClusterN": "Battery Cluster {n}",
"batteryClusterInInverter": "Battery Cluster {cl} in Inverter {inv}",
"clustersBatteriesSummary": "{filledClusters}/{totalClusters} clusters, {filledBat}/{totalBat} batteries",
"batteriesSummary": "{filled}/{total} batteries",
"inverterNSerialNumber": "Inverter {n} Serial Number",
@ -338,6 +340,12 @@
"noDiagnosis": "No AI diagnosis available.",
"comments": "Comments",
"noComments": "No comments yet.",
"commentEdited": "(edited {time})",
"commentMarkdownHint": "Markdown: **bold**, #, ##, ###",
"commentFormatBold": "Bold",
"commentFormatH1": "Heading 1",
"commentFormatH2": "Heading 2",
"commentFormatH3": "Heading 3",
"addComment": "Add",
"timeline": "Timeline",
"noTimelineEvents": "No events yet.",
@ -386,6 +394,8 @@
"subCategory": "Sub-Category",
"edit": "Edit",
"save": "Save",
"subjectRequired": "Subject is required.",
"failedToSaveSubject": "Failed to save subject.",
"descriptionSaved": "Description saved.",
"subCatGeneral": "General",
"subCatOther": "Other",
@ -416,6 +426,7 @@
"timelineAiDiagnosis": "AI Diagnosis",
"timelineEscalated": "Escalated",
"timelineResolutionAdded": "Resolution Added",
"timelineSubjectChanged": "Subject Changed",
"timelineCreatedDesc": "Ticket created by {name}.",
"timelineStatusChangedDesc": "Status changed to {status}.",
"timelineAssignedDesc": "Assigned to {name}.",
@ -446,8 +457,12 @@
"sodistorepro": "Sodistore Pro",
"numberOfInverters": "Number of Inverters",
"documentsTab": "Documents",
"documentsHint": "Accepted formats: JPEG, PNG, GIF, WebP, PDF. Maximum file size: 25 MB.",
"documentsHint": "Accepted formats: JPEG, PNG, GIF, WebP, PDF, MP4, MOV, WebM. Maximum file size: 100 MB.",
"attachFiles": "Attach Files",
"attachFileInvalidType": "Invalid file type: {name}",
"attachFileTooLarge": "File too large: {name} (max {limitMb} MB)",
"videoClickToPlay": "Click to play",
"videoLoading": "Loading…",
"attachments": "Attachments",
"documents": "Documents",
"installationDocuments": "Installation Documents",
@ -456,5 +471,65 @@
"fileTooLarge": "File exceeds maximum size of 25 MB.",
"invalidFileType": "Invalid file type.",
"uploadFailed": "Upload failed.",
"uploadSuccess": "Upload successful."
"uploadSuccess": "Upload successful.",
"checklist": "Checklist",
"checklistTitle": "Steps to Bring Installation to Monitor",
"checklistProgress": "Progress: {done}/{total} ({percent}%)",
"checklistStep": "Step",
"checklistStatus": "Status",
"checklistAssignee": "Assignee",
"checklistDateDone": "Date Done",
"checklistComments": "Comments",
"checklistNotStarted": "Not Started",
"checklistInProgress": "In Progress",
"checklistDone": "Done",
"checklistNoAssignee": "Unassigned",
"checklistNotifyTooltip": "Send email notification to assignee",
"checklistNotifyDisabledTooltip": "Assign someone first to send a notification",
"checklistToggleSubtasks": "Toggle subtasks",
"checklistCommentsPlaceholder": "Notes, contact info, observations…",
"checklistEmailSent": "Email sent to {name}",
"checklistEmailFailed": "Failed to send email — try again",
"checklistSaveFailed": "Failed to save change",
"checklistStep1": "Order created, customer and partner info recorded in CRM",
"checklistStep2": "Hardware assembled at Vebo",
"checklistStep3": "Installation created on Monitor under correct product and folder",
"checklistStep4": "Gateway SD card configured, VPN and gateway name registered",
"checklistStep5": "Information tab filled out (customer, serials, VPN)",
"checklistStep6": "Installation configured and tested electrically / hardware-wise at Vebo",
"checklistStep7": "Installation tested software-wise at Vebo",
"checklistStep8": "Installation delivered to customer site",
"checklistStep9": "Installation connected to grid",
"checklistStep10": "Hardware verified on site",
"checklistStep11": "Software verified on site",
"checklistStep12": "Installation online on Monitor",
"checklistStep13": "Customer informed about Monitor account and reports",
"checklistStep14": "User account created with correct folders and access",
"checklistStep15": "Customer follow-up completed, feedback collected",
"checklistStep16": "Further issues tracked via Ticket system",
"checklistStep3Sub1": "Installation serial number",
"checklistStep5Sub1": "Customer information (email, address)",
"checklistStep5Sub2": "Installation information (external EMS, grid provider, data collection)",
"checklistStep5Sub3": "Battery serial number",
"checklistStep5Sub4": "Inverter serial number",
"checklistStep5Sub5": "Data logger serial number",
"checklistStep5Sub6": "VPN",
"checklistStep6Sub1": "Inverter firmware and configuration verified",
"checklistStep6Sub2": "Battery firmware and configuration verified",
"checklistStep6Sub3": "Internet for gateway configured",
"checklistStep6Sub4": "Communication cable between gateway and inverter correct",
"checklistStep7Sub1": "S3 bucket number and key credentials copied from Information tab into config.json",
"checklistStep7Sub2": "Product ID configured in config.json",
"checklistStep7Sub3": "USB ID configured in config.json",
"checklistStep7Sub4": "Inverter data reading from inverter tested",
"checklistStep8Sub1": "Delivery receipt with customer signature received and uploaded",
"checklistStep10Sub1": "Installation protocol uploaded",
"checklistStep10Sub2": "Time and material report uploaded to Monitoring",
"checklistNoAttachments": "No file attached yet.",
"setupProgress": "Setup Progress",
"checklistPhaseEmpty": "Not started",
"checklistPhasePreparation": "Preparation",
"checklistPhaseOnSite": "On-site",
"checklistPhaseHandover": "Customer handover",
"checklistPhaseComplete": "Complete"
}

View File

@ -11,7 +11,7 @@
"distributionPartner": "Partenaire de distribution",
"inverterFirmwareVersion": "Version firmware onduleur",
"batteryFirmwareVersion": "Version firmware batterie",
"networkProvider": "Gestionnaire de réseau",
"networkProvider": "Gestionnaire de réseau de distribution",
"emailAddress": "Adresse e-mail",
"createNewFolder": "Nouveau dossier",
"createNewUser": "Nouvel utilisateur",
@ -91,6 +91,8 @@
"selectModel": "Sélectionner le modèle...",
"inverterN": "Onduleur {n}",
"clusterN": "Cluster {n}",
"batteryClusterN": "Cluster de batteries {n}",
"batteryClusterInInverter": "Cluster de batteries {cl} sur onduleur {inv}",
"clustersBatteriesSummary": "{filledClusters}/{totalClusters} clusters, {filledBat}/{totalBat} batteries",
"batteriesSummary": "{filled}/{total} batteries",
"inverterNSerialNumber": "Numéro de série onduleur {n}",
@ -590,6 +592,12 @@
"noDiagnosis": "Aucun diagnostic IA disponible.",
"comments": "Commentaires",
"noComments": "Aucun commentaire pour le moment.",
"commentEdited": "(modifié {time})",
"commentMarkdownHint": "Markdown : **gras**, #, ##, ###",
"commentFormatBold": "Gras",
"commentFormatH1": "Titre 1",
"commentFormatH2": "Titre 2",
"commentFormatH3": "Titre 3",
"addComment": "Ajouter",
"timeline": "Chronologie",
"noTimelineEvents": "Aucun événement pour le moment.",
@ -638,6 +646,8 @@
"subCategory": "Sous-catégorie",
"edit": "Modifier",
"save": "Enregistrer",
"subjectRequired": "Le sujet est requis.",
"failedToSaveSubject": "Échec de l'enregistrement du sujet.",
"descriptionSaved": "Description enregistrée.",
"subCatGeneral": "Général",
"subCatOther": "Autre",
@ -668,6 +678,7 @@
"timelineAiDiagnosis": "Diagnostic IA",
"timelineEscalated": "Escaladé",
"timelineResolutionAdded": "Résolution ajoutée",
"timelineSubjectChanged": "Sujet modifié",
"timelineCreatedDesc": "Ticket créé par {name}.",
"timelineStatusChangedDesc": "Statut modifié en {status}.",
"timelineAssignedDesc": "Assigné à {name}.",
@ -698,8 +709,12 @@
"sodistorepro": "Sodistore Pro",
"numberOfInverters": "Nombre d'onduleurs",
"documentsTab": "Documents",
"documentsHint": "Formats acceptés : JPEG, PNG, GIF, WebP, PDF. Taille maximale : 25 Mo.",
"documentsHint": "Formats acceptés : JPEG, PNG, GIF, WebP, PDF, MP4, MOV, WebM. Taille maximale : 100 Mo.",
"attachFiles": "Joindre des fichiers",
"attachFileInvalidType": "Type de fichier non valide : {name}",
"attachFileTooLarge": "Fichier trop volumineux : {name} (max. {limitMb} Mo)",
"videoClickToPlay": "Cliquer pour lire",
"videoLoading": "Chargement…",
"attachments": "Pièces jointes",
"documents": "Documents",
"installationDocuments": "Documents d'installation",
@ -708,5 +723,65 @@
"fileTooLarge": "Le fichier dépasse la taille maximale de 25 Mo.",
"invalidFileType": "Type de fichier non valide.",
"uploadFailed": "Échec du téléchargement.",
"uploadSuccess": "Téléchargement réussi."
"uploadSuccess": "Téléchargement réussi.",
"checklist": "Checklist",
"checklistTitle": "Étapes pour connecter l'installation à Monitor",
"checklistProgress": "Progression : {done}/{total} ({percent}%)",
"checklistStep": "Étape",
"checklistStatus": "Statut",
"checklistAssignee": "Responsable",
"checklistDateDone": "Date de réalisation",
"checklistComments": "Commentaires",
"checklistNotStarted": "Non commencé",
"checklistInProgress": "En cours",
"checklistDone": "Terminé",
"checklistNoAssignee": "Non attribué",
"checklistNotifyTooltip": "Envoyer une notification par e-mail au responsable",
"checklistNotifyDisabledTooltip": "Attribuez d'abord un responsable pour envoyer une notification",
"checklistToggleSubtasks": "Afficher/masquer les sous-tâches",
"checklistCommentsPlaceholder": "Notes, coordonnées, observations…",
"checklistEmailSent": "E-mail envoyé à {name}",
"checklistEmailFailed": "Échec de l'envoi — veuillez réessayer",
"checklistSaveFailed": "Échec de l'enregistrement",
"checklistStep1": "Commande créée, informations client et partenaire enregistrées dans le CRM",
"checklistStep2": "Matériel assemblé chez Vebo",
"checklistStep3": "Installation créée sur Monitor sous le bon produit et dossier",
"checklistStep4": "Carte SD du gateway configurée, VPN et nom du gateway enregistrés",
"checklistStep5": "Onglet Informations rempli (client, numéros de série, VPN)",
"checklistStep6": "Installation configurée et testée électriquement/matériel chez Vebo",
"checklistStep7": "Installation testée côté logiciel chez Vebo",
"checklistStep8": "Installation livrée sur le site du client",
"checklistStep9": "Installation raccordée au réseau",
"checklistStep10": "Matériel vérifié sur site",
"checklistStep11": "Logiciel vérifié sur site",
"checklistStep12": "Installation en ligne sur Monitor",
"checklistStep13": "Client informé du compte Monitor et des rapports",
"checklistStep14": "Compte utilisateur créé avec les dossiers et accès corrects",
"checklistStep15": "Suivi client effectué, retour recueilli",
"checklistStep16": "Problèmes ultérieurs suivis via le système de tickets",
"checklistStep3Sub1": "Numéro de série de l'installation",
"checklistStep5Sub1": "Informations client (e-mail, adresse)",
"checklistStep5Sub2": "Informations d'installation (EMS externe, fournisseur réseau, collecte de données)",
"checklistStep5Sub3": "Numéro de série de la batterie",
"checklistStep5Sub4": "Numéro de série de l'onduleur",
"checklistStep5Sub5": "Numéro de série de l'enregistreur de données",
"checklistStep5Sub6": "VPN",
"checklistStep6Sub1": "Firmware et configuration de l'onduleur vérifiés",
"checklistStep6Sub2": "Firmware et configuration de la batterie vérifiés",
"checklistStep6Sub3": "Internet pour le gateway configuré",
"checklistStep6Sub4": "Câble de communication entre gateway et onduleur correct",
"checklistStep7Sub1": "Numéro de bucket S3 et identifiants copiés depuis l'onglet Informations dans config.json",
"checklistStep7Sub2": "ID produit configuré dans config.json",
"checklistStep7Sub3": "ID USB configuré dans config.json",
"checklistStep7Sub4": "Lecture des données de l'onduleur testée",
"checklistStep8Sub1": "Bon de livraison signé par le client reçu et téléversé",
"checklistStep10Sub1": "Procès-verbal d'installation téléversé",
"checklistStep10Sub2": "Rapport de temps et matériaux téléversé dans Monitoring",
"checklistNoAttachments": "Aucun fichier joint pour le moment.",
"setupProgress": "Progression installation",
"checklistPhaseEmpty": "Non commencé",
"checklistPhasePreparation": "Préparation",
"checklistPhaseOnSite": "Sur site",
"checklistPhaseHandover": "Transfert client",
"checklistPhaseComplete": "Terminé"
}

View File

@ -9,7 +9,7 @@
"distributionPartner": "Partner di distribuzione",
"inverterFirmwareVersion": "Versione firmware inverter",
"batteryFirmwareVersion": "Versione firmware batteria",
"networkProvider": "Gestore di rete",
"networkProvider": "Gestore della rete di distribuzione",
"emailAddress": "Indirizzo e-mail",
"customerName": "Nome cliente",
"english": "Inglese",
@ -79,6 +79,8 @@
"selectModel": "Seleziona modello...",
"inverterN": "Inverter {n}",
"clusterN": "Cluster {n}",
"batteryClusterN": "Cluster batteria {n}",
"batteryClusterInInverter": "Cluster batteria {cl} su inverter {inv}",
"clustersBatteriesSummary": "{filledClusters}/{totalClusters} cluster, {filledBat}/{totalBat} batterie",
"batteriesSummary": "{filled}/{total} batterie",
"inverterNSerialNumber": "Numero di serie inverter {n}",
@ -590,6 +592,12 @@
"noDiagnosis": "Nessuna diagnosi IA disponibile.",
"comments": "Commenti",
"noComments": "Nessun commento ancora.",
"commentEdited": "(modificato {time})",
"commentMarkdownHint": "Markdown: **grassetto**, #, ##, ###",
"commentFormatBold": "Grassetto",
"commentFormatH1": "Titolo 1",
"commentFormatH2": "Titolo 2",
"commentFormatH3": "Titolo 3",
"addComment": "Aggiungi",
"timeline": "Cronologia",
"noTimelineEvents": "Nessun evento ancora.",
@ -638,6 +646,8 @@
"subCategory": "Sottocategoria",
"edit": "Modifica",
"save": "Salva",
"subjectRequired": "L'oggetto è obbligatorio.",
"failedToSaveSubject": "Impossibile salvare l'oggetto.",
"descriptionSaved": "Descrizione salvata.",
"subCatGeneral": "Generale",
"subCatOther": "Altro",
@ -668,6 +678,7 @@
"timelineAiDiagnosis": "Diagnosi IA",
"timelineEscalated": "Escalato",
"timelineResolutionAdded": "Risoluzione aggiunta",
"timelineSubjectChanged": "Oggetto modificato",
"timelineCreatedDesc": "Ticket creato da {name}.",
"timelineStatusChangedDesc": "Stato modificato in {status}.",
"timelineAssignedDesc": "Assegnato a {name}.",
@ -698,8 +709,12 @@
"sodistorepro": "Sodistore Pro",
"numberOfInverters": "Numero di inverter",
"documentsTab": "Documenti",
"documentsHint": "Formati accettati: JPEG, PNG, GIF, WebP, PDF. Dimensione massima: 25 MB.",
"documentsHint": "Formati accettati: JPEG, PNG, GIF, WebP, PDF, MP4, MOV, WebM. Dimensione massima: 100 MB.",
"attachFiles": "Allega file",
"attachFileInvalidType": "Tipo di file non valido: {name}",
"attachFileTooLarge": "File troppo grande: {name} (max {limitMb} MB)",
"videoClickToPlay": "Clicca per riprodurre",
"videoLoading": "Caricamento…",
"attachments": "Allegati",
"documents": "Documenti",
"installationDocuments": "Documenti dell'installazione",
@ -708,5 +723,65 @@
"fileTooLarge": "Il file supera la dimensione massima di 25 MB.",
"invalidFileType": "Tipo di file non valido.",
"uploadFailed": "Caricamento fallito.",
"uploadSuccess": "Caricamento riuscito."
"uploadSuccess": "Caricamento riuscito.",
"checklist": "Checklist",
"checklistTitle": "Passi per collegare l'installazione a Monitor",
"checklistProgress": "Avanzamento: {done}/{total} ({percent}%)",
"checklistStep": "Passo",
"checklistStatus": "Stato",
"checklistAssignee": "Assegnatario",
"checklistDateDone": "Data completamento",
"checklistComments": "Commenti",
"checklistNotStarted": "Non avviato",
"checklistInProgress": "In corso",
"checklistDone": "Completato",
"checklistNoAssignee": "Non assegnato",
"checklistNotifyTooltip": "Invia notifica e-mail all'assegnatario",
"checklistNotifyDisabledTooltip": "Assegnare prima qualcuno per inviare una notifica",
"checklistToggleSubtasks": "Mostra/nascondi sottoattività",
"checklistCommentsPlaceholder": "Note, contatti, osservazioni…",
"checklistEmailSent": "E-mail inviata a {name}",
"checklistEmailFailed": "Invio e-mail non riuscito — riprovare",
"checklistSaveFailed": "Salvataggio non riuscito",
"checklistStep1": "Ordine creato, informazioni cliente e partner registrate nel CRM",
"checklistStep2": "Hardware assemblato presso Vebo",
"checklistStep3": "Installazione creata su Monitor sotto il prodotto e la cartella corretti",
"checklistStep4": "Scheda SD del gateway configurata, VPN e nome gateway registrati",
"checklistStep5": "Scheda Informazioni compilata (cliente, seriali, VPN)",
"checklistStep6": "Installazione configurata e testata elettricamente/hardware presso Vebo",
"checklistStep7": "Installazione testata a livello software presso Vebo",
"checklistStep8": "Installazione consegnata presso il sito del cliente",
"checklistStep9": "Installazione collegata alla rete",
"checklistStep10": "Hardware verificato in sito",
"checklistStep11": "Software verificato in sito",
"checklistStep12": "Installazione online su Monitor",
"checklistStep13": "Cliente informato su account Monitor e report",
"checklistStep14": "Account utente creato con cartelle e accessi corretti",
"checklistStep15": "Follow-up cliente completato, feedback raccolto",
"checklistStep16": "Ulteriori problemi tracciati tramite il sistema di ticket",
"checklistStep3Sub1": "Numero di serie dell'installazione",
"checklistStep5Sub1": "Informazioni cliente (e-mail, indirizzo)",
"checklistStep5Sub2": "Informazioni installazione (EMS esterno, fornitore di rete, raccolta dati)",
"checklistStep5Sub3": "Numero di serie batteria",
"checklistStep5Sub4": "Numero di serie inverter",
"checklistStep5Sub5": "Numero di serie data logger",
"checklistStep5Sub6": "VPN",
"checklistStep6Sub1": "Firmware e configurazione inverter verificati",
"checklistStep6Sub2": "Firmware e configurazione batteria verificati",
"checklistStep6Sub3": "Internet per gateway configurato",
"checklistStep6Sub4": "Cavo di comunicazione tra gateway e inverter corretto",
"checklistStep7Sub1": "Numero bucket S3 e credenziali copiati dalla scheda Informazioni in config.json",
"checklistStep7Sub2": "ID prodotto configurato in config.json",
"checklistStep7Sub3": "ID USB configurato in config.json",
"checklistStep7Sub4": "Lettura dati inverter testata",
"checklistStep8Sub1": "Bolla di consegna con firma del cliente ricevuta e caricata",
"checklistStep10Sub1": "Verbale di installazione caricato",
"checklistStep10Sub2": "Rapporto tempi e materiali caricato su Monitoring",
"checklistNoAttachments": "Nessun file allegato.",
"setupProgress": "Avanzamento installazione",
"checklistPhaseEmpty": "Non avviato",
"checklistPhasePreparation": "Preparazione",
"checklistPhaseOnSite": "In sito",
"checklistPhaseHandover": "Consegna cliente",
"checklistPhaseComplete": "Completato"
}