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:
@@ -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]}}
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
{"generationtime_ms":0.35643578}
|
||||
@@ -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}
|
||||
@@ -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"}]}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user