diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index e1c7fb447..df5bed026 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -18,7 +18,7 @@ jobs: with: esp_idf_version: v5.5.2 target: esp32s3 - command: GITHUB_ACTIONS="true" idf.py build + command: idf.py add-dependency "espressif/mdns^1.8.0" && GITHUB_ACTIONS="true" idf.py build path: 'test-ci' - name: Run tests diff --git a/components/connect/CMakeLists.txt b/components/connect/CMakeLists.txt index 91e85c375..3bd0cb0e8 100644 --- a/components/connect/CMakeLists.txt +++ b/components/connect/CMakeLists.txt @@ -13,4 +13,5 @@ REQUIRES "esp_wifi" "esp_event" "stratum" + "espressif__mdns" ) diff --git a/components/connect/connect.c b/components/connect/connect.c index 7d403fd03..beb891fba 100644 --- a/components/connect/connect.c +++ b/components/connect/connect.c @@ -1,11 +1,19 @@ #include #include "esp_event.h" #include "esp_log.h" +#include "mdns.h" +#include "esp_system.h" #include "esp_wifi.h" +#include "freertos/FreeRTOS.h" #include "freertos/event_groups.h" #include "freertos/task.h" #include "freertos/timers.h" #include "lwip/err.h" +#include "lwip/lwip_napt.h" +#include "lwip/sys.h" +#include "lwip/sockets.h" +#include "esp_netif.h" +#include "nvs_flash.h" #include "esp_wifi_types_generic.h" #include "connect.h" @@ -55,11 +63,121 @@ static uint16_t ap_number = 0; static wifi_ap_record_t ap_info[MAX_AP_COUNT]; static int s_retry_num = 0; static int clients_connected_to_ap = 0; +static bool mdns_initialized = false; static const char *get_wifi_reason_string(int reason); static void wifi_softap_on(void); static void wifi_softap_off(void); +static char* generate_unique_hostname(const char *base); +static char* check_and_resolve_hostname_conflict(const char *hostname, const char *current_ip); + +static void initialize_mdns_if_needed(GlobalState *GLOBAL_STATE) { + if (mdns_initialized) { + return; + } + + char * hostname = nvs_config_get_string(NVS_CONFIG_HOSTNAME); + if (hostname == NULL) { + ESP_LOGW(TAG, "Hostname not configured, skipping mDNS setup"); + return; + } + esp_err_t err = mdns_init(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "mDNS/Avahi initialization failed: %s", esp_err_to_name(err)); + ESP_LOGW(TAG, "Device will not be discoverable via mDNS/Bonjour/Avahi"); + } else { + ESP_LOGI(TAG, "mDNS/Avahi initialized successfully - device discoverable on network"); + + /* Get current IP */ + esp_netif_ip_info_t ip_info; + esp_netif_get_ip_info(esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"), &ip_info); + char current_ip[16]; + snprintf(current_ip, sizeof(current_ip), IPSTR, IP2STR(&ip_info.ip)); + + /* Check for hostname conflicts */ + char *final_hostname = check_and_resolve_hostname_conflict(hostname, current_ip); + + /* Set mDNS hostname */ + err = mdns_hostname_set(final_hostname); + if (err != ESP_OK) { + ESP_LOGW(TAG, "mDNS hostname setup failed: %s", esp_err_to_name(err)); + ESP_LOGW(TAG, "Device hostname not set for mDNS discovery"); + } else { + ESP_LOGI(TAG, "mDNS hostname set to: %s.local", final_hostname); + ESP_LOGI(TAG, "Access device at: http://%s.local", final_hostname); + strlcpy(GLOBAL_STATE->SYSTEM_MODULE.mdns_hostname, final_hostname, sizeof(GLOBAL_STATE->SYSTEM_MODULE.mdns_hostname)); + snprintf(GLOBAL_STATE->SYSTEM_MODULE.full_hostname, sizeof(GLOBAL_STATE->SYSTEM_MODULE.full_hostname), "%s.local", final_hostname); + } + + free(final_hostname); + + /* Set mDNS instance name */ + uint8_t mac[6]; + esp_wifi_get_mac(WIFI_IF_STA, mac); + char mac_suffix[6]; + snprintf(mac_suffix, sizeof(mac_suffix), "%02X%02X", mac[4], mac[5]); + + char instance_name[64]; + snprintf(instance_name, sizeof(instance_name), "Bitaxe %s %s (%s)", + GLOBAL_STATE->DEVICE_CONFIG.family.name, + GLOBAL_STATE->DEVICE_CONFIG.board_version, + mac_suffix); + + /* Add HTTP service */ + err = mdns_service_add(instance_name, "_http", "_tcp", 80, NULL, 0); + if (err != ESP_OK) { + ESP_LOGW(TAG, "mDNS HTTP service registration failed: %s", esp_err_to_name(err)); + ESP_LOGW(TAG, "HTTP service not advertised via mDNS"); + } else { + ESP_LOGI(TAG, "mDNS HTTP service registered: _http._tcp port 80"); + ESP_LOGI(TAG, "Discover with: avahi-browse _http._tcp"); + ESP_LOGI(TAG, "mDNS instance: %s", instance_name); + } + + ESP_LOGI(TAG, "mDNS/Avahi setup complete - device ready for network discovery"); + mdns_initialized = true; + } + free(hostname); +} + +esp_err_t update_mdns_hostname(const char *new_hostname, GlobalState *GLOBAL_STATE) { + if (new_hostname == NULL || strlen(new_hostname) == 0) { + ESP_LOGW(TAG, "Invalid hostname provided for mDNS update"); + return ESP_ERR_INVALID_ARG; + } + + /* Get current IP for conflict checking */ + esp_netif_ip_info_t ip_info; + esp_netif_get_ip_info(esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"), &ip_info); + char current_ip[16]; + snprintf(current_ip, sizeof(current_ip), IPSTR, IP2STR(&ip_info.ip)); + + /* Check for hostname conflicts and resolve if needed */ + char *resolved_hostname = check_and_resolve_hostname_conflict(new_hostname, current_ip); + + /* If the hostname was resolved to a different one, update NVS */ + if (strcmp(resolved_hostname, new_hostname) != 0) { + nvs_config_set_string(NVS_CONFIG_HOSTNAME, resolved_hostname); + ESP_LOGI(TAG, "Hostname conflict resolved, updated NVS to: %s", resolved_hostname); + } + + esp_err_t err = mdns_hostname_set(resolved_hostname); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to update mDNS hostname to: %s, error: %s", resolved_hostname, esp_err_to_name(err)); + free(resolved_hostname); + return err; + } + + ESP_LOGI(TAG, "mDNS hostname updated to: %s", resolved_hostname); + if (GLOBAL_STATE != NULL) { + strlcpy(GLOBAL_STATE->SYSTEM_MODULE.mdns_hostname, resolved_hostname, sizeof(GLOBAL_STATE->SYSTEM_MODULE.mdns_hostname)); + snprintf(GLOBAL_STATE->SYSTEM_MODULE.full_hostname, sizeof(GLOBAL_STATE->SYSTEM_MODULE.full_hostname), "%s.local", resolved_hostname); + } + free(resolved_hostname); + return ESP_OK; +} + esp_err_t get_wifi_current_rssi(int8_t *rssi) { wifi_ap_record_t current_ap_info; @@ -255,6 +373,8 @@ static void event_handler(void * arg, esp_event_base_t event_base, int32_t event if (ipv6_err != ESP_OK) { ESP_LOGE(TAG, "Failed to create IPv6 link-local address: %s", esp_err_to_name(ipv6_err)); } + + initialize_mdns_if_needed(GLOBAL_STATE); } if (event_base == IP_EVENT && event_id == IP_EVENT_GOT_IP6) { @@ -286,6 +406,8 @@ static void event_handler(void * arg, esp_event_base_t event_base, int32_t event GLOBAL_STATE->SYSTEM_MODULE.ipv6_addr_str[sizeof(GLOBAL_STATE->SYSTEM_MODULE.ipv6_addr_str) - 1] = '\0'; ESP_LOGI(TAG, "IPv6 Address: %s", GLOBAL_STATE->SYSTEM_MODULE.ipv6_addr_str); } + + initialize_mdns_if_needed(GLOBAL_STATE); } } @@ -343,6 +465,49 @@ static void wifi_softap_off(void) } } + + +static char* generate_unique_hostname(const char *base) { + uint8_t mac[6]; + esp_wifi_get_mac(WIFI_IF_STA, mac); + char suffix[6]; + snprintf(suffix, sizeof(suffix), "-%02x%02x", mac[4], mac[5]); + char *new_hostname = malloc(strlen(base) + strlen(suffix) + 1); + strcpy(new_hostname, base); + strcat(new_hostname, suffix); + return new_hostname; +} + +static char* check_and_resolve_hostname_conflict(const char *hostname, const char *current_ip) { + mdns_result_t *results = NULL; + esp_err_t err = mdns_query_generic(hostname, NULL, NULL, MDNS_TYPE_A, MDNS_QUERY_MULTICAST, 3000, 1, &results); + if (err != ESP_OK || !results || !results->addr) { + // No A record found, no conflict + if (results) mdns_query_results_free(results); + return strdup(hostname); + } + + mdns_ip_addr_t *a = results->addr; + char ip_str[INET6_ADDRSTRLEN]; + if (a->addr.type == IPADDR_TYPE_V4) { + esp_ip4addr_ntoa(&a->addr.u_addr.ip4, ip_str, sizeof(ip_str)); + } else { + inet_ntop(AF_INET6, &a->addr.u_addr.ip6, ip_str, sizeof(ip_str)); + } + + if (strcmp(ip_str, current_ip) != 0) { + // Different IP, conflict detected + char *new_hostname = generate_unique_hostname(hostname); + ESP_LOGI(TAG, "mDNS conflict detected for '%s' at %s, renaming to '%s'", hostname, ip_str, new_hostname); + nvs_config_set_string(NVS_CONFIG_HOSTNAME, new_hostname); + mdns_query_results_free(results); + return new_hostname; + } + + mdns_query_results_free(results); + return strdup(hostname); +} + static void wifi_softap_on(void) { esp_err_t err = esp_wifi_set_mode(WIFI_MODE_APSTA); diff --git a/components/connect/include/connect.h b/components/connect/include/connect.h index 17d9e043b..7d0315fb9 100644 --- a/components/connect/include/connect.h +++ b/components/connect/include/connect.h @@ -6,6 +6,7 @@ #include #include "esp_wifi_types.h" +#include "global_state.h" // Structure to hold WiFi scan results typedef struct { @@ -18,5 +19,6 @@ void toggle_wifi_softap(void); void wifi_init(void * GLOBAL_STATE); esp_err_t wifi_scan(wifi_ap_record_simple_t *ap_records, uint16_t *ap_count); esp_err_t get_wifi_current_rssi(int8_t *rssi); +esp_err_t update_mdns_hostname(const char *new_hostname, GlobalState *GLOBAL_STATE); #endif /* CONNECT_H_ */ diff --git a/main/global_state.h b/main/global_state.h index 72e13113d..d9310ca99 100644 --- a/main/global_state.h +++ b/main/global_state.h @@ -89,6 +89,8 @@ typedef struct char * asic_status; char * version; char * axeOSVersion; + char mdns_hostname[64]; + char full_hostname[70]; } SystemModule; typedef struct diff --git a/main/http_server/axe-os/src/app/components/network-edit/network.edit.component.ts b/main/http_server/axe-os/src/app/components/network-edit/network.edit.component.ts index d81abb03a..5598923d4 100644 --- a/main/http_server/axe-os/src/app/components/network-edit/network.edit.component.ts +++ b/main/http_server/axe-os/src/app/components/network-edit/network.edit.component.ts @@ -7,6 +7,7 @@ import { BehaviorSubject, Observable } from 'rxjs'; import { DialogService } from 'src/app/services/dialog.service'; import { LoadingService } from 'src/app/services/loading.service'; import { SystemApiService } from 'src/app/services/system.service'; +import { ISystemUpdateResponse } from 'src/models/ISystemUpdateResponse'; interface WifiNetwork { ssid: string; @@ -54,7 +55,6 @@ export class NetworkEditComponent implements OnInit { public updateSystem() { - const form = this.form.getRawValue(); // Allow an empty Wi-Fi password @@ -72,7 +72,33 @@ export class NetworkEditComponent implements OnInit { this.systemService.updateSystem(this.uri, form) .pipe(this.loadingService.lockUIUntilComplete()) .subscribe({ - next: () => { + next: (response: any) => { + // Check if response contains redirect information (hostname change) + if (response && response.redirect) { + const redirectResponse = response as ISystemUpdateResponse; + if (redirectResponse.redirect) { + let newHostname: string; + try { + newHostname = new URL(redirectResponse.redirect.url).hostname; + } catch (error) { + console.error('Invalid redirect URL:', redirectResponse.redirect.url, error); + this.toastr.error('Failed to redirect due to invalid URL.'); + return; // Skip redirect on malformed URL + } + const redirectUrl = redirectResponse.redirect.url; + const redirectDelay = redirectResponse.redirect.delay; + + this.toastr.success(redirectResponse.redirect.message); + this.toastr.info(`Redirecting to ${newHostname} in ${Math.ceil(redirectDelay / 1000)} seconds...`); + + setTimeout(() => { + window.location.href = redirectUrl; + }, redirectDelay); + } + return; + } + + // Normal success handling this.toastr.warning('You must restart this device after saving for changes to take effect.'); this.toastr.success('Saved network settings'); this.savedChanges = true; diff --git a/main/http_server/axe-os/src/app/components/swarm/swarm.component.html b/main/http_server/axe-os/src/app/components/swarm/swarm.component.html index fda91889a..5ee4d20df 100644 --- a/main/http_server/axe-os/src/app/components/swarm/swarm.component.html +++ b/main/http_server/axe-os/src/app/components/swarm/swarm.component.html @@ -89,25 +89,21 @@

Swarm

-
- -
- - {{axe.IP}} - - - - +
+ + {{axe.ip}}
-
{{axe.version}} @@ -209,8 +205,7 @@

Swarm

- - {{axe.hostname}} - - +
{{ notification.msg }}
- - {{axe.IP}} - - - - {{axe.hashRate | hashSuffix}}

@@ -345,7 +340,7 @@

Swarm

- Scan your network for devices or add a device manually by using its IP address. + Scan your network for devices or add a device manually by using its address (IP or .local hostname).

@@ -367,13 +362,13 @@

Swarm

- +
- - + +
\ No newline at end of file diff --git a/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts b/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts index d43ea89f5..a84572e81 100644 --- a/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts +++ b/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts @@ -14,7 +14,16 @@ const SWARM_REFRESH_TIME = 'SWARM_REFRESH_TIME'; const SWARM_SORTING = 'SWARM_SORTING'; const SWARM_GRID_VIEW = 'SWARM_GRID_VIEW'; -type SwarmDevice = { IP: string; ASICModel: string; deviceModel: string; swarmColor: string; asicCount: number; [key: string]: any }; +type SwarmDevice = { + address: string; + ASICModel: string; + deviceModel: string; + swarmColor: string; + asicCount: number; + displayName?: string; + connectionAddress?: string; + [key: string]: any +}; @Component({ selector: 'app-swarm', @@ -51,6 +60,8 @@ export class SwarmComponent implements OnInit, OnDestroy { public filterText = ''; + public currentDeviceIp: string | null = null; + @HostListener('document:keydown.esc', ['$event']) onEscKey() { if (this.filterText) { @@ -68,7 +79,7 @@ export class SwarmComponent implements OnInit, OnDestroy { ) { this.form = this.fb.group({ - manualAddIp: [null, [Validators.required, Validators.pattern('(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)')]] + manualAddAddress: [null, [Validators.required, Validators.pattern('^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|[a-zA-Z0-9-]+(?:\\.local)?$')]] }); this.gridView = this.localStorageService.getBool(SWARM_GRID_VIEW); @@ -84,8 +95,9 @@ export class SwarmComponent implements OnInit, OnDestroy { this.localStorageService.setNumber(SWARM_REFRESH_TIME, value); }); + this.selectedSort = this.localStorageService.getObject(SWARM_SORTING) ?? { - sortField: 'IP', + sortField: 'address', sortDirection: 'asc' }; @@ -115,6 +127,12 @@ export class SwarmComponent implements OnInit, OnDestroy { } } }, 1000); + + // Fetch current device IP for isThisDevice + this.httpClient.get(`http://${window.location.hostname}/api/system/info`).subscribe({ + next: (response: any) => this.currentDeviceIp = response.ip, + error: () => this.currentDeviceIp = null + }); } ngOnDestroy(): void { @@ -127,6 +145,22 @@ export class SwarmComponent implements OnInit, OnDestroy { return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0; } +private isIpAddress(value: string): boolean { + const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + return ipRegex.test(value); + } + + // Utility method to get the display name for a device + public getDeviceDisplayName(device: SwarmDevice): string { + return device.displayName || device.address; + } + + // Utility method to get the link URL for a device + // Falls back to IP for older devices without mDNS + public getDeviceLink(device: SwarmDevice): string { + return device['fullHostname'] || device.connectionAddress; + } + private intToIp(int: number): string { return `${(int >>> 24) & 255}.${(int >>> 16) & 255}.${(int >>> 8) & 255}.${int & 255}`; } @@ -142,15 +176,40 @@ export class SwarmComponent implements OnInit, OnDestroy { scanNetwork() { this.scanning = true; - const { start, end } = this.calculateIpRange(window.location.hostname, '255.255.255.0'); - const ips = Array.from({ length: end - start + 1 }, (_, i) => this.intToIp(start + i)); + if (this.isIpAddress(window.location.hostname)) { + // Direct IP access - scan the subnet + const { start, end } = this.calculateIpRange(window.location.hostname, '255.255.255.0'); + const ips = Array.from({ length: end - start + 1 }, (_, i) => this.intToIp(start + i)); + this.performNetworkScan(ips); + } else { + // mDNS hostname - fetch server IP first, then scan its subnet + this.httpClient.get(`http://${window.location.hostname}/api/system/info`) + .subscribe({ + next: (response: any) => { + const serverIp = response.ip; + const { start, end } = this.calculateIpRange(serverIp, '255.255.255.0'); + const ips = Array.from({ length: end - start + 1 }, (_, i) => this.intToIp(start + i)); + this.performNetworkScan(ips); + }, + error: () => { + // Fallback: skip scanning if we can't get the IP + this.scanning = false; + } + }); + } + } + + private performNetworkScan(ips: string[]) { this.getAllDeviceInfo(ips, () => of(null)).subscribe({ next: (result) => { // Filter out null items first const validResults = result.filter((item): item is SwarmDevice => item !== null); // Merge new results with existing swarm entries - const existingIps = new Set(this.swarm.map(item => item.IP)); - const newItems = validResults.filter(item => !existingIps.has(item.IP)); + const existingAddresses = new Set([...this.swarm.map(item => item.address), ...this.swarm.map(item => item.connectionAddress)]); + const newItems = validResults.filter(item => { + const isDuplicate = existingAddresses.has(item['hostname']) || existingAddresses.has(item['ip']); + return !isDuplicate; + }); this.swarm = [...this.swarm, ...newItems]; this.sortSwarm(); this.localStorageService.setObject(SWARM_DATA, this.swarm); @@ -163,18 +222,31 @@ export class SwarmComponent implements OnInit, OnDestroy { }); } - private getAllDeviceInfo(ips: string[], errorHandler: (error: any, ip: string) => Observable, fetchAsic: boolean = true) { - return from(ips).pipe( - mergeMap(IP => forkJoin({ - info: this.httpClient.get(`http://${IP}/api/system/info`), - asic: fetchAsic ? this.httpClient.get(`http://${IP}/api/system/asic`).pipe(catchError(() => of({}))) : of({}) + private getAllDeviceInfo(addresses: string[], errorHandler: (error: any, address: string) => Observable, fetchAsic: boolean = true) { + return from(addresses).pipe( + mergeMap(address => forkJoin({ + info: this.httpClient.get(`http://${address}/api/system/info`).pipe(catchError(() => of(null))), + asic: fetchAsic ? this.httpClient.get(`http://${address}/api/system/asic`).pipe(catchError(() => of({}))) : of({}) }).pipe( map(({ info, asic }) => { - const existingDevice = this.swarm.find(device => device.IP === IP) || {}; - return this.mergeDeviceData(IP, existingDevice, info, asic); + if (info === null) { + return null; + } + + const existingDevice = this.swarm.find(device => device.connectionAddress === address); + const result = { + address: (info as any)['fullHostname'] || (info as any)['hostname'] || address, + displayName: (info as any)['hostname'] ? (info as any)['hostname'].replace(/\.local$/i, '') : address, + connectionAddress: address, + ...(existingDevice ? existingDevice : {}), + ...info, + ...asic, + ...this.numerizeDeviceBestDiffs(info as ISystemInfo) + }; + return this.fallbackDeviceModel(result); }), timeout(5000), - catchError(error => errorHandler(error, IP)) + catchError(error => errorHandler(error, address)) ), 128 ), @@ -183,74 +255,88 @@ export class SwarmComponent implements OnInit, OnDestroy { } public add() { - const IP = this.form.value.manualAddIp; - - // Check if IP already exists - if (this.swarm.some(item => item.IP === IP)) { - this.toastr.warning('Device already added to the swarm.', `Device at ${IP}`); - return; - } + const address = this.form.value.manualAddAddress; forkJoin({ - info: this.httpClient.get(`http://${IP}/api/system/info`), - asic: this.httpClient.get(`http://${IP}/api/system/asic`).pipe(catchError(() => of({}))) - }).pipe( - timeout(5000), - catchError(error => this.refreshErrorHandler(error, IP)) - ).subscribe(({ info, asic }) => { + info: this.httpClient.get(`http://${address}/api/system/info`).pipe(catchError(error => { + if (error.status === 401 || error.status === 0) { + this.toastr.warning(`Potential swarm peer detected at ${address} - upgrade its firmware to be able to add it.`); + return of({ _corsError: 401 }); + } + throw error; + })), + asic: this.httpClient.get(`http://${address}/api/system/asic`).pipe(catchError(() => of({}))) + }).subscribe(({ info, asic }) => { + if ((info as any)._corsError === 401) { + return; // Already showed warning + } if (!info.ASICModel || !asic.ASICModel) { return; } - this.swarm.push(this.mergeDeviceData(IP, {}, info, asic)); + + if (this.swarm.some(item => item.connectionAddress === info['ip'])) { + this.toastr.warning('Device already added to the swarm.', `Device at ${address}`); + return; + } + + const device = { + address: info['fullHostname'] || info['hostname'] || address, + displayName: info['hostname'] ? info['hostname'].replace(/\.local$/i, '') : address, + connectionAddress: info['ip'] || address, + ...asic, + ...info, + ...this.numerizeDeviceBestDiffs(info) + }; + this.swarm.push(device); this.sortSwarm(); this.localStorageService.setObject(SWARM_DATA, this.swarm); this.calculateTotals(); }); } - public edit(axe: any) { - this.selectedAxeOs = axe; + public edit(device: any) { + this.selectedAxeOs = device; this.modalComponent.isVisible = true; } - public postAction(axe: any, action: string) { - this.httpClient.post(`http://${axe.IP}/api/system/${action}`, {}, { responseType: 'json' }).pipe( + public postAction(device: any, action: string) { + this.httpClient.post(`http://${device.connectionAddress}/api/system/${action}`, {}, { responseType: 'text' }).pipe( timeout(800), catchError(error => { if ((action === 'restart' || action === 'identify') && (error.status === 200 || error.status === 0 || error.name === 'HttpErrorResponse' || error.statusText === 'Unknown Error')) { if (action === 'restart') { - return of({ message: 'System will restarted shortly' }); + return of('System will restart shortly'); } else { - return of({ message: 'Identify signal sent - device should say "Hi!"' }); + return of('Identify signal sent - device should say "Hi!"'); } } - let errorMsg = `Failed to ${action} device`; + let errorMsg = `Failed to ${action} device at ${device.address}`; if (error.name === 'TimeoutError') { errorMsg = 'Request timed out'; } else if (error.message) { errorMsg += `: ${error.message}`; } - this.toastr.error(errorMsg, `Device at ${axe.IP}`); + this.toastr.error(errorMsg, `Device at ${device.address}`); return of(null); }) ).subscribe((res: any) => { if (res !== null) { - this.toastr.success(res.message, `Device at ${axe.IP}`); + this.toastr.success(res, `Device at ${device.address}`); this.refreshList(false); } }); } - public remove(axeOs: any) { - this.swarm = this.swarm.filter(axe => axe.IP !== axeOs.IP); + public remove(device: any) { + this.swarm = this.swarm.filter(axe => axe.address !== device.address); this.localStorageService.setObject(SWARM_DATA, this.swarm); this.calculateTotals(); } - public refreshErrorHandler = (error: any, ip: string) => { + public refreshErrorHandler = (error: any, address: string) => { const errorMessage = error?.message || error?.statusText || error?.toString() || 'Unknown error'; - this.toastr.error(`Failed to get info: ${errorMessage}`, `Device at ${ip}`); - const existingDevice = this.swarm.find(axeOs => axeOs.IP === ip); + this.toastr.error(`Failed to get info: ${errorMessage}`, `Device at ${address}`); + const existingDevice = this.swarm.find(axeOs => axeOs.connectionAddress === address); return of({ ...existingDevice, hashRate: 0, @@ -271,10 +357,10 @@ export class SwarmComponent implements OnInit, OnDestroy { } this.refreshIntervalTime = this.refreshTimeSet; - const ips = this.swarm.map(axeOs => axeOs.IP); + const addresses = this.swarm.filter(Boolean).map(axeOs => axeOs.connectionAddress); this.isRefreshing = true; - this.getAllDeviceInfo(ips, this.refreshErrorHandler, fetchAsic).subscribe({ + this.getAllDeviceInfo(addresses, this.refreshErrorHandler, fetchAsic).subscribe({ next: (result) => { this.swarm = result; this.sortSwarm(); @@ -306,15 +392,28 @@ export class SwarmComponent implements OnInit, OnDestroy { let comparison = 0; const fieldType = typeof a[this.selectedSort.sortField]; - if (this.selectedSort.sortField === 'IP') { - // Split IP into octets and compare numerically - const aOctets = a[this.selectedSort.sortField].split('.').map(Number); - const bOctets = b[this.selectedSort.sortField].split('.').map(Number); - for (let i = 0; i < 4; i++) { - if (aOctets[i] !== bOctets[i]) { - comparison = aOctets[i] - bOctets[i]; - break; + if (this.selectedSort.sortField === 'address') { + const aValue = a[this.selectedSort.sortField]; + const bValue = b[this.selectedSort.sortField]; + const aIsIp = this.isIpAddress(aValue); + const bIsIp = this.isIpAddress(bValue); + + if (aIsIp && bIsIp) { + // Both are IPs, sort numerically + const aOctets = aValue.split('.').map(Number); + const bOctets = bValue.split('.').map(Number); + for (let i = 0; i < 4; i++) { + if (aOctets[i] !== bOctets[i]) { + comparison = aOctets[i] - bOctets[i]; + break; + } } + } else if (!aIsIp && !bIsIp) { + // Both are hostnames, sort alphabetically + comparison = aValue.localeCompare(bValue); + } else { + // Mixed, sort IPs before hostnames + comparison = aIsIp ? -1 : 1; } } else if (fieldType === 'number') { comparison = a[this.selectedSort.sortField] - b[this.selectedSort.sortField]; @@ -332,7 +431,7 @@ export class SwarmComponent implements OnInit, OnDestroy { } get deviceFamilies(): SwarmDevice[] { - return this.filteredSwarm.filter((v, i, a) => + return this.filteredSwarm.filter(Boolean).filter((v, i, a) => a.findIndex(({ deviceModel, ASICModel, asicCount }) => v.deviceModel === deviceModel && v.ASICModel === ASICModel && @@ -341,6 +440,26 @@ export class SwarmComponent implements OnInit, OnDestroy { ); } + private fallbackDeviceModel(data: any): any { + if (data.deviceModel && data.swarmColor && data.poolDifficulty && data.hashRate) return data; + const deviceModel = data.deviceModel || this.deriveDeviceModel(data); + const swarmColor = data.swarmColor || this.deriveSwarmColor(deviceModel); + const poolDifficulty = data.poolDifficulty || data.stratumDiff; + const hashRate = data.hashRate || data.hashRate_10m; + return { ...data, deviceModel, swarmColor, poolDifficulty, hashRate }; + } + + private numerizeDeviceBestDiffs(info: ISystemInfo) { + const parseAsNumber = (val: number | string): number => { + return typeof val === 'string' ? this.parseSuffixString(val) : val; + }; + + return { + bestDiff: parseAsNumber(info.bestDiff), + bestSessionDiff: parseAsNumber(info.bestSessionDiff), + }; + } + private deriveDeviceModel(data: any): string { if (data.boardVersion && data.boardVersion.length > 1) { if (data.boardVersion[0] == "1" || data.boardVersion == "2.2") return "Max"; @@ -423,10 +542,10 @@ export class SwarmComponent implements OnInit, OnDestroy { get sortOptions() { return [ - { label: 'Hostname', value: { sortField: 'hostname', sortDirection: 'desc' } }, - { label: 'Hostname', value: { sortField: 'hostname', sortDirection: 'asc' } }, - { label: 'IP', value: { sortField: 'IP', sortDirection: 'desc' } }, - { label: 'IP', value: { sortField: 'IP', sortDirection: 'asc' } }, + { label: 'Hostname (Z-A)', value: { sortField: 'hostname', sortDirection: 'desc' } }, + { label: 'Hostname (A-Z)', value: { sortField: 'hostname', sortDirection: 'asc' } }, + { label: 'Address', value: { sortField: 'address', sortDirection: 'desc' } }, + { label: 'Address', value: { sortField: 'address', sortDirection: 'asc' } }, { label: 'Hashrate', value: { sortField: 'hashRate', sortDirection: 'desc' } }, { label: 'Hashrate', value: { sortField: 'hashRate', sortDirection: 'asc' } }, { label: 'Shares', value: { sortField: 'sharesAccepted', sortDirection: 'desc' } }, @@ -458,11 +577,11 @@ export class SwarmComponent implements OnInit, OnDestroy { } const filter = this.filterText.toLowerCase(); - return this.swarm.filter(axe => - axe.hostname.toLowerCase().includes(filter) || +return this.swarm.filter(axe => + this.getDeviceDisplayName(axe).toLowerCase().includes(filter) || axe.ASICModel.toLowerCase().includes(filter) || axe.deviceModel.toLowerCase().includes(filter) || - axe.IP.includes(filter) + axe.address.toLowerCase().includes(filter) ); } @@ -483,7 +602,17 @@ export class SwarmComponent implements OnInit, OnDestroy { } } - isThisDevice(IP: string): boolean { - return IP === window.location.hostname; + isThisDevice(device: SwarmDevice): boolean { + const hostname = window.location.hostname; + + if (device.address === hostname || device.connectionAddress === hostname) { + return true; + } + + if (this.currentDeviceIp !== null && device['ip'] === this.currentDeviceIp) { + return true; + } + + return false; } } diff --git a/main/http_server/axe-os/src/app/services/system.service.ts b/main/http_server/axe-os/src/app/services/system.service.ts index 86f02eae3..fe542929f 100644 --- a/main/http_server/axe-os/src/app/services/system.service.ts +++ b/main/http_server/axe-os/src/app/services/system.service.ts @@ -12,6 +12,7 @@ import { SystemService as GeneratedSystemService, Settings } from 'src/app/generated'; +import { ISystemUpdateResponse } from 'src/models/ISystemUpdateResponse'; import { environment } from '../../environments/environment'; @@ -249,7 +250,7 @@ export class SystemApiService { return of('Device identified (mock)'); } - public updateSystem(uri: string = '', update: any) { + public updateSystem(uri: string = '', update: any): Observable { if (environment.production && this.generatedSystemService && !uri) { return this.generatedSystemService.updateSystemSettings(update as Settings); } @@ -258,6 +259,16 @@ export class SystemApiService { return this.httpClient.patch(`${uri}/api/system`, update); } + if (update.hostname) { + return of({ + status: 'success', + redirect: { + url: `http://${update.hostname}.local`, + delay: 2000, + message: 'Hostname updated. Redirecting to new address...' + } + } as ISystemUpdateResponse); + } return of(true); } diff --git a/main/http_server/axe-os/src/models/ISystemUpdateResponse.ts b/main/http_server/axe-os/src/models/ISystemUpdateResponse.ts new file mode 100644 index 000000000..c795ac552 --- /dev/null +++ b/main/http_server/axe-os/src/models/ISystemUpdateResponse.ts @@ -0,0 +1,8 @@ +export interface ISystemUpdateResponse { + status: string; + redirect?: { + url: string; + delay: number; + message: string; + }; +} \ No newline at end of file diff --git a/main/http_server/http_server.c b/main/http_server/http_server.c index d8bc92070..92942b6d6 100644 --- a/main/http_server/http_server.c +++ b/main/http_server/http_server.c @@ -27,6 +27,7 @@ #include "esp_wifi.h" #include "lwip/err.h" #include "lwip/inet.h" +#include #include "lwip/lwip_napt.h" #include "lwip/netdb.h" #include "lwip/sockets.h" @@ -43,6 +44,7 @@ #include "theme_api.h" // Add theme API include #include "axe-os/api/system/asic_settings.h" #include "display.h" +#include "mdns.h" #include "http_server.h" #include "system.h" #include "websocket.h" @@ -227,37 +229,60 @@ static esp_err_t ip_in_private_range(uint32_t address) { static uint32_t extract_origin_ip_addr(char *origin) { - char ip_str[16]; + char host_str[128]; uint32_t origin_ip_addr = 0; - // Find the start of the IP address in the Origin header + // Find the start of the hostname in the Origin header const char *prefix = "http://"; - char *ip_start = strstr(origin, prefix); - if (ip_start) { - ip_start += strlen(prefix); // Move past "http://" - - // Extract the IP address portion (up to the next '/') - char *ip_end = strchr(ip_start, '/'); - size_t ip_len = ip_end ? (size_t)(ip_end - ip_start) : strlen(ip_start); - if (ip_len < sizeof(ip_str)) { - strncpy(ip_str, ip_start, ip_len); - ip_str[ip_len] = '\0'; // Null-terminate the string - - // Convert the IP address string to uint32_t - origin_ip_addr = inet_addr(ip_str); - if (origin_ip_addr == INADDR_NONE) { - ESP_LOGW(CORS_TAG, "Invalid IP address: %s", ip_str); - } else { + char *host_start = strstr(origin, prefix); + if (host_start) { + host_start += strlen(prefix); // Move past "http://" + + // Extract the hostname portion (up to the next '/') + char *host_end = strchr(host_start, '/'); + size_t host_len = host_end ? (size_t)(host_end - host_start) : strlen(host_start); + if (host_len < sizeof(host_str)) { + strncpy(host_str, host_start, host_len); + host_str[host_len] = '\0'; // Null-terminate the string + + // Check if it's an IP address or hostname + struct in_addr addr; + if (inet_pton(AF_INET, host_str, &addr) == 1) { + origin_ip_addr = addr.s_addr; ESP_LOGD(CORS_TAG, "Extracted IP address %lu", origin_ip_addr); + } else { + ESP_LOGD(CORS_TAG, "Origin contains hostname: %s (not an IP)", host_str); + // For hostnames, return 0 to indicate it's not an IP address + origin_ip_addr = 0; } } else { - ESP_LOGW(CORS_TAG, "IP address string is too long: %s", ip_start); + ESP_LOGW(CORS_TAG, "Hostname string is too long: %s", host_start); } } return origin_ip_addr; } +// Helper function to normalize hostname by stripping ".local" suffix if present +// This prevents Avahi from creating duplicate ".local.local" hostnames +static void normalize_hostname(char *hostname, size_t max_len) { + if (hostname == NULL || strlen(hostname) == 0) { + return; + } + + size_t len = strlen(hostname); + const char *suffix = ".local"; + size_t suffix_len = strlen(suffix); + + // Check if hostname ends with ".local" (case-insensitive) + if (len > suffix_len && + strcasecmp(hostname + len - suffix_len, suffix) == 0) { + // Strip the ".local" suffix + hostname[len - suffix_len] = '\0'; + ESP_LOGD(TAG, "Normalized hostname from '%s.local' to '%s'", hostname, hostname); + } +} + esp_err_t is_network_allowed(httpd_req_t * req) { if (GLOBAL_STATE->SYSTEM_MODULE.ap_enabled == true) { @@ -294,9 +319,42 @@ esp_err_t is_network_allowed(httpd_req_t * req) origin_ip_addr = request_ip_addr; } - if (ip_in_private_range(origin_ip_addr) == ESP_OK && ip_in_private_range(request_ip_addr) == ESP_OK) { + if (origin_ip_addr != 0 && ip_in_private_range(origin_ip_addr) == ESP_OK && ip_in_private_range(request_ip_addr) == ESP_OK) { + ESP_LOGD(CORS_TAG, "Origin and IP both in private range. Allowing."); return ESP_OK; } + + // If origin contains hostname (origin_ip_addr == 0), proceed to hostname validation + if (origin_ip_addr == 0) { + ESP_LOGD(CORS_TAG, "Origin contains hostname, proceeding to hostname validation"); + } + + // Check if Origin header matches the avahi hostname + if (httpd_req_get_hdr_value_len(req, "Origin") > 0) { + httpd_req_get_hdr_value_str(req, "Origin", origin, sizeof(origin)); + ESP_LOGD(CORS_TAG, "Origin header: %s", origin); + char *hostname = nvs_config_get_string(NVS_CONFIG_HOSTNAME); + ESP_LOGD(CORS_TAG, "Configured hostname: %s", hostname); + char expected[256]; + snprintf(expected, sizeof(expected), "http://%s.local", hostname); + ESP_LOGD(CORS_TAG, "Expected origin with .local: %s", expected); + if (strcmp(origin, expected) == 0) { + free(hostname); + ESP_LOGD(CORS_TAG, "Request from avahi hostname - allowing access"); + return ESP_OK; + } + // Also check without .local suffix + snprintf(expected, sizeof(expected), "http://%s", hostname); + ESP_LOGD(CORS_TAG, "Expected origin without .local: %s", expected); + if (strcmp(origin, expected) == 0) { + free(hostname); + ESP_LOGD(CORS_TAG, "Request from hostname - allowing access"); + return ESP_OK; + } + free(hostname); + } else { + ESP_LOGD(CORS_TAG, "No Origin header found"); + } ESP_LOGI(CORS_TAG, "Client is NOT in the private ip ranges or same range as server."); return ESP_FAIL; @@ -519,9 +577,24 @@ static esp_err_t handle_options_request(httpd_req_t * req) return ESP_OK; } -bool check_settings_and_update(const cJSON * const root) +bool check_settings_and_update(const cJSON * const root, char **redirect_url) { bool result = true; + char *old_hostname = NULL; + bool hostname_changed = false; + + // Check for hostname change first + cJSON *hostname_item = cJSON_GetObjectItem(root, "hostname"); + if (hostname_item && cJSON_IsString(hostname_item)) { + old_hostname = nvs_config_get_string(NVS_CONFIG_HOSTNAME); + char normalized_new_hostname[64]; + strlcpy(normalized_new_hostname, hostname_item->valuestring, sizeof(normalized_new_hostname)); + normalize_hostname(normalized_new_hostname, sizeof(normalized_new_hostname)); + if (strcmp(old_hostname, normalized_new_hostname) != 0) { + hostname_changed = true; + ESP_LOGI(TAG, "Hostname change detected: %s -> %s", old_hostname, hostname_item->valuestring); + } + } for (NvsConfigKey key = 0; key < NVS_CONFIG_COUNT; key++) { Settings *setting = nvs_config_get_settings(key); @@ -608,7 +681,21 @@ bool check_settings_and_update(const cJSON * const root) switch(setting->type) { case TYPE_STR: - nvs_config_set_string(key, item->valuestring); + + if (key == NVS_CONFIG_HOSTNAME) + { + char normalized_hostname[64]; + strlcpy(normalized_hostname, item->valuestring, sizeof(normalized_hostname)); + normalize_hostname(normalized_hostname, sizeof(normalized_hostname)); + nvs_config_set_string(key, normalized_hostname); + update_mdns_hostname(normalized_hostname, GLOBAL_STATE); + ESP_LOGI(TAG, "Updated hostname to: %s", normalized_hostname); + } + else + { + nvs_config_set_string(key, item->valuestring); + } + break; case TYPE_U16: nvs_config_set_u16(key, (uint16_t)item->valueint); @@ -629,6 +716,23 @@ bool check_settings_and_update(const cJSON * const root) } } + // Set redirect URL if hostname changed + if (hostname_changed && redirect_url) { + char *current_hostname = nvs_config_get_string(NVS_CONFIG_HOSTNAME); + if (current_hostname) { + *redirect_url = malloc(256); + if (*redirect_url) { + snprintf(*redirect_url, 256, "http://%s.local", current_hostname); + ESP_LOGI(TAG, "Hostname redirect URL set: %s", *redirect_url); + } + free(current_hostname); + } + } + + if (old_hostname) { + free(old_hostname); + } + return result; } @@ -670,15 +774,37 @@ static esp_err_t PATCH_update_settings(httpd_req_t * req) return ESP_OK; } - if (!check_settings_and_update(root)) { + char *redirect_url = NULL; + if (!check_settings_and_update(root, &redirect_url)) { cJSON_Delete(root); + if (redirect_url) { + free(redirect_url); + } httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Wrong API input"); return ESP_OK; } - cJSON_Delete(root); - httpd_resp_send_chunk(req, NULL, 0); - return ESP_OK; + // Create response JSON + cJSON *response = cJSON_CreateObject(); + if (redirect_url) { + cJSON_AddStringToObject(response, "status", "success"); + cJSON *redirect = cJSON_CreateObject(); + cJSON_AddStringToObject(redirect, "url", redirect_url); + cJSON_AddNumberToObject(redirect, "delay", 2000); + cJSON_AddStringToObject(redirect, "message", "Hostname updated. Redirecting to new address..."); + cJSON_AddItemToObject(response, "redirect", redirect); + + ESP_LOGI(TAG, "Sending hostname change redirect response"); + esp_err_t res = HTTP_send_json(req, response, &api_common_prebuffer_len); + cJSON_Delete(response); + free(redirect_url); + cJSON_Delete(root); + return res; + } else { + cJSON_Delete(root); + httpd_resp_send_chunk(req, NULL, 0); + return ESP_OK; + } } static esp_err_t POST_identify(httpd_req_t * req) @@ -830,7 +956,9 @@ static esp_err_t GET_system_info(httpd_req_t * req) } char * ssid = nvs_config_get_string(NVS_CONFIG_WIFI_SSID); - char * hostname = nvs_config_get_string(NVS_CONFIG_HOSTNAME); + char * hostname_base = nvs_config_get_string(NVS_CONFIG_HOSTNAME); + char * mdns_hostname = GLOBAL_STATE->SYSTEM_MODULE.mdns_hostname; + char * full_hostname = GLOBAL_STATE->SYSTEM_MODULE.full_hostname; char * ipv4 = GLOBAL_STATE->SYSTEM_MODULE.ip_addr_str; char * ipv6 = GLOBAL_STATE->SYSTEM_MODULE.ipv6_addr_str; char * stratumURL = nvs_config_get_string(NVS_CONFIG_STRATUM_URL); @@ -850,6 +978,15 @@ static esp_err_t GET_system_info(httpd_req_t * req) int8_t wifi_rssi = -90; get_wifi_current_rssi(&wifi_rssi); + esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + char ip_str[16] = ""; + if (netif != NULL) { + esp_netif_ip_info_t ip_info; + if (esp_netif_get_ip_info(netif, &ip_info) == ESP_OK) { + inet_ntop(AF_INET, &ip_info.ip, ip_str, sizeof(ip_str)); + } + } + cJSON * root = cJSON_CreateObject(); cJSON_AddFloatToObject(root, "power", GLOBAL_STATE->POWER_MANAGEMENT_MODULE.power); cJSON_AddFloatToObject(root, "voltage", GLOBAL_STATE->POWER_MANAGEMENT_MODULE.voltage); @@ -884,11 +1021,16 @@ static esp_err_t GET_system_info(httpd_req_t * req) cJSON_AddFloatToObject(root, "frequency", frequency); cJSON_AddStringToObject(root, "ssid", ssid); cJSON_AddStringToObject(root, "macAddr", formattedMac); - cJSON_AddStringToObject(root, "hostname", hostname); + cJSON_AddStringToObject(root, "hostname", hostname_base); + cJSON_AddStringToObject(root, "fullHostname", full_hostname); + if (strlen(mdns_hostname) > 0) { + cJSON_AddStringToObject(root, "mdnsHostname", mdns_hostname); + } cJSON_AddStringToObject(root, "ipv4", ipv4); cJSON_AddStringToObject(root, "ipv6", ipv6); cJSON_AddStringToObject(root, "wifiStatus", GLOBAL_STATE->SYSTEM_MODULE.wifi_status); cJSON_AddNumberToObject(root, "wifiRSSI", wifi_rssi); + cJSON_AddStringToObject(root, "ip", ip_str); cJSON_AddNumberToObject(root, "apEnabled", GLOBAL_STATE->SYSTEM_MODULE.ap_enabled); cJSON_AddNumberToObject(root, "sharesAccepted", GLOBAL_STATE->SYSTEM_MODULE.shares_accepted); cJSON_AddNumberToObject(root, "sharesRejected", GLOBAL_STATE->SYSTEM_MODULE.shares_rejected); @@ -999,7 +1141,7 @@ static esp_err_t GET_system_info(httpd_req_t * req) } free(ssid); - free(hostname); + free(hostname_base); free(stratumURL); free(fallbackStratumURL); free(stratumCert); diff --git a/main/http_server/openapi.yaml b/main/http_server/openapi.yaml index ffcb46706..7159cec7a 100644 --- a/main/http_server/openapi.yaml +++ b/main/http_server/openapi.yaml @@ -303,6 +303,12 @@ components: hostname: type: string description: Device hostname + fullHostname: + type: string + description: Full hostname including .local domain for mDNS + mdnsHostname: + type: string + description: mDNS/Bonjour hostname for network discovery idfVersion: type: string description: ESP-IDF version diff --git a/main/idf_component.yml b/main/idf_component.yml index dfa9fba45..49883c15d 100644 --- a/main/idf_component.yml +++ b/main/idf_component.yml @@ -3,6 +3,7 @@ dependencies: lvgl/lvgl: "9.3.0" espressif/esp_lvgl_port: "2.6.3" esp_lcd_sh1107: "1.1.0" + espressif/mdns: "^1.8.0" ## Required IDF version idf: version: ">=5.5.0" diff --git a/readme.md b/readme.md index 2d650e00a..adfcc71fe 100755 --- a/readme.md +++ b/readme.md @@ -72,7 +72,7 @@ Available API endpoints: * `/api/system` Update system settings -### API examples in `curl`: +### API examples in `curl` (works with IP addresses or .local hostnames): ```bash # Get system information @@ -116,6 +116,56 @@ curl -X PATCH http://YOUR-BITAXE-IP/api/system \ -d '{"fanspeed": "desired_speed_value"}' ``` +## mDNS Support + +ESP-Miner now includes comprehensive mDNS (multicast DNS) support for seamless network discovery and device accessibility. This feature enables automatic device discovery on local networks without requiring manual IP address configuration. + +### Features + +- **Automatic mDNS Initialization**: Device automatically registers with mDNS/Bonjour/Avahi services on network connection +- **Dynamic Hostname Registration**: Device hostname is registered as `.local` (e.g., `bitaxe.local`) +- **Service Advertisement**: HTTP service is advertised as `_http._tcp` on port 80 +- **Dynamic Hostname Updates**: mDNS hostname updates automatically when device hostname is changed via web interface +- **Hostname Normalization**: Automatically strips `.local` suffix when setting hostnames to prevent duplicate registrations +- **CORS Support**: Enhanced CORS handling to allow requests from mDNS hostnames +- **Hostname Conflict Resolution**: Automatically detects and resolves hostname conflicts by appending MAC address suffix when needed +- **Enhanced Swarm Discovery**: Swarm mode supports both IP addresses and .local hostnames for seamless network management + +### Network Discovery + +Once connected to your local network, the device becomes discoverable through: + +```bash +# Using avahi-browse (Linux) +avahi-browse _http._tcp + +# Using dns-sd (macOS) +dns-sd -B _http._tcp + +# Direct access +http://.local +``` + +### Configuration + +- **Default Hostname**: `bitaxe` (configurable via web interface) +- **Service Type**: `_http._tcp` +- **Port**: `80` +- **Instance Name**: `ESP-Miner Web Server` + +### Hostname Conflict Resolution + +If multiple devices attempt to use the same hostname, ESP-Miner automatically resolves conflicts by appending a MAC address-derived suffix (e.g., `bitaxe-12ab` if `bitaxe` is taken). This ensures unique network identification without manual intervention. + +### Benefits + +- **Zero-Configuration Discovery**: Devices automatically appear in network browsers +- **Cross-Platform Compatibility**: Works with Windows, macOS, Linux, and mobile devices +- **No IP Address Required**: Access devices using human-readable names +- **Automatic Resolution**: DNS resolution happens transparently in the background +- **Zero-Configuration Swarm Management**: Automatic device discovery and management without IP configuration +- **Enhanced Cross-Platform Compatibility**: Improved support across different network environments and discovery protocols + ## Administration The firmware hosts a small web server on port 80 for administrative purposes. Once the Bitaxe device is connected to the local network, the admin web front end may be accessed via a web browser connected to the same network at `http://`, replacing `IP` with the LAN IP address of the Bitaxe device, or `http://bitaxe`, provided your network supports mDNS configuration.