from flask import Flask, request, redirect, url_for, render_template_string
import requests
from typing import Any, Dict, List, Optional, Tuple
app = Flask(__name__)
app.config["SECRET_KEY"] = "sh-schema-editor-dev"
CHANNEL_COUNT = 8
CHANNEL_BYTES = 4
SCHEMA_LEN = CHANNEL_COUNT * CHANNEL_BYTES
SH_PIN_UNUSED = 255
FIELD_NAMES = ["pin", "indicator", "feedback", "flags"]
FIELD_LABELS = {
"pin": "Pin",
"indicator": "Indicator",
"feedback": "Feedback",
"flags": "Flags",
}
HTML = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SH Channels Schema Editor</title>
<style>
:root {
--bg: #0f1115;
--panel: #181c23;
--panel-2: #202632;
--text: #e8eef7;
--muted: #9fb0c7;
--accent: #4da3ff;
--accent-2: #2d7fd6;
--danger: #d86161;
--ok: #5fbc7a;
--border: #2f3745;
--input: #121720;
--warning: #d8aa52;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, sans-serif;
background: var(--bg);
color: var(--text);
}
.wrap {
max-width: 1180px;
margin: 0 auto;
padding: 24px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 18px;
margin-bottom: 18px;
}
h1, h2, h3 { margin-top: 0; }
.muted { color: var(--muted); }
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.grid-4 {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
label {
display: block;
font-size: 14px;
margin-bottom: 6px;
color: var(--muted);
}
input[type=text], input[type=number] {
width: 100%;
background: var(--input);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
outline: none;
}
input[type=checkbox] {
transform: scale(1.2);
}
.btn-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 14px;
}
button {
background: var(--accent);
color: white;
border: 0;
border-radius: 10px;
padding: 10px 14px;
cursor: pointer;
font-weight: 700;
}
button.secondary {
background: #445067;
}
button.warn {
background: var(--warning);
color: #111;
}
button:hover { background: var(--accent-2); }
.channel-card {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px;
margin-bottom: 14px;
}
.channel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
.channel-title {
font-size: 18px;
font-weight: 700;
}
.tag {
display: inline-block;
padding: 4px 8px;
border-radius: 999px;
background: #273140;
color: var(--muted);
font-size: 12px;
border: 1px solid var(--border);
}
.msg {
padding: 12px 14px;
border-radius: 10px;
margin-bottom: 14px;
border: 1px solid var(--border);
}
.msg.ok { background: rgba(95,188,122,0.14); color: #dff4e6; }
.msg.err { background: rgba(216,97,97,0.14); color: #ffdede; }
.msg.info { background: rgba(77,163,255,0.14); color: #e2f0ff; }
.mono {
font-family: Consolas, monospace;
white-space: pre-wrap;
word-break: break-word;
}
.topline {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.small { font-size: 13px; }
.flags-row {
display: flex;
gap: 18px;
align-items: center;
flex-wrap: wrap;
margin-top: 8px;
}
.inline-check {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
}
.footer-note {
color: var(--muted);
font-size: 13px;
margin-top: 12px;
}
@media (max-width: 900px) {
.grid, .grid-4 { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="wrap">
<div class="topline">
<div>
<h1>SH Channels Schema Editor</h1>
<div class="muted">Port 8001. Reads and writes <span class="mono">/channels_schema</span> and <span class="mono">/set_channels_schema</span>.</div>
</div>
{% if device_ip %}
<div class="tag">Device: {{ device_ip }}</div>
{% endif %}
</div>
{% if message %}
<div class="msg {{ message_type }}">{{ message }}</div>
{% endif %}
<div class="panel">
<h2>Connection</h2>
<form method="post" action="{{ url_for('connect') }}">
<div class="grid">
<div>
<label for="device_ip">Device IP or host</label>
<input id="device_ip" name="device_ip" type="text" value="{{ device_ip }}" placeholder="192.168.1.50">
</div>
<div>
<label for="token">Authorization token</label>
<input id="token" name="token" type="text" value="{{ token }}" placeholder="Bearer token value only">
</div>
</div>
<div class="btn-row">
<button type="submit">Load schema from device</button>
</div>
</form>
<div class="footer-note">In setup mode the token may be empty. In normal mode the device usually requires a valid Bearer token.</div>
</div>
{% if schema %}
<div class="panel">
<h2>Schema editor</h2>
<form method="post" action="{{ url_for('save_schema') }}">
<input type="hidden" name="device_ip" value="{{ device_ip }}">
<input type="hidden" name="token" value="{{ token }}">
{% for channel in channels %}
<div class="channel-card">
<div class="channel-header">
<div class="channel-title">Channel {{ channel.index }}</div>
<div class="tag">Bytes {{ channel.base }}..{{ channel.base + 3 }}</div>
</div>
<div class="grid-4">
<div>
<label for="pin_{{ channel.index }}">Pin</label>
<input id="pin_{{ channel.index }}" name="pin_{{ channel.index }}" type="number" min="0" max="255" value="{{ channel.pin }}">
</div>
<div>
<label for="indicator_{{ channel.index }}">Indicator</label>
<input id="indicator_{{ channel.index }}" name="indicator_{{ channel.index }}" type="number" min="0" max="255" value="{{ channel.indicator }}">
</div>
<div>
<label for="feedback_{{ channel.index }}">Feedback</label>
<input id="feedback_{{ channel.index }}" name="feedback_{{ channel.index }}" type="number" min="0" max="255" value="{{ channel.feedback }}">
</div>
<div>
<label for="flags_{{ channel.index }}">Flags (raw byte)</label>
<input id="flags_{{ channel.index }}" name="flags_{{ channel.index }}" type="number" min="0" max="255" value="{{ channel.flags }}">
</div>
</div>
<div class="flags-row">
<label class="inline-check">
<input type="checkbox" name="invert_{{ channel.index }}" {% if channel.invert %}checked{% endif %}>
Invert channel (bit 0)
</label>
<button class="secondary" type="submit" name="fill_unused" value="{{ channel.index }}">Set this channel unused</button>
</div>
</div>
{% endfor %}
<div class="btn-row">
<button type="submit">Save schema to device</button>
<button class="secondary" type="submit" name="normalize" value="1">Normalize invert flags from checkboxes</button>
<button class="warn" type="submit" name="fill_all_unused" value="1">Fill all with 255/0</button>
</div>
</form>
</div>
<div class="panel">
<h2>Raw schema</h2>
<div class="mono small">{{ schema | join(', ') }}</div>
</div>
{% endif %}
</div>
</body>
</html>
"""
def build_headers(token: str) -> Dict[str, str]:
headers: Dict[str, str] = {"Content-Type": "application/json"}
token = token.strip()
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def normalize_ip(device_ip: str) -> str:
device_ip = device_ip.strip()
if device_ip.startswith("http://") or device_ip.startswith("https://"):
return device_ip.rstrip("/")
return f"http://{device_ip}"
def parse_device_schema(payload: Dict[str, Any]) -> List[int]:
schema = payload.get("schema")
if not isinstance(schema, list):
raise ValueError("Device response does not contain a schema array")
if len(schema) != SCHEMA_LEN:
raise ValueError(f"Schema length is {len(schema)}, expected {SCHEMA_LEN}")
normalized: List[int] = []
for value in schema:
if not isinstance(value, int):
raise ValueError("Schema contains a non-integer value")
if value < 0 or value > 255:
raise ValueError("Schema values must be in range 0..255")
normalized.append(value)
return normalized
def get_schema(device_ip: str, token: str) -> List[int]:
base_url = normalize_ip(device_ip)
response = requests.get(
f"{base_url}/channels_schema",
headers=build_headers(token),
timeout=5,
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("message") or data.get("error") or "Device returned an error")
return parse_device_schema(data)
def set_schema(device_ip: str, token: str, schema: List[int]) -> Dict[str, Any]:
base_url = normalize_ip(device_ip)
response = requests.post(
f"{base_url}/set_channels_schema",
headers=build_headers(token),
json={"schema": schema},
timeout=8,
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("message") or data.get("error") or "Device returned an error")
return data
def split_schema(schema: List[int]) -> List[Dict[str, Any]]:
channels: List[Dict[str, Any]] = []
for index in range(CHANNEL_COUNT):
base = index * CHANNEL_BYTES
flags = schema[base + 3]
channels.append(
{
"index": index,
"base": base,
"pin": schema[base + 0],
"indicator": schema[base + 1],
"feedback": schema[base + 2],
"flags": flags,
"invert": (flags & 0x01) != 0,
}
)
return channels
def coerce_byte(value: Optional[str], field_name: str) -> int:
if value is None:
raise ValueError(f"Missing value for {field_name}")
value = value.strip()
if value == "":
raise ValueError(f"Empty value for {field_name}")
try:
parsed = int(value)
except ValueError as exc:
raise ValueError(f"Invalid integer for {field_name}") from exc
if parsed < 0 or parsed > 255:
raise ValueError(f"Value for {field_name} must be 0..255")
return parsed
def schema_from_form(form: Any) -> List[int]:
schema: List[int] = []
for ch in range(CHANNEL_COUNT):
pin = coerce_byte(form.get(f"pin_{ch}"), f"pin_{ch}")
indicator = coerce_byte(form.get(f"indicator_{ch}"), f"indicator_{ch}")
feedback = coerce_byte(form.get(f"feedback_{ch}"), f"feedback_{ch}")
flags = coerce_byte(form.get(f"flags_{ch}"), f"flags_{ch}")
if form.get(f"invert_{ch}"):
flags |= 0x01
else:
flags &= 0xFE
schema.extend([pin, indicator, feedback, flags])
return schema
def render_page(
*,
device_ip: str = "",
token: str = "",
schema: Optional[List[int]] = None,
message: str = "",
message_type: str = "info",
) -> str:
return render_template_string(
HTML,
device_ip=device_ip,
token=token,
schema=schema,
channels=split_schema(schema) if schema else [],
message=message,
message_type=message_type,
)
@app.get("/")
def index() -> str:
return render_page()
@app.post("/connect")
def connect() -> str:
device_ip = request.form.get("device_ip", "").strip()
token = request.form.get("token", "").strip()
if not device_ip:
return render_page(device_ip=device_ip, token=token, message="Device IP is required.", message_type="err")
try:
schema = get_schema(device_ip, token)
except Exception as exc:
return render_page(device_ip=device_ip, token=token, message=f"Failed to load schema: {exc}", message_type="err")
return render_page(device_ip=device_ip, token=token, schema=schema, message="Schema loaded successfully.", message_type="ok")
@app.post("/save")
def save_schema() -> str:
device_ip = request.form.get("device_ip", "").strip()
token = request.form.get("token", "").strip()
if not device_ip:
return render_page(device_ip=device_ip, token=token, message="Device IP is required.", message_type="err")
try:
schema = schema_from_form(request.form)
fill_unused_channel = request.form.get("fill_unused")
if fill_unused_channel is not None:
ch = int(fill_unused_channel)
base = ch * CHANNEL_BYTES
schema[base + 0] = SH_PIN_UNUSED
schema[base + 1] = SH_PIN_UNUSED
schema[base + 2] = SH_PIN_UNUSED
schema[base + 3] = 0
return render_page(device_ip=device_ip, token=token, schema=schema, message=f"Channel {ch} marked as unused. Not saved yet.", message_type="info")
if request.form.get("fill_all_unused"):
schema = []
for _ in range(CHANNEL_COUNT):
schema.extend([SH_PIN_UNUSED, SH_PIN_UNUSED, SH_PIN_UNUSED, 0])
return render_page(device_ip=device_ip, token=token, schema=schema, message="All channels marked as unused. Not saved yet.", message_type="info")
if request.form.get("normalize"):
return render_page(device_ip=device_ip, token=token, schema=schema, message="Flags normalized from checkboxes. Not saved yet.", message_type="info")
result = set_schema(device_ip, token, schema)
return render_page(device_ip=device_ip, token=token, schema=schema, message=result.get("message", "Schema saved."), message_type="ok")
except Exception as exc:
try:
schema = schema_from_form(request.form)
except Exception:
schema = None
return render_page(device_ip=device_ip, token=token, schema=schema, message=f"Failed to save schema: {exc}", message_type="err")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8001, debug=True)