Vamos con la creación de paquetes. Para explicarlo mejor, me apoyaré en un "solucionador de sudokus" que funciona de maravilla, pero que nunca echarás en falta en tu router.
Esta versión, además de resolver los sudokus, permite guardarlos y restaurarlos en el sistema de archivos UCI.
El código para resolver sudokus no es mío para nada. Está basado en el algoritmo de Peter Norvig (
https://norvig.com/sudoku.html) y la adaptación a Lua de flyinghyrax (
https://github.com/flyinghyrax/rm-sudoku-solver). Lo que no teníamos es la adaptación a LuCI/OpenWrt. En eso sí que tengo algo de culpa.
La primera vez que construí el paquete, instalé el SDK de OpenWrt. Pero no hace falta. El buildroot tampoco. Como la aplicación está escrita en Lua, no hay nada que compilar. Por otro lado, un paquete ipk es, esencialmente, un conjunto de archivos comprimidos. Y para eso ya tenemos el compresor
tar de Linux.
Lua es independiente del hardware y corre en cualquier router OpenWrt sin importar su arquitectura. La aplicación la componen cuatro ficheros que se instalan en determinadas carpetas del router. Para su empaquetado se usan dos ficheros más.
Vamos a ello:
El el PC prepara esta estructura de directorios:
luci-app-sudoku/
├── usr/
│ └── lib/
│ └── lua/
│ └── luci/
│ ├── controller/
│ │ └── sudoku.lua
│ ├── model/
│ │ └── sudoku.lua
│ └── view/
│ └── sudoku/
│ └── sudoku.htm
├── etc/
│ └── config/
│ └── sudoku
│
└── control
En lugar de poner enlaces de descarga, que desaparecen cuando menos te lo esperas, pongo directamente el código:
Fichero usr/lib/lua/luci/contoller/sudoku.lua
module("luci.controller.sudoku", package.seeall)
function index()
entry({"admin", "services", "sudoku"}, call("action_sudoku"), _("Sudoku Solver"), 90)
end
function action_sudoku()
local model = require "luci.model.sudoku"
local http = luci.http
local form = http.formvalue
local mensaje = ""
local input_str = string.rep("0", 81)
local result_str = nil
local saved_boards = model.get_all_boards()
local accion = form("accion")
local input_board = model.get_board_from_form()
-- Caso: sin acción (inicio o reset)
if not accion then
input_str = string.rep("0", 81)
result_str = nil
-- Caso: resolver sudoku
elseif accion == "resolver" then
input_str = model.board_to_string(input_board)
-- Medir el tiempo de resolución
local start_time = os.clock()
local solved, solved_board = model.solve(input_board)
local end_time = os.clock()
local tiempo_resolucion = end_time - start_time
if solved then
result_str = model.board_to_string(solved_board)
mensaje = string.format('<span style="color:var(--success-color-low);">¡Sudoku resuelto en %.2f segundos!</span>', tiempo_resolucion)
else
result_str = nil
mensaje = '<span style="color:var(--error-color-low);">No se pudo resolver el sudoku.</span>'
end
-- Caso: guardar sudoku
elseif accion == "guardar" then
input_str = model.board_to_string(input_board)
local nombre = form("nombre") or "sin_nombre"
model.save_board(nombre, input_str)
mensaje = '<span style="color:var(--success-color-low);">Sudoku guardado.</span>'
saved_boards = model.get_all_boards()
-- Caso: restaurar sudoku
elseif accion == "restaurar" then
local section = form("saved_section")
local board_str = model.get_board(section)
if board_str then
input_str = board_str
mensaje = '<span style="color:var(--primary-color-low);">Sudoku restaurado.</span>'
else
input_str = string.rep("0", 81)
mensaje = '<span style="color:var(--error-color-low);">No se encontró el sudoku.</span>'
end
result_str = nil
-- Caso: borrar sudoku
elseif accion == "borrar" then
local section = form("saved_section")
model.delete_board(section)
input_str = string.rep("0", 81)
mensaje = '<span style="color:var(--error-color-low);">Sudoku borrado.</span>'
result_str = nil
saved_boards = model.get_all_boards()
end
luci.template.render("sudoku/sudoku", {
input = input_str,
result = result_str,
mensaje = mensaje,
saved_boards = saved_boards
})
end
Fichero usr/lib/lua/luci/model/sudoku.lua
--[[
modelo.lua - Módulo de gestión y resolución de Sudokus para LuCI/OpenWrt
Copyright (C) 2025 raphik
Basado en el algoritmo de Peter Norvig (https://norvig.com/sudoku.html)
y en la adaptación a Lua de flyinghyrax (https://github.com/flyinghyrax/rm-sudoku-solver).
Este programa es software libre: puedes redistribuirlo y/o modificarlo bajo los términos
de la Licencia Pública General de GNU publicada por la Free Software Foundation, ya sea
la versión 3 de la Licencia, o (a tu elección) cualquier versión posterior.
Este programa se distribuye con la esperanza de que sea útil, pero SIN GARANTÍA ALGUNA;
ni siquiera la garantía implícita MERCANTIL o de APTITUD PARA UN PROPÓSITO PARTICULAR.
Consulta la Licencia Pública General de GNU para más detalles.
Deberías haber recibido una copia de la Licencia Pública General de GNU junto a este programa.
En caso contrario, consulta <http://www.gnu.org/licenses/>.
Créditos:
- Algoritmo original: Peter Norvig
- Adaptación Lua: flyinghyrax
- Integración y mejoras para OpenWrt/LuCI: raphik
--]]
--[[
Este módulo implementa el modelo de datos para una aplicación de Sudoku en OpenWrt,
utilizando el sistema de configuración UCI para almacenar, cargar y borrar tableros.
Además, integra un solucionador avanzado basado en el algoritmo de Peter Norvig,
adaptado en Lua a partir de la implementación de flyinghyrax.
Funcionalidades principales:
- Gestión de tableros: guardar, cargar, listar y eliminar sudokus en UCI.
- Conversión entre formatos de tablero (cadena y array).
- Resolución eficiente de sudokus, incluyendo puzzles de dificultad "diabólica".
- Interfaz compatible con controladores y vistas existentes en LuCI.
Fecha: 24/05/2025
Ejemplo de uso:
local modelo = require "luci.model.sudoku"
local ok, solucion = modelo.solve(tablero)
--]]
local M = {}
local uci = require "luci.model.uci".cursor()
local sectiontype = "board" -- El tipo de sección es 'board'
-- === Gestión de tableros en UCI ===
function M.get_all_boards()
local boards = {}
uci:foreach("sudoku", sectiontype, function(s)
boards[#boards+1] = {
section = s['.name'],
name = s.name or s['.name'],
input = s.input or string.rep("0", 81),
difficulty = s.difficulty or "?"
}
end)
return boards
end
function M.get_board(section)
return uci:get("sudoku", section, "input")
end
function M.save_board(name, input_str)
local s = uci:add("sudoku", sectiontype)
uci:set("sudoku", s, "name", name)
uci:set("sudoku", s, "input", input_str)
uci:commit("sudoku")
end
function M.delete_board(section)
uci:delete("sudoku", section)
uci:commit("sudoku")
end
function M.string_to_board(str)
local board = {}
for i = 1, #str do
board[i] = tonumber(str:sub(i, i)) or 0
end
return board
end
function M.board_to_string(board)
local t = {}
for i = 1, 81 do
t[#t+1] = tostring(board[i] or 0)
end
return table.concat(t)
end
function M.get_board_from_form()
local board = {}
for i = 0, 8 do
for j = 0, 8 do
local val = luci.http.formvalue("cell_"..i.."_"..j)
board[i*9 + j + 1] = tonumber(val) or 0
end
end
return board
end
-- === Solver Norvig (flyinghyrax adaptado) ===
local digits = "123456789"
local rows = {"A","B","C","D","E","F","G","H","I"}
local cols = {"1","2","3","4","5","6","7","8","9"}
local function cross(A, B)
local res = {}
for _, a in ipairs(A) do
for _, b in ipairs(B) do
res[#res+1] = a..b
end
end
return res
end
local squares = cross(rows, cols)
local unitlist = {}
for _, r in ipairs(rows) do unitlist[#unitlist+1] = cross({r}, cols) end
for _, c in ipairs(cols) do unitlist[#unitlist+1] = cross(rows, {c}) end
for _, rs in ipairs{{"A","B","C"},{"D","E","F"},{"G","H","I"}} do
for _, cs in ipairs{{"1","2","3"},{"4","5","6"},{"7","8","9"}} do
unitlist[#unitlist+1] = cross(rs, cs)
end
end
local units = {}
local peers = {}
for _, s in ipairs(squares) do
units[s] = {}
for _, u in ipairs(unitlist) do
for _, sq in ipairs(u) do
if sq == s then
units[s][#units[s]+1] = u
break
end
end
end
local ps = {}
for _, u in ipairs(units[s]) do
for _, sq in ipairs(u) do
if sq ~= s then ps[sq] = true end
end
end
peers[s] = {}
for sq in pairs(ps) do table.insert(peers[s], sq) end
end
local function parse_grid(grid)
local values = {}
for _, s in ipairs(squares) do values[s] = digits end
local i = 1
for _, s in ipairs(squares) do
local d = grid:sub(i,i)
if digits:find(d, 1, true) then
if not M._assign(values, s, d) then return false end
end
i = i + 1
end
return values
end
function M._assign(values, s, d)
local other = values[s]:gsub(d, "")
for i = 1, #other do
if not M._eliminate(values, s, other:sub(i,i)) then return false end
end
return values
end
function M._eliminate(values, s, d)
if not values[s]:find(d, 1, true) then return values end
values[s] = values[s]:gsub(d, "")
if #values[s] == 0 then return false end
if #values[s] == 1 then
local d2 = values[s]
for _, s2 in ipairs(peers[s]) do
if not M._eliminate(values, s2, d2) then return false end
end
end
for _, u in ipairs(units[s]) do
local dplaces = {}
for _, s2 in ipairs(u) do
if values[s2]:find(d, 1, true) then dplaces[#dplaces+1] = s2 end
end
if #dplaces == 0 then return false end
if #dplaces == 1 then
if not M._assign(values, dplaces[1], d) then return false end
end
end
return values
end
local function search(values)
if not values then return false end
local solved = true
for _, s in ipairs(squares) do
if #values[s] ~= 1 then solved = false break end
end
if solved then return values end
local min, smin = 10, nil
for _, s in ipairs(squares) do
if #values[s] > 1 and #values[s] < min then
min, smin = #values[s], s
end
end
for i = 1, #values[smin] do
local d = values[smin]:sub(i,i)
local vals = {}
for k,v in pairs(values) do vals[k]=v end
local attempt = search(M._assign(vals, smin, d))
if attempt then return attempt end
end
return false
end
-- Interfaz compatible: resuelve un tablero (array de 81 números) y devuelve ok, solución
function M.solve(board)
-- Convierte el array a cadena
local t = {}
for i = 1, 81 do t[#t+1] = tostring(board[i] or 0) end
local str = table.concat(t)
local values = parse_grid(str)
if not values then return false, board end
local result = search(values)
if not result then return false, board end
-- Devuelve la solución como array
local solved = {}
for _, s in ipairs(squares) do
solved[#solved+1] = tonumber(result[s])
end
return true, solved
end
return M
Fichero usr/lib/lua/luci/view/sudoku/sudoku.htm
<%+header%>
<h2>Sudoku Solver</h2>
<div id="sudoku-mensajes" style="min-height:24px;"><%= mensaje or " " %></div>
<form id="sudoku-form" method="post" action="<%=REQUEST_URI%>">
<table class="table" id="sudoku-table" style="width:auto;">
<% for i=0,8 do %>
<tr>
<% for j=0,8 do
local inicial = ""
if input and #input == 81 then
inicial = input:sub(i*9+j+1, i*9+j+1)
end
local valor = ""
local clase = "cbi-input-text sudoku-inicial"
local readonly = ""
if result and #result == 81 then
valor = result:sub(i*9+j+1, i*9+j+1)
if inicial ~= "0" and inicial ~= "" then
clase = clase .. " sudoku-inicial"
readonly = "readonly"
else
clase = clase .. " sudoku-resuelto"
end
else
valor = inicial
if inicial ~= "0" and inicial ~= "" then
clase = clase .. " sudoku-inicial"
end
end
if valor == "0" then valor = "" end
%>
<td class="sudoku-cell sudoku-cell-<%=i%>-<%=j%>">
<input type="text" name="cell_<%=i%>_<%=j%>" maxlength="1" value="<%=valor%>" class="<%=clase%>" <%= readonly %> style="text-align:center; width:2.5em;" />
</td>
<% end %>
</tr>
<% end %>
</table>
<br>
<button type="submit" name="accion" value="resolver" class="cbi-button cbi-input-apply" id="btn-resolver">Resolver</button>
<input type="text" name="nombre" placeholder="Nombre para guardar" class="cbi-input-text" style="margin-left:1em;">
<button type="submit" name="accion" value="guardar" class="cbi-button" id="btn-guardar">Guardar</button>
<button type="button" class="cbi-button" id="btn-limpiar" style="margin-left:1em;">Limpiar tablero</button>
</form>
<form id="sudoku-acciones" method="post" action="<%=REQUEST_URI%>" style="margin-top:2em;">
<label for="saved_section"><b>Sudokus guardados:</b></label>
<select name="saved_section" id="saved_section" class="cbi-input-select">
<% for i, s in ipairs(saved_boards or {}) do %>
<option value="<%=s.section%>"><%=s.name%> [<%=s.difficulty%>]</option>
<% end %>
</select>
<button type="submit" name="accion" value="restaurar" class="cbi-button" id="btn-restaurar">Restaurar</button>
<button type="submit" name="accion" value="borrar" class="cbi-button cbi-input-remove" id="btn-borrar">Borrar</button>
</form>
<style>
#sudoku-table {
border: 3px solid var(--text-color-high);
border-collapse: collapse;
background-color: var(--background-color-medium);
}
#sudoku-table td {
border: 1px solid var(--text-color-medium);
padding: 0;
}
#sudoku-table tr:nth-child(3n) td {
border-bottom: 2.5px solid var(--text-color-medium);
}
#sudoku-table td:nth-child(3n) {
border-right: 2.5px solid var(--text-color-medium);
}
#sudoku-table tr:first-child td {
border-top: 2.5px solid var(--text-color-medium);
}
#sudoku-table td:first-child {
border-left: 2.5px solid var(--text-color-medium);
}
.sudoku-inicial {
font-weight: bold;
color: var(--primary-color-low) !important;
background: transparent !important;
}
.sudoku-resuelto {
font-weight: normal;
color: var(--error-color-low) !important;
background: transparent !important;
}
#sudoku-table input[type="text"] {
background: transparent !important;
border: none;
box-shadow: none;
}
</style>
<script>
document.addEventListener("DOMContentLoaded", function() {
var msgDiv = document.getElementById("sudoku-mensajes");
var btnResolver = document.getElementById("btn-resolver");
if (btnResolver) {
btnResolver.addEventListener("click", function() {
if (msgDiv) msgDiv.innerHTML = '<span class="spinning" style="color:blue;">Resolviendo...</span>';
});
}
var btnGuardar = document.getElementById("btn-guardar");
if (btnGuardar) {
btnGuardar.addEventListener("click", function() {
if (msgDiv) msgDiv.innerHTML = '<span class="spinning" style="color:blue;">Guardando...</span>';
});
}
var btnRestaurar = document.getElementById("btn-restaurar");
if (btnRestaurar) {
btnRestaurar.addEventListener("click", function() {
if (msgDiv) msgDiv.innerHTML = '<span class="spinning" style="color:blue;">Restaurando...</span>';
});
}
var btnBorrar = document.getElementById("btn-borrar");
if (btnBorrar) {
btnBorrar.addEventListener("click", function() {
if (msgDiv) msgDiv.innerHTML = '<span class="spinning" style="color:blue;">Borrando...</span>';
});
}
// Botón para limpiar el tablero (solo frontend)
var btnLimpiar = document.getElementById("btn-limpiar");
if (btnLimpiar) {
btnLimpiar.addEventListener("click", function() {
document.querySelectorAll('#sudoku-table input[type="text"]').forEach(function(input) {
input.value = '';
input.removeAttribute('readonly'); // ¡Permite editar todas las celdas!
input.className = "cbi-input-text sudoku-inicial"; // Solo la clase base
});
if (msgDiv) msgDiv.innerHTML = " ";
});
}
});
</script>
<%+footer%>
Fichero etc/config/sudoku
config board
option name 'Everest de Arto Inkala'
option input '800000000003600000070090200050007000000045700000100030001000068008500010090000400'
option difficulty 'diabólico'
config board
option name 'The Imitation Game'
option input '005300000800000020070010500400005300010070006003200080060500009004000030000009700'
option difficulty 'diabólico'
config board
option name 'AI Escargot'
option input '000000010400000000020000000000050407008000300001090000300400200050100000000806000'
option difficulty 'diabólico'
config board
option name 'Diabólico 4'
option input '000900002050123400000000000000000000000000000000000000000000000003000000000000001'
option difficulty 'diabólico'
config board
option name 'Diabólico 5'
option input '100007090030020008009600500005300900010080002600004000300000010040000007007000300'
option difficulty 'diabólico'
config board
option name 'Diabólico 6'
option input '100000000000003000000020500000000010050407020000000000001000000000000806000000000'
option difficulty 'diabólico'
config board
option name 'Diabólico 7'
option input '000000000000000000000000000000000000000000000000000000000000000000000000000000001'
option difficulty 'diabólico'
config board
option name 'Diabólico 8'
option input '000000000000000000000000000000000000000000000000000000000000000000000000000000009'
option difficulty 'diabólico'
config board
option name 'Diabólico 9'
option input '000000000000000000000000000000000000000000000000000000000000000000000000000000006'
option difficulty 'diabólico'
config board
option name 'Diabólico 10'
option input '000000000000000000000000000000000000000000000000000000000000000000000000000000007'
option difficulty 'diabólico'
Fichero control. Su nombre lo dice todo; sirve para llevar el control de las versiones del paquete generado. Va en la raiz de la carpeta que soporta toda la estructura del paquete. Es muy importante que su última línea esté en blanco.
Package: luci-app-sudoku
Version: 1.00
Description: Sudoku solver in Lua - just for fun, learning, or because it can.
Maintainer: NOBODY <abandon@ware.com>
Architecture: all
Depends: luci-compat, lua
Por último el fichero ipk_builder.sh. es un script en bash (hay que hacerlo ejecutable) que lanza el proceso automatizado de construcción del paquete ipk y subida e instalación , en el router.
#!/bin/bash
# Configuración
ROUTER_IP="192.168.1.5"
USER="root"
CONTROL="control"
# Extrae nombre y versión del paquete
PKG=$(grep '^Package:' $CONTROL | awk '{print $2}')
VER=$(grep '^Version:' $CONTROL | awk '{print $2}')
IPK="${PKG}_${VER}.ipk"
# 1. Empaqueta el .ipk
tar czf data.tar.gz --owner=0 --group=0 -C . etc usr
tar czf control.tar.gz -C . control
echo "2.0" > debian-binary
tar czf "$IPK" control.tar.gz data.tar.gz debian-binary
# 2. Copia el .ipk al router
scp "$IPK" $USER@$ROUTER_IP:/tmp/
# 3. Instala el paquete y reinicia el servidor web
ssh $USER@$ROUTER_IP "opkg install --force-reinstall --force-overwrite /tmp/$IPK && /etc/init.d/uhttpd restart"
# 4. Limpieza local opcional
rm control.tar.gz data.tar.gz debian-binary
echo "¡Paquete $IPK generado, transferido, instalado (forzando sobreescritura) y servicio reiniciado en $ROUTER_IP!"
Después de construir un primer paquete con el SDK de OpenWrt, me di cuenta que un paquete ipk es, esencialmente, un conjunto de archivos comprimidos. Y para eso ya tenemos el compresor
tar de Linux.
No sé si todo este rollo interesa o no, pero al menos yo me he despachado a gusto.
