Источник

Одноплатные компьютеры давно перестали быть чем-то удивительным. Теперь они, скорее, утилитарная вещь, база для домашних проектов. Со временем появилась другая проблема — доступность. Первые Raspberry Pi стоили от 25 до 35 $, и это позволяло использовать их для обучения школьников программированию без покупки дорогого ПК. Они были медленными и не слишком удобными, зато на их основе можно было делать разные хоббийные проекты, поэтому они стали популярными.

Со временем характеристики «малинки» значительно выросли, что отразилось на ценах. Стоимость Raspberry Pi 5 начинается с 55 $, а максимальная версия обойдется примерно в 150 $. Закономерно на рынке пачками появляются более дешевые альтернативы, часть их которых по качеству не уступают оригиналу.

Сегодня я расскажу об одном из таких аналогов, разработанном радиолюбителем из США, Майклом Бурмейстером-Брауном, позывной N7MDB. Это одноплатный компьютер на базе 4-ядерного ARM Cortex-A53 с тактовой частотой 1.7 GHz с прошивкой, доработанной для запуска HamClock.

Внешний вид

Корпус устройства квадратный, 92 мм x 92 мм. Большая часть портов расположена на одной боковой грани. Разъем питания 5В 2A с плюсом на центральном штыре. Это позволяет запитать девайс от любого приличного БП или повербанка. Потом идет разъем USB 2.0, вход сетевой карты RJ-45 (Fast Ethernet 100 Mbit/s), видеовыходы (HDMI и композитный):

На соседней грани расположился еще один порт USB 2.0 и очень хорошо замаскированный слот для MicroSD. Правда с ним приходится быть аккуратным — иногда карта может провалиться в щель между платой и корпусом, после чего придется лезть за отверткой и открывать последний:

В целом, сам корпус не создает ощущения хлипкости. Сделан добротно, выглядит стильно, снизу резиновые ножки под которыми скрыто четыре болта.

Что внутри

Традиционно мы решили сначала посмотреть на саму плату. Снять ее на составляет никакого труда. И в таком виде сразу становится понятно ее происхождение. Инфракрасный приемник выдает то, что изначально это должна была быть телевизионная приставка Android TV. Но применяемый процессор позволяет без каких-либо сложностей переделать ее в компьютер под управлением Linux.

Для лучшего понимания размеров, я положил рядом Arduino Uno Q, обзор которой недавно публиковал тут, на Хабре и снизу Raspberry Pi Zero W2:

Поскольку SoC Allwinner H6 п��лностью скрыт приклеенным на термоклей радиатором, посмотрим на другие чипы. Прежде всего это оперативная память от Micron. Маркировка 4BE77 D9PQL одназначно указывает на тип и объем — MT41K256M16HA-125:E (DDR3L-1600, 512MB). Всего на плате четыре таких чипа (два сверху, два снизу), что суммарно дает 2GB DDR3L, работающих в двухканальном режиме:

Рядом можно найти вот такую небольшую микросхему. Это ни что иное, как печально известный однокристальный чип Wi-Fi Allwinner XR819, отличающийся своими драйверами «с сюрпризами». Тут он соединен напрямую с SoC Allwinner H6 и обеспечивает поддержку Wi-Fi 802.11 b/g/n (2.4 GHz), а антенна тут встроена прямо в печатную плату (подведена к ANT1):

Следующий чип имеет маркировку SEC 637 B041 KLMAG1JENB. Это флеш-память Samsung eMMC 5.1, объемом 16 GB. В отличие от Raspberry Pi, тут операционная система устанавливается во внутренний накопитель eMMC, а слот MicroSD может служить дополнительным хранилищем. Однако, это накладывает свои особенности, о них чуть позже. Ниже видна большая пустая площадка для распайки еще одного накопителя eMMC:

На оборотной стороне платы, кроме двух чипов оперативной памяти, нет ничего интересного. Поскольку ко мне она попала после длительного использования, видны окислы, которые легко убираются изопропиловым спиртом:

Софт

Вот мы и добрались до самого интересного. Ко мне плата поступила с жалобой: не работает операционная система: включается, но не загружается. В наших краях не так давно было массовое отключение электроэнергии и именно оно привело к сбою. Все дело в том, что ОС работает на файловой системе ext4 с выключенным журналированием. В итоге внезапная потеря питания легко может привести ФС к неконсистентному состоянию. Следовательно, ее придется починить или полностью перезалить прошивку.

Последнее тут делается довольно любопытным способом. Заливка образа на флешку ничем не отличается от такой же процедуры на Raspberry Pi. Кому-то привычнее это делать с помощью Balena Etcher, а кому-то через стандартный Raspberry Pi Imager. Когда карта готова, можно начинать прошивку. Главное: отключить монитор, вставить MicroSD в слот и подать питание. Синий светодиод начнет гореть красным и надо подождать 15–20 минут, пока он вновь не станет синим. Затем отключить питание, убрать карту памяти и можно снова включать.

Столь странный порядок действий имеет вполне логичное объяснение. Поскольку Inovato Quadra представляет собой доработанный Android TV бокс на базе соответствующего SoC, приходится учитывать особенную логику загрузки таких девайсов. По умолчанию ОС хранится в eMMC. Если файловая система последней повреждена, то устройство запускает свой внутренний скрипт прошивки, который смотрит на наличие/отсутствие MicroSD-карты и на то, подключен дисплей или нет.

Если MicroSD присутствует, а в HDMI есть монитор, то скрипт считает, что пользователь хочет просто выполнить загрузку с накопителя в Desktop-режиме и пытается это сделать. Но вот если экран не подключен, то скрипт понимает, что нужно запускать перепрошивку eMMC, и приступает к этому процессу без дальнейших подтверждений.

Такой подход представляет собой разумный инженерный компромисс, ведь не требуется отдельная аппаратная кнопка или необходимость выставления правильного положения джампера, как на Arduino Uno Q. Роль триггера играет подключенный монитор и это позволяет без проблем запустить процесс. Единственный момент — отсутствие возможности контролировать прогресс. Но если образ в порядке, а чип накопителя исправен, процедура выполняется за ожидаемое количество времени.

Тут трудится Armbian Linux. По-сути, обычный Debian с привычным менеджером пакетов apt. Основным отличием является кастомное ядро, собранное под конкретный SoC, патчи для дополнительных чипов (вроде XR819) и собственная утилита конфигурации armbian-config. Кроме того, система оптимизирована для работы даже с небольшим объемом ОЗУ.

Вывод neofetch
Вывод neofetch

Кажется, что это отличный конфиг с процессором на 1.7 GHz, но на деле рабочий стол откликается довольно медленно. Чтобы посмотреть на модель взглянем на device-tree:

Подробности по SoC
Подробности по SoC

По коду sun50i-h6 становится ясно, что тут используется процессор ARM Cortex-A53 Quad-Core с графикой Mali-T720 MP2. Удивительно, но если верить техническим характеристикам, этот чип способен справиться с декодированием Ultra HD 4k and Full HD 1080p (MPEG-2, MPEG-4 SP/ASP GMC, H.263, H.264, H.265, WMV9/VC-1 и VP8). Но не стоит ждать волшебства. Ведь конкретно у этой модели есть один существенный минус — перегрев.

Систему охлаждения нельзя назвать эффективной. В простое температура держится чуть более 50 градусов, но достаточно дать хотя бы какую-то нагрузку, как чип начинает изображать из себя кипятильник, быстро и ровно набирая более 80°C (привет, троттлинг). Дойдя до 105°C одноплатник молча выключается, дабы предотвратить тепловое разрушение SoC.

Разумеется, проблема возникла во всех продаваемых Inovato Quadra, поэтому в официальном интернет-магазине появилось несколько необходимых аксессуаров. Первый — напечатанная на 3D-принтере стойка с держателем для вентилятора охлаждения. Второй — сам вентилятор с питанием от USB. Ну и третий — USB-хаб, поскольку один USB-порт будет занят:

Inovato Quadra c аксессуарами
Inovato Quadra c аксессуарами

С одной стороны такая конструкция выглядит неплохо и даже небольшой вентилятор значительно улучшает ситуацию. С другой, создает дополнительный шум, пускай и не слишком сильный. Основной задачей этого одноплатника разработчик считает перманентную работу HamClock, все-таки хотелось бы иметь пассивную систему охлаждения:

HamClock на Inovata Quadro
HamClock на Inovata Quadro

Оговорюсь, в мои руки попала первая версия этого одноплатника. Сейчас и до конца 2025 года в продаже есть более современная модель Quadra 4k, лишенная недостатка с перегревом. Во-первых, у нее снижена тактовая частота с 1.7 GHz до 1.5 GHz, а во-вторых, в нее встроена более эффективная система пассивного охлаждения.

Производительность

Для понимания решил посмотреть насколько быстро работает процессор Quadra на простых задачах. Для этого соорудил компиляцию из тестов:

  • целочисленный CPU (решето Эратосфена),

  • плавающая точка (много sin(sqrt())),

  • хеширование (SHA-256, «bytes/s»),

  • текст/regex,

  • JSON-сериализация + gzip,

  • SQLite (вставка и агрегация),

  • плюс параллельный тест на нескольких ядрах.

Полный код вы можете найти под спойлером:

Скрытый текст
#!/usr/bin/env python3
"""
Portable Microbench (no external deps)
- CPU integer: Sieve of Eratosthenes
- CPU float: sin(sqrt) accumulation
- Hash: SHA-256 throughput
- Text/Regex: findall on synthesized text
- JSON/Gzip: serialize/deserialize & compress
- SQLite: insert & aggregate in-memory
- Parallel scaling: float workload with multiprocessing

Usage:
    python3 microbench.py            # run with defaults
    python3 microbench.py --secs 2   # target seconds per test (default 1.5)
    python3 microbench.py --quick    # faster (≈0.7s/test)
    python3 microbench.py --long     # slower (≈3s/test)
    python3 microbench.py --no-par   # skip parallel test
    python3 microbench.py --csv out.csv  # also save results as CSV
    python3 microbench.py --json out.json # also save results as JSON

Test notes:
- Designed to run on tiny SBCs up to beefier ARM boards.
- Pure-Python by default; uses stdlib only.
- Results are *relative* across devices; absolute values are for rough comparison.
"""

import argparse
import gzip
import hashlib
import io
import json
import math
import os
import platform
import random
import re
import sqlite3
import statistics
import sys
import time
from multiprocessing import Pool, cpu_count

# ---------- Utilities ----------

def human(n):
    # human-friendly number formatting
    if n is None:
        return "-"
    if n >= 1e9:
        return f"{n/1e9:.2f}G"
    if n >= 1e6:
        return f"{n/1e6:.2f}M"
    if n >= 1e3:
        return f"{n/1e3:.2f}k"
    return f"{n:.2f}"

def now():
    return time.perf_counter()

def calibrate(fn, make_args, target_secs, min_iters=1):
    """Find an iteration count so total runtime ~ target_secs."""
    iters = min_iters
    # warmup
    fn(*make_args(iters))
    start = now()
    fn(*make_args(iters))
    elapsed = now() - start
    if elapsed <= 0:
        elapsed = 1e-6
    # scale to hit target
    scale = max(target_secs / elapsed, 1.0)
    iters = max(int(iters * scale), min_iters)
    # Limit excessive iters
    iters = min(iters, 10**9)
    return iters

def run_bench(fn, make_args, target_secs, unit_label, higher_is_better=True):
    iters = calibrate(fn, make_args, target_secs)
    start = now()
    fn(*make_args(iters))
    elapsed = now() - start
    # throughput = iterations per second (or bytes/sec inside fn if returns custom)
    # If the function returns a numeric throughput, use that; else use iterations.
    throughput = None
    try:
        result = fn(*make_args(iters))
        if isinstance(result, (int, float)) and result > 0:
            throughput = result / elapsed
    except Exception:
        # Some tests might not be re-entrant; run once for timing only
        pass
    if throughput is None:
        throughput = iters / elapsed
    return {
        "iters": iters,
        "elapsed_s": elapsed,
        "throughput": throughput,
        "unit": unit_label,
        "higher_is_better": higher_is_better,
    }

# ---------- Workloads ----------

def sieve_count(n):
    """Return the count of primes up to n using a simple sieve."""
    sieve = bytearray(b"\x01") * (n + 1)
    sieve[0:2] = b"\x00\x00"
    m = int(n ** 0.5) + 1
    for p in range(2, m):
        if sieve[p]:
            step = p
            start = p*p
            sieve[start:n+1:step] = b"\x00" * (((n - start) // step) + 1)
    return int(sum(sieve))

def bench_sieve(iters, limit=200_000):
    # Count primes up to 'limit', repeated 'iters' times
    total = 0
    for _ in range(iters):
        total += sieve_count(limit)
    # Return iterations as measure (ops = runs)
    return iters

def bench_float(iters, n=400_000):
    # Floating-point heavy loop
    acc = 0.0
    for _ in range(iters):
        # sum sin(sqrt(i)) for i in range(n)
        a = 0.0
        for i in range(n):
            a += math.sin(math.sqrt(i + 0.123))
        acc += a
    # Return iterations as measure
    # Prevent dead-code elimination
    if acc == -1.0:
        print("unlikely")
    return iters

def bench_hash(iters, block_size=64, blocks=1_000_000):
    # Hash 'blocks' of pseudo-random data per iter; fixed seed
    rnd = random.Random(42)
    data = bytes(rnd.getrandbits(8) for _ in range(block_size))
    total_bytes = 0
    for _ in range(iters):
        h = hashlib.sha256()
        # stream multiple blocks
        for _b in range(blocks):
            h.update(data)
        _ = h.digest()
        total_bytes += block_size * blocks
    # Return bytes hashed as measure
    return total_bytes

_LOREM = " ".join([
    "lorem", "ipsum", "dolor", "sit", "amet,", "consectetur",
    "adipiscing", "elit.", "Sed", "do", "eiusmod", "tempor",
    "incididunt", "ut", "labore", "et", "dolore", "magna", "aliqua."
])
_REGEX = re.compile(r"\b(?:dolor|elit|tempor|aliqua)\b")

def bench_regex(iters, para_reps=5000):
    text = (" " + _LOREM) * para_reps
    count = 0
    for _ in range(iters):
        count += len(_REGEX.findall(text))
    if count == -1:
        print("never")
    return iters

def bench_json_gzip(iters, objs=15_000):
    payload = [
        {"id": i, "x": i % 7, "y": (i * 13) % 97, "name": f"name_{i}", "ok": (i % 2 == 0)}
        for i in range(objs)
    ]
    total_bytes = 0
    for _ in range(iters):
        s = json.dumps(payload, separators=(",", ":")).encode("utf-8")
        # round-trip JSON
        _ = json.loads(s)
        # compress
        buf = io.BytesIO()
        with gzip.GzipFile(fileobj=buf, mode="wb", compresslevel=5) as f:
            f.write(s)
        total_bytes += len(s)
    return total_bytes

def bench_sqlite(iters, rows=40_000):
    total_rows = 0
    for _ in range(iters):
        con = sqlite3.connect(":memory:")
        cur = con.cursor()
        cur.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, a INT, b INT)")
        cur.executemany("INSERT INTO t (a,b) VALUES (?,?)",
                        ((i % 97, (i*7) % 101) for i in range(rows)))
        cur.execute("SELECT SUM(a*b) FROM t WHERE a BETWEEN 10 AND 60")
        _ = cur.fetchone()[0]
        con.close()
        total_rows += rows
    return total_rows

def _float_worker(args):
    # worker for parallel float test
    iters, n = args
    return bench_float(iters, n)

def bench_parallel_float(target_secs, workers=None, n=250_000):
    cores = max(1, cpu_count() or 1)
    if workers is None:
        workers = min(4, cores)
    # Calibrate per-process iters
    iters = calibrate(bench_float, lambda it: (it, n), target_secs / 1.2)  # a tad shorter
    # Run in parallel
    start = now()
    with Pool(processes=workers) as pool:
        results = pool.map(_float_worker, [(iters, n)] * workers)
    elapsed = now() - start
    total_iters = sum(results)
    thr = total_iters / elapsed
    return {
        "workers": workers,
        "iters_per_worker": iters,
        "elapsed_s": elapsed,
        "throughput": thr,
        "unit": "iters/s",
        "higher_is_better": True,
        "cores": cores,
    }

# ---------- Runner ----------

TESTS = [
    ("CPU int (sieve)", lambda secs: run_bench(bench_sieve, lambda it: (it, 200_000), secs, "runs/s")),
    ("CPU float (sin√)", lambda secs: run_bench(bench_float, lambda it: (it, 400_000), secs, "runs/s")),
    ("SHA-256",        lambda secs: run_bench(bench_hash,  lambda it: (it, 64, 200_000), secs, "bytes/s")),
    ("Regex",          lambda secs: run_bench(bench_regex, lambda it: (it, 5000), secs, "runs/s")),
    ("JSON+Gzip",      lambda secs: run_bench(bench_json_gzip, lambda it: (it, 10_000), secs, "bytes/s")),
    ("SQLite",         lambda secs: run_bench(bench_sqlite, lambda it: (it, 30_000), secs, "rows/s")),
]

def gather_sysinfo():
    info = {
        "python": sys.version.split()[0],
        "impl": platform.python_implementation(),
        "platform": platform.platform(),
        "machine": platform.machine(),
        "processor": platform.processor(),
        "cpu_count": cpu_count() or 1,
    }
    # Try to read /proc/cpuinfo & meminfo if present
    try:
        with open("/proc/cpuinfo", "r") as f:
            info["/proc/cpuinfo"] = f.read().strip()
    except Exception:
        pass
    try:
        with open("/proc/meminfo", "r") as f:
            info["/proc/meminfo"] = f.read().strip()
    except Exception:
        pass
    return info

def geometric_mean(nums):
    nums = [x for x in nums if x > 0]
    if not nums:
        return 0.0
    log_sum = sum(math.log(x) for x in nums)
    return math.exp(log_sum / len(nums))

def main():
    ap = argparse.ArgumentParser()
    g = ap.add_mutually_exclusive_group()
    g.add_argument("--quick", action="store_true", help="~0.7s per test")
    g.add_argument("--long", action="store_true", help="~3s per test")
    ap.add_argument("--secs", type=float, default=None, help="target seconds per test (default 1.5)")
    ap.add_argument("--no-par", action="store_true", help="skip parallel test")
    ap.add_argument("--csv", type=str, default=None, help="also write CSV here")
    ap.add_argument("--json", type=str, default=None, help="also write JSON here")
    args = ap.parse_args()

    target = 1.5
    if args.quick:
        target = 0.7
    if args.long:
        target = 3.0
    if args.secs is not None:
        target = max(0.3, float(args.secs))

    print("Portable Microbench\n")
    sysinfo = gather_sysinfo()
    print(f"Python: {sysinfo['python']} ({sysinfo['impl']})")
    print(f"Platform: {sysinfo['platform']}")
    print(f"Machine: {sysinfo['machine']} | CPU count: {sysinfo['cpu_count']}")
    print()

    results = []
    for name, runner in TESTS:
        print(f"[{name}] target ≈ {target:.1f}s ...", flush=True)
        r = runner(target)
        r["name"] = name
        results.append(r)
        print(f"  {name:<18} {human(r['throughput'])} {r['unit']}  (elapsed {r['elapsed_s']:.2f}s)")

    par = None
    if not args.no_par:
        print(f"[Parallel float] target ≈ {target:.1f}s ...", flush=True)
        par = bench_parallel_float(target, None, 250_000)
        print(f"  Parallel float    {human(par['throughput'])} {par['unit']}  "
              f"({par['workers']} workers, elapsed {par['elapsed_s']:.2f}s)")

    # Compute a composite score using geometric mean of normalized throughputs.
    # We normalize each test by dividing by the median throughput of all tests on this device,
    # so the composite is roughly balanced across kinds of work.
    thr_values = [r["throughput"] for r in results if r["higher_is_better"]]
    median_thr = statistics.median(thr_values) if thr_values else 1.0
    normalized = [r["throughput"] / median_thr for r in results if r["higher_is_better"]]
    score = geometric_mean(normalized) * 100.0  # mean around 100 on each device

    print("\nSummary:")
    print(f"{'Test':<20} {'Throughput':>14}  {'Unit'}")
    print("-"*40)
    for r in results:
        print(f"{r['name']:<20} {human(r['throughput']):>14}  {r['unit']}")
    if par:
        print(f"{'Parallel float':<20} {human(par['throughput']):>14}  {par['unit']} ({par['workers']} workers)")
    print("-"*40)
    print(f"Composite score (≈relative on this device): {score:.1f}")

    out = {
        "sysinfo": sysinfo,
        "results": results,
        "parallel": par,
        "composite_score": score,
        "target_secs": target,
        "ts": time.time(),
    }

    if args.csv:
        try:
            with open(args.csv, "w") as f:
                f.write("name,throughput,unit,elapsed_s,iters\n")
                for r in results:
                    f.write(f"{r['name']},{r['throughput']},{r['unit']},{r['elapsed_s']},{r['iters']}\n")
                if par:
                    f.write(f"Parallel float,{par['throughput']},{par['unit']},{par['elapsed_s']},{par['iters_per_worker']*par['workers']}\n")
            print(f"\nWrote CSV: {args.csv}")
        except Exception as e:
            print(f"Failed to write CSV: {e}", file=sys.stderr)

    if args.json:
        try:
            with open(args.json, "w") as f:
                json.dump(out, f, indent=2)
            print(f"Wrote JSON: {args.json}")
        except Exception as e:
            print(f"Failed to write JSON: {e}", file=sys.stderr)

if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print("\nInterrupted.")

Результаты тестов

Inovato Quadra

Python: 3.11.2 (CPython)

Platform: Linux-6.12.35-current-sunxi64-aarch64-with-glibc2.36

Machine: aarch64 | CPU count: 4

Summary:

Test                     Throughput  Unit

----------------------------------------

CPU int (sieve)              136.96  runs/s

CPU float (sin√)               2.79  runs/s

SHA-256                     144.78M  bytes/s

Regex                         15.33  runs/s

JSON+Gzip                     3.02M  bytes/s

SQLite                      123.11k  rows/s

Parallel float                16.16  iters/s (4 workers)

----------------------------------------

Composite score (≈relative on this device): 13.4

Raspberry Pi Zero 2 W

Python: 3.13.5 (CPython)

Platform: Linux-6.12.47+rpt-rpi-v8-aarch64-with-glibc2.41

Machine: aarch64 | CPU count: 4

Summary:

Test                     Throughput  Unit

----------------------------------------

CPU int (sieve)               80.69  runs/s

CPU float (sin√)               1.63  runs/s

SHA-256                      35.46M  bytes/s

Regex                          6.24  runs/s

JSON+Gzip                     2.24M  bytes/s

SQLite                       82.85k  rows/s

Parallel float                 9.50  iters/s (4 workers)

----------------------------------------

Composite score (≈relative on this device): 10.1

Arduino Uno Q

Python: 3.13.5 (CPython)

Platform: Linux-6.16.7-g0dd6551ae96b-aarch64-with-glibc2.41

Machine: aarch64 | CPU count: 4

Summary:

Test                     Throughput  Unit

----------------------------------------

CPU int (sieve)              149.50  runs/s

CPU float (sin√)               3.33  runs/s

SHA-256                     154.55M  bytes/s

Regex                         12.41  runs/s

JSON+Gzip                     4.24M  bytes/s

SQLite                      161.03k  rows/s

Parallel float                16.68  iters/s (4 workers)

----------------------------------------

Composite score (≈relative on this device): 11.6

Общий график

Inovato Quadra и Arduino Uno Q показывают схожие результаты, а Raspberry Pi Zero 2 W отстает по всем тестам, особенно в SHA-256 и Regex. Интересно, что Uno Q чуть лучше по целочисленным операциям и SQLite, но немного уступает Quadra в регулярках. Но теперь, давайте вспомним, что Arduino Uno Q имеет память LPDDR4 и стоит 55 $, а Inovato Quadro с RAM предыдущего поколения можно было найти по цене от 29 $. Более продвинутая Quadro 4k на момент написания текста стоит 49 $.

Заключение

Inovato Quadra меня приятно удивил. Этот одноплатный компьютер из Android TV бокса оказался довольно оригинальным решением для работы HamClock. Небольшие габариты и приятная стоимость сделали его конкурентоспособным продуктом, решающим конкретную задачу радиолюбителей. Единственное, что действительно портит впечатление, — необходимость во внешней системе охлаждения.

Ситуация со слетевшей прошивкой после сбоя питания — не редкость для этих устройств. Благо, такой «кирпич» можно восстановить буквально за 30 минут, записав оригинальный образ ОС на MicroSD-карту. Если же произойдет что-то серьезнее, то можно быстро подпаять четыре проводка к UART и выполнить восстановление из U-boot.

Хотел бы я закончить этот обзор на позитивной ноте, но увы. На сайте разработчика висит тревожная и печальная новость. Майкл сообщил, что у него обнаружили рак, и официальный магазин Inovato будет закрыт 19 декабря 2025 года. Возможно, эта дата поставит финальную точку в истории этих недорогих одноплатников, а работающие экземпляры станут редкостью. Остается лишь надеяться, что состояние здоровья Майкла после терапии позволит ему продолжить работу над проектом.

Комментарии (0)