feat: 开放小爱音箱补丁固件构建源代码和教程
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# 下载固件
|
||||
if [ -z "$CI" ]; then
|
||||
npm run ota
|
||||
else
|
||||
npx tsx src/ota.ts
|
||||
fi
|
||||
|
||||
# 提取固件
|
||||
npm run extract
|
||||
|
||||
# 打补丁
|
||||
npm run patch
|
||||
|
||||
# 打包固件
|
||||
npm run squashfs
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Source: https://github.com/csftech/Xiaomi-OpenWrt-firmware-toolkit
|
||||
# Author: @csftech (Sheng-Fu Chang)
|
||||
# MIT Licensed
|
||||
|
||||
import argparse
|
||||
import binascii
|
||||
import ctypes
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
|
||||
class ImageHeader(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("magic", ctypes.c_uint),
|
||||
("signature_offset", ctypes.c_uint),
|
||||
("crc32_checksum", ctypes.c_uint),
|
||||
("file_type", ctypes.c_ushort),
|
||||
("model", ctypes.c_ushort),
|
||||
("segment_offsets", ctypes.c_uint * 8),
|
||||
]
|
||||
|
||||
|
||||
class SegmentHeader(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("magic", ctypes.c_uint),
|
||||
("flash_address", ctypes.c_uint),
|
||||
("length", ctypes.c_uint),
|
||||
("partition", ctypes.c_uint),
|
||||
("segment_name", ctypes.c_char * 32),
|
||||
]
|
||||
|
||||
|
||||
class Firmware:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.image_header = ImageHeader()
|
||||
self.fd = open(self.path, "rb")
|
||||
|
||||
def verify(self, ignore_hash=False):
|
||||
logging.info("[Jobs] Verifying firmware image...")
|
||||
assert self.fd.readinto(self.image_header) == ctypes.sizeof(self.image_header)
|
||||
|
||||
# magic
|
||||
logging.info(f"firmware magic: {hex(self.image_header.magic)}")
|
||||
assert self.image_header.magic in [0x31524448, 0x32524448]
|
||||
|
||||
if self.image_header.magic == 0x32524448:
|
||||
logging.info("NOTE: firmware seems to be encrypted?")
|
||||
|
||||
# signature
|
||||
self.fd.seek(self.image_header.signature_offset)
|
||||
signature_length = int.from_bytes(self.fd.read(16), "little")
|
||||
signature = self.fd.read()
|
||||
logging.info(
|
||||
f"firmware signature_offset: {hex(self.image_header.signature_offset)}"
|
||||
)
|
||||
logging.info(f"firmware signature_length: {signature_length}")
|
||||
logging.info(f"firmware signature: {signature.hex()}")
|
||||
assert len(signature) == signature_length
|
||||
|
||||
# crc32
|
||||
self.fd.seek(12)
|
||||
logging.info(
|
||||
f"firmware crc32_checksum: {hex(self.image_header.crc32_checksum)}"
|
||||
)
|
||||
computed_checksum = ~binascii.crc32(self.fd.read()) & 0xFFFFFFFF
|
||||
logging.info(f"computed crc32_checksum: {hex(computed_checksum)}")
|
||||
assert self.image_header.crc32_checksum == computed_checksum
|
||||
|
||||
# md5
|
||||
self.fd.seek(0)
|
||||
m = hashlib.md5()
|
||||
m.update(self.fd.read())
|
||||
hash = m.hexdigest()
|
||||
logging.info(f"computed md5 hash: {hash}")
|
||||
filename_hash = os.path.basename(self.path).split("_")[-2]
|
||||
try:
|
||||
assert hash[(len(filename_hash) * -1) :] == filename_hash
|
||||
except AssertionError:
|
||||
if ignore_hash:
|
||||
logging.warning(
|
||||
f'Warning: hash "{filename_hash}" does not match expected!'
|
||||
)
|
||||
pass
|
||||
else:
|
||||
raise
|
||||
|
||||
self.fd.seek(0)
|
||||
return True
|
||||
|
||||
def extract(self, dest: str = None):
|
||||
logging.info("[Jobs] Extracting firmware...")
|
||||
current_time = time.strftime("%Y%m%d_%H%M%S", time.localtime())
|
||||
self.dest_dir = os.path.join(
|
||||
dest, f'{os.path.basename(self.path).replace(".bin", "")}_{current_time}'
|
||||
)
|
||||
if dest is not None:
|
||||
self.dest_dir = dest
|
||||
os.mkdir(self.dest_dir)
|
||||
logging.info(f"create destination directory: {self.dest_dir}")
|
||||
|
||||
for address in self.image_header.segment_offsets:
|
||||
if address:
|
||||
self.fd.seek(address)
|
||||
segment_header = SegmentHeader()
|
||||
assert self.fd.readinto(segment_header) == ctypes.sizeof(segment_header)
|
||||
|
||||
# extract segment
|
||||
with open(
|
||||
os.path.join(self.dest_dir, segment_header.segment_name.decode()),
|
||||
"wb",
|
||||
) as s:
|
||||
s.write(self.fd.read(segment_header.length))
|
||||
logging.info(
|
||||
f'extracting segment: {segment_header.segment_name.decode("ascii")}'
|
||||
)
|
||||
|
||||
|
||||
def run(path, extract=False, dest=None, ignore_hash=False):
|
||||
logging.info(f"[MSG] Input file: {path}")
|
||||
firmware = Firmware(path)
|
||||
if firmware.verify(ignore_hash):
|
||||
logging.info(
|
||||
"[Jobs] Verification success: it's a genuine firmware from Xiaomi."
|
||||
)
|
||||
|
||||
if extract:
|
||||
firmware.extract(dest)
|
||||
logging.info("[Jobs] Extraction complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(format="%(asctime)s - %(message)s", level=logging.INFO)
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--dest",
|
||||
help="Destination directory to store extracted files, default=current working directory",
|
||||
action="store",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--ignore-hash",
|
||||
help="Do not fail if md5 hash does not match",
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument("-e", "--extract", help="Input your firmware", action="store")
|
||||
parser.add_argument("-s", "--show", help="Show firmware info", action="store")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.extract:
|
||||
firmware_path = os.path.abspath(args.extract)
|
||||
dest_path = os.getcwd()
|
||||
if args.dest:
|
||||
dest_path = args.dest
|
||||
run(firmware_path, extract=True, dest=dest_path, ignore_hash=args.ignore_hash)
|
||||
elif args.show:
|
||||
firmware_path = os.path.abspath(args.show)
|
||||
run(firmware_path, ignore_hash=args.ignore_hash)
|
||||
else:
|
||||
parser.print_help()
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
BASE_DIR=$(pwd)
|
||||
WORK_DIR=$BASE_DIR/temp
|
||||
|
||||
FIRMWARE=$(basename $(ls $BASE_DIR/assets/*.bin 2>/dev/null | head -n 1) .bin)
|
||||
|
||||
if [ ! -f "$BASE_DIR/assets/$FIRMWARE.bin" ]; then
|
||||
echo "❌ 固件文件不存在,请先下载固件到:$BASE_DIR/assets/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$WORK_DIR" && mkdir -pv "$WORK_DIR" && cd $WORK_DIR
|
||||
|
||||
python3 $BASE_DIR/src/extract.py -e "$BASE_DIR/assets/$FIRMWARE.bin" -d "$WORK_DIR/$FIRMWARE"
|
||||
|
||||
ln -sf $WORK_DIR/$FIRMWARE/root.squashfs $WORK_DIR/root.squashfs
|
||||
|
||||
unsquashfs $WORK_DIR/root.squashfs
|
||||
@@ -0,0 +1,261 @@
|
||||
import { getMiNA } from "@mi-gpt/miot";
|
||||
import { createHash } from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as stream from "node:stream";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const kSupportedDevices = [
|
||||
"LX06", // 小爱音箱 Pro
|
||||
"OH2P", // Xiaomi 智能音箱 Pro
|
||||
];
|
||||
|
||||
// 获取 OTA 信息
|
||||
async function getOTA(channel: "release" | "current" | "stable" = "release") {
|
||||
const MiNA = await getMiNA({
|
||||
userId: process.env.MI_USER!,
|
||||
password: process.env.MI_PASS!,
|
||||
});
|
||||
if (!MiNA) {
|
||||
console.log(`❌ 登录失败,请检查小米账号密码是否正确:`);
|
||||
console.log(`当前账号:${process.env.MI_USER}`);
|
||||
console.log(`当前密码:${process.env.MI_PASS}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const devices = await MiNA.getDevices();
|
||||
const speaker = devices?.find(
|
||||
(e: any) =>
|
||||
e.name === process.env.MI_DID || e.miotDID === process.env.MI_DID
|
||||
);
|
||||
if (!speaker) {
|
||||
console.log(`❌ 找不到设备: ${process.env.MI_DID}`);
|
||||
console.log(`可用设备列表:`);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
devices.map((e: any) => ({
|
||||
name: e.name,
|
||||
did: e.miotDID,
|
||||
})),
|
||||
null,
|
||||
4
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!kSupportedDevices.includes(speaker.hardware)) {
|
||||
console.log(
|
||||
`❌ 暂不支持当前设备型号: ${speaker.hardware}(${speaker.name})`
|
||||
);
|
||||
console.log(`可用设备列表:`);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
devices
|
||||
.filter((e: any) => kSupportedDevices.includes(e.hardware))
|
||||
.map((e: any) => ({
|
||||
name: e.name,
|
||||
did: e.miotDID,
|
||||
})),
|
||||
null,
|
||||
4
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sn = speaker.serialNumber;
|
||||
const model = speaker.hardware;
|
||||
const version = speaker.romVersion;
|
||||
const time = new Date().getTime();
|
||||
const otaInfo = `channel=${channel}&filterID=${sn}&locale=zh_CN&model=${model}&time=${time}&version=${version}&8007236f-a2d6-4847-ac83-c49395ad6d65`;
|
||||
const base64Str = Buffer.from(otaInfo).toString("base64");
|
||||
const code = createHash("md5").update(base64Str).digest("hex");
|
||||
|
||||
return {
|
||||
sn,
|
||||
model,
|
||||
version,
|
||||
url: `http://api.miwifi.com/rs/grayupgrade/v2/${model}?model=${model}&version=${version}&channel=${channel}&filterID=${sn}&locale=zh_CN&time=${time}&s=${code}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`🔥 正在获取设备信息...`);
|
||||
const ota = await getOTA();
|
||||
if (!ota) {
|
||||
console.log(`❌ 获取设备信息失败`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`🔥 正在获取 OTA 信息...`);
|
||||
const res = await fetch(ota.url);
|
||||
const data = await res.json();
|
||||
if (data.code === "0" && data.data) {
|
||||
if (data.data.currentInfo) {
|
||||
console.log("\n=== 当前版本固件 ===\n");
|
||||
const filePath = await downloadFirmware(data.data.currentInfo);
|
||||
if (filePath) {
|
||||
fs.writeFileSync(
|
||||
path.join(process.cwd(), "assets", ".model"),
|
||||
ota.model
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(process.cwd(), "assets", ".version"),
|
||||
ota.version
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`❌ 获取固件信息失败: ${data.code || "未知错误"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
// 下载固件
|
||||
async function downloadFirmware(firmware: {
|
||||
link: string;
|
||||
hash: string;
|
||||
toVersion?: string;
|
||||
size?: number;
|
||||
}): Promise<string | undefined> {
|
||||
if (!firmware || !firmware.link) {
|
||||
console.log("❌ 无效的固件信息");
|
||||
return;
|
||||
}
|
||||
|
||||
const assetsDir = path.join(process.cwd(), "assets");
|
||||
|
||||
// 从链接中提取文件名
|
||||
const url = new URL(firmware.link);
|
||||
const filename = path.basename(url.pathname);
|
||||
|
||||
// 打印固件信息
|
||||
console.log(`- 版本: ${firmware.toVersion || "未知"}`);
|
||||
console.log(
|
||||
`- 大小: ${
|
||||
firmware.size ? (firmware.size / 1024 / 1024).toFixed(2) + "MB" : "未知"
|
||||
}`
|
||||
);
|
||||
console.log(`- 文件: ${filename}`);
|
||||
console.log(`- MD5: ${firmware.hash || "未知"}\n`);
|
||||
|
||||
try {
|
||||
const filePath = await downloadFile(firmware.link, assetsDir, filename);
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
console.error(`❌ 下载固件失败: ${error}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDir(dirPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.promises.access(dirPath);
|
||||
} catch (error) {
|
||||
await fs.promises.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(
|
||||
url: string,
|
||||
destDir: string,
|
||||
filename: string
|
||||
): Promise<string> {
|
||||
await ensureDir(destDir);
|
||||
|
||||
const destPath = path.join(destDir, filename);
|
||||
|
||||
// 检查文件是否已存在
|
||||
try {
|
||||
await fs.promises.access(destPath);
|
||||
console.log(`ℹ️ 文件已存在: ${destPath}`);
|
||||
return destPath;
|
||||
} catch (error) {
|
||||
// 文件不存在,下载它
|
||||
console.log(`⬇️ 开始下载: ${url}`);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载失败: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// 获取文件大小
|
||||
const contentLength = response.headers.get("content-length");
|
||||
const totalSize = contentLength ? parseInt(contentLength, 10) : 0;
|
||||
|
||||
// 创建文件写入流
|
||||
const fileStream = fs.createWriteStream(destPath);
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("下载失败: 无法获取响应体");
|
||||
}
|
||||
|
||||
// 创建进度显示变量
|
||||
let downloadedBytes = 0;
|
||||
let lastLoggedPercent = -1;
|
||||
let startTime = Date.now();
|
||||
|
||||
// 使用 TransformStream 来跟踪下载进度
|
||||
const progressStream = new stream.Transform({
|
||||
transform(chunk, encoding, callback) {
|
||||
downloadedBytes += chunk.length;
|
||||
|
||||
if (totalSize) {
|
||||
const percent = Math.floor((downloadedBytes / totalSize) * 100);
|
||||
|
||||
// 确保每增加1%才打印一次,避免日志过多
|
||||
if (percent > lastLoggedPercent) {
|
||||
lastLoggedPercent = percent;
|
||||
|
||||
// 格式化输出
|
||||
const downloaded = (downloadedBytes / 1024 / 1024).toFixed(2);
|
||||
const total = (totalSize / 1024 / 1024).toFixed(2);
|
||||
|
||||
// 使用\r使光标回到行首,实现同行更新
|
||||
process.stdout.write(
|
||||
`\r下载进度: ${percent}% | ${downloaded}MB/${total}MB`
|
||||
);
|
||||
|
||||
// 如果下载完成,打印换行符
|
||||
if (downloadedBytes === totalSize) {
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 无法获取总大小时,只显示已下载大小
|
||||
if (downloadedBytes % (1024 * 1024) === 0) {
|
||||
// 每1MB打印一次
|
||||
const downloaded = (downloadedBytes / 1024 / 1024).toFixed(2);
|
||||
process.stdout.write(`\r已下载: ${downloaded}MB`);
|
||||
}
|
||||
}
|
||||
|
||||
this.push(chunk);
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
// 完成后清理并打印最终结果
|
||||
progressStream.on("end", () => {
|
||||
const totalTime = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
console.log(`\n✅ 下载完成: ${destPath}`);
|
||||
console.log(
|
||||
` 总大小: ${(downloadedBytes / 1024 / 1024).toFixed(
|
||||
2
|
||||
)}MB, 用时: ${totalTime}秒`
|
||||
);
|
||||
});
|
||||
|
||||
// 使用pipeline连接流
|
||||
await promisify(stream.pipeline)(
|
||||
// @ts-ignore - response.body 在 Node.js 中是 ReadableStream
|
||||
response.body,
|
||||
progressStream,
|
||||
fileStream
|
||||
);
|
||||
|
||||
return destPath;
|
||||
}
|
||||
}
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
BASE_DIR=$(pwd)
|
||||
WORK_DIR=$BASE_DIR/temp
|
||||
|
||||
PASSWORD=${SSH_PASSWORD:-"open-xiaoai"}
|
||||
PASSWORD=$(openssl passwd -1 -salt "open-xiaoai" "$PASSWORD")
|
||||
|
||||
# 应用指定目录下的补丁文件
|
||||
apply_patches() {
|
||||
local patch_dir="$1"
|
||||
local message="$2"
|
||||
|
||||
echo "🔥 $message"
|
||||
|
||||
if [ -d "$patch_dir" ]; then
|
||||
for file in "$patch_dir"/*; do
|
||||
if [ -f "$file" ]; then
|
||||
if [[ "$file" == *.patch ]]; then
|
||||
# 创建临时文件用于占位符替换
|
||||
local temp_patch=$(mktemp)
|
||||
# 将补丁文件中的 {SSH_PASSWORD} 替换为 PASSWORD
|
||||
sed "s/{SSH_PASSWORD}/$PASSWORD/g" "$file" > "$temp_patch"
|
||||
# 应用替换后的补丁
|
||||
patch -p1 < "$temp_patch"
|
||||
# 清理临时文件
|
||||
rm "$temp_patch"
|
||||
elif [[ "$file" == *.sh ]]; then
|
||||
sh "$file"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
if [ ! -f "$BASE_DIR/assets/.model" ]; then
|
||||
echo "❌ 固件信息不存在,请先下载固件到:$BASE_DIR/assets/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PATCH_DIR=$BASE_DIR/patches
|
||||
MODEL=$(cat $BASE_DIR/assets/.model)
|
||||
|
||||
cd $WORK_DIR/squashfs-root
|
||||
|
||||
# 应用通用补丁
|
||||
apply_patches "$PATCH_DIR" "正在应用通用补丁..."
|
||||
|
||||
# 应用特定型号补丁
|
||||
apply_patches "$PATCH_DIR/$MODEL" "正在应用 $MODEL 补丁..."
|
||||
|
||||
echo "✅ 补丁应用完成"
|
||||
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
BASE_DIR=$(pwd)
|
||||
WORK_DIR=$BASE_DIR/temp
|
||||
|
||||
FIRMWARE=$(basename $(ls $BASE_DIR/assets/*.bin 2>/dev/null | head -n 1) .bin)
|
||||
|
||||
cd $WORK_DIR
|
||||
|
||||
if [ ! -f "$BASE_DIR/assets/$FIRMWARE.bin" ]; then
|
||||
echo "❌ 固件文件不存在,请先下载固件到:$BASE_DIR/assets/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$FIRMWARE" ]; then
|
||||
echo "❌ 解压后的固件文件夹不存在,请先提取固件"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SQUASHFS_INFO=$(file $FIRMWARE/root.squashfs)
|
||||
echo "🚗 原始固件信息: $SQUASHFS_INFO"
|
||||
|
||||
COMPRESSION=$(echo "$SQUASHFS_INFO" | grep -o "xz\|gzip\|lzo\|lz4\|zstd compressed" | cut -d' ' -f1)
|
||||
BLOCKSIZE=$(echo "$SQUASHFS_INFO" | grep -o "blocksize: [0-9]* bytes" | cut -d' ' -f2)
|
||||
|
||||
echo "🔥 使用原始参数重新打包固件..."
|
||||
mksquashfs squashfs-root $FIRMWARE/root.squashfs \
|
||||
-comp $COMPRESSION -b $BLOCKSIZE \
|
||||
-noappend -all-root -always-use-fragments -no-xattrs -no-exports
|
||||
|
||||
cp -rf $FIRMWARE $BASE_DIR/assets/$FIRMWARE
|
||||
|
||||
echo "✅ 打包完成,固件文件已复制到 assets 目录..."
|
||||
echo $BASE_DIR/assets/$FIRMWARE/root.squashfs
|
||||
Reference in New Issue
Block a user