scada:events

Розбіжності

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

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

Наступна ревізія
Попередня ревізія
scada:events [2026/08/28 10:31] – створено scadascada:events [2026/08/28 10:58] (поточний) scada
Рядок 1: Рядок 1:
-sad+====== Логування подій керування (MWJ Audit Service) ====== 
 + 
 +===== Опис ===== 
 + 
 +Служба логування подій керування (вхід/вихід операторів, запис тегів) для проєкту **UA_Energy_Demo_23_04**. 
 + 
 +Складається з трьох частин: 
 +  * **mwj_audit_service.py** — Python-скрипт, сканує журнал подій AlarmServer і віддає їх у JSON на ``127.0.0.1:5005/events``. Запускається автоматично через Task Scheduler. 
 +  * **mwj_events.aspx** — сторінка на WebHMI-сервері, яка проксіює цей JSON у браузер клієнта. 
 +  * **mwj_rawevents_patch.js** — JS-патч, який підтягує ці події в AlarmViewer (Events view) і показує їх поряд зі стандартними подіями. 
 + 
 +===== 1. Розміщення mwj_audit_service.py ===== 
 + 
 +Скопіювати скрипт у папку проєкту: 
 + 
 +<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> 
 +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 
 +DAYS_BACK = 7 
 +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> 
 + 
 +**Що замінити:** тільки рядок ``JOURNAL_DIR = r"...UA_Energy_Demo_23_04..."`` — підставити фактичну назву проєкту у шлях до Journal. 
 + 
 +===== 2. Реєстрація завдання в Task Scheduler ===== 
 + 
 +Створити нове завдання (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> 
 +C:\Program Files (x86)\Schneider Electric\Power Operation\v2024\Applications\Web\WebHmi 
 +</code> 
 + 
 +Код без змін (порт 5005 має співпадати з портом у Python-скрипті): 
 + 
 +<code aspx> 
 +<%@ Page Language="C#" AutoEventWireup="true" %> 
 +<%@ Import Namespace="System.Net" %> 
 +<%@ Import Namespace="System.IO" %> 
 +<script runat="server"> 
 +    protected void Page_Load(object sender, EventArgs e) 
 +    { 
 +        Response.Clear(); 
 +        Response.ContentType = "application/json; charset=utf-8"; 
 +        Response.AppendHeader("Access-Control-Allow-Origin", "*"); 
 + 
 +        try 
 +        { 
 +            HttpWebRequest request = (HttpWebRequest)WebRequest.Create("http://127.0.0.1:5005/events"); 
 +            request.Method = "GET"; 
 +            request.Timeout = 4000; 
 + 
 +            using (HttpWebResponse response = (HttpWebResponse)request.GetResponse()) 
 +            { 
 +                using (StreamReader reader = new StreamReader(response.GetResponseStream())) 
 +                { 
 +                    string json = reader.ReadToEnd(); 
 +                    Response.Write(json); 
 +                } 
 +            } 
 +        } 
 +        catch (Exception ex) 
 +        { 
 +            Response.StatusCode = 500; 
 +            Response.Write("{\"error\": \"" + ex.Message.Replace("\"", "\\\"") + "\"}"); 
 +        } 
 +        Response.End(); 
 +    } 
 +</script> 
 +</code> 
 + 
 +**Перевірка:** ``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.1787902283.txt.gz
  • Востаннє змінено: 2026/08/28 10:31
  • повз scada