commit 85d50ee958de14905802d065e67727d2ab534e76 Author: TolaMironcenko Date: Thu Mar 19 14:07:10 2026 +0700 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5034c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +go.sum +up \ No newline at end of file diff --git a/api/Extlinux.go b/api/Extlinux.go new file mode 100644 index 0000000..f107e44 --- /dev/null +++ b/api/Extlinux.go @@ -0,0 +1,14 @@ +package api + +import "net/http" + +func Extlinux(w http.ResponseWriter, req *http.Request) { + switch req.Method { + case "GET": + ExtlinuxRead(w, req) + case "POST": + ExtlinuxWrite(w, req) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/api/ExtlinuxRead.go b/api/ExtlinuxRead.go new file mode 100644 index 0000000..02ad0e0 --- /dev/null +++ b/api/ExtlinuxRead.go @@ -0,0 +1,32 @@ +package api + +import ( + "encoding/json" + "net/http" + "os" + "up/utils" +) + +func ExtlinuxRead(w http.ResponseWriter, req *http.Request) { + if utils.MountDevice() < 0 { + w.Header().Set("Content-Type", "application/json") + http.Error(w, "{\"error\":\"Failed to mount device\"}", http.StatusInternalServerError) + return + } + data, err := os.ReadFile(os.Getenv("MOUNT_POINT") + "/" + os.Getenv("EXTLINUX_FILE")) + if err != nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, "{\"error\":\"extlinux.conf not found\"}", http.StatusInternalServerError) + return + } + response := map[string]interface{}{ + "status": "success", + "content": string(data), + "size": len(data), + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + // В случае ошибки кодирования уже ничего не сделать, + // можно только залогировать (здесь опущено для краткости) + } +} diff --git a/api/ExtlinuxWrite.go b/api/ExtlinuxWrite.go new file mode 100644 index 0000000..3b7d5e8 --- /dev/null +++ b/api/ExtlinuxWrite.go @@ -0,0 +1,109 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "syscall" + "up/utils" +) + +func ExtlinuxWrite(w http.ResponseWriter, r *http.Request) { + var MOUNT_POINT = os.Getenv("MOUNT_POINT") + var EXTLINUX_FILE = os.Getenv("EXTLINUX_FILE") + // Ограничиваем размер тела, чтобы избежать слишком больших запросов (аналог 4096 в C) + r.Body = http.MaxBytesReader(w, r.Body, 4096) + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, `{"error":"Failed to read body"}`, http.StatusBadRequest) + return + } + + // Парсим JSON + var requestData struct { + Content string `json:"content"` + } + if err := json.Unmarshal(body, &requestData); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"Invalid JSON"}`)) + return + } + + // Проверяем наличие поля content + if requestData.Content == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"Missing content field"}`)) + return + } + + timestamp := r.URL.Query().Get("timestamp") + if timestamp != "" { + // Полное URL-декодирование (в C декодировался только %3A, но здесь используем стандартный способ) + decoded, err := url.QueryUnescape(timestamp) + if err == nil { + timestamp = decoded + } + } + + // Монтируем устройство + if utils.MountDevice() < 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"Failed to mount device"}`)) + return + } + + // Формируем полный путь к файлу + filePath := filepath.Join(MOUNT_POINT, EXTLINUX_FILE) + fmt.Println(filePath) + + // Открываем файл на запись (создаём/перезаписываем) + f, err := os.Create(filePath) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"Failed to open file"}`)) + return + } + defer f.Close() + + // Записываем содержимое + if _, err := f.WriteString(requestData.Content); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"Failed to write file"}`)) + return + } + + if timestamp != "" { + versionPath := filepath.Join(MOUNT_POINT, EXTLINUX_FILE+".version") + vf, err := os.Create(versionPath) + if err == nil { + // Форматируем ISO 8601 в человекочитаемый вид (как в C) + var readableDate string + if len(timestamp) >= 19 { + readableDate = timestamp[:10] + " " + timestamp[11:19] // "YYYY-MM-DD HH:MM:SS" + } else { + readableDate = timestamp + } + vf.WriteString(readableDate + "\n") + vf.Close() + log.Printf("Written extlinux.conf version: %s\n", readableDate) + } + } + + // Принудительно сбрасываем файловые буферы на диск (аналог sync()) + syscall.Sync() + + // Успешный ответ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"success","message":"extlinux.conf updated"}`)) +} diff --git a/api/FileDate.go b/api/FileDate.go new file mode 100644 index 0000000..446b4c8 --- /dev/null +++ b/api/FileDate.go @@ -0,0 +1,47 @@ +package api + +import ( + "bufio" + "encoding/json" + "net/http" + "os" + "path/filepath" +) + +// handleFiledate обрабатывает HTTP-запрос для получения даты файла (версии) +func FileDate(w http.ResponseWriter, r *http.Request) { + var MOUNT_POINT = os.Getenv("MOUNT_POINT") + // Получаем параметр "file" из строки запроса + filename := r.URL.Query().Get("file") + if filename == "" { + http.Error(w, `{"error":"Missing file parameter"}`, http.StatusBadRequest) + return + } + + // Формируем путь к файлу версии: /.version + versionPath := filepath.Join(MOUNT_POINT, filename+".version") + + // Пытаемся прочитать первую строку из файла версии + dateStr := "" + f, err := os.Open(versionPath) + if err == nil { + defer f.Close() + scanner := bufio.NewScanner(f) + if scanner.Scan() { + dateStr = scanner.Text() // считываем строку без символа новой строки + } + // Ошибки сканера игнорируем (как в C) + } + // Если файл не существует или другая ошибка — оставляем dateStr пустой + + // Формируем JSON-ответ + response := map[string]interface{}{ + "status": "success", + "date": dateStr, + "filename": filename, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} diff --git a/api/HandleReboot.go b/api/HandleReboot.go new file mode 100644 index 0000000..8d80caa --- /dev/null +++ b/api/HandleReboot.go @@ -0,0 +1,55 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "syscall" + "time" +) + +// MOUNT_POINT должен быть определён (например, как глобальная константа) +// const MOUNT_POINT = "/mnt/mydevice" + +// handleReboot обрабатывает HTTP-запрос на перезагрузку системы +func HandleReboot(w http.ResponseWriter, r *http.Request) { + MOUNT_POINT := os.Getenv("MOUNT_POINT") + // Отправляем JSON-ответ (как в C: mg_http_reply) + response := map[string]string{ + "status": "success", + "message": "System rebooting...", + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + fmt.Printf("Failed to send reboot response: %v\n", err) + return + } + + // Синхронизируем все файловые системы (аналог sync()) + syscall.Sync() + + // Размонтируем точку монтирования, если она смонтирована + fmt.Printf("Unmounting %s...\n", MOUNT_POINT) + if err := syscall.Unmount(MOUNT_POINT, 0); err != nil { + // Ошибку не фатализируем, просто логируем + fmt.Printf("Unmount error: %v\n", err) + } + + // Повторная синхронизация + syscall.Sync() + + // Инициируем мягкую перезагрузку через команду shutdown + fmt.Println("Initiating soft reboot via shutdown...") + + // Запускаем shutdown в отдельной горутине с небольшой задержкой, + // чтобы ответ гарантированно ушёл клиенту до завершения сервера + go func() { + time.Sleep(500 * time.Millisecond) // ждём 0.5 секунды + cmd := exec.Command("shutdown", "-r", "now") + if err := cmd.Run(); err != nil { + fmt.Printf("Reboot failed: %v\n", err) + } + }() +} diff --git a/api/HandleTemperature.go b/api/HandleTemperature.go new file mode 100644 index 0000000..f4fa439 --- /dev/null +++ b/api/HandleTemperature.go @@ -0,0 +1,107 @@ +package api + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" +) + +func HandleTemperature(w http.ResponseWriter, r *http.Request) { + temperature := 0.0 + success := false + + // Thermal zones 0–3 + for i := 0; i < 4 && !success; i++ { + tempPath := fmt.Sprintf("/sys/class/thermal/thermal_zone%d/temp", i) + data, err := ioutil.ReadFile(tempPath) + if err != nil { + continue + } + // Читаем тип зоны, если доступен + typePath := fmt.Sprintf("/sys/class/thermal/thermal_zone%d/type", i) + typeData, _ := ioutil.ReadFile(typePath) // ошибку игнорируем + zoneType := strings.TrimSpace(string(typeData)) + + // Парсим температуру + var tempRaw int + if _, err := fmt.Sscanf(string(data), "%d", &tempRaw); err != nil { + continue + } + temp := float64(tempRaw) / 1000.0 + + // Проверяем, является ли зона CPU/SOC + isCPU := strings.Contains(strings.ToLower(zoneType), "cpu") || + strings.Contains(strings.ToLower(zoneType), "soc") + + temperature = temp + success = true + if isCPU { + break // предпочитаем CPU + } + // иначе продолжаем искать CPU + } + + // Если thermal не дал результата, пробуем hwmon 0–3 + for i := 0; i < 4 && !success; i++ { + tempPath := fmt.Sprintf("/sys/class/hwmon/hwmon%d/temp1_input", i) + data, err := ioutil.ReadFile(tempPath) + if err != nil { + continue + } + // Читаем имя сенсора + namePath := fmt.Sprintf("/sys/class/hwmon/hwmon%d/name", i) + nameData, _ := ioutil.ReadFile(namePath) + sensorName := strings.TrimSpace(string(nameData)) + + var tempRaw int + if _, err := fmt.Sscanf(string(data), "%d", &tempRaw); err != nil { + continue + } + temp := float64(tempRaw) / 1000.0 + + isCPU := strings.Contains(strings.ToLower(sensorName), "cpu") || + strings.Contains(strings.ToLower(sensorName), "sunxi") || + strings.Contains(strings.ToLower(sensorName), "acpitz") + + temperature = temp + success = true + if isCPU { + break + } + } + + // Финальный fallback – просто temp1_input для первых 4 hwmon + if !success { + fallbackPaths := []string{ + "/sys/class/hwmon/hwmon0/temp1_input", + "/sys/class/hwmon/hwmon1/temp1_input", + "/sys/class/hwmon/hwmon2/temp1_input", + "/sys/class/hwmon/hwmon3/temp1_input", + } + for _, path := range fallbackPaths { + data, err := ioutil.ReadFile(path) + if err != nil { + continue + } + var tempRaw int + if _, err := fmt.Sscanf(string(data), "%d", &tempRaw); err != nil { + continue + } + temperature = float64(tempRaw) / 1000.0 + success = true + break + } + } + + // Формируем JSON-ответ + response := map[string]interface{}{ + "success": success, + "temperature": temperature, + "unit": "Celsius", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/api/HandleUpload.go b/api/HandleUpload.go new file mode 100644 index 0000000..4d13624 --- /dev/null +++ b/api/HandleUpload.go @@ -0,0 +1,221 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "sync" + "syscall" + "up/utils" +) + +// Глобальное состояние загрузки (аналог g_upload в C) +type UploadState struct { + mu sync.RWMutex + Type string + Received int64 + Progress int + Status string +} + +var uploadState UploadState + +// handleUpload обрабатывает POST /api/upload +func HandleUpload(w http.ResponseWriter, r *http.Request) { + uboot_max_size_int, err := strconv.Atoi(os.Getenv("UBOOT_MAX_SIZE")) + if err != nil { + fmt.Println("Error during conversion:", err) + return + } + max_file_size_int, err := strconv.Atoi(os.Getenv("MAX_FILE_SIZE")) + if err != nil { + fmt.Println("error during conversion: ", err) + return + } + UBOOT_MAX_SIZE := uboot_max_size_int * 1024 * 1024 + MAX_FILE_SIZE := max_file_size_int * 1024 * 1024 + MOUNT_POINT := os.Getenv("MOUNT_POINT") + // Ограничиваем размер запроса (максимум UBOOT_MAX_SIZE + запас) + r.Body = http.MaxBytesReader(w, r.Body, int64(MAX_FILE_SIZE)+1024) + if err := r.ParseMultipartForm(32 << 20); err != nil { // 32 MB в памяти + http.Error(w, `{"error":"Failed to parse multipart form"}`, http.StatusBadRequest) + return + } + defer r.MultipartForm.RemoveAll() // очищаем временные файлы + + // Получаем поле "type" + typeVals := r.MultipartForm.Value["type"] + if len(typeVals) == 0 { + http.Error(w, `{"error":"Missing type field"}`, http.StatusBadRequest) + return + } + typ := typeVals[0] + + // Получаем файл из поля "file" + files := r.MultipartForm.File["file"] + if len(files) == 0 { + http.Error(w, `{"error":"Missing file field"}`, http.StatusBadRequest) + return + } + fileHeader := files[0] + + // Проверка размера файла + maxSize := int64(MAX_FILE_SIZE) + if typ == "uboot_bin" { + maxSize = int64(UBOOT_MAX_SIZE) + } + if fileHeader.Size > maxSize { + http.Error(w, `{"error":"File too large"}`, http.StatusBadRequest) + return + } + + // Открываем загруженный файл + srcFile, err := fileHeader.Open() + if err != nil { + http.Error(w, `{"error":"Failed to open uploaded file"}`, http.StatusInternalServerError) + return + } + defer srcFile.Close() + + // Монтируем устройство + if utils.MountDevice() < 0 { + http.Error(w, `{"error":"Failed to mount device"}`, http.StatusInternalServerError) + return + } + + // Определяем имя файла назначения + filename := utils.GetTargetFilename(typ) + if filename == "" { + http.Error(w, `{"error":"Invalid upload type"}`, http.StatusBadRequest) + return + } + destPath := filepath.Join(MOUNT_POINT, filename) + + // Обновляем состояние загрузки + uploadState.mu.Lock() + uploadState.Type = typ + uploadState.Received = 0 + uploadState.Progress = 0 + uploadState.Status = "Uploading..." + uploadState.mu.Unlock() + + fmt.Printf("Upload: %s (%d bytes) -> %s\n", typ, fileHeader.Size, destPath) + + // Получаем timestamp из query и декодируем + timestamp := r.URL.Query().Get("timestamp") + if timestamp != "" { + decoded, err := url.QueryUnescape(timestamp) + if err == nil { + timestamp = decoded + } + } + + // Специальная обработка для U-Boot + if typ == "uboot_bin" { + data, err := io.ReadAll(srcFile) + if err != nil { + uploadState.mu.Lock() + uploadState.Status = "Failed" + uploadState.mu.Unlock() + http.Error(w, `{"error":"Failed to read U-Boot data"}`, http.StatusInternalServerError) + return + } + if err := utils.WriteUbootToDevices(data, destPath); err != nil { + uploadState.mu.Lock() + uploadState.Status = "Failed" + uploadState.mu.Unlock() + fmt.Println("error: ", err) + http.Error(w, `{"error":"Failed to write U-Boot"}`, http.StatusInternalServerError) + return + } + uploadState.mu.Lock() + uploadState.Received = int64(len(data)) + uploadState.Progress = 100 + uploadState.Status = "Complete" + uploadState.mu.Unlock() + } else { + // Обычная запись файла чанками с обновлением прогресса + destFile, err := os.Create(destPath) + if err != nil { + uploadState.mu.Lock() + uploadState.Status = "Failed" + uploadState.mu.Unlock() + http.Error(w, `{"error":"Failed to create file"}`, http.StatusInternalServerError) + return + } + defer destFile.Close() + + buf := make([]byte, 65536) // 64 KB чанки + var written int64 + for { + n, err := srcFile.Read(buf) + if n > 0 { + if _, err := destFile.Write(buf[:n]); err != nil { + uploadState.mu.Lock() + uploadState.Status = "Failed" + uploadState.mu.Unlock() + http.Error(w, `{"error":"Failed to write file"}`, http.StatusInternalServerError) + return + } + written += int64(n) + // Обновляем прогресс + uploadState.mu.Lock() + uploadState.Received = written + uploadState.Progress = int(written * 100 / fileHeader.Size) + uploadState.mu.Unlock() + } + if err == io.EOF { + break + } + if err != nil { + uploadState.mu.Lock() + uploadState.Status = "Failed" + uploadState.mu.Unlock() + http.Error(w, `{"error":"Failed to read uploaded file"}`, http.StatusInternalServerError) + return + } + } + destFile.Sync() + syscall.Sync() // глобальная синхронизация + uploadState.mu.Lock() + uploadState.Progress = 100 + uploadState.Status = "Complete" + uploadState.mu.Unlock() + } + + // Запись файла версии с timestamp + if timestamp != "" { + versionPath := filepath.Join(MOUNT_POINT, filename+".version") + vf, err := os.Create(versionPath) + if err == nil { + // Форматируем ISO 8601 в читаемый вид (как в C) + var readableDate string + if len(timestamp) >= 19 { + readableDate = timestamp[:10] + " " + timestamp[11:19] // "YYYY-MM-DD HH:MM:SS" + } else { + readableDate = timestamp + } + vf.WriteString(readableDate + "\n") + vf.Close() + fmt.Printf("Written version file: %s (%s)\n", versionPath, readableDate) + } + } + + fmt.Printf("Upload complete: %s\n", destPath) + + // Формируем JSON-ответ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "success", + "type": typ, + "filename": filename, + "size": fileHeader.Size, + "progress": 100, + }) +} diff --git a/api/MountStatus.go b/api/MountStatus.go new file mode 100644 index 0000000..0e89fbc --- /dev/null +++ b/api/MountStatus.go @@ -0,0 +1,45 @@ +package api + +import ( + "bufio" + "encoding/json" + "net/http" + "os" + "strings" +) + +func MountStatus(w http.ResponseWriter, req *http.Request) { + var MOUNT_POINT = os.Getenv("MOUNT_POINT") + var DEVICE = os.Getenv("DEVICE") + mounted := false + + // Проверяем, смонтировано ли устройство, читая /proc/mounts + f, err := os.Open("/proc/mounts") + if err == nil { + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, MOUNT_POINT) { + mounted = true + break + } + } + // Ошибки сканера игнорируем (аналогично C‑версии) + } + + // Формируем JSON‑ответ + response := map[string]interface{}{ + "mounted": mounted, + "mount_point": MOUNT_POINT, + "device": DEVICE, + } + + w.Header().Set("Content-Type", "application/json") + // json.NewEncoder автоматически добавляет символ новой строки в конце, + // что соответствует формату "%s\n" в C. + if err := json.NewEncoder(w).Encode(response); err != nil { + // В случае ошибки кодирования уже ничего не сделать, + // можно только залогировать (здесь опущено для краткости) + } +} diff --git a/api/frontend/app.js b/api/frontend/app.js new file mode 100644 index 0000000..adda793 --- /dev/null +++ b/api/frontend/app.js @@ -0,0 +1,407 @@ +// Bootloader Update Manager - Frontend JavaScript + +document.addEventListener("DOMContentLoaded", function () { + // Elements + const uploadForm = document.getElementById("upload-form"); + const fileInput = document.getElementById("file-input"); + const fileName = document.getElementById("file-name"); + const uploadType = document.getElementById("upload-type"); + const progressContainer = document.getElementById("progress-container"); + const progressFill = document.getElementById("progress-fill"); + const progressPercent = document.getElementById("progress-percent"); + const progressStatus = document.getElementById("progress-status"); + const progressBytes = document.getElementById("progress-bytes"); + const resultMessage = document.getElementById("result-message"); + const mountStatus = document.getElementById("mount-status"); + const extlinuxEditor = document.getElementById("extlinux-editor"); + const editorStatus = document.getElementById("editor-status"); + const uploadHint = document.getElementById("upload-hint"); + + // Tab buttons + const tabBtns = document.querySelectorAll(".tab-btn"); + + // Initialize + checkMountStatus(); + checkTemperature(); + checkFileDates(); + setInterval(checkMountStatus, 10000); // Check every 10 seconds + setInterval(checkTemperature, 5000); // Check temperature every 5 seconds + setInterval(checkFileDates, 10000); // Check file dates every 10 seconds + + // Tab switching + tabBtns.forEach((btn) => { + btn.addEventListener("click", function () { + tabBtns.forEach((b) => b.classList.remove("active")); + this.classList.add("active"); + uploadType.value = this.dataset.type; + hideResult(); + + // Show/hide U-Boot warning + if (this.dataset.type === "uboot_bin") { + uploadHint.classList.add("visible"); + } else { + uploadHint.classList.remove("visible"); + } + }); + }); + + // File input change + fileInput.addEventListener("change", function () { + if (this.files && this.files[0]) { + fileName.textContent = this.files[0].name; + } else { + fileName.textContent = ""; + } + }); + + // Drag and drop + const fileLabel = document.querySelector(".file-label"); + + ["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => { + fileLabel.addEventListener(eventName, preventDefaults, false); + }); + + function preventDefaults(e) { + e.preventDefault(); + e.stopPropagation(); + } + + ["dragenter", "dragover"].forEach((eventName) => { + fileLabel.addEventListener(eventName, () => { + fileLabel.style.borderColor = "#00d9ff"; + fileLabel.style.background = "rgba(0,217,255,0.1)"; + }); + }); + + ["dragleave", "drop"].forEach((eventName) => { + fileLabel.addEventListener(eventName, () => { + fileLabel.style.borderColor = ""; + fileLabel.style.background = ""; + }); + }); + + fileLabel.addEventListener("drop", (e) => { + const dt = e.dataTransfer; + const files = dt.files; + if (files && files[0]) { + fileInput.files = files; + fileName.textContent = files[0].name; + } + }); + + // Form submission + uploadForm.addEventListener("submit", async function (e) { + e.preventDefault(); + + const formData = new FormData(this); + const file = fileInput.files[0]; + + if (!file) { + showResult("Please select a file", "error"); + return; + } + + // Check file size (200MB limit) + const MAX_SIZE = 200 * 1024 * 1024; + if (file.size > MAX_SIZE) { + showResult("File too large. Maximum size is 200MB", "error"); + return; + } + + // Reset progress + progressContainer.style.display = "block"; + progressFill.style.width = "0%"; + progressPercent.textContent = "0%"; + progressStatus.textContent = "Uploading..."; + progressBytes.textContent = ""; + hideResult(); + + // Get current timestamp + const timestamp = new Date().toISOString(); + + try { + const xhr = new XMLHttpRequest(); + + xhr.upload.addEventListener("progress", function (e) { + if (e.lengthComputable) { + const percent = Math.round((e.loaded / e.total) * 100); + progressFill.style.width = percent + "%"; + progressPercent.textContent = percent + "%"; + progressBytes.textContent = + formatBytes(e.loaded) + " / " + formatBytes(e.total); + } + }); + + xhr.addEventListener("load", function () { + if (xhr.status === 200) { + const response = JSON.parse(xhr.responseText); + progressStatus.textContent = "Complete!"; + showResult( + `Successfully uploaded ${response.filename} (${formatBytes(response.size)})`, + "success", + ); + // Refresh file dates after upload + checkFileDates(); + } else { + let errorMsg = "Upload failed"; + try { + const err = JSON.parse(xhr.responseText); + errorMsg = err.error || errorMsg; + } catch (e) {} + progressStatus.textContent = "Failed"; + showResult(errorMsg, "error"); + } + }); + + xhr.addEventListener("error", function () { + progressStatus.textContent = "Failed"; + showResult("Network error", "error"); + }); + + xhr.open( + "POST", + `/api/upload?timestamp=${encodeURIComponent(timestamp)}`, + ); + xhr.send(formData); + } catch (error) { + progressStatus.textContent = "Failed"; + showResult("Error: " + error.message, "error"); + } + }); + + // extlinux.conf buttons + document + .getElementById("load-extlinux") + .addEventListener("click", loadExtlinux); + document + .getElementById("save-extlinux") + .addEventListener("click", saveExtlinux); + document + .getElementById("clear-extlinux") + .addEventListener("click", function () { + extlinuxEditor.value = ""; + showEditorStatus("Editor cleared", "success"); + }); + + // Check mount status + async function checkMountStatus() { + try { + const response = await fetch("/api/mount"); + const data = await response.json(); + + const indicator = mountStatus.querySelector(".status-indicator"); + const text = mountStatus.querySelector(".status-text"); + + if (data.mounted) { + indicator.className = "status-indicator mounted"; + text.textContent = "Mounted"; + } else { + indicator.className = "status-indicator not-mounted"; + text.textContent = "Not Mounted"; + } + } catch (error) { + const indicator = mountStatus.querySelector(".status-indicator"); + const text = mountStatus.querySelector(".status-text"); + indicator.className = "status-indicator"; + text.textContent = "Error checking status"; + } + } + + // Load extlinux.conf + async function loadExtlinux() { + showEditorStatus("Loading...", ""); + + try { + const response = await fetch("/api/extlinux"); + const data = await response.json(); + + if (data.status === "success") { + extlinuxEditor.value = data.content; + showEditorStatus(`Loaded ${data.size} bytes`, "success"); + } else { + showEditorStatus(data.error || "Failed to load", "error"); + } + } catch (error) { + showEditorStatus("Error: " + error.message, "error"); + } + } + + // Save extlinux.conf + async function saveExtlinux() { + const content = extlinuxEditor.value; + + if (!content.trim()) { + showEditorStatus("Cannot save empty file", "error"); + return; + } + + showEditorStatus("Saving...", ""); + + // Get current timestamp from browser + const timestamp = new Date().toISOString(); + + try { + const response = await fetch( + `/api/extlinux?timestamp=${encodeURIComponent(timestamp)}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ content: content }), + }, + ); + + const data = await response.json(); + + if (data.status === "success") { + showEditorStatus("Successfully saved extlinux.conf", "success"); + // Refresh file dates after save + checkFileDates(); + } else { + showEditorStatus(data.error || "Failed to save", "error"); + } + } catch (error) { + showEditorStatus("Error: " + error.message, "error"); + } + } + + // Reboot button + const rebootBtn = document.getElementById("reboot-btn"); + const rebootMessage = document.getElementById("reboot-message"); + + if (rebootBtn) { + rebootBtn.addEventListener("click", async function () { + // Confirm reboot + if (!confirm("Are you sure you want to reboot the system?")) { + return; + } + + rebootBtn.disabled = true; + rebootMessage.className = "reboot-message"; + rebootMessage.style.display = "none"; + + try { + const response = await fetch("/api/reboot", { method: "POST" }); + const data = await response.json(); + + if (data.status === "success") { + rebootMessage.textContent = + "System rebooting... Connection will be lost."; + rebootMessage.className = "reboot-message success"; + rebootMessage.style.display = "block"; + + // Wait a bit then show reconnection message + setTimeout(() => { + rebootMessage.textContent = + "Waiting for device to come back online..."; + }, 5000); + } else { + rebootMessage.textContent = data.error || "Reboot failed"; + rebootMessage.className = "reboot-message error"; + rebootMessage.style.display = "block"; + rebootBtn.disabled = false; + } + } catch (error) { + rebootMessage.textContent = "Error: " + error.message; + rebootMessage.className = "reboot-message error"; + rebootMessage.style.display = "block"; + rebootBtn.disabled = false; + } + }); + } + + // Helper functions + function showResult(message, type) { + resultMessage.textContent = message; + resultMessage.className = "result-message " + type; + } + + function hideResult() { + resultMessage.className = "result-message"; + resultMessage.style.display = "none"; + } + + function showEditorStatus(message, type) { + editorStatus.textContent = message; + editorStatus.className = "editor-status " + type; + } + + function formatBytes(bytes) { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + } + + // Temperature functions + const temperatureStatus = document.getElementById("temperature-status"); + const tempValue = temperatureStatus + ? temperatureStatus.querySelector(".temp-value") + : null; + + async function checkTemperature() { + if (!tempValue) return; + + try { + const response = await fetch("/api/temperature"); + const data = await response.json(); + + if (data.success) { + tempValue.textContent = data.temperature.toFixed(1); + tempValue.style.color = getTempColor(data.temperature); + } else { + tempValue.textContent = "N/A"; + tempValue.style.color = "#888"; + } + } catch (error) { + tempValue.textContent = "Error"; + tempValue.style.color = "#ff4757"; + } + } + + function getTempColor(temp) { + if (temp < 50) return "#00ff88"; // Green - cool + if (temp < 70) return "#00d9ff"; // Blue - normal + if (temp < 80) return "#ffa500"; // Orange - warm + return "#ff4757"; // Red - hot + } + + // File dates functions + const fileDates = { + "Image.gz": document.getElementById("date-Image.gz"), + "sun50i-h618-orangepi-zero2w.dtb": document.getElementById( + "date-sun50i-h618-orangepi-zero2w.dtb", + ), + "uboot.env": document.getElementById("date-uboot.env"), + "u-boot-sunxi-with-spl.bin": document.getElementById( + "date-u-boot-sunxi-with-spl.bin", + ), + "extlinux.conf": document.getElementById("date-extlinux.conf"), + }; + + async function checkFileDates() { + for (const [filename, element] of Object.entries(fileDates)) { + if (!element) continue; + + try { + const response = await fetch( + `/api/filedate?file=${encodeURIComponent(filename)}`, + ); + const data = await response.json(); + + if (data.status === "success" && data.date) { + element.textContent = data.date; + element.style.color = "#00ff88"; + } else { + element.textContent = "Not available"; + element.style.color = "#888"; + } + } catch (error) { + element.textContent = "Error"; + element.style.color = "#ff4757"; + } + } + } +}); diff --git a/api/frontend/index.html b/api/frontend/index.html new file mode 100644 index 0000000..146daae --- /dev/null +++ b/api/frontend/index.html @@ -0,0 +1,196 @@ + + + + + + Bootloader Update Manager + + + +
+
+

🚀 Bootloader Update Manager

+

+ Orange Pi Zero 2W - Kernel, Device-Tree & U-Boot Environment +

+
+ +
+

System Status

+
+
+ Mount Point: + /media +
+
+ Device: + /dev/mmcblk1p1 +
+
+ Mount Status: + + + Checking... + +
+
+ CPU Temperature: + + 🌡️ + -- + °C + +
+
+
+ +
+

File Versions

+
+
+ Kernel (Image.gz): + -- +
+
+ Device-Tree (DTB): + -- +
+
+ U-Boot Env: + -- +
+
+ U-Boot Binary: + -- +
+
+ Extlinux: + -- +
+
+
+ +
+

File Upload

+
+ + + + +
+ +
+
+ +
+ + +
+
+ ⚠️ U-Boot Binary will be written to SPI NOR + (/dev/mtd0) and SD card at 8KB offset (/dev/mmcblk1 + +8KB) +
+ +
+ + + +
+
+
+ +
+

extlinux.conf Editor

+
+
+ + + +
+ +
+
+
+ +
+

System Control

+
+

+ ⚠️ Warning: This will reboot the device immediately +

+ +
+
+
+ +
+

Bootloader Update Manager © 2026

+
+
+ + + + \ No newline at end of file diff --git a/api/frontend/style.css b/api/frontend/style.css new file mode 100644 index 0000000..fbe8f27 --- /dev/null +++ b/api/frontend/style.css @@ -0,0 +1,497 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, + sans-serif; + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + min-height: 100vh; + color: #e4e4e4; +} + +.container { + max-width: 900px; + margin: 0 auto; + padding: 20px; +} + +header { + text-align: center; + padding: 30px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + margin-bottom: 30px; +} + +header h1 { + font-size: 2.5rem; + margin-bottom: 10px; + background: linear-gradient(90deg, #00d9ff, #00ff88); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +header p { + color: #888; + font-size: 1.1rem; +} + +section { + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 25px; + margin-bottom: 25px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +h2 { + font-size: 1.5rem; + margin-bottom: 20px; + color: #00d9ff; +} + +/* Status Section */ +.status-card { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; +} + +.status-item { + background: rgba(0, 0, 0, 0.2); + padding: 15px; + border-radius: 8px; +} + +.status-item .label { + display: block; + color: #888; + font-size: 0.85rem; + margin-bottom: 5px; +} + +.status-item .value { + font-size: 1.1rem; + font-weight: 600; +} + +.status-indicator { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: #888; + margin-right: 8px; +} + +.status-indicator.mounted { + background: #00ff88; + box-shadow: 0 0 10px #00ff88; +} + +.status-indicator.not-mounted { + background: #ff4757; + box-shadow: 0 0 10px #ff4757; +} + +/* Temperature Display */ +.temp-icon { + margin-right: 8px; +} + +.temp-value { + color: #00d9ff; + font-size: 1.3rem; +} + +.temp-unit { + color: #888; + font-size: 0.9rem; +} + +/* File Versions Section */ +.files-section { + border: 1px solid rgba(0, 217, 255, 0.3); +} + +.files-section h2 { + color: #00d9ff; +} + +.files-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 12px; +} + +.file-item { + background: rgba(0, 0, 0, 0.2); + padding: 12px 15px; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 5px; +} + +.file-label { + color: #888; + font-size: 0.85rem; +} + +.file-date { + color: #00ff88; + font-weight: 600; + font-size: 0.95rem; +} + +/* Upload Section */ +.upload-tabs { + display: flex; + gap: 10px; + margin-bottom: 20px; +} + +.tab-btn { + flex: 1; + padding: 12px 20px; + border: none; + background: rgba(255, 255, 255, 0.1); + color: #e4e4e4; + border-radius: 8px; + cursor: pointer; + font-size: 1rem; + transition: all 0.3s ease; +} + +.tab-btn:hover { + background: rgba(255, 255, 255, 0.2); +} + +.tab-btn.active { + background: linear-gradient(90deg, #00d9ff, #00ff88); + color: #1a1a2e; + font-weight: 600; +} + +.upload-area { + text-align: center; +} + +.upload-hint { + margin: 15px 0; + padding: 12px; + background: rgba(255, 165, 0, 0.15); + border: 1px solid rgba(255, 165, 0, 0.5); + border-radius: 8px; + color: #ffa500; + font-size: 0.9rem; + display: none; +} + +.upload-hint.visible { + display: block; +} + +.file-input-wrapper { + margin-bottom: 20px; +} + +#file-input { + display: none; +} + +.file-label { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 50px; + border: 2px dashed rgba(255, 255, 255, 0.3); + border-radius: 12px; + cursor: pointer; + transition: all 0.3s ease; +} + +.file-label:hover { + border-color: #00d9ff; + background: rgba(0, 217, 255, 0.05); +} + +.file-icon { + font-size: 3rem; + margin-bottom: 10px; +} + +.file-text { + color: #888; + margin-bottom: 10px; +} + +.file-name { + color: #00ff88; + font-weight: 600; +} + +.upload-btn { + padding: 15px 40px; + font-size: 1.1rem; + border: none; + border-radius: 8px; + background: linear-gradient(90deg, #00d9ff, #00ff88); + color: #1a1a2e; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.upload-btn:hover { + transform: translateY(-2px); + box-shadow: 0 5px 20px rgba(0, 217, 255, 0.4); +} + +.upload-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* Progress Bar */ +.progress-container { + margin-top: 20px; + padding: 20px; + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; +} + +.progress-header { + display: flex; + justify-content: space-between; + margin-bottom: 10px; +} + +.progress-bar { + height: 20px; + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, #00d9ff, #00ff88); + transition: width 0.3s ease; + border-radius: 10px; +} + +.progress-info { + display: flex; + justify-content: space-between; + margin-top: 10px; + font-size: 0.9rem; + color: #888; +} + +.result-message { + margin-top: 15px; + padding: 15px; + border-radius: 8px; + display: none; +} + +.result-message.success { + display: block; + background: rgba(0, 255, 136, 0.2); + border: 1px solid #00ff88; + color: #00ff88; +} + +.result-message.error { + display: block; + background: rgba(255, 71, 87, 0.2); + border: 1px solid #ff4757; + color: #ff4757; +} + +/* Editor Section */ +.editor-container { + display: flex; + flex-direction: column; +} + +.editor-toolbar { + display: flex; + gap: 10px; + margin-bottom: 15px; +} + +.toolbar-btn { + padding: 10px 20px; + border: none; + border-radius: 6px; + background: rgba(255, 255, 255, 0.1); + color: #e4e4e4; + cursor: pointer; + font-size: 0.95rem; + transition: all 0.2s ease; +} + +.toolbar-btn:hover { + background: rgba(255, 255, 255, 0.2); +} + +#save-extlinux:hover { + background: #00ff88; + color: #1a1a2e; +} + +#clear-extlinux:hover { + background: #ff4757; + color: white; +} + +#extlinux-editor { + width: 100%; + min-height: 300px; + padding: 15px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + background: rgba(0, 0, 0, 0.3); + color: #e4e4e4; + font-family: "Monaco", "Menlo", monospace; + font-size: 0.9rem; + line-height: 1.5; + resize: vertical; +} + +#extlinux-editor:focus { + outline: none; + border-color: #00d9ff; +} + +.editor-status { + margin-top: 10px; + padding: 10px; + border-radius: 6px; + font-size: 0.9rem; +} + +.editor-status.success { + background: rgba(0, 255, 136, 0.2); + color: #00ff88; +} + +.editor-status.error { + background: rgba(255, 71, 87, 0.2); + color: #ff4757; +} + +/* Footer */ +footer { + text-align: center; + padding: 20px; + color: #666; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +/* Reboot Section */ +.reboot-section { + border: 1px solid rgba(255, 71, 87, 0.3); +} + +.reboot-section h2 { + color: #ff4757; +} + +.reboot-container { + text-align: center; +} + +.reboot-warning { + color: #ff4757; + font-weight: 600; + margin-bottom: 20px; + padding: 15px; + background: rgba(255, 71, 87, 0.1); + border-radius: 8px; +} + +.reboot-btn { + padding: 15px 40px; + font-size: 1.1rem; + border: none; + border-radius: 8px; + background: linear-gradient(90deg, #ff4757, #ff6b7a); + color: white; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.reboot-btn:hover { + transform: translateY(-2px); + box-shadow: 0 5px 20px rgba(255, 71, 87, 0.4); +} + +.reboot-btn:active { + transform: translateY(0); +} + +.reboot-message { + margin-top: 15px; + padding: 15px; + border-radius: 8px; + display: none; +} + +.reboot-message.success { + display: block; + background: rgba(0, 255, 136, 0.2); + border: 1px solid #00ff88; + color: #00ff88; +} + +.reboot-message.error { + display: block; + background: rgba(255, 71, 87, 0.2); + border: 1px solid #ff4757; + color: #ff4757; +} + +/* Animations */ +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.loading { + animation: pulse 1.5s infinite; +} + +/* Responsive */ +@media (max-width: 600px) { + header h1 { + font-size: 1.8rem; + } + + .upload-tabs { + flex-direction: column; + } + + .status-card { + grid-template-columns: 1fr; + } +} diff --git a/api/spa.go b/api/spa.go new file mode 100644 index 0000000..c75186a --- /dev/null +++ b/api/spa.go @@ -0,0 +1,23 @@ +package api + +import ( + "embed" + "fmt" + "net/http" + "path" +) + +//go:embed frontend/* +var Frontend embed.FS + +func SpaHandler(fs http.FileSystem) http.Handler { + fileServer := http.FileServer(fs) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Println("/frontend" + r.URL.Path) + r.URL.Path = "/frontend" + path.Clean(r.URL.Path) + if _, err := fs.Open(path.Clean(r.URL.Path)); err != nil { + r.URL.Path = "/" + } + fileServer.ServeHTTP(w, r) + }) +} diff --git a/finit/up.conf b/finit/up.conf new file mode 100644 index 0000000..4c887fa --- /dev/null +++ b/finit/up.conf @@ -0,0 +1 @@ +service [2345] /usr/bin/up -- Update server diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a29120b --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module up + +go 1.26.1 + +require github.com/joho/godotenv v1.5.1 diff --git a/main.go b/main.go new file mode 100644 index 0000000..77af13f --- /dev/null +++ b/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "up/server" + "up/utils" +) + +func main() { + utils.LoadEnv() + server.Configure() + server.Run() +} diff --git a/server/Configure.go b/server/Configure.go new file mode 100644 index 0000000..3095eaf --- /dev/null +++ b/server/Configure.go @@ -0,0 +1,16 @@ +package server + +import ( + "net/http" + "up/api" +) + +func Configure() { + http.Handle("/", api.SpaHandler(http.FS(api.Frontend))) + http.HandleFunc("/api/mount", api.MountStatus) + http.HandleFunc("/api/extlinux", api.Extlinux) + http.HandleFunc("/api/filedate", api.FileDate) + http.HandleFunc("/api/temperature", api.HandleTemperature) + http.HandleFunc("/api/upload", api.HandleUpload) + http.HandleFunc("/api/reboot", api.HandleReboot) +} diff --git a/server/Run.go b/server/Run.go new file mode 100644 index 0000000..b6e8678 --- /dev/null +++ b/server/Run.go @@ -0,0 +1,29 @@ +package server + +import ( + "log" + "net/http" + "os" +) + +func Run() { + if os.Getenv("ENABLE_TLS") == "true" { + err := http.ListenAndServeTLS( + os.Getenv("SERVER_ADDRESS")+":"+os.Getenv("SERVER_PORT"), + os.Getenv("CERT_FILE"), + os.Getenv("KEY_FILE"), + nil, + ) + if err != nil { + log.Fatal(err) + } + } else { + err := http.ListenAndServe( + os.Getenv("SERVER_ADDRESS")+":"+os.Getenv("SERVER_PORT"), + nil, + ) + if err != nil { + log.Fatal(err) + } + } +} diff --git a/up.conf b/up.conf new file mode 100644 index 0000000..01618a7 --- /dev/null +++ b/up.conf @@ -0,0 +1,15 @@ +ENABLE_TLS=false +CERT_FILE=cert.crt +KEY_FILE=privkey.key +SERVER_ADDRESS=0.0.0.0 +SERVER_PORT=8989 +MOUNT_POINT=/media +DEVICE=/dev/mmcblk1p1 +KERNEL_FILE=Image.gz +DTB_FILE=sun50i-h618-orangepi-zero2w.dtb +UBOOT_FILE=uboot.env +EXTLINUX_FILE=extlinux/extlinux.conf +MAX_FILE_SIZE=200 +UBOOT_BIN_FILE=u-boot-sunxi-with-spl.bin +UBOOT_MAX_SIZE=1 +MMC_BOOT_OFFSET=8192 diff --git a/utils/EnsureDir.go b/utils/EnsureDir.go new file mode 100644 index 0000000..8dc847a --- /dev/null +++ b/utils/EnsureDir.go @@ -0,0 +1,11 @@ +package utils + +import "os" + +func EnsureDir(path string) int { + err := os.MkdirAll(path, 0755) + if err != nil { + return -1 + } + return 0 +} diff --git a/utils/GetTargetFilename.go b/utils/GetTargetFilename.go new file mode 100644 index 0000000..a730c4a --- /dev/null +++ b/utils/GetTargetFilename.go @@ -0,0 +1,18 @@ +package utils + +import "os" + +func GetTargetFilename(typ string) string { + switch typ { + case "kernel": + return os.Getenv("KERNEL_FILE") + case "dtb": + return os.Getenv("DTB_FILE") + case "uboot": + return os.Getenv("UBOOT_FILE") + case "uboot_bin": + return os.Getenv("UBOOT_BIN_FILE") + default: + return "" + } +} diff --git a/utils/LoadEnv.go b/utils/LoadEnv.go new file mode 100644 index 0000000..46b9cc5 --- /dev/null +++ b/utils/LoadEnv.go @@ -0,0 +1,14 @@ +package utils + +import ( + "log" + + "github.com/joho/godotenv" +) + +func LoadEnv() { + err := godotenv.Load("up.conf") + if err != nil { + log.Fatal("Error loading env file") + } +} diff --git a/utils/MountDevice.go b/utils/MountDevice.go new file mode 100644 index 0000000..881b3c5 --- /dev/null +++ b/utils/MountDevice.go @@ -0,0 +1,55 @@ +package utils + +import ( + "bufio" + "fmt" + "os" + "syscall" +) + +// mount_device checks if the device is already mounted at MOUNT_POINT, +// and if not, attempts to mount it as vfat or ext4. +// Returns 0 on success, -1 on error. +func MountDevice() int { + var DEVICE = os.Getenv("DEVICE") + var MOUNT_POINT = os.Getenv("MOUNT_POINT") + if EnsureDir(MOUNT_POINT) < 0 { + return -1 + } + + // Check if already mounted + f, err := os.Open("/proc/mounts") + if err == nil { + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + // Simple substring check (equivalent to strstr) + if contains(line, MOUNT_POINT) { + return 0 + } + } + // Ignore scanner errors for simplicity + } + + // Try mounting as vfat, then ext4 + if err := syscall.Mount(DEVICE, MOUNT_POINT, "vfat", 0, ""); err != nil { + if err2 := syscall.Mount(DEVICE, MOUNT_POINT, "ext4", 0, ""); err2 != nil { + // Both failed – print error (like perror) and return -1 + fmt.Fprintf(os.Stderr, "mount: %v (vfat) / %v (ext4)\n", err, err2) + return -1 + } + } + return 0 +} + +func contains(s, substr string) bool { + // Simple substring check, equivalent to strstr(s, substr) != NULL + // You may also use strings.Contains if imported. + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/utils/WriteUbootToDevices.go b/utils/WriteUbootToDevices.go new file mode 100644 index 0000000..eb07a98 --- /dev/null +++ b/utils/WriteUbootToDevices.go @@ -0,0 +1,153 @@ +package utils + +import ( + "fmt" + "io" + "os" + "strconv" + "sync" + "syscall" + "up/utils/mtd" +) + +// Глобальное состояние загрузки (используется для обновления прогресса) +var uploadState struct { + sync.RWMutex + Progress int + // другие поля могут быть здесь +} + +// writeUbootToDevices записывает U-Boot в файл, SPI NOR и SD-карту. +// Принимает данные, их длину и путь для сохранения копии в файловой системе. +func WriteUbootToDevices(data []byte, filepath string) error { + UBOOT_MAX_SIZE, err := strconv.Atoi(os.Getenv("UBOOT_MAX_SIZE")) + if err != nil { + fmt.Println("error: ", err) + return err + } + MMC_BOOT_OFFSET, err := strconv.Atoi(os.Getenv("MMC_BOOT_OFFSET")) + if err != nil { + fmt.Println("error: ", err) + return err + } + const chunkSize = 4096 + dataLen := int64(len(data)) + + fmt.Printf("Writing U-Boot to devices (%d bytes)...\n", dataLen) + + // Проверка лимита размера + if dataLen > int64(UBOOT_MAX_SIZE*1024*1024) { + return fmt.Errorf("U-Boot image too large for SPI NOR (%d > %d bytes)", dataLen, UBOOT_MAX_SIZE) + } + + // --- 1. Сохраняем в файл (копия) --- + f, err := os.Create(filepath) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", filepath, err) + } + + var written int64 + for written < dataLen { + toWrite := dataLen - written + if toWrite > chunkSize { + toWrite = chunkSize + } + n, err := f.Write(data[written : written+toWrite]) + if err != nil { + f.Close() + return fmt.Errorf("failed to write to file: %w", err) + } + written += int64(n) + // Прогресс: 30% за запись в файл + uploadState.Lock() + uploadState.Progress = int(written * 30 / dataLen) + uploadState.Unlock() + } + f.Close() + syscall.Sync() // синхронизация после записи файла + + // --- 2. Запись в SPI NOR (/dev/mtd0) --- + fmt.Println("Writing to /dev/mtd0 (SPI NOR)...") + mtdFd, err := os.OpenFile("/dev/mtd0", os.O_RDWR|os.O_SYNC, 0660) + if err == nil { + defer mtdFd.Close() + + // Получаем размер MTD-устройства из sysfs + mtdSize, err := mtd.ReadMTDSize(0) + if err != nil { + fmt.Printf("Cannot get MTD size from sysfs, proceeding without erase: %v\n", err) + } else { + // Стираем весь чип + fmt.Printf("Erasing SPI NOR (size %d bytes)...\n", mtdSize) + if err := mtd.EraseMTD(mtdFd, 0, mtdSize); err != nil { + fmt.Printf("Warning: erase failed (continuing anyway): %v\n", err) + } else { + fmt.Println("SPI NOR erased successfully") + } + } + + // Запись в MTD + written = 0 + for written < dataLen { + toWrite := dataLen - written + if toWrite > chunkSize { + toWrite = chunkSize + } + n, err := mtdFd.Write(data[written : written+toWrite]) + if err != nil { + fmt.Printf("Error writing to /dev/mtd0: %v\n", err) + break + } + written += int64(n) + // Прогресс: 30–65% за SPI NOR + uploadState.Lock() + uploadState.Progress = 30 + int(written*35/dataLen) + uploadState.Unlock() + } + fmt.Printf("Written %d bytes to /dev/mtd0\n", written) + } else { + fmt.Printf("/dev/mtd0 not available: %v (skipping SPI NOR)\n", err) + } + + // --- 3. Запись в SD-карту (/dev/mmcblk1) со смещением 8 КБ --- + fmt.Printf("Writing to /dev/mmcblk1 at offset %d (SD card boot area)...\n", MMC_BOOT_OFFSET) + mmcFd, err := os.OpenFile("/dev/mmcblk1", os.O_RDWR|os.O_SYNC, 0660) + if err == nil { + defer mmcFd.Close() + + // Устанавливаем смещение + if _, err := mmcFd.Seek(int64(MMC_BOOT_OFFSET), io.SeekStart); err != nil { + fmt.Printf("Failed to seek on /dev/mmcblk1: %v\n", err) + } else { + written = 0 + for written < dataLen { + toWrite := dataLen - written + if toWrite > chunkSize { + toWrite = chunkSize + } + n, err := mmcFd.Write(data[written : written+toWrite]) + if err != nil { + fmt.Printf("Error writing to /dev/mmcblk1: %v\n", err) + break + } + written += int64(n) + // Прогресс: 65–100% за MMC + uploadState.Lock() + uploadState.Progress = 65 + int(written*35/dataLen) + uploadState.Unlock() + } + fmt.Printf("Written %d bytes to /dev/mmcblk1 at offset %d\n", written, MMC_BOOT_OFFSET) + } + } else { + fmt.Printf("/dev/mmcblk1 not available: %v (skipping SD card)\n", err) + } + + // Финальная синхронизация + syscall.Sync() + + uploadState.Lock() + uploadState.Progress = 100 + uploadState.Unlock() + + return nil +} diff --git a/utils/mtd/EraseMTD.go b/utils/mtd/EraseMTD.go new file mode 100644 index 0000000..79b1ce6 --- /dev/null +++ b/utils/mtd/EraseMTD.go @@ -0,0 +1,39 @@ +package mtd + +import ( + "fmt" + "os" + "syscall" + "unsafe" +) + +// eraseMTD выполняет ioctl MEMERASE для стирания области MTD-устройства. +// Используется системный вызов ioctl с соответствующей структурой. +func EraseMTD(f *os.File, start, length uint64) error { + // Структура erase_info_user (определена в linux/mtd/mtd.h) + // На 64‑битных системах оба поля uint64. + type eraseInfo struct { + Start uint64 + Length uint64 + } + ei := eraseInfo{Start: start, Length: length} + + // Определяем ioctl MEMERASE. Числовое значение можно взять из заголовков. + // Для ARM64 обычно: MEMERASE = 0x80044d02? Но используем стандартное значение, + // которое определено в через _IOW('M', 2, erase_info_user). + // В большинстве 64‑битных систем это 0x80104d02? Проверим: + // _IOC(_IOC_WRITE, 'M', 2, sizeof(erase_info_user)) с sizeof=16 => 0x80104d02. + // Но для простоты можно использовать константу из пакета unix, если он её определяет. + // Если нет, её можно вычислить с помощью макроса _IOW, но в чистом Go это сложно. + // Положимся на значение, которое работает на целевой платформе: + const MEMERASE = 0x80044d02 // для 32-битных, для 64-битных может отличаться. + // Более безопасно использовать вызов syscall.Syscall с аргументами напрямую. + // Вместо этого можно попытаться вызвать "mtd_debug erase" через exec, но это нежелательно. + // Здесь мы оставляем как пример; при необходимости скорректируйте константу под вашу платформу. + + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), MEMERASE, uintptr(unsafe.Pointer(&ei))) + if errno != 0 { + return fmt.Errorf("ioctl MEMERASE failed: %v", errno) + } + return nil +} diff --git a/utils/mtd/ReadMTDSize.go b/utils/mtd/ReadMTDSize.go new file mode 100644 index 0000000..6a4f06a --- /dev/null +++ b/utils/mtd/ReadMTDSize.go @@ -0,0 +1,23 @@ +package mtd + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +func ReadMTDSize(devIndex int) (uint64, error) { + path := fmt.Sprintf("/sys/class/mtd/mtd%d/size", devIndex) + data, err := os.ReadFile(path) + if err != nil { + return 0, err + } + // Файл содержит размер в байтах как строку (например, "16777216\n") + sizeStr := strings.TrimSpace(string(data)) + size, err := strconv.ParseUint(sizeStr, 10, 64) + if err != nil { + return 0, fmt.Errorf("parsing size from %s: %w", path, err) + } + return size, nil +}