Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
11e9d9a
Add plugin driver system with config parsing
marconetsf Sep 11, 2025
fb3b5d0
Refactor plugin driver for Python Modbus support
marconetsf Sep 11, 2025
cd10661
sync plugin driver
marconetsf Sep 11, 2025
158b390
Adjusting python.h include
marconetsf Sep 12, 2025
77dbf44
fix init driver's args encapsulation
marconetsf Sep 12, 2025
b10e7b8
adjusting brackets position and function identation
marconetsf Sep 12, 2025
75dfe2f
fix cmakelist
marconetsf Sep 12, 2025
e89ff10
adjust python_plugin_bridge.h identation
marconetsf Sep 15, 2025
ccef749
python start funct running within a thread
marconetsf Sep 15, 2025
ce90100
fixing pointer dereferencing
marconetsf Sep 15, 2025
3063116
Fix buffer access in Python plugin driver
marconetsf Sep 16, 2025
ef9b1ae
deleting stop call
marconetsf Sep 16, 2025
39a893d
Remove unused _runtime_args_capsule variable
marconetsf Sep 16, 2025
df1ac5d
Refactor Python plugin threading and lifecycle management
marconetsf Sep 17, 2025
89e8a3e
Refactor plugin driver cleanup and GIL management
marconetsf Sep 17, 2025
32fc0ec
Refactor plugin loop to run in a separate thread
marconetsf Sep 17, 2025
993cfef
Refactor Modbus plugin for safer buffer access
marconetsf Sep 17, 2025
763b105
Update Python plugin documentation and type safety
marconetsf Sep 17, 2025
d447cc1
moving examples and plugins to respective folder
marconetsf Sep 17, 2025
85ac4cb
deleting unused plugins from config
marconetsf Sep 17, 2025
79bb1b6
Expose plugin mutex helpers and use in PLC cycle
marconetsf Sep 17, 2025
c3c0ac9
Merge branch 'development' into task/RTOP-57-implement-driver-contracts
marconetsf Sep 18, 2025
e5cfc77
Changing pluggins paths to meet exec.sh start path
marconetsf Sep 18, 2025
345fc60
Merge pull request #5 from Autonomy-Logic/task/RTOP-57-implement-driv…
thiagoralves Sep 18, 2025
469fc7b
Rtop 58 plugin modbus slave (#6)
marconetsf Sep 23, 2025
70c560e
Merge branch 'development' into RTOP-52-Standard-Network-Driver
marconetsf Sep 23, 2025
9cf4e5f
fixing plugin's dedicated data retrieval
marconetsf Sep 24, 2025
9bb279c
RTOP 74 adding plugin individual venv usage
marconetsf Sep 25, 2025
2771514
Merge branch 'development' into RTOP-74-Define-venv-environment-for-p…
marconetsf Sep 25, 2025
14301df
Merge branch 'development' into RTOP-52-Standard-Network-Driver
marconetsf Sep 25, 2025
69d1056
Update scripts/manage_plugin_venvs.sh
marconetsf Sep 25, 2025
cefb954
Disabling initial plugin prints and avoiding code insertion
marconetsf Sep 26, 2025
b021601
Merge branch 'RTOP-74-Define-venv-environment-for-python-plugins' of …
marconetsf Sep 26, 2025
f8ab311
install now has support for apt yum and dfs and plc program is being …
marconetsf Sep 26, 2025
e086fea
adding proper build error and success logs
marconetsf Sep 29, 2025
55dfcd2
[RTOP 74][WIP] implementing initialization scripts
marconetsf Sep 29, 2025
7f98748
changing runtime venv from .venv to venvs/runtime/
marconetsf Sep 29, 2025
43e43ac
Merge branch 'development' into RTOP-52-Standard-Network-Driver
marconetsf Sep 29, 2025
950a475
Merge branch 'RTOP-52-Standard-Network-Driver' into RTOP-74-Define-ve…
thiagoralves Sep 29, 2025
659f49b
Merge pull request #11 from Autonomy-Logic/RTOP-74-Define-venv-enviro…
thiagoralves Sep 29, 2025
a919d86
Add requirements.txt to the Modbus driver
thiagoralves Sep 29, 2025
c2acb8c
Add checks on install and start_openplc scripts
Sep 29, 2025
f8974ee
Quick fix on start_openplc.sh
Sep 29, 2025
aad7e53
Fix zip file check to allow generated bash script
lucasbutzke Sep 30, 2025
865c429
[RTOP-72] Parse and log unix socket log messages
lucasbutzke Sep 30, 2025
8791754
[RTOP-72] Additional Exceptions
lucasbutzke Sep 30, 2025
debb9e9
[RTOP-72] Logging format refactor
lucasbutzke Oct 1, 2025
4d65c3d
[RTOP-76] Logging module
lucasbutzke Oct 1, 2025
c1f7aa7
Merge branch 'fix-reastapi-zip-file-check' into RTOP-76-Runtime-logs-…
lucasbutzke Oct 1, 2025
b0ea1fd
[RTOP-76] Logger parser
lucasbutzke Oct 1, 2025
58b5f48
[RTOP-76] Logger in app
lucasbutzke Oct 2, 2025
7287680
Merge branch 'development' into RTOP-76-Runtime-logs-parser
lucasbutzke Oct 2, 2025
7dd4fc3
[RTOP-76] Runtime logging buffer test
lucasbutzke Oct 2, 2025
6651c8c
[RTOP-76] Fix logging instantiation, parser and buffer
lucasbutzke Oct 3, 2025
b8bff1e
[RTOP-76] Runtime Logs parser, buffering and json response
lucasbutzke Oct 3, 2025
145dade
Merge branch 'development' into RTOP-78-Create-log-filters
lucasbutzke Oct 6, 2025
0f07f85
[RTOP-77] Python logs parsing to JSON
lucasbutzke Oct 7, 2025
74ef50e
[RTOP-78] Fix python restapi logging format
lucasbutzke Oct 7, 2025
7acdcdd
[RTOP-78] Logs id field
lucasbutzke Oct 8, 2025
4dd1e49
Merge remote-tracking branch 'origin/development' into RTOP-76-Runtim…
lucasbutzke Oct 8, 2025
7d9915b
Merge branch 'RTOP-76-Runtime-logs-parser' into RTOP-78-Create-log-fi…
lucasbutzke Oct 8, 2025
0b8319a
[RTOP-78] Adding python logging messages
lucasbutzke Oct 8, 2025
b7c08b3
[RTOP-78] Logs simple filter
lucasbutzke Oct 9, 2025
64c0582
[RTOP-78] Fix logs filter
lucasbutzke Oct 9, 2025
7a74e9b
Update webserver/plcapp_management.py
lucasbutzke Oct 9, 2025
f7e7791
[RTOP-78] db exceptions COPILOT fix sugestions
lucasbutzke Oct 9, 2025
2c3d6c2
[RTOP-78] Fix kwt Exception COPILOT sugestion
lucasbutzke Oct 9, 2025
f5ec8d6
Merge branch 'development' into RTOP-78-Create-log-filters
lucasbutzke Oct 9, 2025
0fd3cfb
[RTOP-78] Remove allowed bash file in zip
lucasbutzke Oct 13, 2025
117636d
[RTOP-78] Remove json library that is not used in this file
lucasbutzke Oct 13, 2025
894e604
[RTOP-78] Fix and removing Exceptions imports
lucasbutzke Oct 13, 2025
f0c3d7e
Fix failed build due to logger
thiagoralves Oct 15, 2025
e302448
[RTOP-78] Fix restapi database and env directory location for docker …
lucasbutzke Oct 16, 2025
e806a0d
[RTOP-78] config.py import logging module
lucasbutzke Oct 16, 2025
2170d37
Update webserver/runtimemanager.py
lucasbutzke Oct 16, 2025
8c45b4f
Update webserver/logger/bufferhandler.py
lucasbutzke Oct 16, 2025
d2bc4f5
[RTOP-78] Removing commented code
lucasbutzke Oct 16, 2025
2aebd4c
Merge branch 'RTOP-78-Create-log-filters' into development
lucasbutzke Oct 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ __pycache__/
*.pem
*.db
*.socket
*.installed

# Ignore all object files and shared libraries
*.o
Expand Down
25 changes: 14 additions & 11 deletions webserver/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,15 @@
MAX_FILE_SIZE
)

# from logger import get_logger, LogParser
from logger import get_logger, LogParser

logger, _ = get_logger("logger", use_buffer=True)

app = flask.Flask(__name__)
app.secret_key = str(os.urandom(16))
login_manager = flask_login.LoginManager()
login_manager.init_app(app)

# logger = get_logger(use_buffer=True)

runtime_manager = RuntimeManager(
runtime_path="./build/plc_main",
plc_socket="/run/runtime/plc_runtime.socket",
Expand All @@ -63,7 +62,15 @@ def handle_stop_plc(data: dict) -> dict:


def handle_runtime_logs(data: dict) -> dict:
response = runtime_manager.get_logs()
if "id" in data:
min_id = int(data["id"])
else:
min_id = None
if "level" in data:
level = data["level"]
else:
level = None
response = runtime_manager.get_logs(min_id=min_id, level=level)
return {"runtime-logs": response}


Expand Down Expand Up @@ -200,12 +207,8 @@ def run_https():
# logger.info("Generating https certificate...")
print("Generating https certificate...") # TODO: remove this temporary print once logger is functional again
cert_gen.generate_self_signed_cert(cert_file=CERT_FILE, key_file=KEY_FILE)

# Check if the certificate is valid
if not cert_gen.is_certificate_valid(CERT_FILE):
# logger.error("Invalid certificate. Cannot start https application")
print("Invalid certificate. Cannot start https application") # TODO: remove this temporary print once logger is functional again
sys.exit(1)
else:
logger.warning("Credentials already generated!")

context = (CERT_FILE, KEY_FILE)
app_restapi.run(
Expand All @@ -226,8 +229,8 @@ def run_https():
# logger.info("HTTP server stopped by KeyboardInterrupt")
pass
finally:
logger.info("Runtime manager stopped")
runtime_manager.stop()
# logger.info("Runtime manager stopped")


if __name__ == "__main__":
Expand Down
22 changes: 11 additions & 11 deletions webserver/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
from pathlib import Path

from dotenv import load_dotenv
from logger import get_logger, LogParser

logger, buffer = get_logger("logger", use_buffer=True)

# Always resolve .env relative to the repo root to guarantee it is found
ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
DB_PATH = Path(__file__).resolve().parent.parent / "restapi.db"
ENV_PATH = Path(__file__).resolve().parent.parent / "webserver/.env"
DB_PATH = Path(__file__).resolve().parent.parent / "webserver/restapi.db"
BASE_DIR = os.path.abspath(os.path.dirname(__file__))

# logger = logging.getLogger("logger")


# Function to validate environment variable values
def is_valid_env(var_name, value):
if var_name == "SQLALCHEMY_DATABASE_URI":
Expand All @@ -35,18 +35,18 @@ def generate_env_file():
f.write(f"PEPPER={pepper}\n")

os.chmod(ENV_PATH, 0o600)
logger.info(f".env file created at {ENV_PATH}")
logger.info(".env file created at %s", ENV_PATH)

# Ensure the database file exists and is writable
# Deletion is required because new secrets will change the database saved hashes
if os.path.exists(DB_PATH):
os.remove(DB_PATH)
logger.info(f"Deleted existing database file: {DB_PATH}")
logger.warning("Deleted existing database file: %s", DB_PATH)


# Load .env file
if not os.path.isfile(ENV_PATH):
logger.warning(".env file not found, creating one...")
logger.info(".env file not found, creating one...")
generate_env_file()

load_dotenv(dotenv_path=ENV_PATH, override=False)
Expand All @@ -58,7 +58,7 @@ def generate_env_file():
if not val or not is_valid_env(var, val):
raise RuntimeError(f"Environment variable '{var}' is invalid or missing")
except RuntimeError as e:
logger.error(f"{e}")
logger.error("%s", e)
# Need to regenerate .env file and remove the database as well
response = (
input(
Expand All @@ -68,11 +68,11 @@ def generate_env_file():
.lower()
)
if response == "y":
logger.info("Regenerating .env with new valid values...")
print("Regenerating .env with new valid values...")
generate_env_file()
load_dotenv(ENV_PATH)
else:
logger.error("Exiting due to invalid environment configuration.")
print("Exiting due to invalid environment configuration.")
exit(1)


Expand Down
40 changes: 18 additions & 22 deletions webserver/logger/__init__.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,29 @@
import logging
import logging.config
from .logger import get_logger
from .parser import LogParser
from .bufferhandler import BufferHandler
from .formatter import JsonFormatter

__all__ = ["get_logger", "LogParser", "BufferHandler"]
__all__ = ["get_logger", "LogParser", "BufferHandler", "JsonFormatter"]
__version__ = "0.1"
__author__ = "Autonomy"
__license__ = "MIT"
__description__ = "RestAPI interface for runtime core"

# Single global buffer for all logs
shared_buffer_handler = BufferHandler()

# Configure logging once
logging.config.dictConfig(
{
"version": 1,
"formatters": {
"default": {
"format": "[%(levelname)s] %(asctime)s - %(name)s - %(message)s",
"datefmt": "%H:%M:%S",
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
"level": "DEBUG",
}
},
"root": {"level": "DEBUG", "handlers": ["console"]},
}
)
formatter = JsonFormatter()
shared_buffer_handler.setFormatter(formatter)

def get_logger(name="runtime", use_buffer: bool = False):
Comment thread
lucasbutzke marked this conversation as resolved.
"""Return a logger that shares the same buffer handler."""
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
logger.propagate = False

if use_buffer:
if not any(isinstance(h, BufferHandler) for h in logger.handlers):
logger.addHandler(shared_buffer_handler)

return logger, shared_buffer_handler
110 changes: 74 additions & 36 deletions webserver/logger/bufferhandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,60 +2,98 @@
from collections import deque
from typing import List, Optional
import json
import re
from datetime import datetime
from datetime import datetime, timezone
from threading import Lock


class BufferHandler(logging.Handler):
"""
Custom logging handler that stores log records in memory (FIFO).
Logs are formatted using the attached formatter (JSON).
"""
_instance = None
_lock = Lock()

def __init__(self, capacity: int = 1000):
super().__init__()
self.buffer = deque(maxlen=capacity)

def emit(self, record: logging.LogRecord) -> None:
try:
self.buffer.append(self.format(record))
except Exception:
self.handleError(record)
with self._lock:
try:
self.buffer.append(self.format(record))
except Exception:
self.handleError(record)

def filter_logs(self, logs, level=None, min_id=None, max_id=None):
result = logs
if level is not None:
result = [log for log in result if log.get("level") == level]
if min_id is not None:
result = [log for log in result if log.get("id", 0) >= min_id]
if max_id is not None:
result = [log for log in result if log.get("id", 0) <= max_id]
return result

def get_logs(self, count: Optional[int] = None) -> List[str]:
def get_logs(self, count: Optional[int] = None,
min_id: Optional[int] = None,
level: Optional[str] = None) -> List[str]:
"""Retrieve logs from buffer."""
if count is None or count > len(self.buffer):
return list(self.buffer)
return list(self.buffer)[-count:]

def normalize_buffer_logs(self, buffer_records):
"""
Takes a list of log strings from buffer and returns a list of clean JSON dicts.
"""
result = []
json_extract = re.compile(r'(\{.*\})') # match JSON inside log line

for record in buffer_records:
match = json_extract.search(record)
if not match:
continue
with self._lock:
filtered_logs = [json.loads(item) for item in self.buffer]
# json_output = json.dumps(filtered_logs, indent=2)
filtered_logs = self.filter_logs(filtered_logs, level=level, min_id=min_id)
if count is not None and count < len(filtered_logs):
filtered_logs = filtered_logs[-count:]
return filtered_logs

def normalize_timestamp_no_microseconds(self, ts: str) -> str:
"""Normalize ISO 8601 timestamp to remove microseconds."""
dt = datetime.fromisoformat(ts)
return dt.replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%S%z")

def normalize_logs(self, json_logs: List[dict]) -> List[dict]:
"""Normalize a list of log entries (dicts)."""
normalized = []
for data in json_logs:
try:
raw_json = json.loads(match.group(1))
# Convert unix timestamp → readable datetime
ts = int(raw_json.get("timestamp", 0))
dt = datetime.utcfromtimestamp(ts).isoformat() + "Z"

entry = {
"timestamp": dt,
"level": raw_json.get("level", "INFO"),
"message": raw_json.get("message", "")
}
result.append(entry)
except (json.JSONDecodeError, ValueError):
continue
# Normalize timestamp (convert unix timestamp → ISO 8601)
ts = data.get("timestamp")

return result
# If it's numeric (e.g., 1759843183), convert it to ISO 8601 UTC
if ts and str(ts).isdigit():
ts_dt = datetime.fromtimestamp(int(ts), tz=timezone.utc)
data["timestamp"] = ts_dt.isoformat()

# If it's ISO 8601 but has microseconds, strip them
if "timestamp" in data:
data["timestamp"] = self.normalize_timestamp_no_microseconds(data["timestamp"])

# Ensure minimal required fields
data.setdefault("level", "INFO")
data.setdefault("message", "")

normalized.append(data)

except (json.JSONDecodeError, TypeError, ValueError) as e:
# If something is not JSON, safely wrap it
normalized.append({
"id": None,
"timestamp": datetime.now(timezone.utc).isoformat(),
"level": "ERROR",
"message": f"Malformed log: {data} ({e})",
})

return normalized

@classmethod
def get_instance(cls):
"""Singleton accessor."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance

def clear(self) -> None:
self.buffer.clear()
Expand Down
36 changes: 26 additions & 10 deletions webserver/logger/formatter.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,35 @@
from datetime import datetime, timezone
import logging
import time
import json


class JsonFormatter(logging.Formatter):
"""Format log records as JSON strings."""
log_id = 0

def format(self, record):
msg = record.getMessage()
self.log_id += 1

def format(self, record: logging.LogRecord) -> str:
log_dict = {
"timestamp": str(int(record.created)), # epoch seconds
# Try to detect pre-formatted JSON
if msg.strip().startswith("{") and msg.strip().endswith("}"):
try:
parsed = json.loads(msg)
# Already JSON — just make sure timestamp exists
if "timestamp" not in parsed:
parsed["timestamp"] = datetime.now(timezone.utc).isoformat()
parsed["id"] = self.log_id
return json.dumps(parsed)

except json.JSONDecodeError:
pass # continue to default formatting

# Not JSON, so create our standard JSON structure
log_entry = {
"id": self.log_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"level": record.levelname,
"message": record.getMessage()
"message": msg,
}
return json.dumps(log_entry)

# Include optional fields if present
if hasattr(record, "source"):
log_dict["source"] = record.source

return json.dumps(log_dict, ensure_ascii=False)
Loading