scada:events

Розбіжності

Тут показані розбіжності між вибраною ревізією та поточною версією сторінки.

Посилання на цей список змін

Порівняння попередніх версій Попередня ревізія
Наступна ревізія
Попередня ревізія
scada:events [2026/08/28 10:49] scadascada:events [2026/08/28 10:58] (поточний) scada
Рядок 5: Рядок 5:
 Служба логування подій керування (вхід/вихід операторів, запис тегів) для проєкту **UA_Energy_Demo_23_04**. Служба логування подій керування (вхід/вихід операторів, запис тегів) для проєкту **UA_Energy_Demo_23_04**.
  
-Складається з двох частин: +Складається з трьох частин: 
-  * **mwj_audit_service.py** — Python-скрипт, сканує журнал подій AlarmServer і віддає їх у JSON на ``127.0.0.1:5005/events``. Запускається через Task Scheduler.+  * **mwj_audit_service.py** — Python-скрипт, сканує журнал подій AlarmServer і віддає їх у JSON на ``127.0.0.1:5005/events``. Запускається автоматично через Task Scheduler.
   * **mwj_events.aspx** — сторінка на WebHMI-сервері, яка проксіює цей JSON у браузер клієнта.   * **mwj_events.aspx** — сторінка на WebHMI-сервері, яка проксіює цей JSON у браузер клієнта.
 +  * **mwj_rawevents_patch.js** — JS-патч, який підтягує ці події в AlarmViewer (Events view) і показує їх поряд зі стандартними подіями.
  
-===== 1. Python-скрипт =====+===== 1. Розміщення mwj_audit_service.py =====
  
-Розмістити ``mwj_audit_service.py`` на сервері (наприклад, ``C:\python_portable\mwj_audit_service.py``) і зареєструвати запуск через Task Scheduler (див. інструкцію встановлення Python).+Скопіювати скрипт у папку проєкту:
  
-Перед запуском **замінити** в коді шлях до журналу під конкретний проєкт:+<code> 
 +C:\ProgramData\Schneider Electric\Power Operation\v2024\User\UA_Energy_Demo_23_04\mwj_audit_service.py 
 +</code> 
 + 
 +Перед запуском **замінити** ``JOURNAL_DIR`` під конкретний проєкт (та за потреби ``PORT``, якщо 5005 зайнятий):
  
 <code python> <code python>
-JOURNAL_DIR = r"C:\ProgramData\Schneider Electric\Power Operation\v2024\Data\UA_Energy_Demo_23_04\c1.AlarmServer1\Database\Journal\JnI00000"+import re 
 +import os 
 +import struct 
 +import datetime 
 +import json 
 +import threading 
 +import time 
 +from http.server import HTTPServer, BaseHTTPRequestHandler 
 + 
 +JOURNAL_DIR = r"C:\ProgramData\Schneider Electric\Power Operation\v2024\Data\UA_Energy_Demo_23_04\c1.AlarmServer1\Database\Journal\JnI00000" #шлях до журналу подій хард клієнта
 PORT = 5005 PORT = 5005
 DAYS_BACK = 7 DAYS_BACK = 7
 POLL_SEC = 1 POLL_SEC = 1
 +CORS_ORIGIN = "*"
 +
 +def filetime_to_dt(raw8: bytes):
 +    ft = struct.unpack('<Q', raw8)[0]
 +    if ft == 0:
 +        return None
 +    microseconds = ft // 10
 +    return datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) + \
 +           datetime.timedelta(microseconds=microseconds)
 +def parse_mwj(path: str, since: datetime.datetime) -> list:
 +    events = []
 +    try:
 +        data = open(path, 'rb').read()
 +    except Exception:
 +        return events
 +    ext_pat = 'ExternalEvent'.encode('utf-16-le')
 +    kernel_pat = 'Kernel,367,S"'.encode('utf-16-le')
 +    pos = 0
 +    while True:
 +        pos = data.find(ext_pat, pos)
 +        if pos == -1:
 +            break
 +        ts_bytes = data[pos - 8 : pos]
 +        dt = filetime_to_dt(ts_bytes) if len(ts_bytes) == 8 else None
 +
 +        if dt and dt < since:
 +            pos += len(ext_pat)
 +            continue
 +        k_pos = data.find(kernel_pat, pos, pos + 512)
 +        msg  = ''
 +        user = ''
 +        if k_pos != -1:
 +            str_start = k_pos + len(kernel_pat)
 +            str_end = data.find(b'\x22\x00', str_start)
 +            if str_end != -1:
 +                try:
 +                    msg = data[str_start:str_end].decode('utf-16-le')
 +                except UnicodeDecodeError:
 +                    pass
 +                after = str_end + 4
 +                user_match = re.search(b'(?:[\x21-\x7e]\x00){2,}', data[after : after + 128])
 +                if user_match:
 +                    candidate = user_match.group().decode('utf-16-le')
 +                    if not any(candidate.startswith(x) for x in ('Kernel', 'DBServer', 'System', 'Digital', 'External')):
 +                        user = candidate
 +        if msg:
 +            event_type = classify(msg)
 +            if event_type:
 +                events.append({
 +                    'id':        f"{path}:{pos}",
 +                    'timestamp': dt.isoformat() if dt else '',
 +                    'type':      event_type,
 +                    'message':   msg,
 +                    'user':      user,
 +                })
 +
 +        pos += len(ext_pat)
 +    return events
 +def classify(msg: str) -> str | None:
 +    if 'Tag Write Operation' in msg:
 +        return 'TagWrite'
 +    if 'logged in' in msg or 'Logged on' in msg:
 +        return 'Login'
 +    if 'logged out' in msg or 'Logged off' in msg:
 +        return 'Logout'
 +    return None
 +_cache_lock  = threading.Lock()
 +_events_cache: list = []
 +def scan_all():
 +    since = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=DAYS_BACK)
 +    result = []
 +    if not os.path.isdir(JOURNAL_DIR):
 +        print(f"[WARN] Journal dir not found: {JOURNAL_DIR}")
 +        return
 +    for fname in sorted(os.listdir(JOURNAL_DIR)):
 +        if not fname.upper().endswith('.MWJ'):
 +            continue
 +        full = os.path.join(JOURNAL_DIR, fname)
 +        try:
 +            mtime = datetime.datetime.fromtimestamp(
 +                os.path.getmtime(full), tz=datetime.timezone.utc)
 +        except OSError:
 +            continue
 +        if mtime < since:
 +            continue
 +        result.extend(parse_mwj(full, since))
 +    seen = set()
 +    unique = []
 +    for e in sorted(result, key=lambda x: x['timestamp'], reverse=True):
 +        if e['id'] not in seen:
 +            seen.add(e['id'])
 +            unique.append(e)
 +    with _cache_lock:
 +        global _events_cache
 +        _events_cache = unique
 +    print(f"[{datetime.datetime.now():%H:%M:%S}] Scanned {JOURNAL_DIR}: {len(unique)} events")
 +def scanner_thread():
 +    while True:
 +        try:
 +            scan_all()
 +        except Exception as e:
 +            print(f"[ERROR] Scanner: {e}")
 +        time.sleep(POLL_SEC)
 +class Handler(BaseHTTPRequestHandler):
 +    def log_message(self, fmt, *args):
 +        pass  # suppress default access log
 +    def send_cors(self):
 +        self.send_header('Access-Control-Allow-Origin', CORS_ORIGIN)
 +        self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
 +        self.send_header('Access-Control-Allow-Headers', 'Content-Type')
 +    def do_OPTIONS(self):
 +        self.send_response(204)
 +        self.send_cors()
 +        self.end_headers()
 +    def do_GET(self):
 +        path = self.path.split('?')[0]
 +        if path == '/events':
 +            with _cache_lock:
 +                data = json.dumps(_events_cache, ensure_ascii=False).encode('utf-8')
 +            self.send_response(200)
 +            self.send_header('Content-Type', 'application/json; charset=utf-8')
 +            self.send_header('Content-Length', str(len(data)))
 +            self.send_cors()
 +            self.end_headers()
 +            self.wfile.write(data)
 +        elif path == '/health':
 +            body = b'OK'
 +            self.send_response(200)
 +            self.send_cors()
 +            self.send_header('Content-Length', '2')
 +            self.end_headers()
 +            self.wfile.write(body)
 +        else:
 +            self.send_response(404)
 +            self.end_headers()
 +if __name__ == '__main__':
 +    print(f"Starting MWJ Audit Service on port {PORT}")
 +    scan_all()
 +    t = threading.Thread(target=scanner_thread, daemon=True)
 +    t.start()
 +    server = HTTPServer(('127.0.0.1', PORT), Handler)
 +    print(f"Listening on http://127.0.0.1:{PORT}/events")
 +    try:
 +        server.serve_forever()
 +    except KeyboardInterrupt:
 +        print("Stopped.")
 </code> </code>
  
-  * ``UA_Energy_Demo_23_04`` — замінити на назву поточного проєкту (частина шляху до Journal). +**Що замінити:** тільки рядок ``JOURNAL_DIR = r"...UA_Energy_Demo_23_04..."`` — підставити фактичну назву проєкту у шлях до Journal.
-  * ``PORT`` — залишити 5005, якщо не зайнятий іншим сервісом.+
  
-===== 2. ASPX-міст =====+===== 2. Реєстрація завдання в Task Scheduler =====
  
-Файл ``mwj_events.aspx`` вставити за шляхом:+Створити нове завдання (Create Task, не Basic Task) з іменем **logging**. 
 + 
 +== General == 
 +  * Security options: **Run whether user is logged on or not** 
 +  * Увімкнути **Run with highest privileges** 
 +  * Configure for: відповідно до ОС сервера 
 + 
 +== Triggers == 
 +  * New → Begin the task: **At startup** 
 +  * Status: Enabled 
 + 
 +== Actions == 
 +  * New → Action: **Start a program** 
 +  * Program/script: 
 +<code> 
 +C:\python_portable\python.exe 
 +</code> 
 +  * Add arguments: 
 +<code> 
 +mwj_audit_service.py 
 +</code> 
 +  * Start in (обов'язково!): 
 +<code> 
 +C:\ProgramData\Schneider Electric\Power Operation\v2024\User\UA_Energy_Demo_23_04 
 +</code> 
 +(поле "Start in" вказує на папку, куди поклали скрипт у п.1 — без цього скрипт може не знайти сам себе при запуску не з тієї директорії) 
 + 
 +== Conditions == 
 +  * Power: увімкнути **Start the task only if the computer is on AC power** та **Stop if the computer switches to battery power** 
 +  * Idle: не використовувати 
 +  * Network: не обмежувати 
 + 
 +Зберегти завдання (OK), при потребі ввести пароль облікового запису Administrator. 
 + 
 +**Перевірка:** запустити завдання вручну (Run), відкрити на сервері ``http://127.0.0.1:5005/health`` — має повернути ``OK``, і ``http://127.0.0.1:5005/events`` — має повернути масив JSON. 
 + 
 +===== 3. Розміщення mwj_events.aspx ===== 
 + 
 +Файл вставити за шляхом:
  
 <code> <code>
Рядок 72: Рядок 269:
  
 **Перевірка:** ``https://<адреса_webhmi>/mwj_events.aspx`` має віддавати той самий JSON, що й ``http://127.0.0.1:5005/events`` на сервері. **Перевірка:** ``https://<адреса_webhmi>/mwj_events.aspx`` має віддавати той самий JSON, що й ``http://127.0.0.1:5005/events`` на сервері.
 +
 +===== 4. Розміщення mwj_rawevents_patch.js =====
 +
 +Скопіювати скрипт у папку:
 +
 +<code>
 +C:\Program Files (x86)\Schneider Electric\Power Operation\v2024\Applications\Web\SystemDataService\Content\Applications\Alarms\Scripts\Scripts\mwj_rawevents_patch.js
 +</code>
 +
 +Далі відкрити файл (без змін розширення/типу, редагувати як текстовий):
 +
 +<code>
 +C:\Program Files (x86)\Schneider Electric\Power Operation\v2024\Applications\Web\SystemDataService\Content\Applications\Alarms\Scripts\Alarms.Script.Shared
 +</code>
 +
 +і додати в кінець списку рядок:
 +
 +<code>
 +Scripts\mwj_rawevents_patch.js
 +</code>
 +
 +Це підключає патч до AlarmViewer, щоб він підвантажив ``mwj_rawevents_patch.js`` разом з іншими скриптами модуля Alarms.
 +
 +Повний код патча:
 +
 +<code javascript>
 +(function(){
 +    'use strict';
 +    var AUDIT_API_URL = '/WebHmi/mwj_events.aspx';
 +    var POLLING_INTERVAL_MS = 5000;
 +    var MWJ_SOURCE_ID = '<System>.Система';
 +    var MWJ_PRIORITY = 0;
 +    var _mwjCache = [];
 +    var _mwjIds = {};
 +    var _lastRawStr = '';
 +    var _lastNativeData = [];
 +
 +    function fetchMwj(){
 +        fetch(AUDIT_API_URL)
 +            .then(function(r){return r.json();})
 +            .then(function(events){
 +                if(!Array.isArray(events)) return;
 +                var str = JSON.stringify(events);
 +                if(str === _lastRawStr) return;
 +                _lastRawStr = str;
 +                var prevIds = _mwjIds;
 +                _mwjIds = {};
 +                _mwjCache = events.map(toRawEvent);
 +                _mwjCache.forEach(function(e){_mwjIds[e.Id] = true;});
 +                var newEvents = _mwjCache.filter(function(e){return !prevIds[e.Id];});
 +                if(newEvents.length > 0) pushNewMwjToView(newEvents);
 +            })
 +            .catch(function(e){ console.warn('[MWJ] fetch error:', e); });
 +    }
 +    function extractUser(msg){
 +        var m = msg.match(/(?:^User|by User)\s+([^\s\.]+)/i);
 +        return m ? m[1] : '';
 +    }
 +    function extractTag(msg){
 +        var m = msg.match(/wrote to tags:\s*([^@\n]+)/i);
 +        if(!m) return msg;
 +        return m[1].replace(/\.\s*$/, '').trim();
 +    }
 +    function toRawEvent(evt){
 +        var ts  = evt.timestamp ? new Date(evt.timestamp).getTime() : Date.now();
 +        var msg = evt.message || '';
 +        var user = extractUser(msg);
 +        var tagName = '';
 +        var effect = '';
 +        var cause = '';
 +
 +        if(evt.type === 'Login'){
 +            tagName = user;
 +            effect = 'Вхід у систему';
 +            cause = 'Вхід користувача ' + user;
 +        }
 +        else if(evt.type === 'Logout'){
 +            tagName = user;
 +            effect = 'Вихід із системи';
 +            cause = 'Вихід користувача ' + user;
 +
 +        }
 +        else if(evt.type === 'TagWrite'){
 +            var tag = extractTag(msg);
 +            tagName = 'Користувач ' + user + ' успішно записав тег';
 +            effect = 'Запис тегу';
 +            cause = tag;
 +
 +        }
 +        else{
 +            tagName   = msg;
 +            effect  = 'Системна подія';
 +            cause = msg;
 +        }
 +        return{
 +            Id: 'MWJ_' + evt.id,
 +            TimestampUtc: '/Date(' + ts + ')/',
 +            StartTimeUtc: '/Date(' + ts + ')/',
 +            Source:{
 +                SourceId: MWJ_SOURCE_ID,
 +                DisplayName: MWJ_SOURCE_ID,
 +                IanaTimeZoneName: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
 +            },
 +            Cause: cause,
 +            CauseValue: user,
 +            Effect: effect,
 +            EffectValue: effect,
 +            Name: tagName,
 +            DisplayEffectValue: tagName,
 +            DisplayType: 'Подія',
 +            EventType: 4,
 +            Priority: MWJ_PRIORITY,
 +            TimeQuality: 0, TimeQualityMicroseconds: 0,
 +            Custom1:'', Custom3:'', Custom4:'', Custom5:'', Custom6:'', Custom7:'', Custom8:'',
 +            IsActive: false, IsAcknowledged: true,
 +            IsDisabled: false, IsShelved: false, IsDeleted: false,
 +            _isMwj: true
 +        };
 +    }
 +    function pushNewMwjToView(newEvents){
 +        var App = window.AlarmViewer &&
 +                  window.AlarmViewer.Models &&
 +                  window.AlarmViewer.Models.Application;
 +        if(!App) return;
 +        var AlarmViews = IonDataApi.AlarmViews;
 +        if(App.ViewModeCurrent !== AlarmViews.ViewModeEvents) return;
 +        var view = App.ActiveView;
 +        if(!view || typeof view.UpdateViewWithChanges !== 'function') return;
 +        view.UpdateViewWithChanges(newEvents, []);
 +    }
 +    function mergeWithMwj(nativeData){
 +        var nativeIds = {};
 +        nativeData.forEach(function(e){if(e && e.Id) nativeIds[e.Id] = true;});
 +        var result = nativeData.slice();
 +        _mwjCache.forEach(function(mwj){
 +            if(!nativeIds[mwj.Id]) result.push(mwj);
 +        });
 +        return result;
 +    }
 +    function waitForApp(cb){
 +        var n = 0;
 +        var t = setInterval(function(){
 +            n++;
 +            var App = window.AlarmViewer &&
 +                      window.AlarmViewer.Models &&
 +                      window.AlarmViewer.Models.Application;
 +            if(App &&
 +               typeof App.RenderFreshData             === 'function' &&
 +               typeof App.RenderIncrementalUpdateData === 'function'){
 +                clearInterval(t);
 +                cb(App);
 +            }
 +            if(n > 300){ clearInterval(t); console.warn('[MWJ] Application не знайдено за 30с'); }
 +        }, 100);
 +    }
 +    function applyPatch(App){
 +        var AlarmViews = IonDataApi.AlarmViews;
 +
 +        var _origFresh = App.RenderFreshData;
 +        App.RenderFreshData = function(isValid, viewMode, data){
 +            if(viewMode === AlarmViews.ViewModeEvents && Array.isArray(data)){
 +                _lastNativeData = data.filter(function(e){return !e._isMwj;});
 +                data = mergeWithMwj(_lastNativeData);
 +            }
 +            return _origFresh.call(this, isValid, viewMode, data);
 +        };
 +        var _origIncr = App.RenderIncrementalUpdateData;
 +        App.RenderIncrementalUpdateData = function(viewMode, added, removed){
 +            if(viewMode === AlarmViews.ViewModeEvents){
 +                if(Array.isArray(removed)){
 +                    removed = removed.filter(function(e){return !_mwjIds[e.Id];});
 +                }
 +                if(Array.isArray(added)){
 +                    added.forEach(function(item){
 +                        if(!item._isMwj){
 +                            var found = false;
 +                            for(var i = 0; i < _lastNativeData.length; i++){
 +                                if(_lastNativeData[i].Id === item.Id){
 +                                    _lastNativeData[i] = item; found = true; break;
 +                                }
 +                            }
 +                            if(!found) _lastNativeData.push(item);
 +                        }
 +                    });
 +                }
 +            }
 +            return _origIncr.call(this, viewMode, added, removed);
 +        };
 +    }
 +    fetchMwj();
 +    setInterval(fetchMwj, POLLING_INTERVAL_MS);
 +    if(document.readyState === 'loading'){
 +        document.addEventListener('DOMContentLoaded', function(){ waitForApp(applyPatch); });
 +    }
 +    else{
 +        waitForApp(applyPatch);
 +    }
 +})();
 +</code>
 +
 +**Перевірка:** відкрити AlarmViewer → вкладку Events, після входу/виходу оператора або запису тегу — подія має з'явитись у списку протягом ~5 секунд (``POLLING_INTERVAL_MS``).
 +
 +===== 5. Типові проблеми =====
 +
 +^ Симптом ^ Ймовірна причина ^ Рішення ^
 +| ``/health`` не відповідає | Завдання Task Scheduler не запустилось / скрипт впав | Перевірити журнал завдання (History), запустити скрипт вручну через ``python_portable\python.exe`` для перегляду traceback |
 +| ``/events`` повертає порожній масив | Невірний ``JOURNAL_DIR`` для проєкту, або ``DAYS_BACK`` замалий | Перевірити фактичний шлях журналу проєкту, звірити назву проєкту в шляху |
 +| ASPX повертає ``{"error": ...}`` | Python-сервіс не на тому ж сервері, або порт закритий фаєрволом | Перевірити, що IIS/WebHMI і скрипт на одній машині; дозволити 127.0.0.1:5005 у фаєрволі |
 +| Події не з'являються в AlarmViewer | ``mwj_rawevents_patch.js`` не підключено в ``Alarms.Script.Shared`` | Перевірити, що рядок ``Scripts\mwj_rawevents_patch.js`` доданий у файл і немає помилок у шляху |
 +| Завдання не стартує при перезавантаженні | Не увімкнено ``Run whether user is logged on or not`` або немає прав | Перевірити Security options і ``Run with highest privileges`` |
  • scada/events.1787903354.txt.gz
  • Востаннє змінено: 2026/08/28 10:49
  • повз scada