Zyxel GS1900-24EP support, production ready?

Beg to differ.

Pretty sure that PoE works perfectly fine, if you use this:

/usr/bin/poemgr-realtek:

#!/usr/bin/lua

-------------------------------------------------------------
----  For testing, start in foreground with:
----  $ poemgr-realtek -d -v
----
----  ("-d" is debug, "-v" is verbose)
----
----  While the daemon is running, PoE ports can be manually
----  enabled via ubus:
----  $ ubus call poe manage "{'port':'lan1', 'enable':true}"
----
----  Inspect the per-port consumption to see if power is delivered:
----  $ ubus call poe info
----
----  If everything works, install the PoE manager:
----    1. poe manager (this file)   --> /usr/bin/poemgr-realtek
----    2. service definition file   --> /etc/init.d/poemgr-realtek
----    3. configuration             --> /etc/config/poe
----
----  And install required packages / dependencies:
----  $ opkg install lua-rs232 libuci-lua libubus-lua
----
----  Start the service via GUI (System -> Startup) or CLI:
----  $ service poemgr-realtek start
----
----  In the configuration file, PoE ports can be enabled or
----  disabled with the "enable" property.
----
----  Priority groups can be configured with "priority".
----  Priorities range from 0 (low) to 3 (critical), with
----  1 (normal) and 2 (high) in between.
----
----  Mapping between the PSE power lines and Ethernet ports
----  can be adjusted with the "id" and "name" properties.
----
----  After modifying the configuration file or updating
----  configuration via UCI, perform a commit to push
----  changes to the PoE controller:
----  $ ubus call uci commit '{"config":"poe"}'
----
----  For reading out internal statistics, the following
----  command is available.
----  $ ubus call poe stats
----
----  For experimenting with new or updated opcodes in the
----  controller firmware, the following command is available.
----  $ ubus call poe sendframe '{"frame":"40"}'
-------------------------------------------------------------

require "uci"
require "ubus"
require "uloop"

local rs = require "luars232"
local stderr = io.stderr

-- PSE known power limits for smallest supported device (zyxel,gs1900-10hp-v2)
local PSE_WATTS = 77.0
local PSE_GUARD = 11.0

-- SoC serial port for talking to MCU
local SOC_UART = "/dev/ttyS1"

-- MCU baud rate (Realtek RTL8238)
local MCU_RATE = rs.RS232_BAUD_115200

-- How many times to try again in case of timeouts, checksum errors etc.
local MAX_RETRIES = 3

-- MCU error codes (Realtek RTL8238)
local MCU_ERR_CHECKSUM = 0x62
local MCU_ERR_INCOMPLETE = 0xfd
local MCU_ERR_MALFORMED = 0xfe
local MCU_ERR_BUSY = 0xff

-- MCU commands (Realtek RTL8238)
local MCU_CMD_GET_CONTROLLER_STATUS = 0x40
local MCU_CMD_SET_GLOBAL_POWER_LIMITS = 0x04
local MCU_CMD_GET_GLOBAL_POWER_USAGE = 0x41
local MCU_CMD_SET_PORT_PRIORITY = 0x15
local MCU_CMD_SET_PORT_POWER_DELIVERY = 0x01
local MCU_CMD_GET_PORT_POWER_USAGE = 0x44

-- Hardware the above is tested on
local MATCH_BOARDNAME_1 = "ZyXEL_GS1900_10HPv2"
local MATCH_BOARDNAME_2 = "ZyXEL_GS1900_24EP"
local MATCH_PSEID_1 = "4"
local MATCH_PSEID_2 = ""

-- Text markings on packages - guesswork, need photos
local CHIPNAME_PSE = "Nuvoton NUC029ZAN"
local CHIPNAME_MCU = "Realtek RTL8238"

-- Internal statistics counters
local stats_msg = 0
local stats_timeout = 0
local stats_checksum = 0
local stats_error = 0
local stats_notify = 0
local stats_reload = 0

-- Command-line options
local skip_hwcheck = false
local log_verbose = false
local log_debug = false

-- Filled with defaults (1=lan1, 2=lan2 etc.) during init
local ifname
local portid, portprio, portenable
local budget, guard
local poe_ports

-- Globals
local seq = 0
local queue = {}

function verbose(s)
	if log_verbose then io.write(s) end
end

function debug(s)
	if log_debug then io.write(s) end
end

function check(cond, msg)
	if not cond then
		if type(msg) == "function" then msg = msg() end
		stderr:write(msg)
		assert(false)
	end
end

function read_fw(tool, key)
	local handle = io.popen(string.format("%s -n %s", tool, key))
	local output = handle:read("*a")
	local res = output:gsub('[\n\r]', '')
	handle:close()
	return res
end

function read_fw_env(key)
	return read_fw("fw_printenv", key)
end

function read_fw_sys(key)
	return read_fw("fw_printsys", key)
end

function check_compatible()
	if skip_hwcheck then
		verbose("skip board revision check\n")
	else
		local model = read_fw_env("boardmodel")
		local pseid = read_fw_sys("pseId")
		check(
			(model == MATCH_BOARDNAME_1 or model == MATCH_BOARDNAME_2) and (pseid == MATCH_PSEID_1 or pseid == MATCH_PSEID_2),
			"ERROR: unknown hardware model or revision\n"
		)
	end
end

function configure_uart()
	local err, handle = rs.open(SOC_UART)
	check(err == rs.RS232_ERR_NOERROR, function() return string.format("ERROR: failed to open('%s'): %s\n", SOC_UART, rs.error_tostring(err)) end)

	check(handle:set_baud_rate(MCU_RATE) == rs.RS232_ERR_NOERROR, "ERROR: failed to set baud rate")
	check(handle:set_data_bits(rs.RS232_DATA_8) == rs.RS232_ERR_NOERROR, "ERROR: failed to set data bits")
	check(handle:set_parity(rs.RS232_PARITY_NONE) == rs.RS232_ERR_NOERROR, "ERROR: failed to set parity")
	check(handle:set_stop_bits(rs.RS232_STOP_1) == rs.RS232_ERR_NOERROR, "ERROR: failed to set stop bits")
	check(handle:set_flow_control(rs.RS232_FLOW_OFF)  == rs.RS232_ERR_NOERROR, "ERROR: failed to disable flow control")

	io.write(string.format("configured %s\n", tostring(handle)))
	synchronize(handle)
	return handle
end

function synchronize(handle)
	local interval = 1000
	local buffer = {}
	local size = 1
	local err, data, i

	io.write(string.format("wait for quiescent %s\n", SOC_UART))
	while size > 0 do
		err, data, size = handle:read(4096, interval)
		if err == rs.RS232_ERR_NOERROR then
			for i = 1, string.len(data) do
				table.insert(buffer, string.byte(string.sub(data, i, i)))
			end
		elseif err == rs.RS232_ERR_TIMEOUT then
			size = 0
		else
			check(false, string.format("ERROR: failed to read(): %s\n", rs.error_tostring(err)))
		end
	end

	if #buffer > 0 then
		debug("RX")
		for i = 1, #buffer do
			debug(string.format(" %02x", buffer[i]))
		end
		debug("\n")
	end
end

function try_process_unsolicited()
	-- handle any notifications received asynchronously
	stats_notify = stats_notify + #queue
	if #queue == 0 then return nil end
	verbose(string.format("ignoring %d unknown notifications from controller\n", #queue))
	queue = {}
end

function try_exchange_message(h, xmit)
	local interval = 1000
	local retries = 10
	local recv = {}
	local round = 0
	local sum = 0
	local data = ""
	local err, size, left, i

	if #xmit > 12 then
		verbose(string.format("output frame too large, discard\n", xmit))
		return nil
	end

	if #xmit < 1 then
		verbose("output frame too small, discard\n")
		return nil
	end

	-- reserve all-zeroes and bit 2^7 for controller
	seq = (seq % 127) + 1
	xmit[2] = seq

	-- pad to full frame length
	while #xmit < 12 do
		table.insert(xmit, 0xff)
	end

	-- coalesce to zero to avoid crash on invalid input
	for i = 1, 12 do
		if type(xmit[i]) ~= "number" then xmit[i] = 0 end
		xmit[i] = math.floor(math.abs(xmit[i])) % 256
	end

	-- calculate checksum
	for i = 1, 11 do
		sum = sum + xmit[i]
	end
	xmit[12] = sum % 256

	debug("TX ->")
	for i = 1, 12 do
		debug(string.format(" %02x", xmit[i]))
	end
	debug("\n")

	for i = 1, 12 do
		data = data .. string.char(xmit[i])
	end

	err, size = h:write(data)
	check(err == rs.RS232_ERR_NOERROR, function() return string.format("ERROR: failed to write(): %s\n", rs.error_tostring(err)) end)

	while round < retries do
		while round < retries and #recv < 12 do
			err, data, size = h:read(12 - #recv, interval)
			if err == rs.RS232_ERR_NOERROR then
				check(#recv + size < 13, "ERROR: read() returned more octets than asked")
				check(size > 0, "ERROR: RX buffer emptied between select() and read()")
				for i = 1, string.len(data) do
					table.insert(recv, string.byte(string.sub(data, i, i)))
				end
			elseif err ~= rs.RS232_ERR_TIMEOUT then
				check(false, string.format("ERROR: failed to read(): %s\n", rs.error_tostring(err)))
			end
			round = round + 1
		end

		if #recv > 0 then
			stats_msg = stats_msg + 1
			debug("RX <-")
			for i = 1, #recv do
				debug(string.format(" %02x", recv[i]))
			end
			debug("\n")
		end

		if #recv < 12 then
			stats_timeout = stats_timeout + 1
			stderr:write("timeout while waiting for response\n")
			return nil
		end

		sum = 0
		for i = 1, 11 do
			sum = sum + recv[i]
		end

		if sum % 256 ~= recv[12] then
			stats_checksum = stats_checksum + 1
			stderr:write("frame checksum invalid in response\n")
			return nil

		elseif recv[1] ~= xmit[1] and recv[2] ~= xmit[2] then
			verbose("unsolicited frame, add to queue\n");
			table.insert(queue, recv)
			recv = {}
			round = 0

		elseif recv[2] ~= xmit[2] then
			recv = {}
			verbose("wrong sequence number from controller, discard\n")

		elseif recv[1] == MCU_ERR_CHECKSUM then
			stats_error = stats_error + 1
			verbose("controller report: invalid checksum\n")

		elseif recv[1] == MCU_ERR_INCOMPLETE then
			stats_error = stats_error + 1
			verbose("controller report: incomplete request\n")

		elseif recv[1] == MCU_ERR_MALFORMED then
			stats_error = stats_error + 1
			verbose("controller report: malformed request\n")

		elseif recv[1] == MCU_ERR_BUSY then
			stats_error = stats_error + 1
			verbose("controller report: busy\n")

		elseif recv[1] ~= xmit[1] then
			stats_error = stats_error + 1
			verbose(string.format("controller report: (unknown error reply %s)\n", recv[1]));

		else
			return recv
		end
	end
end

function exchange_message_once(h, xmit)
	local recv = try_exchange_message(h, xmit)
	try_process_unsolicited()
	return recv
end

function exchange_message(h, xmit)
	local recv, round
	for round = 1, MAX_RETRIES do
		if round > 1 then verbose("Retrying...\n") end
		recv = exchange_message_once(h, xmit)
		if recv ~= nil then break end
	end
	return recv
end

function uintbe16(nr)
	-- coalesce to zero to avoid crash on malformed input
	if type(nr) ~= "number" then nr = 0 end
	local l = math.floor(nr * 10 / 256)
	local r = math.floor(nr * 10) % 256
	return l, r
end

function get_controller_status(h)
	verbose("request controller status\n")
	local cmd = {MCU_CMD_GET_CONTROLLER_STATUS}
	local reply = exchange_message(h, cmd)
	if type(reply) == "nil" then return nil end
	table.remove(reply, #reply)
	table.remove(reply, 1)
	table.remove(reply, 1)
	return unpack(reply)
end

function set_power_limits(h, total, guard)
	verbose(string.format("set power limits, global budget: %s, port guard: %s\n", total, guard))
	local cmd = {MCU_CMD_SET_GLOBAL_POWER_LIMITS, 0, 0x00}
	cmd[4], cmd[5] = uintbe16(total)
	cmd[6], cmd[7] = uintbe16(guard)
	exchange_message(h, cmd)
end

function get_global_power_usage(h)
	verbose("get global power usage\n")
	local cmd = {MCU_CMD_GET_GLOBAL_POWER_USAGE, 0}
	local reply = exchange_message(h, cmd)
	if type(reply) == "nil" then return nil end
	local watts = (reply[3] * 256 + reply[4]) / 10.0
	verbose(string.format("global watts: %s\n", watts))
	return watts
end

function set_port_priority(h, port, prio)
	verbose(string.format("set priority %s for port %s\n", prio, port))
	local cmd = {MCU_CMD_SET_PORT_PRIORITY, 0, port, prio}
	exchange_message(h, cmd)
end

function disable_port_power(h, port)
	verbose(string.format("disable power on port %s\n", port))
	local cmd = {MCU_CMD_SET_PORT_POWER_DELIVERY, 0, port, 0x00}
	exchange_message(h, cmd)
end

function enable_port_power(h, port)
	verbose(string.format("enable power on port %s\n", port))
	local cmd = {MCU_CMD_SET_PORT_POWER_DELIVERY, 0, port, 0x01}
	exchange_message(h, cmd)
end

function get_port_power_usage(h, port)
	verbose(string.format("get power usage on port %s\n", port))
	local cmd = {MCU_CMD_GET_PORT_POWER_USAGE, 0, port}
	local reply = exchange_message(h, cmd)
	if type(reply) == "nil" then return nil end
	local watts = (reply[10] * 256 + reply[11]) / 10.0
	local milliamps = reply[6] * 256 + reply[7]
	verbose(string.format("port %s watts: %s milliAmps: %s\n", port, watts, milliamps))
	return watts, milliamps
end

function count_ports(h)
	local ports = ({get_controller_status(h)})[2]
	check(type(ports) ~= "nil", "ERROR: failed to get controller global state\n")
	io.write(string.format("controller status request ok, %s ports\n", tostring(ports)))
	poe_ports = ports
end

function ensure_defaults()
	check(type(poe_ports) ~= "nil", "ERROR: port count not retrieved yet\n")
	-- default available pse port ids
	portid = {}
	local i
	for i = 1, poe_ports do
		table.insert(portid, i)
	end
	-- default global policy
	budget = PSE_WATTS
	guard = PSE_GUARD
	-- default pse port id to interface name mapping is lan1->pse1, lan2->pse2 etc.
	ifname = {}
	for i = 1, poe_ports do
		ifname[i] = "lan" .. tostring(i)
	end
	-- default is empty, so to do nothing
	portprio = {}
	portenable = {}
end

function load_config()
	-- preferred if each poe controller had its own
	-- top-level section (fx. "poe.poectrl0") but alas
	local cfg = uci.cursor()
	local k, v

	local global_budget
	local global_guard
	cfg:foreach("poe", "global", function(s)
		for k, v in pairs(s) do
			if k == "budget" then global_budget = tonumber(v) end
			if k == "guard" then global_guard = tonumber(v) end
		end
	end)
	-- if user supplied budget and/or guard values exist, apply
	if type(global_budget) == "number" then budget = global_budget end
	if type(global_guard) == "number" then guard = global_guard end

	verbose("loaded global policy")
	if type(global_budget) == "number" then verbose(" [budget]") end
	if type(global_guard) == "number" then verbose(" [guard]") end
	verbose("\n")

	local custom_ifname = {}
	local custom_enable = {}
	local custom_prio = {}
	local custom_portid = {}
	cfg:foreach("poe", "port", function(s)
		local p_id
		local p_prio
		local p_enable
		local p_name
		for k, v in pairs(s) do
			if k == "id" then p_id = tonumber(v) end
			if k == "priority" then p_prio = tonumber(v) end
			if k == "enable" then p_enable = tostring(v) end
			if k == "name" then p_name = tostring(v) end
		end
		if type(p_id) == "number" then
			table.insert(custom_portid, p_id)
			if type(p_prio) == "number" then custom_prio[p_id] = p_prio end
			if type(p_enable) == "string" then
				custom_enable[p_id] = (p_enable ~= "0" and p_enable ~= "false" and p_enable ~= "off" and p_enable ~= "no")
			end
			if type(p_name) == "string" then custom_ifname[p_id] = p_name end
		end
	end)
	-- user supplied pse port id values exist, apply
	if #custom_portid > 0 then portid = custom_portid end
	-- user supplied pse port priorities exist, apply
	if #custom_prio > 0 then portprio = custom_prio end
	-- user supplied pse port enable exist, apply
	if #custom_enable > 0 then portenable = custom_enable end
	-- user supplied eth<->pse interface name/id mapping exist, apply
	if #custom_ifname > 0 then ifname = custom_ifname end

	verbose("loaded per-port policy")
	if #custom_portid > 0 then verbose(" [port-ids: " .. tostring(#custom_portid) .. "]") end
	if #custom_prio > 0 then verbose(" [port-prios: " .. tostring(#custom_prio) .. "]") end
	if #custom_enable > 0 then verbose(" [port-enables: " .. tostring(#custom_enable) .. "]") end
	if #custom_ifname > 0 then verbose(" [port-idnames: " .. tostring(#custom_ifname) .. "]") end
	verbose("\n")

	stats_reload = stats_reload + 1
end

function apply_config(h)
	-- set global policy (budget etc.)
	set_power_limits(h, budget, guard)
	-- set policy for each port (priority etc.)
	local port, prio
	for port, prio in pairs(portprio) do
		set_port_priority(h, port - 1, prio)
	end
end

function run_administrative_actions(h)
	-- enable and disable individual ports
	local port, enable
	for port, enable in pairs(portenable) do
		local bit = enable ~= 0
		if bit then
			enable_port_power(h, port - 1)
		else
			disable_port_power(h, port - 1)
		end
	end
end

function parse_cmdline()
	local i
	for i = 1, #arg do
		if arg[i] == "-v" then log_verbose = true end
		if arg[i] == "-d" then log_debug = true end
		if arg[i] == "-f" then skip_hwcheck = true end
	end
end

-- adjust verbosity etc.
parse_cmdline()
-- quit early if wrong hardware
check_compatible()
-- make sure we can talk to the MCU
local h = configure_uart()
-- grab a port count used to build defaults
count_ports(h)
-- configure final bits and pieces of configurable settings
ensure_defaults()
-- load user config
load_config()
-- apply loaded configuration
apply_config(h)
-- finally, after applying policies, enable and disable power on the ports
run_administrative_actions(h)

uloop.init()

local conn = ubus.connect()
check(conn, "ERROR: could not connect to ubus\n")

local methods = {
	-- could register as eg. poe[0] in case of multiple controllers
	poe = {
		reload = {
			function(req, msg)
				ensure_defaults()
				load_config()
				apply_config(h)
				run_administrative_actions(h)
			end, {}
		},
		stats = {
			function(req, msg)
				local reply = {}
				reply['configuration reloads'] = stats_reload
				reply['messages exchanged'] = stats_msg
				reply['communication timeouts'] = stats_timeout
				reply['frame checksum errors'] = stats_checksum
				reply['notifications received'] = stats_notify
				reply['controller errors'] = stats_error
				conn:reply(req, reply)
			end, {}
		},
		sendframe = {
			function(req, msg)
				local reply = {}
				local hex = tostring(msg.frame)
				if type(hex) ~= "string" then hex = "" end
				hex = string.gsub(hex, '[^%x]', '')
				local xmit = {}
				for k in string.gmatch(hex, "(%x%x)") do table.insert(xmit, tonumber(k, 16)) end
				local recv = exchange_message_once(h, xmit)
				if type(recv) ~= "nil" then reply.frame = string.gsub(table.concat(recv, ' '), '(%d+)', function(k) return string.format('%02x', k) end) end
				conn:reply(req, reply)
			end, {frame = ubus.STRING}
		},
		info = {
			function(req, msg)
				local reply = {}
				local mcu_usage = get_global_power_usage(h)

				-- static values
				reply.mcu = CHIPNAME_MCU
				reply.pse = CHIPNAME_PSE

				-- values applied during last config reload
				reply.budget = string.format("%.1f", budget)
				reply.guard = string.format("%.1f", guard)

				-- values from controller
				reply.poe_ports = poe_ports
				-- seem to report about 4x too high ?
				reply.consumption = string.format("%.1f", mcu_usage)

				reply.ports = {}
				local i
				for i = 1, #portid do
					local item = {}
					local id = portid[i]
					local name = ifname[id]
					local prio = portprio[id]
					local enable = portenable[id]
					local w, ma = get_port_power_usage(h, id - 1)

					-- values applied during last config reload
					if type(name) ~= "nil" then item.name = name end
					if type(prio) ~= "nil" then item.priority = prio end
					if type(enable) ~= "nil" then
						if enable then item.enabled = "yes" else item.enabled = "no" end
					end

					-- values from controller
					item.consumption = string.format("%.1f", w)
					-- figure out command to fetch these
					--item.status = "disable" or "search" or "deliver" or "unknown"
					--item.mode = "poe++" or "poe+" or "poe", alt. "802.3bt" or "802.3at" or "802.3af"

					table.insert(reply.ports, item)
				end
				conn:reply(req, reply)
			end, {}
		},
		manage = {
			function(req, msg)
				local enable = msg.enable
				local portname = msg.port
				if type(enable) == "boolean" and type(portname) == "string" then
					local i
					for i = 1, #portid do
						local id = portid[i]
						local name = ifname[id]
						if name == portname then
							portenable[id] = enable
							if enable then
								enable_port_power(h, id - 1)
							else
								disable_port_power(h, id - 1)
							end
						end
					end
				end
			end, {port = ubus.STRING, enable = ubus.BOOLEAN}
		},
	},
}

conn:add(methods)

uloop.run()

/etc/init.d/poemgr-realtek:

#!/bin/sh /etc/rc.common

START=80
USE_PROCD=1
PROG=/usr/bin/poemgr-realtek

reload_service() {
	ubus call poe reload
}

service_triggers() {
	procd_add_config_trigger "config.change" "poe" ubus call poe reload
}

start_service() {
	procd_open_instance
	procd_set_param command "$PROG"
	procd_set_param respawn
	procd_set_param stdout 1
	procd_set_param stderr 1
	procd_close_instance
}

Since it was specifically designed for that new PSE controller...

I'm using it myself on another switch (Zyxel GS1900-10HPv2) having that same PSE controller with great success.

Still waiting for someone to actually test it on the Zyxel GS1900-24EP though (without running realtek-poe at the same time), please feel free to apply if you have this switch.