diff --git a/channels_schema_changer/app.py b/channels_schema_changer/app.py
new file mode 100644
index 0000000..f235c1e
--- /dev/null
+++ b/channels_schema_changer/app.py
@@ -0,0 +1,479 @@
+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 = """
+
+
+
+
+
+ SH Channels Schema Editor
+
+
+
+
+
+
+
SH Channels Schema Editor
+
Port 8001. Reads and writes /channels_schema and /set_channels_schema.
+
+ {% if device_ip %}
+
Device: {{ device_ip }}
+ {% endif %}
+
+
+ {% if message %}
+
{{ message }}
+ {% endif %}
+
+
+
+ {% if schema %}
+
+
+
+
Raw schema
+
{{ schema | join(', ') }}
+
+ {% endif %}
+
+
+
+"""
+
+
+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)
diff --git a/channels_schema_changer/requirements.txt b/channels_schema_changer/requirements.txt
new file mode 100644
index 0000000..6e49680
--- /dev/null
+++ b/channels_schema_changer/requirements.txt
@@ -0,0 +1,12 @@
+blinker==1.9.0
+certifi==2026.2.25
+charset-normalizer==3.4.5
+click==8.3.1
+Flask==3.1.3
+idna==3.11
+itsdangerous==2.2.0
+Jinja2==3.1.6
+MarkupSafe==3.0.3
+requests==2.32.5
+urllib3==2.6.3
+Werkzeug==3.1.6
diff --git a/server/SHServ/Controllers/EventsController.php b/server/SHServ/Controllers/EventsController.php
index 9a6167b..a7247d0 100644
--- a/server/SHServ/Controllers/EventsController.php
+++ b/server/SHServ/Controllers/EventsController.php
@@ -13,6 +13,7 @@
* @param String $device_id device_hard_id
* @param String $data Arguments from device to event handlers
*/
+
public function new_event($event_name, $device_id, $data) {
$devices_model = new Devices();
$device = $devices_model -> by_hard_id($device_id);
@@ -28,19 +29,24 @@
ignore_user_abort(true);
set_time_limit(10);
- ob_start();
+ $response = json_encode(['status' => 'ok']);
+
http_response_code(200);
header("Content-Type: application/json; charset=utf-8");
- echo json_encode(['status' => 'ok']);
+ header("Content-Length: " . strlen($response));
+ header("Connection: close");
- $size = ob_get_length();
- header("Content-Length: {$size}");
- ob_end_flush();
- flush();
+ echo $response;
+
+ if (function_exists('fastcgi_finish_request')) {
+ fastcgi_finish_request();
+ } else {
+ ob_flush();
+ flush();
+ }
$events_model = new EventsModel();
- // For multichannels
if(isset($data["channel"])) {
$events_model -> channel_alias_device_event_call($device, $event_name, intval($data["channel"]), $data);
$events_model -> channel_device_event_call($device, $event_name, intval($data["channel"]), $data);
diff --git a/server/SHServ/Entities/Device.php b/server/SHServ/Entities/Device.php
index 8a7dd80..de80a90 100644
--- a/server/SHServ/Entities/Device.php
+++ b/server/SHServ/Entities/Device.php
@@ -88,14 +88,12 @@
if(!$this -> auth())
return false;
-
$this -> auth() -> device_token = $token;
$this -> device_api_instance -> set_local_token($token);
return $this -> auth() -> update();
}
public function resetup(String $token) {
- $this -> auth() -> kill();
return $this -> set_device_token($token);
}
}
\ No newline at end of file