diff --git a/requirements.txt b/requirements.txt index 6b269f15..b605c06d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,4 +29,5 @@ SudachiDict-core==20250825 SudachiDict-full==20250825 translators @ git+https://github.com/misyaguziya/translators@5.9.2.1 SpeechRecognition @ git+https://github.com/misyaguziya/custom_speech_recognition@3.10.4.1 -tinyoscquery @ git+https://github.com/cyberkitsune/tinyoscquery@0.1.3 \ No newline at end of file +tinyoscquery @ git+https://github.com/cyberkitsune/tinyoscquery@0.1.3 +aptabase==0.1.0 \ No newline at end of file diff --git a/requirements_cuda.txt b/requirements_cuda.txt index a93729f8..324a3711 100644 --- a/requirements_cuda.txt +++ b/requirements_cuda.txt @@ -30,4 +30,5 @@ SudachiDict-core==20250825 SudachiDict-full==20250825 translators @ git+https://github.com/misyaguziya/translators@5.9.2.1 SpeechRecognition @ git+https://github.com/misyaguziya/custom_speech_recognition@3.10.4.1 -tinyoscquery @ git+https://github.com/cyberkitsune/tinyoscquery@0.1.3 \ No newline at end of file +tinyoscquery @ git+https://github.com/cyberkitsune/tinyoscquery@0.1.3 +aptabase==0.1.0 \ No newline at end of file diff --git a/src-python/config.py b/src-python/config.py index f1a86916..f34f4580 100644 --- a/src-python/config.py +++ b/src-python/config.py @@ -724,6 +724,9 @@ class Config: NOTIFICATION_VRC_SFX = ManagedProperty('NOTIFICATION_VRC_SFX', type_=bool) WEBSOCKET_HOST = ManagedProperty('WEBSOCKET_HOST', type_=str) WEBSOCKET_PORT = ManagedProperty('WEBSOCKET_PORT', type_=int) + + # --- Telemetry Settings --- + TELEMETRY_ENABLED = ManagedProperty('TELEMETRY_ENABLED', type_=bool) # --- Selection properties with validation (ManagedProperty) --- SELECTED_TAB_NO = ManagedProperty('SELECTED_TAB_NO', type_=str, allowed=lambda v, inst: v in inst.SELECTABLE_TAB_NO_LIST) @@ -1022,6 +1025,9 @@ class Config: self._WEBSOCKET_SERVER = False self._WEBSOCKET_HOST = "127.0.0.1" self._WEBSOCKET_PORT = 2231 + + ## Telemetry + self._TELEMETRY_ENABLED = True # デフォルト有効 def load_config(self): if os_path.isfile(self.PATH_CONFIG) is not False: diff --git a/src-python/controller.py b/src-python/controller.py index 8d1c53c9..e6350127 100644 --- a/src-python/controller.py +++ b/src-python/controller.py @@ -48,6 +48,13 @@ class Controller: def setRun(self, run:Callable[[int, str, Any], None]) -> None: self.run = run + + def shutdown(self) -> None: + """Shutdown controller and model (including telemetry).""" + try: + model.shutdown() + except Exception: + errorLogging() # response functions def connectedNetwork(self) -> None: @@ -271,6 +278,8 @@ class Controller: ) elif isinstance(message, str) and len(message) > 0: + model.telemetryTouchActivity() + model.telemetryTrackCoreFeature("mic_speech_to_text") translation = [] transliteration_message = [] transliteration_translation = [] @@ -287,6 +296,7 @@ class Controller: pass else: try: + model.telemetryTrackCoreFeature("translation") translation, success = model.getInputTranslate(message, source_language=language) if all(success) is not True: self.changeToCTranslate2Process() @@ -437,6 +447,8 @@ class Controller: }, ) elif isinstance(message, str) and len(message) > 0: + model.telemetryTouchActivity() + model.telemetryTrackCoreFeature("speaker_speech_to_text") translation = [] transliteration_message = [] transliteration_translation = [] @@ -453,6 +465,7 @@ class Controller: pass else: try: + model.telemetryTrackCoreFeature("translation") translation, success = model.getOutputTranslate(message, source_language=language) if all(success) is not True: self.changeToCTranslate2Process() @@ -617,6 +630,8 @@ class Controller: id = data["id"] message = data["message"] if len(message) > 0: + model.telemetryTouchActivity() + model.telemetryTrackCoreFeature("text_input") translation = [] transliteration_message: List[Any] = [] transliteration_translation = [] @@ -624,6 +639,7 @@ class Controller: pass else: try: + model.telemetryTrackCoreFeature("translation") if config.USE_EXCLUDE_WORDS is True: replacement_message, replacement_dict = self.replaceExclamationsWithRandom(message) translation, success = model.getInputTranslate(replacement_message) diff --git a/src-python/docs/telemetry_design.md b/src-python/docs/telemetry_design.md index e6a9af8c..95dcd6e6 100644 --- a/src-python/docs/telemetry_design.md +++ b/src-python/docs/telemetry_design.md @@ -370,47 +370,67 @@ def telemetry.get_state() -> dict: ### パブリック API 使用例 -#### ケース1: アプリ起動・終了 +#### ケース1: アプリ起動・終了(MVC 準拠) ```python -# mainloop.py +# model.py from models.telemetry import telemetry from config import config -# 起動時 -def app_startup(): - telemetry.init(enabled=config.TELEMETRY_ENABLED) - # ... 他の初期化処理 +class Model: + def init(self): + # ... 既存の初期化処理 + + # Telemetry 初期化 + try: + telemetry.init(enabled=config.TELEMETRY_ENABLED) + except Exception: + errorLogging() + + def shutdown(self): + """Model cleanup on application shutdown.""" + try: + # Telemetry 終了(app_closed 送信) + telemetry.shutdown() + except Exception: + errorLogging() + + # ... その他のクリーンアップ処理 -# 終了時 -def app_shutdown(): - telemetry.shutdown() - # ... 他のクリーンアップ +# controller.py +class Controller: + def init(self): + # Model 初期化(telemetry も初期化される) + model.init() + + def shutdown(self): + # Model シャットダウン(telemetry も終了) + model.shutdown() ``` -#### ケース2: 翻訳機能 +#### ケース2: 翻訳機能(MVC 準拠) ```python # controller.py def micMessage(self, result: dict): - # テキスト入力フェーズ(テキスト入力イベント用) + # テキスト入力フェーズ message = result["text"] - telemetry.touch_activity() + model.telemetryTouchActivity() # model 経由でアクティビティ更新 # 翻訳開始前(コア機能イベント送信) if config.ENABLE_TRANSLATION: - telemetry.track_core_feature("translation") + model.telemetryTrackCoreFeature("translation") # model 経由 translation, success = model.getInputTranslate(message) # ... 処理続行 ``` -#### ケース3: マイク文字起こし +#### ケース3: マイク文字起こし(MVC 準拠) ```python # model.py def startMicTranscript(self, fnc): - # マイク開始前 - telemetry.track_core_feature("mic_speech_to_text") + # マイク開始前(内部で telemetry 呼び出し) + self.telemetryTrackCoreFeature("mic_speech_to_text") mic_device = selected_mic_device[0] self.mic_audio_recorder = SelectedMicEnergyAndAudioRecorder(...) @@ -418,24 +438,24 @@ def startMicTranscript(self, fnc): # ... 処理続行 ``` -#### ケース4: エラー報告 +#### ケース4: エラー報告(MVC 準拠) ```python # controller.py except Exception as e: is_vram_error, error_message = model.detectVRAMError(e) if is_vram_error: - telemetry.track("error", {"error_type": "VRAM_ERROR"}) + model.telemetryTrack("error", {"error_type": "VRAM_ERROR"}) # model 経由 # ... エラー処理 ``` -#### ケース5: 設定変更 +#### ケース5: 設定変更(MVC 準拠) ```python # controller.py def setUiLanguage(data): config.UI_LANGUAGE = data - telemetry.track("config_changed", {"section": "appearance"}) + model.telemetryTrack("config_changed", {"section": "appearance"}) # model 経由 return {"status": 200, "result": config.UI_LANGUAGE} ``` @@ -783,12 +803,10 @@ class TelemetryCore: ### 既存コード構造への組み込み -#### パターン1: mainloop.py の stop() メソッド +#### パターン1: mainloop.py の stop() メソッド(MVC 準拠) ```python # mainloop.py -from models.telemetry import telemetry - class Main: def stop(self, wait: float = 2.0) -> None: """Signal threads to stop and wait for them to finish. @@ -796,11 +814,11 @@ class Main: Args: wait: maximum seconds to wait for threads to join. """ - # ここで telemetry を終了(最後のイベント送信) + # Controller 経由で shutdown(telemetry も含む) try: - telemetry.shutdown() # app_closed 送信 + self.controller.shutdown() # model.shutdown() を呼び出し except Exception: - pass # 握りつぶし + errorLogging() self._stop_event.set() # give threads a chance to exit @@ -810,7 +828,7 @@ class Main: th.join(timeout=remaining) ``` -**重要**: `telemetry.shutdown()` は **スレッド停止前** に呼び出す必要があります。そうしないと heartbeat スレッドが動作していない状態で shutdown に失敗する可能性があります。 +**重要**: `controller.shutdown()` は **スレッド停止前** に呼び出す必要があります。そうしないと heartbeat スレッドが動作していない状態で shutdown に失敗する可能性があります。 #### パターン2: Controller.setWatchdogCallback @@ -922,24 +940,18 @@ def shutdown(self) -> None: その後、mainloop.py で呼び出し: +```python(MVC 準拠): + ```python # mainloop.py def stop(self, wait: float = 2.0) -> None: """Signal threads to stop and wait for them to finish.""" - # テレメトリ終了(app_closed 送信) + # Controller 経由でシャットダウン(telemetry + model 含む) try: - telemetry.shutdown() + self.controller.shutdown() # model.shutdown() が呼ばれる except Exception: - pass - - # モデルクリーンアップ - try: - model.shutdown() - except Exception: - pass - - self._stop_event.set() + errorLogging()op_event.set() start = time.time() for th in self._threads: remaining = max(0.0, wait - (time.time() - start)) diff --git a/src-python/mainloop.py b/src-python/mainloop.py index 2d6aa50a..cf92b7aa 100644 --- a/src-python/mainloop.py +++ b/src-python/mainloop.py @@ -460,14 +460,20 @@ class Main: """Read lines from stdin, parse JSON and enqueue requests. Uses blocking readline but honors stop via _stop_event checked between reads. + EOF on stdin indicates the frontend has closed; trigger app_closed event. """ while not self._stop_event.is_set(): try: line = sys.stdin.readline() if not line: - # EOF reached; sleep briefly and re-check stop event - time.sleep(0.1) - continue + # EOF reached - frontend has closed connection + # Trigger telemetry shutdown to send app_closed event + printLog("Frontend disconnected (stdin EOF)", {"event": "frontend_closed"}) + try: + self.controller.shutdown() + except Exception: + errorLogging() + break received_data = json.loads(line.strip()) if received_data: @@ -584,6 +590,12 @@ class Main: Args: wait: maximum seconds to wait for threads to join. """ + # Controller 経由でシャットダウン(model.shutdown() → telemetry.shutdown() が呼ばれる) + try: + self.controller.shutdown() + except Exception: + errorLogging() + self._stop_event.set() # give threads a chance to exit start = time.time() diff --git a/src-python/model.py b/src-python/model.py index 335991ec..755aca39 100644 --- a/src-python/model.py +++ b/src-python/model.py @@ -32,6 +32,7 @@ from models.overlay.overlay import Overlay from models.overlay.overlay_image import OverlayImage from models.watchdog.watchdog import Watchdog from models.websocket.websocket_server import WebSocketServer +from models.telemetry import Telemetry from utils import errorLogging, setupLogger class threadFnc(Thread): @@ -140,6 +141,11 @@ class Model: # default no-op callbacks for energy check functions self.check_mic_energy_fnc: Callable[[float], None] = lambda v: None self.check_speaker_energy_fnc: Callable[[float], None] = lambda v: None + + # Telemetry 初期化(Model 内でインスタンスを保持) + self.telemetry = Telemetry() + self.telemetry.init(enabled=config.TELEMETRY_ENABLED, app_version=config.VERSION) + self._inited = True def ensure_initialized(self) -> None: @@ -1290,5 +1296,77 @@ class Model: except Exception: errorLogging() return False + + # ========================================================================= + # Telemetry Methods (MVC Pattern) + # ========================================================================= + + def shutdown(self): + """Model cleanup on application shutdown.""" + # Telemetry 終了(app_closed 送信) + if hasattr(self, "telemetry") and self.telemetry: + self.telemetry.shutdown() + + try: + # オーバーレイ終了 + if hasattr(self, 'overlay') and self.overlay: + self.shutdownOverlay() + except Exception: + errorLogging() + + try: + # Watchdog 停止 + if hasattr(self, 'th_watchdog'): + self.stopWatchdog() + except Exception: + errorLogging() + + try: + # WebSocket サーバー停止 + if hasattr(self, 'websocket_server_alive') and self.websocket_server_alive: + self.stopWebSocketServer() + except Exception: + errorLogging() + + try: + # OSC ハンドラー停止 + if hasattr(self, 'osc_handler'): + self.stopReceiveOSC() + except Exception: + errorLogging() + + try: + # オーディオ停止 + self.stopMicTranscript() + self.stopSpeakerTranscript() + self.stopCheckMicEnergy() + self.stopCheckSpeakerEnergy() + except Exception: + errorLogging() + + try: + # ロガー停止 + if hasattr(self, 'logger') and self.logger: + self.stopLogger() + except Exception: + errorLogging() + + # メモリ解放 + gc.collect() + + def telemetryTrack(self, event: str, payload: dict = None): + """汎用テレメトリイベント送信 (Model ラッパー)""" + if hasattr(self, "telemetry") and self.telemetry: + self.telemetry.track(event, payload) + + def telemetryTrackCoreFeature(self, feature: str): + """コア機能テレメトリイベント送信 (Model ラッパー)""" + if hasattr(self, "telemetry") and self.telemetry: + self.telemetry.track_core_feature(feature) + + def telemetryTouchActivity(self): + """テレメトリアクティビティ更新 (Model ラッパー)""" + if hasattr(self, "telemetry") and self.telemetry: + self.telemetry.touch_activity() model = Model() \ No newline at end of file diff --git a/src-python/models/telemetry/__init__.py b/src-python/models/telemetry/__init__.py new file mode 100644 index 00000000..78a1a31a --- /dev/null +++ b/src-python/models/telemetry/__init__.py @@ -0,0 +1,202 @@ +""" +テレメトリ(Aptabase)管理モジュール + +パブリック API を提供し、内部実装を隠蔽する。 +""" +import asyncio +import threading +from concurrent.futures import Future + +# Allow running as a script for quick verification. +try: + from .state import TelemetryState + from .heartbeat import HeartbeatManager + from .core import TelemetryCore +except ImportError: + import os + import sys + + sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + from models.telemetry.state import TelemetryState + from models.telemetry.heartbeat import HeartbeatManager + from models.telemetry.core import TelemetryCore + + +class Telemetry: + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + self.state = TelemetryState() + self.core = TelemetryCore(self.state) + self.heartbeat = HeartbeatManager(self.state, self.core, self._schedule_async_with_loop) + self._loop = None + self._loop_thread = None + self._initialized = True + + def _start_event_loop(self): + """バックグラウンドでイベントループを開始""" + if self._loop_thread is not None and self._loop_thread.is_alive(): + return + + def run_loop(): + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop.run_forever() + + self._loop_thread = threading.Thread(target=run_loop, daemon=True, name="telemetry_loop") + self._loop_thread.start() + + # ループが開始されるまで待機 + while self._loop is None: + pass + + def _stop_event_loop(self, timeout: float = 5.0): + """イベントループを停止(フラッシュ完了を待つ)""" + if self._loop is None: + return + + # ループにstop()を予約 + self._loop.call_soon_threadsafe(self._loop.stop) + + # ループスレッド終了を待機 + if self._loop_thread is not None: + self._loop_thread.join(timeout=timeout) + + self._loop = None + self._loop_thread = None + + def _run_async(self, coro): + """同期コンテキストから非同期関数を実行""" + if self._loop is None: + return + + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + try: + # タイムアウト付きで待機(ブロッキングしすぎないように) + return future.result(timeout=5.0) + except Exception: + # テレメトリ失敗は黙殺 + pass + + def _schedule_async(self, coro): + """非同期タスクをバックグラウンドでスケジュール(待機しない)""" + if self._loop is None: + return + + try: + asyncio.run_coroutine_threadsafe(coro, self._loop) + except Exception: + pass + + def _schedule_async_with_loop(self, coro): + """ループ取得とスケジューリングのヘルパー(heartbeat用)""" + return self._schedule_async(coro) + + def init(self, enabled: bool, app_version: str = "1.0.0"): + """テレメトリ初期化(同期インターフェース)""" + self.state.set_enabled(enabled) + if enabled: + self._start_event_loop() + self._run_async(self._init_async(app_version)) + + async def _init_async(self, app_version: str): + """非同期初期化処理""" + await self.core.start(app_version=app_version) + await self.core.send_event("app_started") + self.heartbeat.start() + + def shutdown(self): + """テレメトリ終了(同期インターフェース) + + 重要:Tauri sidecar環境では、このメソッド実行後にプロセス終了が発生します。 + app_closed イベントが確実に送信されるように、以下の手順を実行します: + 1. heartbeat スレッド停止 + 2. app_closed イベント送信を同期待機 + 3. Aptabase クライアント停止(フラッシュ含む) + 4. イベントループ完全停止を待機 + """ + try: + # Step 1: Heartbeat 停止(5分待機中の送信を防ぐ) + self.heartbeat.stop() + + # Step 2-3: app_closed 送信とクライアント停止を同期待機 + if self.state.is_enabled(): + try: + # _run_async で最大5秒間待機(Aptabase のフラッシュ含む) + self._run_async(self._shutdown_async()) + except Exception: + pass + + # Step 4: イベントループを完全停止(フラッシュ確認を待つ) + # sidecar 終了前に確実に完了させるため、タイムアウトを長めに設定 + self._stop_event_loop(timeout=5.0) + except Exception: + # どの段階で失敗してもプロセス終了処理は進行させる + pass + finally: + # 状態をリセット + self.state.reset() + + async def _shutdown_async(self): + """非同期終了処理""" + await self.core.send_event("app_closed") + await self.core.stop() + + def track(self, event: str, payload: dict = None): + """汎用イベント送信(同期インターフェース)""" + if not self.state.is_enabled(): + return + self._schedule_async(self.core.send_event(event, payload)) + + def track_core_feature(self, feature: str): + """コア機能イベント送信(同期インターフェース)""" + if not self.state.is_enabled(): + return + self.state.touch_activity() + if self.core.is_duplicate_core_feature(feature): + return + self._schedule_async(self._track_core_feature_async(feature)) + + async def _track_core_feature_async(self, feature: str): + """非同期コア機能送信処理""" + await self.core.send_event("core_feature", {"feature": feature}) + self.state.record_feature_sent(feature) + + def touch_activity(self): + """アクティビティ時刻更新""" + if self.state.is_enabled(): + self.state.touch_activity() + + def is_enabled(self) -> bool: + """有効状態確認""" + return self.state.is_enabled() + + def get_state(self) -> dict: + """内部状態取得(デバッグ用)""" + return self.state.get_debug_info() + + +if __name__ == "__main__": + # 同期インターフェースのデモ + telemetry = Telemetry() + telemetry.init(enabled=True) + telemetry.track("debug_test", {"message": "telemetry main demo"}) + telemetry.track_core_feature("text_input") + telemetry.touch_activity() + print("state:", telemetry.get_state()) + + # イベント送信完了を待つ + import time + time.sleep(2) + + telemetry.shutdown() + print("telemetry demo finished") + diff --git a/src-python/models/telemetry/client.py b/src-python/models/telemetry/client.py new file mode 100644 index 00000000..32d25a89 --- /dev/null +++ b/src-python/models/telemetry/client.py @@ -0,0 +1,58 @@ +""" +Aptabase SDK ラッパー(非同期版) +""" +from typing import Optional, Dict, Any + +# Aptabase SDK のインポート +try: + from aptabase import Aptabase +except ImportError: + Aptabase = None + + +class AptabaseWrapper: + APP_KEY = "A-US-2082730845" + + def __init__(self): + self.client = None + + async def start(self, app_version: str = "1.0.0"): + """Aptabase クライアント開始""" + if Aptabase is None: + raise ImportError("aptabase library not installed") + try: + self.client = Aptabase( + app_key=self.APP_KEY, + app_version=app_version, + is_debug=False, + max_batch_size=25, + flush_interval=10.0, + timeout=30.0 + ) + await self.client.start() + except Exception as e: + raise RuntimeError(f"Failed to initialize Aptabase: {e}") + + async def track(self, event_name: str, properties: Optional[Dict[str, Any]] = None): + """イベント送信(非同期)""" + if self.client is None: + return + + # properties が None なら空辞書 + if properties is None: + properties = {} + + try: + await self.client.track(event_name, properties) + except Exception: + # テレメトリ送信失敗は黙殺(本体処理を止めない) + pass + + async def stop(self): + """クライアント停止(フラッシュ含む)""" + if self.client is not None: + try: + await self.client.stop() + except Exception: + pass + self.client = None diff --git a/src-python/models/telemetry/core.py b/src-python/models/telemetry/core.py new file mode 100644 index 00000000..cdb0e5ab --- /dev/null +++ b/src-python/models/telemetry/core.py @@ -0,0 +1,57 @@ +""" +テレメトリコアロジック +- イベント構築・送信 +- 重複検出 +""" + +from .client import AptabaseWrapper +from .state import TelemetryState + + +class TelemetryCore: + VALID_CORE_FEATURES = { + "translation", + "mic_speech_to_text", + "speaker_speech_to_text", + "text_input", + } + + def __init__(self, state: TelemetryState): + self.state = state + self.client = None + try: + self.client = AptabaseWrapper() + except Exception: + self.client = None + + async def start(self, app_version: str = "1.0.0"): + """Aptabase クライアント開始""" + if self.client is None: + return + try: + await self.client.start(app_version=app_version) + except Exception: + self.client = None + + async def stop(self): + """Aptabase クライアント停止""" + if self.client is not None: + try: + await self.client.stop() + except Exception: + pass + + async def send_event(self, event_name: str, payload: dict = None): + """イベント送信(非同期)""" + if self.client is None: + return + + # ペイロード準備 + properties = payload or {} + + # イベント送信 + await self.client.track(event_name, properties) + + def is_duplicate_core_feature(self, feature: str) -> bool: + """セッション内の重複チェック""" + return self.state.has_feature_been_sent(feature) diff --git a/src-python/models/telemetry/heartbeat.py b/src-python/models/telemetry/heartbeat.py new file mode 100644 index 00000000..0d417d93 --- /dev/null +++ b/src-python/models/telemetry/heartbeat.py @@ -0,0 +1,53 @@ +""" +Heartbeat スレッド管理 +- 5分間隔でアクティブ確認 +- 操作なしで5分経過したら送信停止 +""" +from datetime import datetime +from threading import Thread, Event +import time + + +class HeartbeatManager: + INTERVAL = 300 # 5 minutes + TIMEOUT = 300 # 5 minutes + + def __init__(self, state, core, schedule_async): + self.state = state + self.core = core + self.schedule_async = schedule_async + self.thread = None + self._stop_event = Event() + + def start(self): + """Heartbeat スレッド開始""" + if self.thread is not None and self.thread.is_alive(): + return + self._stop_event.clear() + self.thread = Thread(target=self._run, daemon=True, name="telemetry_heartbeat") + self.thread.start() + + def stop(self): + """Heartbeat スレッド停止""" + self._stop_event.set() + if self.thread is not None: + self.thread.join(timeout=1.0) + self.thread = None + + def _run(self): + """Heartbeat ループ""" + while not self._stop_event.is_set(): + time.sleep(self.INTERVAL) + + if not self.state.is_enabled(): + continue + + last_activity = self.state.get_last_activity() + if last_activity is None: + continue + + elapsed = (datetime.now() - last_activity).total_seconds() + if elapsed < self.TIMEOUT: + # アクティブなら heartbeat 送信(非同期スケジュール) + if self.core.client is not None: + self.schedule_async(self.core.send_event("session_heartbeat")) diff --git a/src-python/models/telemetry/state.py b/src-python/models/telemetry/state.py new file mode 100644 index 00000000..119972d3 --- /dev/null +++ b/src-python/models/telemetry/state.py @@ -0,0 +1,63 @@ +""" +テレメトリ状態管理 +- enable/disable フラグ +- 最終操作時刻 +- セッション内送信済み機能リスト +""" +from datetime import datetime +from threading import Lock + + +class TelemetryState: + def __init__(self): + self._enabled = True # デフォルト有効 + self._last_activity = None + self._session_features_sent = set() + self._lock = Lock() + + def set_enabled(self, value: bool): + """有効/無効設定""" + with self._lock: + self._enabled = bool(value) + if not self._enabled: + self._session_features_sent.clear() + + def is_enabled(self) -> bool: + """有効状態確認""" + with self._lock: + return self._enabled + + def touch_activity(self): + """最終操作時刻更新""" + with self._lock: + self._last_activity = datetime.now() + + def get_last_activity(self): + """最終操作時刻取得""" + with self._lock: + return self._last_activity + + def record_feature_sent(self, feature: str): + """送信済み機能を記録""" + with self._lock: + self._session_features_sent.add(feature) + + def has_feature_been_sent(self, feature: str) -> bool: + """機能がこのセッション内で送信済みか""" + with self._lock: + return feature in self._session_features_sent + + def reset(self): + """状態をリセット""" + with self._lock: + self._last_activity = None + self._session_features_sent.clear() + + def get_debug_info(self) -> dict: + """デバッグ用情報取得""" + with self._lock: + return { + "enabled": self._enabled, + "last_activity": self._last_activity.isoformat() if self._last_activity else None, + "session_features_sent": list(self._session_features_sent), + }