added linear slider

This commit is contained in:
2026-06-15 08:40:27 +03:00
parent ab2a3bb035
commit 2f303134c1
20 changed files with 481 additions and 196 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ Android-клиент и Python-сервер для мониторинга LoRa
1. Запустите сервер: `cd server && pip install -r requirements.txt && python flask_app.py`
2. Соберите APK в Android Studio или `./gradlew assembleDebug`
3. В приложении: Настройки → URL `http://<ваш-сервер>:7634`, включите telnet при наличии моста COM→telnet
3. В приложении: Настройки → URL `https://lora.grigowashere.ru` (или свой сервер), включите telnet при наличии моста COM→telnet
## Тесты
@@ -49,6 +49,7 @@ public class MainActivity extends AppCompatActivity {
ViewPager2 pager = findViewById(R.id.viewPager);
TabLayout tabs = findViewById(R.id.tabLayout);
pager.setOffscreenPageLimit(1);
pager.setAdapter(new MainPagerAdapter(this));
new TabLayoutMediator(tabs, pager, (tab, position) -> {
int titleRes = switch (position) {
@@ -14,7 +14,8 @@ public class SettingsRepository {
private static final String KEY_TELNET_ENABLED = "telnet_enabled";
private static final String KEY_DEVICE_ID = "device_id";
public static final String DEFAULT_SERVER = "http://grigowashere.ru:7634";
public static final String DEFAULT_SERVER = "https://lora.grigowashere.ru";
private static final String LEGACY_SERVER_HTTP = "http://grigowashere.ru:7634";
public static final String DEFAULT_TELNET_HOST = "127.0.0.1";
public static final int DEFAULT_TELNET_PORT = 2727;
public static final String DEFAULT_RSSI_REGEX = "(?:RSSI|Power)[:\\s]*(-?\\d+(?:\\.\\d+)?)";
@@ -25,6 +26,28 @@ public class SettingsRepository {
public SettingsRepository(Context context) {
prefs = context.getApplicationContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
migrateLegacyServerUrl();
}
private void migrateLegacyServerUrl() {
String current = prefs.getString(KEY_SERVER_URL, null);
if (current == null || !isLegacyServerUrl(current)) {
return;
}
prefs.edit().putString(KEY_SERVER_URL, DEFAULT_SERVER).apply();
}
static boolean isLegacyServerUrl(String url) {
if (url == null) {
return false;
}
String u = url.trim().toLowerCase();
while (u.endsWith("/")) {
u = u.substring(0, u.length() - 1);
}
return u.equals(LEGACY_SERVER_HTTP)
|| u.equals("http://grigowashere.ru")
|| u.equals("https://grigowashere.ru:7634");
}
public String getServerUrl() {
@@ -7,9 +7,10 @@ import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.Spinner;
import android.widget.TextView;
@@ -20,6 +21,8 @@ import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import com.google.android.material.button.MaterialButtonToggleGroup;
import com.grigowashere.loratester.CommandPoller;
import com.grigowashere.loratester.LoraApp;
import com.grigowashere.loratester.PeerDevices;
@@ -87,7 +90,7 @@ public class MapFragment extends Fragment {
private static final long LORA_STATS_FRESH_MS = 120_000;
private static final int HEATMAP_GPS_FOLLOW_M = 50;
private static final long HEATMAP_GPS_DEBOUNCE_MS = 2000L;
private static final int[] HEATMAP_RADIUS_OPTIONS = {100, 200, 500};
private static final int[] HEATMAP_RADIUS_OPTIONS = {50, 100, 200, 500};
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final DateFormat timeFormat =
@@ -109,14 +112,14 @@ public class MapFragment extends Fragment {
private TextView trackStatus;
private ImageView iconServer;
private ImageView iconLora;
private Button btnTrack;
private Button btnPairedTrack;
private Button btnFindHill;
private Button btnHeatmap;
private Button btnCenterMe;
private Button btnCenterTx;
private Button btnCenterRx;
private Button btnCenterBoth;
private ImageButton btnTrack;
private ImageButton btnPairedTrack;
private ImageButton btnFindHill;
private ImageButton btnHeatmap;
private ImageButton btnToolCenter;
private ImageButton btnToolMore;
private View mapToolDrawer;
private MaterialButtonToggleGroup mapCenterMode;
private Spinner trackSpinner;
private Spinner mapHeatmapRadius;
private TextView mapHeatmapStatus;
@@ -142,11 +145,13 @@ public class MapFragment extends Fragment {
private Polyline hillPathLine;
private ElevationHeatmapLayer heatmapLayer;
private boolean heatmapActive;
private int heatmapRadiusM = 200;
private int heatmapRadiusM = 100;
private double lastHeatmapLat = Double.NaN;
private double lastHeatmapLon = Double.NaN;
private boolean suppressHeatmapSpinner;
private Runnable heatmapReloadRunnable;
private boolean suppressCenterToggle;
private boolean mapGestureActive;
private float touchDownX;
private float touchDownY;
private Runnable pendingFitRunnable;
@@ -185,28 +190,19 @@ public class MapFragment extends Fragment {
btnPairedTrack = view.findViewById(R.id.btnPairedTrack);
btnFindHill = view.findViewById(R.id.btnFindHill);
btnHeatmap = view.findViewById(R.id.btnHeatmap);
btnToolCenter = view.findViewById(R.id.btnToolCenter);
btnToolMore = view.findViewById(R.id.btnToolMore);
mapToolDrawer = view.findViewById(R.id.mapToolDrawer);
mapCenterMode = view.findViewById(R.id.mapCenterMode);
mapHeatmapRadius = view.findViewById(R.id.mapHeatmapRadius);
mapHeatmapStatus = view.findViewById(R.id.mapHeatmapStatus);
mapHeatmapLegend = view.findViewById(R.id.mapHeatmapLegend);
btnCenterMe = view.findViewById(R.id.btnCenterMe);
btnCenterTx = view.findViewById(R.id.btnCenterTx);
btnCenterRx = view.findViewById(R.id.btnCenterRx);
btnCenterBoth = view.findViewById(R.id.btnCenterBoth);
trackSpinner = view.findViewById(R.id.trackSpinner);
pollHelper = new FragmentPollHelper(this, this::refreshDevices);
if (btnCenterMe != null) {
btnCenterMe.setOnClickListener(v -> centerOnSelf());
}
if (btnCenterTx != null) {
btnCenterTx.setOnClickListener(v -> centerOnRole(StatsExtractor.ROLE_TX));
}
if (btnCenterRx != null) {
btnCenterRx.setOnClickListener(v -> centerOnRole(StatsExtractor.ROLE_RX));
}
if (btnCenterBoth != null) {
btnCenterBoth.setOnClickListener(v -> centerOnBoth());
}
setupToolRail();
setupCenterModeToggle();
if (btnFindHill != null) {
btnFindHill.setOnClickListener(v -> findNearestHill());
}
@@ -234,6 +230,67 @@ public class MapFragment extends Fragment {
setupTrackSpinnerListener();
}
private void setupToolRail() {
if (btnToolCenter != null) {
btnToolCenter.setOnClickListener(v -> {
setMapDrawerOpen(true);
if (mapCenterMode != null) {
mapCenterMode.requestFocus();
}
});
}
if (btnToolMore != null) {
btnToolMore.setOnClickListener(v -> setMapDrawerOpen(
mapToolDrawer == null || mapToolDrawer.getVisibility() != View.VISIBLE));
}
}
private void setMapDrawerOpen(boolean open) {
if (mapToolDrawer != null) {
mapToolDrawer.setVisibility(open ? View.VISIBLE : View.GONE);
}
if (btnToolMore != null) {
btnToolMore.setActivated(open);
}
}
private void setupCenterModeToggle() {
if (mapCenterMode == null) {
return;
}
mapCenterMode.addOnButtonCheckedListener((group, checkedId, isChecked) -> {
if (suppressCenterToggle || !isChecked) {
return;
}
boolean ok;
if (checkedId == R.id.centerMe) {
ok = centerOnSelf();
} else if (checkedId == R.id.centerTx) {
ok = centerOnRole(StatsExtractor.ROLE_TX);
} else if (checkedId == R.id.centerRx) {
ok = centerOnRole(StatsExtractor.ROLE_RX);
} else if (checkedId == R.id.centerBoth) {
ok = centerOnBoth();
} else {
ok = true;
}
if (!ok) {
Toast.makeText(requireContext(), R.string.map_center_unavailable, Toast.LENGTH_SHORT)
.show();
clearCenterModeSelection();
}
});
}
private void clearCenterModeSelection() {
if (mapCenterMode == null) {
return;
}
suppressCenterToggle = true;
mapCenterMode.clearChecked();
suppressCenterToggle = false;
}
private void setupTrackSpinnerListener() {
trackSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
@@ -252,12 +309,13 @@ public class MapFragment extends Fragment {
private void setupMapView() {
mapView.setClickable(true);
mapView.getMapScaleBar().setVisible(true);
mapView.getMapScaleBar().setVisible(false);
mapView.setBuiltInZoomControls(false);
mapView.setOnTouchListener((v, event) -> {
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
mapGestureActive = true;
touchDownX = event.getX();
touchDownY = event.getY();
v.getParent().requestDisallowInterceptTouchEvent(true);
@@ -270,9 +328,11 @@ public class MapFragment extends Fragment {
}
} else if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
v.getParent().requestDisallowInterceptTouchEvent(false);
mapGestureActive = false;
if (userMovedMap) {
saveCameraState();
}
requestMapInvalidate();
}
return false;
});
@@ -282,7 +342,7 @@ public class MapFragment extends Fragment {
requireContext(),
"loratester-tiles",
TILE_SIZE_PX,
1f,
2.5f,
mapView.getModel().frameBufferModel.getOverdrawFactor()
);
@@ -384,6 +444,10 @@ public class MapFragment extends Fragment {
iconLora = null;
btnFindHill = null;
btnHeatmap = null;
btnToolCenter = null;
btnToolMore = null;
mapToolDrawer = null;
mapCenterMode = null;
mapHeatmapRadius = null;
mapHeatmapStatus = null;
mapHeatmapLegend = null;
@@ -393,11 +457,8 @@ public class MapFragment extends Fragment {
mapStatus = null;
mapDistance = null;
trackStatus = null;
btnCenterMe = null;
btnCenterTx = null;
btnCenterRx = null;
btnCenterBoth = null;
btnTrack = null;
btnPairedTrack = null;
trackSpinner = null;
pollHelper = null;
super.onDestroyView();
@@ -434,7 +495,9 @@ public class MapFragment extends Fragment {
if (!isAdded() || btnTrack == null) {
return;
}
btnTrack.setText(recording ? R.string.track_stop : R.string.track_start);
btnTrack.setActivated(recording);
btnTrack.setContentDescription(getString(
recording ? R.string.track_stop : R.string.track_start));
if (trackStatus != null) {
trackStatus.setText(getString(R.string.track_status, pointCount));
}
@@ -494,9 +557,27 @@ public class MapFragment extends Fragment {
} else {
liveTrackMarker.setLatLong(pos);
}
requestMapInvalidate();
}
private void requestMapInvalidate() {
if (!isMapReady() || mapGestureActive) {
return;
}
mapView.invalidate();
}
private void requestMapInvalidateAfterLayout() {
if (!isMapReady()) {
return;
}
mapView.post(() -> {
if (!mapGestureActive) {
mapView.invalidate();
}
});
}
private void clearLiveTrackLayers() {
liveTrackPoints.clear();
if (mapView != null) {
@@ -889,7 +970,7 @@ public class MapFragment extends Fragment {
bounds.add(new LatLong(fromLat, fromLon));
bounds.add(hillPos);
fitBoundsOnce(bounds, false, true);
mapView.invalidate();
requestMapInvalidateAfterLayout();
}
private void clearHillLayers() {
@@ -987,13 +1068,16 @@ public class MapFragment extends Fragment {
/** Finer step for small radius, coarser for large (meters). */
private static int heatmapStepForRadius(int radiusM) {
if (radiusM <= 50) {
return 1;
}
if (radiusM <= 100) {
return 8;
return 1;
}
if (radiusM <= 200) {
return 12;
return 3;
}
return 18;
return 6;
}
private void loadHeatmap(double lat, double lon, int radiusM) {
@@ -1044,7 +1128,7 @@ public class MapFragment extends Fragment {
));
}
if (mapView != null) {
mapView.invalidate();
requestMapInvalidate();
}
});
} catch (Exception e) {
@@ -1208,39 +1292,43 @@ public class MapFragment extends Fragment {
}
}
private void centerOnSelf() {
private boolean centerOnSelf() {
if (uploader == null) {
return;
return false;
}
String myId = uploader.getDeviceId();
for (DeviceInfo d : lastDevices) {
if (myId.equals(d.device_id) && GeoUtils.isValidCoordinate(d.lat, d.lon)) {
centerOnPoint(new LatLong(d.lat, d.lon), (byte) 14);
return;
return true;
}
}
return false;
}
private void centerOnRole(String role) {
private boolean centerOnRole(String role) {
for (DeviceInfo d : lastDevices) {
if (role.equals(d.role) && GeoUtils.isValidCoordinate(d.lat, d.lon)) {
centerOnPoint(new LatLong(d.lat, d.lon), (byte) 14);
return;
return true;
}
}
return false;
}
private void centerOnBoth() {
private boolean centerOnBoth() {
List<LatLong> points = new ArrayList<>();
for (DeviceInfo d : lastDevices) {
if (GeoUtils.isValidCoordinate(d.lat, d.lon)) {
points.add(new LatLong(d.lat, d.lon));
}
}
if (!points.isEmpty()) {
userMovedMap = false;
fitBoundsOnce(points, points.size() == 1, true);
if (points.isEmpty()) {
return false;
}
userMovedMap = false;
fitBoundsOnce(points, points.size() == 1, true);
return true;
}
private void centerOnPoint(LatLong point, byte zoom) {
@@ -1251,7 +1339,7 @@ public class MapFragment extends Fragment {
position.setCenter(point);
position.setZoomLevel(zoom);
saveCameraState();
mapView.invalidate();
requestMapInvalidateAfterLayout();
}
private void saveCameraState() {
@@ -1307,6 +1395,7 @@ public class MapFragment extends Fragment {
position.setCenter(points.get(0));
position.setZoomLevel((byte) 14);
saveCameraState();
requestMapInvalidateAfterLayout();
return;
}
double minLat = Double.MAX_VALUE;
@@ -1333,5 +1422,6 @@ public class MapFragment extends Fragment {
byte zoom = (byte) Math.max(12, Math.min(16, Math.floor(Math.min(latZoom, lonZoom))));
position.setZoomLevel(zoom);
saveCameraState();
requestMapInvalidateAfterLayout();
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#00FF88" android:state_activated="true" />
<item android:color="#FFFFFF" />
</selector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#CC0F3460" />
<corners android:radius="12dp" />
</shape>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM12,2C6.47,2 2,6.47 2,12s4.47,10 10,10 10,-4.47 10,-10S17.53,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M3,17h2v-3H3v3zM3,12h2V9H3v3zM7,17h2v-5H7v5zM7,7h2V5H7v2zM11,17h2V7h-2v10zM15,17h2v-3h-2v3zM15,12h2V9h-2v3zM19,17h2v-7h-2v7zM19,8h2V5h-2v3z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,14c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,20c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3zM8,11c1.66,0 2.99,-1.34 2.99,-3S9.66,5 8,5C6.34,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5V19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45V19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
</vector>
+196 -137
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
@@ -8,175 +9,232 @@
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.grigowashere.loratester.ui.ElevationHeatmapLegendView
android:id="@+id/mapHeatmapLegend"
<LinearLayout
android:id="@+id/mapStatusChip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_gravity="top|start"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="10dp"
android:layout_marginBottom="10dp"
android:elevation="4dp"
android:visibility="gone" />
android:layout_marginEnd="56dp"
android:background="@drawable/bg_map_panel"
android:elevation="6dp"
android:orientation="vertical"
android:paddingStart="8dp"
android:paddingTop="6dp"
android:paddingEnd="8dp"
android:paddingBottom="6dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/iconServer"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@string/status_server"
android:src="@drawable/ic_link_server" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="3dp"
android:text="@string/status_server_short"
android:textColor="#AAAAAA"
android:textSize="9sp" />
<ImageView
android:id="@+id/iconLora"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="8dp"
android:contentDescription="@string/status_lora"
android:src="@drawable/ic_link_lora" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="3dp"
android:text="@string/status_lora_short"
android:textColor="#AAAAAA"
android:textSize="9sp" />
</LinearLayout>
<TextView
android:id="@+id/mapStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:maxLines="2"
android:textColor="#FFFFFF"
android:textSize="10sp" />
<TextView
android:id="@+id/mapDistance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#00FF88"
android:textSize="9sp"
android:visibility="gone" />
</LinearLayout>
<LinearLayout
android:id="@+id/mapToolRail"
android:layout_width="48dp"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="6dp"
android:background="@drawable/bg_map_panel"
android:elevation="6dp"
android:gravity="center_horizontal"
android:orientation="vertical"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<ImageButton
android:id="@+id/btnToolCenter"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_tool_center"
android:src="@drawable/ic_center"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnFindHill"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_find_hill"
android:src="@drawable/ic_hill"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnHeatmap"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_heatmap"
android:src="@drawable/ic_heatmap"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnTrack"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_tool_track"
android:src="@drawable/ic_track"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnPairedTrack"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_tool_paired"
android:src="@drawable/ic_paired_track"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnToolMore"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_tool_more"
android:src="@drawable/ic_more_vert"
app:tint="@color/map_tool_icon_tint" />
</LinearLayout>
<ScrollView
android:id="@+id/mapSidePanel"
android:layout_width="152dp"
android:id="@+id/mapToolDrawer"
android:layout_width="148dp"
android:layout_height="wrap_content"
android:layout_gravity="end|top"
android:layout_margin="6dp"
android:background="#CC0F3460"
android:elevation="4dp"
android:fillViewport="false"
android:scrollbars="none">
android:layout_marginTop="8dp"
android:layout_marginEnd="56dp"
android:background="@drawable/bg_map_panel"
android:elevation="6dp"
android:scrollbars="none"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="6dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/iconServer"
android:layout_width="20dp"
android:layout_height="20dp"
android:contentDescription="@string/status_server"
android:src="@drawable/ic_link_server" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/status_server_short"
android:textColor="#AAAAAA"
android:textSize="9sp" />
<ImageView
android:id="@+id/iconLora"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="10dp"
android:contentDescription="@string/status_lora"
android:src="@drawable/ic_link_lora" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/status_lora_short"
android:textColor="#AAAAAA"
android:textSize="9sp" />
</LinearLayout>
android:padding="8dp">
<TextView
android:id="@+id/mapStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#FFFFFF"
android:textSize="10sp" />
<TextView
android:id="@+id/mapDistance"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@string/map_center_mode"
android:textColor="#00FF88"
android:textSize="9sp"
android:visibility="gone" />
android:textSize="10sp"
android:textStyle="bold" />
<LinearLayout
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/mapCenterMode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:orientation="horizontal">
android:orientation="vertical"
app:selectionRequired="false"
app:singleSelection="true">
<Button
android:id="@+id/btnCenterMe"
<com.google.android.material.button.MaterialButton
android:id="@+id/centerMe"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:minHeight="32dp"
android:text="@string/map_center_me"
android:textSize="10sp" />
<Button
android:id="@+id/btnCenterTx"
<com.google.android.material.button.MaterialButton
android:id="@+id/centerTx"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
android:layout_weight="1"
android:minHeight="32dp"
android:text="@string/map_center_tx"
android:textSize="10sp" />
<Button
android:id="@+id/btnCenterRx"
<com.google.android.material.button.MaterialButton
android:id="@+id/centerRx"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
android:layout_weight="1"
android:minHeight="32dp"
android:text="@string/map_center_rx"
android:textSize="10sp" />
<Button
android:id="@+id/btnCenterBoth"
<com.google.android.material.button.MaterialButton
android:id="@+id/centerBoth"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
android:layout_weight="1"
android:minHeight="32dp"
android:text="@string/map_center_both"
android:textSize="10sp" />
</LinearLayout>
<Button
android:id="@+id/btnFindHill"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:drawableStart="@drawable/ic_hill"
android:drawablePadding="6dp"
android:minHeight="34dp"
android:text="@string/map_find_hill"
android:textSize="10sp" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:id="@+id/mapHillStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#FFC107"
android:textSize="9sp"
android:visibility="gone" />
android:layout_marginTop="8dp"
android:text="@string/map_heatmap_radius"
android:textColor="#CCCCCC"
android:textSize="9sp" />
<Spinner
android:id="@+id/mapHeatmapRadius"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp" />
<Button
android:id="@+id/btnHeatmap"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:minHeight="34dp"
android:text="@string/map_heatmap"
android:textSize="10sp" />
android:layout_marginTop="2dp" />
<TextView
android:id="@+id/mapHeatmapStatus"
@@ -187,33 +245,29 @@
android:textSize="9sp"
android:visibility="gone" />
<TextView
android:id="@+id/mapHillStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#FFC107"
android:textSize="9sp"
android:visibility="gone" />
<TextView
android:id="@+id/mapLegend"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginTop="6dp"
android:text="@string/map_legend"
android:textColor="#CCCCCC"
android:textSize="9sp" />
<Button
android:id="@+id/btnTrack"
<Spinner
android:id="@+id/trackSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:minHeight="36dp"
android:text="@string/track_start"
android:textSize="11sp" />
<Button
android:id="@+id/btnPairedTrack"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:minHeight="36dp"
android:text="@string/track_paired_start"
android:textSize="11sp" />
android:layout_marginTop="6dp" />
<TextView
android:id="@+id/trackStatus"
@@ -222,13 +276,18 @@
android:layout_marginTop="2dp"
android:textColor="#CCCCCC"
android:textSize="9sp" />
<Spinner
android:id="@+id/trackSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp" />
</LinearLayout>
</ScrollView>
<com.grigowashere.loratester.ui.ElevationHeatmapLegendView
android:id="@+id/mapHeatmapLegend"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="10dp"
android:elevation="4dp"
android:visibility="gone" />
</FrameLayout>
+6
View File
@@ -80,6 +80,12 @@
<string name="map_center_tx">TX</string>
<string name="map_center_rx">RX</string>
<string name="map_center_both">Оба</string>
<string name="map_center_mode">Центр карты</string>
<string name="map_center_unavailable">Нет координат для выбранного режима</string>
<string name="map_tool_center">Центрировать карту</string>
<string name="map_tool_track">Трекинг пути</string>
<string name="map_tool_paired">Синхр. трек TX/RX</string>
<string name="map_tool_more">Дополнительно</string>
<string name="map_gps_distance">GPS между устройствами: %1$s m</string>
<string name="status_server">Связь с сервером</string>
<string name="status_server_short">сервер</string>
@@ -0,0 +1,24 @@
package com.grigowashere.loratester;
import org.junit.Test;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class SettingsRepositoryTest {
@Test
public void detectsLegacyServerUrls() {
assertTrue(SettingsRepository.isLegacyServerUrl("http://grigowashere.ru:7634"));
assertTrue(SettingsRepository.isLegacyServerUrl("http://grigowashere.ru:7634/"));
assertTrue(SettingsRepository.isLegacyServerUrl("http://grigowashere.ru"));
assertTrue(SettingsRepository.isLegacyServerUrl("https://grigowashere.ru:7634"));
}
@Test
public void ignoresCurrentServerUrl() {
assertFalse(SettingsRepository.isLegacyServerUrl(SettingsRepository.DEFAULT_SERVER));
assertFalse(SettingsRepository.isLegacyServerUrl("https://example.com"));
assertFalse(SettingsRepository.isLegacyServerUrl(null));
}
}
+2 -2
View File
@@ -50,7 +50,7 @@ LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation
БД хранится в volume `loratester-data` (`/data/loratester.db` внутри контейнера).
## Деплой (grigowashere.ru:7634)
## Деплой (lora.grigowashere.ru)
```bash
cd /srv/storage/disk2/services/LoraTester/server
@@ -148,6 +148,6 @@ python -m pytest tests/ -v
## Android
URL: `http://grigowashere.ru:7634`. На карте: **Начать/Остановить трекинг пути** — точки с GPS, статистикой приёма и высотой (локальный Open-Meteo на сервере). Вкладка **Статистика** — история с сервера.
URL: `https://lora.grigowashere.ru`. На карте: **Начать/Остановить трекинг пути** — точки с GPS, статистикой приёма и высотой (локальный Open-Meteo на сервере). Вкладка **Статистика** — история с сервера.
Telnet: `127.0.0.1:2727` — мост COM→telnet на устройстве.
Binary file not shown.
+9 -4
View File
@@ -371,6 +371,7 @@ def _offset_m(lat: float, lon: float, north_m: float, east_m: float) -> tuple[fl
_MAX_GRID_POINTS = 2500
_MAX_GRID_POINTS_FINE = 12000
def _auto_step_m(radius_m: float) -> float:
@@ -401,11 +402,15 @@ def _sample_circular_grid(
return cells
def _resolve_grid_step(lat: float, lon: float, radius_m: float, step_m: float) -> float:
def _resolve_grid_step(
lat: float, lon: float, radius_m: float, step_m: float
) -> float:
if step_m <= 0:
step_m = _auto_step_m(radius_m)
step_m = max(5.0, min(float(step_m), 100.0))
while len(_sample_circular_grid(lat, lon, radius_m, step_m)) > _MAX_GRID_POINTS:
min_step = 1.0 if radius_m <= 100.0 else 5.0
step_m = max(min_step, min(float(step_m), 100.0))
max_points = _MAX_GRID_POINTS_FINE if radius_m <= 100.0 and step_m <= 1.0 else _MAX_GRID_POINTS
while len(_sample_circular_grid(lat, lon, radius_m, step_m)) > max_points:
step_m = math.ceil(step_m * 1.25)
if step_m >= radius_m:
break
@@ -427,7 +432,7 @@ def build_elevation_grid(
"elevation_url": ELEVATION_API_URL,
}
radius_m = max(100.0, min(float(radius_m), 500.0))
radius_m = max(50.0, min(float(radius_m), 500.0))
step_m = _resolve_grid_step(lat, lon, radius_m, step_m)
center_elev = fetch_elevation_m(lat, lon)
+2 -2
View File
@@ -349,8 +349,8 @@ def elevation_nearest_hill(
def elevation_grid(
lat: float = Query(..., ge=-90.0, le=90.0),
lon: float = Query(..., ge=-180.0, le=180.0),
radius_m: float = Query(200.0, ge=100.0, le=500.0),
step_m: float = Query(0.0, ge=0.0, le=100.0),
radius_m: float = Query(200.0, ge=50.0, le=500.0),
step_m: float = Query(0.0, ge=1.0, le=100.0),
):
from core.elevation import build_elevation_grid
+16
View File
@@ -141,6 +141,22 @@ def test_build_elevation_grid_delta(monkeypatch):
assert all("delta_m" in p for p in result["points"])
def test_build_elevation_grid_fine_step_small_radius(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 120.0)
monkeypatch.setattr(
elev,
"fetch_elevations_batch",
lambda lats, lons: [120.0 + i * 0.1 for i in range(len(lats))],
)
result = elev.build_elevation_grid(55.75, 37.62, radius_m=50, step_m=1)
assert result["ok"] is True
assert result["step_m"] == 1
assert len(result["points"]) > 1000
def test_build_elevation_grid_limits_points(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})