supynote 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. supynote/__init__.py +0 -0
  2. supynote/application/__init__.py +0 -0
  3. supynote/application/dtos/browse_dto.py +40 -0
  4. supynote/application/dtos/device_dto.py +42 -0
  5. supynote/application/dtos/device_info_dto.py +50 -0
  6. supynote/application/dtos/download_dto.py +96 -0
  7. supynote/application/dtos/list_files_dto.py +50 -0
  8. supynote/application/use_cases/browse_device.py +69 -0
  9. supynote/application/use_cases/download_use_case.py +250 -0
  10. supynote/application/use_cases/find_device.py +101 -0
  11. supynote/application/use_cases/get_device_info.py +73 -0
  12. supynote/application/use_cases/list_files.py +91 -0
  13. supynote/cli.py +153 -0
  14. supynote/converter.py +364 -0
  15. supynote/device_finder.py +94 -0
  16. supynote/domain/__init__.py +0 -0
  17. supynote/domain/device_management/__init__.py +0 -0
  18. supynote/domain/device_management/entities/device.py +152 -0
  19. supynote/domain/device_management/repositories/device_repository.py +51 -0
  20. supynote/domain/device_management/value_objects/device_connection.py +134 -0
  21. supynote/domain/note_management/__init__.py +0 -0
  22. supynote/domain/note_management/entities/note.py +201 -0
  23. supynote/domain/note_management/repositories/note_repository.py +72 -0
  24. supynote/domain/note_management/services/conversion_service.py +97 -0
  25. supynote/domain/note_management/value_objects/note_id.py +41 -0
  26. supynote/domain/note_management/value_objects/note_path.py +93 -0
  27. supynote/domain/note_management/value_objects/time_range_filter.py +81 -0
  28. supynote/domain/shared/__init__.py +0 -0
  29. supynote/domain/shared/base_entity.py +62 -0
  30. supynote/domain/shared/base_value_object.py +64 -0
  31. supynote/infrastructure/__init__.py +0 -0
  32. supynote/infrastructure/network/network_discovery_service.py +60 -0
  33. supynote/infrastructure/repositories/memory_device_repository.py +39 -0
  34. supynote/infrastructure/repositories/supernote_remote_repository.py +203 -0
  35. supynote/merger.py +517 -0
  36. supynote/ocr/__init__.py +6 -0
  37. supynote/ocr/entities.py +51 -0
  38. supynote/ocr/llava_service.py +212 -0
  39. supynote/ocr/native_service.py +581 -0
  40. supynote/ocr/pdf_processor.py +128 -0
  41. supynote/ocr/services.py +191 -0
  42. supynote/ocr/trocr_service.py +290 -0
  43. supynote/pdf_merger.py +196 -0
  44. supynote/presentation/__init__.py +0 -0
  45. supynote/presentation/cli/__init__.py +0 -0
  46. supynote/presentation/cli/commands/browse_command.py +33 -0
  47. supynote/presentation/cli/commands/commands.py +64 -0
  48. supynote/presentation/cli/commands/find_command.py +50 -0
  49. supynote/presentation/cli/commands/info_command.py +42 -0
  50. supynote/presentation/cli/commands/list_command.py +41 -0
  51. supynote/presentation/cli/container.py +114 -0
  52. supynote/presentation/cli/dispatcher.py +68 -0
  53. supynote/presentation/cli/main_refactored.py +74 -0
  54. supynote/services/post_processing_service.py +115 -0
  55. supynote/supernote.py +507 -0
  56. supynote-0.8.0.dist-info/METADATA +248 -0
  57. supynote-0.8.0.dist-info/RECORD +60 -0
  58. supynote-0.8.0.dist-info/WHEEL +4 -0
  59. supynote-0.8.0.dist-info/entry_points.txt +2 -0
  60. supynote-0.8.0.dist-info/licenses/LICENSE +21 -0
supynote/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,40 @@
1
+ """DTOs for the Browse Device use case."""
2
+ from dataclasses import dataclass
3
+ from typing import Optional
4
+
5
+
6
+ @dataclass
7
+ class BrowseDeviceRequest:
8
+ """Request DTO for browsing a device."""
9
+
10
+ ip: Optional[str] = None
11
+ port: str = "8089"
12
+ open_in_browser: bool = True
13
+
14
+
15
+ @dataclass
16
+ class BrowseDeviceResponse:
17
+ """Response DTO for browsing a device."""
18
+
19
+ success: bool
20
+ url: Optional[str] = None
21
+ device_ip: Optional[str] = None
22
+ message: str = ""
23
+
24
+ @classmethod
25
+ def success_opened(cls, url: str, device_ip: str) -> "BrowseDeviceResponse":
26
+ """Create a success response when browser was opened."""
27
+ return cls(
28
+ success=True,
29
+ url=url,
30
+ device_ip=device_ip,
31
+ message=f"🌐 Opening {url} in browser..."
32
+ )
33
+
34
+ @classmethod
35
+ def device_not_found(cls) -> "BrowseDeviceResponse":
36
+ """Create a response when no device was found."""
37
+ return cls(
38
+ success=False,
39
+ message="āŒ No Supernote device found. Use --ip to specify manually."
40
+ )
@@ -0,0 +1,42 @@
1
+ """Data Transfer Objects for device operations."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class FindDeviceRequest:
9
+ """Request DTO for finding a device."""
10
+ ip: Optional[str] = None # Explicitly provided IP
11
+ network_range: Optional[str] = None
12
+ open_in_browser: bool = False
13
+
14
+
15
+ @dataclass
16
+ class FindDeviceResponse:
17
+ """Response DTO for finding a device."""
18
+ found: bool
19
+ ip: Optional[str] = None # Simplified name
20
+ ip_address: Optional[str] = None # Keep for compatibility
21
+ port: Optional[str] = None
22
+ url: Optional[str] = None
23
+ device_name: Optional[str] = None
24
+ source: Optional[str] = None # "provided", "network", "stored"
25
+
26
+ @classmethod
27
+ def not_found(cls) -> 'FindDeviceResponse':
28
+ """Create a response for when no device is found."""
29
+ return cls(found=False)
30
+
31
+ @classmethod
32
+ def success(cls, ip: str, port: str, url: str, name: str, source: str = "network") -> 'FindDeviceResponse':
33
+ """Create a successful response."""
34
+ return cls(
35
+ found=True,
36
+ ip=ip,
37
+ ip_address=ip, # Duplicate for compatibility
38
+ port=port,
39
+ url=url,
40
+ device_name=name,
41
+ source=source
42
+ )
@@ -0,0 +1,50 @@
1
+ """DTOs for the Device Info use case."""
2
+ from dataclasses import dataclass
3
+ from typing import Optional
4
+
5
+
6
+ @dataclass
7
+ class DeviceInfoRequest:
8
+ """Request DTO for getting device information."""
9
+
10
+ ip: Optional[str] = None
11
+ port: str = "8089"
12
+ output_directory: Optional[str] = None
13
+
14
+
15
+ @dataclass
16
+ class DeviceInfoResponse:
17
+ """Response DTO for device information."""
18
+
19
+ success: bool
20
+ ip: Optional[str] = None
21
+ port: Optional[str] = None
22
+ url: Optional[str] = None
23
+ status: Optional[str] = None
24
+ output_directory: Optional[str] = None
25
+ message: Optional[str] = None
26
+
27
+ @classmethod
28
+ def success_with_info(
29
+ cls,
30
+ ip: str,
31
+ port: str,
32
+ output_directory: Optional[str] = None
33
+ ) -> "DeviceInfoResponse":
34
+ """Create a success response with device info."""
35
+ return cls(
36
+ success=True,
37
+ ip=ip,
38
+ port=port,
39
+ url=f"http://{ip}:{port}",
40
+ status="🟢 Connected",
41
+ output_directory=output_directory
42
+ )
43
+
44
+ @classmethod
45
+ def device_not_found(cls) -> "DeviceInfoResponse":
46
+ """Create a response when no device was found."""
47
+ return cls(
48
+ success=False,
49
+ message="āŒ No Supernote device found. Use --ip to specify manually."
50
+ )
@@ -0,0 +1,96 @@
1
+ """Data Transfer Objects for download operations."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional, List
5
+ from enum import Enum
6
+
7
+ from ...domain.note_management.value_objects.time_range_filter import TimeRange
8
+
9
+
10
+ class DownloadMode(Enum):
11
+ """Download execution mode."""
12
+ SYNC = "sync"
13
+ ASYNC = "async"
14
+
15
+
16
+ class WorkflowStep(Enum):
17
+ """OCR workflow steps."""
18
+ DOWNLOAD = "download"
19
+ CONVERT = "convert"
20
+ OCR = "ocr"
21
+ MERGE = "merge"
22
+
23
+
24
+ @dataclass
25
+ class DownloadNotesRequest:
26
+ """Request DTO for downloading notes."""
27
+ path: str
28
+ output_directory: Optional[str] = None
29
+ force: bool = False
30
+ check_size: bool = True
31
+ time_range: TimeRange = TimeRange.ALL
32
+ max_workers: int = 20
33
+ download_mode: DownloadMode = DownloadMode.ASYNC
34
+
35
+ # Workflow options
36
+ convert_pdf: bool = False
37
+ enable_ocr: bool = False
38
+ merge_by_date: bool = False
39
+ conversion_workers: int = 8
40
+
41
+
42
+ @dataclass
43
+ class DownloadProgress:
44
+ """Progress information for download operations."""
45
+ current: int
46
+ total: int
47
+ current_file: Optional[str] = None
48
+ phase: WorkflowStep = WorkflowStep.DOWNLOAD
49
+
50
+ @property
51
+ def percentage(self) -> float:
52
+ """Get completion percentage."""
53
+ if self.total == 0:
54
+ return 0.0
55
+ return (self.current / self.total) * 100.0
56
+
57
+
58
+ @dataclass
59
+ class WorkflowSummary:
60
+ """Summary of completed workflow steps."""
61
+ downloaded_files: int = 0
62
+ converted_files: int = 0
63
+ ocr_processed_files: int = 0
64
+ merged_pdfs: int = 0
65
+ failed_downloads: int = 0
66
+ failed_conversions: int = 0
67
+ failed_ocr: int = 0
68
+ skipped_files: int = 0
69
+
70
+
71
+ @dataclass
72
+ class DownloadNotesResponse:
73
+ """Response DTO for downloading notes."""
74
+ success: bool
75
+ total_files: int
76
+ workflow_summary: WorkflowSummary
77
+ error_message: Optional[str] = None
78
+
79
+ @classmethod
80
+ def success_response(cls, total_files: int, summary: WorkflowSummary) -> 'DownloadNotesResponse':
81
+ """Create a successful response."""
82
+ return cls(
83
+ success=True,
84
+ total_files=total_files,
85
+ workflow_summary=summary
86
+ )
87
+
88
+ @classmethod
89
+ def failure_response(cls, error_message: str) -> 'DownloadNotesResponse':
90
+ """Create a failure response."""
91
+ return cls(
92
+ success=False,
93
+ total_files=0,
94
+ workflow_summary=WorkflowSummary(),
95
+ error_message=error_message
96
+ )
@@ -0,0 +1,50 @@
1
+ """DTOs for the List Files use case."""
2
+ from dataclasses import dataclass
3
+ from typing import Optional, List, Dict, Any
4
+
5
+
6
+ @dataclass
7
+ class FileItem:
8
+ """Represents a file or directory item."""
9
+ name: str
10
+ is_directory: bool
11
+ date: Optional[str] = None
12
+ size: Optional[int] = None
13
+ path: Optional[str] = None
14
+
15
+
16
+ @dataclass
17
+ class ListFilesRequest:
18
+ """Request DTO for listing files."""
19
+
20
+ ip: Optional[str] = None
21
+ port: str = "8089"
22
+ directory: str = "" # Empty string means root
23
+
24
+
25
+ @dataclass
26
+ class ListFilesResponse:
27
+ """Response DTO for listing files."""
28
+
29
+ success: bool
30
+ directory: str = ""
31
+ files: List[FileItem] = None
32
+ message: Optional[str] = None
33
+
34
+ @classmethod
35
+ def success_with_files(cls, directory: str, files: List[FileItem]) -> "ListFilesResponse":
36
+ """Create a success response with file list."""
37
+ return cls(
38
+ success=True,
39
+ directory=directory or "root",
40
+ files=files or []
41
+ )
42
+
43
+ @classmethod
44
+ def error(cls, message: str) -> "ListFilesResponse":
45
+ """Create an error response."""
46
+ return cls(
47
+ success=False,
48
+ files=[],
49
+ message=message
50
+ )
@@ -0,0 +1,69 @@
1
+ """Use case for browsing a device in web browser."""
2
+ import webbrowser
3
+ from typing import Optional
4
+
5
+ from ..dtos.browse_dto import BrowseDeviceRequest, BrowseDeviceResponse
6
+ from ...domain.device_management.repositories.device_repository import DeviceRepository
7
+ from ...domain.device_management.value_objects.device_connection import DeviceConnection
8
+ from ...infrastructure.network.network_discovery_service import NetworkDiscoveryService
9
+
10
+
11
+ class BrowseDeviceUseCase:
12
+ """Use case for opening device web interface in browser."""
13
+
14
+ def __init__(
15
+ self,
16
+ device_repository: DeviceRepository,
17
+ discovery_service: NetworkDiscoveryService
18
+ ):
19
+ """Initialize the use case."""
20
+ self._device_repository = device_repository
21
+ self._discovery_service = discovery_service
22
+
23
+ def execute(self, request: BrowseDeviceRequest) -> BrowseDeviceResponse:
24
+ """
25
+ Execute the browse device use case.
26
+
27
+ 1. Find or discover device
28
+ 2. Build URL
29
+ 3. Open in browser
30
+ """
31
+ # Try to find device
32
+ device_ip = self._find_or_discover_device(request.ip)
33
+
34
+ if not device_ip:
35
+ return BrowseDeviceResponse.device_not_found()
36
+
37
+ # Build URL
38
+ url = f"http://{device_ip}:{request.port}"
39
+
40
+ # Open in browser if requested
41
+ if request.open_in_browser:
42
+ webbrowser.open(url)
43
+
44
+ return BrowseDeviceResponse.success_opened(url, device_ip)
45
+
46
+ def _find_or_discover_device(self, requested_ip: Optional[str]) -> Optional[str]:
47
+ """Find device from repository or discover on network."""
48
+ # If IP was provided, use it directly
49
+ if requested_ip:
50
+ return requested_ip
51
+
52
+ # Try to find in repository first
53
+ connection = DeviceConnection.for_discovery()
54
+ stored_device = self._device_repository.find_by_connection(connection)
55
+
56
+ if stored_device:
57
+ return stored_device.connection.ip_address.value
58
+
59
+ # Discover on network
60
+ discovered_ip = self._discovery_service.discover_device()
61
+
62
+ if discovered_ip:
63
+ # Store for future use
64
+ from ...domain.device_management.entities.device import Device
65
+ device = Device.discover(discovered_ip, str(connection.port.value))
66
+ self._device_repository.save(device)
67
+ return discovered_ip
68
+
69
+ return None
@@ -0,0 +1,250 @@
1
+ """Use case for download and related operations."""
2
+ from typing import Optional, Any, Dict
3
+ from pathlib import Path
4
+ import asyncio
5
+
6
+ from ...infrastructure.network.network_discovery_service import NetworkDiscoveryService
7
+ from ...domain.device_management.repositories.device_repository import DeviceRepository
8
+
9
+
10
+ class DownloadUseCase:
11
+ """Handles download and related operations."""
12
+
13
+ def __init__(
14
+ self,
15
+ device_repository: DeviceRepository,
16
+ discovery_service: NetworkDiscoveryService
17
+ ):
18
+ """Initialize the adapter."""
19
+ self._device_repository = device_repository
20
+ self._discovery_service = discovery_service
21
+
22
+ def execute_download(self, args: Any) -> bool:
23
+ """Execute download command using legacy code."""
24
+ ip = self._get_device_ip(args)
25
+ if not ip:
26
+ print("āŒ No Supernote device found. Use --ip to specify manually.")
27
+ return False
28
+
29
+ from ...supernote import Supernote
30
+ from ...converter import PDFConverter
31
+
32
+ device = Supernote(ip, args.port, args.output)
33
+
34
+ if args.use_async:
35
+ # Use high-performance async downloader
36
+ async def async_download():
37
+ try:
38
+ if "/" in args.path and not args.path.endswith("/"):
39
+ # Downloading a specific file
40
+ success = device.download_file(args.path, force=args.force, check_size=args.check_size)
41
+ if success and args.convert_pdf and args.path.lower().endswith('.note'):
42
+ local_file = device.local_root / args.path.lstrip('/')
43
+ converter = PDFConverter(vectorize=True, enable_links=True)
44
+ converter.convert_file(local_file)
45
+ else:
46
+ # Downloading a directory with async
47
+ success, total = await device.download_directory_async(
48
+ args.path, args.workers, args.force, args.check_size,
49
+ time_range=args.time_range)
50
+ print(f"šŸŽ‰ Async download completed: {success}/{total} files")
51
+
52
+ if args.convert_pdf:
53
+ # Convert downloaded files
54
+ converter = PDFConverter(vectorize=True, enable_links=True, verbose=getattr(args, 'verbose', False))
55
+ local_dir = device.raw_dir / args.path.lstrip('/')
56
+ if local_dir.exists():
57
+ converter.convert_directory(local_dir, max_workers=args.conversion_workers, time_range=args.time_range)
58
+
59
+ # Handle OCR and merger processing
60
+ from ...services.post_processing_service import PostProcessingService
61
+ post_processor = PostProcessingService()
62
+
63
+ # Use processed_output if provided
64
+ output_dir = Path(args.processed_output) if getattr(args, 'processed_output', None) else None
65
+ post_processor.process_downloaded_files(local_dir, device, args, args.conversion_workers, output_dir)
66
+ except Exception as e:
67
+ print(f"āŒ Download failed: {e}")
68
+ return False
69
+ finally:
70
+ # Clean up async session
71
+ await device.close_async()
72
+ return True
73
+
74
+ return asyncio.run(async_download())
75
+ else:
76
+ # Sync download
77
+ if "/" in args.path and not args.path.endswith("/"):
78
+ return device.download_file(args.path, force=args.force)
79
+ else:
80
+ success, total = device.download_directory(args.path, args.workers, args.force)
81
+ print(f"Downloaded {success}/{total} files")
82
+ return success > 0
83
+
84
+ def execute_convert(self, args: Any) -> bool:
85
+ """Execute convert command using legacy code."""
86
+ from ...converter import PDFConverter
87
+
88
+ input_path = Path(args.path)
89
+ if not input_path.exists():
90
+ print(f"āŒ Path does not exist: {input_path}")
91
+ return False
92
+
93
+ converter = PDFConverter(
94
+ vectorize=not args.no_vector,
95
+ enable_links=not args.no_links
96
+ )
97
+
98
+ if input_path.is_file():
99
+ output_dir = Path(args.output) if args.output else None
100
+ converter.convert_file(input_path, output_dir)
101
+ elif input_path.is_dir():
102
+ output_dir = Path(args.output) if args.output else None
103
+ converter.convert_directory(input_path, output_dir, recursive=args.recursive, max_workers=args.workers)
104
+ else:
105
+ print(f"āŒ Invalid path: {input_path}")
106
+ return False
107
+
108
+ return True
109
+
110
+ def execute_validate(self, args: Any) -> bool:
111
+ """Execute validate command using legacy code."""
112
+ from concurrent.futures import ThreadPoolExecutor, as_completed
113
+ from ...converter import PDFConverter
114
+
115
+ directory = Path(args.directory)
116
+ if not directory.exists():
117
+ print(f"āŒ Directory does not exist: {directory}")
118
+ return False
119
+
120
+ # Find all .note files
121
+ note_files = list(directory.rglob("*.note"))
122
+ print(f"šŸ” Found {len(note_files)} .note files to validate")
123
+
124
+ converter = PDFConverter()
125
+ problematic_files = []
126
+
127
+ def validate_file(file_path):
128
+ try:
129
+ # Try to convert to validate
130
+ converter.convert_file(file_path, Path("/tmp"))
131
+ return file_path, True, None
132
+ except Exception as e:
133
+ return file_path, False, str(e)
134
+
135
+ with ThreadPoolExecutor(max_workers=args.workers) as executor:
136
+ futures = {executor.submit(validate_file, f): f for f in note_files}
137
+
138
+ for future in as_completed(futures):
139
+ file_path, success, error = future.result()
140
+ if not success:
141
+ problematic_files.append((file_path, error))
142
+ print(f"āŒ {file_path}: {error}")
143
+
144
+ if problematic_files:
145
+ print(f"\nāš ļø Found {len(problematic_files)} problematic files")
146
+
147
+ if args.fix:
148
+ ip = self._get_device_ip(args)
149
+ if ip:
150
+ print("šŸ”§ Re-downloading problematic files...")
151
+ from ...supernote import Supernote
152
+ device = Supernote(ip, args.port)
153
+
154
+ for file_path, _ in problematic_files:
155
+ relative_path = file_path.relative_to(directory)
156
+ device.download_file(str(relative_path), force=True)
157
+
158
+ if args.convert:
159
+ converter.convert_file(file_path)
160
+ else:
161
+ print("āœ… All files validated successfully")
162
+
163
+ return True
164
+
165
+ def execute_ocr(self, args: Any) -> bool:
166
+ """Execute OCR command using legacy code."""
167
+ input_path = Path(args.input)
168
+
169
+ if not input_path.exists():
170
+ print(f"āŒ Path does not exist: {input_path}")
171
+ return False
172
+
173
+ if args.engine == "native":
174
+ from ...ocr.native_service import NativeSupernoteService
175
+ service = NativeSupernoteService()
176
+
177
+ if args.batch and input_path.is_dir():
178
+ # Batch process directory
179
+ note_files = list(input_path.glob("*.note"))
180
+ print(f"šŸ” Found {len(note_files)} .note files to process")
181
+
182
+ for note_file in note_files:
183
+ output_file = note_file.with_suffix('.pdf')
184
+ if args.output:
185
+ output_dir = Path(args.output)
186
+ output_dir.mkdir(exist_ok=True)
187
+ output_file = output_dir / output_file.name
188
+
189
+ print(f"šŸ” Processing {note_file.name}...")
190
+ service.convert_note_to_searchable_pdf(note_file, output_file)
191
+ else:
192
+ # Single file
193
+ output_file = Path(args.output) if args.output else input_path.with_suffix('.pdf')
194
+ service.convert_note_to_searchable_pdf(input_path, output_file)
195
+ else:
196
+ print(f"āŒ OCR engine '{args.engine}' not yet implemented in DDD")
197
+ return False
198
+
199
+ return True
200
+
201
+ def execute_merge(self, args: Any) -> bool:
202
+ """Execute merge command using DateBasedMerger."""
203
+ from ...merger import DateBasedMerger, MergeConfig
204
+ import os
205
+
206
+ directory = Path(args.directory)
207
+ if not directory.exists():
208
+ print(f"āŒ Directory does not exist: {directory}")
209
+ return False
210
+
211
+ # Get journals directory from env var or args
212
+ journals_dir_str = os.environ.get("SUPYNOTE_JOURNALS_DIR") or getattr(args, 'journals_dir', None)
213
+ journals_dir = Path(journals_dir_str) if journals_dir_str else None
214
+
215
+ # Get assets directory from env var if set
216
+ assets_dir_str = os.environ.get("SUPYNOTE_ASSETS_DIR")
217
+ assets_dir = Path(assets_dir_str) if assets_dir_str else None
218
+
219
+ merge_config = MergeConfig(
220
+ pdf_output_dir=args.pdf_output,
221
+ markdown_output_dir=args.markdown_output,
222
+ time_range=args.time_range,
223
+ journals_dir=journals_dir,
224
+ assets_dir=assets_dir
225
+ )
226
+
227
+ merger = DateBasedMerger(merge_config)
228
+
229
+ if args.pdf_only:
230
+ merger.merge_pdfs_by_date(directory)
231
+ elif args.markdown_only:
232
+ merger.merge_markdown_by_date(directory)
233
+ else:
234
+ merger.merge_all_by_date(directory)
235
+
236
+ return True
237
+
238
+ def _get_device_ip(self, args: Any) -> Optional[str]:
239
+ """Get device IP from args or discovery."""
240
+ if hasattr(args, 'ip') and args.ip:
241
+ return args.ip
242
+
243
+ # Try discovery
244
+ ip = self._discovery_service.discover_device()
245
+ if ip:
246
+ from ...domain.device_management.entities.device import Device
247
+ device = Device.discover(ip, "8089")
248
+ self._device_repository.save(device)
249
+
250
+ return ip