✦ Основы гиперпрыжковой теории (ГПТ)
Фрагмент учебного конспекта по теории гиперпереходов: постулаты, правила, теоремы и инженерные следствия.
🧭 Базовые обозначения
  • L - расчетная длина прыжка в обычном пространстве;
  • Lc - критическая навигационная дистанция, принимаемая равной 1,2 светового года;
  • σ(L) - характеристический радиус локализации выхода;
  • τ(L) - характеристический радиус временной локализации выхода;
  • r0 - расчетная точка выхода;
  • RH - радиус Хаббла;
  • RG - эффективный радиус захвата Врат;
  • Λ - постоянная Найяра;
  • σc - радиус локализации на критической дистанции, принимаемый равным 9 световых минут;
  • τc - временная неопределенность на критической дистанции.
1. Правило Найяра о ближней расходимости локализации

Формулировка. При уменьшении длины прыжка ниже критической дистанции Lc радиус локализации выхода растет экспоненциально по обратной длине прыжка. На дальних дистанциях рост сохраняется, но становится медленным и логарифмическим.

Для 0 < L <= Lc:
σ(L) = σc * exp[ Λ * (1/L - 1/Lc) ]
Для L > Lc:
σ(L) = σc * [ 1 + α * ln(L/Lc) ]

Нормировка:

Lc = 1.2 св. года
σc = 9 св. минут
Λ ≈ 284 св. минут
α ≈ 0.12

Следствия.

  • При L = Lc радиус локализации стабильно определяется в пределах около 9 световых минут.
  • При L → 0+ величина σ(L) стремится к бесконечности.
  • На больших расстояниях радиус локализации растет слабо, примерно как ln(L).
2. Эффект гравитационного преломления траектории (ГПТ-смещение)

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

Физический смысл. ГПТ-чувствительный контур реагирует не на однородное ускорение как таковое, а на приливную неоднородность метрики. Поэтому определяющим параметром является не величина поля вида GM/r², а величина его пространственного градиента, пропорциональная GM/r³.

ΔR_g = μ * ( G*M / r^3 ) * D

где:

  • ΔR_g - добавочное гравитационное смещение точки выхода;
  • μ - эмпирический коэффициент ГПТ-чувствительности;
  • G - гравитационная постоянная;
  • M - масса ближайшего доминирующего тела;
  • r - расстояние до центра масс;
  • D - расчетная длина прыжка.

Обобщенная форма записи:

ΔR_g ~ μ * ||T|| * D

где ||T|| - норма приливного тензора локальной кривизны.

3. Следствия эффекта ГПТ-смещения
  1. Чем ближе старт к массивному телу, тем хуже предсказуемость выхода.
  2. Чем дальше прыжок, тем сильнее накапливается гравитационная ошибка.
  3. Низкие орбиты и глубокие гравитационные колодцы считаются навигационно неблагоприятными зонами.
  4. Точки Лагранжа, высокие орбиты и межпланетное пространство являются предпочтительными областями старта.

Суммарная эффективная ошибка локализации может оцениваться как:

σ_eff^2(L) = σ^2(L) + ΔR_g^2

где σ(L) - базовая ошибка локализации, задаваемая правилом Найяра.

4. Принцип Гревса-Субраманьяна о допустимых зонах установки Врат

Формулировка. Для устойчивой работы Врат требуется не только малая пространственная ошибка, но и минимальная приливная деформация рабочей метрики в объеме фазовой синхронизации.

Практически это означает, что Врата стандартного класса устанавливаются только в областях, где гравитационное преломление траектории мало и стабильно во времени.

Нормативно предпочтительные зоны:

  • точки Лагранжа;
  • высокие стационарные орбиты;
  • межпланетные якорные области;
  • зоны с малым градиентом гравитационного поля.

Следствие. Если одна пара Врат размещена в зоне слабой приливной кривизны, а другая - в низкой орбите или у поверхности массивного тела, общий канал становится плавающим и не обеспечивает гарантированного попадания в приемную камеру.

⚠ Военное исключение: тактические Врата

Военные структуры могут временно развертывать тактические Врата в областях с повышенной приливной кривизной - в том числе на низких орбитах или вблизи планетарных тел. Однако при этом аварийность канала резко возрастает. 

Практические риски:

  • нестабильность окна выхода;
  • неполное совмещение канала;
  • выход объекта вне приемной камеры;
  • врезание в твердую породу, конструкцию или атмосферу;
  • размазывание полезной нагрузки по пространственно-временному окну выхода.

По этой причине тактические Врата рассматриваются как средство чрезвычайного применения, а не как допустимая гражданская инфраструктура.

🧠 Инженерное замечание

ГПТ-смещение не следует путать с общей ошибкой навигационного решения. Даже идеально рассчитанный прыжок может оказаться неудачным, если старт выполнен в зоне, где локальная приливная геометрия недостаточно благоприятна для стабильного прожига гипер-ядра.

5. Следствие Найяра-Шеноя о пороге космологической дезориентации

Формулировка. Существует такая малая длина прыжка LH, при которой радиус локализации сравним с радиусом Хаббла:

σ(LH) ≈ RH

В принятой инженерной нормировке это достигается примерно при:

LH ≈ 8 световых минут

Интерпретация. При попытке прыгнуть “слишком близко” область допустимого выхода разрастается до космологических масштабов. Объект может выйти практически в любом направлении относительно цели, включая направление, противоположное расчетному.

Важно: пока L > 0, вероятность выхода вблизи расчетной точки остается ненулевой, сколь бы малой она ни была.

6. Теорема Шанкары о нулевом прыжке

Формулировка. Прыжок с нулевой расчетной длиной является сингулярным предельным случаем и не допускает конечной области локализации выхода.

lim (L → 0+) σ(L) = ∞

Для точного нулевого прыжка вероятность выхода в любой конечной области пространства принимается равной нулю:

P(выход в любой конечной области | L = 0) = 0

Практический вывод. Нулевой прыжок запрещен как форма гарантированной безвозвратной утраты отправляемого объекта. То есть, для человека это фактически  смерть.

7. Принцип Мадхавы о гиперхронологической привязке

Формулировка. Гиперпрыжок определяется не относительно локального времени отправителя, а относительно глобальной гиперхронологической поверхности, задаваемой структурой гиперсреды.

Поэтому мгновенный переход не означает нулевой временной сдвиг в произвольной системе отсчета.

t_out = t_in + Δt_h + ε_t

где:

  • Δt_h - системный положительный лаг перехода;
  • ε_t - случайная временная флуктуация;
  • математическое ожидание ε_t близко к нулю, но физически запрещены сценарии нарушения причинности.
t_out >= t_in

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

8. Теорема Айенгара о сопряженности навигационной и хронологической ошибки

Формулировка. Рост пространственной неопределенности выхода сопровождается ростом временной неопределенности, связанной с ней степенным законом.

τ(L) = τc * ( σ(L) / σc )^β

Для инженерной нормировки допустимо принимать:

τc = 2.7 с
β = 2/3

Интерпретация.

  • На критической дистанции временной разброс составляет секунды.
  • На безопасных дальних прыжках он обычно несущественен.
  • На опасных ближних прыжках он может возрастать до часов, дней, лет и более.
9. Теорема Менона о недостижимости собственного прошлого

Формулировка. Ни одна последовательность допустимых гиперпрыжков не может перевести объект в область пространства-времени, причинно предшествующую моменту прожига его гипер-ядра.

t_out < t_burn - запрещено

Следствие. Стандартная ГПТ не может быть использована для построения машины времени, несмотря на наличие временного разброса выхода.

10. Теорема Брукса-Аникеева о трансгиперной неидентифицируемости

Формулировка. Никакой конечный экспериментальный протокол не может строго доказать абсолютную тождественность субъекта или объекта на входе и на выходе гиперпрыжка.

Пусть X_in - объект на входе, X_out - объект на выходе. Тогда даже если конечный набор наблюдаемых полностью совпадает:

O_n(X_in) = O_n(X_out)

из этого не следует строгая онтологическая идентичность:

X_in ≡ X_out - не доказуемо

Следствие. ГПТ допускает доказательство структурной, функциональной, биографической и информационной непрерывности, но не абсолютного метафизического тождества.

11. Принцип Риттера-Моханти о приводных Вратах

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

L >= Lc

Для допустимого режима:

σG(L) = max( RG , η * σ(L) )

где η << 1 - коэффициент навигационного сжатия.

Инженерный смысл. Врата не отменяют правило Найяра. Они лишь делают дальний прыжок инженерно управляемым, сужая окно выхода до нескольких метров или десятков метров.

12. Принцип Субраманьяна-Риттера о фазовой фиксации Врат

Формулировка. Врата уменьшают не только пространственную, но и временную область локализации выхода.

τG(L) = max( TG , ηt * τ(L) )

где:

  • TG - временное окно Врат;
  • ηt - коэффициент темпорального сжатия;
  • обычно TG лежит в пределах от миллисекунд до микросекунд.

Следствие. Без Врат гиперпрыжок - это искусство попасть примерно туда и примерно тогда. Со Вратами - это уже транспорт.

13. Теорема Ковальского-Хартмана о невозможности короткой вратной стяжки

Формулировка. Построение сети Врат на расстояниях меньше критической дистанции Lc не дает устойчивой навигационной выгоды.

При L < Lc вероятность промаха по Вратам растет в соответствии с общим законом ближней расходимости локализации.

Следствие. Вратные сети строятся редкими магистральными узлами, а не густой локальной решеткой.

14. Закон Субраманьяна-Карпова об изодальном расходе гипертоплива

Формулировка. Расход гипертоплива не зависит от длины прыжка и определяется только параметрами прожига гипер-ядра и массой транспортируемой системы.

mf = Φ(M, χ, ν)

где:

  • mf - масса гипертоплива;
  • M - полная масса системы;
  • χ - класс гипер-ядра;
  • ν - режим прожига.

Ключевое следствие. Прыжок до Андромеды и прыжок до Стрельца A* стоят одинаково по топливу при одинаковой массе и классе прожига. Ограничение задается не “дальностью как ценой”, а навигацией и доступностью топлива.

15. Теорема Лобача-Куна о непереносимости гипертоплива через канал прожига

Формулировка. Гипертопливо не переносится через образующуюся при прожиге червоточину и потому не может быть доставлено “само собой” в точку выхода.

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

Практический смысл. Главная проблема ГПТ - не цена прыжка как такового, а сверхзатратное производство, кратковременная стабилизация и жесткие ограничения хранения гипертоплива.

16. Закон Риттера–Аникеева о входном остатке

Формулировка. В момент прожига гипер-ядра объект на стороне входа необратимо утрачивает устойчивую локальную материальную конфигурацию. Этот процесс сопровождается кратковременным выбросом излучения и частиц, однако суммарная локально наблюдаемая энергия выброса определяется не полной массой корабля, а лишь малой долей энергии гипертоплива, реально диссипирующей в исходное пространство.

Иначе говоря: корабль, полезная нагрузка и топливо на стороне входа действительно погибают как локально существующая система, однако наблюдаемый “пшгик” не является полной энергетической разверткой всей отправляемой массы. Локально фиксируется только входной остаток, связанный с частичным распадом энергетики прожига.

16.1. Масса гипертоплива

В современной инженерной модели расход гипертоплива задаётся как малая доля отправляемой массы:

m_fuel = η * M_ship

где:

  • m_fuel - масса гипертоплива, подаваемого на прожиг;
  • η - удельная топливная доля;
  • M_ship - полная масса корабля вместе с полезной нагрузкой.

Для стандартного человеческого ГПТ-флота принимается:

η = 10^(-4)

То есть на каждый килограмм отправляемой массы требуется примерно 0.0001 кг гипертоплива. Иными словами:

  • 0.1 г топлива на 1 кг массы;
  • 100 г топлива на 1 тонну массы;
  • 100 кг топлива на 1000 тонн массы.
16.2. Энергия гипертоплива

Полная энергетическая ёмкость топлива описывается как:

E_fuel = ξ * m_fuel * c^2

где:

  • E_fuel - полная энергия топлива, вовлечённого в прожиг;
  • ξ - коэффициент эффективной энергетической развёртки топлива;
  • c - скорость света.

В эксплуатационных расчётах обычно полагают:

ξ ≈ 1

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

16.3. Собственно закон входного остатка

Локально наблюдаемая энергия выброса на стороне входа составляет лишь малую долю от полной энергии топлива:

E_res = κ * E_fuel

или, развёрнуто:

E_res = κ * ξ * η * M_ship * c^2

где:

  • E_res - энергия локально наблюдаемого входного остатка;
  • κ - коэффициент входного остатка, причём κ << 1;
  • ξ - коэффициент энергетической развёртки топлива;
  • η - удельная топливная доля;
  • M_ship - полная масса корабля;
  • c - скорость света.

Типичный инженерный диапазон для штатных гражданских прожигов:

10^(-9) <= κ <= 10^(-7)

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

κ_typ ≈ 10^(-8)

Следствие 1. Даже сравнительно яркий входной прожиг соответствует лишь очень малой доле энергии топлива:

E_res << E_fuel

Следствие 2. Наблюдаемый “пышщщ” не является аннигиляцией корабля. Он является остаточным эффектом прожига и частичного локального сброса энергии топлива.

Следствие 3. Закон Риттера–Аникеева согласуется с теоремой Цзянь–Риттера: корабль на стороне входа гибнет полностью, но по эту сторону не остаётся энергетического эквивалента всей его массы.

16.4. Быстрая инженерная оценка

Если принять:

η = 10^(-4)
ξ = 1
κ_typ = 10^(-8)

то получаем:

E_res = 10^(-12) * M_ship * c^2

или, если масса корабля выражена в килограммах:

E_res ≈ 9 * 10^4 * M_kg [Дж]

или, если масса корабля выражена в тоннах:

E_res ≈ 9 * 10^7 * M_ton [Дж]

Для грубого перевода в условный тротиловый эквивалент можно использовать:

W_TNT ≈ E_res / (4.184 * 10^9)
W_TNT ≈ 0.0215 * M_ton [кг ТНТ]

Это означает, что в мирном режиме энергия входного остатка растёт линейно с массой корабля и остаётся опасной, но не апокалиптической. Главная проблема - не только суммарная энергия, но и её форма: жесткое излучение, плазменный выброс, локальная ионизация, электромагнитная наводка и вторичные радиационные эффекты.

16.5. Типичные классы кораблей и рекомендуемые дистанции старта

Ниже приведена ориентировочная таблица для штатного гражданского прожига при η = 10^(-4), ξ = 1 и κ = 10^(-8). Под безопасной дистанцией понимается минимальное расстояние от доков, жилых орбитальных объектов, терминалов, складов, ремонтных платформ и иной чувствительной гражданской инфраструктуры.

Класс корабляТипичная массаМасса топливаОценка E_resУсловный эквивалентРекомендуемая дистанция старта
Эсминец
(минимальный класс с бортовым ГПТ)
20 000 т 2 т ≈ 1.8 * 10^12 Дж ≈ 430 кг ТНТ не менее 800 км
Крейсер 80 000 т 8 т ≈ 7.2 * 10^12 Дж ≈ 1.72 т ТНТ не менее 1 500 км
Тяжёлый транспорт / танкер 150 000 т 15 т ≈ 1.35 * 10^13 Дж ≈ 3.23 т ТНТ не менее 2 200 км
Пассажирский лайнер дальнего следования 250 000 т 25 т ≈ 2.25 * 10^13 Дж ≈ 5.38 т ТНТ не менее 3 000 км
Сверхтяжёлый грузовик / контейнеровоз 500 000 т 50 т ≈ 4.5 * 10^13 Дж ≈ 10.76 т ТНТ не менее 5 000 км

Примечание. В человеческой практике корабли легче эсминца бортовым ГПТ-оборудованием, как правило, не оснащаются. Причины: громоздкость ядра, жесткие требования к радиационной защите, необходимость в сложном сопусвующем оборудовани, огромная стоимость, сложность обслуживания и нерациональность размещения столь тяжёлой системы на малых судах. Поэтому в таблице не рассматриваются шаттлы, тендеры и прочие малые аппараты.

16.6. Эксплуатационные следствия для гражданской инфраструктуры
  • Отбытие в гиперпрыжок производится вдали от доков. Корабль сначала выводится на удалённую стартовую позицию и только затем прожигает гипер-ядро.
  • Посадка пассажиров и экипажа осуществляется через тендеры, шлюпки, челноки и служебные катера. Для крупных лайнеров и грузовиков это штатная практика.
  • Прожиг вблизи гражданских станций, жилых орбит и плотной сервисной инфраструктуры запрещён. Основные риски: гамма-компонента, ионизация среды, плазменный выброс и электромагнитные перегрузки.
  • Стандартные Врата используются в первую очередь на приём. Они работают как управляемый объём захвата и безопасного появления корабля, а не как симметричная дверца туда-сюда.
  • На передачу обычные Врата гражданского класса, как правило, не работают. Одноразовые и специальные режимы теоретически возможны, но дороги, капризны и инфраструктурно неудобны.
16.7. Приём больших судов через Врата

Прибывший через Врата корабль не “подруливает к пирсу” сам по себе. Его сначала принимает специальный объем безопасности, после чего в работу вступают орбитальные буксиры, тяговые фермы, швартовочные сети и диспетчерские алгоритмы разведения масс.

Крупнотоннажные лайнеры и грузовики, как правило, либо медленно перегоняются буксирными команами к внешним докам, либо обслуживаются на удаленных рейдах через контейнерные лихтеры, пассажирские тендеры и сервисные модули. Именно поэтому большой межзвёздный корабль обычно висит далеко за пределами основной доковой суеты.

Неформальное толкование

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

17. Спектральный состав входного остатка

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

Условная запись спектрального баланса:

E_res = E_gamma + E_nu + E_ex + E_th

где:

  • E_gamma - энергия фотонной компоненты (основной вклад);
  • E_nu - энергия нейтринной компоненты;
  • E_ex - энергия экзотических частиц или квазичастиц, фиксируемых косвенно;
  • E_th - энергия вторичных тепловых и плазменных эффектов.

Инженерная оценка для штатного прожига:

E_gamma / E_res ≈ 0.70 ... 0.95
E_nu / E_res ≈ 0.01 ... 0.10
E_ex / E_res ≈ 0.01 ... 0.20
E_th / E_res <= 0.05

Интерпретация. Для невооруженного наблюдателя входной прожиг выглядит как краткий ослепительный световой срыв, иногда сопровождаемый ударной волной, плазменным ореолом и остаточным свечением. При инструментальной регистрации доминирует именно фотонный импульс, что и создает субъективное впечатление, будто объект “улетел в свет”.

18. Визуально наблюдаемая фаза прожига

Формулировка. На замедленной съемке и при фазово-чувствительной регистрации входная деструкция локального состояния имеет две характерные стадии:

  1. предсрывное свечение - вещество объекта начинает неравномерно светиться, теряя обычные оптические свойства;
  2. фаза "схлопывания" - контуры объекта нарушаются, после чего он визуально исчезает, оставляя фотонно-плазменный остаток.

Условная временная запись процесса:

S_matter → S_lum → ∅_local + R_res

где:

  • S_matter - обычное локальное материальное состояние объекта;
  • S_lum - краткая светящаяся предсрывная фаза;
  • ∅_local - отсутствие объекта как допустимого локального состояния;
  • R_res - наблюдаемый входной остаток.

Практический вывод. На стороне входа не остается сомнений, что объекту “по эту сторону” пришел конец. Именно поэтому философские споры о тождестве субъекта после прыжка для большинства людей начинаются уже после того, как они видят прожиг хотя бы однажды вблизи.

19. Принцип метрического целеуказания

Формулировка. Гипер-ядро наводится не на текущее видимое положение звезды или планетной системы, а на расчетную фазово-гравитационную конфигурацию области выхода, в которой цель окажется в момент ожидаемого выхода из гиперперехода.

Иначе говоря, прыжок совершается не туда, где цель видна, а туда, где она должна быть после учета движения системы, гравитационной топографии региона, временного сдвига и статистической локализационной ошибки.

Target = F( r_future , Phi_metric , tau_exit , sigma_loc )

где:

  • Target - целевое решение для прожига гипер-ядра;
  • r_future - прогнозное положение цели на момент выхода;
  • Phi_metric - карта локальной и маршрутной гравитационной метрики;
  • tau_exit - ожидаемое временное окно выхода;
  • sigma_loc - допустимая ошибка локализации.

Интерпретация. Для ГПТ-навигации звезда или планета являются не столько оптическим ориентиром, сколько гравитационно-фазовым узлом. Именно поэтому в основе гиперпрыжковой навигации лежат не обычные звездные карты, а прогнозные карты гравитационных колодцев, приливных градиентов, фазовых якорей и устойчивых метрических опор.

Следствие 1. Минимальный горизонт надежного прогноза не может быть меньше критической дистанции прыжка. По этой причине целеуказание всегда строится на прогнозе будущего состояния системы, а не на ее “настоящем” визуальном положении.

Следствие 2. Ошибка в карте гравитационных колодцев опаснее простой угловой ошибки наведения. Неправильная оценка метрики может привести к выходу “рядом со звездной системой”, но в принципиально неблагоприятном гравитационном окне.

Следствие 3. Врата играют роль внешних фазово-навигационных якорей. При наличии Врат гипер-ядро использует не только общую прогнозную карту, но и готовую приводную сигнатуру, резко уменьшающую флуктуации выхода.

19.1. Эксплуатационное замечание

На практике экипаж не “вводит координаты звезды”, а загружает в навигационную автоматику решение прожига, в котором уже учтены:

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

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

Неформальная формула навигаторов

ГПТ прыгает не к звезде, а к тому месту, куда звезда прилетит, когда ты там вывалишься.

20. Ресурс и деградация гипер-ядра

Формулировка. Гипер-ядро является многоразовым прожиговым реактором, однако его эксплуатационный ресурс ограничен. Каждый гиперпрыжок вызывает накопление необратимых изменений в активной зоне, фазовом каркасе и системах стабилизации. По этой причине ядро не может использоваться неограниченно часто и требует периодического обслуживания, перекалибровки и, в пределе, замены ключевых компонентов.

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

Wear_jump = W0 * K_range * K_grav * K_stab * K_gate

где:

  • Wear_jump - условная стоимость одного прожига по ресурсу ядра;
  • W0 - базовый износ штатного прожига;
  • K_range - поправка на длину прыжка и геометрию решения;
  • K_grav - поправка на гравитационную сложность стартовой области;
  • K_stab - поправка на чистоту и стабильность прожига;
  • K_gate - поправка на наличие или отсутствие внешней приводной стабилизации Врат.

Интерпретация коэффициентов.

  • K_range обычно близок к единице в штатных дальних режимах, но может резко возрастать в ближних и нестандартных прыжках;
  • K_grav возрастает вблизи сильных гравитационных градиентов, на низких орбитах и в иных неблагоприятных стартовых зонах;
  • K_stab отражает качество работы автоматики, стабильность топлива и чистоту фазового режима;
  • K_gate обычно меньше единицы при работе на готовый приводной якорь и больше единицы при полностью автономном прожиге.

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

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

Следствие 3. Ближние прыжки, аварийные режимы, работа без Врат и прожиги из неблагоприятных гравитационных областей ускоряют деградацию активной зоны и фазового каркаса.

20.1. Практика эксплуатации

В гражданском флоте гипер-ядро рассматривается как один из главных активов корабля. После каждого прожига проводится:

  • диагностика активной зоны;
  • контроль фазового каркаса;
  • оценка остаточного дрейфа параметров;
  • сверка с эталонной сигнатурой прожига;
  • при необходимости - перекалибровка и частичная замена узлов.

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

20.2. Эксплуатационное ограничение

Именно по причине ограниченного ресурса гипер-ядра межзвездный корабль не ведет себя как “маршрутка”, прыгающая по мелочи каждые полчаса. Даже при наличии топлива каждый прожиг имеет:

  • цену по ресурсу ядра;
  • цену по ресурсу обслуживающей автоматики;
  • цену по времени на послепрыжковую диагностику;
  • цену по риску накопления скрытых дефектов.

В этом смысле хороший капитан экономит не только топливо, но и прожиги.

Неформальный язык флота
  • выполнить прожиг ядра - срвершить гипер прыжок;
  • грязный прожиг - прыжок с плохой фазовой чистотой;
  • надорвать ядро - сильно ускорить деградацию тяжелым режимом;
  • ядро повело - накопился опасный дрейф параметров;
  • сухое окно - хороший стабильный режим выхода.

Примечание. Точный механизм деградации гипер-ядра остается предметом спора. В инженерной практике достаточно того, что ядро статистически изнашивается, предсказуемо ухудшает параметры после тяжелых прожигов и не прощает слишком вольного обращения. Как и сама ГПТ в целом, эта область гораздо лучше описана эмпирически, чем понята теоретически.

21. Итоги 
Инженерное замечание по безопасности

Несмотря на то, что входной остаток много слабее полного энергетического эквивалента массы корабля и топлива, зона прожига остается крайне опасной из-за:

  • жесткого фотонного импульса;
  • возможного гамма-излучения;
  • вторичной ионизации среды;
  • плазменного выброса;
  • локальных электромагнитных перегрузок.

По этой причине присутствие незащищенного персонала в непосредственной близости от зоны прожига категорически запрещено даже при штатном прыжке.

🧠 Философское следствие

Закон Риттера-Аникеева усиливает практический смысл теоремы Брукса-Аникеева о трансгиперной неидентифицируемости:

На стороне входа объект гибнет несомненно. На стороне выхода вопрос стоит уже не о выживании локальной материи, а о допустимости считать выходное состояние продолжением прежнего субъекта.
✅ Инженерные аксиомы навигатора
  1. Чем ближе цель - тем опаснее прыжок.
  2. Очень далекий прыжок не дороже ближнего по топливу.
  3. Врата не отменяют физику ГПТ, а цивилизованно живут в ее рамках.
  4. ГПТ обеспечивает функциональную непрерывность, но не дает строгого доказательства абсолютной идентичности субъекта.
  5. Прыжок “в ноль” является гарантированной формой безвозвратной утраты.
📝 Примечание для учебных и художественных текстов

В прикладной навигации удачным обычно считается прыжок, при котором пространственная ошибка локализации не превышает 5-10 световых минут. Все более точные режимы требуют либо очень качественной навигационной привязки, либо использования Врат как пространственно-временных приводных маяков.

🔬 22. Реестр ученых ГПТ

Ниже приведен сводный реестр ключевых ученых, чьи имена закрепились в теории гиперпрыжков, инженерии Врат, физике гипертоплива, философии тождества и медицине гиперперехода.

ℹ Примечание о Фейнмане

Ученый Марк Фейнман, фигурирующий в данном реестре, является лишь однофамильцем известного физика Ричарда Фейнмана и не отождествляется с ним в каноне данной вселенной.

УченыйГде фигурируетСпециализацияНаучная рольМесто в истории ГПТ
1 Аравинд Найяр Правило Найяра о ближней расходимости локализации; Постоянная Найяра (Λ); Следствие Найяра-Шеноя Физик-теоретик, основатель ГПТ Открыл фундаментальный закон локализации и доказал контринтуитивную опасность ближних прыжков. До него экспедиции улетали вникуда и не возвращались. Первая эпоха; центральная фигура всей теории ГПТ
2 Киран Шеной Следствие Найяра-Шеноя о пороге космологической дезориентации Космолог, математический физик Связал ошибку прыжка с космологическими масштабами и радиусом Хаббла Первая эпоха; школа Найяра
3 Адити Шанкара Теорема Шанкары о нулевом прыжке Математик, специалист по сингулярностям Доказала, что нулевой прыжок является предельным и физически патологическим случаем Первая эпоха; формирование строгой математики ГПТ
4 Мадхава Кесаван Принцип Мадхавы о гиперхронологической привязке Физик-хронолог Сформулировал временную модель гиперпрыжка и задал понятие гиперхронологической поверхности Вторая эпоха; становление хронотеории ГПТ
5 Сринивас Айенгар Теорема Айенгара о сопряженности навигационной и хронологической ошибки Математик-статистик Вывел закон связи между пространственной и временной ошибками выхода Вторая эпоха; развитие хроностатистики ГПТ
6 Виджай Менон Теорема Менона о недостижимости собственного прошлого Физик-теоретик, специалист по причинности Установил строгие ограничения, не позволяющие использовать ГПТ как полноценную машину времени Вторая эпоха; хронологическая защита
7 Артур Брукс Теорема Брукса-Аникеева о трансгиперной неидентифицируемости Философ науки, логик Поставил вопрос о недоказуемости идентичности субъекта на входе и на выходе Философский кризис ГПТ
8 Дмитрий Аникеев Теорема Брукса-Аникеева; Закон Риттера-Аникеева о входном остатке Физик-экспериментатор Связал философию тождества с наблюдаемой физикой входа и физикой остаточного излучения Философский кризис + экспериментальная физика прожига
9 Иоганн Риттер Принцип Риттера-Моханти; Принцип Субраманьяна-Риттера; Закон Риттера-Аникеева; Теорема Цзянь-Риттера Инженер-физик, специалист по Вратам и физике прожига Один из главных инженеров практической эпохи ГПТ; довел теорию до надежной транспортной технологии Великая инженерная эпоха ГПТ
10 Раджеш Моханти Принцип Риттера-Моханти о приводных Вратах Инженер Врат Разработал подход к использованию Врат как приводных маяков, повышающих точность выхода Инженеризация Врат
11 Субраманьян Рао Принцип Субраманьяна-Риттера; Принцип Гревса-Субраманьяна Физик-синхронист Разработал фазовую и временную фиксацию Врат, стабилизирующую окно выхода Инженеризация Врат; школа фазовой стабилизации
12 Клаус Гревс Принцип Гревса-Субраманьяна о допустимых зонах установки Врат Инженер-сетевик, проектировщик ГПТ-инфраструктуры Показал, что Врата нельзя строить где попало: гравитационная среда влияет на стабильность канала Инженеризация Врат; инфраструктурная школа
13 Ян Ковальский Теорема Ковальского-Хартмана о невозможности короткой вратной стяжки Тополог ГПТ-сетей Доказал бесперспективность чрезмерно плотной локальной сети Врат на малых дистанциях Эпоха сетевой оптимизации
14 Эрих Хартман Теорема Ковальского-Хартмана Математик, специалист по топологии транспортных сетей Соформализовал топологические ограничения вратных сетей и их масштабирования Эпоха сетевой оптимизации
15 Сергей Карпов Закон Фейнмана-Карпова об изодальном расходе гипертоплива Инженер-энергетик, специалист по гипертопливу Довел идею изодального расхода до инженерной формы и эксплуатационных расчетов Промышленная стабилизация ГПТ
16 Марк Фейнман Закон Фейнмана-Карпова об изодальном расходе гипертоплива Теоретик физики гипертоплива Предложил общую модель, согласно которой расход гипертоплива не зависит от длины прыжка Промышленная стабилизация ГПТ; однофамилец Ричарда Фейнмана
17 Антон Лобач Теорема Лобача-Кюна о непереносимости гипертоплива через канал прожига Физик гипертоплива Показал, что гипертопливо нельзя транспортировать через уже сформированный канал как обычный груз Промышленная стабилизация; физика каналов
18 Мартин Кюн Теорема Лобача-Кюна Физик-теоретик, специалист по каналам прожига Соформализовал ограничения переноса гипертоплива и устойчивости канала Промышленная стабилизация; физика каналов
19 Ли Цзянь Теорема Цзянь-Риттера о входной деструкции локального состояния Физик высокоэнергетического входа Строго доказал, что прожиг на входе - это не разрушение объекта по обычным физическим каналам, а распад его локальной допустимости как состояния. Поздняя физика прожига; китайская школа диагностики входа
20 Чжао Вэньцзе Синдром Чжао; медицина гиперпрыжка Нейрофизиолог, врач гиперперехода Первым системно описал психоневрологические и вегетативные последствия выхода из гиперпрыжка Медицина массового гипертранспорта
🧭 Условное деление по эпохам
  • Первая эпоха - фундаментальная теория: Найяр, Шеной, Шанкара.
  • Вторая эпоха - хронотеория и причинность: Мадхава, Айенгар, Менон.
  • Третья эпоха - философский кризис тождества: Брукс, Аникеев.
  • Четвертая эпоха - инженеризация Врат: Риттер, Моханти, Субраманьян, Гревс.
  • Пятая эпоха - сетевое и промышленное развитие: Ковальский, Хартман, Карпов, Фейнман, Лобач, Кюн, Цзянь.
  • Шестая эпоха - медицина массового гипертранспорта: Чжао Вэньцзе.

ме

 

🛠 Главный файл шаблона для DWIN + UART5 + RS-485 + Modbus

 

mercury 1.4s 1

 

😌 Не надо бояться Keil и TL51

У многих при виде Keil, T5L или TL51 сразу включается внутренний хтонический ужас, будто впереди не работа с микроконтроллером, а какое-то особо извращенное инженерное испытание. Я лично и сам мандражировал как девственница перед гэнг-бэнгом. На самом деле, все оказалось довольно просто. TL51 — это не магия, не инопланетная технология и не черный ящик. Это вполне обычный микроконтроллер, и, насколько можно судить по логике работы, речь идет о вполне понятной 8-битной архитектуре, со своими регистрами, UART, таймерами, прерываниями и прочими знакомыми вещами.

То есть подход здесь примерно такой же, как и в старых добрых проектах под AVR или ту же ATmega: настраиваем периферию, инициализируем порты, запускаем UART, крутим основной цикл, обрабатываем события. Никакой принципиально новой вселенной тут нет. Если человек уже работал с микроконтроллерами не только через яркие кнопочки Arduino IDE, а понимает, что такое регистры, биты, таймеры и обмен по последовательному интерфейсу, то и Keil, и TL51 он вполне осилит без мистических обрядов и кровавых жертвоприношений.

Да, местами тут есть свои странности: названия регистров могут быть непривычными, документация — мутной, а некоторые вещи приходится выяснять почти археологическими методами. Но это не делает платформу какой-то запредельно сложной. Порог входа здесь психологически завышен сильнее, чем технически. И если не страдать недугом «Arduino головного мозга», где любой шаг вне готовой библиотеки вызывает экзистенциальный кризис, то с этой системой можно работать совершенно нормально.

Поэтому правильный настрой в начале такой: перед нами обычный микроконтроллер с UART, таймерами и памятью, а не древнее проклятие инженеров DWIN. Нужно просто спокойно разобрать, что и где инициализируется, какие функции за что отвечают, и как связаны между собой экран, UART5, RS-485 и Modbus. А дальше всё становится гораздо менее страшным и гораздо более похожим на обычную прикладную разработку под железо.

/******************************************************************************
 * Project : TEMPLATE
 * File    : APP.c
 * Author  : Andrey madmentat
 * Site    : https://madmentat.ru
 * Year    : 2026
 *
 * Brief:
 *   Главный файл шаблона приложения для DWIN/DGUS.
 *   Содержит базовую прикладную логику проекта:
 *   инициализацию UART5, RS-485, Modbus RTU и основной цикл обработки.
 *   modbus rtu slave 8n1 115200
 * Notes:
 *   - Используется как стартовая точка для проектов с DWIN HMI
 *   - Подходит для обмена данными по Modbus RTU Slave
 *   - Может быть расширен пользовательской логикой страниц, VP и меню
 ******************************************************************************/
#include "APP.h"
#include "SYSTEM.h"
#include "UART.h"
#include "TIMER.h"
#include "modbus.h"
#include <string.h>
// ==================== ОСНОВНЫЕ НАСТРОЙКИ ====================
// Примеры часто используемых VP (можно добавлять свои)
#define STATUS_VP          0x3000   // Статус/сообщение для STM32
#define CONTROL_VP         0x3002   // Команда от STM32 к DWIN
#define VALUE_VP           0x3100   // Пример числового значения
#define TEXT_VP            0x1005   // Пример текстового VP (26 символов)
// ==================== ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ ====================
xdata uint32_t SysTimCnt = 0;     // Счётчик системного таймера (инкремент в ISR)
xdata uint32_t Uptime = 0;        // Время работы в секундах
xdata uint32_t UptimeOld = 0;     // Для сравнения (периодические задачи)
xdata uint16_t CurrentPage = 0;   // Текущая страница (для отслеживания смены)
// ==================== ПРОТОТИПЫ ====================
static void WriteDgusByte(uint16_t vp, uint8_t offset, uint8_t value);
static void ClearText(uint16_t vp);
static void WriteText(uint16_t vp, const char* str);
// ==================== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ DGUS ====================
static void WriteDgusByte(uint16_t vp, uint8_t offset, uint8_t value)
{
    uint16_t current_word = ReadDgus(vp + (offset / 2));
    if (offset % 2 == 0)
        current_word = (current_word & 0x00FF) | ((uint16_t)value << 8);
    else
        current_word = (current_word & 0xFF00) | value;
    WriteDgus(vp + (offset / 2), current_word);
}
static void ClearText(uint16_t vp)
{
    uint8_t i;
    WriteDgus(vp, 0xFFFF);                     // Очистка первого слова
    
    for (i = 0; i < 26; i++)                   // 26 символов — стандарт для DWIN
    {
        WriteDgusByte(vp, i, 0x20);            // Пробел
    }
}
static void WriteText(uint16_t vp, const char* str)
{
    uint8_t len = 0;
    uint8_t i;
    while (str[len] && len < 26) len++;
    ClearText(vp);
    for (i = 0; i < len; i++)
    {
        WriteDgusByte(vp, i, (uint8_t)str[i]);
    }
}
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
void AppInit(void)
{
    UartInit();
    TimerInit();
    ModbusInit();
    SysTimCnt   = 0;
    Uptime      = 0;
    UptimeOld   = 0;
    CurrentPage = 0;
    // Пример начальной инициализации VP
    WriteText(TEXT_VP, "READY");
    WriteDgus(STATUS_VP, 0x0000);      // Статус = 0
    WriteDgus(CONTROL_VP, 0x0000);
    // Можно сразу перейти на главную страницу
    // PageChange(0);
}
// ==================== ОСНОВНОЙ ЦИКЛ ====================
void AppProcess(void)
{
    uint16_t new_page;
    ModbusProcess();          // ← Обработка Modbus RTU Slave (самое важное!)
    new_page = GetPageID();
    // Отслеживаем смену страницы (удобно для реакций)
    if (new_page != CurrentPage)
    {
        CurrentPage = new_page;
        // Здесь можно добавить действия при смене страницы
    }
    // Пример периодической задачи раз в секунду
    if (Uptime != UptimeOld)
    {
        UptimeOld = Uptime;
        // Можно обновлять STATUS_VP или делать heartbeat
        // WriteDgus(STATUS_VP, (uint16_t)Uptime);
    }
    // Если используете тач/клавиатуру — сюда можно добавить обработчик
    // ProcessTouch() или аналог
    UartProcess();            // Если нужно (обычно пустой или для отладки)
}

 

Этот файл — главный прикладной файл шаблона. Именно здесь собирается базовая логика работы программы: инициализация UART, таймера, Modbus, работа с DGUS/DWIN-переменными и выполнение основного цикла.

Если говорить совсем по-простому, то этот файл отвечает за следующее:

  • 📡 запускает связь по UART5;
  • 🔁 готовит программу к работе по RS-485;
  • 📘 подключает обработку Modbus RTU Slave;
  • 🖥 умеет писать текст и значения в DWIN/DGUS;
  • ⏱ выполняет периодические действия в основном цикле.

📂 Какие файлы подключаются

В начале файла подключаются заголовки:

#include "APP.h"
#include "SYSTEM.h"
#include "UART.h"
#include "TIMER.h"
#include "modbus.h"
#include <string.h>

Что это значит:

  • APP.h — заголовок самого прикладного модуля;
  • SYSTEM.h — общие системные объявления;
  • UART.h — работа с UART, в том числе UART5;
  • TIMER.h — системный таймер;
  • modbus.h — поддержка протокола Modbus;
  • string.h — стандартные функции для строк.

💡 То есть этот файл сам по себе не делает всё в одиночку. Он скорее выступает как дирижёр, который вызывает нужные модули.


⚙ Основные настройки VP

#define STATUS_VP   0x3000
#define CONTROL_VP  0x3002
#define VALUE_VP    0x3100
#define TEXT_VP     0x1005

Это адреса переменных VP в памяти DWIN/DGUS.

  • 📌 STATUS_VP — адрес для статуса или сообщения;
  • 📌 CONTROL_VP — адрес для управляющей команды;
  • 📌 VALUE_VP — адрес для числового значения;
  • 📌 TEXT_VP — адрес текстового поля на экране.

Проще говоря, это ячейки экрана, в которые программа может записывать данные.


🧠 Глобальные переменные

xdata uint32_t SysTimCnt = 0;
xdata uint32_t Uptime = 0;
xdata uint32_t UptimeOld = 0;
xdata uint16_t CurrentPage = 0;

Они нужны для хранения текущего состояния программы:

  • SysTimCnt — системный счётчик тиков таймера;
  • Uptime — время работы программы;
  • 🔄 UptimeOld — предыдущее значение времени, чтобы понимать, прошла ли секунда;
  • 📄 CurrentPage — текущая страница DWIN, чтобы отслеживать переключения.

То есть это не какие-то «магические числа», а обычные служебные переменные, нужные для контроля времени и экрана.


🖊 Вспомогательные функции для DGUS

В файле есть три важных служебных функции:

  • WriteDgusByte()
  • ClearText()
  • WriteText()

1️⃣ WriteDgusByte()

Эта функция записывает один байт в текстовую область DWIN. Поскольку DGUS обычно работает словами по 16 бит, приходится:

  • сначала прочитать текущее слово,
  • поменять в нём только нужный байт,
  • записать слово обратно.

🔧 Это нужно потому, что текст на DWIN часто хранится не как обычная C-строка, а как набор байтов внутри 16-битных слов.

2️⃣ ClearText()

Эта функция очищает текстовое поле. Сначала записывает специальное значение, а потом заполняет область пробелами.

🧹 Проще говоря: было что-то написано на экране — функция стирает это и делает поле пустым.

3️⃣ WriteText()

Эта функция выводит текст в заданный VP. Она:

  • считает длину строки;
  • очищает текстовое поле;
  • записывает символы один за другим.

🖥 Благодаря этому можно делать, например, такие надписи:

WriteText(TEXT_VP, "READY");

То есть на экране появится слово READY.


🚀 Что делает AppInit()

Функция AppInit() — это начальная инициализация программы.

void AppInit(void)
{
    UartInit();
    TimerInit();
    ModbusInit();
    SysTimCnt   = 0;
    Uptime      = 0;
    UptimeOld   = 0;
    CurrentPage = 0;
    WriteText(TEXT_VP, "READY");
    WriteDgus(STATUS_VP, 0x0000);
    WriteDgus(CONTROL_VP, 0x0000);
}

По шагам:

  • 📡 UartInit() — запускает UART;
  • TimerInit() — запускает таймер;
  • 📘 ModbusInit() — подготавливает Modbus;
  • 🧹 обнуляет служебные переменные;
  • 🖥 пишет на экран слово READY;
  • 📌 сбрасывает значения статуса и управления.

То есть после включения устройство приводит всё в понятное начальное состояние.

✅ Иными словами: AppInit() — это «подготовить железку к жизни».


🔁 Что делает AppProcess()

Функция AppProcess() — это основной рабочий цикл программы. Она вызывается снова и снова, пока устройство включено.

void AppProcess(void)
{
    uint16_t new_page;
    ModbusProcess();
    new_page = GetPageID();
    if (new_page != CurrentPage)
    {
        CurrentPage = new_page;
    }
    if (Uptime != UptimeOld)
    {
        UptimeOld = Uptime;
    }
    UartProcess();
}

Разберём по-человечески.

1️⃣ ModbusProcess()

Это самая важная часть цикла. Здесь идёт обработка обмена по Modbus RTU Slave.

📡 Именно тут устройство:

  • принимает запросы от мастера;
  • разбирает пакеты;
  • отвечает по RS-485;
  • обновляет регистры и данные.

Если убрать или сломать эту часть, то Modbus просто работать не будет.

2️⃣ GetPageID()

Дальше программа узнаёт, какая сейчас открыта страница на DWIN.

Это полезно, если нужно делать разную логику для разных экранов. Например:

  • на главной странице читать только основные параметры;
  • в инженерном меню читать другой набор переменных;
  • не опрашивать всё подряд без необходимости.

💡 Это как раз тот путь, который помогает уменьшить лишние задержки и нагрузку.

3️⃣ Контроль времени

Дальше идёт проверка:

if (Uptime != UptimeOld)
{
    UptimeOld = Uptime;
}

Это значит: если прошла ещё одна секунда — можно выполнить какое-нибудь периодическое действие.

Например:

  • 💓 послать heartbeat;
  • 🧾 обновить статус;
  • 🔍 проверить наличие связи;
  • 🖥 вывести диагностическую информацию на экран.

4️⃣ UartProcess()

В конце вызывается UartProcess(). В зависимости от проекта там может быть:

  • обработка буферов приёма и передачи;
  • отладка;
  • или вообще почти ничего, если всё уже делается в других местах.

📡 Как тут связаны UART5, RS-485 и Modbus

Сам этот файл не содержит всей низкоуровневой магии UART5 в явном виде, но он управляет общей логикой через вызовы:

  • UartInit() — инициализация UART;
  • UartProcess() — обработка UART;
  • ModbusInit() — запуск Modbus;
  • ModbusProcess() — работа протокола Modbus.

То есть реальная работа с UART5 и RS-485 обычно находится в модулях UART.c и modbus.c, а данный файл управляет этим на уровне приложения.

📘 Упрощённая схема такая:

Modbus master (например Modbus Poll)
        ↓
      RS-485
        ↓
      UART5
        ↓
   modbus.c / UART.c
        ↓
      APP.c
        ↓
   переменные и экран DWIN

🖥 Зачем вообще нужен этот файл

Этот файл удобен как главный шаблон проекта, потому что в нём уже есть:

  • ✅ базовая структура программы;
  • ✅ инициализация всех нужных подсистем;
  • ✅ работа с текстом на DWIN;
  • ✅ основной цикл обработки;
  • ✅ место для добавления своей логики.

То есть можно не начинать с полного нуля, а взять этот шаблон и постепенно наращивать функциональность.


🔍 Что в этом шаблоне пока простое, а что можно улучшить

Сейчас шаблон хороший как основа, но он ещё базовый.

Что уже есть:

  • ✔ понятная инициализация;
  • ✔ вывод текста в DWIN;
  • ✔ вызов Modbus в основном цикле;
  • ✔ отслеживание текущей страницы;
  • ✔ заготовка для периодических действий.

Что можно развить дальше:

  • 🔧 добавить реакцию на конкретные страницы;
  • 🔧 опрашивать только нужные VP в зависимости от экрана;
  • 🔧 сделать индикацию потери связи;
  • 🔧 добавить heartbeat в Modbus или DGUS;
  • 🔧 реализовать меню, кнопки, ввод пароля и т. д.

🧾 Итог простыми словами

Если совсем по-простому, то этот файл делает следующее:

  1. 🚀 запускает UART, таймер и Modbus;
  2. 🖥 подготавливает DWIN-экран;
  3. 🔁 в бесконечном цикле обрабатывает обмен по Modbus;
  4. 📄 следит за текущей страницей экрана;
  5. ⏱ позволяет выполнять действия раз в секунду или по событиям.

То есть это не «весь Modbus целиком», а главный управляющий файл приложения, в котором сходятся вместе: UART5, RS-485, DWIN/DGUS и Modbus RTU Slave.


💬 Пояснение для маленьких и тупых (все, как мы любим)

  • 🤖 AppInit() — это «включили всё нужное»;
  • 🔁 AppProcess() — это «крутимся по кругу и работаем»;
  • 🖥 WriteText() — это «пишем текст на экран»;
  • 📡 ModbusProcess() — это «слушаем мастера и отвечаем ему»;
  • 📄 GetPageID() — это «смотрим, какой экран открыт».

Вот и все. Не магия. Просто аккуратно разложенная по функциям логика.

 

P.S. ⚠ Подготовка шаблона под DMG80480C043_01WTR

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

Проблема заключалась в том, что реальная частота моего микроконтроллера фактически была неизвестна. Документация на такие панели часто не даёт нормальной уверенности в подобных деталях, а если и даёт, то далеко не всегда понятно, насколько этим данным можно доверять на практике. В результате обычный путь вида «вписал стандартное значение и пошёл дальше» здесь не сработал.

Поэтому пришлось действовать более практичным, инженерным и местами слегка шаманским методом: была написана специальная тестовая программа, задача которой состояла в том, чтобы методом перебора подбирать нужное значение переменной i для функции static void Uart5Init(void) из файла UART.c.

Смысл теста был предельно простой:

  • ⚙ программа выставляла очередное значение i;
  • 📡 запускала UART5 с этими настройками;
  • 🖥 отправляла текст через UART5 примерно раз в одну секунду;
  • 👀 оставалось смотреть, в какой момент на экране появится нормально читаемый текст, а не мусор и абракадабра.

Как только на экране начал отображаться читаемый текст, стало ясно, что найденное значение i является близким к правильному, а значит, параметры инициализации UART5 наконец-то совпали с реальными условиями работы данной панели. Любопытно, что подходящим оказался не один-единственный параметр. В процессе перебора был найден довольно широкий рабочий диапазон — примерно около 16 значений, при которых текст уже становился читаемым. Это полезное наблюдение, потому что оно косвенно показывает: даже при неизвестной точной частоте МК можно эмпирически подобрать достаточно устойчивую настройку UART.

Иными словами, в данном случае настройка UART5 выполнялась не от красивой теории из документации, а от практического результата. Сначала подбиралось значение, при котором связь реально начинала работать, и только после этого шаблон можно было считать пригодным для дальнейшего использования.

✅ Именно таким способом и удалось определить оптимальное значение переменной i, которое затем было использовано в рабочем варианте шаблона.

Ну что, уже ручки чешутся посмотреть поближе, о чем речь? Ну вот мы и добрались до этого момента!

СКАЧАТЬ АРХИВ С ШАБЛОНОМ!

🔐 Автообновление SSL: Certbot + Telegram + systemd timer

Это заметка-памятка: как я сделал обновление сертификатов Let’s Encrypt автоматическим, предсказуемым и наблюдаемым. Всё завязано на один bash-скрипт и нормальную демонизацию через systemd.

⚙️ certbot standalone
📨 2 сообщения в Telegram: старт + итог
🧯 nginx останавливается и поднимается обратно
🕒 systemd timer: 2 раза в месяц
Важное наблюдение из практики: даже если certbot обновил сертификат, браузер может продолжать ругаться, если nginx смотрит на не тот путь к fullchain.pem/privkey.pem. Это лечится правильными путями в конфиге и reload/restart.

🎯 Задача

  • Обновлять SSL-сертификаты Let’s Encrypt (Certbot) для нескольких доменов.
  • Работать без ручного вмешательства и «зависаний».
  • Присылать в Telegram не спам, а короткий отчёт (2 сообщения).
  • Запускаться автоматически 2 раза в месяц в боевом режиме: DRY_RUN=0.
1. stop nginx
2. certbot (groups)
3. start nginx
4. Telegram summary

🧩 Почему certbot standalone

В режиме --standalone certbot поднимает временный HTTP-сервер на 80 порту для прохождения HTTP-01 challenge. Поэтому на время обновления нужно освободить порт. Проще всего — кратко остановить nginx.

➕ Плюсы

  • Минимум зависимости от конфигов nginx (webroot не нужен).
  • Меньше «хрупких мест» и ручных условий.

➖ Минусы

  • Нужно освобождать 80 порт (стоп/старт nginx).
  • Короткий простой веба возможен (обычно секунды).
Если простой недопустим вообще — смотри альтернативы: webroot или DNS-01.

📦 Группировка доменов по сертификатам

Доменов много, но certbot удобно запускать группами: 1 строка = 1 сертификат. Это удобно и для диагностики, и для отчёта.

  • ard-s.ru www.ard-s.ru
  • asterisk.ard-s.ru www.asterisk.ard-s.ru
  • ficus.ard-s.ru www.ficus.ard-s.ru
  • hypervisor.ard-s.ru www.hypervisor.ard-s.ru
  • mail.ard-s.ru
  • mikrotik.ard-s.ru www.mikrotik.ard-s.ru
  • pacs.ard-s.ru www.pacs.ard-s.ru
  • secure.ard-s.ru
  • test.ard-s.ru www.test.ard-s.ru

🧠 Как работает скрипт

1) Строгий режим bash

  • set -e, set -u, set -o pipefail

Идея простая: лучше громко упасть, чем тихо сделать ерунду.

2) Логи

Скрипт пишет в /var/log/certbot-update.log (плюс systemd-журнал).

3) Telegram-уведомления

  • 🟡 старт
  • 🟢/🟠 итоговый отчёт (сколько успешно, сколько с ошибкой, статус nginx)
Техника отправки: используем реальные переводы строк (через printf) и curl --data-urlencode — иначе Telegram может показать «\\n» вместо новой строки.

4) Критичный момент: set -e и $(...)

Если запускать certbot внутри подстановки $(...) при включённом set -e, bash может завершить скрипт раньше, чем мы успеем обработать код возврата. Поэтому в месте запуска certbot временно отключается set -e, а затем включается обратно.

5) Неприятная, но типовая проблема: nginx отдаёт старый сертификат

Certbot может обновить сертификат в /etc/letsencrypt/live/..., но если в nginx прописаны неверные пути к ssl_certificate/ssl_certificate_key, клиент продолжит видеть старый сертификат.

Решение: привести пути к актуальным live/.../fullchain.pem и live/.../privkey.pem и сделать reload / restart nginx.

📄 Листинг скрипта

nano /usr/local/bin/update_certificates.sh

#!/bin/bash
# Обновление SSL-сертификатов certbot (standalone) для набора доменов.
# Telegram: РОВНО 2 сообщения (старт + финальный отчет).
# Логи: /var/log/certbot-update.log
#
# Запуск:
#   DRY_RUN=1 bash /usr/local/bin/update_certificates.sh   # тестовый прогон
#   bash /usr/local/bin/update_certificates.sh             # реальный прогон (DRY_RUN=0)

set -euo pipefail

LOG_FILE="/var/log/certbot-update.log"

# --- Telegram ---
# ВАЖНО: вставь СВЕЖИЙ токен (старый уже светился)
BOT_TOKEN="ВСТАВЬ_НОВЫЙ_ТОКЕН_СЮДА"
CHAT_ID="-1003310217653"

# --- Наборы доменов (1 сертификат на строку) ---
DOMAINS=(
  "ard-s.ru www.ard-s.ru"
  "asterisk.ard-s.ru www.asterisk.ard-s.ru"
  "ficus.ard-s.ru www.ficus.ard-s.ru"
  "hypervisor.ard-s.ru www.hypervisor.ard-s.ru"
  "mail.ard-s.ru"
  "mikrotik.ard-s.ru www.mikrotik.ard-s.ru"
  "pacs.ard-s.ru www.pacs.ard-s.ru"   # фикс опечатки
  "secure.ard-s.ru"
  "test.ard-s.ru www.test.ard-s.ru"
)

# 0 = реальный прогон, 1 = certbot --dry-run
DRY_RUN="${DRY_RUN:-0}"

log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S %z')] $*" | tee -a "$LOG_FILE"
}

tg_send() {
  local action="$1"
  local status="$2"
  local message="${3:-}"
  local details="${4:-}"

  local emoji="⚪"
  local status_text="$status"

  case "$status" in
    start)   emoji="🟡"; status_text="НАЧАЛО ОБНОВЛЕНИЯ" ;;
    success) emoji="🟢"; status_text="УСПЕШНО" ;;
    warning) emoji="🟠"; status_text="С ПРЕДУПРЕЖДЕНИЯМИ" ;;
    error)   emoji="🔴"; status_text="ОШИБКА" ;;
    info)    emoji="ℹ️"; status_text="ИНФОРМАЦИЯ" ;;
  esac

  export TZ='Europe/Moscow'
  local current_time
  current_time=$(date '+%d.%m.%Y %H:%M:%S')

  local hostname
  hostname=$(hostname)

  # Собираем текст с РЕАЛЬНЫМИ переводами строк
  local text
  text=$(
    printf "🔐 <b>Обновление SSL-сертификатов</b>\n\n"
    printf "%s <b>Статус:</b> %s\n" "$emoji" "$status_text"
    printf "📅 <b>Дата и время:</b> %s\n" "$current_time"
    printf "📝 <b>Действие:</b> %s\n" "$action"

    if [[ -n "$message" ]]; then
      printf "\n💬 <b>Сообщение:</b>\n<code>%s</code>\n" "$message"
    fi

    if [[ -n "$details" ]]; then
      # Ограничим размер, чтобы Telegram не отверг сообщение
      if (( ${#details} > 3000 )); then
        details="${details:0:2997}..."
      fi
      printf "\n📋 <b>Детали:</b>\n<pre>%s</pre>\n" "$details"
    fi

    printf "\n🖥 <b>Сервер:</b> %s\n" "$hostname"
  )

  # Если токен не задан — не валим скрипт, но логируем
  if [[ -z "${BOT_TOKEN}" || "${BOT_TOKEN}" == "ВСТАВЬ_НОВЫЙ_ТОКЕН_СЮДА" ]]; then
    log "⚠️ BOT_TOKEN не задан/шаблонный — Telegram уведомление не отправлено."
    return 0
  fi

  local url="https://api.telegram.org/bot${BOT_TOKEN}/sendMessage"

  if command -v curl >/dev/null 2>&1; then
    curl -sS -X POST "$url" \
      --connect-timeout 10 --max-time 20 \
      --data-urlencode "chat_id=${CHAT_ID}" \
      --data-urlencode "parse_mode=HTML" \
      --data-urlencode "disable_web_page_preview=true" \
      --data-urlencode "text=${text}" \
      >/dev/null 2>&1 || log "⚠️ Не удалось отправить уведомление в Telegram (curl)."
  elif command -v wget >/dev/null 2>&1; then
    # У wget нет удобного urlencode — поэтому используем curl предпочтительно.
    # Но чтобы хоть как-то работало, отправим как есть (может сломаться на спецсимволах).
    wget -q --timeout=20 --tries=2 \
      --post-data="chat_id=${CHAT_ID}&parse_mode=HTML&disable_web_page_preview=true&text=$(printf '%s' "$text")" \
      "$url" -O /dev/null 2>&1 || log "⚠️ Не удалось отправить уведомление в Telegram (wget)."
  else
    log "⚠️ Нет curl/wget — Telegram уведомление невозможно."
  fi
}

build_d_args() {
  local domain_set="$1"
  local args=()
  local d
  for d in $domain_set; do
    args+=("-d" "$d")
  done
  printf '%q ' "${args[@]}"
}

# --- Управление nginx и trap ---
nginx_was_running=0
nginx_started_by_script=0

restore_nginx() {
  # Поднимем nginx только если он был запущен до старта и мы его останавливали
  if [[ "$nginx_was_running" -eq 1 && "$nginx_started_by_script" -eq 0 ]]; then
    log "🚀 Возврат nginx (trap)..."
    systemctl start nginx >/dev/null 2>&1 || true
  fi
}
trap restore_nginx EXIT

# --- Start ---
JOB_START_EPOCH=$(date +%s)
log "=== START ==="

if systemctl is-active --quiet nginx; then
  nginx_was_running=1
else
  nginx_was_running=0
fi

tg_send \
  "Запуск автоматического обновления SSL-сертификатов" \
  "start" \
  "Наборов доменов: ${#DOMAINS[@]}. DRY_RUN=${DRY_RUN}"

if [[ "$nginx_was_running" -eq 1 ]]; then
  log "🛑 Остановка nginx..."
  systemctl stop nginx
else
  log "ℹ️ nginx уже остановлен — stop пропущен."
fi

# Проверка порта 80 — standalone должен поднять временный сервер
if ss -lntp 2>/dev/null | grep -qE ':(80)\s'; then
  local80="$(ss -lntp 2>/dev/null | grep -E ':(80)\s' || true)"
  log "❌ Порт 80 занят. certbot --standalone не сможет стартовать."
  log "$local80"
  tg_send \
    "Проверка перед certbot" \
    "error" \
    "Порт 80 занят — standalone не стартует" \
    "$local80"
  exit 1
fi

SUCCESSFUL=""
FAILED=""
TOTAL=${#DOMAINS[@]}
PROCESSED=0

for domain_set in "${DOMAINS[@]}"; do
  PROCESSED=$((PROCESSED + 1))
  log "🔄 [$PROCESSED/$TOTAL] certbot для: $domain_set"

  start_time=$(date +%s)

  # Ключевой фикс против преждевременного выхода из-за set -e внутри $(...)
  set +e
  if [[ "$DRY_RUN" == "1" ]]; then
    output=$(certbot certonly --standalone --dry-run \
      $(build_d_args "$domain_set") \
      --non-interactive --agree-tos --expand 2>&1)
  else
    output=$(certbot certonly --standalone \
      $(build_d_args "$domain_set") \
      --non-interactive --agree-tos --expand 2>&1)
  fi
  exit_code=$?
  set -e

  end_time=$(date +%s)
  duration=$((end_time - start_time))

  if [[ $exit_code -eq 0 ]]; then
    log "✅ OK: $domain_set (${duration}s)"
    SUCCESSFUL+=$'\n'"$domain_set"
  else
    log "❌ FAIL: $domain_set (${duration}s)"
    log "   certbot (первые 30 строк):"
    echo "$output" | head -n 30 | sed 's/^/   /' | tee -a "$LOG_FILE" >/dev/null
    FAILED+=$'\n'"$domain_set"
  fi

  log "---"
  sleep 2
done

# Поднимаем nginx обратно, если он был запущен до старта
NGINX_STATUS="⚪ nginx неизвестно"
if [[ "$nginx_was_running" -eq 1 ]]; then
  log "🚀 Запуск nginx..."
  systemctl start nginx
  nginx_started_by_script=1

  if systemctl is-active --quiet nginx; then
    log "✅ nginx запущен"
    NGINX_STATUS="🟢 nginx запущен"
  else
    log "❌ nginx не запустился!"
    NGINX_STATUS="🔴 nginx не запущен"
  fi
else
  NGINX_STATUS="ℹ️ nginx был остановлен до старта"
fi

# Короткая выжимка certbot certificates
CERT_INFO_SNIP=""
set +e
CERT_INFO_FULL=$(certbot certificates 2>/dev/null)
set -e
if [[ -n "${CERT_INFO_FULL:-}" ]]; then
  CERT_INFO_SNIP=$(echo "$CERT_INFO_FULL" | grep -E 'Certificate Name:|Domains:|Expiry Date:' | head -n 60)
fi

# Счётчики по наборам (а не по словам)
ok_sets=0
bad_sets=0
if [[ -n "$SUCCESSFUL" ]]; then
  ok_sets=$(echo "$SUCCESSFUL" | sed '/^\s*$/d' | wc -l)
fi
if [[ -n "$FAILED" ]]; then
  bad_sets=$(echo "$FAILED" | sed '/^\s*$/d' | wc -l)
fi

end_epoch=$(date +%s)
total_duration=$((end_epoch - JOB_START_EPOCH))

# Финальный отчёт — 2-е сообщение
details=$(
  printf "⏱ Длительность: %s сек\n" "$total_duration"
  printf "%s\n" "$NGINX_STATUS"
  printf "DRY_RUN=%s\n\n" "$DRY_RUN"

  printf "📦 Наборов доменов: %s\n" "$TOTAL"
  printf "✅ Успешно: %s\n" "$ok_sets"
  printf "❌ Ошибки: %s\n\n" "$bad_sets"

  if [[ -n "$SUCCESSFUL" ]]; then
    printf "✅ Успешно:\n"
    echo "$SUCCESSFUL" | sed '/^\s*$/d' | sed 's/^/  • /'
    printf "\n"
  fi

  if [[ -n "$FAILED" ]]; then
    printf "❌ С ошибками:\n"
    echo "$FAILED" | sed '/^\s*$/d' | sed 's/^/  • /'
    printf "\n⚠️ Проверь: DNS A/AAAA, порт 80, firewall, и лог certbot.\n"
  fi

  if [[ -n "$CERT_INFO_SNIP" ]]; then
    printf "\ncertbot certificates (кратко):\n%s\n" "$CERT_INFO_SNIP"
  fi
)

if [[ "$bad_sets" -eq 0 ]]; then
  tg_send \
    "Завершение обновления SSL-сертификатов" \
    "success" \
    "Обновление завершено успешно" \
    "$details"
else
  tg_send \
    "Завершение обновления SSL-сертификатов" \
    "warning" \
    "Обновление завершено с ошибками" \
    "$details"
fi

log "=== DONE ==="

# Код выхода
if [[ "$bad_sets" -gt 0 ]]; then
  exit 1
else
  exit 0
fi

🧿 Демонизация: systemd service + timer

Чтобы не запускать руками, используется пара unit-файлов: certbot-update.service и certbot-update.timer.

systemd service

  • Type=oneshot — задача разовая, не висящий демон.
  • Environment=DRY_RUN=0 — принудительно боевой режим.
  • ExecStart=/usr/local/bin/update_certificates.sh — запуск скрипта.

systemd timer

  • Запуск 1-го и 15-го числа (ночью).
  • Persistent=true — догоняет пропущенные запуски.
  • RandomizedDelaySec — при желании добавляет небольшой рандом.
Почему systemd лучше cron: легко смотреть логи, статусы, следующие запуски, и меньше «скрытой магии».

📄 Листинг юнита systemd

nano /etc/systemd/system/certbot-update.service

  GNU nano 8.4                              /etc/systemd/system/certbot-update.service *
[Unit]
Description=Update SSL certificates (certbot standalone) + Telegram report
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
# Важно: принудительно боевой режим
Environment=DRY_RUN=0
# Скрипт должен быть исполняемым
ExecStart=/usr/local/bin/update_certificates.sh
# На всякий случай: не зависать бесконечно
TimeoutStartSec=30min
# Логи будут в journald + твой /var/log/certbot-update.log
# (скрипт сам пишет в /var/log/certbot-update.log)

📄 Таймер

nano /etc/systemd/system/certbot-update.timer

  GNU nano 8.4                               /etc/systemd/system/certbot-update.timer *
[Unit]
Description=Run certbot-update twice a month
[Timer]
OnCalendar=*-*-01 04:20:00
OnCalendar=*-*-15 04:20:00
# Если сервер был выключен в момент запуска — догонит после включения
Persistent=true
# Небольшой рандом, чтобы не долбить LE ровно в одно время (полезно, но не обязательно)
RandomizedDelaySec=10min
Unit=certbot-update.service
[Install]
WantedBy=timers.target

Активация и проверка:

# Перезагружаем systemd для загрузки новых юнитов
sudo systemctl daemon-reload

# Включаем и запускаем таймер
sudo systemctl enable --now certbot-update.timer

# Проверяем статус таймера
sudo systemctl status certbot-update.timer

# Смотрим список предстоящих запусков
sudo systemctl list-timers --all | grep certbot-update

# Проверяем лог последнего запуска
sudo journalctl -u certbot-update.service -n 50

✅ Итог

  • Сертификаты обновляются автоматически.
  • nginx корректно останавливается и поднимается обратно.
  • Telegram даёт понятный отчёт: старт и итог.
  • systemd timer делает процесс регулярным и незаметным.
Если после успешного обновления браузер всё равно ругается — почти всегда виноват nginx, который смотрит на неправильные пути сертификатов. Это лечится за минуту.
 

🔔 P.S

  • Говорят, у nginx есть какой-то свой механизм мягкого обновления сертификатов, который работает значительно быстрее. Но я, пожалуй, не буду вносить изменения в скрипт, пока жаренный петух не клюнет в жопу. Работает - и так сойдет! 
 

 

STM32 - MODBUS RTU MASTER 8N1 115200 / DWIN;

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

#include "main.h"
#include <string.h>
#include <stdint.h>
/* ===================== Конфигурация ===================== */
#define SYSCLK_HZ           8000000UL
#define BAUDRATE            115200UL
#define TIMEOUT_MS          100U
#define IWDG_TIMEOUT_MS     2000U
#define FLASH_STATE_ADDR    0x08007C00UL
#define FLASH_MAGIC_VALUE   0xDEADBEEFUL
/* RS485 direction pin */
#define RS485_CONTROL_PIN   GPIO_PIN_1
#define RS485_CONTROL_PORT  GPIOF
/* DWIN / Modbus */
#define MODBUS_SLAVE_ID     1U
#define DWIN_VP_READ        0x1001U
#define DWIN_VP_WRITE       0x1500U
#define MODBUS_POLL_MS      10U
#define READ_QTY         10U
/* Светодиоды */
#define LED_COUNT           6
static const uint16_t led_pins[LED_COUNT] = {
    GPIO_PIN_15, GPIO_PIN_12, GPIO_PIN_11,
    GPIO_PIN_10, GPIO_PIN_9,  GPIO_PIN_8
};
/* ===================== Глобальные переменные ===================== */
volatile uint32_t timer_overflows = 0;
volatile uint32_t WatchdogTIMER_1 = 0;
volatile uint32_t WatchdogTIMER_2 = 1000;
typedef struct {
    uint8_t  led_states[LED_COUNT];
    uint32_t reset_count;
    uint32_t magic;
    uint32_t checksum;
} SystemState;
SystemState system_state = {0};
/* ===================== Прототипы ===================== */
void SystemClock_Config(void);
void MX_GPIO_Init(void);
void UART_Init(void);
void RS485_Init(void);
void RS485_SetTransmitMode(void);
void RS485_SetReceiveMode(void);
void Timer_Init(void);
void WatchdogTimer_Init(void);
void IWDG_Init(void);
void Delay_us(uint32_t us);
uint32_t millis(void);
void CheckResetCause(uint8_t *is_iwdg_reset);
void LoadSystemState(void);
void SaveSystemState(void);
uint32_t CalculateChecksum(SystemState *state);
void ApplyLedStates(void);
void UART_ClearRx(void);
void UART_SendBytes(const uint8_t *data, uint16_t len);
uint8_t UART_ReceiveExact(uint8_t *buf, uint16_t len, uint32_t timeout_ms);
uint16_t Modbus_CRC16(const uint8_t *data, uint16_t len);
uint8_t Modbus_ReadHoldingRegisters(uint8_t slave_id, uint16_t start_addr, uint16_t quantity, uint16_t *dest);
uint8_t Modbus_WriteSingleRegister(uint8_t slave_id, uint16_t reg_addr, uint16_t value);
void Error_Handler(void);
/* ===================== Задержка ===================== */
void Delay_us(uint32_t us)
{
    uint32_t cycles = (SYSCLK_HZ / 1000000UL) * us;
    while (cycles--) {
        __NOP();
    }
}
/* ===================== millis ===================== */
uint32_t millis(void)
{
    uint32_t overflows;
    uint32_t cnt;
    __disable_irq();
    overflows = timer_overflows;
    cnt = TIM3->CNT;
    __enable_irq();
    return (overflows * 1000UL) + cnt;
}
/* ===================== RS485 ===================== */
void RS485_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    __HAL_RCC_GPIOF_CLK_ENABLE();
    GPIO_InitStruct.Pin = RS485_CONTROL_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(RS485_CONTROL_PORT, &GPIO_InitStruct);
    RS485_SetReceiveMode();
}
void RS485_SetTransmitMode(void)
{
    HAL_GPIO_WritePin(RS485_CONTROL_PORT, RS485_CONTROL_PIN, GPIO_PIN_SET);
    Delay_us(50);
}
void RS485_SetReceiveMode(void)
{
    HAL_GPIO_WritePin(RS485_CONTROL_PORT, RS485_CONTROL_PIN, GPIO_PIN_RESET);
    Delay_us(50);
}
/* ===================== UART ===================== */
void UART_Init(void)
{
    __HAL_RCC_USART1_CLK_ENABLE();
    __HAL_RCC_GPIOA_CLK_ENABLE();
    /* PA2 = TX, PA3 = RX, AF1 */
    GPIOA->MODER &= ~(GPIO_MODER_MODER2_Msk | GPIO_MODER_MODER3_Msk);
    GPIOA->MODER |= (GPIO_MODER_MODER2_1 | GPIO_MODER_MODER3_1);
    GPIOA->AFR[0] &= ~((0xFUL << GPIO_AFRL_AFSEL2_Pos) | (0xFUL << GPIO_AFRL_AFSEL3_Pos));
    GPIOA->AFR[0] |=  ((0x1UL << GPIO_AFRL_AFSEL2_Pos) | (0x1UL << GPIO_AFRL_AFSEL3_Pos));
    USART1->CR1 = 0;
    USART1->CR2 = 0;
    USART1->CR3 = 0;
    /* 115200, 8N1 */
    USART1->BRR = (SYSCLK_HZ + (BAUDRATE / 2U)) / BAUDRATE;
    USART1->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE;
}
void UART_ClearRx(void)
{
    while (USART1->ISR & USART_ISR_RXNE) {
        volatile uint8_t dummy = (uint8_t)USART1->RDR;
        (void)dummy;
    }
    if (USART1->ISR & (USART_ISR_ORE | USART_ISR_PE | USART_ISR_FE | USART_ISR_NE)) {
        USART1->ICR = USART_ICR_ORECF | USART_ICR_PECF | USART_ICR_FECF | USART_ICR_NCF;
    }
}
void UART_SendBytes(const uint8_t *data, uint16_t len)
{
    RS485_SetTransmitMode();
    for (uint16_t i = 0; i < len; i++) {
        while ((USART1->ISR & USART_ISR_TXE) == 0U) {
        }
        USART1->TDR = data[i];
    }
    while ((USART1->ISR & USART_ISR_TC) == 0U) {
    }
    Delay_us(50);
    RS485_SetReceiveMode();
}
uint8_t UART_ReceiveExact(uint8_t *buf, uint16_t len, uint32_t timeout_ms)
{
    uint32_t start = millis();
    uint16_t index = 0;
    while (index < len) {
        if (USART1->ISR & USART_ISR_RXNE) {
            buf[index++] = (uint8_t)USART1->RDR;
            start = millis();
        }
        if (USART1->ISR & (USART_ISR_ORE | USART_ISR_PE | USART_ISR_FE | USART_ISR_NE)) {
            USART1->ICR = USART_ICR_ORECF | USART_ICR_PECF | USART_ICR_FECF | USART_ICR_NCF;
            return 0;
        }
        if ((millis() - start) >= timeout_ms) {
            return 0;
        }
    }
    return 1;
}
/* ===================== Timer TIM3 для millis ===================== */
void Timer_Init(void)
{
    __HAL_RCC_TIM3_CLK_ENABLE();
    TIM3->PSC = (SYSCLK_HZ / 1000UL) - 1UL;
    TIM3->ARR = 1000UL - 1UL;
    TIM3->CNT = 0;
    TIM3->DIER |= TIM_DIER_UIE;
    NVIC_SetPriority(TIM3_IRQn, 1);
    NVIC_EnableIRQ(TIM3_IRQn);
    TIM3->CR1 |= TIM_CR1_CEN;
}
/* ===================== Timer TIM14 для watchdog ===================== */
void WatchdogTimer_Init(void)
{
    __HAL_RCC_TIM14_CLK_ENABLE();
    TIM14->PSC = (SYSCLK_HZ / 1000UL) - 1UL;
    TIM14->ARR = 1UL;
    TIM14->CNT = 0;
    TIM14->DIER |= TIM_DIER_UIE;
    NVIC_SetPriority(TIM14_IRQn, 2);
    NVIC_EnableIRQ(TIM14_IRQn);
    TIM14->CR1 |= TIM_CR1_CEN;
}
void TIM14_IRQHandler(void)
{
    if (TIM14->SR & TIM_SR_UIF) {
        TIM14->SR &= ~TIM_SR_UIF;
        WatchdogTIMER_1++;
    }
}
void TIM3_IRQHandler(void)
{
    if (TIM3->SR & TIM_SR_UIF) {
        TIM3->SR &= ~TIM_SR_UIF;
        timer_overflows++;
    }
}
/* ===================== IWDG ===================== */
void IWDG_Init(void)
{
    __HAL_RCC_LSI_ENABLE();
    while ((RCC->CSR & RCC_CSR_LSIRDY) == 0U) {
    }
    IWDG->KR = 0x5555;
    IWDG->PR = 0x04;
    IWDG->RLR = 2500U;
    IWDG->KR = 0xAAAA;
    IWDG->KR = 0xCCCC;
}
/* ===================== Flash state ===================== */
uint32_t CalculateChecksum(SystemState *state)
{
    uint32_t sum = 0;
    for (int i = 0; i < LED_COUNT; i++) {
        sum += state->led_states[i];
    }
    sum += state->reset_count;
    return sum;
}
void LoadSystemState(void)
{
    SystemState *flash_state = (SystemState *)FLASH_STATE_ADDR;
    if ((flash_state->magic == FLASH_MAGIC_VALUE) &&
        (flash_state->checksum == CalculateChecksum(flash_state))) {
        memcpy(&system_state, flash_state, sizeof(SystemState));
    } else {
        memset(system_state.led_states, 0, sizeof(system_state.led_states));
        system_state.reset_count = 0;
        system_state.magic = FLASH_MAGIC_VALUE;
        system_state.checksum = CalculateChecksum(&system_state);
        SaveSystemState();
    }
}
void SaveSystemState(void)
{
    HAL_StatusTypeDef status;
    FLASH_EraseInitTypeDef erase_init = {0};
    uint32_t page_error = 0;
    uint32_t *state_ptr = (uint32_t *)&system_state;
    system_state.checksum = CalculateChecksum(&system_state);
    system_state.magic = FLASH_MAGIC_VALUE;
    HAL_FLASH_Unlock();
    erase_init.TypeErase   = FLASH_TYPEERASE_PAGES;
    erase_init.PageAddress = FLASH_STATE_ADDR;
    erase_init.NbPages     = 1;
    status = HAL_FLASHEx_Erase(&erase_init, &page_error);
    if (status != HAL_OK) {
        HAL_FLASH_Lock();
        return;
    }
    for (uint32_t i = 0; i < (sizeof(SystemState) / 4U); i++) {
        status = HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD,
                                   FLASH_STATE_ADDR + (i * 4U),
                                   state_ptr[i]);
        if (status != HAL_OK) {
            break;
        }
    }
    HAL_FLASH_Lock();
}
/* ===================== LED ===================== */
void ApplyLedStates(void)
{
    for (int i = 0; i < LED_COUNT; i++) {
        HAL_GPIO_WritePin(GPIOA, led_pins[i],
                          system_state.led_states[i] ? GPIO_PIN_SET : GPIO_PIN_RESET);
    }
}
/* ===================== Reset cause ===================== */
void CheckResetCause(uint8_t *is_iwdg_reset)
{
    uint32_t reset_flags = RCC->CSR;
    *is_iwdg_reset = (reset_flags & RCC_CSR_IWDGRSTF) ? 1U : 0U;
    RCC->CSR |= RCC_CSR_RMVF;
}
/* ===================== GPIO ===================== */
void MX_GPIO_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    __HAL_RCC_GPIOA_CLK_ENABLE();
    GPIO_InitStruct.Pin = GPIO_PIN_8  | GPIO_PIN_9  | GPIO_PIN_10 |
                          GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_15;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
    HAL_GPIO_WritePin(GPIOA,
                      GPIO_PIN_8  | GPIO_PIN_9  | GPIO_PIN_10 |
                      GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_15,
                      GPIO_PIN_RESET);
}
/* ===================== Clock ===================== */
void SystemClock_Config(void)
{
    RCC_OscInitTypeDef RCC_OscInitStruct = {0};
    RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};
    RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSI;
    RCC_OscInitStruct.HSIState = RCC_HSI_ON;
    RCC_OscInitStruct.HSICalibrationValue = RCC_HSICALIBRATION_DEFAULT;
    RCC_OscInitStruct.PLL.PLLState = RCC_PLL_NONE;
    if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK) {
        Error_Handler();
    }
    RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK |
                                  RCC_CLOCKTYPE_SYSCLK |
                                  RCC_CLOCKTYPE_PCLK1;
    RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_HSI;
    RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1;
    RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV1;
    if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_0) != HAL_OK) {
        Error_Handler();
    }
}
/* ===================== Modbus CRC ===================== */
uint16_t Modbus_CRC16(const uint8_t *data, uint16_t len)
{
    uint16_t crc = 0xFFFFU;
    for (uint16_t pos = 0; pos < len; pos++) {
        crc ^= (uint16_t)data[pos];
        for (uint8_t i = 0; i < 8; i++) {
            if (crc & 0x0001U) {
                crc >>= 1;
                crc ^= 0xA001U;
            } else {
                crc >>= 1;
            }
        }
    }
    return crc;
}
/* ===================== Modbus RTU Master ===================== */
uint8_t Modbus_ReadHoldingRegisters(uint8_t slave_id, uint16_t start_addr, uint16_t quantity, uint16_t *dest)
{
    uint8_t tx[8];
    uint8_t rx[64];
    uint16_t crc;
    uint16_t expected_len;
    if ((quantity == 0U) || (quantity > 29U) || (dest == NULL)) {
        return 0;
    }
    tx[0] = slave_id;
    tx[1] = 0x03;
    tx[2] = (uint8_t)(start_addr >> 8);
    tx[3] = (uint8_t)(start_addr & 0xFF);
    tx[4] = (uint8_t)(quantity >> 8);
    tx[5] = (uint8_t)(quantity & 0xFF);
    crc = Modbus_CRC16(tx, 6);
    tx[6] = (uint8_t)(crc & 0xFF);
    tx[7] = (uint8_t)((crc >> 8) & 0xFF);
    UART_ClearRx();
    UART_SendBytes(tx, 8);
    expected_len = (uint16_t)(5U + (2U * quantity));
    if (!UART_ReceiveExact(rx, expected_len, TIMEOUT_MS)) {
        return 0;
    }
    if (rx[0] != slave_id) return 0;
    if (rx[1] & 0x80U) return 0;
    if (rx[1] != 0x03U) return 0;
    if (rx[2] != (uint8_t)(quantity * 2U)) return 0;
    crc = Modbus_CRC16(rx, expected_len - 2U);
    if ((rx[expected_len - 2U] != (uint8_t)(crc & 0xFF)) ||
        (rx[expected_len - 1U] != (uint8_t)((crc >> 8) & 0xFF))) {
        return 0;
    }
    for (uint16_t i = 0; i < quantity; i++) {
        dest[i] = ((uint16_t)rx[3U + (2U * i)] << 8) |
                  (uint16_t)rx[4U + (2U * i)];
    }
    return 1;
}
uint8_t Modbus_WriteSingleRegister(uint8_t slave_id, uint16_t reg_addr, uint16_t value)
{
    uint8_t tx[8];
    uint8_t rx[8];
    uint16_t crc;
    tx[0] = slave_id;
    tx[1] = 0x06;
    tx[2] = (uint8_t)(reg_addr >> 8);
    tx[3] = (uint8_t)(reg_addr & 0xFF);
    tx[4] = (uint8_t)(value >> 8);
    tx[5] = (uint8_t)(value & 0xFF);
    crc = Modbus_CRC16(tx, 6);
    tx[6] = (uint8_t)(crc & 0xFF);
    tx[7] = (uint8_t)((crc >> 8) & 0xFF);
    UART_ClearRx();
    UART_SendBytes(tx, 8);
    if (!UART_ReceiveExact(rx, 8, TIMEOUT_MS)) {
        return 0;
    }
    if (memcmp(tx, rx, 8) != 0) {
        return 0;
    }
    return 1;
}
/* ===================== main ===================== */
int main(void)
{
    uint8_t is_iwdg_reset = 0;
    uint32_t last_poll_time = 0;
    uint16_t src_value = 0;
    uint8_t heartbeat = 0;
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    RS485_Init();
    UART_Init();
    Timer_Init();
    WatchdogTimer_Init();
    IWDG_Init();
    CheckResetCause(&is_iwdg_reset);
    LoadSystemState();
    system_state.reset_count++;
    if (!is_iwdg_reset) {
        memset(system_state.led_states, 0, sizeof(system_state.led_states));
    }
    ApplyLedStates();
    SaveSystemState();
    while (1) {
        if (WatchdogTIMER_1 >= WatchdogTIMER_2) {
            IWDG->KR = 0xAAAA;
            WatchdogTIMER_1 = 0;
        }
        if ((millis() - last_poll_time) >= MODBUS_POLL_MS) {
            last_poll_time = millis();
            if (Modbus_ReadHoldingRegisters(MODBUS_SLAVE_ID, DWIN_VP_READ, 1, &src_value)) {
                if (Modbus_WriteSingleRegister(MODBUS_SLAVE_ID, DWIN_VP_WRITE, src_value)) {
                    heartbeat ^= 1U;
                    system_state.led_states[0] = heartbeat;
                    system_state.led_states[1] = 1;
                    system_state.led_states[2] = 0;
                } else {
                    system_state.led_states[1] = 0;
                    system_state.led_states[2] = 1;
                }
            } else {
                system_state.led_states[1] = 0;
                system_state.led_states[2] = 1;
            }
            ApplyLedStates();
        }
    }
}
/* ===================== Error ===================== */
void Error_Handler(void)
{
    __disable_irq();
    while (1) {
    }
}
#ifdef USE_FULL_ASSERT
void assert_failed(uint8_t *file, uint32_t line)
{
    (void)file;
    (void)line;
}
#endif

 

 

 

⚡ madLENotify: уведомления об отключениях Ленэнерго в Telegram

madLENotify — маленький самописный демон на C++17, который периодически проверяет страницу плановых работ 🌐 и отправляет уведомления в Telegram 📨 при появлении новых отключений в выбранном населённом пункте.

Что умеет:
• 📅 парсит плановые отключения (дата/время/адрес/комментарий)
• 🔔 фильтрует по окну уведомлений (например, за 3 дня вперёд)
• 🕘 отправляет сообщения после заданного времени (send_time)
• 🧠 не спамит: ведёт журнал отправок (sent_log.json)
• 🌐 имеет простой HTTP API для просмотра/обновления конфига (по желанию)
• 🧾 умеет прислать историю прошедших отключений (history)

🧩 Как это работает

  1. Демон раз в check_interval_sec секунд качает страницу planned_work.
  2. Парсит HTML-таблицу и выделяет события отключений.
  3. Фильтрует события по посёлку/городу (settlement) и по диапазону дней (alert_days).
  4. Если наступило время отправки (send_time), то формирует сообщение по шаблону и шлёт в Telegram.
  5. Факт отправки фиксируется в sent_log.json, чтобы не дублить одно и то же.

🛡️ Про лимит daily_limit

Параметр daily_limit ограничивает количество сообщений в сутки на каждое отдельное событие, а не “одно на всё”. То есть если в один день появилось два разных отключения — можно получить два уведомления даже при daily_limit = 1. Это важно, когда Ленэнерго публикует несколько объявлений на разные интервалы времени.

📦 Установка и сборка

🧰 Зависимости

  • 🐧 Linux сервер (обычно Debian/Ubuntu).
  • 🧱 Компилятор g++ с поддержкой C++17.
  • 🌐 libcurl (-lcurl).
  • 🧵 pthread (-lpthread).
  • 📄 json.hpp (nlohmann/json) и httplib.h (если используешь HTTP API).

🔨 Сборка

Пример команды сборки:

g++ -o lenotify lenotify.cpp -std=c++17 -O2 -lcurl -lpthread -I.

После сборки рядом появится бинарник lenotify. Его можно запускать вручную, либо оформить как systemd-сервис.

⚙️ Настройка: settings.json

Конфигурация хранится в JSON. Основные поля:

  • 📍 settlement — населённый пункт для фильтра (например, “Пески”).
  • 🔗 url_base — базовый URL planned_work.
  • 🔔 alert_days — за сколько дней вперёд предупреждать.
  • 🕘 send_time — время (МСК), после которого можно отправлять уведомления.
  • check_interval_sec — период проверки (в секундах).
  • 🚦 daily_limit — лимит сообщений в сутки по каждому событию.
  • 💬 default_message — шаблон сообщения с плейсхолдерами.
  • 📨 Telegram: telegram_bot_token, telegram_api_url, chat_ids.
  • 🌐 api_port — порт локального HTTP API (если нужно).

🧾 Листинг конфигурации:

{
  "settlement": "Пески",
  "url_base": "https://rosseti-lenenergo.ru/planned_work/",

  "check_interval_sec": 1800,
  "daily_limit": 5,

  "alert_days": 3,
  "send_time": "09:00",                                                                                                   "test_mode": false,
  "max_message_len": 320,

  "default_message": "⚡ Плановое отключение электроэнергии: {badge} {when} {time_range} — {address_short}{comment_opt}"
,м

  "tg_bot_token": "8356004729:ABHt4Rw8XTujAl_QQLyTbsddCQEcRrbXMD8",
  "tg_chat_id": "-1003310217653",

  "api_port": 8080
}

 

🧠 Код демона: lenotify.cpp

Внутри программы есть несколько важных частей:

  • 🌐 загрузка HTML через libcurl;
  • 🧽 очистка HTML и парсинг строк таблицы отключений;
  • 🧾 формирование хеша события (дата/время/адрес/комментарий) — чтобы отличать события друг от друга;
  • 📓 sent_log.json — учёт отправленных сообщений, чтобы не повторяться;
  • 📨 отправка в Telegram через sendMessage;
  • 🌐 небольшой HTTP API для просмотра/обновления конфига (опционально).

🧾 Основной исходник:

// lenotify.cpp
// -----------------------------------------------------------------------------
// madLENotify v1.5 - Уведомления об отключениях Ленэнерго через Telegram
//
// Build:
//   g++ -o lenotify lenotify.cpp -std=c++17 -O2 -lcurl -lpthread -I.
//
// Run:
//   ./lenotify
//   ./lenotify --once
//   ./lenotify --test --force --once
//   ./lenotify --test-send
//   ./lenotify --history
//   ./lenotify --tg-check
//   ./lenotify --config /root/settings.json --history
//   ./lenotify --man
//
// Notes:
// - Источник дат по МСК. Чтобы days_left совпадал, можно запускать так:
//     TZ=Europe/Moscow ./lenotify --once
// -----------------------------------------------------------------------------

#include <curl/curl.h>

#include <algorithm>
#include <chrono>
#include <cctype>
#include <ctime>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <mutex>
#include <regex>
#include <sstream>
#include <string>
#include <thread>
#include <vector>

#include "json.hpp"
#include "httplib.h"

using json = nlohmann::json;

// ------------------------- Logging -------------------------
static void log_line(const std::string& msg) {
    std::ofstream logfile("log.txt", std::ios::app);
    auto now = std::chrono::system_clock::now();
    std::time_t t = std::chrono::system_clock::to_time_t(now);
    logfile << std::put_time(std::localtime(&t), "%Y-%m-%d %H:%M:%S") << " " << msg << "\n";
    std::cout  << std::put_time(std::localtime(&t), "%Y-%m-%d %H:%M:%S") << " " << msg << "\n";
}

// ------------------------- CLI man/help -------------------------
static const char* MAN_TEXT = R"MAN(
madLENotify v1.5 — оповещения об отключениях (Ленэнерго planned_work) через Telegram бота

Использование:
  ./lenotify [--config FILE] [--once] [--test] [--force] [--test-send] [--debug] [--history] [--tg-check] [--man|--help]

Опции:
  --config FILE    Явно указать путь к файлу настроек (JSON).
                   Если не задано — пробует settings.json затем config.json в текущей директории.
  --man, --help    Показать справку.
  --once           Один цикл проверки и выход.
  --test           Отладка: слать сразу (игнор send_time и daily_limit).
  --force          Принудительно слать даже если уже слали сегодня (игнор sent_log).
  --test-send      Отправить тестовое сообщение и выйти.
  --debug          Подробный вывод отладочной информации.
  --history        Отправить в Telegram таблицу истории (прошедшие отключения).
  --tg-check       Проверить Telegram токен через getMe и выйти.

Поддерживаемые ключи в JSON (оба варианта):
  Новый/унифицированный формат:
    telegram_bot_token, telegram_api_url, chat_ids (array of strings)
  Старый/короткий формат:
    tg_bot_token, tg_api_url, tg_chat_id (string)
  Остальное:
    settlement, url_base, alert_days, send_time, check_interval_sec,
    daily_limit, max_message_len, default_message, api_port,
    history_days, history_max_rows, debug_telegram_url

Важно:
  Токен бота нельзя публиковать. Если засветили — перевыпустите в @BotFather.
)MAN";

// ------------------------- Helpers: strings -------------------------
static std::string trim_ws(std::string s) {
    auto issp = [](unsigned char c){ return std::isspace(c) != 0; };
    while (!s.empty() && issp((unsigned char)s.front())) s.erase(s.begin());
    while (!s.empty() && issp((unsigned char)s.back()))  s.pop_back();
    return s;
}

static std::string collapse_spaces(std::string s) {
    s = std::regex_replace(s, std::regex("\\s+"), " ");
    return trim_ws(s);
}

static std::string replace_all(std::string s, const std::string& what, const std::string& with) {
    if (what.empty()) return s;
    size_t pos = 0;
    while ((pos = s.find(what, pos)) != std::string::npos) {
        s.replace(pos, what.size(), with);
        pos += with.size();
    }
    return s;
}

static std::string strip_tags(std::string s) {
    s = std::regex_replace(s, std::regex("<[^>]*>"), " ");
    s = std::regex_replace(s, std::regex("&nbsp;|&#160;"), " ");
    s = std::regex_replace(s, std::regex("&amp;"), "&");
    s = std::regex_replace(s, std::regex("&quot;"), "\"");
    s = std::regex_replace(s, std::regex("&lt;"), "<");
    s = std::regex_replace(s, std::regex("&gt;"), ">");
    return collapse_spaces(s);
}

static std::string remove_uuids(std::string s) {
    s = std::regex_replace(
        s,
        std::regex(R"(\b[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}\b)"),
        ""
    );
    return collapse_spaces(s);
}

static std::string html_escape(std::string s) {
    s = replace_all(s, "&", "&amp;");
    s = replace_all(s, "<", "&lt;");
    s = replace_all(s, ">", "&gt;");
    return s;
}

// ------------------------- UTF-8 validation + cp1251->utf8 fallback -------------------------
static bool is_valid_utf8(const std::string& s) {
    const unsigned char* p = (const unsigned char*)s.data();
    size_t n = s.size();
    size_t i = 0;
    while (i < n) {
        unsigned char c = p[i];
        if (c <= 0x7F) { i++; continue; }

        if ((c & 0xE0) == 0xC0) {
            if (i + 1 >= n) return false;
            unsigned char c1 = p[i + 1];
            if ((c1 & 0xC0) != 0x80) return false;
            if (c == 0xC0 || c == 0xC1) return false;
            i += 2;
            continue;
        }
        if ((c & 0xF0) == 0xE0) {
            if (i + 2 >= n) return false;
            unsigned char c1 = p[i + 1], c2 = p[i + 2];
            if ((c1 & 0xC0) != 0x80 || (c2 & 0xC0) != 0x80) return false;
            if (c == 0xE0 && (c1 & 0xE0) == 0x80) return false;
            if (c == 0xED && (c1 & 0xE0) == 0xA0) return false;
            i += 3;
            continue;
        }
        if ((c & 0xF8) == 0xF0) {
            if (i + 3 >= n) return false;
            unsigned char c1 = p[i + 1], c2 = p[i + 2], c3 = p[i + 3];
            if ((c1 & 0xC0) != 0x80 || (c2 & 0xC0) != 0x80 || (c3 & 0xC0) != 0x80) return false;
            if (c == 0xF0 && (c1 & 0xF0) == 0x80) return false;
            if (c > 0xF4) return false;
            if (c == 0xF4 && c1 > 0x8F) return false;
            i += 4;
            continue;
        }
        return false;
    }
    return true;
}

static std::string cp1251_to_utf8(const std::string& in) {
    std::string out;
    out.reserve(in.size() * 2);

    auto append_utf8 = [&](uint32_t cp) {
        if (cp <= 0x7F) out.push_back((char)cp);
        else if (cp <= 0x7FF) {
            out.push_back((char)(0xC0 | (cp >> 6)));
            out.push_back((char)(0x80 | (cp & 0x3F)));
        } else {
            out.push_back((char)(0xE0 | (cp >> 12)));
            out.push_back((char)(0x80 | ((cp >> 6) & 0x3F)));
            out.push_back((char)(0x80 | (cp & 0x3F)));
        }
    };

    for (unsigned char ch : in) {
        if (ch < 0x80) { out.push_back((char)ch); continue; }

        uint32_t cp = 0;
        if (ch == 0xA8) cp = 0x0401;
        else if (ch == 0xB8) cp = 0x0451;
        else if (ch >= 0xC0 && ch <= 0xDF) cp = 0x0410 + (ch - 0xC0);
        else if (ch >= 0xE0 && ch <= 0xFF) cp = 0x0430 + (ch - 0xE0);
        else {
            switch (ch) {
                case 0x82: cp = 0x201A; break;
                case 0x84: cp = 0x201E; break;
                case 0x85: cp = 0x2026; break;
                case 0x86: cp = 0x2020; break;
                case 0x87: cp = 0x2021; break;
                case 0x91: cp = 0x2018; break;
                case 0x92: cp = 0x2019; break;
                case 0x93: cp = 0x201C; break;
                case 0x94: cp = 0x201D; break;
                case 0x96: cp = 0x2013; break;
                case 0x97: cp = 0x2014; break;
                default:   cp = 0x003F; break;
            }
        }
        append_utf8(cp);
    }
    return out;
}

static bool meta_says_cp1251(const std::string& html) {
    std::string h = html;
    std::transform(h.begin(), h.end(), h.begin(),
                   [](unsigned char c){ return (char)std::tolower(c); });
    return (h.find("charset=windows-1251") != std::string::npos) ||
           (h.find("charset=cp1251") != std::string::npos) ||
           (h.find("charset=1251") != std::string::npos);
}

static std::string ensure_utf8_for_telegram(const std::string& s) {
    if (is_valid_utf8(s)) return s;
    std::string converted = cp1251_to_utf8(s);
    if (is_valid_utf8(converted)) return converted;

    std::string out;
    out.reserve(s.size());
    for (unsigned char c : s) out.push_back((c < 0x80) ? (char)c : '?');
    return out;
}

// ------------------------- CURL helpers -------------------------
static size_t write_callback(void* contents, size_t size, size_t nmemb, std::string* s) {
    s->append(static_cast<char*>(contents), size * nmemb);
    return size * nmemb;
}

static std::string curl_escape_value(const std::string& value) {
    CURL* curl = curl_easy_init();
    if (!curl) return value;
    char* encoded = curl_easy_escape(curl, value.c_str(), 0);
    std::string result = encoded ? encoded : "";
    if (encoded) curl_free(encoded);
    curl_easy_cleanup(curl);
    return result;
}

static bool curl_http_get(const std::string& url, std::string& out_body, std::string& out_err, long timeout_sec=15) {
    out_body.clear();
    out_err.clear();

    CURL* curl = curl_easy_init();
    if (!curl) { out_err = "Failed to initialize CURL"; return false; }

    curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &out_body);
    curl_easy_setopt(curl, CURLOPT_TIMEOUT, timeout_sec);
    curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
    curl_easy_setopt(curl, CURLOPT_USERAGENT, "madLENotify/1.5 (curl)");

    CURLcode res = curl_easy_perform(curl);
    if (res != CURLE_OK) {
        out_err = std::string("CURL error: ") + curl_easy_strerror(res);
        curl_easy_cleanup(curl);
        return false;
    }

    curl_easy_cleanup(curl);
    return true;
}

static bool curl_http_post_form(const std::string& url, const std::string& post_data,
                               std::string& out_body, std::string& out_err, long timeout_sec=15) {
    out_body.clear();
    out_err.clear();

    CURL* curl = curl_easy_init();
    if (!curl) { out_err = "Failed to initialize CURL"; return false; }

    struct curl_slist* headers = nullptr;
    headers = curl_slist_append(headers, "Content-Type: application/x-www-form-urlencoded");

    curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
    curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
    curl_easy_setopt(curl, CURLOPT_POST, 1L);
    curl_easy_setopt(curl, CURLOPT_POSTFIELDS, post_data.c_str());
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &out_body);
    curl_easy_setopt(curl, CURLOPT_TIMEOUT, timeout_sec);
    curl_easy_setopt(curl, CURLOPT_USERAGENT, "madLENotify/1.5 (curl)");

    CURLcode res = curl_easy_perform(curl);

    curl_slist_free_all(headers);
    curl_easy_cleanup(curl);

    if (res != CURLE_OK) {
        out_err = std::string("CURL error: ") + curl_easy_strerror(res);
        return false;
    }
    return true;
}

// ------------------------- Fetch planned_work HTML -------------------------
static std::string fetch_html(const std::string& url) {
    std::string response, err;
    bool ok = curl_http_get(url, response, err, 30);
    if (!ok) {
        log_line("Fetch failed: " + err);
        return "";
    }
    return response;
}

// ------------------------- Time/date helpers -------------------------
static std::string today_ymd() {
    time_t now = time(nullptr);
    tm* lt = localtime(&now);
    std::ostringstream ss;
    ss << (lt->tm_year + 1900) << "-"
       << std::setw(2) << std::setfill('0') << (lt->tm_mon + 1) << "-"
       << std::setw(2) << std::setfill('0') << lt->tm_mday;
    return ss.str();
}

static void load_daily_sent(const std::string& file, std::string& day, int& sent) {
    day.clear();
    sent = 0;
    std::ifstream f(file);
    if (!f) return;
    f >> day >> sent;
    if (!f) { day.clear(); sent = 0; }
}

static void save_daily_sent(const std::string& file, const std::string& day, int sent) {
    std::ofstream f(file, std::ios::trunc);
    f << day << " " << sent;
}

static bool parse_hhmm(const std::string& s, int& hh, int& mm) {
    hh = mm = 0;
    if (sscanf(s.c_str(), "%d:%d", &hh, &mm) != 2) return false;
    if (hh < 0 || hh > 23 || mm < 0 || mm > 59) return false;
    return true;
}

static int minutes_now_local() {
    time_t now = time(nullptr);
    tm* lt = localtime(&now);
    return lt->tm_hour * 60 + lt->tm_min;
}

static std::string dmy_dash_to_dot(std::string s) {
    for (char& c : s) if (c == '-') c = '.';
    return s;
}

static std::string date_dot_to_ddmm(const std::string& d) {
    int day=0, mon=0, year=0;
    if (sscanf(d.c_str(), "%d.%d.%d", &day, &mon, &year) != 3) return "";
    std::ostringstream ss;
    ss << std::setw(2) << std::setfill('0') << day
       << "." << std::setw(2) << std::setfill('0') << mon;
    return ss.str();
}

static bool parse_dt_dot(const std::string& date_dot, const std::string& time_hm, std::tm& out) {
    int d=0,m=0,y=0, hh=0, mm=0;
    if (sscanf(date_dot.c_str(), "%d.%d.%d", &d, &m, &y) != 3) return false;
    if (!time_hm.empty()) {
        if (sscanf(time_hm.c_str(), "%d:%d", &hh, &mm) != 2) return false;
    }
    std::tm t{};
    t.tm_mday = d;
    t.tm_mon  = m - 1;
    t.tm_year = y - 1900;
    t.tm_hour = hh;
    t.tm_min  = mm;
    t.tm_sec  = 0;
    t.tm_isdst = -1;
    out = t;
    return true;
}

static std::tm today_date_tm() {
    time_t now = time(nullptr);
    tm t = *localtime(&now);
    t.tm_hour = t.tm_min = t.tm_sec = 0;
    t.tm_isdst = -1;
    return t;
}

static int days_between_dates_local(const std::tm& a, const std::tm& b) {
    std::tm aa = a;
    std::tm bb = b;
    aa.tm_hour = aa.tm_min = aa.tm_sec = 0;
    bb.tm_hour = bb.tm_min = bb.tm_sec = 0;
    time_t ta = mktime(&aa);
    time_t tb = mktime(&bb);
    double diff = difftime(tb, ta);
    return (int)(diff / 86400.0);
}

// ------------------------- Config: defaults + compatibility -------------------------
static void ensure_config_defaults(json& cfg) {
    if (!cfg.contains("settlement")) cfg["settlement"] = "Пески";
    if (!cfg.contains("url_base")) cfg["url_base"] = "https://rosseti-lenenergo.ru/planned_work/";

    if (!cfg.contains("daily_limit")) cfg["daily_limit"] = 5;
    if (!cfg.contains("check_interval_sec")) cfg["check_interval_sec"] = 1800;

    if (!cfg.contains("alert_days")) cfg["alert_days"] = 3;
    if (!cfg.contains("send_time")) cfg["send_time"] = "09:00";
    if (!cfg.contains("test_mode")) cfg["test_mode"] = false;
    if (!cfg.contains("max_message_len")) cfg["max_message_len"] = 4096;

    if (!cfg.contains("default_message"))
        cfg["default_message"] = "⚡ {badge} {when} {time_range} — {address_short}{comment_opt}";

    if (!cfg.contains("api_port")) cfg["api_port"] = 8080;
    if (!cfg.contains("history_days")) cfg["history_days"] = 30;
    if (!cfg.contains("history_max_rows")) cfg["history_max_rows"] = 200;
    if (!cfg.contains("debug_telegram_url")) cfg["debug_telegram_url"] = true;

    // Telegram API base
    if (!cfg.contains("telegram_api_url") && cfg.contains("tg_api_url"))
        cfg["telegram_api_url"] = cfg["tg_api_url"];
    if (!cfg.contains("telegram_api_url"))
        cfg["telegram_api_url"] = "https://api.telegram.org";

    // Token compatibility
    if (!cfg.contains("telegram_bot_token") && cfg.contains("tg_bot_token"))
        cfg["telegram_bot_token"] = cfg["tg_bot_token"];
    if (!cfg.contains("telegram_bot_token"))
        cfg["telegram_bot_token"] = "";

    // Chat ids compatibility
    if (!cfg.contains("chat_ids")) {
        if (cfg.contains("tg_chat_id")) {
            cfg["chat_ids"] = json::array({ cfg["tg_chat_id"] });
        } else {
            cfg["chat_ids"] = json::array();
        }
    } else {
        // ensure array
        if (!cfg["chat_ids"].is_array()) {
            json arr = json::array();
            arr.push_back(cfg["chat_ids"]);
            cfg["chat_ids"] = arr;
        }
    }
}

static bool file_exists(const std::string& path) {
    std::ifstream f(path);
    return f.good();
}

static bool load_config_from_file(const std::string& path, json& out_cfg, std::string& out_err) {
    out_err.clear();
    std::ifstream f(path);
    if (!f.is_open()) {
        out_err = "Cannot open file: " + path;
        return false;
    }
    try {
        f >> out_cfg;
    } catch (const std::exception& e) {
        out_err = std::string("JSON parse error: ") + e.what();
        return false;
    }
    ensure_config_defaults(out_cfg);
    return true;
}

// ------------------------- URL planned_work builder -------------------------
static std::string build_planned_work_url(const json& cfg) {
    std::string base = cfg.value("url_base", "https://rosseti-lenenergo.ru/planned_work/");

    if (base.find('?') == std::string::npos) {
        base += "?reg=&date_start=&date_finish=&res=&";
    } else {
        if (base.back() != '&' && base.back() != '?') base += "&";
    }

    const std::string settlement = cfg.value("settlement", "Пески");
    const std::string city = curl_escape_value(settlement);

    if (base.find("city=") == std::string::npos) base += "city=" + city + "&";
    if (base.find("street=") == std::string::npos) base += "street=";

    return base;
}

// ------------------------- Outage + hashing -------------------------
struct Outage {
    std::string date_start_dot; // dd.mm.yyyy
    std::string time_start;     // HH:MM
    std::string date_end_dot;
    std::string time_end;
    std::string address;
    std::string comment;

    std::tm start_tm{};
    bool start_tm_ok = false;

    std::string key_string() const {
        return date_start_dot + "|" + time_start + "|" + date_end_dot + "|" + time_end + "|" + address + "|" + comment;
    }

    std::string hash64_hex() const {
        const uint64_t FNV_OFFSET = 1469598103934665603ull;
        const uint64_t FNV_PRIME  = 1099511628211ull;
        uint64_t h = FNV_OFFSET;
        std::string k = key_string();
        for (unsigned char c : k) { h ^= (uint64_t)c; h *= FNV_PRIME; }
        std::ostringstream ss;
        ss << std::hex << std::setfill('0') << std::setw(16) << h;
        return ss.str();
    }
};

// ------------------------- Parser helpers -------------------------
static bool contains_settlement(const std::string& row_text, const std::string& settlement) {
    if (settlement.empty() || row_text.empty()) return false;

    std::string lower_row = row_text;
    std::transform(lower_row.begin(), lower_row.end(), lower_row.begin(),
                   [](unsigned char c){ return (char)std::tolower(c); });

    std::string lower_settlement = settlement;
    std::transform(lower_settlement.begin(), lower_settlement.end(), lower_settlement.begin(),
                   [](unsigned char c){ return (char)std::tolower(c); });

    if (lower_row.find(lower_settlement) != std::string::npos) return true;

    std::vector<std::string> prefixes = {
        "д ", "дер ", "деревня ",
        "пос ", "пос. ", "посёлок ",
        "п ", "п. ",
        "с ", "село ",
        "г ", "город ",
        "рп ", "рабочий поселок ",
        "тер ", "тер. ", "территория ",
        "мкр ", "микрорайон ",
        "ул ", "улица ",
        "пер ", "переулок ",
        "пр ", "проспект ",
        "б-р ", "бульвар ",
        "ш ", "шоссе "
    };

    for (const auto& prefix : prefixes) {
        if (lower_row.find(prefix + lower_settlement) != std::string::npos) return true;
        if (lower_row.find(prefix + " " + lower_settlement) != std::string::npos) return true;
    }
    return false;
}

static bool looks_like_dmy_dash(const std::string& s) {
    return std::regex_match(s, std::regex(R"(\d{2}-\d{2}-\d{4})"));
}
static bool looks_like_hhmm(const std::string& s) {
    return std::regex_match(s, std::regex(R"(\d{1,2}:\d{2})"));
}

// ------------------------- Parser main -------------------------
static std::vector<Outage> parse_outages(const std::string& html_utf8, const std::string& settlement, bool debug=false) {
    std::vector<Outage> outages;

    std::regex tr_re(R"(<tr[^>]*>([\s\S]*?)</tr>)", std::regex::icase);
    std::regex td_re(R"(<td[^>]*>([\s\S]*?)</td>)", std::regex::icase);

    for (auto tr_it = std::sregex_iterator(html_utf8.begin(), html_utf8.end(), tr_re);
         tr_it != std::sregex_iterator(); ++tr_it) {

        std::string tr_body = (*tr_it)[1].str();
        if (tr_body.find("<th") != std::string::npos || tr_body.find("</th>") != std::string::npos) continue;

        std::vector<std::string> cells;
        for (auto td_it = std::sregex_iterator(tr_body.begin(), tr_body.end(), td_re);
             td_it != std::sregex_iterator(); ++td_it) {
            std::string cell = strip_tags((*td_it)[1].str());
            cell = remove_uuids(cell);
            cells.push_back(cell);
        }
        if (cells.empty()) continue;

        std::string row_text;
        for (auto& c : cells) if (!c.empty()) row_text += c + " ";
        row_text = collapse_spaces(remove_uuids(row_text));
        if (row_text.empty()) continue;

        if (!contains_settlement(row_text, settlement)) continue;

        Outage o;

        if (cells.size() >= 7 &&
            looks_like_dmy_dash(cells[3]) &&
            looks_like_hhmm(cells[4]) &&
            looks_like_dmy_dash(cells[5]) &&
            looks_like_hhmm(cells[6])) {
            o.date_start_dot = dmy_dash_to_dot(cells[3]);
            o.time_start = cells[4];
            o.date_end_dot = dmy_dash_to_dot(cells[5]);
            o.time_end = cells[6];
        } else {
            std::smatch m;
            std::regex re_date(R"(\b(\d{1,2}[.\-]\d{1,2}[.\-]\d{4})\b)");
            std::regex re_time(R"(\b(\d{1,2}:\d{2})\b)");

            std::string d1, d2, t1, t2;
            if (std::regex_search(row_text, m, re_date)) {
                d1 = dmy_dash_to_dot(m[1].str());
                std::string tail = row_text.substr((size_t)m.position(1) + (size_t)m.length(1));

                std::smatch m2;
                if (std::regex_search(tail, m2, re_time)) t1 = m2[1].str();
                if (std::regex_search(tail, m2, re_date)) {
                    d2 = dmy_dash_to_dot(m2[1].str());
                    std::string tail2 = tail.substr((size_t)m2.position(1) + (size_t)m2.length(1));
                    std::smatch m3;
                    if (std::regex_search(tail2, m3, re_time)) t2 = m3[1].str();
                }
            }

            o.date_start_dot = d1;
            o.time_start = t1;
            o.date_end_dot = d2;
            o.time_end = t2;
        }

        if (cells.size() >= 3 && !cells[2].empty()) o.address = cells[2];
        else o.address = row_text;

        if (cells.size() >= 10 && !cells[9].empty()) o.comment = cells[9];
        else o.comment.clear();

        std::tm tm_start{};
        if (parse_dt_dot(o.date_start_dot, o.time_start, tm_start)) {
            o.start_tm = tm_start;
            o.start_tm_ok = true;
        }
        if (!o.start_tm_ok) continue;

        outages.push_back(std::move(o));
    }

    if (debug) std::cout << "Total outages found: " << outages.size() << "\n";
    return outages;
}

// ------------------------- Address shorten -------------------------
static std::string address_shorten(std::string a) {
    a = collapse_spaces(a);
    a = std::regex_replace(a, std::regex(R"(Ленинградская область\s*)"), "");
    a = std::regex_replace(a, std::regex(R"(\s*р-н\s+\S+)"), "");
    a = std::regex_replace(a, std::regex(R"(\s*район\s+\S+)"), "");
    a = collapse_spaces(a);
    if (a.size() > 90) { a.resize(90); a += "..."; }
    return a;
}

// ------------------------- Template tags -------------------------
static std::string when_label(int days_left, const std::string& date_ddmm) {
    if (days_left == 0) return "СЕГОДНЯ";
    if (days_left == 1) return "ЗАВТРА";
    if (days_left >= 2 && days_left <= 9) {
        std::ostringstream ss; ss << "ЧЕРЕЗ " << days_left << " ДН.";
        return ss.str();
    }
    return date_ddmm.empty() ? "СКОРО" : date_ddmm;
}

static std::string badge_label(int days_left) {
    if (days_left == 0) return "🟥";
    if (days_left == 1) return "🟨";
    return "⬜";
}

static std::string time_range_ascii(const std::string& a, const std::string& b) {
    if (!a.empty() && !b.empty()) return a + "-" + b;
    if (!a.empty()) return a;
    if (!b.empty()) return b;
    return "";
}

static std::string format_message(const json& cfg, const Outage& o) {
    std::string tmpl = cfg.value("default_message",
        "⚡ {badge} {when} {time_range} — {address_short}{comment_opt}");

    std::tm today = today_date_tm();
    int days_left = days_between_dates_local(today, o.start_tm);

    std::string ddmm = date_dot_to_ddmm(o.date_start_dot);
    std::string when = when_label(days_left, ddmm);
    std::string badge = badge_label(days_left);

    std::string addr_short = address_shorten(o.address);
    std::string tr = time_range_ascii(o.time_start, o.time_end);

    std::string comment = collapse_spaces(o.comment);
    std::string comment_opt = comment.empty() ? "" : (". " + comment);

    std::string msg = tmpl;
    msg = replace_all(msg, "{badge}", badge);
    msg = replace_all(msg, "{when}", when);
    msg = replace_all(msg, "{days_left}", std::to_string(days_left));
    msg = replace_all(msg, "{date}", ddmm);
    msg = replace_all(msg, "{date_full}", o.date_start_dot);
    msg = replace_all(msg, "{time_start}", o.time_start);
    msg = replace_all(msg, "{time_end}", o.time_end);
    msg = replace_all(msg, "{time_range}", tr);
    msg = replace_all(msg, "{address}", o.address);
    msg = replace_all(msg, "{address_short}", addr_short);
    msg = replace_all(msg, "{comment}", comment);
    msg = replace_all(msg, "{comment_opt}", comment_opt);

    msg = remove_uuids(msg);
    msg = collapse_spaces(msg);

    int maxlen = cfg.value("max_message_len", 4096);
    if (maxlen < 40) maxlen = 40;
    if ((int)msg.size() > maxlen) {
        msg.resize((size_t)maxlen);
        msg = collapse_spaces(msg);
        if (msg.size() + 3 <= (size_t)maxlen) msg += "...";
    }
    return msg;
}

// ------------------------- Policy -------------------------
static bool is_within_alert_window(const Outage& o, int alert_days) {
    std::tm today = today_date_tm();
    int days_left = days_between_dates_local(today, o.start_tm);
    return days_left >= 0 && days_left <= alert_days;
}

static bool time_to_send_today(const json& cfg, bool test_mode_cli) {
    if (test_mode_cli) return true;
    if (cfg.value("test_mode", false)) return true;

    int hh=0, mm=0;
    std::string st = cfg.value("send_time", "09:00");
    if (!parse_hhmm(st, hh, mm)) return true;
    int nowm = minutes_now_local();
    int target = hh * 60 + mm;
    return nowm >= target;
}

// ------------------------- Persistence: sent_log.json -------------------------
static json load_sent_log() {
    std::ifstream f("sent_log.json");
    if (!f) return json::object();
    try {
        json j; f >> j;
        if (!j.is_object()) return json::object();
        return j;
    } catch (...) {
        return json::object();
    }
}

static void save_sent_log(const json& j) {
    std::ofstream("sent_log.json", std::ios::trunc) << j.dump(2);
}

static bool sent_today(json& sent_log, const std::string& day, const std::string& chat_id, const std::string& h) {
    if (!sent_log.contains(day)) return false;
    if (!sent_log[day].contains(chat_id)) return false;
    for (auto& x : sent_log[day][chat_id]) {
        if (x.is_string() && x.get<std::string>() == h) return true;
    }
    return false;
}

static void mark_sent_today(json& sent_log, const std::string& day, const std::string& chat_id, const std::string& h) {
    if (!sent_log.contains(day) || !sent_log[day].is_object()) sent_log[day] = json::object();
    if (!sent_log[day].contains(chat_id) || !sent_log[day][chat_id].is_array()) sent_log[day][chat_id] = json::array();
    sent_log[day][chat_id].push_back(h);
}

static void cleanup_sent_log(json& sent_log, int keep_days = 14) {
    if (!sent_log.is_object()) return;
    std::vector<std::string> keys;
    for (auto it = sent_log.begin(); it != sent_log.end(); ++it) {
        if (it.key().size() == 10) keys.push_back(it.key());
    }
    std::sort(keys.begin(), keys.end());
    if ((int)keys.size() <= keep_days) return;
    int drop = (int)keys.size() - keep_days;
    for (int i = 0; i < drop; ++i) sent_log.erase(keys[i]);
}

// ------------------------- Telegram: unified -------------------------
static std::string tg_api_base(const json& cfg) {
    std::string base = trim_ws(cfg.value("telegram_api_url", "https://api.telegram.org"));
    while (!base.empty() && base.back() == '/') base.pop_back();
    // If user mistakenly put /bot... in api_url, strip it
    auto p = base.find("/bot");
    if (p != std::string::npos) base = base.substr(0, p);
    return base;
}

static std::string tg_token(const json& cfg) {
    return trim_ws(cfg.value("telegram_bot_token", ""));
}

static std::string tg_token_preview(const std::string& token) {
    if (token.empty()) return "(empty)";
    if (token.size() <= 16) return token;
    return token.substr(0, 6) + "..." + token.substr(token.size()-6);
}

static std::string tg_url_method(const json& cfg, const std::string& method) {
    std::string token = tg_token(cfg);
    if (token.empty()) return "";
    return tg_api_base(cfg) + "/bot" + token + "/" + method;
}

static bool telegram_getMe(const json& cfg, std::string& out_err) {
    out_err.clear();
    std::string url = tg_url_method(cfg, "getMe");
    if (url.empty()) { out_err = "telegram_bot_token is empty"; return false; }

    if (cfg.value("debug_telegram_url", true)) log_line("Telegram URL: " + url);

    std::string body, err;
    if (!curl_http_get(url, body, err, 15)) { out_err = err; return false; }

    if (body.find("\"ok\":true") != std::string::npos) return true;
    out_err = "Telegram API error: " + body;
    return false;
}

static bool send_telegram(const json& cfg,
                          const std::string& chat_id,
                          const std::string& text_any,
                          std::string& out_err,
                          const std::string& parse_mode /* "" or "HTML" */) {
    out_err.clear();

    std::string token = tg_token(cfg);
    if (token.empty()) { out_err = "telegram_bot_token is empty"; return false; }

    std::string url = tg_url_method(cfg, "sendMessage");
    if (url.empty()) { out_err = "failed to build telegram sendMessage url"; return false; }

    if (cfg.value("debug_telegram_url", true)) log_line("Telegram URL: " + url);

    std::string msg = ensure_utf8_for_telegram(text_any);

    // IMPORTANT: escape both chat_id and text like curl -d does
    std::string post =
        "chat_id=" + curl_escape_value(chat_id) +
        "&text=" + curl_escape_value(msg) +
        "&disable_web_page_preview=true";

    if (!parse_mode.empty()) post += "&parse_mode=" + curl_escape_value(parse_mode);

    std::string body, err;
    if (!curl_http_post_form(url, post, body, err, 15)) { out_err = err; return false; }

    if (body.find("\"ok\":true") != std::string::npos) return true;
    out_err = "Telegram API error: " + body;
    return false;
}

// ------------------------- Test message -------------------------
static bool send_test_message(const json& cfg, const std::string& chat_id, std::string& out_err) {
    std::time_t now = std::time(nullptr);
    std::tm* local_time = std::localtime(&now);
    char time_buf[80];
    std::strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", local_time);

    std::string m =
        "madLENotify — Тестовое сообщение\n\n"
        "✅ Бот успешно запущен и готов к работе!\n"
        "🕒 Время сервера: " + std::string(time_buf) + "\n"
        "📍 Населенный пункт: " + cfg.value("settlement", "Пески") + "\n"
        "⏰ Период проверки: " + std::to_string(cfg.value("check_interval_sec", 1800) / 60) + " мин.\n"
        "🔔 Оповещение за: " + std::to_string(cfg.value("alert_days", 3)) + " дня\n";

    return send_telegram(cfg, chat_id, m, out_err, "");
}

// ------------------------- History table builder -------------------------
static std::string dt_line(const Outage& o, bool is_start) {
    const std::string& d = is_start ? o.date_start_dot : o.date_end_dot;
    const std::string& t = is_start ? o.time_start     : o.time_end;
    if (d.empty() && t.empty()) return "";
    if (d.empty()) return t;
    if (t.empty()) return d;
    return d + " " + t;
}

static std::vector<std::string> build_history_messages_html(const json& cfg,
                                                            const std::vector<Outage>& outages) {
    std::tm today = today_date_tm();
    int history_days = cfg.value("history_days", 30);
    int max_rows = cfg.value("history_max_rows", 200);

    std::vector<Outage> past;
    for (const auto& o : outages) {
        int days_left = days_between_dates_local(today, o.start_tm);
        if (days_left < 0 && (-days_left <= history_days)) past.push_back(o);
    }

    std::sort(past.begin(), past.end(), [](const Outage& a, const Outage& b) {
        std::tm aa = a.start_tm;
        std::tm bb = b.start_tm;
        return mktime(&aa) > mktime(&bb);
    });

    if ((int)past.size() > max_rows) past.resize((size_t)max_rows);

    if (past.empty()) {
        return { "🕘 Прошедшие отключения\n\nЗа последние " + std::to_string(history_days) + " дней отключений не найдено." };
    }

    struct Row { std::string a, b; };
    std::vector<Row> rows;
    for (const auto& o : past) rows.push_back({ dt_line(o, true), dt_line(o, false) });

    // fixed width for pre
    const int W = 19; // "dd.mm.yyyy hh:mm" = 16, + запас
    const int rows_per_msg = 55;

    std::vector<std::string> out;

    for (int i = 0; i < (int)rows.size(); i += rows_per_msg) {
        int j = std::min((int)rows.size(), i + rows_per_msg);

        std::ostringstream ss;
        ss << "🕘 Прошедшие отключения\n";
        ss << "<pre>";
        ss << std::left << std::setw(W) << "Начало работ" << "  " << std::setw(W) << "Окончание работ" << "\n";
        for (int k = i; k < j; ++k) {
            std::string a = html_escape(rows[k].a);
            std::string b = html_escape(rows[k].b);
            ss << std::left << std::setw(W) << a << "  " << std::setw(W) << b << "\n";
        }
        ss << "</pre>\n";
        ss << "События определяются по дате МСК; формат источника может меняться.";

        out.push_back(ss.str());
    }

    return out;
}

// ------------------------- Save last html -------------------------
static void save_last_html(const std::string& html_utf8) {
    std::ofstream("last.html", std::ios::trunc) << html_utf8;
}

// ------------------------- HTTP API -------------------------
static void start_api_thread(json& config, std::mutex& config_mtx) {
    std::thread([&config, &config_mtx]() {
        httplib::Server svr;

        svr.Get("/config", [&config, &config_mtx](const httplib::Request&, httplib::Response& res) {
            std::lock_guard<std::mutex> lk(config_mtx);
            res.set_content(config.dump(2), "application/json");
        });

        svr.Post("/config", [&config, &config_mtx](const httplib::Request& req, httplib::Response& res) {
            try {
                json new_cfg = json::parse(req.body);
                ensure_config_defaults(new_cfg);
                {
                    std::lock_guard<std::mutex> lk(config_mtx);
                    config = new_cfg;
                    std::ofstream("settings.json", std::ios::trunc) << config.dump(2);
                }
                log_line("Config updated via API -> settings.json");
                res.set_content("Updated", "text/plain");
            } catch (...) {
                res.status = 400;
                res.set_content("Invalid JSON", "text/plain");
            }
        });

        int port = 8080;
        {
            std::lock_guard<std::mutex> lk(config_mtx);
            port = config.value("api_port", 8080);
        }

        std::cout << "🌐 madLENotify API запущен на порту " << port << "\n";
        log_line("madLENotify API started on port " + std::to_string(port));

        bool ok = svr.listen("0.0.0.0", port);
        log_line(std::string("API listen result: ") + (ok ? "OK" : "FAIL"));
    }).detach();
}

// ------------------------- History mode -------------------------
static void run_history_mode(json& config, std::mutex& config_mtx) {
    json cfg;
    {
        std::lock_guard<std::mutex> lk(config_mtx);
        cfg = config;
    }

    std::cout << "madLENotify: Запуск режима истории...\n";

    // TG check
    {
        std::string tgerr;
        bool ok = telegram_getMe(cfg, tgerr);
        if (!ok) {
            log_line("Telegram getMe FAILED: " + tgerr);
            std::cerr << "Ошибка Telegram (getMe): " << tgerr << "\n";
            return;
        }
        log_line("Telegram getMe OK");
    }

    std::string url = build_planned_work_url(cfg);
    std::cout << "URL: " << url << "\n";

    std::string html = fetch_html(url);
    if (html.empty()) {
        std::cerr << "Ошибка: не удалось скачать страницу\n";
        const auto chat_ids = cfg["chat_ids"].get<std::vector<std::string>>();
        for (const auto& chat_id : chat_ids) {
            std::string err;
            send_telegram(cfg, chat_id,
                          "madLENotify: Не удалось скачать данные с сайта Ленэнерго\nURL: " + url,
                          err, "");
        }
        return;
    }

    if (meta_says_cp1251(html) || !is_valid_utf8(html)) {
        log_line("History: converting HTML to UTF-8 (cp1251 fallback)");
        html = cp1251_to_utf8(html);
    } else {
        html = ensure_utf8_for_telegram(html);
    }

    save_last_html(html);
    std::cout << "Страница скачана (" << html.size() << " байт)\n";

    std::string settlement = cfg.value("settlement", "Пески");
    auto outages = parse_outages(html, settlement, false);

    std::cout << "НАЙДЕНО ОТКЛЮЧЕНИЙ: " << outages.size() << "\n";

    auto msgs = build_history_messages_html(cfg, outages);
    const auto chat_ids = cfg["chat_ids"].get<std::vector<std::string>>();

    for (const auto& chat_id : chat_ids) {
        for (const auto& m : msgs) {
            std::string err;
            bool ok = send_telegram(cfg, chat_id, m, err, "HTML");
            if (!ok) {
                log_line("Failed to send history chunk to chat " + chat_id + ": " + err);
                std::cerr << "Ошибка отправки history: " << err << "\n";
                break;
            }
            std::this_thread::sleep_for(std::chrono::milliseconds(250));
        }
    }
}

// ------------------------- One cycle -------------------------
static void run_one_cycle(json& config,
                          std::mutex& config_mtx,
                          json& sent_log,
                          bool test_mode_cli,
                          bool force_cli,
                          bool debug_mode) {
    json cfg;
    {
        std::lock_guard<std::mutex> lk(config_mtx);
        cfg = config;
    }

    std::cout << "madLENotify: Начало проверки...\n";

    // TG check
    {
        std::string tgerr;
        bool ok = telegram_getMe(cfg, tgerr);
        if (!ok) {
            log_line("Telegram getMe FAILED: " + tgerr);
            std::cerr << "Ошибка Telegram (getMe): " << tgerr << "\n";
            return;
        }
        log_line("Telegram getMe OK");
    }

    std::string today = today_ymd();
    std::string last_day;
    int sent_count = 0;

    load_daily_sent("daily_sent.txt", last_day, sent_count);
    if (last_day != today) sent_count = 0;

    int daily_limit = cfg.value("daily_limit", 5);
    std::cout << "Отправлено сегодня: " << sent_count << " (лимит " << daily_limit << ")\n";

    std::string url = build_planned_work_url(cfg);
    std::cout << "URL: " << url << "\n";

    std::string html = fetch_html(url);
    if (html.empty()) {
        log_line("Fetch failed: empty HTML");
        std::cout << "Ошибка загрузки страницы\n";

        const auto chat_ids = cfg["chat_ids"].get<std::vector<std::string>>();
        for (const auto& chat_id : chat_ids) {
            std::string err;
            send_telegram(cfg, chat_id,
                          "madLENotify: Ошибка загрузки данных\nURL: " + url,
                          err, "");
        }
        return;
    }

    if (meta_says_cp1251(html) || !is_valid_utf8(html)) {
        log_line("Cycle: converting HTML to UTF-8 (cp1251 fallback)");
        html = cp1251_to_utf8(html);
    } else {
        html = ensure_utf8_for_telegram(html);
    }

    save_last_html(html);
    std::cout << "Страница скачана (размер: " << html.size() << " байт)\n";

    std::string settlement = cfg.value("settlement", "Пески");
    auto outages = parse_outages(html, settlement, debug_mode);

    int alert_days = cfg.value("alert_days", 3);
    std::vector<Outage> eligible;
    for (const auto& o : outages) if (is_within_alert_window(o, alert_days)) eligible.push_back(o);

    std::cout << "Найдено отключений: " << outages.size()
              << ", подходящих (в пределах " << alert_days << " дней): " << eligible.size() << "\n";

    if (!time_to_send_today(cfg, test_mode_cli)) {
        std::cout << "Не время для отправки (send_time=" << cfg.value("send_time", "09:00") << ")\n";
        log_line("Not time to send yet");
        return;
    }

    const auto chat_ids = cfg["chat_ids"].get<std::vector<std::string>>();

    for (const auto& o : eligible) {
        std::string h = o.hash64_hex();

        for (const auto& chat_id : chat_ids) {
            if (!test_mode_cli && sent_count >= daily_limit) {
                log_line("Daily limit reached");
                std::cout << "Достигнут дневной лимит сообщений\n";
                goto DONE_SENDING;
            }

            if (!force_cli && sent_today(sent_log, today, chat_id, h)) {
                std::cout << "Сообщение уже отправлено сегодня (hash: " << h.substr(0, 8) << "...)\n";
                continue;
            }

            std::string msg = format_message(cfg, o);
            std::cout << "Отправка -> chat " << chat_id << ": " << msg << "\n";

            std::string err;
            bool ok = send_telegram(cfg, chat_id, msg, err, "");
            if (!ok) {
                std::cout << "Ошибка отправки в chat " << chat_id << "\n";
                log_line("Send failed to chat " + chat_id + ": " + err);
                std::cerr << "DETAILS:\n" << err << "\n";
            } else {
                sent_count++;
                mark_sent_today(sent_log, today, chat_id, h);
                log_line("Sent to chat " + chat_id + ": " + msg);
                std::cout << "Сообщение отправлено в chat " << chat_id << "\n";
            }
        }
    }

DONE_SENDING:
    cleanup_sent_log(sent_log, 14);
    save_sent_log(sent_log);
    save_daily_sent("daily_sent.txt", today, sent_count);

    std::cout << "madLENotify: Проверка завершена\n";
    std::cout << "  Найдено отключений: " << outages.size() << "\n";
    std::cout << "  В пределах " << alert_days << " дней: " << eligible.size() << "\n";
    std::cout << "  Отправлено сообщений: " << sent_count << "\n";
}

// ------------------------- main -------------------------
int main(int argc, char** argv) {
    bool once = false;
    bool test_cli = false;
    bool force_cli = false;
    bool test_send = false;
    bool debug_mode = false;
    bool history_mode = false;
    bool tg_check = false;
    std::string config_path;

    for (int i = 1; i < argc; ++i) {
        std::string a = argv[i];
        if (a == "--man" || a == "--help") {
            std::cout << MAN_TEXT;
            return 0;
        } else if (a == "--once") {
            once = true;
        } else if (a == "--test") {
            test_cli = true;
        } else if (a == "--force") {
            force_cli = true;
        } else if (a == "--test-send") {
            test_send = true;
        } else if (a == "--debug") {
            debug_mode = true;
        } else if (a == "--history") {
            history_mode = true;
        } else if (a == "--tg-check") {
            tg_check = true;
        } else if (a == "--config") {
            if (i + 1 >= argc) {
                std::cerr << "Ошибка: --config требует путь к файлу\n";
                return 2;
            }
            config_path = argv[++i];
        } else {
            std::cerr << "Неизвестная опция: " << a << "\n\n" << MAN_TEXT;
            return 2;
        }
    }

    curl_global_init(CURL_GLOBAL_DEFAULT);

    std::cout << "madLENotify v1.5 — Уведомления об отключениях Ленэнерго\n";
    std::cout << "=====================================================\n";

    // choose config
    if (config_path.empty()) {
        if (file_exists("settings.json")) config_path = "settings.json";
        else config_path = "config.json";
    }

    json config;
    std::string err;
    if (!load_config_from_file(config_path, config, err)) {
        std::cerr << "Ошибка загрузки " << config_path << ": " << err << "\n";
        curl_global_cleanup();
        return 1;
    }

    // Print what we loaded
    std::string token = tg_token(config);
    std::cout << "Файл настроек: " << config_path << "\n";
    std::cout << "Населенный пункт: " << config.value("settlement", "Пески") << "\n";
    std::cout << "URL: " << build_planned_work_url(config) << "\n";
    std::cout << "Интервал проверки: " << config.value("check_interval_sec", 1800) << " сек.\n";
    std::cout << "Оповещение за: " << config.value("alert_days", 3) << " дня\n";
    std::cout << "History days: " << config.value("history_days", 30) << "\n";
    std::cout << "History max rows: " << config.value("history_max_rows", 200) << "\n";
    std::cout << "Telegram api: " << tg_api_base(config) << "\n";
    std::cout << "Telegram token preview: " << tg_token_preview(token) << " (len=" << token.size() << ")\n";
    if (!config["chat_ids"].is_array() || config["chat_ids"].empty()) {
        std::cout << "⚠ chat_ids пустой! Добавь chat_ids: [\"-100...\"] или tg_chat_id\n";
    } else {
        std::cout << "chat_ids: " << config["chat_ids"].dump() << "\n";
    }

    if (tg_check) {
        std::string e;
        bool ok = telegram_getMe(config, e);
        if (ok) {
            std::cout << "Telegram getMe: OK\n";
            log_line("Telegram getMe: OK");
            curl_global_cleanup();
            return 0;
        }
        std::cerr << "Telegram getMe: FAIL: " << e << "\n";
        log_line("Telegram getMe: FAIL: " + e);
        curl_global_cleanup();
        return 1;
    }

    if (test_send) {
        std::cout << "\nОтправка тестового сообщения...\n";

        std::string e;
        if (!telegram_getMe(config, e)) {
            std::cerr << "Ошибка Telegram (getMe): " << e << "\n";
            log_line("Telegram getMe FAILED: " + e);
            curl_global_cleanup();
            return 1;
        }

        const auto chat_ids = config["chat_ids"].get<std::vector<std::string>>();
        bool any_sent = false;
        for (const auto& chat_id : chat_ids) {
            std::string se;
            bool ok = send_test_message(config, chat_id, se);
            if (ok) {
                std::cout << "Тестовое сообщение отправлено в chat " << chat_id << "\n";
                log_line("Test message sent to chat " + chat_id);
                any_sent = true;
            } else {
                std::cerr << "Ошибка отправки в chat " << chat_id << ": " << se << "\n";
                log_line("Failed to send test message to chat " + chat_id + ": " + se);
            }
        }

        curl_global_cleanup();
        return any_sent ? 0 : 1;
    }

    if (history_mode) {
        std::mutex config_mtx;
        run_history_mode(config, config_mtx);
        curl_global_cleanup();
        return 0;
    }

    std::mutex config_mtx;
    start_api_thread(config, config_mtx);

    std::cout << "\nКонфигурация загружена\n";

    json sent_log = load_sent_log();

    while (true) {
        std::cout << "\n" << std::string(50, '=') << "\n";
        run_one_cycle(config, config_mtx, sent_log, test_cli, force_cli, debug_mode);

        if (once) {
            std::cout << "\nЗавершено (--once)\n";
            break;
        }

        json cfg;
        {
            std::lock_guard<std::mutex> lk(config_mtx);
            cfg = config;
        }
        int sleep_sec = cfg.value("check_interval_sec", 1800);
        std::cout << "\nОжидание " << sleep_sec << " секунд...\n";
        std::this_thread::sleep_for(std::chrono::seconds(sleep_sec));
    }

    curl_global_cleanup();
    std::cout << "\nmadLENotify завершает работу\n";
    return 0;
}

 

 🧷 Запуск как systemd-сервис

Чтобы демон работал постоянно и автоматически стартовал после перезагрузки, оформим его как systemd unit 🧩.

📄 Создаём юнит

Обычно кладём файл в /etc/systemd/system/lenotify.service. Внутри задаём путь к бинарнику, рабочую директорию, перезапуск и вывод в журнал.

[Unit]
Description=LENotify - Telegram notifications for Leningrad power outages
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/root
Environment=TZ=Europe/Moscow
ExecStart=/root/lenotify
Restart=always
RestartSec=30
StandardOutput=journal
StandardError=journal
SyslogIdentifier=lenotify

# Ограничения ресурсов
MemoryMax=512M
CPUQuota=50%

[Install]
WantedBy=multi-user.target

 

✅ Включаем и запускаем

🚀 Команды включения юнита и т. п.

 

# Перезагружаем systemd
sudo systemctl daemon-reload

# Включаем автозапуск
sudo systemctl enable lenotify

# Запускаем сервис
sudo systemctl start lenotify

# Проверяем статус
sudo systemctl status lenotify

# Смотрим логи
sudo journalctl -u lenotify -f

 

🛠 Полезные команды

Когда сервис живёт в systemd, самое полезное — смотреть статус и журнал. Плюс иногда нужно перезагрузить конфиги systemd после правок.

# Остановить сервис
sudo systemctl stop lenotify

# Перезапустить сервис
sudo systemctl restart lenotify

# Проверить логи
sudo journalctl -u lenotify -n 50

# Следить за логами в реальном времени
sudo journalctl -u lenotify -f

# Проверить, запущен ли
sudo systemctl is-active lenotify

🧪 Режимы запуска и отладка

  • 🔁 ./lenotify — обычный режим: вечный демон, периодически проверяет и шлёт.
  • 🕐 ./lenotify --once — один цикл проверки и выход (удобно для отладки).
  • 🧪 ./lenotify --test --force --once — отладка “в лоб”: игнорирует send_time и историю отправок.
  • 📨 ./lenotify --test-send — тестовое сообщение (проверка Telegram).
  • 🧾 ./lenotify --history — отправка истории прошедших отключений.
  • 🤖 ./lenotify --tg-check — проверка токена через Telegram getMe.
  • 🪵 ./lenotify --debug — более подробный вывод по парсингу.

📌 Советы по эксплуатации

  • 🔒 Не публикуй токен бота. Если засветился — перевыпусти в @BotFather.
  • 🧾 Проверь, что chat_ids заполнен (например, "-100..." для каналов/супергрупп).
  • 🕘 Убедись, что TZ на сервере корректный (или задай Environment=TZ=Europe/Moscow в юните).
  • 🧰 Если что-то “не шлёт”, сначала смотри журнал systemd — там обычно всё видно.

🏁 Итого

madLENotify — простой, понятный и автономный способ получать уведомления об отключениях электричества в Telegram. Он живёт как сервис, пишет логи, умеет историю и не превращает чат в мусорку благодаря учёту отправок 🧯.