Initial import: ESP32 ZiFi32 firmware with SMB2/3 VFS bridge

Based on https://github.com/tslabs/zx-evo/tree/master/pentevo/esp32,
extended with an SMB2/3 server that exposes the ZX Evolution's real SD
card as a network share (browse/download/upload/delete), plus assorted
stability and protocol-correctness fixes in vfs_bridge.cpp and the
vendored libsmb2 - see firmware/README.md for the detailed changelog
and current status.

Also adds:
- firmware/: ready-to-flash compiled binaries + flashing instructions
- hardware/: adapter board connection diagram, PCB photo, and Gerber
  files
- src/third_party/libsmb2: vendored from sahlberg/libsmb2 (LGPL-2.1,
  see its own COPYING/LICENCE files), de-submoduled to a plain
  snapshot with local patches applied directly

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 16:00:05 +03:00
co-authored by Claude Sonnet 5
commit 6d835bc44c
377 changed files with 189430 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
{"latitude":41.875,"longitude":12.5,"generationtime_ms":0.22363662719726562,"utc_offset_seconds":7200,"timezone":"Europe/Rome","timezone_abbreviation":"GMT+2","elevation":35.0,"current_units":{"time":"unixtime","interval":"seconds","temperature_2m":"°C","weather_code":"wmo code","is_day":"","wind_speed_10m":"km/h","precipitation":"mm","surface_pressure":"hPa"},"current":{"time":1789122600,"interval":900,"temperature_2m":28.3,"weather_code":2,"is_day":1,"wind_speed_10m":6.3,"precipitation":0.00,"surface_pressure":1012.8},"daily_units":{"time":"unixtime","weather_code":"wmo code","temperature_2m_max":"°C","temperature_2m_min":"°C","precipitation_sum":"mm","sunrise":"unixtime","sunset":"unixtime"},"daily":{"time":[1789077600,1789164000,1789250400,1789336800,1789423200,1789509600],"weather_code":[3,3,2,3,1,2],"temperature_2m_max":[29.8,30.3,29.8,29.5,30.8,30.4],"temperature_2m_min":[22.0,19.5,19.7,19.0,19.2,17.9],"precipitation_sum":[0.00,0.00,0.00,0.00,0.00,0.00],"sunrise":[1789101951,1789188412,1789274874,1789361336,1789447798,1789534260],"sunset":[1789147598,1789233893,1789320188,1789406484,1789492780,1789579076]}}
+1
View File
@@ -0,0 +1 @@
{"results":[{"id":703448,"name":"Kyiv","latitude":50.45466,"longitude":30.5238,"elevation":179.0,"feature_code":"PPLC","country_code":"UA","admin1_id":703447,"timezone":"Europe/Kyiv","population":2952301,"country_id":690791,"country":"Ukraine","admin1":"Kyiv City"}],"generationtime_ms":0.31280518}
+1
View File
@@ -0,0 +1 @@
{"generationtime_ms":0.35643578}
+1
View File
@@ -0,0 +1 @@
{"results":[{"id":3169070,"name":"Рим","latitude":41.89193,"longitude":12.51133,"elevation":20.0,"feature_code":"PPLC","country_code":"IT","admin1_id":3174976,"admin2_id":3169069,"admin3_id":3169071,"timezone":"Europe/Rome","population":2318895,"postcodes":["00187"],"country_id":3175395,"country":"Италия","admin1":"Лацио","admin2":"Città metropolitana di Roma Capitale","admin3":"Roma"}],"generationtime_ms":2.6960373}
@@ -0,0 +1 @@
{"results":[{"id":3169070,"name":"Rome","latitude":41.89193,"longitude":12.51133,"elevation":20.0,"feature_code":"PPLC","country_code":"IT","admin1_id":3174976,"admin2_id":3169069,"admin3_id":3169071,"timezone":"Europe/Rome","population":2318895,"postcodes":["00187"],"country_id":3175395,"country":"Italy","admin1":"Lazio","admin2":"Province of Rome","admin3":"Rome"}],"generationtime_ms":1.3924837}
+1
View File
@@ -0,0 +1 @@
{"country": "Italy", "country abbreviation": "IT", "post code": "00144", "places": [{"place name": "Decima", "longitude": "12.4333", "latitude": "41.75", "state": "Lazio", "state abbreviation": "LZ"}, {"place name": "Roma", "longitude": "12.4833", "latitude": "41.9", "state": "Lazio", "state abbreviation": "LZ"}]}
+249
View File
@@ -0,0 +1,249 @@
"""Host tests for the transferred weather parser; requires MSVC."""
import datetime
import json
import math
import pathlib
import struct
import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
FIXTURES = ROOT / 'tests' / 'fixtures' / 'weather'
BUILD = ROOT / '.test-build' / 'weather_parse'
VSWHERE = pathlib.Path(r'C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe')
# Смещения записи — как в include/zifi/weather_parse.hpp.
REC_SIZE = 90
REC_PLACE = 2
REC_TEMP = 26
REC_DAYS = 42
def find_vcvars() -> pathlib.Path | None:
if not VSWHERE.exists():
return None
result = subprocess.run([str(VSWHERE), '-products', '*', '-latest', '-find',
r'VC\Auxiliary\Build\vcvars64.bat'],
capture_output=True, text=True)
path = result.stdout.strip().splitlines()
return pathlib.Path(path[0]) if path else None
def short(path: pathlib.Path) -> str:
"""Короткий 8.3-путь: cmd.exe читает .bat в OEM-кодировке, и кириллица
профиля в пути до исходников ломает команду."""
import ctypes
buffer = ctypes.create_unicode_buffer(32768)
if not ctypes.windll.kernel32.GetShortPathNameW(str(path), buffer, len(buffer)):
return str(path)
return buffer.value
def build_exe(build: pathlib.Path, exe_name: str, includes: list[pathlib.Path],
sources: list[tuple[pathlib.Path, str]]) -> pathlib.Path | None:
"""Собрать программу MSVC. sources — (исходник, имя объекта): у исходников
бывают одинаковые короткие имена (WEATHE~1.CPP), поэтому каждый
компилируется отдельно в объект со своим именем."""
vcvars = find_vcvars()
if vcvars is None:
return None
build.mkdir(parents=True, exist_ok=True)
exe = build / exe_name
script = build / 'build_host.bat'
flags = '/nologo /c /EHsc /std:c++17 /O2 /utf-8 ' + ' '.join(
f'/I "{short(path)}"' for path in includes)
lines = ['@echo off', f'call "{short(vcvars)}" >nul 2>nul || exit /b 1']
lines += [f'cl {flags} "{short(source)}" /Fo:{obj} || exit /b 1' for source, obj in sources]
lines.append('cl /nologo ' + ' '.join(obj for _, obj in sources) + f' /Fe:{exe_name}')
script.write_text('\r\n'.join(lines) + '\r\n', encoding='ascii')
result = subprocess.run(['cmd.exe', '/c', short(script)], capture_output=True,
cwd=short(build))
if result.returncode != 0:
output = result.stdout.decode('cp866', 'replace') + result.stderr.decode('cp866', 'replace')
raise AssertionError(f'сборка {exe_name} не удалась:\n{output}')
return exe
def build_host() -> pathlib.Path | None:
return build_exe(BUILD, 'weather_parse_host.exe', [ROOT / 'main'],
[(ROOT / 'main' / 'weather_parse.cpp', 'parse.obj'),
(ROOT / 'tests' / 'weather_parse_host.cpp', 'host.obj')])
def rnd(value: float) -> int:
"""Округление как lround в прошивке: половина — от нуля (22.5 -> 23),
а не банковское округление round() в Python."""
return int(math.copysign(math.floor(abs(value) + 0.5), value))
def local_hm(unix: int, offset: int) -> tuple[int, int]:
t = datetime.datetime.fromtimestamp(unix + offset, datetime.timezone.utc)
return t.hour, t.minute
def local_date(unix: int, offset: int) -> tuple[int, int, int]:
t = datetime.datetime.fromtimestamp(unix + offset, datetime.timezone.utc)
return t.day, t.month, t.weekday()
class WeatherParseHostTest(unittest.TestCase):
exe: pathlib.Path | None = None
@classmethod
def setUpClass(cls) -> None:
cls.exe = build_host()
if cls.exe is None:
raise unittest.SkipTest('MSVC не найден: host-тест разбора пропущен')
def run_host(self, *args: str) -> str:
result = subprocess.run([str(self.exe), *args], capture_output=True)
output = result.stdout.decode('utf-8', 'replace').strip()
self.assertEqual(result.returncode, 0, output)
return output
def rejected(self, mode: str, body: bytes) -> bytes:
"""Ответ, который разбор отвергает: строка вывода host-обёртки."""
sample = BUILD / 'rejected.json'
sample.write_bytes(body)
result = subprocess.run([str(self.exe), mode, str(sample)], capture_output=True)
self.assertEqual(result.returncode, 1, result.stdout)
return result.stdout.strip()
def geo(self, mode: str, fixture: str) -> list[str]:
"""Разобрать ответ справочника места: [GEO, широта, долгота, место]."""
parts = self.run_host(mode, str(FIXTURES / fixture)).split(' ', 3)
self.assertEqual(parts[0], 'GEO', parts)
return parts
def test_city_search_english(self) -> None:
# city: Kyiv — поиск с language=en, название по-английски
place = json.loads((FIXTURES / 'openmeteo_city_kyiv.json').read_text('utf-8'))['results'][0]
parts = self.geo('city', 'openmeteo_city_kyiv.json')
self.assertAlmostEqual(float(parts[1]), place['latitude'], places=4)
self.assertAlmostEqual(float(parts[2]), place['longitude'], places=4)
self.assertEqual(parts[3], 'Kyiv')
def test_city_search_russian(self) -> None:
# кириллица ищется с language=ru — и название приходит по-русски
self.assertEqual(self.geo('city', 'openmeteo_city_rim_ru.json')[3], 'Рим')
self.assertEqual(self.geo('city', 'openmeteo_city_rome_it.json')[3], 'Rome')
def test_city_not_found(self) -> None:
# ничего не нашёл — ответ без results; только это служба запоминает
self.assertEqual(self.rejected('city', (FIXTURES / 'openmeteo_city_none.json').read_bytes()),
b'NOTFOUND city: not found')
self.assertEqual(self.rejected('city', b'{"results":[]}'), b'NOTFOUND city: not found')
def test_city_broken_reply(self) -> None:
# испорченный ответ — ошибка, но не «места нет»: повтор может помочь
self.assertEqual(self.rejected('city', b'[]'), b'ERR city: broken json')
self.assertEqual(self.rejected('city', b'{"results":{}}'), b'ERR city: broken json')
self.assertEqual(self.rejected('city', b'{"results":[{"name":"x",'), b'ERR city: broken json')
self.assertEqual(self.rejected('city', b'{"results":[{"name":"x"}]}'),
b'ERR city: no coordinates')
def test_zippopotam_takes_last_place(self) -> None:
places = json.loads((FIXTURES / 'zippopotam_it_00144.json').read_text('utf-8'))['places']
last = places[-1]
parts = self.geo('zip', 'zippopotam_it_00144.json')
self.assertAlmostEqual(float(parts[1]), float(last['latitude']), places=4)
self.assertAlmostEqual(float(parts[2]), float(last['longitude']), places=4)
self.assertEqual(parts[3], last['place name'])
self.assertEqual(parts[3], 'Roma')
def test_zippopotam_rejects_empty_reply(self) -> None:
self.assertEqual(self.rejected('zip', b'{}'), b'ERR zip: no places')
def test_ini_text_to_utf8(self) -> None:
# zifi.ini правят и на ПК (UTF-8, CP1251 из Блокнота «ANSI»), и в
# редакторе Wild Commander (CP866): геокодеру нужен UTF-8.
cases = [
('Kyiv', 'Kyiv'.encode('ascii')),
('Рим', 'Рим'.encode('utf-8')),
('Рим', 'Рим'.encode('cp866')),
('Рим', 'Рим'.encode('cp1251')),
('Одесса', 'Одесса'.encode('cp866')),
('Одесса', 'Одесса'.encode('cp1251')),
('Нью-Йорк', 'Нью-Йорк'.encode('cp1251')),
('Київ', 'Київ'.encode('cp1251')),
('Київ', 'Київ'.encode('cp866')),
('Zürich', 'Zürich'.encode('utf-8')),
]
for i, (expected, raw) in enumerate(cases):
sample = BUILD / f'ini_{i}.txt'
sample.write_bytes(raw)
output = self.run_host('ini', str(sample))
self.assertTrue(output.startswith('UTF8'), output)
self.assertEqual(bytes.fromhex(output[5:]).decode('utf-8'), expected, raw)
def test_open_meteo_record(self) -> None:
fixture = FIXTURES / 'open_meteo_roma.json'
output = self.run_host('meteo', str(fixture), 'Roma')
self.assertTrue(output.startswith('REC '), output)
record = bytes.fromhex(output[4:])
self.assertEqual(len(record), REC_SIZE)
src = json.loads(fixture.read_text('utf-8'))
offset = src['utc_offset_seconds']
cur = src['current']
daily = src['daily']
self.assertEqual(record[0], 1)
self.assertEqual(record[1], 1)
self.assertEqual(record[REC_PLACE:REC_PLACE + 24].split(b'\0')[0], b'Roma')
temp, code, is_day, wind10, precip10, press = struct.unpack_from('<bBBHHH', record, REC_TEMP)
self.assertEqual(temp, rnd(cur['temperature_2m']))
self.assertEqual(code, cur['weather_code'])
self.assertEqual(is_day, cur['is_day'])
self.assertEqual(wind10, rnd(cur['wind_speed_10m'] * 10))
self.assertEqual(precip10, rnd(cur['precipitation'] * 10))
self.assertEqual(press, rnd(cur['surface_pressure'] * 0.750062))
self.assertEqual(tuple(record[35:37]), local_hm(daily['sunrise'][0], offset))
self.assertEqual(tuple(record[37:39]), local_hm(daily['sunset'][0], offset))
self.assertEqual(tuple(record[39:41]), local_hm(cur['time'], offset))
self.assertEqual(record[41], 6)
for i in range(6):
day = record[REC_DAYS + i * 8:REC_DAYS + (i + 1) * 8]
d, m, wday = local_date(daily['time'][i] + 43200, offset)
self.assertEqual((day[0], day[1], day[2]), (d, m, wday), f'день {i}')
self.assertEqual(day[3], daily['weather_code'][i])
tmin, tmax, dprecip = struct.unpack_from('<bbH', day, 4)
self.assertEqual(tmin, rnd(daily['temperature_2m_min'][i]))
self.assertEqual(tmax, rnd(daily['temperature_2m_max'][i]))
self.assertEqual(dprecip, rnd(daily['precipitation_sum'][i] * 10))
def test_place_from_utf8_file(self) -> None:
# Название места — в UTF-8, как его дал справочник; передаётся файлом:
# так делает модель ESP стенда Unreal (аргументы Windows приходят в ANSI).
for text, expected in (('Città di Castello', b'Citta di Castello'),
('Одесса', 'Одесса'.encode('cp866'))):
place = BUILD / 'place_utf8.txt'
place.write_bytes(text.encode('utf-8'))
output = self.run_host('meteo', str(FIXTURES / 'open_meteo_roma.json'), '@' + str(place))
record = bytes.fromhex(output[4:])
self.assertEqual(record[REC_PLACE:REC_PLACE + 24].split(b'\0')[0], expected)
def test_utf8_to_cp866(self) -> None:
sample = BUILD / 'sample_utf8.txt'
sample.write_bytes('Zürich Gréoux Москва Ёж 15°'.encode('utf-8'))
output = self.run_host('cp866', str(sample))
self.assertTrue(output.startswith('CP866 '))
self.assertEqual(bytes.fromhex(output[6:]),
'Zurich Greoux Москва Ёж 15°'.encode('cp866'))
def test_utf8_to_cp866_ukrainian(self) -> None:
# Є є Ї ї Ў ў в CP866 есть; І і пишутся латинскими I i (выглядят так
# же), Ґ ґ — как Г г; типографские апостроф, кавычки и тире — ASCII.
text = 'Київ Єнакієве Ґалаґан Ўсход Кам’янець «Ліс» — І'
sample = BUILD / 'sample_ua.txt'
sample.write_bytes(text.encode('utf-8'))
output = self.run_host('cp866', str(sample))
simple = text.translate(str.maketrans({'і': 'i', 'І': 'I', 'ґ': 'г', 'Ґ': 'Г',
'’': "'", '«': '"', '»': '"', '—': '-'}))
self.assertEqual(bytes.fromhex(output[6:]), simple.encode('cp866'))
if __name__ == '__main__':
unittest.main()
+116
View File
@@ -0,0 +1,116 @@
// Host-обёртка для tests/test_weather_parse.py: запускает разбор ответов
// погодных сервисов из src/weather_parse.cpp на ПК и печатает результат.
// weather_parse_host city <файл json> -> GEO <lat> <lon> <place utf-8>
// (NOTFOUND <текст> — места нет)
// weather_parse_host zip <файл json> -> GEO <lat> <lon> <place utf-8>
// weather_parse_host meteo <файл json> <место> -> REC <hex записи>
// weather_parse_host cp866 <файл utf-8> -> CP866 <hex>
// weather_parse_host ini <файл> -> UTF8 <hex> (значение zifi.ini в UTF-8)
// Место с не-ASCII буквами передаётся как @<файл UTF-8>: аргументы командной
// строки Windows приходят в main() в кодовой странице ANSI, а не в UTF-8.
// Этим пользуется модель ESP стенда Unreal (shared/weather/esp_model.py).
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <string>
#include <vector>
#include "zifi/weather_parse.hpp"
namespace {
std::vector<char> readFile(const char* path) {
std::vector<char> data;
FILE* file = fopen(path, "rb");
if (file == nullptr) {
return data;
}
char buffer[4096];
size_t got;
while ((got = fread(buffer, 1, sizeof(buffer), file)) != 0) {
data.insert(data.end(), buffer, buffer + got);
}
fclose(file);
data.push_back(0);
return data;
}
void printHex(const unsigned char* data, size_t length) {
for (size_t i = 0; i < length; ++i) {
printf("%02x", data[i]);
}
printf("\n");
}
} // namespace
int main(int argc, char** argv) {
if (argc < 3) {
printf("ERR usage\n");
return 2;
}
const std::string mode = argv[1];
std::vector<char> data = readFile(argv[2]);
if (data.size() <= 1) {
printf("ERR read %s\n", argv[2]);
return 2;
}
const size_t length = data.size() - 1;
char error[96] = {};
if (mode == "city") {
zifi::GeoResult geo = {};
bool notFound = false;
if (!zifi::parseCitySearch(data.data(), length, geo, notFound, error, sizeof(error))) {
printf("%s %s\n", notFound ? "NOTFOUND" : "ERR", error);
return 1;
}
printf("GEO %.4f %.4f %s\n", geo.latitude, geo.longitude, geo.place);
return 0;
}
if (mode == "zip") {
zifi::GeoResult geo = {};
if (!zifi::parseZippopotam(data.data(), length, geo, error, sizeof(error))) {
printf("ERR %s\n", error);
return 1;
}
printf("GEO %.4f %.4f %s\n", geo.latitude, geo.longitude, geo.place);
return 0;
}
if (mode == "ini") {
char out[256];
zifi::iniTextToUtf8(data.data(), out, sizeof(out));
printf("UTF8 ");
printHex(reinterpret_cast<const unsigned char*>(out), strlen(out));
return 0;
}
if (mode == "meteo") {
unsigned char record[zifi::kWeatherRecordSize];
const char* place = argc > 3 ? argv[3] : "";
std::vector<char> placeFile;
if (place[0] == '@') {
placeFile = readFile(place + 1);
if (placeFile.empty()) {
printf("ERR read %s\n", place + 1);
return 2;
}
place = placeFile.data();
}
if (!zifi::parseOpenMeteo(data.data(), length, place, record, error, sizeof(error))) {
printf("ERR %s\n", error);
return 1;
}
printf("REC ");
printHex(record, sizeof(record));
return 0;
}
if (mode == "cp866") {
char out[64];
zifi::utf8ToCp866(data.data(), out, sizeof(out));
printf("CP866 ");
printHex(reinterpret_cast<const unsigned char*>(out), strlen(out));
return 0;
}
printf("ERR mode\n");
return 2;
}